13. 一份内核,多种运行壳
Agent 内核完成后,你会想加更多入口:交互式 CLI、一次性 print 模式、JSON 事件模式、SDK、RPC、TUI。不要为每个入口写一套 Agent。所有运行壳都应该订阅同一个内核事件流,只在输入输出协议上不同。
运行壳的差异
交互式 CLI 关心编辑器、快捷键、历史记录、状态行。Print 模式关心“给我一个最终答案,并用退出码表示成功失败”。JSON 模式关心 stdout 纪律:每行都是机器可解析事件,不能混入人类日志。SDK 关心可组合 API。RPC 关心请求 id、连接生命周期和并发会话。
这些都是产品层差异,不应该进入 Agent loop。内核只发事件、接收用户输入、维护状态。
Stdout 纪律
机器可读模式最容易被破坏。只要你在 stdout 打一行调试日志,下游解析器就会失败。建议规则:
- JSON 模式下 stdout 只输出 JSONL 事件。
- 人类日志、调试信息和警告走 stderr。
- 每条事件都有
type、timestamp和可选id。 - 最终结果有明确
done或error事件。
这条纪律会影响整个代码结构。不要让底层工具随意 console.log;工具输出应该通过事件返回,由运行壳决定如何显示。
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 暴露,比如 setModel、setActiveTools、compact。
RPC 的基本形状
RPC 可以用 JSONL over stdio、WebSocket 或 HTTP SSE。关键是请求和事件关联:
{"id":"1","method":"prompt","params":{"text":"修复 lint"}}
{"id":"1","event":{"type":"turn_start"}}
{"id":"1","event":{"type":"tool_execution_started","name":"bash"}}
{"id":"1","result":{"status":"completed"}}
RPC 层不应该重新解释模型消息。它只是把内核事件序列化给远端,并把远端命令转成内核方法。
TUI 是事件流的投影
终端 UI 看起来复杂,但本质仍是事件投影。assistant text delta 更新文本块,tool events 更新工具行,queue events 更新状态栏,session events 更新历史树。TUI 不应该决定 Agent 下一步要不要调用工具,它只显示内核事实并收集用户输入。
这也是为什么前面要坚持统一事件流。如果 TUI 需要私有状态才能工作,JSON 模式和 SDK 很快会落后。
练习
实现两个运行壳:
tiny-agent -p "task":print 模式,只输出最终回答。tiny-agent --json -p "task":JSONL 模式,输出事件。
验收标准:
- 两个模式使用同一个 Agent 内核。
- JSON 模式 stdout 没有非 JSON 内容。
- 工具输出通过事件传递,不由工具直接打印。
- print 模式遇到工具错误时有非零退出码或明确错误事件。
- SDK 订阅到的事件和 JSON 模式事件语义一致。