Skip to content

第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否
认证/providerapplyAuth 抛 ModelsErrorlazy 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 → Harnessprompt、工具、system promptAgentHarness/Agent初始化失败不进入 loop
loop → ModelsContext、Model、AbortSignalModels.streamSimple未认证、未知 Provider、主动中止
Provider → loopAssistantMessage event streamrunAgentLooperror/aborted/length 停止原因
tool → looptool result message下一次 Context工具错误仍是模型可见结果
Agent → UI/store事件与会话条目interactive mode/sessionUI 观察不等于持久化成功

“中间最典型的一段是 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否
abortedAbortSignalemit turn_end + agent_end否
lengthtoken 上限截断全部 tool call 标错后继续是
toolUse模型发 tool call执行工具,结果回传继续是
工具 isError工具抛错/被 blockpush 回 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 持有 currentContextserver 持有、client 持副本副本需 snapshot 校正
认证Models 直接解析client 传 token、server 校验认证位置不同
工具执行本地进程内取决于 backend runtime需按 backend 核验
失败边界event sink/stopReasonframe/握手/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 才能定位。

源码走查 ​

  1. 从 packages/coding-agent/src/main.ts 追到会话创建,记录每次传入的配置/资源对象。
  2. 在 agent-loop.ts 标出 assistant tool call 变为 tool result 的路径,再回到下一次模型流。
  3. 在 models.ts 跟到 Provider 的 streamSimple,为一次中止标出 AbortSignal 传递点。
  4. 观察交互模式如何订阅会话事件;若使用远程模式,再比对 snapshot 与 progress 的显示时机。
  5. 对一次工具失败收集 loop 停止原因、tool result、session 条目和 UI 事件,验证四者没有混成同一种错误。