《AI Agent 开发入门》3.2 停止条件与循环上限

上节的 run() 只会跑到自然收敛,模型一卡住就束手无策。本节给循环装上五条止损线:无工具调用、达到 max_steps、stop_reason 为 max_tokens、工具连续失败、重复动作。我们用 AgentLoopError 把异常终止变成可诊断的数据,再用两个假模型分别制造死循环与原地打转,逐一验证每条止损线是否真的生效。

3.2 停止条件与循环上限

上一节的 run() 有一个隐含假设:模型总会自己停下来。真实世界不成立。模型会为了一个查不到的结果反复搜索、会固执地重复同一个动作、会因为输出被截断而给不出完整答案。如果循环只认「没有工具调用就结束」这一条出口,遇到这些情况就会一直转下去,直到 max_steps 用尽——而每一轮都在真金白银地烧 token。

本节把 minagent 推进到「会自己喊停」:在 minagent/loop.py 里补齐五条终止路径,用 AgentLoopError 把异常终止连同部分结果一起抛出,让调用方能诊断「为什么停的、停在哪一步」。

3.2.1 五种终止路径

一个健壮的循环不是只有一个出口,而是一组出口。把它们列全,是写对循环的第一步:

终止路径触发条件性质期望行为
自然收敛某轮 msg.tool_calls 为空成功返回 RunResult
达到步数上限循环跑满 max_steps异常抛错,附已走轨迹
输出被截断resp.stop_reason == "max_tokens"异常抛错,保留半截文本
工具连续失败连续 N 次工具返回 error异常抛错,附最后一次错误
重复动作相邻两次工具调用签名相同异常抛错,附已走轨迹

只有第一条是「好」的结束。后四条都是「坏」的结束——但「坏」不代表程序该崩,而是意味着我们要把失败变成一种可读的数据,交给上层去决定是重试、降级还是报错。这是整个章节的设计基调。

3.2.2 无工具调用:唯一的「成功」出口

这是上一节已有的逻辑,原样保留:

if not msg.tool_calls:
    steps.append(Step(index=i, kind="final", detail=msg.content))
    return RunResult(messages=messages, steps=steps, final=msg.content)

判据是「这一轮没有要求任何工具」。注意它和「content 非空」不是一回事:模型可能既说了话又调了工具,那种情况仍要执行工具、继续循环。只有工具列表为空,才轮到文本答案登场。

3.2.3 max_steps:兜底保险丝

max_steps 是最粗暴也最可靠的一道防线。它不判断模型「该不该停」,只规定「最多跑几步」。上一节它在超限时返回一个带 error 步的结果,本节改成立即抛出异常:

raise AgentLoopError("max_steps", RunResult(messages, steps, ""))

为什么默认是 8?这是一个经验值:绝大多数单文件任务(查资料、算数、读一篇文章)在 3~6 步内收敛,8 步留了余量又能把最坏成本压在可控范围。复杂任务可以调大,但要清楚每加一步就多一次模型调用的开销。第 11 章讲规划时,会用一个更大的上限配合计划检查点,而不是简单地把它调大。

3.2.4 输出被截断:先看 stop_reason

ModelResponse.stop_reason 是个容易被忽略但极重要的信号。它告诉我们模型为什么停下了:

stop_reason含义循环该怎么反应
end_turn正常说完正常处理
tool_use要求调工具执行工具
max_tokens输出达到长度上限被截断这一轮结果不完整,应中断

被截断的 content 是一句话说到一半,它看起来像「最终答案」,实则不是。如果不管 stop_reason,循环会把这个半截答案当成功返回,用户拿到一个残缺的结果却毫不知情。所以在追加消息之后、判断工具调用之前,先检查这一条:

if resp.stop_reason == "max_tokens":
    raise AgentLoopError("max_tokens", RunResult(messages, steps, msg.content))

这里把半截文本放进 final 一起抛出,是为了让调用方能看到「模型说到哪了」,而不是只拿到一个干巴巴的错误码。

3.2.5 工具连续失败

工具失败本身不是问题——上一节说过,registry.call() 出错时返回 {"error": ...} 的 JSON,让模型有机会读到错误并换个思路。真正的问题是连续失败:模型反复重试同一个坏参数,每次都拿到同样的错误,却始终不改。

