把"跑完一个问题就退出"的脚本,变成"一直等你输入"的对话程序 —— agent 第一次像"产品"
前三步都是一次性问答:npm run agent → 问一句 → 打印答案 → 退出。真实产品不这样:用户和 agent 之间是持续对话。这一步把它做成 REPL。
npm run agent 是"跑一次就结束"的脚本;本步的 npm run chat 是"永远等你"的程序——退出要你主动敲 /quit 或 Ctrl+D。src/main.ts:用 node:readline 建交互接口,监听 line / close 事件,处理斜杠命令(/quit /help);② 改造 src/agent.ts:把主循环从"顶层脚本"抽成 runAgent(task) 函数——因为 REPL 要反复调用它;③ llm.ts、tools.ts、config.ts 一行不改。main.ts 不是"从头跑到尾"的顺序代码,而是注册好监听器,然后闲下来等事件。程序结构因此"反过来了":while (true) { 读一行; 回答 } 这种写法在 Node 里做不到(readline 的读是异步的,没有阻塞式"读一行"函数)。你要接受的是:代码不是按执行顺序写的,是按"事件来了做什么"写的。另外两个标志变量 busy / closed 看起来多余,但管道灌入输入时它们就是"保命"的(见 5.1 的说明)。目标:npm run chat 启动后出现 你> 提示符,可以一直对话,/quit 才退出。
src/main.ts:readline 接口 + 事件监听这是本步唯一的新文件,也是"入口换人"的地方:之前入口是 agent.ts(启动即跑主循环),现在入口变成 main.ts(启动即建 REPL,一行输入调一次 runAgent)。
while (true) { const line = 读一行(); 回答(line) }。事件驱动是另一种写法:先声明"将来每来一行输入,我要做什么",然后程序就闲着,操作系统把输入送到时,回调自动执行。Node 的 HTTP、文件、定时器全部是这个风格——掌握它等于掌握了 Node 的主流编程方式。busy / closed 两个标志是干什么的?
管道灌入时(比如 Write-Output "你好","/quit" | node src/main.ts),两行输入瞬间就到,第一轮 runAgent 还没跑完,close 事件就来了。busy 防止"上一轮没结束又开新一轮";closed 记住"流已结束"。maybeExit() 在两处被调:close 时(可能还在忙,先记着)和每轮跑完后(若流已结束,现在补退)。不写这两个标志,管道灌输入时程序会提前退出或并发乱跑——你以后写 CLI 工具会反复遇到这个模式。process.exit(0):它会立刻终止进程,把还没写完的 console.log 掐断——要用 process.exitCode = 0 让进程"自然退出";② 忘了 line.trim():Windows 管道送来的行可能带 \r,text === "/quit" 匹配不上,命令失效;③ 斜杠命令里忘了 busy = false:下一行输入会被永久忽略。src/agent.ts:把主循环抽成 runAgent 函数task 4 的 agent.ts 是"顶层脚本":文件被加载时就执行主循环。本步把它包进 runAgent(task) 函数,并在文件底部用 import.meta.url 判断"是否被直接执行"——被直接执行时仍调一次,保证 npm run agent 用法不变。
npm run agent 依旧能用),它只是把"执行时机"从"加载时"改成了"被调用时"。import.meta.url 那一坨在干什么?
Node 里一个 .ts 文件可能被两种方式加载:被 node src/agent.ts 直接执行,或被别的文件 import。本文件希望"直接执行时跑一个任务、被 main.ts import 时什么都不跑"。判断方法:import.meta.url(本文件真实路径)是否等于 pathToFileURL(process.argv[1])(node 执行的第一个参数转成的 URL)。相等 = 我是入口。这是 Node 生态的通用惯用法,以后写 CLI 工具会反复看到。runAgent 用 export 导出:main.ts import { runAgent } 直接报错;② 抽函数后没删原来的顶层调用:会出现"import 时也跑一次 + 直接跑时再跑一次"的双重执行;③ 判断直接运行的代码写错(如直接比 process.argv[1] === __filename):路径带反斜杠/正斜杠差异会导致判断失败。npm run chat 启动 REPL。重点观察两点:① 输入一行、回答一行,程序不退出;② 斜杠命令是"程序自己处理"的——/help 不会发给模型(命令行里能看到它立即响应,没有请求延迟)。
npm run chat 能进入交互模式并持续对话;② /quit 正常退出、/help 不消耗模型请求;③ 管道灌入测试 Write-Output "/help","/quit" | node --env-file=.env src/main.ts 能完整跑完并打印"再见!"(验证 busy/closed 收尾逻辑)。三条都满足,task 5 通关。.env 是否就位(node --env-file=.env 找不到文件会直接报错);② 每轮回答都很慢、且第二轮忘掉第一轮:这是正常的——task 5 还没做跨轮记忆,每轮都是独立会话,这正是 task 6 要解决的问题;③ 想测试"模型记住我说过的话":请先完成 Step 6。前三步的速查表在 agent-tutorial0123.html,task 4 的在 task4.html。这里只列 task 5 新增/涉及的名字。判断口诀不变:上网络的键名 = 协议定;只在你代码里出现的 = 你定,但要对齐。
| 字段 / 值 | 能不能改 | 谁规定的 | 说明 |
|---|---|---|---|
提示符字符串 "你> " | 自己起 | 你自己 | readline 的 prompt 选项,显示在行首;rl.prompt() 打印它 |
事件名 line / close | 不能改 | Node readline 模块 | 事件驱动 API:每读到一行触发 line;输入流结束触发 close(Ctrl+D / 管道结束 / close()) |
busy / closed 状态标志 | 自己起 | 你自己 | 防并发 + 收尾判断;管道灌入输入时保证"正在跑的回答不被掐断" |
斜杠命令 /quit /help | 自己起 | 你自己 | 以 / 开头 = 给程序的指令,由 main.ts 处理,不发给模型 |
process.exitCode | 不能改 | Node | 设置进程退出码后"自然退出",输出写完才退;区别于 process.exit(0)(立即掐断) |
import.meta.url / pathToFileURL | 不能改 | ECMAScript / Node | 判断"本文件是否被直接执行";被 import 时跳过,直接运行时跑一次 |
runAgent(task) 函数与参数 | 自己起 | 你自己 | task 5 把主循环抽成可复用函数;签名在 task 6 会扩展成 (task, history) |
npm run chat 脚本 | 自己起 | package.json | 新入口:node --env-file=.env src/main.ts;npm run agent 保留不变 |
请求体 model / messages / tools 键名 | 不能改 | OpenAI 兼容协议 | 与 task 3~4 相同,见 agent-tutorial0123.html / task4.html |
runAgent 让它可复用;用 readline 的 line/close 事件而不是循环来"等输入";busy/closed 是管道灌输入时的保命符。程序从"跑一次"变成"一直等你"。