Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
II 版 · Harness 工程 第 02 章 The Loop and When to Stop
II · Harness 工程 02 / 22 入门

Agent Loop 与终止条件

The Loop and When to Stop
预计阅读 32 分钟
难度 入门
关键词 ReAct · 终止条件
本机状态 未读

写一个循环谁都会:调用模型、解析动作、执行工具、把结果塞回去。难的是四件事——知道它该停下来、知道它卡住了、知道它绕圈子、以及在它需要人拍板时把控制权交出去。这一章把循环从一个 while 语句升级成一个可控的执行系统。

编辑部注 · 「什么时候该停下来」是本刊认为最被低估的工程问题。绝大多数线上事故不是模型不够聪明,而是循环没有正确的刹车。

先看一个未经训练的写法:while True: plan = model(ctx); ctx += act(plan)。它的失败方式多得惊人——模型可能永远说「再查一次」,可能反复调用同一个工具,可能在一个空结果上打转,也可能已经完成了却不知道要停。这一章把每一种失败都对应上一个明确的终止条件。

一、循环的骨架

Agent 循环的最小结构只有三步,这三步的命名在不同框架里不同,但语义一致:

阶段做什么失败模式
Think(思考)模型基于当前上下文决定下一步动作动作格式非法;在信息不足时硬猜;重复同一个动作
Act(行动)执行工具调用,产生副作用或获取信息参数错误;超时;权限不足;副作用不可逆
Observe(观察)把结果加工后回喂上下文结果过长挤爆上下文;错误信息丢失;把外部内容当指令

看起来很清晰,但工程上的麻烦几乎全部集中在「Observe → Think」这一段的回喂内容上。同一段工具结果,怎么截断、怎么标注来源、怎么标记可信度,直接决定下一轮模型会不会做出荒唐决策。

二、五类终止条件:一个都不能少

这是本章的核心。只写「任务完成就停」的系统,一定会以三种方式之一失控。

五道刹车:任何一道被触发都要退出循环 Think Act Observe 回到 Think(这一圈必须有刹车) ① 任务完成 模型明确声明结束 要求:必须是一个显式动作(如 finish 工具),不能靠「不再调用工具」隐式判断 ② 预算耗尽 轮数 / token / 时间 / 金额任一触顶 要求:退出时要返回「部分结果 + 已完成的步骤」,而不是一句失败 ③ 无进展检测 连续 N 轮状态未发生实质变化 判据:工具调用签名重复、观察结果哈希不变、产出文件无变更 ④ 需要人工 越权、不可逆操作、信息缺失 要求:暂停并保持状态可恢复,不能「假装问过了」继续往下跑 ⑤ 系统异常 模型输出无法解析 / 流式卡死 / 上游 5xx 要求:区分「可重试」与「不可重试」,且有最大重试次数(否则变成死循环) 为什么五类都要有(这是面试里最好用的一句话) 只写① → 模型判断失误时无限循环,成本失控(最常见的事故)。 只写② → 明明两轮就能做完,却因为轮数上限太低而中途放弃。 缺③ → 模型在两轮之间原地打转,预算被无声耗尽,日志里看不出异常。
图 1 五类终止条件分别覆盖不同的失控方式。第 ③ 类「无进展检测」是最容易被漏掉、也最能体现工程经验的一条——它处理的不是「跑不完」,而是「跑了但没动」,这类问题在日志里几乎看不出来。

三、无进展检测怎么做

不要指望模型自己意识到自己在绕圈。要在 Harness 侧用可计算的判据来检测。常用手段按可靠性排序:

判据计算方式优点局限
工具调用签名重复(tool_name, normalized_args) 做哈希,统计重复次数实现简单,几乎零误报参数只要有微小变化就检测不到(但那种情况往往也不是真进展)
观察结果哈希不变对工具返回内容取哈希,连续 3 轮相同即判定停滞能捕捉「换了参数但结果一样」返回内容带时间戳等噪声时会失效
状态指纹不变对「工作区文件列表 + 文件哈希」取指纹最接近「实质进展」的定义只适用于有文件系统产出的任务
模型自述停滞让模型在每轮输出一个 progress 标记能捕捉语义层面的绕圈不可靠,模型经常自信地认为自己有进展
成本斜率异常每轮 token 消耗持续上升但产出不变能提前预警上下文膨胀只能作为辅助信号
一个真实的反模式

很多实现只统计「同一个工具调用了多少次」,超过阈值就报错退出。问题是:合法的搜索任务本来就会调用同一工具很多次(换不同关键词检索)。

正确的判据不是「调用了多少次」,而是「调用之后状态有没有变化」。同样是调用 search 十次,每次返回不同内容是新信息;每次返回同一批结果才是死循环。这个区别决定了你的检测器是帮了忙还是帮了倒忙。

四、动手:一个带完整刹车的循环

agent_loop.pypython
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 天前),确认删除还是先备份?」才是可决策的。把选项和后果一起给出来。

第三,不是所有事都要问。 该自动化的自动化,该问的才问。判据是「可逆性 + 影响范围」:

操作类型示例策略
只读、无副作用检索、读取、查询直接执行,不问
可逆写操作写文件到工作目录、创建草稿自动执行 + 记录,可回滚
不可逆但有边界发送邮件到内部群、创建线上工单首次确认,同类操作可批量授权
不可逆且影响外部删除数据、支付、发布公开内容每次强制确认,且确认信息必须包含影响范围

