一条消息的旅程:手把手读透 Pi 源码的纵向主线

3943 字
20 分钟
一条消息的旅程:手把手读透 Pi 源码的纵向主线

一条消息的旅程:手把手读透 Pi 源码的纵向主线#

📦 Pi Agent Harness

🔁 Agent Loop

⚡ Streaming

🛠️ Tool Calling

📚 Pi 源码学习系列

✅ 第 1 篇 · 已发布
一条消息的旅程:三层架构与双层循环
✅ 第 2 篇 · 已发布
模型层:模型目录、OAuth 与 provider 适配器
📝 第 3 篇 · 规划中
工具深潜:read / bash / edit / write 怎么执行
📝 第 4 篇 · 规划中
会话层:持久化、恢复与配置系统

读一个十几万行的项目,最怕的事是”按包顺序读”。我从 packages/ai 的第一行开始啃过,三天后脑子里只剩一堆孤立的概念:模型目录、认证、CBOR 协议、差分渲染……它们各自都懂,但凑不成一个”它到底怎么干活”的画面。

后来换了个方法:不按目录读,按一条消息读。从你按下回车,到屏幕上滚出第一行回答,中间经过的所有代码,就是项目的主干。把这根主干摸透,剩下的都是枝叶。

这篇文章就是这个过程的记录。我用的是 Pi Agent Harness(一个开源 AI 编程助手,类似 Claude Code 的终端版),但它适合任何 agent 项目:先追一条消息,再谈其他

🗺️ 全景:三层架构#

先记住这张图。整篇文章就是沿着它的箭头从上往下走一遍。

🗺️ 三层架构全景:一条消息的完整路径
应用层 · packages/coding-agent(5.9 万行)— 管“做什么”
main.ts
入口 / 参数解析 / 模式分发
InteractiveMode
TUI 循环,收键盘输入
AgentSession
会话管家:持久化 / 重试 / 压缩
运行时层 · packages/agent(1.2 万行)— 管“循环怎么转”
agent-loop.ts
双层循环:问模型 → 执行工具 → 再问
Agent
发动机:状态 + 事件广播
模型层 · packages/ai(2.3 万行)— 管“怎么跟 LLM 说话”
ModelRuntime
认证边界:注入 API Key / Headers
provider 适配器
OpenAI / Anthropic / Google …
HTTP + SSE
请求与流式解析
AgentTool(手)→ 内置工具 read / bash / edit / write
StreamFn(嘴)→ 模型层。两个接口咬合上下两层

三层各管一件事:应用层管”做什么”(会话、工具、配置),运行时层管”循环怎么转”模型层管”怎么跟 LLM 说话”

两层之间只靠两个接口咬合,记住这两个词:

接口类比职责
AgentTool循环的”手”schema 校验参数 + execute() 执行
StreamFn循环的”嘴”把上下文发给模型,返回事件流

AgentTool 是应用层提供的,StreamFn 是模型层提供的,循环本身谁都不认识。这就是整个架构能拆成三个包的原因。

🚪 站 1:入口 main.ts 是个路由器#

packages/coding-agent/src/main.ts 自己不干活,它做三件事:解析参数、组装运行时、按模式分发。

if (appMode === "rpc") {
await runRpcMode(runtime);
} else if (appMode === "interactive") {
const interactiveMode = new InteractiveMode(runtime, {...});
await interactiveMode.run();
} else {
await runPrintMode(runtime, {...});
}

appMode 的判定藏着第一个小惊喜:

function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {
if (parsed.mode === "rpc") return "rpc";
if (parsed.mode === "json") return "json";
if (parsed.print || !stdinIsTTY || !stdoutIsTTY) return "print"; // 管道输入自动降级
return "interactive";
}
🔀 模式分发决策:同一个会话,三种驱动方式
进程启动
stdin 是 TTY?
↓ / ↓
否(echo hi | pi)
print 模式
跑一次就退出
是 → 再看 —mode 参数
rpc / json / interactive
JSON-RPC / JSON 事件流 / TUI 交互
同一个 AgentSession
三种驱动方式,一份会话状态

stdin 不是终端就自动降级成 print 模式,所以 echo "hi" | pi 能一行跑完。同一个会话,三种驱动方式:TUI 事件驱动、一次性 prompt、JSON-RPC。读到这里先记住”会话”这个抽象,它是后面所有站点的共同底座。

🏭 站 2:总装车间,三个服务一个发动机#

main.ts 里有一段长长的依赖组装,产出的核心是三个服务:

const { settingsManager, modelRuntime, resourceLoader } = services;
  • settingsManager 管配置(模型、工具开关、重试策略)
  • modelRuntime 管模型目录、认证、发请求(站 7 的主角)
  • resourceLoader 管加载扩展、技能、AGENTS.md

