第 5 步 · 交互模式(REPL)

把"跑完一个问题就退出"的脚本,变成"一直等你输入"的对话程序 —— agent 第一次像"产品"

配套可运行项目:f:\myagent\task5 | 前置:已完成 Step 4(多 Provider 抽象)

本步的心智转变:从"顺序执行"到"事件驱动"

前三步都是一次性问答npm run agent → 问一句 → 打印答案 → 退出。真实产品不这样:用户和 agent 之间是持续对话。这一步把它做成 REPL。

什么是 REPL? REPL = Read-Eval-Print Loop(读-执行-打印循环)。流程是:一行输入 → 执行(交给 agent)→ 打印结果 → 再读下一行。Node 的终端本身就是 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.tstools.tsconfig.ts 一行不改。
本步最大的坑:把 REPL 想成 while 循环 main.ts 不是"从头跑到尾"的顺序代码,而是注册好监听器,然后闲下来等事件。程序结构因此"反过来了":while (true) { 读一行; 回答 } 这种写法在 Node 里做不到(readline 的读是异步的,没有阻塞式"读一行"函数)。你要接受的是:代码不是按执行顺序写的,是按"事件来了做什么"写的。另外两个标志变量 busy / closed 看起来多余,但管道灌入输入时它们就是"保命"的(见 5.1 的说明)。
5

交互模式 REPL(读一行 → 答一句 → 再读一行)

目标:npm run chat 启动后出现 你> 提示符,可以一直对话,/quit 才退出。

5.1 新建 src/main.ts:readline 接口 + 事件监听

这是本步唯一的新文件,也是"入口换人"的地方:之前入口是 agent.ts(启动即跑主循环),现在入口变成 main.ts(启动即建 REPL,一行输入调一次 runAgent)。

src/main.ts(本步新增,新入口)
为什么是"事件驱动"而不是"循环里读一行"? readline 的"读一行"是异步的:你调用它之后程序不会停在那里等,而是继续往下跑。所以没法写 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 管道送来的行可能带 \rtext === "/quit" 匹配不上,命令失效;③ 斜杠命令里忘了 busy = false:下一行输入会被永久忽略。

5.2 改造 src/agent.ts:把主循环抽成 runAgent 函数

task 4 的 agent.ts 是"顶层脚本":文件被加载时就执行主循环。本步把它包进 runAgent(task) 函数,并在文件底部用 import.meta.url 判断"是否被直接执行"——被直接执行时仍调一次,保证 npm run agent 用法不变。

src/agent.ts(升级)
升级前:src/agent.ts(task 4,顶层脚本)
为什么要抽成函数? 顶层脚本"启动即执行",只能跑一次;REPL 每收到一行输入就要跑一次。把逻辑装进函数,就等于把"跑一次"变成"可以反复跑"——这是"可复用"的最小动作。抽函数本身没有改变任何行为(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 工具会反复看到。
常见坑 ① 忘了把 runAgentexport 导出:main.ts import { runAgent } 直接报错;② 抽函数后没删原来的顶层调用:会出现"import 时也跑一次 + 直接跑时再跑一次"的双重执行;③ 判断直接运行的代码写错(如直接比 process.argv[1] === __filename):路径带反斜杠/正斜杠差异会导致判断失败。

5.3 运行验收:交互式对话

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。
?

字段来源速查表(task 5 新增:交互模式相关)

前三步的速查表在 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.tsnpm run agent 保留不变
请求体 model / messages / tools 键名不能改OpenAI 兼容协议与 task 3~4 相同,见 agent-tutorial0123.html / task4.html
一句话记住 task 5 "抽函数 + 事件驱动"。把主循环抽成 runAgent 让它可复用;用 readline 的 line/close 事件而不是循环来"等输入";busy/closed 是管道灌输入时的保命符。程序从"跑一次"变成"一直等你"。
← Home