Agent Loop 是一个 while 循环:组装上下文 → 模型决策 → 执行工具 → 观察回灌 → 判停。听起来是「谁都会写」,但能不能写严谨,是「会调框架」和「会做 Agent」的分界线——因为循环写得不严谨,模型再强也救不回来。
前六期铺垫到这一期,主线已经完整:模型只提出决策,运行时负责执行。这一期把「运行时」的心脏——那个 while 循环——拆到最细。
它与相邻篇的分工要说清:
- 本篇只讲「循环本身」:怎么转、怎么停、怎么防绕圈;
- 循环外面的容器(Harness 六件套:Loop / Context Manager / Tool Runtime / State Store / Memory / Guardrails)见第 09 期;
- 循环里的计划与状态(Planning、Checkpoint、Resume)见第 08 期。
本篇回答两个问题:
| # | 面试问题 | 指向哪个工程面 |
|---|---|---|
| Q1 | 什么是 ReAct?它和 CoT 有什么区别? | 循环的认知范式 |
| Q2 | Agent Loop 由哪几步组成?怎么决定什么时候停止?无限循环怎么防? | 循环的工程实现 |
Q1 说明「为什么要有循环」,Q2 说明「循环怎么写成能上线的样子」。Q1 答不出「外部观察注入」,Q2 的六步就只是机械背诵。
一、ReAct 与 Chain-of-Thought 是什么关系
1.1 推荐回答
CoT 是「只推理」,ReAct 是「推理与行动交错」。 差别只有一个字,但决定了整个范式:
CoT(Chain-of-Thought):问题 → 想 → 想 → 想 → 答案
全程封闭,没有外部信号
ReAct(Reasoning + Acting):想 → 做(调工具)→ 看(观察)→ 再想 → 再做 → …
每一步都被真实世界锚定
核心差异:ReAct 把外部观察(Observation)重新注入推理链,让真实世界的反馈可以推翻上一步的推理。CoT 没有这个回路——它一旦推错,误差就一路累积到最后。
1.2 逐项对比
| 维度 | Chain-of-Thought | ReAct |
|---|---|---|
| 输出结构 | 纯推理链 | 推理 + 动作 + 观察 交替 |
| 信息源 | 只有模型内部知识 | 内部知识 + 外部工具返回 |
| 纠错时机 | 结束后(错了就是错了) | 每步都能被观察纠正 |
| 调用成本 | 1 次 | N 次(每步一次) |
| 适用任务 | 纯推理(数学、逻辑) | 需要外部信息/副作用的任务 |
| 失败模式 | 误差累积、幻觉事实 | 绕圈、贪心短视、成本高 |
一句话:CoT 让模型把推理写出来,ReAct 让模型把推理交给环境校验。
1.3 一个具体例子(代码实证)
同一个任务「订单能不能退款」,两种范式的写法:
CoT :模型凭记忆推断「一般 7 天可退」,直接答「可以退」 ← 政策可能是别的天数,错
ReAct :先 get_order 拿到签收日期 → 再 check_refund_policy 判定 → 再决定写操作
每一步都有事实依据,观察结果可以推翻上一步
这段差别不是「哪个更聪明」,而是有没有把不确定性交给环境。退款政策是业务事实,不该靠模型「记得」。
二、Agent Loop 的六个动作
这是本篇的骨架。一个能上线的循环,每一步都有它的坑:
① 组装上下文 → ② 调用模型决策 → ③ 解析输出
↓
⑥ 判停 ← ⑤ 观察回灌 ← ④ 执行工具
2.1 ① 组装上下文
把 System、用户目标、召回的记忆、任务状态、近期工具结果、最近对话按预算拼起来——细节全在第 05 期。这里只强调一句:这一步是每轮都要重做的,也是成本增长的主因(见 5.1)。
2.2 ② 调用模型决策
把组装好的上下文发给模型,拿回一个决策。这一步本身没坑,坑在于**「决策」的形态有三种**,第 ③ 步要处理。
2.3 ③ 解析输出
模型的输出可能是三种形态,解析层必须都接得住:
| 形态 | 说明 | 处理 |
|---|---|---|
原生 tool_calls | API 结构化返回 | 直接用 |
| 文本 JSON | 模型在文本里写了 JSON | 抽 {...} 再解析 |
| 传统 ReAct 文本 | Thought/Action/Observation 格式 | 正则解析(脆弱) |
为什么现在不流行文本 ReAct 格式? 因为它解析脆弱(正则一错整轮失败)、无法并行调用、格式错误就是硬失败。原生 function calling 把这层交给模型和 API 约束,运行时省心很多。但即便如此,解析层仍要处理「格式坏掉」——见 4.3 的三级处理。
2.4 ④ 执行工具
真正调用工具。这一步的工程约束:
- 超时:每个工具必须有超时,不能挂死循环;
- 权限:写操作要过权限校验(第 04 期);
- 错误结构化:返回
{ok, error:{code, message, retryable}},而不是抛异常——因为下一步要把它回灌给模型(见 4.3)。
2.5 ⑤ 观察回灌——最容易漏的一步
把工具结果写回 messages。这一步如果漏了,模型下一轮「不记得」自己调过工具,就会再调一次——这是初学者 Agent 死循环的头号成因。
配套代码里,只要把回灌那两行注释掉,收敛型模型立刻退化成绕圈型。这不是理论——是代码跑出来的。
2.6 ⑥ 判停
见第 4 节。这是本期的重头。
2.7 单步执行的四个不变式
一个 step 要做到四件事,缺一不可:
- 一个 step 只做一件事——一个工具调用,或一次最终答复;
- 副作用必须幂等或可补偿——否则重试/Resume 会做两遍(第 08 期展开);
- 观察必须结构化——不要让模型读自由文本工具结果;
- 每步必须可序列化——不能含连接、句柄、闭包等不可恢复引用(否则无法 checkpoint)。
三、ReAct 的四类缺陷
ReAct 不是银弹,它的缺陷必须在面试里主动说出来:
| 缺陷 | 表现 | 缓解 |
|---|---|---|
| 贪心短视 | 每步只看眼前,缺全局规划,长任务走偏 | 外层加 Planning(第 08 期) |
| 误差放大 | 前一步的小错在后续循环被反复放大 | 每步校验 + Reflection |
| 漂移 / 绕圈 | 长任务里忘记目标,原地打转 | 循环检测(4.6)+ 目标重述 |
| 成本线性涨 | 每步一次调用,且重发全历史 | 上下文压缩(第 05 期)+ 预算兜底 |
「贪心短视」是 Plan-and-Execute 存在的理由——这正是第 08 期的起点。
四、终止条件:三重保障
4.1 推荐回答
终止必须三层,少任何一层,循环都会以你不希望的方式结束:
显式终止 ← 模型输出终止动作(正常收尾)
隐式兜底 ← max_steps / max_tokens / timeout / cost_budget / tool_call_limit
循环检测 ← 同一 (tool, args) 重复 N 次 / 连续无新增信息
- 少了显式终止:任务完成了也不知道停;
- 少了隐式兜底:模型不给终止时就无限跑(烧钱);
- 少了循环检测:兜底之前它已经白烧了几十步的钱,还可能执行了不该执行的写操作。
4.2 三类终止的对照(代码实证)
同一个循环跑三个场景,停止原因完全不同:
| 场景 | 模型 | 停止原因 | 步数 | token | 成本 | 副作用 |
|---|---|---|---|---|---|---|
| 正常收敛 | 收敛型 | MODEL_FINISHED | 4 | 4510 | 0.02706 | 1 |
| 原地绕圈 | 绕圈型 | LOOP_DETECTED | 3 | 3525 | 0.02115 | 0 |
| 格式坏掉 | 格式坏掉型 | MODEL_FINISHED(修复后收敛) | 4 | 4915 | 0.02949 | 0 |
三个读法:
- 显式终止:收敛型在第 4 步主动给出最终答复——正常收尾靠模型自收手;
- 循环检测:绕圈型永远不给终止动作。如果没有
LoopDetector,它会一直转到MAX_STEPS,而且中间还敢真的调create_refund——那就出事了; - 隐式兜底:格式坏掉型前两次输出解析失败(占了两步),走的是「回灌 → 让模型自己修」。这两次失败也占步骤,这就是
max_steps必须留够余量的原因。
4.3 解析失败的三级处理
格式坏掉型演示的是这段:
def parse_decision(raw):
try:
return {"ok": True, "value": json.loads(raw)} # 1. 直接解析
except json.JSONDecodeError:
pass
m = re.search(r"\{.*\}", raw, re.S)
if m:
try:
return {"ok": True, "value": json.loads(m.group(0)), "repaired": True} # 2. 抽 {...}
except json.JSONDecodeError:
pass
return {"ok": False, "error": { # 3. 结构化错误 → 回灌
"code": "MALFORMED_OUTPUT", "message": "输出不是合法 JSON,请只返回一个 JSON 对象",
"retryable": True, "raw_preview": raw[:60]}}
关键:解析失败要回灌让模型修,而不是抛异常终止。 但必须有重试上限——同一错误反复重试就是绕圈的另一种形态(受 max_steps 兜底)。
4.4 一次 step 的完整流程(含所有兜底)
配套代码的 run_agent 主干(省略细节):
for step in range(1, budget.max_steps + 1):
d = model.decide(msgs) # ② 调用模型
budget.tokens_used += d.prompt_tokens + d.completion_tokens
if d.kind == "malformed": # ③ 解析失败 → 回灌
parsed = parse_decision(d.raw)
if not parsed["ok"]:
msgs.append({"role": "user", "content": json.dumps(parsed["error"])})
continue
if d.kind == "final": # 显式终止
stop_reason = "MODEL_FINISHED"; break
looped, why = detector.observe_action(d.tool, d.args) # 循环检测(工具调用前)
if looped:
stop_reason = "LOOP_DETECTED"; break
result = execute_tool(d.tool, d.args, side_effects) # ④ 执行工具
msgs.append({"role": "assistant", "name": d.tool}) # ⑤ 观察回灌
msgs.append({"role": "tool", "name": d.tool, "payload": result})
dup, why2 = detector.observe_observation(result, prev_obs) # 无进展检测
if dup:
stop_reason = "NO_PROGRESS"; break
if budget.tokens_used >= budget.max_tokens: # ⑥ 隐式兜底
stop_reason = "TOKEN_BUDGET"; break
else:
stop_reason = "MAX_STEPS"
注意循环检测的两个位置:工具调用前(抓重复动作)和观察回灌后(抓无新增信息)。
4.5 模型给了不存在的工具 / 参数不合法怎么办
把结构化错误回灌让它改,而不是抛异常终止。 例如:
{"code": "UNKNOWN_TOOL", "message": "未知工具 get_oder,可用工具:get_order, check_refund_policy, create_refund", "retryable": true}
模型下一轮会自己纠正拼写。但要设重试上限——同一个错误反复重试,本质还是绕圈,由 max_steps / 循环检测兜底。
4.6 绕圈检测的工程做法(代码实证)
两条互补的信号:
信号一:动作重复计数。 对 (tool_name, 规范化 args) 做哈希,同一组合重复到阈值就判定绕圈。
key = sha1((tool + json.dumps(args, sort_keys=True)).encode()).hexdigest()[:10]
self.counts[key] += 1
if self.counts[key] >= self.max_repeat: # 默认 3
return True, f"动作 {tool} 已重复 {self.counts[key]} 次,判定为原地绕圈"
为什么必须是「动作」而不是「话术」? 看这段对比:
第 1 轮:「让我先查一下订单状态。」
第 2 轮:「好的,我再确认一下这个订单的信息。」
第 3 轮:「为了准确起见,我再查询一次订单详情。」
措辞三轮全不一样,动作完全一样。所以判据落在动作上。
信号二:观察无进展。 连续两轮观察结果完全相同 → 新增信息量为零 → 无进展。
两个信号互补:一个抓「重复做」,一个抓「做了也白做」。
4.7 怎么区分「合理的重试」和「绕圈」
这是面试里的好问题。判据不是「重复了几次」,而是新增信息量是否趋零:
- 重试后拿到了新的错误码 / 新的状态 → 有信息增益,是合理重试;
- 重试后观察结果与上一轮完全相同 → 零信息增益,是绕圈。
所以循环检测不能只看重复计数,还要看观察是否变化——这也是代码里两个信号都要有的原因。
4.8 终止条件设多少步合适
不背数字,给判据:
| 任务类型 | 参考区间 | 判据 |
|---|---|---|
| 单工具查询 | 3–5 步 | 一次查询 + 一次答复 + 余量 |
| 多步分析 | 10–20 步 | 每步一个工具,留 2–3 步格式修复余量 |
| 长任务(带 Planning) | 按计划步数 × 1.5 | 留重规划余量 |
关键是留余量给格式修复和非预期步骤——代码里格式坏掉型就白占了 2 步。
五、Loop 的成本形状
5.1 为什么 Agent 比 Workflow 贵得多
每轮都要重发完整历史。第 N 轮的输入 token ≈ 前 N-1 轮的累积:
第 1 轮:system + user
第 2 轮:+ assistant + tool result
第 3 轮:+ assistant + tool result
...
第 N 轮:全部历史
输入 token 随轮次近似二次增长。这和第 02 期「Workflow vs Agent 4.54×」的实测是同一个根因——不是模型调用次数多,而是每一轮都在重发累积的上下文。
5.2 缓解手段
- KV Cache / 前缀稳定:让重复前缀命中缓存(第 05 期 3.4);
- 上下文压缩:压掉旧的工具结果(第 05 期第 4 节);
- 预算兜底:
max_tokens/cost_budget硬性截断; - 子 Agent 隔离:把探索过程放到子循环,只回传结论(第 05 期 4.5)。
六、一轮里的并行工具调用
无依赖的工具可以同一轮并行发出;有依赖的必须串行。判据:
| 可以并行 | 必须串行 |
|---|---|
| 多个独立查询(查 A 订单 + 查 B 订单) | 有数据依赖(先查 ID 再用 ID 查详情) |
| 只读操作之间 | 有顺序语义(先创建再更新) |
| 无共享资源写 | 写同一共享资源 |
并行能显著降低墙钟时间,但返回值顺序必须与发出顺序对应,否则模型会把结果对错工具。细节与第 12 期的并发章打通。
七、常见的错误认识
- 「Agent 就是 while True 调模型」 —— 六个动作,每个都有坑;
- 「有 max_steps 就不会死循环」 —— 会,只是死得慢;兜底之前可能已经烧了几十步;
- 「模型承诺会停就会停」 —— 模型说「我马上收尾」和它真的收尾是两件事,兜底必须存在;
- 「观察回灌可以省」 —— 漏了它,收敛型立刻变绕圈型;
- 「解析失败就抛异常」 —— 应该回灌让模型修,但有上限;
- 「绕圈看模型有没有说重复话」 —— 要看动作,不是话术;
- 「重试次数超过 N 就是绕圈」 —— 看信息增益,不看次数;
- 「循环检测可以省,反正有 max_steps」 —— max_steps 是最后一道墙,不是第一道;
- 「Loop 写得不严谨没关系,模型强就行」 —— 循环是骨架,骨架歪了肌肉救不回来;
- 「并行调用随便并行」 —— 有依赖/有写冲突的并行会让状态错乱。
八、概念速查
| 概念 | 一句话 |
|---|---|
| ReAct | 推理与行动交错,把观察注入推理链 |
| CoT | 只推理,全程封闭,无外部信号 |
| 六个动作 | 组装上下文 / 调模型 / 解析输出 / 执行工具 / 观察回灌 / 判停 |
| 显式终止 | 模型输出终止动作 |
| 隐式兜底 | max_steps / max_tokens / timeout / cost / tool_call_limit |
| 循环检测 | (tool,args) 哈希计数 + 观察无进展 |
| 单步四不变式 | 一事 / 幂等 / 结构化 / 可序列化 |
| 三级解析 | 直接解析 → 抽 {...} → 结构化错误回灌 |
| Loop 成本形状 | 每轮重发全历史 → 输入 token 近似二次增长 |
九、动手实验
配套代码:
agent-developer-interview/agent-07-agent-loop
实验用一个零依赖的循环实现,把六个动作、三类终止、绕圈检测全部参数化,三个脚本化模型分别演示正常收敛、原地绕圈、格式坏掉三种情形。
9.1 核心代码节选(一):循环主干
见 4.4 的 run_agent——每一步都有编号注释,重点在「循环检测出现在两个位置」。
9.2 核心代码节选(二):绕圈检测
见 4.6 的 LoopDetector.observe_action。真实输出:
第 1 次 get_order({"order_id": "A1001"}) → 继续
第 2 次 get_order({"order_id": "A1001"}) → 继续
第 3 次 get_order({"order_id": "A1001"}) → 🛑 已重复 3 次,判定为原地绕圈
9.3 核心代码节选(三):ReAct vs CoT
def show_react_vs_cot():
# CoT:全程封闭,中间错了也没有外部信号纠正
# ReAct:想 → 做 → 看 → 再想,每一步都被真实世界锚定
...
9.4 脚本与结论对应表
| 段落 | 验证的结论 |
|---|---|
| 三场景 trace | 显式终止 / 循环检测 / 隐式兜底各自的触发形态 |
| 终止对照表 | 三类终止条件「少一层会怎样」 |
| 绕圈哈希演示 | 判据是动作,不是话术 |
| ReAct/CoT 对比 | 「把推理交给环境校验」 |
9.5 三个「改一改再跑」的练习
- 把
Budget.max_steps从 8 降到 3,观察「格式坏掉型」在第 3 步还没修完就被MAX_STEPS掐掉; - 把「⑤ 观察回灌」那两行
msgs.append注释掉,观察收敛型模型如何退化成绕圈型; - 把
LoopDetector(max_repeat=2),观察多早就会误伤「合法的重复查询」(如分页翻页)。
十、延伸阅读
站内
- LLM 基础与模型边界:Attention 的 O(n²) 与 KV Cache,是 Loop 成本形状的机制来源。
- 第 02 期:什么是 AI Agent——标准链路、Workflow vs Agent 的 4.54× 实测。
- 第 05 期:上下文工程与压缩——每轮组装上下文的预算管理与压缩手段。
- 第 06 期:记忆系统——循环里召回的「关于用户的事实」从哪来。
- 第 08 期:Planning 与状态管理——循环里的计划、Checkpoint 与断点续跑。
- 第 09 期:Harness——循环外面的六件套容器。
- AI 笔记:异步流水线:工具执行的异步化与调度。
站外
- Yao et al.:ReAct: Synergizing Reasoning and Acting in Language Models——ReAct 原始论文。
- Wei et al.:Chain-of-Thought Prompting Elicits Reasoning in Large Language Models——CoT 原始论文。
- Anthropic:Building effective agents——为什么「简单循环 + 好工具」常胜过复杂框架。
- OpenAI:Function calling 文档——原生
tool_calls的返回结构。
十一、一句话总结
Agent Loop 的六个动作里,最容易漏的是「观察回灌」;终止条件的三个层次里,最容易被省的是「循环检测」。这两个恰恰是循环能不能上线的分水岭。
而一切终止机制背后有一条统一的判据:循环该继续还是该停,看的是「有没有新生信息」,不是「模型怎么说」。 有信息增益就继续,零增益就是绕圈——动作重复计数只是这条判据的一个可实现的代理。