它们被装进 AgentSessionAgentSession 内部又创建一个 Agent,然后订阅它的事件:

// agent-session.ts:395
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);

这行代码是整个项目事件架构的缩影:

📡 事件架构:发动机只发事件,不关心谁在听
Agent 发动机
只发 AgentEvent,不关心谁在听
↘    ↓    ↙
AgentSession 管家
持久化 / 重试 / 压缩
TUI 显示屏
渲染 / 光标 / 队列
扩展钩子
tool_call / tool_result
数据单向流动:发动机 → 事件 → 多个订阅者各取所需

数据单向流动:发动机产生事件,多个订阅者各取所需。这个设计让”重试、压缩、持久化”这些脏活全部挂在循环外面,循环本身保持纯净。后面你会看到这个”挂外面”的思路反复出现。

⌨️ 站 3:输入,prompt() 的预处理#

用户在 TUI 里按下回车,InteractiveMode 把文本交给 AgentSession.prompt()。注意它第一件事不是发消息:

async prompt(text: string, options?: PromptOptions): Promise<void> {
// 1. 以 "/" 开头的先查扩展命令(/model、/compact 都是扩展命令)
if (expandPromptTemplates && text.startsWith("/")) {
const handled = await this._tryExecuteExtensionCommand(text);
if (handled) return; // 扩展命令自己管 LLM 交互
}
// 2. 展开 @file 模板、初始消息...
}

/model/compact 这些你以为的”内置命令”,其实全是扩展命令。它们走的是另一条路,根本不进主循环。

然后进入 _runAgentPrompt,真正的转发:

private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
this._isAgentRunActive = true;
try {
await this.agent.prompt(messages);
while (await this._handlePostAgentRun()) { // 重试 / 压缩循环
await this.agent.continue();
}
} finally { ... }
}

_handlePostAgentRun 检查最后一条 assistant 消息:可重试错误就自动重试,上下文超限就自动压缩(把旧消息总结掉)。重试和压缩都是事后观察结果再干预,不是循环的一部分。这是事件驱动架构的直接收益:外科手术不用切开病人。

🔥 站 4:点火,Agent.prompt 的快照与钩子#

Agent.prompt() 干两件事:把输入规范成消息数组,然后带着上下文快照循环配置点火:

agent.ts
private runPromptMessages(messages: AgentMessage[]) {
await runAgentLoop(
messages,
this.createContextSnapshot(), // { systemPrompt, messages, tools } 的拷贝
this.createLoopConfig(), // 所有钩子打包成配置
(event) => this.processEvents(event),
signal,
this.streamFunction, // ← 站 6 的主角
);
}

两个细节值得停一下:

为什么拷贝? 循环内部会原地修改 messages 数组(站 6 的 partial 替换),快照是为了隔离。

钩子即配置。 createLoopConfigAgent 类的公共字段(beforeToolCallafterToolCallgetSteeringMessages……)打包成 AgentLoopConfig。没有继承,没有接口实现类,全是回调函数。想扩展循环?往配置里塞一个函数就行。

💓 站 5:心脏,双层循环#

packages/agent/src/agent-loop.tsrunLoop 是整个项目最重要的函数。它是个双层循环:

💓 runLoop:外层管消息,内层管工具往返
外层循环:还有 follow-up 消息吗?
有 → 设为 pending,继续下一轮
无 → agent_end,收工
内层循环:一轮对话(有工具调用或 pending 消息就继续)
注入 pending 消息(steering 插话)
请求 LLM(streamAssistantResponse)
消息里有 toolCall?执行工具,结果进上下文,回到 ②
没 toolCall → turn_end,内层退出,回到外层
while (true) { // 外层: 处理 follow-up 消息
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) { // 内层: 一轮对话
// 1. 注入排队中的 steering 消息
// 2. 请求 LLM → assistant 消息
// 3. 有 toolCall? 执行 → ToolResultMessage 进上下文 → hasMoreToolCalls = true
// 4. 没 toolCall → 内层退出
}
// 外层: 查 follow-up 队列, 有就继续, 没有就 agent_end
}

为什么是双层? 因为用户可以在 agent 干活时插话(steering,比如”等等,先别改那个文件”),也可以排队等它干完再问(follow-up)。内层循环处理”一轮对话”(一次 LLM 调用加若干工具执行),外层循环处理”这轮完了还有没有新指令”。

顺带一提:内层循环里有个小细节,stopReason === "length"(输出被 token 上限截断)时,会把这一批工具调用全部判失败而不是执行。因为截断的工具参数可能是残缺的 JSON,执行了反而危险。

🧱 站 6:LLM 边界,两次转换和 partial 替换#

streamAssistantResponse 是 agent 层和模型层的唯一接触点,也是全项目最优雅的流式处理:

