Appearance
第29章 接力赛——End-to-End
本章导读: 源码路线终点。本章不再读新文件,而是把前十章走过的
models.ts→lazy.ts→agent-loop.ts→harness→main.ts串起来。读完你会发现 pi-mono 里并没有一个"总控函数"——一次完整请求是跨包的控制流拼接,每一站只交接不包办。
源码篇走完了。小a合上笔记本,长长舒了一口气:“所以 pi-mono 里一定有个‘总控函数’吧?我从命令行敲一句话进去,它就从第一行开始,一路跑完,把结果吐出来。”
“没有这样的函数。”老z笑了,“这不是缺点——它是一张地图。真正的走查不找总控,而是把不同包的真实调用点连起来。以下是基于 pi-mono@583f153d 拼出的路径;不是某个单一函数的逐行执行记录。”
一条正常路径
text
CLI main/模式选择
→ 配置、资源与模型运行时
→ AgentSession / AgentHarness
→ Agent 与 runAgentLoop
→ Models.streamSimple → Provider.streamSimple
→ 模型事件 → assistant message / tool call
→ 工具结果 → 后续模型消息
→ session entry 与 TUI 更新“packages/coding-agent/src/main.ts 与 cli.ts 是命令行启动路径;core/resource-loader.ts、core/model-runtime.ts、core/agent-session.ts 负责资源、模型和会话装配。packages/agent/src/harness/agent-harness.ts、agent.ts、agent-loop.ts 承接状态和循环。packages/ai/src/models.ts 的 streamSimple 解析认证并委派 Provider 流。交互模式在 modes/interactive/interactive-mode.ts,TUI 基础设施在 packages/tui/src。”
比喻:接力赛,不是单人长跑
每个包只负责自己那一段接力棒(上下文、模型、事件、条目);没有一个人从头跑到尾。
“这条链路的每段都有独立入口,因此它是跨模块控制流拼接,不要误读为上述文件按名字顺序直接互调。”
小a追问:“既然没有总控,那每段怎么知道上一段把什么交给它?”
“靠类型契约,不靠隐式共享状态。”老z逐段点交接点,“CLI 层把 args 解析成配置对象,传给 resource-loader.ts;resource-loader 汇集成 Resources,传给 agent-session.ts;session 装配出 Context(含 system prompt、tools、messages)、Model 和 AbortSignal,传给 AgentHarness/Agent;loop 拿到这些后调 streamFunction,也就是 Models.streamSimple;Models 解析认证后委派给 Provider.streamSimple。每段只消费上游显式传入的参数,不读全局单例——替换 Provider 或换 TUI 不必重写 loop,代价是排查故障要在交接点逐段关联。”
“把一次本地交互的完整形态画出来,是这样的。”老z补了一张图:
text
main() ──args──> createAgentSessionFromServices ──> AgentSession(持有 Agent)
│ │
└──interactive 模式─────┘
InteractiveMode.init ──> AgentSession.subscribe(handleEvent)
用户输入 ──> Agent.runPromptMessages ──> runAgentLoop(prompts, context, config, emit, signal, streamFn)
├─ streamAssistantResponse ──> Models.streamSimple
│ │ lazyStream 同步返回,auth 后台解析
│ v
│ Provider.streamSimple ──> 真实 HTTP/SDK 流
│ v
│ assistant message(stop / toolUse / length / error / aborted)
v
tool call ──> executeToolCalls / failToolCallsFromTruncatedMessage
v
tool result 作为消息 push 回 context
v
下一次 stream → turn_end → agent_end
v
AgentSession 持久化 + InteractiveMode 刷新 TUI“注意这条图里没有一条线把 ‘CLI → 模型 → 工具 → 会话 → TUI’ 串成函数调用栈。”老z说,“每条边都是独立入口:main() 装配,runAgentLoop 循环,Models.streamSimple 认证委派,AgentSession 持久化,InteractiveMode 订阅渲染。读法是‘谁在什么时机把什么交给谁’。”
“注意交接的数据形态。”老z补充,“传给 Agent 的不是裸字符串 prompt,而是 Context 对象(messages 数组 + tools 定义 + system prompt);模型层拿到的也不是裸 HTTP 请求,而是 Model 引用加 SimpleStreamOptions。每段的失败语义也不同:CLI 失败是配置错误,session 初始化失败不进 loop,loop 失败是 stopReason 异常,Models 失败是认证或 provider 未知——四种错误不会混成一种。”
“把失败按层级摊开,关键分界是‘会不会进 loop’。”老z列了一张错误分类表:
| 失败层 | 典型错误 | 从哪里暴露 | 会不会进入 loop |
|---|---|---|---|
| CLI 配置 | 参数解析、资源读取、cwd 不存在 | main() 退出前 | 否 |
| session 装配 | 模型未选、服务初始化失败 | createAgentSessionFromServices | 否 |
| 认证/provider | applyAuth 抛 ModelsError | lazy stream 的 error 事件 | 是——以 error stopReason 出现 |
| 模型流 | HTTP 中断、限流、采样异常 | provider stream 事件 | 是——stopReason error/aborted |
| 工具执行 | 工具抛错 | executeToolCalls 捕获 | 是——isError tool result 回传 |
| 会话持久化 | JSONL 写失败 | AgentSession 内部 | 否——UI 不感知 |
工具结果怎样返回循环
小a:“那工具调用的结果,怎么回到循环里?”
“助手流中的 tool call 被 Agent 循环交给已装配的工具,工具结果再作为消息进入后续模型上下文。”老z说,“runAgentLoop 管理停止原因、中止和下一次流;会话/Harness 负责把状态与条目交给存储。工具输出可能失败、被截断或被拒绝,正常路径必须显式传回结果,而不是假定每次调用成功。”
小a追问:“那 tool call 到底在 loop 的哪一步变成 tool result?”
“看 runAgentLoop 的内层循环。”老z回到 agent-loop.ts,“stream 完一条 assistant message 后,先检查 stopReason:error 或 aborted 直接 emit agent_end 退出,不执行工具。正常情况下从 message 里 filter(c => c.type === "toolCall") 拿出 tool calls,然后有个关键分支——stopReason === "length" 时走 failToolCallsFromTruncatedMessage,把所有 tool call 标记为失败而不是执行。这是因为 length 截断意味着 tool call 的 arguments 可能不完整,执行它会有语义错误。只有非 length 的 stopReason 才走 executeToolCalls 真正执行。”
“执行完的 tool results 被 push 进 currentContext.messages 和 newMessages。”老z继续,“然后 hasMoreToolCalls = !executedToolBatch.terminate——如果工具批次要求终止(比如某个工具返回了致命错误),内层循环就停。否则继续下一轮 stream,此时 context 里已经带了 tool result messages,模型看到它们再决定下一步。tool result 是普通消息,不是特殊的回调——模型把工具输出当成对话历史的一部分。”
小a:“那一个 tool call 从消息里被拿出来到变成 result,中间到底走了几步?”
“四步,每步都可能短路成 error result。”老z回到 executeToolCalls 的管线,“第一步 prepareToolCall:按 name 找工具,找不到直接产 Tool not found;找到了就 prepareArguments 预处理、validateToolArguments 验证参数,再跑 beforeToolCall 钩子——钩子 block 就产出被阻止的 error result。第二步 tool.execute,抛错被捕获成 error result。第三步 finalizeExecutedToolCall 跑 afterToolCall 钩子,钩子抛错也把结果降级为 error。第四步 createToolResultMessage 把 outcome 变成 toolResult 消息。还有一处:顺序执行时每跑完一个工具就检查 signal.aborted——abort 短路后续工具,已执行的照常保留。”
小a:“那 terminate 的语义是什么?”
“看 shouldTerminateToolBatch。”老z指回 agent-loop.ts,“finalizedCalls.length > 0 && finalizedCalls.every(f => f.result.terminate === true)——批里每个工具的结果都标记了 terminate,整批才终止,是 every 不是 some。terminate 字段由工具返回、也可被 afterToolCall 改写,loop 不猜‘哪个错误该终止’——这是产品层通过工具显式表达的,和 length 分支的‘loop 代判参数不完整’正好相反。”
“基于源码的推断:模型访问、循环和产品工具分处不同包,使替换 Provider 或宿主 UI 时不必重写每个层面;代价是排查一次故障需要跨包关联事件和状态。”
中断与恢复不是同一件事
小a:“那我 Ctrl-C 之后,重新打开进程,一切就自动恢复了对吧?”
“不是同一件事。”老z说,“AbortSignal 穿过模型流和 Agent 路径,模型或工具错误也会产生停止/错误状态。会话 JSONL 与 compaction 提供持久化和上下文管理,但**‘进程存活后自动恢复到正确外部世界’不是这些模块能单独保证的**:已执行的文件写入、网络请求或远程命令仍需依赖幂等、审计与产品层恢复策略。”
“远程模式再加入 protocol frame、handshake、snapshot 和 progress;snapshot 是完整状态校正,progress 是增量展示。它们不改变本地工具副作用的事务语义。”
以本地交互为例逐段交接
小a:“那从命令行输入到屏幕输出,具体在哪一段交接?”
“CLI 解析参数和配置后,resource-loader.ts 汇集项目资源,model-runtime.ts 暴露模型目录,agent-session.ts 将模型、系统提示、工具、会话服务和运行环境装配为 session。传给 Agent 的不是裸文本,而是 Context、Model、工具定义与 AbortSignal。”
| 交接点 | 上游数据 | 下游消费者 | 失败/中止语义 |
|---|---|---|---|
| CLI → 配置 | args、cwd、配置路径 | resource/model runtime | 参数或资源读取失败,不能建 session |
| session → Harness | prompt、工具、system prompt | AgentHarness/Agent | 初始化失败不进入 loop |
| loop → Models | Context、Model、AbortSignal | Models.streamSimple | 未认证、未知 Provider、主动中止 |
| Provider → loop | AssistantMessage event stream | runAgentLoop | error/aborted/length 停止原因 |
| tool → loop | tool result message | 下一次 Context | 工具错误仍是模型可见结果 |
| Agent → UI/store | 事件与会话条目 | interactive mode/session | UI 观察不等于持久化成功 |
“中间最典型的一段是 ModelsImpl.streamSimple。”老z打开 packages/ai/src/models.ts:
ts
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions);
});“这里的跨包数据是 Model、Context 与事件流。**认证错误发生在 applyAuth,不是由 TUI 猜测;**Provider 选择后才有具体 HTTP/SDK 行为。”
小a:“那 lazyStream 包这一层,有什么特别的?不包不行吗?”
“不包的话,streamSimple 得等认证解析完才能返回。”老z打开 packages/ai/src/api/lazy.ts,“lazyStream(model, setup) 同步返回一个空的 AssistantMessageEventStream,setup(requireProvider + applyAuth + provider.streamSimple)在后台跑,成功就 forwardStream 转发事件,失败就把错误包装成 stopReason: "error" 的 assistant message 推给外层并 end()。所以认证和 provider 选择是流式的一部分——loop 拿到流就开始消费,认证失败以流里的 error 事件到达,而不是在调用点抛异常。这解释了错误分类表里‘认证失败进入 loop’那一行。applyAuth 还有一处细节:transformHeaders 在认证解析之后运行,因此 header 变换能看到最终要用的 auth header。”
工具、停止与会话记录
“runAgentLoop 接收消息、工具、流函数和中止信号,收集 assistant message,再把 tool result 放回待处理消息。它不拥有 CLI 参数解析,也不拥有终端渲染,因此调试需同时查看 loop 停止原因与 coding-agent 的工具实现。”
“正常路径中,模型流因 tool use 结束时,loop 执行相应工具并把结果变成后续上下文;因 stop 结束时,turn 收束。两个重要边界是:AbortSignal 使流和会话停止,但不会撤销已发生的外部副作用;工具 error 不是 transport error,仍应保留给模型调整下一步。”
小a追问:“AbortSignal 在 loop 里怎么传播?它和工具 error 的停止效果一样吗?”
“不一样,路径不同。”老z回到 runAgentLoop,“signal 传给 streamAssistantResponse,模型流内部检查 signal.aborted——abort 会让流产出 stopReason: "aborted" 的 assistant message。loop 看到 error 或 aborted 就 emit turn_end + agent_end 退出,不执行任何 tool call。而工具 error 是另一条路:executeToolCalls 执行工具时,工具抛错会被捕获并封装成 isError: true 的 tool result message——它 push 回 context,loop 继续下一轮让模型看到错误并调整。所以 abort 终止整个 agent,工具 error 只是一次可恢复的失败。两者的停止语义不能混:把工具 error 当成 abort 会丢掉模型自我修正的机会,把 abort 当成工具 error 会试图继续一个已被取消的会话。”
“还有一个第三态。”老z补一句,“streamAssistantResponse 本身也检查 signal.aborted——同一个 signal 在 loop 里有三个检查点:模型流层把循环停掉,工具准备层防新执行,执行层只短路剩余工具、保留已完成的。”
“把三类停止并排看,差异很清楚。”老z画了一张表:
| 停止形态 | 由谁触发 | loop 行为 | 能否恢复 |
|---|---|---|---|
| error | 模型流/认证错误 | emit turn_end + agent_end | 否 |
| aborted | AbortSignal | emit turn_end + agent_end | 否 |
| length | token 上限截断 | 全部 tool call 标错后继续 | 是 |
| toolUse | 模型发 tool call | 执行工具,结果回传继续 | 是 |
| 工具 isError | 工具抛错/被 block | push 回 context,下一轮继续 | 是 |
“而 createJsonlSessionStore 保存会话条目,它与 interactive mode 的刷新是不同通道——‘屏幕已看到输出’不能证明条目已耐久化,反之亦然。”
远程路径在哪里分叉
小a:“那远程模式,是从哪一段开始分叉的?”
“本地交互在 session/TUI 边界直接消费 Agent 事件。协议远程路径则加入 pi-protocol、pi-server、pi-client:对象先经 schema、CBOR、frame,client 先 hello,server 再以 snapshot 和 progress 回传。”老z说,“已确认的 server 边界是:它把命令交给 backend 持有的 PiSessionRuntime;**该接口只规定 prompt、steer、abort、snapshot 等会话操作,并不规定具体的 Agent loop、模型或工具实现。**某个 concrete backend 是否以 coding-agent 运行它们,需另行核验。”
小a追问:“那远程模式下,本地 loop 和远程 runtime 是同一个东西吗?”
“接口上是同一个抽象,实现上可分叉。”老z打开 packages/server/src/types.ts,“PiSessionRuntime 接口只规定 snapshot()、prompt()、steer()、abort()、subscribe()——不规定内部跑的是 runAgentLoop 还是别的循环,backend 可以是 in-process 的 coding-agent,也可以是另一个进程代理。本地交互模式里 TUI 直接订阅 Agent 的 event sink,事件是内存里的对象引用;远程模式里这些事件要先序列化成 session_progress/session_snapshot envelope,过协议层到 client 再反序列化。同一个语义事件,传输形态完全不同——远程故障在协议边界查,本地故障在 event sink 查。”
“这也解释了为什么不能跨包共享状态。”老z补充,“本地 loop 持有 currentContext 的对象引用;远程模式下 server 的 loop 持有 context,client 只拿到序列化后的 snapshot 副本——client 改本地副本不影响 server 真实状态,只有重新发命令(prompt/steer)才能影响。”
小a:“那本地和远程这两条路,维护成本差在哪?”
“差在‘对象引用’和‘序列化副本’之间的所有边界。”老z列了一张设计取舍表:
| 决策点 | 本地交互 | 远程模式 | 复用/代价 |
|---|---|---|---|
| 事件传递 | emit 内存对象引用 | envelope 序列化+反序列化 | 同一 Agent,不同 transport |
| 状态权威 | loop 持有 currentContext | server 持有、client 持副本 | 副本需 snapshot 校正 |
| 认证 | Models 直接解析 | client 传 token、server 校验 | 认证位置不同 |
| 工具执行 | 本地进程内 | 取决于 backend runtime | 需按 backend 核验 |
| 失败边界 | event sink/stopReason | frame/握手/snapshot | 排查层不同 |
“每行都是同一个抽象的两种落地。”老z总结,“接口上 PiSessionRuntime 只规定 prompt/steer/abort/snapshot/subscribe,不规定实现;本地模式里 session 直接在 AgentSession 上跑,远程模式里 server 把命令转给 backend 的 runtime。复用的是抽象和协议,不是内存布局——排查远程故障盯协议边界,本地故障盯 event sink。” |
“坏 frame 在 decoder 层终止连接,版本错误在握手层返回;断线后未完成 request 可能处于未知提交状态。snapshot revision 校正显示状态,却不能回滚已执行写入。重新附着是新的协议操作,不是旧 socket 自动延续。”
“工程取舍是分层允许本地与远程复用 runtime,代价是故障要定位到配置、认证、流、loop、工具、session、UI 或协议边界。”老z说,“日志应带 session ID、request ID、tool call ID 与 Provider 标识。”
小结
走完一遍完整请求,你会发现 pi-mono 里没有一个"总控函数"能从头跑到尾。一次请求是跨包的控制流拼接:CLI 解析参数并装配资源,Harness 组织会话状态和生命周期,loop 推动消息在模型与工具之间往返,Models 委派 Provider 发出真实的流,工具结果作为消息回到下一次上下文,TUI 订阅事件来刷新界面。每段交接都有明确的上下游数据和失败语义——传给 Agent 的不是裸文本而是 Context、Model、工具定义和 AbortSignal;认证错误发生在 applyAuth 而不是 TUI 猜测;工具 error 不是 transport error,仍要作为结果显式回传给模型。中断与恢复也不是同一件事:AbortSignal 让流和会话停止,但不撤销已发生的外部副作用;JSONL 和 compaction 提供持久化,却不保证进程重启后外部世界自动回到正确状态。
这张地图的关键是理解每段"只交接不包办"。loop 不拥有 CLI 参数解析,也不拥有终端渲染;Models 不拥有工具实现;Agent 不拥有 Provider 的 HTTP 细节。取而代之的是明确的契约:Agent 把 Context、工具、signal 交给 loop,loop 把 streamFunction 指向 Models,Models 把 Model 与 Context 交给 Provider。工具调用在 loop 里有四条独立路径——length 截断全部标记失败、找不到工具立即报错、执行抛错封装成 isError、terminate 批次才停止——都是产品策略在源码里的显式表达,不是循环的猜测。
远程路径在协议层分叉:对象先经 schema、CBOR、frame,客户端先 hello,服务端再以 snapshot 和 progress 回传;坏帧终止连接,版本错误在握手层返回,断线后未完成请求的提交状态未知。本地与远程复用同一个 Agent 抽象,但事件形态完全不同——本地是内存对象引用,远程是序列化 envelope 加副本校正,所以本地故障查 event sink,远程故障查协议边界。源码篇到此结束,但它给出的不是结论,而是一张地图——下次遇到故障或想扩展,你知道该往哪个边界看,以及日志里该带哪些 ID 才能定位。
源码走查
- 从
packages/coding-agent/src/main.ts追到会话创建,记录每次传入的配置/资源对象。 - 在
agent-loop.ts标出 assistant tool call 变为 tool result 的路径,再回到下一次模型流。 - 在
models.ts跟到 Provider 的streamSimple,为一次中止标出AbortSignal传递点。 - 观察交互模式如何订阅会话事件;若使用远程模式,再比对 snapshot 与 progress 的显示时机。
- 对一次工具失败收集 loop 停止原因、tool result、session 条目和 UI 事件,验证四者没有混成同一种错误。