本系列讲实现 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_deltastdout打字机效果,立刻显示
工具开始 / 失败状态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("> ");

要点:

  1. createInterface({ input, output }):把键盘输入和屏幕输出绑成一个问答接口。
  2. question(prompt):打印提示符,返回 Promise,等用户回车后得到字符串。
  3. 用完要 rl.close():释放对 stdin 的占用,否则进程可能挂着不退。
  4. 示例用的是 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 退出

finallyinterrupt.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>

和主循环的关系

主循环仍是 queryCLI/REPL 不实现 ReAct,只负责:

  1. 把用户输入变成消息 / runTurn
  2. 把生成器产出印到终端
  3. 把操作系统信号(SIGINT)接到 AbortController

学本篇,是在学 Agent 的门面:人从哪进、字从哪出、停从哪按。


本系列小结

「做 Agent 会用到的 Node API」五篇可以记成一张清单:

  1. path / fs — 手脚落在磁盘上,且别逃出工作区
  2. child_process.spawn — 借系统 shell 跑命令,超时要能杀
  3. async function* / for await — 主循环与流式模型怎么转
  4. AbortController — 取消怎么往下传
  5. argv / 标准 IO / readline — 怎么接到真实终端

有了它们,再读 harness 设计文(主循环、工具、权限、MCP……)时,脚下的运行时就不至于一片雾。


你可以带走什么?

  1. 先分清 stdout / stderr / stdin / argv,CLI 才不会和管道打架。
  2. headless / pipe / REPL 三种模式,由参数路由,复用同一套消费流逻辑。
  3. readline/promises + question 做交互;一个 interface 全家共用
  4. 行输入做成异步生成器,会话循环与真实终端解耦,方便测试。
  5. Ctrl+C 挂在外壳:有回合先 abort,空闲双击退出;必要时二次可强退。

仓库与延伸

欢迎 Star、Issue 和 PR。


本文为「做 Agent 会用到的 Node API」系列第 5 篇(收官);示例基于 react-agent-mini。

更多推荐