// 转换 1: AgentMessage[] → Message[] (过滤 UI-only 消息, 转换自定义消息)
const llmMessages = await config.convertToLlm(messages);
// 转换 2: 调 StreamFn — 返回事件流而不是 Promise
const response = await streamFunction(config.model, llmContext, {...});
let partialMessage: AssistantMessage | null = null;
for await (const event of response) {
switch (event.type) {
case "start":
partialMessage = event.partial;
context.messages.push(partialMessage); // 占位对象入数组
break;
case "text_delta": case "toolcall_delta": ...
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage; // 原地替换!
emit({ type: "message_update", ... });
break;
case "done": case "error":
return await response.result();
}
}

partialMessage 是一个不断被整体替换的占位对象。每次 text_delta 到来,事件携带的是累积的完整 partial,循环直接拿新对象换掉数组最后一项:

⚡ 流式事件:partial 整体替换,上游只见完整快照
provider → agent-loopagent-loop → TUIpartial 内容
startmessage_start空 partial 入数组
text_deltamessage_update你好
text_deltamessage_update你好世界(整体替换,不是增量)
donemessage_end最终消息落定
每次 message_update 携带完整快照,UI 不用自己拼 delta

好处是连锁的:订阅者每次收到 message_update 都是完整快照,不用自己拼增量;context.messages 的最后一项永远是最新状态,任何时刻读取都是安全的。增量拼装的复杂度被收敛在 provider 内部processResponsesStream 负责解析 SSE 并产出累积 partial),上游全部享受完整快照的简单。

对比常见做法(UI 自己拼 delta 字符串),这个设计让每个环节都少了一个状态。

💰 站 7:钱的边界,ModelRuntime.prepareRequest#

默认的 StreamFn 是谁装的?sdk.ts 里一行:

setDefaultStreamFn(streamSimple); // 来自 @earendil-works/pi-ai/compat

这行的意义在于:agent 包完全不依赖 ai 包,依赖方向由宿主注入。ModelRuntime.streamSimple 再套一层:

// model-runtime.ts:636
streamSimple(model, context, options) {
return lazyStream(model, async () => {
const prepared = await this.prepareRequest(model, options); // ← 关键
return prepared.provider.streamSimple(prepared.model, context, prepared.options);
});
}

prepareRequest认证边界:解析该 provider 的凭证(API key、OAuth token、headers、baseUrl 覆盖),注入请求选项。所有 provider 的请求都从这里过,所以”凭证过期”只在这一层处理。它是整条链路上唯一碰钱和身份的地方。

🌐 站 8:真正的 HTTP,provider 适配器#

streamSimple 只是薄封装,真正干活的是 stream

// openai-responses.ts:101
const client = createClient(model, context, apiKey, ...);
const { data: openaiStream, response } = await retryProviderRequest(
() => client.responses.create(params, requestOptions).withResponse(),
{...}
);
stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model, {...});
stream.push({ type: "done", reason: output.stopReason, message: output });

两个学习要点:

返回值永远是事件流,错误也走事件流。 catch 里是 stream.push({type:"error"}) 而不是 throw。这与 StreamFn 的契约一致(types.ts 里写明:“必须不抛异常,失败编码在事件流里”)。调用方不需要 try/catch 包一层,for await 就能处理所有情况。

每个 provider 文件导出相同形状的 stream + streamSimple anthropic-messages.ts、google-generative-ai.ts、bedrock 系列……全是同一个接口的不同适配器。所谓”统一多 provider API”,全部秘密就这一句:一个接口,N 个适配器

🛠️ 站 9:工具执行,三段式流水线#

模型说”我要调用 bash”,循环进入 executeToolCalls。每个工具调用走三段式:

🛠️ 工具执行:三段式流水线
① prepare 校验
  • 在 tools 里找工具
  • TypeBox schema 校验参数
  • beforeToolCall 钩子(扩展可拦截)
  • 被拦 → 错误结果喂回模型
② execute 执行
  • tool.execute() 真正的工具函数
  • onUpdate 回调 → 实时进度
  • TUI 显示正在执行的命令
③ finalize 收尾
  • afterToolCall 钩子(可改写结果)
  • 生成 ToolResultMessage
  • 推回上下文,循环继续
// 第一段: prepare — 校验 + 前置钩子
const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
const validatedArgs = validateToolArguments(tool, preparedToolCall); // TypeBox schema
const beforeResult = await config.beforeToolCall?.(...);
if (beforeResult?.block) return createErrorToolResult(...); // 拦截 → 错误结果
// 第二段: execute — 真正的工具函数
const result = await prepared.tool.execute(toolCall.id, prepared.args, signal, onUpdate);
// 第三段: finalize — 后置钩子 + 归一化
const afterResult = await config.afterToolCall?.(...);