六、自测

本章自测第 1、3 题为高频考点
Q1只实现「任务完成后退出」这一种终止条件,最可能发生什么?
C。这是线上最典型的事故形态。模型可能因为对「完成」的判断出错而持续重试,也可能在两轮之间空转,而没有预算上限与无进展检测的系统不会阻止它。「加一个 max_turns」是任何 Agent 上线的第一条纪律。
Q2为什么不应该用「本轮没有调用工具」作为完成信号?
B。「没调工具」是一个缺失信号,不是肯定信号,它无法区分「我做完了」和「我还在想」。工程上的原则是:状态的迁移必须由显式事件驱动,不能由「某个事件没发生」来推断。这一点在状态机设计里同样成立。
Q3检测「无进展」最可靠的判据是?
D。A 会误杀合法的多次检索(换关键词是正常行为);B 不可靠,模型经常误判自己;C 是预算控制而不是进展检测。只有「调用重复 + 结果不变」才同时满足「没有新信息进入」和「没有新动作产生」两个条件。

七、小结

要素必须做到
循环骨架Think / Act / Observe 三步,重点在 Observe 的回喂加工
终止条件五类齐全:完成、预算、无进展、需要人工、系统异常
无进展检测用「调用签名 + 结果哈希」,不要用调用次数
人工介入暂停可恢复;问题带选项与后果;按可逆性分级而非一律询问
退出时返回部分结果与已完成步骤,不返回一句失败

循环的上限由模型的推理能力决定,循环的可靠性由刹车决定。而线上事故绝大多数出在刹车上。本刊编辑部

刹车装好了,下一章处理最容易出问题的那一层:工具。因为工具是模型唯一能真正改变世界的手。

面试官会怎么问

共 4 条 · 其中 2 条高频 · 先自己答一遍,再展开对照

Agent Loop 的终止条件你会设计哪几类? 高频
五类缺一不可:
① 显式完成——模型明确表示任务达成(要做好格式约束,不要靠正则猜自然语言);
② 资源用尽——最大轮数、token 预算上限、时间上限,三者都要有,因为不同任务会先撞到不同的墙;
③ 无进展检测——连续 N 轮动作或工具调用高度相似、或者没有产生新的有效信息,判定为原地打转并中断;
④ 循环检测——A→B→A 这类振荡模式;
⑤ 主动挂起——需要人工确认、缺少必要信息、或者触及高风险操作时,转 Human-in-the-loop 而不是硬着头皮往下走。
还要有一个兜底:异常终止也必须产出可读的中间结果,而不是把已经完成的工作丢掉。

追问链

  1. 如果只允许你保留一个条件,你留哪个?为什么?
  2. 无进展检测具体怎么实现?
加分点:主动说"异常终止也要保留中间产物",这是有生产经验的人才会想到的点。
模型开始重复同一个动作不收敛,你系统层面怎么发现? 高频
不指望模型自己发现,要在 Harness 层做判定。可用的信号有四种:
① 动作指纹去重——把每轮的工具名 + 规范化参数(排序后哈希)作为指纹,连续重复即判定打转;
② 结果熵检测——工具返回内容高度相似甚至完全一致,说明再调也拿不到新信息;
③ 目标相似度——对比相邻几轮的思考文本相似度,超过阈值说明在原地复述;
④ 进度信号——为任务维护"已完成子目标集合",若连续多轮集合不增长,即无进展。
检出之后的处理分三级:先注入提示纠偏(告诉它已经做过什么)→ 再强制换策略(禁用刚才那个工具)→ 最后中断并交出中间结果并说明卡在哪。
加分点:给出"三级处理"而不是直接中断,说明你考虑过误判成本——这是判断力的体现。
一次 loop 最多几轮?这个数怎么定?
不该拍一个固定值,应该按任务类型分层
简单查询型 3–5 轮(一次检索加一次总结足够);常规办事型 10–20 轮;代码修复类长任务可以放到 30–50 轮,但必须配预算上限兜底。
定这个数的依据是"分布 + 成本":看线上真实任务的轮数分布,取 P95 再留一点余量;同时算一下撞到上限时的最坏成本,确保可接受。
另外要区分软上限与硬上限:软上限触发时提示模型"轮数不多了,请收尾并给出当前结论",硬上限触发时直接中断。只设硬上限会让模型在最后一轮还去开一个新工具,交出一个半成品。
ReAct 和 Plan-and-Execute 该怎么选?
取决于任务的可预规划程度与失败代价
ReAct(边想边做)每轮根据最新观察决定下一步,适应性强、对信息不确定的任务友好,缺点是容易短视、可能反复试探、总轮数偏高。
Plan-and-Execute(先规划再执行)先生成步骤清单再逐步执行,优点是全局感强、可审计、可并行;缺点是计划一旦错误会一路错下去,且环境变化后计划失效。
实践中常见的组合是两层:外层用规划定大方向(3–6 步),内层用 ReAct 处理每一步的具体不确定性,并在关键节点重新规划。选择标准是:信息在开始时就基本齐备 → 偏规划;信息要边做边获得 → 偏 ReAct。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes