Agent 内核:792 行源码拆解
@yanhua1010 对照 codex / grok-build / pi,逐段拆解 earendil pi 的 agent-loop.ts:Agent 的本质是一个 while 循环。
开篇:先说结论
以下来自 @yanhua1010 的系列第 1 篇。作者对照三个开源 coding agent——OpenAI codex(101k star)、xAI grok-build(23k star)、earendil pi(78k star)——一层层拆它们的内核,最后带你从零写一个自己的。
过去一年 "Agent" 这个词越来越玄乎,什么编排、规划、反思、多智能体……但打开一个真正几十万人每天在用的 coding agent 源码,核心就一件事:
pi 是三个项目里最适合拿来读的:TypeScript、架构分层清晰、MIT 协议。整个 agent 循环写在一个文件里:packages/agent/src/agent-loop.ts,一共 792 行(源码版本 pi-agent-core 0.82.1)。
这门课带你读完这 792 行,更重要的是搞明白:它凭什么只用 792 行。
五层全景:循环在系统里的位置
在看循环代码之前,先看它在整个系统里的位置。pi 分三个包,用户输入会经过五层调用:
| 层 | 包 / 组件 | 管什么 |
|---|---|---|
| 产品层 | pi-coding-agent(AgentSession) | 管策略:扩展命令、模板展开、任务排队、重试、上下文压缩 |
| 内核层 | pi-agent-core(runLoop) | 今天的主角,只管循环 |
| 协议层 | pi-ai | 统一各家 LLM API,把网络错误封装成流内事件 |
就像一家餐厅:产品层是前厅经理(排队、翻台、处理投诉),内核层是传菜员(只管在厨房和餐桌之间来回跑),协议层是供应商(保证食材不断供)。传菜员不需要会做菜,所以他的活儿可以用 792 行写完。
核心逻辑:20 行伪代码
去掉工程细节后,runLoop 的核心逻辑大概是这样的(以下代码均来自原文):
while (true) {
// 1. 调 LLM,拿到助手回复(流式)
const message = await streamAssistantResponse(context);
// 2. 回复里有工具调用吗?
const toolCalls = message.content.filter(c => c.type === "toolCall");
if (toolCalls.length === 0) break; // 没有 → 任务结束
// 3. 执行工具,结果塞回上下文
const results = await executeToolCalls(toolCalls);
context.messages.push(...results);
// 4. 回到 1,模型看到工具结果后决定下一步
}
读文件、改代码、跑命令这些能力,都是 executeToolCalls 里的具体工具实现。模型主要负责决策(调哪个工具、传什么参数),循环负责执行和回传。
作者还做了一个能跑的版本:110 行的单 JS 文件,接入 DeepSeek API 就能在本地运行,让你对 agent 有一个直观的感受,而不是停留在 chatbot 的概念上。代码在作者仓库的 steps/01:github.com/yanhua1010/build-your-own-coding-agent。
双层循环:随时打断 + 任务排队
pi 的循环其实是两层:
// 外层:处理"排队消息"
while (true) {
// 内层:工具调用 + 用户插话
while (hasMoreToolCalls || pendingMessages.length > 0) {
...核心循环...
}
// agent 要停了,但用户是不是又排队了新任务?
const followUps = await config.getFollowUpMessages?.();
if (followUps.length > 0) { pendingMessages = followUps; continue;}
break;
}
场景:agent 正在干活,你已经想好了下一个任务,直接输进去排队。干完当前任务后不会停下来,外层循环会检查队列里有没有新消息,有就继续跑。用户感受到的"连贯感"就来自这几行。
Steering 转向消息:随时纠偏
内层循环每转一圈都会问一次:
pendingMessages = (await config.getSteeringMessages?.()) || [];
"转向消息":agent 执行到一半你发现方向不对,直接打字纠正,这些消息会在下一次调 LLM 之前注入上下文,模型立刻能看到你的纠偏。
和 follow-up 的区别就一个:轮询点的位置。steering 在内层,每个 turn 后都问;follow-up 在外层,agent 彻底闲下来才问。同一个机制放在两个时机,就实现了"随时可打断 + 任务可排队"的完整交互。
用过 Claude Code 的人知道这有多关键。没有这个机制,你只能看着 agent 在错误方向上一直跑,等它跑完再重来。
内层 steering 像开车时副驾随时喊"前面路口左转";外层 follow-up 像到了终点问"下一单去哪"。一个管中途纠偏,一个管连单。
事件流:循环与 UI 彻底解耦
runLoop 的函数签名里没有任何和打印或渲染相关的东西,它只有一个事件出口:
emit({ type: "agent_start"});
emit({ type: "message_update",...}); // 流式 token
emit({ type: "tool_execution_start",...});
emit({ type: "turn_end",...});
TUI 是一个消费者,Web 界面是另一个,CI 无头模式是第三个。自己写过 agent 的人应该有体会:一开始图方便把 console.log 写在循环里,后面想加 Web 界面的时候就得全改。
pi 还有一条硬规矩:事件序列在任何路径下都必须闭合。即使循环内部抛了异常,上层也会伪造一条 error 消息,把 message_end、turn_end、agent_end 补发完整。订阅者永远能等到完整的事件序列,所以 UI 和持久化那边的代码才能写得简单。
错误处理:让模型自己收拾烂摊子
如果遇到工具找不到、参数不对或者执行异常,pi 的处理一律是包成错误结果返回给模型:
return {
kind: "immediate",
result: createErrorToolResult(`Tool ${toolCall.name} not found`),
isError: true,
};
模型看到报错后会自己想办法,比如换一组参数重试,或者换一个工具。agent 的健壮性其实就靠这一点:出了错让模型自己去处理。
作者的 110 行 mini-agent 也实现了这个,就一个 catch 的事。实测效果:让它读一个不存在的 config.json,它收到报错后自己跑了 ls 排查,确认文件确实不存在之后,跟作者确认要不要新建。
Token 截断防御:印象最深的一段
当 LLM 的回复因为输出 token 上限被截断时(stopReason === "length"):
// 输出被截断 → 每个工具调用的参数都可能不完整
// 全部标记失败,一个都不执行
const batch = message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(...);
为什么要这样处理?因为流式传输的工具参数用的是"尽力修复"的 JSON 解析器,被截断的参数修复后可能看起来完全合法,schema 校验也能过。比如一个 write_file 调用,文件内容被截断了一半,JSON 修复后能正常解析——如果直接执行就是数据事故。
就像收到一份被撕掉一半的施工图纸:宁可让设计师重画,也不能照着"看起来完整"的残图施工——因为缺的那半可能正好是承重墙。
三段流水线:只并行该并行的那一段
每个工具调用走 prepare → execute → finalize 三段:
- prepare:找工具、校验参数、跑 beforeToolCall 钩子,权限系统就挂在这里,钩子返回 block 就拒绝执行。你在 Claude Code 里看到的"是否允许运行此命令"就是这类钩子;
- execute:真正执行,支持流式回报进度;
- finalize:跑 afterToolCall 钩子,可以改写结果,比如脱敏、截断超长输出。
同一批工具调用默认并行,但只有 execute 段并行,prepare 段仍然串行——否则几个权限确认弹窗会同时弹出来,用户根本没法一个个处理。而且只要批处理里有一个工具声明了 executionMode: "sequential",整批降级为串行:比如改文件和跑命令如果并行执行,结果是不可预期的。
像机场安检:prepare 是查证件(必须一个一个来),execute 是过安检门(可以多通道并行),finalize 是取行李。证件查验要是并行乱了,整个秩序就崩了。
系列预告与资源
792 行之所以干净,是因为上下两层各自承担了对应的职责,后续会单独展开:协议层的错误契约(pi-ai 承诺"流一旦返回就绝不 reject",任何网络失败都变成流内的 error 事件——内核敢用 stopReason 而不是 try/catch 做分支,根本原因在这里);Tool calling 全链路(三段式流水线和并行策略完整展开,包括权限钩子的设计)。
系列研读笔记、两张图解、mini-agent 代码都在:github.com/yanhua1010/build-your-own-coding-agent(每篇文章对应一步可运行的代码,接 DeepSeek 或 GLM 的 API 就能跑)。pi 源码遵循 MIT License,版权归 earendil-works 所有。
术语翻卡:点卡片看解释
自测一下:点问题展开参考答案
pi 的 agent-loop.ts(792 行)核心就是:调 LLM → 有 toolCall 就执行并把结果 push 回 context → 无 toolCall 则 break。读文件改代码跑命令都是 executeToolCalls 里的具体工具,模型只负责决策。
区别只有一个:轮询点的位置。steering 在内层、每个 turn 后都问,实现随时打断纠偏;follow-up 在外层、agent 彻底闲下来才问,实现任务排队。
因为流式工具参数用"尽力修复"的 JSON 解析器,被截断的参数修复后可能看起来完全合法、schema 校验也能过(如 write_file 内容被截断一半),直接执行就是数据事故。宁可全部失败让模型重发。
权限确认挂在 prepare 的 beforeToolCall 钩子;如果 prepare 并行,几个权限弹窗会同时弹出来,用户没法一个个处理。任一工具声明 sequential 则整批降级串行。
责任划分:错误处理推给下层的 pi-ai(协议层),状态维护和重试推给上层的 pi-coding-agent(产品层),内核层 runLoop 只管循环。