最值得学的是错误处理哲学:工具不存在、参数校验失败、被钩子拦截,全都变成一条错误 ToolResultMessage 喂回给模型,让模型自己改正。错误是对话的一部分,循环不中断。beforeToolCall / afterToolCall 正是扩展系统(tool_call / tool_result 钩子)的挂载点。

📡 站 10:回声,一个事件四个去处#

Agent.processEvents 把每个事件先更新内部状态(streamingMessagemessages.push……),再广播给所有订阅者。订阅者一号是 AgentSession._handleAgentEvent

// agent-session.ts:610
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// 1. 用户消息若来自队列, 先从队列移除 (UI 看到最新队列状态)
// 2. 转发给扩展 (tool_call / tool_result 等钩子)
await this._emitExtensionEvent(event);
// 3. 转发给 UI 订阅者
this._emit(event.type === "agent_end" ? {...} : event);
// 4. 持久化到磁盘 (session jsonl)
if (event.type === "message_end") {
if (event.message.role === "user" || event.message.role === "assistant" || ...) {
this.sessionManager.appendMessage(event.message);
}
}
};
📡 一个事件,四个去处
一个 AgentEvent
↙   ↓   ↓   ↘
① 队列清理
steering 消息出队
② 扩展钩子
tool_call / tool_result
③ UI 渲染
滚动 / 光标 / 状态栏
④ 磁盘持久化
session jsonl

一个事件,四个去处。UI 不需要自己记历史,sessionManager 落盘的 jsonl 就是完整会话,这也是 --continue / --resume 能恢复会话的原因。

🐣 站 11:工具的出生,从定义到可执行#

最后补上工具是怎么来的。_buildRuntime 里:

const baseToolDefinitions = createAllToolDefinitions(this._cwd, {...});
// → read / bash / edit / write 等内置工具的定义

_refreshToolRegistry 里,内置工具和扩展工具统一包装(每个工具外面包一层扩展钩子),最后:

this.setActiveToolsByName([...new Set(nextActiveToolNames)]);
// → this.agent.state.tools = tools (setter 拷贝数组)

工具的”出生证明”是 ToolDefinition(声明 TypeBox schema 和 prompt 片段),可执行的 AgentTool(带 execute)是运行时形态。schema 一份定义,两个用途:既校验参数,又生成发给模型的 tools JSON。

🧠 记忆锚点#

概念一句话
AgentMessage vs Message内部对话格式 vs 发给 LLM 的格式,convertToLlm 是桥
StreamFn”嘴”的接口,setDefaultStreamFn 注入,agent 包不依赖 ai 包
AgentTool”手”的接口,schema 校验 + execute 执行
partial 替换流式增量在 provider 内部拼好,上游只见完整快照
双层循环内层 = 工具往返,外层 = steering / follow-up 消息
事件四分发队列清理 → 扩展 → UI → 磁盘,一次广播全搞定
错误即消息工具失败不中断循环,变成错误结果喂回模型

🧪 动手验证#

光读不练容易飘,这四个实验每个十分钟:

  1. 看会话文件:跑 ./pi-test.sh 问一个问题,然后打开 ~/.pi/sessions/ 下的 jsonl。你会看到 user / assistant / toolResult 消息和 toolCall 的完整往返,这就是站 10 落盘的东西。
  2. 数 LLM 调用次数:跑 pi -p "读一下 package.json",观察它先调 read 工具、再调一次模型。两次 LLM 调用 = 一个回合,这就是内层循环。
  3. 打断点看 partial:在 agent-loop.tsstreamAssistantResponse 里临时打印每次 text_delta 的累积长度,观察站 6 的替换机制。
  4. 改一个数字感受重试:把 openai-responses.tsmaxRetries: 0 改成 3,感受重试层的位置。

结尾#

这条主线走完,Pi 的骨架就清楚了:一个双层循环,两个接口,一种事件流。剩下的内容,模型目录、OAuth、差分渲染、远程协议,都是挂在这根主干上的器官,想读哪块读哪块,不会再迷路。

这也是整个系列的地基。下一篇沿着模型层往下钻,看模型目录怎么生成、认证怎么解析、provider 适配器怎么接;再往后是工具、会话层。每一篇都会像本文一样,从一条具体的路径出发,带着图走完全程。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

一条消息的旅程:手把手读透 Pi 源码的纵向主线
https://rushzb-blog.pages.dev/posts/pi-message-journey/
作者
rushzb
发布于
2026-08-13
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
rushzb
初级 Vibe Coder
公告
欢迎浏览我的学习报告~
分类
标签
最新动态
站点统计
文章
6
动态
1
分类
1
标签
25
总字数
56,556
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.14.2
文章许可
CC BY-NC-SA 4.0