「AI Agent 的核心就是一个 while 循环」——这句话对了一半。给 LLM API 套个循环确实十几行就能写完,但 Claude Code、Grok Build、pi、OpenClaw 这些能真正干活的 agent,循环本体都不复杂,复杂的全是边界:工具参数被 token 上限截断了怎么办?用户在模型回答到一半时插话怎么办?工具失败了,错误怎么送回模型?
这些边界问题没有标准答案,每个产品交了自己的答卷。本文分三步把它讲清楚:先用十几行伪代码建立所有 agent 共享的最小模型;然后拿一份又小又完整开源的实现——pi 的 packages/agent——逐段读源码,看生产级循环怎么回答这些问题;最后看另外三家各自做了什么不同的选择。
面向没有写过 agent 的读者,术语会随用随解释。
最小模型:十几行就是一个 agent
先把所有 agent 产品的共同内核写出来。不管什么厂商、什么语言,剥到最后都是这个:
messages = [user("帮我修一下登录页的 bug")]while True: reply = llm(messages, tools) # 1. 把对话历史发给模型 messages.append(reply) if not reply.tool_calls: # 2. 模型没要工具 = 它认为干完了 break for call in reply.tool_calls: # 3. 模型要求调工具 result = run_tool(call) # 真的去执行 messages.append(tool_result(result)) # 4. 结果写回历史,下轮模型能看到什么是工具调用(tool call)模型本身只能输出文本,不能真的读文件、跑命令。「调工具」是它和外部世界的约定:模型按约定格式输出「我要调 read_file,参数是 xxx」,由外面的程序真正执行,再把结果作为一条消息喂回去。模型看到结果后继续推理。一来一回,模型就有了「手和眼睛」。
这个循环能跑,但只能活在 demo 里。拿到真实世界用,立刻撞上一串问题:
- 模型一轮要调三个工具,串行等还是并行跑?并行的话结果按什么顺序写回历史?
- 模型输出被 token 上限砍断,工具参数只剩半截,要不要执行?
- 用户趁工具还在跑插了一句「别改了」,怎么安全地插进去?
- 网络断了、工具抛异常了,循环是崩掉还是体面收尾?
- 对话越来越长塞不下上下文了,每轮发给模型的历史由谁来裁剪?
下面这份开源实现把每个问题都回答了,而且回答得相当干净。
拿谁开刀:pi-agent-core
选 pi 的循环层当解剖标本,原因有三个:MIT 协议源码全部公开;循环核心(agent-loop.ts + agent.ts + types.ts)不到 2000 行 TypeScript,一个人能读完;它不做产品化的封装,所有边界都裸露在外,没有藏在功能背后。
它在 pi 里的位置:
pi-ai 模型层:40 家厂商统一成一个 streamFnpi-agent-core 循环层:Agent 类 + agentLoop,本文标本 ├─ agent-loop.ts 循环本体(796 行) ├─ agent.ts 有状态封装(592 行) ├─ types.ts 全部契约(443 行) └─ harness/ 再往上:session、压缩、skills(本文不展开)pi-coding-agent 产品层:终端 UI、工具实现、extension模型层那 40 家厂商怎么收敛成一个接口,之前拆过:
读源码前固定版本本文固定的版本:pi
0.84.2(commita69bef789)、Grok Build 源码 commit07b2f714、OpenClaw 源码 commita1f8e1b0、Claude Code 2.1.202(均为 2026-08-23 前后拉取)。pi 的代码摘自packages/agent/src/,为排版有删减,删减处用注释标出。
先跑一遍:不用 API key 看一次完整循环
读源码之前先建立直觉。pi-ai 内置了一个按剧本回答的假模型(fauxProvider),配上循环层和一个真工具,不调任何真实 LLM,就能把完整循环跑一遍:
// demo.mjs(Node 24 实测可跑)import { Agent } from '@earendil-works/pi-agent-core';import { createModels, fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText,} from '@earendil-works/pi-ai';import { Type } from 'typebox';
// 假模型的剧本:第一轮要求调 add 工具,第二轮给出总结const faux = fauxProvider();const models = createModels();models.setProvider(faux.provider);faux.setResponses([ fauxAssistantMessage([fauxToolCall('add', { a: 17, b: 25 })], { stopReason: 'toolUse' }), fauxAssistantMessage([fauxText('17 + 25 = 42。')]),]);
// 一个真工具:参数用 TypeBox 描述,execute 里是真逻辑const addTool = { name: 'add', label: 'Add', description: 'Add two numbers', parameters: Type.Object({ a: Type.Number(), b: Type.Number() }), execute: async (toolCallId, params) => ({ content: [{ type: 'text', text: String(params.a + params.b) }], details: {}, }),};
const agent = new Agent({ initialState: { systemPrompt: 'You are helpful.', model: faux.getModel(), tools: [addTool] }, streamFn: models.streamSimple.bind(models),});
agent.subscribe((event) => { if (event.type === 'message_update') return; // 流式增量太多,略过 if (event.type === 'tool_execution_end') { console.log(`[${event.type}] add ->`, event.result.content[0].text); return; } console.log(`[${event.type}]`, event.message?.role ?? '');});
await agent.prompt('17 加 25 等于几?');console.log('对话记录:', agent.state.messages.map((m) => m.role).join(' -> '));真实输出:
[agent_start][turn_start][message_start] user[message_end] user[message_start] assistant[message_end] assistant[tool_execution_start][tool_execution_end] add -> 42[message_start] toolResult[message_end] toolResult[turn_end] assistant[turn_start][message_start] assistant[message_end] assistant[turn_end] assistant[agent_end]对话记录: user -> assistant -> toolResult -> assistant这一屏输出就是 agent 循环的全部骨架,注意三件事:
- 循环的燃料是事件。模型每说一个字、工具每动一步,都有一个事件。UI 层(终端、网页)只需要订阅事件流,不用关心循环内部。
- 工具结果是一条消息。
toolResult和user、assistant平级,按顺序进入对话记录——这就是「把工具结果喂回模型」的具体形态。 - 循环转了两轮才停。第一轮模型要工具,第二轮看到结果后总结。退出条件是「这轮没有工具调用,也没有排队消息」。
什么是 turn一次「模型回复 + 它引发的全部工具执行」叫一个 turn。上面的输出有两个 turn:第一个 turn 里模型要求调工具,第二个 turn 里模型纯文本收尾。
下面开始读源码,看这些事件是谁、在什么时刻发出来的。
循环本体:两个 while 在转什么
循环在 agent-loop.ts 的 runLoop() 里。删掉事件发射和细节后,骨架是这样的:
// packages/agent/src/agent-loop.ts(删减版,保留控制流)async function runLoop(initialContext, newMessages, config, signal, emit, streamFn) { let currentContext = initialContext; // 循环开始前就查一次:用户可能在等待时已经输入了下一句 let pendingMessages = (await config.getSteeringMessages?.()) || [];
while (true) { // 外层:只管 follow-up let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) { // 内层:转 turn // 1. 先把排队消息注入上下文 if (pendingMessages.length > 0) { for (const message of pendingMessages) { currentContext.messages.push(message); newMessages.push(message); } pendingMessages = []; }
// 2. 问模型,流式收一条 assistant 消息 const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
// 3. 模型出错或被中断:收尾,整个循环结束 if (message.stopReason === "error" || message.stopReason === "aborted") { /* emit turn_end + agent_end */ return; }
// 4. 模型要调工具就执行,结果写回上下文 const toolCalls = message.content.filter((c) => c.type === "toolCall"); hasMoreToolCalls = false; if (toolCalls.length > 0) { const batch = /* 执行整批工具 */; for (const result of batch.messages) { currentContext.messages.push(result); // toolResult 回到上下文 } hasMoreToolCalls = !batch.terminate; }
// 5. turn 结束,给用户纠偏的机会 pendingMessages = (await config.getSteeringMessages?.()) || []; }
// 内层停了 = 模型不要工具了。看看有没有 follow-up 要接着干 const followUpMessages = (await config.getFollowUpMessages?.()) || []; if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; // 回内层再转 } break; // 真没事了,退出 } // emit agent_end}对比前面的十几行伪代码,结构上的增量就是两个 while:
为什么需要两层?因为它们对应两种不同语义的「别停」:
- 内层回答「模型还要不要继续」——工具结果回来了,得让模型再看一眼。
- 外层回答「用户还有没有追加任务」——模型本来要停了,但你提前排了一句「完事后把结果也发我邮箱」,循环就得续上。
单 while 也能写,但「中途纠偏」和「结束后追加」两个时机就混在一起了。拆成两层后,steering 只在内层轮末被检查(此时当前 turn 的工具已全部执行完),follow-up 只在循环本来要停时被检查——两个注入点的语义各自唯一。这个区分讲队列时还会用到。
另外注意第 3 步:stopReason 是 "error" 或 "aborted" 时,循环不是抛异常,而是正常地走 turn_end → agent_end 收尾。错误是这个协议里的一等公民,不是异常路径。 这个设计有什么好处,后面讲 Agent 类时展开。
每次问模型前,消息要过两道筛子
runLoop 里真正跟模型说话的只有 streamAssistantResponse()。它开头做了两件事:
async function streamAssistantResponse(context, config, signal, emit, streamFunction) { // 第一道:可选的上下文变换(AgentMessage[] → AgentMessage[]) let messages = context.messages; if (config.transformContext) { messages = await config.transformContext(messages, signal); }
// 第二道:转成 LLM 认得的消息格式(AgentMessage[] → Message[]) const llmMessages = await config.convertToLlm(messages);
const llmContext: Context = { systemPrompt: context.systemPrompt, messages: llmMessages, tools: context.tools, }; // ...调 streamFunction(model, llmContext, ...)}两道筛子的职责不一样:
transformContext决定「这次让模型看到什么」:上下文快满了就裁掉旧消息,或者注入外部信息。这就是「上下文治理」的挂载点。convertToLlm决定「这些消息里哪些是模型能懂的」:LLM 只认识user/assistant/toolResult三种角色,应用在界面上显示的通知、状态条之类的自定义消息必须在这一步过滤掉或改写。
默认实现就是一行 filter:
function defaultConvertToLlm(messages: AgentMessage[]): Message[] { return messages.filter( (message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult", );}把「应用的消息列表」和「发给模型的消息列表」分开,是小循环能服务大产品的关键:界面可以往 transcript 里塞各种 UI 状态,模型那边永远只收到干净的三种角色。每次调用都重新过一遍筛子,也意味着上下文治理是逐轮生效的,不是会话开始时一次性决定的。
流式部分也值得看一眼:每收到一个增量事件,就更新上下文里那条「半成品」assistant 消息,同时向外发 message_update:
case "text_delta": // thinking_delta、toolcall_delta 等同理 if (partialMessage) { partialMessage = event.partial; context.messages[context.messages.length - 1] = partialMessage; // 原位替换 await emit({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } }); } break;context.messages 里始终躺着一条随流式增长的部分消息,直到 done 事件把它替换成最终版。所以你中途按 Ctrl+C,上下文里不会缺一条消息,只有一条不完整的。
工具执行链:模型说了不算
模型「要求」调工具,到工具「真的被执行」,中间隔着一条流水线。这段是 agent-loop.ts 里篇幅最大的部分,拆开看每一步都在防一种具体的坏情况。
执行前:三道检查
// packages/agent/src/agent-loop.ts → prepareToolCall()(删减)const tool = currentContext.tools?.find((t) => t.name === toolCall.name);if (!tool) { return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true };}// 1. 工具自带的参数修复(可选)const preparedToolCall = prepareToolCallArguments(tool, toolCall);// 2. schema 校验:参数必须符合工具声明的 TypeBox 定义const validatedArgs = validateToolArguments(tool, preparedToolCall);// 3. beforeToolCall 钩子:应用层可以在这里拦下这次调用if (config.beforeToolCall) { const beforeResult = await config.beforeToolCall({ assistantMessage, toolCall, args: validatedArgs, context }, signal); if (beforeResult?.block) { return { kind: "immediate", result: createErrorToolResult(beforeResult.reason || "..."), isError: true }; }}模型想调不存在的工具、参数不符合 schema、应用层在 beforeToolCall 里拒绝——三种情况都不会执行工具,而是直接生成一条错误 toolResult 送回模型。模型看到的永远是结果,不管这个结果是真执行出来的还是被拦截生成的。 模型下一轮看到「工具不存在」或「被阻止」,会自己调整策略。
这里把「谁说了算」分得很清:模型只负责提议动作,放行与否的仲裁点在 runtime 手里。记住这条原则,后面看 Claude Code 时会发现它把这条原则做成了一整座工厂。
执行中:失败不抛出
工具的 execute 抛了异常怎么办?抓住,变成错误结果:
// packages/agent/src/agent-loop.ts → executePreparedToolCall()(删减)try { const result = await prepared.tool.execute(toolCall.id, args, signal, onUpdate); return { result, isError: false };} catch (error) { return { result: createErrorToolResult(error instanceof Error ? error.message : String(error)), isError: true, };}工具失败是正常业务路径——文件不存在、命令报错都是家常便饭——所以异常在这里被翻译成带 isError: true 的 toolResult,喂回模型让它自己收拾。循环本身继续转。
一个容易漏掉的边界:被 token 上限截断的工具调用
先给一个具体场景:模型调用 write_file 写一个 500 行的文件,但 max_tokens 只够输出 200 行。工具参数是随流逐块吐出的 JSON 字符串,流在第 200 行处物理中断,stopReason === "length"。
接下来是一条三个环节缺一不可的问题链:
- 吐出的参数串是不完整的 JSON——引号和括号都没闭合,本来无法解析;
- 但流式解析器会「抢救」残缺 JSON:自动补上引号和括号,强行解析成对象(pi-ai 里的
parseStreamingJson干的就是这个)。残肢就这样变成了合法 JSON:{"path": "app.ts", "content": "……只写到第 200 行"}; - 这份参数能通过 schema 校验——
path是 string ✓、content是 string ✓。schema 只检查「形状」,检查不了「内容是否完整」。
后果是:没有额外防护的话,write_file 会拿着半份内容覆盖 app.ts,然后报告「执行成功」。最可怕的不是报错,而是一切看起来正常——文件已被悄悄损坏。
pi 的选择是整批拒绝执行:
const executedToolBatch = message.stopReason === "length" ? await failToolCallsFromTruncatedMessage(toolCalls, emit) // 全部标错,不执行 : await executeToolCalls(currentContext, message, config, signal, emit);每个工具收到一条错误结果,大意是「响应触达 token 上限,参数可能被截断,请用完整参数重新发起调用」。这条 toolResult 正常进入上下文,循环继续(返回 terminate: false),模型下一轮看到错误后会自己重发完整调用——截断被翻译成了一个模型可自我恢复的错误。
为什么整批拒绝,而不是只拒最后一个?
- 截断点无法可靠定位:不同内容块的流式增量到达顺序随协议而异,被「抢救」的可能不止一个 call,且每个都被修复得看起来合法——手里没有可信的「完好」标记;
- 一批工具调用通常是模型的整体计划,执行一半、拒绝一半会留下半成品副作用,比全部重来更难收拾;
- 成本不对称:拒绝的代价是一次重试,错执行的代价是悄悄损坏的文件。
自己写循环时,这一节的 checklist 只有一行:过滤出 toolCall 之后,先查 stopReason === "length" 整批拒绝,再校验参数,再执行。这个 if 不难写,难的是知道需要写它。
并行是怎么实现的
模型一轮可以要求调多个工具,默认并行执行。但不是简单粗暴地 Promise.all,而是预检串行、执行并行:
// packages/agent/src/agent-loop.ts → executeToolCallsParallel()(删减)const finalizedCalls = [];for (const toolCall of toolCalls) { const preparation = await prepareToolCall(...); // 逐个串行预检 if (preparation.kind === "immediate") { finalizedCalls.push(/* 已失败的结果 */); // 没通过预检的,直接定案 continue; } finalizedCalls.push(async () => { // 通过预检的,包成 thunk 先不执行 const executed = await executePreparedToolCall(preparation, signal, emit); const finalized = await finalizeExecutedToolCall(...); // 跑 afterToolCall 钩子 await emitToolExecutionEnd(finalized, emit); // 谁先完成谁先报 return finalized; });}// 全部预检完成后,才真正并发启动const orderedFinalizedCalls = await Promise.all( finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))),);// toolResult 消息仍按模型输出的原始顺序落进上下文这个拆分很讲究:预检(校验 + beforeToolCall)串行,保证每个工具在启动前看到的上下文是一致的;执行并发,谁先完成谁先发出 tool_execution_end 事件,UI 能实时更新;但写回对话记录的 toolResult 顺序永远按模型输出的原始顺序,跟完成先后无关——否则同一个批次在不同运行下会产生不同的对话历史,行为就不可复现了。
如果批次里任何一个工具声明了 executionMode: "sequential",整批退化为逐个执行。写文件、跑 git 这类有顺序要求的工具就靠这个声明自保。
Agent 类:把事件流折回成状态
到目前为止的循环是无状态的低层 API:agentLoop() 只发事件,不存东西。Agent 类(agent.ts)做的事用一句话概括:把流过的事件 reduce 成一份可查询的状态,再把事件原样转发给订阅者。
核心是这个 reducer:
// packages/agent/src/agent.ts → processEvents()(删减)private async processEvents(event: AgentEvent): Promise<void> { switch (event.type) { case "message_start": case "message_update": this._state.streamingMessage = event.message; // 正在流式输出的那条 break; case "message_end": this._state.streamingMessage = undefined; this._state.messages.push(event.message); // 完成的消息落进 transcript break; case "tool_execution_start": { const pending = new Set(this._state.pendingToolCalls); pending.add(event.toolCallId); this._state.pendingToolCalls = pending; // 正在执行的工具集合 break; } // ... } // 先更新完内部状态,再按注册顺序逐个 await 订阅者 for (const listener of this.listeners) { await listener(event, signal); }}两个细节:
- 先改状态,再通知。订阅者收到事件时读
agent.state一定是最新的,不会出现「事件到了但状态还没更新」的竞态。 - 订阅者是 await 的。事件处理没完成,循环不往下走。这让你可以在
message_end里做落盘之类的慢操作而不用担心时序;代价是慢订阅者会拖慢整个循环。
出错时为什么要合成一条假消息
Agent 出错时的处理是整个设计里我最推荐抄的一段。模型请求整个失败(网络断了、循环抛异常)时,它不是简单地把异常抛给调用方,而是合成一条 stopReason: "error" 的 assistant 消息,然后补发完整的事件收尾序列:
// packages/agent/src/agent.ts → handleRunFailure()(删减)private async handleRunFailure(error: unknown, aborted: boolean): Promise<void> { const failureMessage = { role: "assistant", content: [{ type: "text", text: "" }], stopReason: aborted ? "aborted" : "error", errorMessage: error instanceof Error ? error.message : String(error), usage: EMPTY_USAGE, // ...api/provider/model/timestamp }; await this.processEvents({ type: "message_start", message: failureMessage }); await this.processEvents({ type: "message_end", message: failureMessage }); await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] }); await this.processEvents({ type: "agent_end", messages: [failureMessage] });}为什么这么做?因为 UI 渲染逻辑只需要处理「消息开始 → 更新 → 结束」这一套序列。如果错误走 throw,UI 就得写第二套错误渲染路径,还要自己清理「有 start 没 end」的半吊子状态。把失败翻译成协议内的消息,任何一次运行,无论成功、失败还是被中断,事件序列在结构上都是完整闭合的。重试也因此变得简单:agent.continue() 从现有上下文接着跑就行,不用修补历史。
这跟模型层「streamFn 不许 throw,错误必须编码进事件流」的约定是同一条设计原则的两端:
中途打断:steer 和 followUp 两条队列
回到开头列的边界问题之一:agent 跑到一半,用户输入了新的话,该怎么办?直接打断会弄死正在执行的工具;完全排队又显得死板。pi 把「中途输入」拆成两种意图,各配一条队列:
class PendingMessageQueue { private messages: AgentMessage[] = []; public mode: QueueMode; // "one-at-a-time"(默认)| "all"
drain(): AgentMessage[] { if (this.mode === "all") { const drained = this.messages.slice(); this.messages = []; return drained; // 全放出来 } const first = this.messages[0]; this.messages = this.messages.slice(1); return first ? [first] : []; // 一次只放最旧的一条 }}steer():纠偏。当前 turn 的工具全部执行完后、下一次模型请求前注入。「方向错了,别继续挖了」属于这种。followUp():追加。只在 agent 本来要停的时候才被取走。「完事之后再顺手做件事」属于这种。
这就是为什么循环要拆成两个 while:steering 队列在内层轮末被检查,follow-up 队列只在外层、循环将停时被检查——队列的语义和循环结构一一咬合。默认 one-at-a-time 模式一次只放一条,剩下留着下轮再放,防止连发五条消息把上下文搅乱。
abort() 则是另一条路:整个 run 挂在一个 AbortController 上,信号传到模型请求和每个工具的 execute,各自负责体面收场。
到这里,一个生产级 agent 循环的全部组成件都拆完了:双 while、两道筛子、工具仲裁链、事件协议、状态 reducer、打断队列。pi 对开头那串边界问题的回答可以总结成一句话:循环保持最小,每个边界开一个口子(hook),交多厚的答卷由用的人决定。
同一个循环,另外三种答卷
pi 只是答卷之一,而且刻意答得最薄。下面三家是 2026 年的热门选手,与 pi 地位相当——循环同构,选择不同。源码可见性本身就值得先说:Grok Build(xai-org/grok-build)和 OpenClaw(openclaw/openclaw)的源码在 GitHub 上完整公开,下面直接按源码讲;Claude Code 的官方仓库只有文档、插件和示例,runtime 不在其中,只能从官方文档和发布二进制确认。
Claude Code:把「模型说了不算」做成一座工厂
Claude Code(本机 2.1.202)官方文档把自己的循环定义为 gather context → take action → verify results → repeat——就是开头那个最小循环。从二进制内嵌代码和公开类型里能确认它的答卷风格:在「模型提议」和「工具执行」之间建一条长仲裁链——schema 校验 → 工具自查 → PreToolUse 钩子 → 权限规则(deny/ask/allow)→ 权限模式 → 独立的安全分类器模型 → 用户确认,然后才轮到 tool.call。
它在并行调度上也选了更细粒度的答案:每个工具声明 isConcurrencySafe(input),调度器按具体入参判断这次调用能不能并行,不安全的调用形成局部屏障——pi 是「批次里有一个 sequential 工具就整批串行」,Claude Code 则让前面的安全工具继续并行。plan mode、任务依赖图、subagent、两阶段上下文压缩,全部内置成产品级状态机。同一份边界问题清单,它每一项都答满了。
Grok Build:给循环装上「卡死检测器」
Grok Build 是 Rust 实现,主循环在 crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs(3300 余行)。骨架和 pi 同构:一个带 loop_index 的大 loop,每圈开头发 LoopStarted 事件,然后依次做几件 pi 里没有的事:
// turn.rs(删减版,保留每圈开头的检查顺序)loop { self.emit_event(Event::LoopStarted { loop_index }); loop_index += 1; // 1. 卡死检测:同一个工具用同样参数连续调太多次,硬停或提醒 if identical_tool_calls.run_len >= identical_tool_calls.hard_stop_threshold() { return Ok(TurnOutcome::StationarityEnded { ... }); } if identical_tool_calls.take_nudge() { /* 注入「你卡在轮询里了」的 system reminder */ } // 2. 把中途插话排进循环的安全点(对应 pi 的 steer) self.drain_interjections_at_safe_point().await; // 3. 上下文将满先做压缩,再问模型 if let Some(trigger) = self.check_auto_compact_needed().await { /* run_compact_only */ } // ...构造请求、问模型、有 tool call 就 execute_tool_calls}第 1 步是 Grok 最有辨识度的一笔,叫 action stationarity(动作停滞)检测:跟踪「同一工具 + 同一参数」连续出现的次数,分级处理——普通工具第 8 次提醒、第 12 次硬停;读文件、写 todo 这类「重复几乎没有收益」的工具阈值更紧(第 4 次提醒、第 8 次硬停)。源码注释里留着这条机制的由来:线上真实发生过一个 turn 里 todo_write 总共被调 224 次、其中连续 12 次参数字节级完全相同的事故。模型卡死循环不是理论问题,是每家都会踩的生产事故。
模型给出最终回答、循环准备收尾时,还有两个 pi 没有的闸门:一是 TodoGate——todo 列表里还有 pending 项就把模型 nudge 回去继续干活(有次数上限,封顶后放行);二是在收尾簿记前后各排一次插话队列,防止用户在收尾瞬间输入的话被丢掉。权限侧则是 Ask / Auto(LLM 分类器)/ Always approve 三档加 hooks,对标 Claude Code 的产品形态。
OpenClaw:把 pi 的循环 fork 走,打上自己的补丁
OpenClaw 和前三家不一样的地方在于:它的循环不是「同构」,而是直接的后代。packages/agent-core/src/agent-loop.ts(1936 行)就是 pi 那个 runLoop 的fork版——同样的双 while、同样的 steering/follow-up 钩子,连注释原文都还在(// Outer loop: continues when queued follow-up messages arrive after agent would stop)。
有看头的是它在 pi 骨架上打的补丁,每个补丁都对应一个真实场景:
- 截断参数的另一种答案:只在
stopReason === "toolUse"时才派发工具(length截断的调用直接不执行),pi 则是整批标错送回模型让它重发。两种都成立,取舍不同。 - 中断检查织进循环:
stopIfAborted()在循环里出现了 5 处,一旦 abort 就合成一条 aborted 消息、补齐完整的收尾事件序列。pi 把这件事放在Agent类的出错处理里做一次,OpenClaw 把它埋进每一圈。 - steering 到达时跳过剩余工具:用户纠偏消息一到,当前批次里还没执行的工具直接标记
"Skipped due to queued user message.",不再执行——pi 是等当前批次跑完才注入。接消息渠道的异步场景里,这个选择更激进也更合理。 - 防死循环的自家版本:检测到
critical-tool-loop(严重工具死循环)时干预恢复一次;再犯就终止整个 run。动机和 Grok 的 stationarity 完全一样。 - 一个并发竞态修复:follow-up 队列排空后、发
agent_end之前,再复查一次 steering——注释写着so agent_end cannot strand an accepted steer,即「收尾瞬间到达的用户输入不能被晾在队列里」。
它的产品形态也回答了另一个维度的问题:循环不变,但 agent 不一定活在终端里——OpenClaw 把 agent 接到 Telegram 等消息渠道做个人助手,收到一条消息就是一次 prompt,回复从聊天软件里出来。Armin Ronacher 的 Pi: The Minimal Agent Within OpenClaw 讲的就是这个血统;pi 的产品观之前写过:
四种答卷摆在一起
| 边界问题 | pi 0.84.2 | Claude Code 2.1.x | Grok Build(07b2f714) | OpenClaw(a1f8e1b0) |
|---|---|---|---|---|
| 循环实现 | TS,MIT 全开源 | TS 编译成 Bun 二进制 | Rust,源码公开 | TS,fork 自 pi 的循环 |
| 工具仲裁 | 两个薄钩子,厚度自定 | 钩子+权限规则+分类器+用户 长链 | hooks + 权限模式 + LLM 分类器 | 沿用 pi 钩子 + 整批准入 |
| 中途输入 | steer / followUp 双队列 | 中断 + 动作后纠偏 | interjection,Steer / Queue 双模式 | steering,可跳过剩余工具 |
| 截断的工具参数 | 整批标错送回模型 | — | — | 不派发(只认 toolUse) |
| 防死循环 | 不内置 | — | action stationarity 分级阈值 | critical-tool-loop 恢复一次后终止 |
| 模型想提前收尾 | 不拦 | — | TodoGate 拦截 nudge | — |
| plan / subagent | 不内置,extension 组合 | 全部内置 | 内置 | 按需组合 |
证据等级说明pi、Grok Build、OpenClaw 的结论全部来自 GitHub 公开源码(commit 见表格),可直接复核。Claude Code 的 runtime 源码不在公开仓库中,描述来自官方文档和发布二进制内嵌代码,只能确认机制存在与大致结构;表中「—」表示没有拿到可复核的证据,不是没有该机制。
把四份答卷横着看,有两个趋同点很有意思。一是**「模型卡死循环」家家都防**:Grok 有分级阈值,OpenClaw 有一次性恢复,连触发后的文案都相似(「你看起来卡在轮询里了」)——这不是抄不抄的问题,是线上事故教出来的。二是收尾瞬间的输入竞态家家都修:Grok 在收尾簿记前后各排一次插话队列,OpenClaw 在 agent_end 前复查 steering——同一个坑,两家独立踩过又各自填上。
没有哪家是标准答案。仲裁链拉满换来的是企业级的可控性和相应的产品复杂度;pi 式的薄核心换来的是可读、可改、可组合;Grok 和 OpenClaw 的卡死检测是在为「模型会偷懒、会卡壳」这个现实买单。选型时真正要问的是:你的场景需要多厚的控制平面。
能带走的判断
如果你要自己动手写一个 agent 循环,无论用什么语言、接什么模型,这五条是共通的:
- 循环就是两个 while:内层管「模型还要不要继续」,外层管「用户还有没有追加」。退出条件写成「没有工具调用且所有队列为空」,打断和追加自然有了注入点。
- 一切皆是事件,包括错误。失败合成一条正常消息、补发完整的收尾序列,比
throw好处理得多——UI、日志、重试逻辑都只需要一套路径。 - 应用消息和模型消息分开。自定义消息类型随便加,但发给模型前过一道筛子,保证模型只看到它能懂的几种角色。
- 工具执行前要有仲裁点,参数残缺要整批拒收。模型提议不等于执行;被 token 上限截断的参数是不可信的。
- 并行时结果落库顺序必须确定。事件可以按完成顺序发,但写回对话历史的结果要按模型输出的原始顺序,否则行为不可复现。
循环同构,控制平面分化——这就是 2026 年 agent 产品的真实格局。十几行的循环谁都会写,拉开差距的从来是边界。
想自己读源码git clone https://github.com/earendil-works/pi.git # 本文精读:packages/agent/src/git clone https://github.com/xai-org/grok-build.git # 主循环:crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rsgit clone https://github.com/openclaw/openclaw.git # 主循环:packages/agent-core/src/agent-loop.ts
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时