Agent Loop - agent的心脏。从6行代码的while循环说起,跟你聊聊写一个agent最底层、必不可少的部分。
AgentLoop:从 while(true) 到生产级循环
先看最小内核:6 行
一个 Agent 最核心的逻辑,可以压缩到这么几行:
while (true) {
const response = await llm.chat(messages)
// 没有工具调用,说明任务完成,结束循环
if (response.toolCalls.length === 0) break
for (const toolCall of response.toolCalls) {
const result = await executeTool(toolCall)
messages.push(result) // 把工具结果告诉 LLM
}
}
我们不妨逐行拆一下,这里每一行都不是随便写的:
messages——loop 的记忆。 它是这轮循环唯一的持久化状态。模型本身是无状态的,它之所以能「记得」前三轮做了什么,全靠我们每轮把完整历史重新喂给它。
llm.chat(messages)——把完整历史喂给模型。 注意是 messages 而不是只传最后一条。这是 Agent 比 chatBot 贵得多的根本原因:上下文随轮数线性增长。
response.toolCalls.length === 0——唯一的退出条件。 模型说「我不用工具了」,就意味着它认为任务完成,可以给最终答案了。
messages.push(result)——把结果塞回去。 这一步最容易漏。工具执行完如果不把结果写回 messages,下一轮模型看不到,就会一直重复调用同一个工具。
while (true)——不自设轮数上限。 循环的终止权交给模型自己。听起来很优雅,但这也正是后面所有麻烦的来源。
但这 6 行,会在哪些地方崩掉
把上面这段代码直接放进生产环境,你会依次遇到这些问题:
- 上下文爆了。 跑到第 30 轮,
messages撑爆了模型的上下文窗口,API 直接报错。 - 死循环了。 模型反复调用同一个工具、同样的参数,你拦不住,因为循环里没有任何检测逻辑。
- API 挂了。 一个 429 限流,整个任务当场中断,前面 20 轮白跑。
- 用户以为卡死了。 一轮循环可能几十秒,中间没有任何输出,用户等不及直接 ctrl+c。
- Token 烧穿了。 一觉醒来发现账单多了一位数字。
- 输出被截断了。 模型说到一半撞上
max_output_tokens,它自己不知道,你也以为它说完了。
发现了吗?这六个问题,没有一个出在「循环」这个结构本身。
所以「能跑的 loop」和「生产级的 loop」之间的差距,不在于要不要写 while,而在于——在每一轮循环里,你还额外做了什么。
一轮 loop 里到底该发生什么
把这六类问题归位,一轮循环里其实有五个阶段:
┌─────────────────────────────────────────────────────┐
│ while (true) │
│ │
│ ① 准备上下文 ── 快爆了吗?压缩、裁剪、注入预算警告 │
│ │ │
│ ▼ │
│ ② 调用模型 ──── 流式接收;识别到工具就立刻开始执行 │
│ │ (不冲突的才能并行) │
│ ▼ │
│ ③ 决定是否继续 ─ 不只是「有没有工具调用」 │
│ │ │
│ ▼ │
│ ④ 执行工具 ──── 报错信息要写给模型看,不是给人看 │
│ │ │
│ ▼ │
│ ⑤ 构建下一轮状态 ─ 记录轮数、token、压缩点、截断次数 │
│ │ │
│ └──────────► 回到 ① │
└─────────────────────────────────────────────────────┘
下面就按这五个阶段,逐个说清楚它们各自在解决什么问题。
准备上下文——要在爆掉之前动手
这是最容易被忽略的阶段。大多数人的做法是「等 API 报 context length exceeded 再处理」,但那时候已经晚了:报错就意味着这一轮已经浪费掉了。
正确的做法是在进入模型调用之前评估,而压缩有轻重三档:
第一档:snipping——直接删。
把最老的消息整条丢掉。代价最小(零 token 开销,不需要调模型),但信息真的丢了。适合处理那些已经确认不再需要的中间结果。
第二档:microcompact——局部替换。
不破坏对话结构,只把旧工具调用的结果替换成占位符。
这个思路的关键是:工具结果往往是上下文里最占地方、又最快过期的内容。第 3 轮 read_file 读到的文件内容,到第 20 轮几乎不可能再被引用。但它的字符数,可能比所有用户消息加起来还多。
实际实现时有两个细节值得注意:
// 允许被压缩的工具
const CLEARABLE_TOOLS = new Set([
'read_file', 'bash', 'grep', 'glob', 'list_directory', 'edit_file', 'write_file'
])
const KEEP_RECENT_TOOL_RESULT = 3 // 保留最近 3 个工具调用结果
export function microcompact(messages: ModelMessage[]) {
// 找出所有工具结果的位置
const toolResultIndices = messages
.map((m, i) => (m.role === 'tool' ? i : -1))
.filter(i => i !== -1)
// 只清理「不包括最近 3 个」的那些
const toClear = toolResultIndices.slice(
0, Math.max(0, toolResultIndices.length - KEEP_RECENT_TOOL_RESULT)
)
let cleared = 0
const result = messages.map((msg, idx) => {
if (!toClear.includes(idx)) return msg
if (msg.role !== 'tool' || !Array.isArray(msg.content)) return msg
// 白名单之外的工具不清理
const toolName = (msg.content[0] as any)?.toolName || 'unknown'
if (!CLEARABLE_TOOLS.has(toolName)) return msg
cleared++
return {
...msg,
content: msg.content.map((part: any) => ({
...part,
output: textToolResultOutput(`[tool result cleared]`),
})),
}
})
return { messages: result, cleared }
}
两个细节:一是留最近 3 个——模型正在处理的那批工具结果不能动,否则它会突然「忘了」自己刚读到什么;二是白名单——像 memory 写入、知识库检索这类结果,往往是任务的关键依据,不适合按「新旧」一刀切。
第三档:summarize——让模型自己摘要。
前两档都是「丢信息换空间」,这一档是用一次额外的模型调用,把旧对话变成一份结构化摘要。代价最贵,但信息保留得最好。
关键在于摘要提示词的质量。如果只是让它「总结一下」,它会给你一段笼统的话;真正有用的是结构化模板:
## 用户意图
(用户在这次对话中想要完成什么)
## 已完成的操作
(Agent 执行了哪些工具调用、产生了什么结果)
## 关键发现
(读取的文件内容要点、搜索结果中的关键信息)
## 当前状态
(对话进行到哪一步了、还有什么没做完)
## 需要保留的细节
(文件路径、变量名、配置值、错误信息等不能丢失的具体内容)
最后那一条是灵魂。摘要最容易出的问题就是「把 src/utils/format.ts:42 概括成『某个工具文件』」,模型拿着这份摘要根本没法继续干活。所以要明确要求:文件路径、UUID、版本号原样保留。
还有两个实现细节:
const CONTEXT_TOKEN_THRESHOLD = 300 // 消息开销小于 300 token 就不摘要
const KEEP_RECENT_MESSAGES = 6 // 保留最近 6 条原始消息
- 阈值太小的话,为了省 200 token 花掉一次完整的模型调用,纯亏。
- 保留最近 6 条之后,还要往前回退到最近一条 user 消息再切分,否则可能把一次工具调用和它的结果切在两边,模型会看到「调用了工具但没有结果」的残缺结构。
三个阶段的关系是递进的:先 snipping,不行再 microcompact,实在不行才 summarize。每次能用便宜的手段解决,就不要动用模型。
调用模型——边说边执行
这个阶段有两个反直觉的设计。
第一:工具不用等模型说完
流式返回时,模型是一个字一个字往外吐的。如果等它完整说完再解析工具调用,那几十秒的输出时间就白等了。
实际的做法是:边输出边识别。一旦流里出现了完整的 tool-call 块,立刻开始执行,不必等结束事件。用户看到的效果就是「模型还在说话,工具已经跑完了」。
第二:只有不冲突的操作才能并行
模型一次回复里可能说要调用多个工具,比如「读 A 文件、读 B 文件、写 C 文件」。这三个能并发吗?
不能全并发。读文件可以并行,写文件必须串行——否则两个工具同时写同一个文件,结果不可预期。
这个约束的实现手段是一把读写锁:
- 只读工具获取共享锁,可以和其它只读工具同时持有
- 读写工具获取独占锁,必须等所有其它工具都执行完才能开始
模型的输出是并发的,但工具的语义是有冲突的,这个矛盾必须在 loop 里解决掉。具体实现放到「工具系统」那一篇展开。
决定是否继续——最被低估的地方
如果像这样写
if (response.toolCalls.length === 0) break
然后就说「这样 Agent 就会在任务完成时停下了」。这是远远不够的。
真实的退出场景,光 Claude Code 里就有 7 种(实际是 10 种):
- LLM 没有工具调用 —— 正常完成
- 流式传输过程中被中断 —— 比如人为手动打断
- 工具执行被中断 —— 用户中途取消
- hook 阻止了继续执行 —— 权限或安全策略拦截
- 超过了最大轮数 —— 硬性保险丝
- 上下文过长,API 拒绝 —— 压缩没救回来
- 压缩后上下文还是过长,无法恢复 —— 彻底放弃
这份清单值得反复看。它说明一件事:「循环结束」不等于「任务完成」。
一个生产级 Agent 必须能区分这些情况,因为它们的后续动作完全不同:
- 情况 1 是成功,可以给用户最终答案
- 情况 5 是没做完但被迫中断,要告诉用户「我跑了 50 轮还没搞定,可能需要你介入」
- 情况 6、7 是失败,要提示用户「上下文超限,建议开新会话或缩小任务范围」
如果这七种情况都走进同一个 break,用户看到的就只有「Agent 突然停了」,完全不知道发生了什么。
执行工具——错误信息是写给模型看的
工具执行失败时,是抛异常还是返回错误字符串?
答案是返回字符串。因为工具结果的接收方不是人,是模型。
抛异常会直接中断整个 loop,模型永远不知道发生了什么;而返回一段可读的错误文本,模型下一轮就能看到「哦,这个文件不存在」,然后自己换个路径重试。
所以错误信息要写得足够优雅:
✗ Error: ENOENT
✓ 错误:文件 src/utils.ts 不存在。当前目录下的文件有:
src/utils/format.ts、src/utils/date.ts,请确认路径。
后者不只是报告错误,还给模型提供了下一步的线索。这个差别在实际任务里非常明显——它决定了 Agent 是能自己爬起来,还是就此卡死。
构建下一轮状态——看一步
进入下一轮之前,还有一些零碎但必要的工作:
- 检查当前有哪些 skill 可用,需不需要注入新的行为规范
- 记录这一轮消费掉了哪些命令、读了哪些文件(避免重复劳动)
- 清理已经废弃的临时状态
这些事都不复杂,但漏掉任何一件,都会在后面某一轮以奇怪的方式表现出来。
状态追踪:loop 的仪表盘
上面五个阶段能顺利运转,靠的是一个东西:状态。
一个生产级 loop 至少要能随时回答这五个问题:
1. 现在到第几轮了? 判断是不是该停下。这是最基础的保险丝。
2. 上一轮为什么选择继续? 是正常执行完了继续?还是遇到了错误在恢复?还是在重试压缩?同样一个「继续」,背后的含义完全不同。
3. 压缩执行到哪了? 是不是已经触发过紧急压缩?压缩之后 token 降了多少?如果压缩完还是超限,说明该放弃了。
4. 输出被截断了几次?
模型输出撞上 max_output_tokens 被截断,可以尝试注入恢复消息让它接着说。但要有次数上限:第一次恢复、第二次恢复、第三次就认栽,把不完整的结果返回给用户并标记「输出被截断」。
5. 有没有被挂起的任务? 比如等待用户确认的危险操作。
这五个问题的答案,几乎决定了 loop 里所有的分支决策。没有状态追踪的 loop,只能做出「继续」或「退出」两个选择;有了状态追踪,才能做出「继续 / 告警 / 恢复 / 降级 / 熔断」这五个选择。
实时反馈:Agent 必须边跑边说
这一点经常被工程上的讨论忽略,但它直接决定产品能不能用。
一次 Agent 任务可能跑几十秒到几分钟。如果这期间终端上什么都不显示,用户的第一反应不是「它在努力工作」,而是「它是不是卡死了」,然后直接 ctrl+c。
所以中间过程必须实时暴露:
- 模型正在说什么(流式输出)
- 正在调用哪个工具、参数是什么
- 工具返回了什么(可以截断预览)
- 当前是第几轮、花了多少 token
实现手段上,用 async generator 会很自然——模型流本身就是一个异步迭代器,一层层 for await 处理下去,天然就实现了「边产出边消费」。
那为什么不直接用 SDK 自带的循环?
说到这里,一个自然的疑问是:现在的 AI SDK 不是已经内置了循环机制吗?
确实有。比如 Vercel AI SDK 的 stopWhen,给它一个终止条件,它就会自动完成「调用模型 → 执行工具 → 再调用模型」的循环。
// SDK 自带的循环:给定终止条件,自动循环
const result = streamText({
model,
tools,
messages,
stopWhen: stepCountIs(10), // 最多 10 步
})
但这个便利是有代价的:你没法在循环中间插入自己的逻辑。
而上面五个阶段讲的每一件事——压缩、并发控制、循环检测、状态追踪、预算控制、权限检查——全部都是「循环中间的逻辑」。
用 SDK 自带的循环,你等于把这五个阶段全部放弃了,只剩下一个「能跑通」的 demo。
所以我的选择是:把 SDK 降级为「一次模型调用」,循环自己写。
const result = streamText({ model, tools, messages, system, maxRetries: 0 })
for await (const part of result.fullStream) {
// 每一次工具调用、每一个文本增量,都从这里过一遍
// 想在哪插入逻辑,就在哪插入
}
maxRetries: 0 也是必须的——重试要由我们自己控制(指数退避、判断哪些错误值得重试),不能交给 SDK 拍脑袋。
最小示例:一个能跑的 loop 骨架
下面是一个完整可运行的骨架。它没有连接真实模型(用 mock 代替),但五个阶段和状态追踪一个不少,你可以直接跑起来看它的执行过程:
/**
* 一个最小但「能进生产」的 AgentLoop 骨架
* 运行:npx tsx agent-loop.ts
* 无需任何 API Key —— 模型用 mock 模拟,换成真实的 streamText 即可
*/
// ---------- 1. 类型:loop 只认这三种信号 ----------
interface ToolCall { id: string; name: string; args: Record<string, any> }
type Chunk =
| { type: 'text'; text: string } // 模型说了几个字
| { type: 'tool-call'; call: ToolCall } // 模型要用工具
| { type: 'finish'; reason: 'end_turn' | 'tool_use' | 'max_tokens' }
// ---------- 2. 工具:模型的手脚 ----------
const tools: Record<string, (args: any) => Promise<string>> = {
read_file: async ({ path }) =>
`export function formatDate(d) { return moment(d).format('YYYY-MM-DD') } // << ${path}`,
edit_file: async ({ path, content }) =>
`已写入 ${path}(${content.length} 字符)`,
}
// ---------- 3. 模型:这里用 mock,真实项目换成 streamText ----------
async function* mockModel(turn: number): AsyncGenerator<Chunk> {
const plan: Array<{ text: string; call?: ToolCall }> = [
{ text: '我先看一下这个文件。', call: { id: 'c1', name: 'read_file', args: { path: 'src/utils.ts' } } },
{ text: '找到了 moment 的用法,把它换成 dayjs。', call: { id: 'c2', name: 'edit_file', args: { path: 'src/utils.ts', content: "import dayjs from 'dayjs'" } } },
{ text: '重构完成:moment 已全部替换为 dayjs。' },
]
const step = plan[Math.min(turn, plan.length - 1)]
for (const ch of step.text) yield { type: 'text', text: ch } // 逐字流式
if (step.call) yield { type: 'tool-call', call: step.call }
yield { type: 'finish', reason: step.call ? 'tool_use' : 'end_turn' }
}
// ---------- 4. 状态:loop 的仪表盘 ----------
interface LoopState {
turn: number // 现在第几轮
exitReason: string // 为什么退出
toolCallCount: number // 一共调了几次工具
lastFinishReason: string // 上一轮为什么继续
truncated: number // 输出被截断了几次(本骨架未实现截断恢复,恒为 0)
}
// ---------- 5. loop 本体 ----------
async function agentLoop(task: string, maxTurns = 12): Promise<LoopState> {
const messages: Array<{ role: string; content: string }> = [{ role: 'user', content: task }]
const state: LoopState = {
turn: 0, exitReason: '', toolCallCount: 0, lastFinishReason: '', truncated: 0,
}
while (true) {
// —— 阶段 1:进入新一轮前,先检查要不要干预(压缩 / 预算 / 熔断)——
state.turn++
if (state.turn > maxTurns) {
state.exitReason = `超过最大轮数 ${maxTurns}`
break
}
// —— 阶段 2:调用模型,流式接收 ——
let text = ''
const calls: ToolCall[] = []
let reason = 'end_turn'
for await (const chunk of mockModel(state.turn - 1)) {
switch (chunk.type) {
case 'text':
text += chunk.text
process.stdout.write(chunk.text) // 实时反馈:边跑边说
break
case 'tool-call':
calls.push(chunk.call) // 边输出边收集,不等模型说完
break
case 'finish':
reason = chunk.reason
break
}
}
state.lastFinishReason = reason
// —— 阶段 3:退出条件(这只是其中一种)——
if (calls.length === 0) {
state.exitReason = '模型没有工具调用,任务结束'
break
}
// —— 阶段 4:执行工具 ——
for (const call of calls) {
const fn = tools[call.name]
const result = fn ? await fn(call.args) : `[错误] 没有名为 ${call.name} 的工具`
state.toolCallCount++
console.log(`\n [工具] ${call.name} -> ${result}`)
messages.push({ role: 'tool', content: result }) // 结果塞回 messages
}
// —— 阶段 5:构建下一轮状态 ——
messages.push({ role: 'assistant', content: text })
console.log(`\n [继续] 第 ${state.turn} 轮结束,进入下一轮`)
}
return state
}
// ---------- 6. 跑起来 ----------
async function main() {
const finalState = await agentLoop('把 src/utils.ts 里的 moment 替换成 dayjs')
console.log('\n\n----------------')
console.log(`退出原因:${finalState.exitReason}`)
console.log(
`状态:${finalState.turn} 轮 / ${finalState.toolCallCount} 次工具调用 / 截断 ${finalState.truncated} 次`
)
}
main()
跑起来的输出:
我先看一下这个文件。
[工具] read_file -> export function formatDate(d) { return moment(d).format('YYYY-MM-DD') } // << src/utils.ts
[继续] 第 1 轮结束,进入下一轮
找到了 moment 的用法,把它换成 dayjs。
[工具] edit_file -> 已写入 src/utils.ts(25 字符)
[继续] 第 2 轮结束,进入下一轮
重构完成:moment 已全部替换为 dayjs。
----------------
退出原因:模型没有工具调用,任务结束
状态:3 轮 / 2 次工具调用 / 截断 0 次
注意最后两行——它把退出原因和状态都打了出来。这就是前面说的「状态追踪」:同样是结束,你能一眼看出它是正常完成,还是撞了轮数上限,还是被熔断。
总结
回到开头那句话:LLM 和 Agent 之间只差一个 loop。
但是loop之间,亦有高低。
- 6 行代码就能让它跑起来,可是仅仅只是跑起来而已。
- 真正核心的部分,全在「每一轮里还应该发生什么」——准备上下文、并发控制、退出判断、状态追踪、实时反馈
所以判断一个 Agent 是不是「生产级」,不要看它能不能跑通一个 demo,去看它的 while 循环里对各种场景的处理怎样,抗逆性如何。