Agent 到底是什么?拆完 792 行代码,只剩一个 while 循环
过去这一年,Agent 这个词越来越玄乎了。编排、规划、反思、多智能体协作……听起来像是什么了不得的新范式。但真正打开一个几十万人在用的 Coding Agent 源码,你会发现核心就一件事:一个 while 循环。
调 LLM,模型说要用工具,执行工具,把结果放回对话,再调 LLM,直到模型不再要求调工具。就这么点事儿。
这篇是 Agent 内核拆解系列的第一篇,我选了 pi 这个项目下手。OpenAI Codex 有 101k star,xAI Grok-Build 有 23k star,而 pi 有 78k star,用的是 TypeScript,架构分层清晰,而且是 MIT 协议。整个 Agent 循环写在一个文件里:packages/agent/src/agent-loop.ts,一共 792 行。
今天我们就读一下这 792 行代码,搞清楚它到底做了什么。更重要的是,凭什么 792 行就够了?
1. 先看一眼全景:5 层架构
在看循环代码之前,得先弄明白这 792 行在整个系统里的位置。
pi 分了三个包,用户输入从产品层进去,经过五层调用才落到最底下。
从上往下:
-
产品层(pi-coding-agent): AgentSession在这儿,管策略。扩展命令、模板展开、任务排队、重试、上下文压缩,都在这一层。 -
内核层(pi-agent-core): runLoop就在这一层,今天的主角。它的活儿很纯粹——只管循环,别的都不管。 -
协议层(pi-ai):统一各家 LLM 的 API,把网络错误封装成流内事件。
这个分层有意思。792 行之所以干净,是因为它什么”额外的事”都不干。错误处理推给下层,状态维护和重试推给上层。说白了就是责任划分得好,各管一摊。
2. 核心逻辑:20 行伪代码
去掉所有工程细节之后,runLoop 的核心逻辑其实就 20 行:
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 就能在本地跑起来。代码放在文末仓库的 steps/01,想动手的可以试试。
但 pi 用了 792 行,多出来的 700 行就是玩具和正经产品之间的差距了。下面展开讲讲 5 个关键设计点。
3. 双层循环 + 消息队列:怎么做到”任务排队”的?
pi 的循环实际上是两层:
// 外层:处理"排队消息"
while (true) {
// 内层:工具调用 + 用户插话
while (hasMoreToolCalls || pendingMessages.length > 0) {
...核心循环...
}
// agent 要停了,但用户是不是又排队了新任务?
const followUps = await config.getFollowUpMessages?.();
if (followUps.length > 0) { pendingMessages = followUps; continue; }
break;
}
场景是这样的:Agent 正在干活,你已经想好了下一个任务,直接打进去排队。干完当前的活儿之后,它不会直接停下来,外层循环会检查队列里有没有新消息,有就继续跑。
用户感受到的那种”连贯感”,就来自这几行代码。
4. Steering 转向:干活干到一半怎么喊停?
内层循环每转一圈都会问一次:
pendingMessages = (await config.getSteeringMessages?.()) || [];
这东西叫”转向消息”。就是你发现 agent 方向跑偏了,直接打字纠正,这些消息会在下一次调 LLM 之前注入上下文,模型立刻就能看到你的纠偏。
跟上面的 follow-up 有什么区别?就一个:轮询点的位置不同。
-
Steering 在内层,每个 turn 后都问 → 随时可以打断 -
Follow-up 在外层,agent 彻底闲下来了才问 → 任务排队
同一个机制,放在两个不同的时机,就实现了”随时可打断 + 任务可排队”的完整交互。
用过 Claude Code 的人应该知道这有多关键。没有 steering 机制,你只能眼睁睁看着 agent 在错误方向上越跑越远,等它跑完了再重来。
5. 事件流 + 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 的做法一开始就把 UI 和逻辑彻底分开了。
更狠的是,pi 有一条硬规矩:事件序列在任何路径下都必须闭合。即使循环内部抛了异常,上层也会伪造一条 error 消息,把 message_end、turn_end、agent_end 全部补齐。订阅者永远能等到完整的事件序列。UI 和持久化那边的代码,根本不用处理”万一没收到结束事件怎么办”这种破事。
6. 错误处理:让模型自己解决自己搞出来的问题
工具调用失败怎么办?工具找不到、参数不对、执行异常——pi 的处理方式一律是:包成错误结果返回给模型。
return {
kind: "immediate",
result: createErrorToolResult(`Tool ${toolCall.name} not found`),
isError: true,
};
模型看到报错之后会自己想办法。换一组参数重试,或者换一个工具,它自己决定。
Agent 的健壮性其实就靠这一点。出了错让模型自己去处理,而不是在工程层面做各种兜底判断。我的 110 行 mini-agent 也实现了这个机制,就一个 catch 的事。
实测效果挺有意思:让它读一个不存在的 config.json,它收到报错之后自己跑了 ls 排查,确认文件确实不存在,然后反过来问我:要不要新建一个?
7. Token 截断防御:这行代码踩过多少坑?
这是整个文件里我印象最深的一段。当 LLM 的回复因为输出 token 上限被截断时(stopReason === "length"):
// 输出被截断 → 每个工具调用的参数都可能不完整
// 全部标记失败,一个都不执行
const batch = message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(...);
为什么要这么处理?
因为流式传输的工具参数用的是”尽力修复”的 JSON 解析器。被截断的参数,修复之后可能看起来完全合法,schema 校验也能过。比如一个 write_file 调用,文件内容被截断了一半,JSON 修复后能正常解析,如果直接执行,写进去的就是半截数据。
这是典型的数据安全事故。
所以 pi 的选择是:宁可全部失败让模型重发,也不执行任何一个可疑调用。这种代码大概率是踩过真实的线上事故之后才加上的。
8. 工具执行的三段流水线:prepare → execute → finalize
每个工具调用走三个阶段:
// prepare:找工具、校验参数、跑 beforeToolCall 钩子
// execute:真正执行,支持流式回报进度
// finalize:跑 afterToolCall 钩子,改写结果
权限系统就挂在 prepare 阶段。钩子返回 block 就拒绝执行——你在 Claude Code 里看到的”是否允许运行此命令”,本质就是这类钩子。
finalize 阶段可以改写结果,比如脱敏、截断超长输出。
同一批工具调用默认并行,但只有 execute 段并行,prepare 段是串行的。为什么?几个权限确认弹窗同时弹出来,用户根本没法一个个处理。
而且只要批处理里有一个工具声明了 executionMode: "sequential",整批降级为串行。改文件和跑命令如果并行执行,结果是不可预期的。
“只并行该并行的那一段”,这个细节自己写 Agent 的时候很容易忽略。
9. 792 行之外:上下两层各自扛了什么
前面说了 792 行之所以干净,是因为上下两层各自承担了职责。
上面那层(产品层) 管什么?
-
扩展命令和模板展开 -
任务队列和重试策略 -
上下文压缩(对话太长的时候要压缩,不然 token 不够用)
下面那层(协议层) 管什么?
-
统一各家 LLM 的 API 格式 -
网络错误封装成流内事件
pi-ai 有一个很关键的承诺:流一旦返回就绝不 reject。任何网络失败都变成流内的 error 事件。内核层之所以敢用 stopReason 而不是 try/catch 做分支,根本原因就在这里。
动手试试
系列的研读笔记、两张图解,还有上面提到的 mini-agent 代码,都放在这个仓库里。每篇文章对应一步可运行的代码,接 DeepSeek 或 GLM 的 API 就能跑。
https://github.com/yanhua1010/build-your-own-coding-agent
实用摘要 / 操作清单
-
理解 Agent 的本质:一个 while 循环,调 LLM → 执行工具 → 结果回传 → 继续调,直到模型不再要求调用工具。 -
分层架构:产品层管策略,内核层只管循环,协议层管 API。职责清晰,代码才能干净。 -
双层循环:内层跑工具调用,外层管任务排队。Steering(转向)在内层轮询,Follow-up(排队)在外层轮询。 -
事件流解耦:通过 emit输出事件,UI 和逻辑分离。事件序列必须闭环,异常也要补齐结束事件。 -
错误处理:工具执行失败一律包成错误结果返回给模型,让模型自己决定下一步。 -
Token 截断防御:输出被截断时,不执行任何工具调用,宁可全部失败让模型重发。 -
三段流水线:prepare(校验/权限)→ execute(执行)→ finalize(改写结果)。prepare 串行,execute 可并行。
一页速览
| 模块 | 职责 | 关键机制 |
|---|---|---|
| 产品层 (pi-coding-agent) | 策略、排队、重试、压缩 | AgentSession |
| 内核层 (pi-agent-core) | while 循环 | runLoop,792 行 |
| 协议层 (pi-ai) | 统一 API、错误封装 | 流不 reject |
| 事件系统 | UI 解耦 | emit + 事件闭环 |
| 工具执行 | 三段流水线 | prepare → execute → finalize |
| Token 截断 | 防御性处理 | 截断时全部失败 |
FAQ
Q:Agent 真的就是一个 while 循环吗?
A:核心逻辑确实就是 while 循环。792 行代码里,循环体就是调 LLM、检查工具调用、执行工具、回传结果。复杂的地方都在循环外面——错误处理、事件分发、权限管理这些。
Q:Steering 和 Follow-up 有什么区别?
A:Steering 在内层轮询,每个 turn 后都能注入新消息,用来”中途纠偏”。Follow-up 在外层轮询,等当前任务彻底结束才检查,用来”排队下一个任务”。机制一样,时机不同。
Q:为什么不直接并行执行所有工具?
A:prepare 阶段串行是因为权限弹窗不能同时弹好几个。另外,如果有工具声明了 sequential 模式,整批都要降级为串行——有些操作并行会出问题,比如改文件和跑命令同时进行。
Q:Token 截断为什么不能执行工具?
A:因为截断后的 JSON 可能被”修复”成一个看起来合法的结构。比如 write_file 的内容被截断一半,修复后 JSON 能解析,但写进去的数据不完整。pi 的选择是宁可全失败,也不执行任何一个可疑调用。
Q:事件序列必须闭环是什么意思?
A:就是不管循环正常结束还是异常退出,agent_end 这个事件一定会被补发。UI 那边只管订阅事件就行,不用处理”收不到结束信号”的边界情况。
Q:我用的是其他 LLM API,能用这套思路吗?
A:能。pi-ai 这层就是做 API 适配的,把各家接口统一成相同的事件格式。自己写的话,只要把不同 SDK 的调用封装成统一的流式接口就行。
Q:mini-agent 和 pi 的区别在哪?
A:mini-agent 只有 110 行,实现了核心的 while 循环和工具执行,能跑起来。pi 的 792 行加了双层循环、事件系统、token 截断防御、三段流水线——这些都是产品级需要的东西。从 mini-agent 到 pi,就是玩具到产品的距离。