做法是维护一个连续失败计数,任一成功就清零:

failures = failures + 1 if '"error"' in result else 0
if failures >= max_consecutive_failures:
    raise AgentLoopError("tool_failures", RunResult(messages, steps, ""))

判据是「工具返回的字符串里含 "error" 键」。这依赖第 4 章会强化的约定:工具的错误必须统一以 {"error": ...} 形式返回。默认阈值 3 次——给模型两次纠错机会,第三次还失败就认定它卡住了。

3.2.6 重复动作检测

最难缠的一种死循环是「原地打转」:模型每次都要调同一个工具、传同样的参数,比如反复搜同一个查不到的词。它每一步都在「正常」调用工具,max_steps 拦得住,但拦得太晚。

检测方法是给每次工具调用算一个签名,和上一次比对:

def _signature(tc: ToolCall) -> tuple[str, str]:
    return tc.name, json.dumps(tc.arguments, sort_keys=True)

sort_keys=True 保证 {"a": 1, "b": 2} 和 {"b": 2, "a": 1} 被视为同一个签名。只比较相邻两次——因为「查 A、查 B、再查 A」是合理的工作流,而「查 A、查 A」才是打转:

sig = _signature(tc)
if sig == last_sig:
    raise AgentLoopError("repeated_action", RunResult(messages, steps, ""))
last_sig = sig

这是一个极简但有效的启发式。它的漏网之鱼是「周期更长的循环」(A、B、A、B……),那种要用窗口内的集合去重,留到第 14 章讲重试与幂等时再展开。

3.2.7 把失败变成可诊断的数据

五条路径里有四条是异常终止。与其让它们各自抛不同的内置异常,不如统一成一个自定义异常,并把已经产生的部分结果挂上去:

class AgentLoopError(RuntimeError):
    """循环异常终止。携带 reason 与已产生的部分结果,便于诊断。"""

    def __init__(self, reason: str, result: RunResult) -> None:
        super().__init__(reason)
        self.reason = reason
        self.result = result

reason 是机器可读的字符串(max_steps / max_tokens / tool_failures / repeated_action),调用方据此决定后续动作;result 是出问题时已经走过的轨迹,日志、监控、调试全靠它。这就是「失败即数据」:错误不再是程序崩溃的终点,而是带着上下文的一个值。

完整的 run() 把五条路径串起来:

# minagent/loop.py
def run(model: Model, registry: ToolRegistry, user_input: str,
        system: str = "", max_steps: int = 8,
        max_consecutive_failures: int = 3) -> RunResult:
    messages: list[Message] = []
    if system:
        messages.append(Message(role="system", content=system))
    messages.append(Message(role="user", content=user_input))

    steps: list[Step] = []
    last_sig: tuple[str, str] | None = None
    failures = 0

    for i in range(max_steps):
        resp = model.complete(messages, registry.schemas())
        msg = resp.message
        messages.append(msg)

        if resp.stop_reason == "max_tokens":
            raise AgentLoopError("max_tokens", RunResult(messages, steps, msg.content))

        if not msg.tool_calls:
            steps.append(Step(index=i, kind="final", detail=msg.content))
            return RunResult(messages=messages, steps=steps, final=msg.content)

        for tc in msg.tool_calls:
            sig = _signature(tc)
            if sig == last_sig:
                raise AgentLoopError("repeated_action", RunResult(messages, steps, ""))
            last_sig = sig

            result = registry.call(tc)
            messages.append(Message(role="tool", content=result, tool_call_id=tc.id))
            steps.append(Step(index=i, kind="tool_call",
                              detail=f"{tc.name}({tc.arguments}) -> {result}"))

            failures = failures + 1 if '"error"' in result else 0
            if failures >= max_consecutive_failures:
                raise AgentLoopError("tool_failures", RunResult(messages, steps, ""))

    raise AgentLoopError("max_steps", RunResult(messages, steps, ""))

3.2.8 跑一遍:死循环与重复动作

验证止损线,最好的办法是故意制造它们。先写一个永远返回同一个工具调用的模型:

