模型已经触及输出上限,Agent 为什么还会重试?一次 DeerFlow 终止状态修复

模型返回了 finish_reason=length,Agent 却又调用了两次模型。把重试次数设得更小,或在提示词里强调“不要重复”,都没有触及问题核心:识别终止原因的组件,和决定下一步执行的组件,没有收到同一份状态。

本文复盘 DeerFlow 的一个真实修复:PR #7605。该 PR 已于 2026 年 11 月 2 日合并。回归测试中,受影响场景的模型调用由三次变为一次。这个数字来自确定性执行图测试,不能直接换算为生产环境的 Token、延迟或费用收益。

从“还有 Todo”到两次多余调用

Agent 的 Todo 能力不只是展示任务列表。当模型准备结束,而任务列表还有未完成项时,TodoMiddleware 可以注入完成提醒,让模型继续执行。这能纠正模型正常提前退出的情况。

输出长度上限是另一种情况。模型可能已经生成了可见文字,但由于供应商的输出预算耗尽而停止。如果下一轮仍带着相同上下文和限制,再加一句“请继续完成任务”,并不意味着模型就能正常完成。

问题发生在以下条件同时成立时:

  1. 状态中存在未完成 Todo。
  2. 模型响应被识别为输出长度截断。
  3. 响应包含可见文字,没有工具调用。

修复前,真实执行图在脚本化响应下走出这条路径:

第一次模型调用:输出截断,但消息未携带终止标记
  → Todo 检查发现任务未完成,安排完成提醒
第二次模型调用:再次截断
  → 再安排一次完成提醒
第三次模型调用:再次截断,提醒次数达到上限后退出

这不是无限循环。Todo 已有提醒次数上限,因此这组场景是两次额外调用。准确描述边界很重要:修复一个有界的无效重试问题,也具有工程价值,无需把它说成无限调用风暴。

根因:运行时知道,消息消费者不知道

DeerFlow 的 ModelLengthFinishReasonMiddleware 负责识别供应商返回的长度终止信息,并记录运行时的 model_length_capped 原因。与此同时,下游 Todo 完成守卫读取的是助手消息 additional_kwargs 中的 model_length_termination。

两个字段服务于不同消费者。运行时原因用于本次执行的状态与诊断,消息标记用于消息处理链上的控制判断。前者已经写入,并不能推导后者一定存在。

旧逻辑对“纯文字且有可见内容”的截断响应提前返回,导致没有写入消息标记。于是长度检测器认为已经识别了终止,Todo 守卫却把它当成一次普通的提前结束。

还有一个容易漏掉的条件:中间件执行顺序。实际 Agent 图中,Todo 的注册位置在 ModelLength 之前,而 after_model 按逆序执行,ModelLength 才能先标记消息、Todo 再读取结果。只孤立调用两个 hook,无法证明真实图的顺序正确。

修复:补齐既有信号,保留已有输出

修复复用了已有的 model_length_termination 协议:每次新识别到长度截断,都给响应写入这个标记,包括之前遗漏的纯文字分支。

下面是解释控制流的简化伪代码,并非可直接替换源码的完整实现:

def handle_length_cap(message):
    if not detect_length_cap(message):
        return None

    record_runtime_stop_reason()
    cleaned = suppress_incomplete_tool_calls(message)

    if already_stamped_and_cleaned(cleaned):
        return None

    result = copy_message(cleaned)
    result.metadata["model_length_termination"] = cap_evidence()

    if had_tool_calls(message) or not has_visible_content(cleaned):
        append_length_notice(result)
    return result

对纯文字截断,用户可见文字保持原样,不额外追加提示。对于被截断的工具参数,原有抑制逻辑继续生效,避免半截工具调用进入执行节点;空响应也保留原有提示行为。

这里的“终止”是停止 Todo 完成提醒重新拉起模型,不是把 Todo 强行标成完成,也不是取消整个线程。正常提前退出仍可得到完成提醒,暂时性失败的提醒恢复路径也应继续工作。不同终止原因需要分别处理,不能用一个全局“停止重试”开关覆盖所有情况。

如何证明修复走到了真实路径

回归测试使用真实 LangGraph 执行图和脚本化模型响应,不依赖在线供应商。模型可控,执行路径仍然真实,适合稳定验证“是否又调用了一次”。

测试覆盖五类供应商元数据或内容表示,再分别运行同步与异步路径,共十个场景。修复前十个测试全部失败,每次观察到三次调用;修复后每个场景只调用一次。

除了调用次数,还需要检查相关约束:

验证点防止的回归
纯文字与原元数据保留,输入消息不被原地修改内部标记意外改变用户看到的回答或共享对象
截断工具调用仍被抑制不完整参数进入真实工具执行
已处理消息再次经过 hook 时保持幂等重复覆盖首次抑制数量等诊断信息
正常提前退出仍能触发 Todo 提醒修复范围过大,削弱原有完成守卫
子 Agent 的终止原因仍可消费主 Agent 修好了,子 Agent 状态传递退化

提交时相关本地套件为 221 项通过,阻塞 I/O 检查为 338 项通过、1 项跳过。随后审查者在独立工作树重跑红测,并核对内容保留、工具抑制和中间件顺序。合并前 PR head 的后端分片、前端、Lint、E2E 与 CLA 检查通过。

这些验证有明确边界:没有运行真实付费模型的性能基准,也没有据此证明所有供应商都能在生产环境节省同样比例的成本。合并后的主分支还出现过独立的文档预算检查失败,后由另一项上游修复处理;合并前一次全绿不能代表后续主分支持续健康。

对 Agent Runtime 设计的三个启发

第一,检查终止条件时,应沿着信号的消费者追踪,而不只看生产者是否写过状态。日志里出现“已经触及上限”,并不足以证明调度器会停下来。

第二,状态的存放位置属于协议设计。运行时上下文、消息元数据、持久化检查点都有不同生命周期。把一个字段搬到另一个位置,可能改变读取范围、重放语义和并发隔离。此次修复选择补齐已有消息协议,避免给 Todo 再增加一套全局状态依赖。

第三,测试应落在错误真正出现的边界。这个问题发生在“模型返回之后、下次模型调用之前”,因此真实执行图中的调用次数,是比单独断言字典里多了一个字段更强的证据。字段测试验证局部修改,图测试验证修改是否产生了期望行为。

Agent 的可靠性往往藏在组件之间:检测器正确、Todo 检查正确,组合起来仍可能重复执行。排查这类问题时,可以从一次异常调用出发,画出终止信号由谁产生、存在哪里、谁读取,以及读取发生在什么时候,再用可控执行图把链路固定下来。

参考与说明

本文依据公开源码、回归测试与上游审查记录整理。文中的简化伪代码用于解释机制,具体实现以对应提交为准。

Logo

更多推荐