做 Agent 会用到的 Node API(5):CLI 与 REPL 胶水
本系列讲实现 Agent harness 时会反复碰到的 Node / JS 运行时能力。默认读者:会一点 JS,但还没自己做过命令行程序。
上一篇:(4)取消与 AbortController
示例仓库:react-agent-mini
相关前作:REPL 篇 · 主循环
场景:主循环写好了,人怎么用?
前四篇分别补了:
| 篇 | 解决什么 |
|---|---|
| 路径与文件 | 工具怎么安全读写磁盘 |
| 子进程 | 怎么跑终端命令 |
| 异步与流 | query 怎么一边跑一边往外吐 |
| AbortController | 怎么半路取消 |
还缺最后一截:把这一切接到用户眼前的终端上。
这就是 CLI / REPL 胶水层要做的事:
用户敲字 / 管道喂入
→ 解析成「一句话」或「一行命令」
→ QueryEngine.runTurn / query
→ 流式打印到屏幕
→ Ctrl+C 时取消当前轮(上一篇)
本篇从零讲:process 标准 IO、argv、三种启动模式、readline、以及怎样消费异步生成器打到终端。
1. 先认门牌:stdin / stdout / stderr / argv
Node 进程一启动,操作系统就给它接好几根「管子」和一份「启动参数」:
| 名字 | 方向 | 日常用途 |
|---|---|---|
process.stdin | 进进程 | 标准输入:键盘、或管道喂进来的文本 |
process.stdout | 出进程 | 标准输出:给用户看的正文(模型回复) |
process.stderr | 出进程 | 标准错误:状态、警告、错误(工具进度) |
process.argv | 启动时 | 命令行参数数组 |
argv 长什么样(示意):
npx bun run dev -- "你好"
│
process.argv ≈
[ bun路径, 脚本路径, "--", "你好", … ]
CLI 代码通常先切掉运行时自己的前缀:
const argv = process.argv.slice(2); // 剩下用户/脚本关心的参数
为什么模型正文走 stdout、工具状态走 stderr?
因为管道场景下,别人可能只想接住「回答」:
echo "总结 README" | bun run dev -p > answer.txt
若工具日志也打进 stdout,answer.txt 就会混进 [工具] Read: …。分流后,重定向更干净。
2. 三种启动模式:一句话 vs 管道 vs 聊天
示例仓库用参数决定走哪条路:
export function resolveLaunchMode(argv: string[]): LaunchMode {
if (argv.includes('-p')) {
return 'pipe'
}
const prompt = parseUserPrompt(argv)
if (prompt) {
return 'headless'
}
return 'repl'
}
| 模式 | 怎么触发(直觉) | 行为 |
|---|---|---|
| headless | 命令行带上问题文本 | 跑一轮就退出 |
| pipe | 带 -p,问题从 stdin 读 | 同样单轮,适合脚本串联 |
| repl | 什么问题都不给 | 进入交互循环,多轮对话 |
对应入口大致是:
if (mode === 'pipe') {
const prompt = await readStdin()
// ...
await runHeadless(prompt, tools, systemPrompt, skills, mcp.clients)
return
}
if (mode === 'headless') {
const prompt = parseUserPrompt(argv)
// ...
await runHeadless(prompt, tools, systemPrompt, skills, mcp.clients)
return
}
headless / pipe 不需要「反复问用户下一句」,所以不必开 readline;直接 query(...) + 消费流即可。
REPL 才需要「读一行 → 跑一轮 → 再读一行」。
3. 从 stdin 读整段:pipe 模式
pipe 要把标准输入读完变成一个字符串。示例用 Bun 的 stdin 流(心智与 Node 异步可读流类似):
async function readStdin(): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of Bun.stdin.stream()) {
chunks.push(Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf-8').trim()
}
又见到上一篇的老朋友:for await 一段段收,最后拼成完整问题。空输入则打印用法并退出——管道接错时要失败得清楚。
4. 把 query 流打到终端:consumeQueryStream
无论 headless 还是 REPL,打印约定应尽量统一,否则两套逻辑会分叉。示例抽了公共函数:
export async function consumeQueryStream(
gen: AsyncGenerator<QueryYield, Terminal>,
writers: StreamWriters = defaultWriters,
): Promise<Terminal> {
let printedTextThisTurn = false
let terminal: Terminal = { reason: 'completed' }
while (true) {
const { value, done } = await gen.next()
if (done) {
terminal = value
break
}
const item = value
if (item.type === 'text_delta') {
writers.stdout.write(item.text)
printedTextThisTurn = true
continue
}
对照记忆:
| 事件 | 打到哪 | 为什么 |
|---|---|---|
text_delta | stdout | 打字机效果,立刻显示 |
| 工具开始 / 失败状态 | stderr | 不污染「回答」管道 |
生成器 done | — | 取出 Terminal(完成 / 取消 / 达上限) |
默认 writer 就是:
process.stdout.write(chunk);
process.stderr.write(chunk);
测试时可注入假的 write,不必真连终端——这和主循环可注入 callModel 是同一设计味道。
headless 一行接上:
await consumeQueryStream(
query({ messages, tools, toolUseContext, systemPrompt }),
);
REPL 则是:
await consume(deps.engine.runTurn(userText, …));
runTurn 内部仍走到 query;胶水层只负责 消费 + 打印 + 会话状态。
5. REPL:readline 是什么?
交互程序需要「显示提示符,等用户敲一行回车」。Node 标准库提供:
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
const rl = readline.createInterface({ input, output });
const line = await rl.question("> ");
要点:
createInterface({ input, output }):把键盘输入和屏幕输出绑成一个问答接口。question(prompt):打印提示符,返回 Promise,等用户回车后得到字符串。- 用完要
rl.close():释放对 stdin 的占用,否则进程可能挂着不退。 - 示例用的是
node:readline/promises(Promise 版),方便await,不必再包一层回调。
5.1 把「一行行输入」变成异步生成器
会话循环希望写成:
for await (const line of lines) {
// 处理这一行
}
于是用生成器包住 question:
export async function* linesFromReadlineQuestions(
rl: ReadlineInterface,
prompt = '> ',
): AsyncGenerator<string> {
while (true) {
try {
yield await rl.question(prompt)
} catch {
break
}
}
}
question 在 interface 关闭时可能失败,用 catch 结束生成器,让 for await 自然退出。
5.2 会话核心故意不直接依赖 readline
runReplSession 只接收 lines: AsyncIterable<string>。好处:
- 单测可以塞假的异步行流,不必模拟真实终端
- CLI 负责创建真正的
readline,再注入进去
分层可以记成:
cli.ts 解析模式、建 engine、建 readline
└─ runRepl 挂 Ctrl+C、把 rl 变成 lines
└─ runReplSession for await 行 → slash / runTurn → consume
6. 为什么「权限确认」要共用同一个 readline?
写文件时 REPL 会问 y/N。若再 createInterface 开第二个:
- 两个接口抢 stdin,提示符错乱
- 行为难测、难复现
示例做法:CLI 只建一个 rl,既给会话提示符,也给权限 ask:
// REPL — 单一 readline,权限确认与提示符共用
const readline = await import('node:readline/promises')
const { stdin: input, stdout: output } = await import('node:process')
const rl = readline.createInterface({ input, output })
const ask = async (prompt: string): Promise<string> => rl.question(prompt)
// ...
canUseTool: createReplCanUseTool(ask),
上一篇讲的「拒绝则 abort」就发生在这个 ask 返回非 y 之后。胶水层提供 同一个问题入口,取消逻辑仍落在 AbortController。
7. Ctrl+C:挂在 REPL 外壳上
上一篇实现了 abortCurrentTurn;本篇外壳负责 监听 SIGINT 并调用它:
const bindInterrupt = (
rl: { close: () => void },
): ReturnType<typeof installTurnInterrupt> =>
installTurnInterrupt({
abortCurrentTurn: () => {
const ok = engine.abortCurrentTurn('interrupt')
if (ok) {
console.log('\n已中断当前回合')
}
return ok
},
onIdleInterrupt: () => {
rl.close()
},
})
| 时机 | 行为 |
|---|---|
正在 runTurn | 第一次 Ctrl+C abort 本轮,打印「已中断」;若仍未收尾,第二次可强制退出 |
空闲(在 question 等输入) | 第一次 Ctrl+C 不动作;短时间内第二次 Ctrl+C 退出 |
finally 里 interrupt.dispose(),卸掉监听,避免泄漏或重复绑定。
8. 一行输入进会话后发生什么(缩略)
runReplSession 里对每一行大致分支:
空行 → 跳过
/exit → 结束循环
/help /clear /compact /memory → 本地处理
/某 skill、MCP slash → 注入或 runTurn
未知 /xxx → 只提示,不把原文当用户问题送给模型
普通句子 → runTurn → consumeQueryStream → 打印上下文占用
产品细节(Skill、MCP)在各自专题文;本篇只需抓住 Node 胶水:读行、分流、消费流、关 interface。
9. 一张总图(本系列收束)
argv / stdin / readline
│
▼
cli 路由(headless | pipe | repl)
│
▼
QueryEngine.runTurn / query
│ async function*(第 3 篇)
│ AbortSignal(第 4 篇)
▼
consumeQueryStream → stdout / stderr
│
├─ 工具 Read/Write → path + fs(第 1 篇)
└─ 工具 Bash → child_process(第 2 篇)
Harness 的「能跑在终端里」,靠的就是这层不厚、但必须清晰的胶水。
常见坑
| 坑 | 建议 |
|---|---|
| 模型正文和日志都打 stdout | 管道场景会脏;状态走 stderr |
REPL 里多次 createInterface | 抢 stdin;权限与提示符共用一个 |
忘记 rl.close() | 进程挂起不退出 |
| headless 与 REPL 两套打印逻辑 | 抽 consumeQueryStream 共用 |
Ctrl+C 直接 process.exit | 分不清「取消本轮」与「退出程序」 |
| 会话循环写死绑定真实 readline | 难测;注入 AsyncIterable<string> |
和主循环的关系
主循环仍是 query;CLI/REPL 不实现 ReAct,只负责:
- 把用户输入变成消息 /
runTurn - 把生成器产出印到终端
- 把操作系统信号(SIGINT)接到 AbortController
学本篇,是在学 Agent 的门面:人从哪进、字从哪出、停从哪按。
本系列小结
「做 Agent 会用到的 Node API」五篇可以记成一张清单:
path/fs— 手脚落在磁盘上,且别逃出工作区child_process.spawn— 借系统 shell 跑命令,超时要能杀async function*/for await— 主循环与流式模型怎么转AbortController— 取消怎么往下传argv/ 标准 IO /readline— 怎么接到真实终端
有了它们,再读 harness 设计文(主循环、工具、权限、MCP……)时,脚下的运行时就不至于一片雾。
你可以带走什么?
- 先分清 stdout / stderr / stdin / argv,CLI 才不会和管道打架。
- headless / pipe / REPL 三种模式,由参数路由,复用同一套消费流逻辑。
readline/promises+question做交互;一个 interface 全家共用。- 行输入做成异步生成器,会话循环与真实终端解耦,方便测试。
- Ctrl+C 挂在外壳:有回合先 abort,空闲双击退出;必要时二次可强退。
仓库与延伸
- GitHub:react-agent-mini
- 相关前作:REPL 篇
- 源码:cli.ts · repl.ts · consumeQueryStream.ts · cliHelpers.ts
欢迎 Star、Issue 和 PR。
本文为「做 Agent 会用到的 Node API」系列第 5 篇(收官);示例基于 react-agent-mini。
更多推荐
所有评论(0)