写一个循环谁都会:调用模型、解析动作、执行工具、把结果塞回去。难的是四件事——知道它该停下来、知道它卡住了、知道它绕圈子、以及在它需要人拍板时把控制权交出去。这一章把循环从一个 while 语句升级成一个可控的执行系统。
编辑部注 · 「什么时候该停下来」是本刊认为最被低估的工程问题。绝大多数线上事故不是模型不够聪明,而是循环没有正确的刹车。
先看一个未经训练的写法:while True: plan = model(ctx); ctx += act(plan)。它的失败方式多得惊人——模型可能永远说「再查一次」,可能反复调用同一个工具,可能在一个空结果上打转,也可能已经完成了却不知道要停。这一章把每一种失败都对应上一个明确的终止条件。
一、循环的骨架
Agent 循环的最小结构只有三步,这三步的命名在不同框架里不同,但语义一致:
| 阶段 | 做什么 | 失败模式 |
|---|---|---|
| Think(思考) | 模型基于当前上下文决定下一步动作 | 动作格式非法;在信息不足时硬猜;重复同一个动作 |
| Act(行动) | 执行工具调用,产生副作用或获取信息 | 参数错误;超时;权限不足;副作用不可逆 |
| Observe(观察) | 把结果加工后回喂上下文 | 结果过长挤爆上下文;错误信息丢失;把外部内容当指令 |
看起来很清晰,但工程上的麻烦几乎全部集中在「Observe → Think」这一段的回喂内容上。同一段工具结果,怎么截断、怎么标注来源、怎么标记可信度,直接决定下一轮模型会不会做出荒唐决策。
二、五类终止条件:一个都不能少
这是本章的核心。只写「任务完成就停」的系统,一定会以三种方式之一失控。
三、无进展检测怎么做
不要指望模型自己意识到自己在绕圈。要在 Harness 侧用可计算的判据来检测。常用手段按可靠性排序:
| 判据 | 计算方式 | 优点 | 局限 |
|---|---|---|---|
| 工具调用签名重复 | 对 (tool_name, normalized_args) 做哈希,统计重复次数 | 实现简单,几乎零误报 | 参数只要有微小变化就检测不到(但那种情况往往也不是真进展) |
| 观察结果哈希不变 | 对工具返回内容取哈希,连续 3 轮相同即判定停滞 | 能捕捉「换了参数但结果一样」 | 返回内容带时间戳等噪声时会失效 |
| 状态指纹不变 | 对「工作区文件列表 + 文件哈希」取指纹 | 最接近「实质进展」的定义 | 只适用于有文件系统产出的任务 |
| 模型自述停滞 | 让模型在每轮输出一个 progress 标记 | 能捕捉语义层面的绕圈 | 不可靠,模型经常自信地认为自己有进展 |
| 成本斜率异常 | 每轮 token 消耗持续上升但产出不变 | 能提前预警上下文膨胀 | 只能作为辅助信号 |
很多实现只统计「同一个工具调用了多少次」,超过阈值就报错退出。问题是:合法的搜索任务本来就会调用同一工具很多次(换不同关键词检索)。
正确的判据不是「调用了多少次」,而是「调用之后状态有没有变化」。同样是调用 search 十次,每次返回不同内容是新信息;每次返回同一批结果才是死循环。这个区别决定了你的检测器是帮了忙还是帮了倒忙。
四、动手:一个带完整刹车的循环
import hashlib, json, time
from dataclasses import dataclass, field
def sig(tool: str, args: dict) -> str:
"""工具调用签名:参数做稳定序列化,避免键序影响"""
return f"{tool}::{json.dumps(args, sort_keys=True, ensure_ascii=False)}"
@dataclass
class LoopGuard:
max_turns: int = 15
max_seconds: float = 300
stall_limit: int = 3 # 连续多少轮无进展就退出
repeat_limit: int = 3 # 同一调用签名最多重复几次
started: float = field(default_factory=time.time)
turns: int = 0
seen: dict = field(default_factory=dict) # 签名 -> 次数
last_obs: str = ""
stall: int = 0
def check(self) -> tuple[bool, str]:
"""返回 (是否应继续, 退出原因)"""
if self.turns >= self.max_turns:
return False, "BUDGET_TURNS"
if time.time() - self.started > self.max_seconds:
return False, "BUDGET_TIME"
if self.stall >= self.stall_limit:
return False, "NO_PROGRESS"
return True, ""
def note_call(self, tool: str, args: dict) -> tuple[bool, str]:
"""记录一次调用;返回是否允许执行"""
s = sig(tool, args)
self.seen[s] = self.seen.get(s, 0) + 1
if self.seen[s] > self.repeat_limit:
return False, f"同一调用已重复 {self.seen[s]} 次,请换策略或结束任务"
return True, ""
def note_observation(self, content: str) -> None:
"""观察结果没变化 → 记为一次停滞"""
h = hashlib.sha256(content.encode()).hexdigest()
if h == self.last_obs:
self.stall += 1
else:
self.stall = 0
self.last_obs = h
def run(task: str, call_model, exec_tool, ask_human):
guard = LoopGuard()
ctx = [{"role": "user", "content": task}]
while True:
ok, why = guard.check()
if not ok:
# 关键:退出时带上已完成的部分,而不是一句失败
return {"status": why, "turns": guard.turns,
"partial": summarize(ctx), "context": ctx}
guard.turns += 1
plan = call_model(ctx, temperature=0)
# ① 显式完成信号(不要靠「没调工具」隐式判断)
if plan["action"] == "finish":
return {"status": "DONE", "result": plan.get("answer"), "turns": guard.turns}
# ④ 需要人工:暂停而不是硬闯
if plan["action"] == "need_human":
return {"status": "NEED_HUMAN", "question": plan.get("question", ""),
"context": ctx, "resumable": True}
# ⑤ 输出无法解析:有限次重试后才放弃
if plan["action"] == "invalid":
ctx.append({"role": "user",
"content": "上一次输出无法解析为合法动作,请只输出 JSON。"})
continue
allowed, msg = guard.note_call(plan["tool"], plan.get("args", {}))
if not allowed:
ctx.append({"role": "user", "content": msg})
continue
result = exec_tool(plan["tool"], plan.get("args", {}))
guard.note_observation(str(result))
# 副作用操作必须确认(人工介入点)
if result.get("side_effect") and not ask_human(plan):
ctx.append({"role": "user", "content": "用户拒绝了该操作。"})
continue
ctx.append({"role": "tool", "content": str(result)[:2000]})- 给
LoopGuard加一个「成本斜率」信号:连续三轮 token 消耗上升但观察结果哈希不变,就提前退出; - 把
stall_limit从 3 调到 1,观察误杀率变化(很多合法任务会出现一轮「无变化」),思考阈值该怎么定; - 写一个测试:构造一个「每次都调用 search('同样的词')」的假模型,验证
NO_PROGRESS能被触发且只用了 3 轮。
五、Human-in-the-loop 的正确姿势
人工介入不是「弹个窗问一下」那么简单,它有三个必须设计清楚的点:
第一,暂停必须可恢复。 用户拒绝或长时间不回应之后,任务不能就这么死掉。要把完整状态(上下文、已完成的步骤、待决策的问题)持久化,用户回来时能从中断点继续。
第二,问题必须包含决策所需的信息。 问「是否继续?」是没用的;问「我准备删除 orders_2025 表(共 12 万行,最后访问 3 天前),确认删除还是先备份?」才是可决策的。把选项和后果一起给出来。
第三,不是所有事都要问。 该自动化的自动化,该问的才问。判据是「可逆性 + 影响范围」:
| 操作类型 | 示例 | 策略 |
|---|---|---|
| 只读、无副作用 | 检索、读取、查询 | 直接执行,不问 |
| 可逆写操作 | 写文件到工作目录、创建草稿 | 自动执行 + 记录,可回滚 |
| 不可逆但有边界 | 发送邮件到内部群、创建线上工单 | 首次确认,同类操作可批量授权 |
| 不可逆且影响外部 | 删除数据、支付、发布公开内容 | 每次强制确认,且确认信息必须包含影响范围 |
六、自测
七、小结
| 要素 | 必须做到 |
|---|---|
| 循环骨架 | Think / Act / Observe 三步,重点在 Observe 的回喂加工 |
| 终止条件 | 五类齐全:完成、预算、无进展、需要人工、系统异常 |
| 无进展检测 | 用「调用签名 + 结果哈希」,不要用调用次数 |
| 人工介入 | 暂停可恢复;问题带选项与后果;按可逆性分级而非一律询问 |
| 退出时 | 返回部分结果与已完成步骤,不返回一句失败 |
循环的上限由模型的推理能力决定,循环的可靠性由刹车决定。而线上事故绝大多数出在刹车上。本刊编辑部
刹车装好了,下一章处理最容易出问题的那一层:工具。因为工具是模型唯一能真正改变世界的手。
◇ 面试官会怎么问
共 4 条 · 其中 2 条高频 · 先自己答一遍,再展开对照
Agent Loop 的终止条件你会设计哪几类? 高频
① 显式完成——模型明确表示任务达成(要做好格式约束,不要靠正则猜自然语言);
② 资源用尽——最大轮数、token 预算上限、时间上限,三者都要有,因为不同任务会先撞到不同的墙;
③ 无进展检测——连续 N 轮动作或工具调用高度相似、或者没有产生新的有效信息,判定为原地打转并中断;
④ 循环检测——A→B→A 这类振荡模式;
⑤ 主动挂起——需要人工确认、缺少必要信息、或者触及高风险操作时,转 Human-in-the-loop 而不是硬着头皮往下走。
还要有一个兜底:异常终止也必须产出可读的中间结果,而不是把已经完成的工作丢掉。
追问链
- 如果只允许你保留一个条件,你留哪个?为什么?
- 无进展检测具体怎么实现?
模型开始重复同一个动作不收敛,你系统层面怎么发现? 高频
① 动作指纹去重——把每轮的工具名 + 规范化参数(排序后哈希)作为指纹,连续重复即判定打转;
② 结果熵检测——工具返回内容高度相似甚至完全一致,说明再调也拿不到新信息;
③ 目标相似度——对比相邻几轮的思考文本相似度,超过阈值说明在原地复述;
④ 进度信号——为任务维护"已完成子目标集合",若连续多轮集合不增长,即无进展。
检出之后的处理分三级:先注入提示纠偏(告诉它已经做过什么)→ 再强制换策略(禁用刚才那个工具)→ 最后中断并交出中间结果并说明卡在哪。
一次 loop 最多几轮?这个数怎么定?
简单查询型 3–5 轮(一次检索加一次总结足够);常规办事型 10–20 轮;代码修复类长任务可以放到 30–50 轮,但必须配预算上限兜底。
定这个数的依据是"分布 + 成本":看线上真实任务的轮数分布,取 P95 再留一点余量;同时算一下撞到上限时的最坏成本,确保可接受。
另外要区分软上限与硬上限:软上限触发时提示模型"轮数不多了,请收尾并给出当前结论",硬上限触发时直接中断。只设硬上限会让模型在最后一轮还去开一个新工具,交出一个半成品。
ReAct 和 Plan-and-Execute 该怎么选?
ReAct(边想边做)每轮根据最新观察决定下一步,适应性强、对信息不确定的任务友好,缺点是容易短视、可能反复试探、总轮数偏高。
Plan-and-Execute(先规划再执行)先生成步骤清单再逐步执行,优点是全局感强、可审计、可并行;缺点是计划一旦错误会一路错下去,且环境变化后计划失效。
实践中常见的组合是两层:外层用规划定大方向(3–6 步),内层用 ReAct 处理每一步的具体不确定性,并在关键节点重新规划。选择标准是:信息在开始时就基本齐备 → 偏规划;信息要边做边获得 → 偏 ReAct。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。