← 返回学习中心 · Agent 工程OiOi Academy
AGENT 理论 · 26

Agent 内核:792 行源码拆解

@yanhua1010 对照 codex / grok-build / pi,逐段拆解 earendil pi 的 agent-loop.ts:Agent 的本质是一个 while 循环。

原作者 @yanhua1010 来源 X 发布 2026-08-01
🎯

开篇:先说结论

以下来自 @yanhua1010 的系列第 1 篇。作者对照三个开源 coding agent——OpenAI codex(101k star)、xAI grok-build(23k star)、earendil pi(78k star)——一层层拆它们的内核,最后带你从零写一个自己的。

过去一年 "Agent" 这个词越来越玄乎,什么编排、规划、反思、多智能体……但打开一个真正几十万人每天在用的 coding agent 源码,核心就一件事:

记住:一个 while 循环。调 LLM → 模型说要用工具 → 执行 → 结果放回对话 → 再调 LLM → 直到模型不再要求调工具。

pi 是三个项目里最适合拿来读的:TypeScript、架构分层清晰、MIT 协议。整个 agent 循环写在一个文件里:packages/agent/src/agent-loop.ts,一共 792 行(源码版本 pi-agent-core 0.82.1)。

这门课带你读完这 792 行,更重要的是搞明白:它凭什么只用 792 行。

原文配图:题图
原文配图:题图
🏗️

五层全景:循环在系统里的位置

在看循环代码之前,先看它在整个系统里的位置。pi 分三个包,用户输入会经过五层调用:

原文配图:pi 三包五层调用链
原文配图:pi 三包五层调用链
层 包 / 组件 管什么
产品层 pi-coding-agent(AgentSession) 管策略:扩展命令、模板展开、任务排队、重试、上下文压缩
内核层 pi-agent-core(runLoop) 今天的主角,只管循环
协议层 pi-ai 统一各家 LLM API,把网络错误封装成流内事件
记住:792 行之所以干净,是因为它什么"额外的事"都不干:错误处理推给下层,状态维护和重试推给上层。说白了就是责任划分做得好。
高中生类比
就像一家餐厅:产品层是前厅经理(排队、翻台、处理投诉),内核层是传菜员(只管在厨房和餐桌之间来回跑),协议层是供应商(保证食材不断供)。传菜员不需要会做菜,所以他的活儿可以用 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 用了 792 行,多出来的 700 行就是玩具和正经产品之间的差距——下面 5 个设计点逐个展开。
📬

双层循环:随时打断 + 任务排队

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 修复后能正常解析——如果直接执行就是数据事故。

记住:pi 的选择:宁可全部失败让模型重发,不执行任何一个可疑调用。这种代码大概率是踩过真实的线上事故之后才加上的。
高中生类比
就像收到一份被撕掉一半的施工图纸:宁可让设计师重画,也不能照着"看起来完整"的残图施工——因为缺的那半可能正好是承重墙。
🔧

三段流水线:只并行该并行的那一段

每个工具调用走 prepare → execute → finalize 三段:

  • prepare:找工具、校验参数、跑 beforeToolCall 钩子,权限系统就挂在这里,钩子返回 block 就拒绝执行。你在 Claude Code 里看到的"是否允许运行此命令"就是这类钩子;
  • execute:真正执行,支持流式回报进度;
  • finalize:跑 afterToolCall 钩子,可以改写结果,比如脱敏、截断超长输出。

同一批工具调用默认并行,但只有 execute 段并行,prepare 段仍然串行——否则几个权限确认弹窗会同时弹出来,用户根本没法一个个处理。而且只要批处理里有一个工具声明了 executionMode: "sequential",整批降级为串行:比如改文件和跑命令如果并行执行,结果是不可预期的。

记住:"只并行该并行的那一段"——这个做法是自己写 agent 时比较容易忽略的地方。
高中生类比
像机场安检: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 所有。

词

术语翻卡:点卡片看解释

while 循环
while loop
Agent 内核的本质:调 LLM → 执行工具 → 结果回填 → 再调 LLM,直到模型不再要求调工具。
toolCall
tool call
模型回复中要求调用工具的部分;循环靠它判断任务是否结束。
steering message
steering message
转向消息:agent 执行中途用户打字纠偏,在下一次调 LLM 前注入上下文。
follow-up
follow-up message
排队消息:agent 彻底闲下来后,外层循环检查队列里有没有新任务。
stopReason
stop reason
LLM 停止输出的原因;等于 length 表示因 token 上限被截断,此时 pi 把全部工具调用标记失败。
prepare / execute / finalize
tool pipeline
工具执行三段:准备(权限钩子挂这里)、执行(唯一可并行的段)、收尾(脱敏/截断)。
事件流解耦
event stream
runLoop 只 emit 事件不做渲染,TUI/Web/CI 各自订阅;异常路径也要补发完整事件序列。
测

自测一下:点问题展开参考答案

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 只管循环。