Agent 的本质是一个 while 循环:拆解 pi 的 792 行核心源码

这是【Agent 内核拆解】系列文章的第 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 行。今天我们一起读一下这 792 行代码,看看它到底做了什么,更重要的是搞明白:它凭什么只用 792 行。

(源码版本:pi-agent-core 0.82.1)

一、5 层全景



在看循环代码之前,先了解一下它在整个系统里的位置。

pi 分三个包,用户输入会经过五层调用:











产品层(pi-coding-agent):AgentSession,管策略,包括扩展命令、模板展开、任务排队、重试、上下文压缩
内核层(pi-agent-core):runLoop,今天的主角,只管循环
协议层(pi-ai):统一各家 LLM API,把网络错误封装成流内事件
记住这个分层。后面你会发现 792 行之所以干净,是因为它什么"额外的事"都不干,错误处理推给下层,状态维护和重试推给上层。

说白了就是责任划分做得好。

二、核心逻辑:20 行伪代码



去掉工程细节后,runLoop 的核心逻辑大概是这样的:

typescript


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。

但 pi 用了 792 行,多出来的 700 行就是玩具和正经产品之间的差距,下面 5 个设计点展开讲讲。

三、双层循环与消息队列



pi 的循环其实是两层:










typescript


// 外层:处理"排队消息"while (true) { // 内层:工具调用 + 用户插话 while (hasMoreToolCalls || pendingMessages.length > 0) { ...核心循环... } // agent 要停了,但用户是不是又排队了新任务? const followUps = await config.getFollowUpMessages?.(); if (followUps.length > 0) { pendingMessages = followUps; continue; } break;}

场景:agent 正在干活,你已经想好了下一个任务,直接输进去排队。干完当前任务后不会停下来,外层循环会检查队列里有没有新消息,有就继续跑。用户感受到的"连贯感"就来自这几行。

四、Steering 转向消息



内层循环每转一圈都会问一次:

typescript


pendingMessages = (await config.getSteeringMessages?.()) || ;

"转向消息",也就是 agent 执行到一半你发现方向不对,直接打字纠正,这些消息会在下一次调 LLM 之前注入上下文,模型立刻能看到你的纠偏。

和上面 follow-up 的区别就一个:轮询点的位置。steering 在内层,每个 turn 后都问;follow-up 在外层,agent 彻底闲下来才问。同一个机制放在两个时机,就实现了"随时可打断 + 任务可排队"的完整交互。

用过 Claude Code 的人知道这有多关键。没有这个机制,你只能看着 agent 在错误方向上一直跑,等它跑完再重来。

五、事件流与 UI 解耦



runLoop 的函数签名里没有任何和打印或渲染相关的东西,它只有一个事件出口:

typescript


emit({ type: "agent_start" });emit({ type: "message_update", ... }); // 流式 tokenemit({ 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 的处理一律是包成错误结果返回给模型:

typescript


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"):

typescript


// 输出被截断 → 每个工具调用的参数都可能不完整// 全部标记失败,一个都不执行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 时比较容易忽略的地方。

接下来讲什么



前面说了 792 行之所以干净,是因为上下两层各自承担了对应的职责。这两层后面会单独展开讲:


协议层的错误契约:pi-ai 承诺"流一旦返回就绝不 reject",任何网络失败都变成流内的 error 事件。内核之所以敢用 stopReason 而不是 try/catch 做分支,根本原因在这里。
Tool calling 全链路:这篇文章提到的三段式流水线和并行策略会完整展开,包括权限钩子的设计
系列的研读笔记、两张图解以及上面提到的 mini-agent 代码都在下面这个仓库里,每篇文章对应一步可运行的代码,接 DeepSeek 或 GLM 的 API 就能跑。

https://github.com/yanhua1010/build-your-own-coding-agent



分类