class LoopingModel:
    """永远返回同一个 tool_call。"""

    def __init__(self, tc: ToolCall) -> None:
        self.tc = tc

    def complete(self, messages, tools) -> ModelResponse:
        return ModelResponse(message=Message(role="assistant", tool_calls=[self.tc]),
                             stop_reason="tool_use")

再写一个「参数每次都变」的模型,用来绕过重复检测、单独验证 max_steps:

class CounterModel:
    """参数每次都变,躲过重复检测,但永不收敛。"""

    def __init__(self) -> None:
        self.n = 0

    def complete(self, messages, tools) -> ModelResponse:
        self.n += 1
        tc = ToolCall(id=f"c{self.n}", name="echo", arguments={"text": f"turn {self.n}"})
        return ModelResponse(message=Message(role="assistant", tool_calls=[tc]),
                             stop_reason="tool_use")

跑三种场景,看它们各自撞上哪条止损线:

try:
    run(CounterModel(), registry(), "开始", max_steps=3)
except AgentLoopError as exc:
    print("A) max_steps:", exc.reason, "| 已完成", len(exc.result.steps), "步")

same = ToolCall(id="c1", name="echo", arguments={"text": "ping"})
try:
    run(LoopingModel(same), registry(), "开始", max_steps=8)
except AgentLoopError as exc:
    print("B) repeated_action:", exc.reason, "| 已完成", len(exc.result.steps), "步")

truncated = ModelResponse(
    message=Message(role="assistant", content="我先算加法得"),
    stop_reason="max_tokens",
)
try:
    run(FakeModel([truncated]), registry(), "请计算 (2+3)*4")
except AgentLoopError as exc:
    print("C) max_tokens:", exc.reason, "| 保留文本", repr(exc.result.final))

输出:

A) max_steps: max_steps | 已完成 3 步
B) repeated_action: repeated_action | 已完成 1 步
C) max_tokens: max_tokens | 保留文本 '我先算加法得'

三个结果值得逐条品味:

  • A 里 CounterModel 每步参数都不同,重复检测抓不到它,于是老老实实跑满 3 步后被 max_steps 拦下,exc.result.steps 恰好是 3 条。
  • B 里 LoopingModel 每次签名都一样,第二次就触发 repeated_action,只走了 1 步就停——比等到 max_steps=8 早了 7 步,省下的都是真金白银。
  • C 里模型没说完整就被截断,循环靠 stop_reason 提前收手,并把半截文本保留在 exc.result.final 里供排查。

三个场景都证明了一件事:止损不是靠运气,而是靠显式写死的条件。每一条路径都有对应的假模型可以复现,也就意味着每一条路径都可以写进自动化测试。

3.2.9 真实模型下会怎样

假模型是「为演示而生」的:CounterModel 无限循环、LoopingModel 无限重复,都是极端构造。真实模型的行为处在两个极端之间——它通常会收敛,但会偶尔陷入打转;它通常输出完整,但偶尔会被 max_tokens 截断。所以这些止损线平时看起来「从没触发过」,恰恰是因为它们在暗中兜底。

真实场景里还有两个假模型模拟不出来的失败模式。其一是语义重复:模型换了措辞、改了参数名(q 变成 query),签名比对认不出来,但实质上在问同一个问题——这需要更聪明的相似度判断。其二是工具失败但模型不自知:它拿到 {"error": ...} 后不纠正,反而把错误当成正常结果继续推进。这两种都只能靠更精细的策略去覆盖,第 14 章会系统展开。

但方向是对的:先把能显式判断的都判断掉,把异常变成带 reason 的数据。剩下的模糊地带,才是真正需要「智能」去处理的部分。

run() 现在既会跑也会停,但它是函数式的——每次调用都要重传 model、registry、system,状态也散在局部变量里。下一节 3.3 抽成 Agent 类:状态、步骤与回调 会把它收进一个对象,让配置、历史与回调各归其位。想回看上一节的循环骨架,见 3.1 朴素循环:跑到没有工具调用为止 。

阅读导航:上一节:3.1 朴素循环:跑到没有工具调用为止 · 下一节:3.3 抽成 Agent 类:状态、步骤与回调 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距