从零构建 Coding Agent
English
本章目录

13. 一份内核,多种运行壳

Agent 内核完成后,你会想加更多入口:交互式 CLI、一次性 print 模式、JSON 事件模式、SDK、RPC、TUI。不要为每个入口写一套 Agent。所有运行壳都应该订阅同一个内核事件流,只在输入输出协议上不同。

运行壳的差异

交互式 TUI 关心编辑器、快捷键、历史记录、状态行。Print 模式关心“给我一个最终答案,并用退出码表示成功失败”。JSON 模式关心 stdout 纪律:每行都是机器可解析事件,不能混入人类日志。SDK 关心可组合 API。RPC 关心请求 id、连接生命周期和并发会话。

真实系统通常用一个标志或运行环境来选择模式:不是终端(stdin/stdout 被重定向)就默认走 print,显式 --json 走事件流,--rpc 走双向协议,否则进交互式 TUI。同一个会话恢复标志(--session--continue--resume)在所有模式下含义一致。这些都是产品层差异,不应该进入 Agent loop。内核只发事件、接收用户输入、维护状态。

Stdout 纪律

机器可读模式最容易被破坏。只要你在 stdout 打一行调试日志,下游解析器就会失败。建议规则:

  • JSON 模式下 stdout 只输出 JSONL 事件。
  • 人类日志、调试信息和警告走 stderr。
  • 每条事件都有 typetimestamp 和可选 id
  • 最终结果有明确的终止事件。
  • 退出码有约定:成功 0,错误 1,被信号终止用对应的信号码。CI 脚本靠退出码判断成败。

这条纪律会影响整个代码结构。这也是第十章强调“工具输出通过事件返回,而不是自己 console.log”的原因:任何一个工具在 stdout 上直接打印,就会污染 JSON 流。工具输出必须走事件,由运行壳决定如何显示。

SDK 不是内部对象泄露

SDK 不应该把内部 state 原样暴露给用户。它应该提供稳定方法:

type AgentSession = {
  prompt(text: string): Promise<void>;
  steer(text: string): Promise<void>;
  followUp(text: string): Promise<void>;
  abort(): void;
  subscribe(listener: (event: AgentEvent) => void): () => void;
};

如果 SDK 用户能直接修改 messages 数组,你就无法维护日志、压缩和事件一致性。需要高级能力时,也应通过明确 API 暴露,比如 setModelsetActiveToolscompact

RPC 与全命令面

RPC 是所有模式里控制面最完整的一个,适合编辑器插件和远程驱动。它通常用 JSONL over stdio:一侧发命令,另一侧回事件和响应。关键是请求和事件关联:

{"id":"1","type":"prompt","message":"修复 lint"}
{"id":"1","type":"response","command":"prompt","success":true}
{"type":"turn_start"}
{"type":"tool_execution_start","name":"bash"}
{"type":"agent_end"}

一个成熟的 RPC 面暴露的命令远不止 prompt:steer、follow_up、abort,切换模型、切换推理强度、切换队列模式,触发压缩,直接执行 bash,导出 HTTP、fork、clone、切换会话,查询状态、消息、会话树。这些命令一一映射到内核方法,RPC 层只做序列化和请求关联,不重新解释模型消息。

一个值得注意的取舍:RPC 面无法提供交互式 TUI 才有的富组件(自定义页脚、内联编辑器)。但它可以支持请求-响应式的对话框——扩展想弹一个“选择模型”的选择框时,RPC 发出一个 UI 请求事件,客户端渲染后把结果回传。这样扩展的交互需求在无终端环境下也能满足,只是呈现形式由客户端决定。

TUI 是事件流的投影

终端 UI 看起来复杂,但本质仍是事件投影。assistant text delta 更新文本块,tool events 更新工具行,queue events 更新状态栏,session events 更新历史树。TUI 不应该决定 Agent 下一步要不要调用工具,它只显示内核事实并收集用户输入。

TUI 真正的工程难点在渲染而非逻辑,这些细节值得知道,即使教学项目不全实现:

  • 差分渲染:缓存上一帧的每一行,只重画变化的行。流式文本每秒更新几十次,整屏重画会闪成一片。
  • 同步输出:用终端的同步刷新转义序列把一帧内的多处改动打包提交,避免撕裂。
  • 字符宽度:CJK、emoji、组合字符的显示宽度不等于字符串长度,换行和光标定位要按字素簇计算。
  • 每工具定制渲染:edit 显示带高亮的 diff,bash 显示可展开的实时输出并标注退出码,搜索结果显示成表格。这些渲染器由工具定义提供,UI 负责调度。
  • 流式参数:tool call 参数边到边显示时,diff 计算要推迟到参数完整(message_end)再做,否则会对着半个参数算出错误的 diff。

把这些复杂度关在 TUI 层内部,正是统一事件流的价值:JSON 模式和 SDK 不需要理解字素宽度,也能拿到同样的领域事实。如果 TUI 需要私有状态才能工作,其他模式很快会落后。

练习

实现两个运行壳:

  • tiny-agent -p "task":print 模式,只输出最终回答。
  • tiny-agent --json -p "task":JSONL 模式,输出事件。

验收标准:

  • 两个模式使用同一个 Agent 内核。
  • JSON 模式 stdout 没有非 JSON 内容,人类日志走 stderr。
  • 工具输出通过事件传递,不由工具直接打印。
  • print 模式遇到工具错误时有非零退出码或明确错误事件。
  • 两个模式都支持 --session 恢复同一个会话。
  • SDK 订阅到的事件和 JSON 模式事件语义一致。