mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
6355 字
17 分钟
Agent 循环是怎样设计的:从 pi 源码到 Claude Code、Grok、OpenClaw
2026-08-23

「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 家厂商统一成一个 streamFn
pi-agent-core 循环层:Agent 类 + agentLoop,本文标本
├─ agent-loop.ts 循环本体(796 行)
├─ agent.ts 有状态封装(592 行)
├─ types.ts 全部契约(443 行)
└─ harness/ 再往上:session、压缩、skills(本文不展开)
pi-coding-agent 产品层:终端 UI、工具实现、extension
earendil-works
/
pi
Waiting for api.github.com...
00K
0K
0K
Waiting...

模型层那 40 家厂商怎么收敛成一个接口,之前拆过:

拆解 @earendil-works/pi-ai:统一 40 家 LLM 的三层设计
pi 的模型层怎么用 Provider / Models / API 实现三层切开 40 家厂商的差异,附一个不用 API key 就能跑通的 agent 循环
2026-08-22开发#Pi#LLM#TypeScript
读源码前固定版本

本文固定的版本:pi 0.84.2(commit a69bef789)、Grok Build 源码 commit 07b2f714、OpenClaw 源码 commit a1f8e1b0、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 循环的全部骨架,注意三件事:

  1. 循环的燃料是事件。模型每说一个字、工具每动一步,都有一个事件。UI 层(终端、网页)只需要订阅事件流,不用关心循环内部。
  2. 工具结果是一条消息toolResultuserassistant 平级,按顺序进入对话记录——这就是「把工具结果喂回模型」的具体形态。
  3. 循环转了两轮才停。第一轮模型要工具,第二轮看到结果后总结。退出条件是「这轮没有工具调用,也没有排队消息」。
什么是 turn

一次「模型回复 + 它引发的全部工具执行」叫一个 turn。上面的输出有两个 turn:第一个 turn 里模型要求调工具,第二个 turn 里模型纯文本收尾。

下面开始读源码,看这些事件是谁、在什么时刻发出来的。

循环本体:两个 while 在转什么#

循环在 agent-loop.tsrunLoop() 里。删掉事件发射和细节后,骨架是这样的:

// 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

graph TD A["prompt 进入"] --> B{"内层 while:<br/>还有工具调用或排队消息?"} B -->|有| C["注入排队的 steering 消息"] C --> D["问模型,流式收回复"] D -->|有 toolCall| E["执行工具,结果写回上下文"] E --> B D -->|纯文本| F{"steering 队列有消息?"} F -->|有| B F -->|空| G{"外层:follow-up 队列有消息?"} G -->|有,搬进内层| B G -->|空| H["agent_end,本轮结束"]

为什么需要两层?因为它们对应两种不同语义的「别停」:

  • 内层回答「模型还要不要继续」——工具结果回来了,得让模型再看一眼。
  • 外层回答「用户还有没有追加任务」——模型本来要停了,但你提前排了一句「完事后把结果也发我邮箱」,循环就得续上。

单 while 也能写,但「中途纠偏」和「结束后追加」两个时机就混在一起了。拆成两层后,steering 只在内层轮末被检查(此时当前 turn 的工具已全部执行完),follow-up 只在循环本来要停时被检查——两个注入点的语义各自唯一。这个区分讲队列时还会用到。

另外注意第 3 步:stopReason"error""aborted" 时,循环不是抛异常,而是正常地走 turn_endagent_end 收尾。错误是这个协议里的一等公民,不是异常路径。 这个设计有什么好处,后面讲 Agent 类时展开。

每次问模型前,消息要过两道筛子#

runLoop 里真正跟模型说话的只有 streamAssistantResponse()。它开头做了两件事:

packages/agent/src/agent-loop.ts
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:

packages/agent/src/agent.ts
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: truetoolResult,喂回模型让它自己收拾。循环本身继续转。

一个容易漏掉的边界:被 token 上限截断的工具调用#

先给一个具体场景:模型调用 write_file 写一个 500 行的文件,但 max_tokens 只够输出 200 行。工具参数是随流逐块吐出的 JSON 字符串,流在第 200 行处物理中断,stopReason === "length"

接下来是一条三个环节缺一不可的问题链:

  1. 吐出的参数串是不完整的 JSON——引号和括号都没闭合,本来无法解析;
  2. 但流式解析器会「抢救」残缺 JSON:自动补上引号和括号,强行解析成对象(pi-ai 里的 parseStreamingJson 干的就是这个)。残肢就这样变成了合法 JSON:{"path": "app.ts", "content": "……只写到第 200 行"}
  3. 这份参数能通过 schema 校验——path 是 string ✓、content 是 string ✓。schema 只检查「形状」,检查不了「内容是否完整」。

后果是:没有额外防护的话,write_file 会拿着半份内容覆盖 app.ts,然后报告「执行成功」。最可怕的不是报错,而是一切看起来正常——文件已被悄悄损坏。

pi 的选择是整批拒绝执行

packages/agent/src/agent-loop.ts
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,错误必须编码进事件流」的约定是同一条设计原则的两端:

拆解 @earendil-works/pi-ai:统一 40 家 LLM 的三层设计

中途打断:steer 和 followUp 两条队列#

回到开头列的边界问题之一:agent 跑到一半,用户输入了新的话,该怎么办?直接打断会弄死正在执行的工具;完全排队又显得死板。pi 把「中途输入」拆成两种意图,各配一条队列:

packages/agent/src/agent.ts
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 和 DeepSeek Harness 简要对比
模型和真实世界之间那一层该长成什么形状:Pi 把 loop 当产品,DSH 把可替换做成内核原语
2026-08-21开发#Pi#DeepSeek#Harness

四种答卷摆在一起#

边界问题pi 0.84.2Claude Code 2.1.xGrok 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 循环,无论用什么语言、接什么模型,这五条是共通的:

  1. 循环就是两个 while:内层管「模型还要不要继续」,外层管「用户还有没有追加」。退出条件写成「没有工具调用且所有队列为空」,打断和追加自然有了注入点。
  2. 一切皆是事件,包括错误。失败合成一条正常消息、补发完整的收尾序列,比 throw 好处理得多——UI、日志、重试逻辑都只需要一套路径。
  3. 应用消息和模型消息分开。自定义消息类型随便加,但发给模型前过一道筛子,保证模型只看到它能懂的几种角色。
  4. 工具执行前要有仲裁点,参数残缺要整批拒收。模型提议不等于执行;被 token 上限截断的参数是不可信的。
  5. 并行时结果落库顺序必须确定。事件可以按完成顺序发,但写回对话历史的结果要按模型输出的原始顺序,否则行为不可复现。

循环同构,控制平面分化——这就是 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.rs
git clone https://github.com/openclaw/openclaw.git # 主循环:packages/agent-core/src/agent-loop.ts
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Agent 循环是怎样设计的:从 pi 源码到 Claude Code、Grok、OpenClaw
https://l1ngg.info/posts/tech/agent-loop-design/
作者
L1ngg
发布于
2026-08-23
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录