Pi 的 runAgentLoop 是整套系统最值得反复读的地方。它做的事情可以用一句话概括:
把用户消息加入上下文,请求模型;如果模型要求调用工具,就执行工具、追加工具结果,再继续请求模型;直到没有工具调用、被中止或被配置要求停止。
真实实现有流式事件、队列、hook、并发工具、错误处理。先看压缩到最小的版本:
async function runAgentLoop(context, config) {
while (true) {
const assistant = await streamAssistantResponse(context, config);
context.messages.push(assistant);
const toolCalls = assistant.content.filter((block) => block.type === "toolCall");
if (toolCalls.length === 0) {
return;
}
for (const call of toolCalls) {
const tool = context.tools.find((tool) => tool.name === call.name);
const result = await tool.execute(call.arguments);
context.messages.push(toToolResultMessage(call, result));
}
}
}
这是所有 Agent 框架都绕不开的骨架。
Pi 区分两类排队消息:
| 队列 | 什么时候注入 | 用途 |
|---|---|---|
steering |
当前助手轮次和工具调用结束后,下一次模型请求前 | 用户想在 Agent 工作中途改变方向 |
followUp |
Agent 本来要停止时 | 用户想排一个“做完以后再做” |
这就是为什么源码里有内外两层循环:内层处理工具和 steering,外层在停止前再检查 follow-up。
flowchart TD
A["start"] --> B["drain steering"]
B --> C["stream assistant"]
C --> D{"has tool calls?"}
D -->|"yes"| E["execute tools"]
E --> B
D -->|"no"| F{"has follow-up?"}
F -->|"yes"| B
F -->|"no"| G["agent_end"]模型流一开始时,Pi 会把 partial assistant message 放进上下文。后续每个 text_delta、thinking_delta、toolcall_delta 都替换这个 partial。
这样 UI 可以持续显示最新状态,而最终 done 时再把 partial 替换成 final message。
Pi 的 prepareToolCall 做了四件事:
beforeToolCall hook,看是否允许执行。任何一步失败,都会生成错误 tool result,而不是让循环崩掉。
afterToolCall 可以改写工具结果,例如截断、脱敏、标记错误或请求提前停止。
Pi 有一个小但重要的规则:只有当当前批次每个工具结果都设置 terminate: true 时,才会提前停止工具循环。这样一个工具的“想停止”不会误杀其他并行工具的结果。
runAgentLoop 只接收 context、config、emit 和可选 streamFn,不直接碰 session file。这是一个很好的边界:loop 是可测试的状态转换,产品层才负责持久化。
| 如果 loop 直接写磁盘 | Pi 的做法 |
|---|---|
| 测试每个边界都要准备真实文件 | 测试只断言事件和新消息 |
| SDK、TUI、RPC 很难复用同一个核心 | 所有模式都订阅同一套 AgentEvent |
| 扩展想替换持久化策略会很痛苦 | AgentSession 和 SessionManager 负责写入 |
你在教学版项目里也会看到同样的拆法:runAgentLoop() 只返回 newMessages/events,src/server/index.ts 再把这些消息写入 JsonlSessionStore。
| 误区 | 为什么错 | 正确做法 |
|---|---|---|
| 工具调用后立刻给用户最终答案 | 模型还没看见工具结果 | 追加 toolResult 后再请求模型 |
| provider 抛错就让进程崩溃 | UI 和 session 无法得到正常事件序列 | 转成 stopReason: "error" 的 assistant message |
| 并行工具按完成顺序写回 | 模型看到的 toolResult 顺序可能和 toolCall 顺序不一致 | 执行可并行,写回按 assistant 原始顺序 |
| abort 只停模型请求 | 工具可能仍在执行副作用 | 把 AbortSignal 传给工具和 hook |
建议按这个顺序读 Pi 源码:
| 文件 | 看什么 |
|---|---|
packages/agent/src/types.ts |
AgentMessage、AgentTool、AgentEvent、AgentLoopConfig |
packages/agent/src/agent-loop.ts |
runLoop、streamAssistantResponse、executeToolCalls |
packages/agent/src/agent.ts |
Agent 如何包装 loop,维护状态和队列 |
packages/ai/src/types.ts |
统一的消息和流式事件协议 |
运行:
npm run demo:01
这个 Demo 删掉了工具和持久化,只保留“上下文 -> 模型 -> 事件 -> 最终消息”。读完这一节再看 Demo,会发现它就是 Pi Loop 的骨架缩影。