1. Agent Loop
July 9, 2026 · View on GitHub
本章目标
造出 coding agent 的心脏:一个循环,不停地「调模型 → 看它要不要用工具 → 用完把结果喂回去 → 再调模型」,直到模型说任务做完了。
起点只有一个十几行的循环,第一次跑它只会聊天、读不了文件;补上一小段工具回路,读文件这类活才走得通。走完这一步,再回头看真实 Claude Code 的循环,多出来的复杂都在解决什么就清楚了。
graph TB
subgraph Agent Loop
A[用户消息] --> B[调用 LLM API]
B --> C{响应包含<br/>tool_use?}
C -->|是| D[执行工具]
D --> E[工具结果推入消息]
E --> B
C -->|否| F[输出文本<br/>结束循环]
end
style B fill:#7c5cfc,color:#fff
style D fill:#e8e0ff
▶ 跑这一章:
node steps/run.mjs 1(无需 API key,走本地 mock 模型)。加--py跑 Python 版。想拿自己的 prompt 连真实模型,就加--live(读.env里的 key)。
第一版:一个只会聊天的循环
先写出最笨的版本。把用户的话加进消息数组,调一次模型,把回复打印出来——就这些:
async function chatOnce(messages, userMessage) {
messages.push({ role: "user", content: userMessage });
const response = await client.messages.create({
model: "claude-...",
max_tokens: 4096,
messages,
});
const text = response.content.find(b => b.type === "text")?.text ?? "";
console.log(text);
messages.push({ role: "assistant", content: response.content });
}
跑起来它确实能聊天。问它「解释一下什么是快速排序」,答得头头是道。可一旦让它干正事,问题就来了:
> 读一下 src/agent.ts,讲讲主循环
我没有办法直接读取你的文件。如果你把内容贴过来,我可以帮你分析……
不是它不肯,是根本没给它手。模型只能吐出文本,而这段代码只会把文本打印掉。它想读文件、想跑命令,可这条路一步都走不通——请求里没告诉它有哪些工具,就算它想调,也没有接住它、真去执行、再把结果递回去的那一环。
给循环装上手:工具回路
要让模型能动手,只差两件事。一是在请求里带上工具清单,告诉它有哪些工具可调——第 1 章先只给一个 read_file,下一章再补齐写文件、跑命令等;二是当它回复里带着「我要调 read_file」时,我们真的去执行,把结果作为下一条消息喂回去,然后再调一次模型,让它接着往下走。
这两件事就是那个 while 循环的由来。把上面的 chatOnce 改成这样:
async function chat(messages, userMessage) {
messages.push({ role: "user", content: userMessage });
while (true) {
const response = await client.messages.create({
model: "claude-...",
max_tokens: 4096,
messages,
tools: toolDefinitions, // ← 就多这一行:把工具清单发过去,模型才知道有哪些工具能调
});
messages.push({ role: "assistant", content: response.content });
// 挑出模型这一轮想调用的工具
const toolUses = response.content.filter(b => b.type === "tool_use");
if (toolUses.length === 0) break; // 一个都没调 → 它认为任务完成了,退出
// 逐个执行,把结果收集起来
const toolResults = [];
for (const toolUse of toolUses) {
printToolCall(toolUse.name, toolUse.input);
const result = await executeTool(toolUse.name, toolUse.input);
printToolResult(toolUse.name, result);
toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: result });
}
// 结果作为一条 user 消息喂回去,循环回到开头,模型接着往下想
messages.push({ role: "user", content: toolResults });
}
}
比第一版多的就两处:请求里多了 tools: toolDefinitions(让模型知道有哪些工具),外面套了个 while(工具跑完把结果喂回去、再问一轮)。刚才读不了文件的同一个问题,现在走得通了。
决定循环转不转的,从头到尾是模型,不是我们的代码。 我们没写任何「如果是读文件请求就……」的分支——是模型自己决定这一步要不要动手、动手之后够不够、要不要再来一轮。这一点就是 agent 和聊天机器人的分界线。
到这里,可运行的最小版就成型了。上面为讲清概念用的是几个自由函数,真代码把它们收进一个 Agent 类,对应关系就三点:messages 变成实例上的 this.messages、client 收进 Agent 构造函数、executeTool 和 toolDefinitions 从 tools.ts 引进来(第 1 章的 tools.ts 里只有 read_file,下一章补齐其余工具)。下面这段就是 steps 里第 1 章的 Agent.chat,两种语言一一对应,真能跑:
TypeScript
async chat(userText: string): Promise<void> {
this.messages.push({ role: "user", content: userText });
while (true) {
let system = SYSTEM_PROMPT;
// Build the request once. Passing `tools` is the one line that makes the
// model tool-aware. Chapter 5 turns the call itself into a stream.
const request = {
model: MODEL,
max_tokens: 4096,
system,
tools: toolDefinitions,
messages: this.messages,
};
const reply = await this.client.messages.create(request);
for (const block of reply.content) {
if (block.type === "text") process.stdout.write(block.text);
}
process.stdout.write("\n");
// Record the assistant's full reply (text + any tool calls).
this.messages.push({ role: "assistant", content: reply.content });
const toolUses = reply.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
);
// No tool calls means the model is done with this turn.
if (toolUses.length === 0) return;
// Run every requested tool and send the outputs back as one user message.
const results: Anthropic.ToolResultBlockParam[] = [];
for (const tu of toolUses) {
console.log(` → ${tu.name}(${JSON.stringify(tu.input)})`);
const output = await executeTool(tu.name, tu.input as Record<string, any>);
results.push({ type: "tool_result", tool_use_id: tu.id, content: output });
}
this.messages.push({ role: "user", content: results });
}
}
Python
def chat(self, user_text: str) -> None:
self.messages.append({"role": "user", "content": user_text})
while True:
system = SYSTEM_PROMPT
tools = tool_definitions
kwargs = dict(model=MODEL, max_tokens=4096, system=system, tools=tools, messages=self.messages)
reply = self.client.messages.create(**kwargs)
for block in reply.content:
if block.type == "text":
print(block.text, end="", flush=True)
print()
# Record the assistant's full reply (text + any tool calls).
self.messages.append({"role": "assistant", "content": reply.content})
tool_uses = [b for b in reply.content if b.type == "tool_use"]
# No tool calls means the model is done with this turn.
if not tool_uses:
return
# Run every requested tool; send the outputs back as one user message.
results = []
for tu in tool_uses:
print(f" → {tu.name}({json.dumps(tu.input)})")
output = execute_tool(tu.name, tu.input)
results.append({"type": "tool_result", "tool_use_id": tu.id, "content": output})
self.messages.append({"role": "user", "content": results})
▶ 现在就跑它(不用 API key,走本地 mock 模型):
$ node steps/run.mjs 1
▶ step 1 demo (no API key — local mock model) sandbox: <sandbox>
you: Read the file greeting.txt and tell me what it says.
→ read_file({"file_path":"greeting.txt"})
greeting.txt says: hello from step one.
模型收到「读 greeting.txt」,回了一句「我要调 read_file」;循环没有停,而是真去读了文件、把内容作为 user 消息喂回去、再调一次模型,这次它才给出答案。真实的 agent.ts 在这之上还叠了记忆预取、上下文压缩、流式提前执行等等,都是后面几章一层层加进去的——本章末尾再看那些。
消息数组是怎么长大的
理解这个循环,关键在看懂消息数组每一轮怎么变长:
第 1 轮:
messages = [
{ role: "user", content: "帮我修复 bug" }
{ role: "assistant", content: [text + tool_use(read_file)] }
{ role: "user", content: [tool_result("文件内容...")] }
]
第 2 轮(模型看到文件内容后决定编辑):
messages = [
...前 3 条,
{ role: "assistant", content: [text + tool_use(edit_file)] }
{ role: "user", content: [tool_result("编辑成功")] }
]
第 3 轮(模型认为任务完成):
messages = [
...前 5 条,
{ role: "assistant", content: [text("已修复!")] } ← 无 tool_use → break
]
带工具的那几轮,数组通常多两条:一条 assistant(模型要调的工具),一条 user(工具结果);最后收尾那轮模型不再调工具,只多一条 assistant 文本。模型每次都能看到从头到尾的完整历史,这就是它能「记得」自己之前做过什么的原因——所谓记忆,此刻不过是一个不断变长的数组。工具结果之所以用 role: "user" 装,是 Anthropic API 的协议要求,而且每条结果必须靠 tool_use_id 认回它对应的那次调用。
收尾:让它能停下来
本章的最小版还没处理中断——按 Ctrl+C 让它中途优雅停下来,是第 4 章接上 CLI 时才补的事。真实 Claude Code 用一个 AbortController 贯穿整个循环:abort() 一调,signal 变 aborted,循环在下一个检查点退出,连正在飞的那次网络请求也一起取消;Python 侧没有 AbortController,用一个标志加取消当前 asyncio task 达到同样效果。
真实 Claude Code 比这多做了什么
上面那个循环,判断逻辑只有一条:有 tool_use 就继续,没有就停。真实的 Claude Code 处理的情况要多得多——把它的循环拆开看,正好照出一个玩具循环和一个生产级引擎之间隔着哪些东西。
下面这些结构(层次、模块名、大致行数)来自对公开版本的分析,Anthropic 官方文档能坐实的只是 tool_use / tool_result 这条工具回路本身;内部实现细节会随版本变化,具体的名字和行数看看趋势就好,别当精确事实。
它把一层循环拆成了两层。外层 QueryEngine(约 1155 行)管整个对话的生命周期:用户输入、USD 预算、Token 统计、会话恢复;内层 queryLoop(约 1728 行)只管一次查询怎么执行:消息压缩、API 调用、工具执行、错误恢复。这样拆是为了关注点分离——外层不必操心「PTL 错误怎么恢复」,内层不必操心「用户输入怎么解析」。
它的内层循环是个异步生成器(async function*)。选生成器而不是回调,图的是两点:一是背压,消费端没处理完,生产端就不往下生成,天然不会堆积事件;二是控制流是线性的,所有分支都用普通的 continue / break 表达,不用写状态机。
「继续循环」这件事,它分了七种情况。最小版只有一种(有 tool_use 就继续),它有七种:
| # | 名称 | 什么时候 | 怎么办 |
|---|---|---|---|
| 1 | next_turn | 模型调了工具 | 执行工具,结果推回,继续 |
| 2 | collapse_drain_retry | PTL 错误,有暂存的折叠操作 | 提交折叠腾空间,重试 |
| 3 | reactive_compact_retry | PTL 错误,折叠空间还不够 | 强制全量摘要压缩,重试 |
| 4 | max_output_tokens_escalate | 输出被截断,第一次 | 升到更高 Token 上限(16K→64K),重试 |
| 5 | max_output_tokens_recovery | 输出被截断,升级已用尽 | 注入续写提示,最多重试 3 次 |
| 6 | stop_hook_blocking | 任务做完但 Stop Hook 拦下了 | 接着执行循环 |
| 7 | token_budget_continuation | API 侧 Token 预算耗尽 | 继续生成 |
我们只实现第 1 种,其余六种都是各类错误和边界的恢复策略。
可恢复的错误,它先扣着不往上抛。输出被截断时,如果直接把错误 yield 给外层,界面就会跳报错——可内层循环后面的恢复逻辑其实能自己处理。所以它先「扣留」这个错误,跑一遍恢复;成功了用户完全无感,失败了才最终暴露。大多数 max_output_tokens 和 prompt_too_long 就是这样被静默消化掉的。
它在流式响应还没结束时就开始执行工具。一个典型响应有 5 到 30 秒的流式窗口,Claude Code 用 StreamingToolExecutor 抓住这段时间:某个工具的参数 JSON 一旦拼完整,立刻开跑,不等整个响应收完。
串行(本章最小版,暂时串行):
[========= API 流式响应 =========][tool1][tool2][tool3]
并行(Claude Code):
[========= API 流式响应 =========]
↑ tool1 的 JSON 完成 → 立即执行
↑ tool2 的 JSON 完成 → 立即执行
这些都是「怎么把同一个循环做得又稳又快」的工程。地基却是同一个——上面那个最小循环,就是它们全部长在上面的地基。
下一章:循环的动力全在工具。没有工具,模型还是只能说话不能做事。下一章我们把工具系统做全,让 agent 真正能改文件、跑命令、搜代码。