Published on

别让兜底回答伪装成功:流式 Agent 的阶段预算、失败终态与安全恢复

Authors
  • avatar
    Name
    hpoenixf
    Twitter

别让兜底回答伪装成功:流式 Agent 的阶段预算、失败终态与安全恢复

一个 Agent 最危险的失败,不是明确报错,而是在规划已经失效、证据没有取得时,仍然给出一段看起来正常的回答。

用户向 Agent 提问后,页面转了很久,最后出现一段语句通顺的文字。这个请求成功了吗?

未必。规划器可能没有生成合法计划,工具可能从未被调用,回答也可能只是失败恢复阶段拼出的通用文本。系统有输出,不代表它完成了用户交给它的任务。

本文把规划、取证、生成和流式交付拆成不同阶段,并分别说明它们的时钟、成功条件和失败状态。恢复可以延续已经验证的事实,不能把技术故障改写成业务完成。

文中的测试数量和耗时来自一次受控开发与验证窗口,用来说明判断方法,不构成通用性能承诺。长期生产失败率与用户重试成功率仍未采集。

一、先定义成功:不是“有文字”,而是任务闭环

传统接口很容易把一次请求压缩成三个状态:处理中、成功、失败。Agent 不适合这么简单的模型,因为一次回答内部还包含规划、工具调用、证据校验、语言生成和流式传输。

如果只看最终有没有文字,下面三种请求都会被误判为成功:

  • 规划失败后,系统改用一个不需要证据的通用任务继续回答;
  • 工具已经成功,但模型续答失败,页面只保留了中间状态;
  • 流正常结束,却没有正文、合法工具调用或明确终态。

更可靠的成功定义至少包含四层。

层次要回答的问题最小成功条件
规划系统是否理解了目标和证据需求产生通过结构校验的计划
取证所需事实是否真的被取得必需工具已调用,来源和时效可核验
生成是否形成可交付回答正文非空,关键断言有证据支持
交付用户是否真正收到结果流中存在唯一、可重放的终态

其中任一层失败,都不能被下一层自动“修成成功”。规划失败时,即使模型能写一段常识,也没有资格声称本次数据任务已经完成。

可以把一次成功写成一个最小判定式:

Success = PlanValid
       ∧ EvidenceSatisfied
       ∧ AnswerDeliverable
       ∧ TerminalCommitted

这里使用逻辑“与”,意味着四项缺一不可。某项未知时,整体也只能是未知,不能因为另外三项通过就补成成功。

这个表达式比平均分更适合高风险任务。平均分允许“语言很好”抵消“证据不存在”,而逻辑门明确规定:再流畅的回答也不能替缺失证据背书。

1.1 兜底回答为何是一种任务类型错误

规划失败后改答常识,看起来只是降级,实质上却可能改变了任务类型。

“查询某指数最近表现”是一个需要当前证据的数据任务;“解释指数是什么”是一个可以使用稳定知识的概念任务。二者话题相近,却有完全不同的完成条件。

如果系统在运行中悄悄把前者替换成后者,用户收到的不是低质量版本,而是另一个问题的正确答案。这类错误比显式失败更难发现,因为文本表面上仍然相关。

因此,降级只能减少承诺,不能替换目标。可以从“给出精确数值”降级为“披露证据缺口”,却不能从“回答当前事实”降级为“讲一段一般知识”后仍标记完成。

1.2 证据状态和运行终态不能混成一列

证据状态说明本轮拿到了什么,不说明失败发生在哪个阶段,也不替代运行终态。

证据状态含义后续处理
grounded证据齐全且新鲜度满足要求只在证据支持的范围内回答
partial部分来源缺失,仍有可用证据保留有证据的部分,并披露缺口
unavailable证据通道不可用,或命中失败终态说明缺口;只有明确定义的恢复路径才能重试
no-evidence通道正常,但没有命中可用证据只报告本次范围内无命中,不推断事实不存在
not-requested本轮没有发起取证不把它当作工具失败,也不据此作事实断言

同一请求的运行阶段仍要单独记录:规划失败、工具失败、用户取消和流中断不是同一种原因。最终证据状态按术语表中的收敛顺序计算;后续成功不得覆盖失败终态。

二、为什么一个 timeout 不够用

在流式 Agent 中,“超时”可能指完全不同的事情。

模型可能长时间没有产生有效正文;工具可能在等待外部数据;请求可能排队很久但实际执行很快;浏览器也可能先于服务端停止观察。它们需要不同的处理方式。

一次真实诊断中,某个质量检查从页面看耗时约 360 秒,但其中约 347 秒属于排队,实际执行约 13 秒并正常完成。若直接把墙钟时间记成执行超时,后续就会错误地调整模型或工具预算。

因此至少要区分四种时钟:

时钟从哪里开始用来判断什么
排队时间请求进入调度队列容量与并发是否不足
阶段执行时间某个阶段真正获得执行权当前模型、工具或校验器是否过慢
无进展时间最近一次有效进展之后当前阶段是否已经失去响应
整体截止时间整条业务请求开始用户最多需要等待多久

“有效进展”也不能只看网络心跳。空白 token、usage 事件和连接保活,只能证明传输仍然存在;可见正文、合法工具调用或结构化计划片段,才可能刷新业务上的无进展计时。

阶段预算应受整体截止时间约束。即使某个阶段拥有更长的理论预算,也只能使用整条请求的剩余时间。这样可以避免每个阶段都合法等待,最终却让整条请求无限延长。

三、主流程:阶段失败不能改变任务性质

下面是一条简化后的运行流程。关键不在于节点多少,而在于每个失败分支都有明确终态,并且不能偷偷回到“生成普通回答”。

graph TD
ReceiveRequest --> CreatePlan
CreatePlan --> ValidatePlan
ValidatePlan --> RecordPlanningFailure
RecordPlanningFailure --> SaveCheckpoint
SaveCheckpoint --> RetryableFailure
ValidatePlan --> ReadEvidence
ReadEvidence --> CheckEvidence
CheckEvidence --> RecordEvidenceMissing
RecordEvidenceMissing --> DiscloseLimitations
CheckEvidence --> GenerateAnswer
GenerateAnswer --> CheckAnswer
CheckAnswer --> RecordInterrupted
RecordInterrupted --> KeepVerifiedText
CheckAnswer --> CompleteOnce
RetryableFailure --> ReplayRunState
DiscloseLimitations --> ReplayRunState
KeepVerifiedText --> ReplayRunState
CompleteOnce --> ReplayRunState

关系或转移条件:

  • ValidatePlan → RecordPlanningFailure:否。
  • ValidatePlan → ReadEvidence:是。
  • CheckEvidence → RecordEvidenceMissing:否。
  • CheckEvidence → GenerateAnswer:是。
  • CheckAnswer → RecordInterrupted:否。
  • CheckAnswer → CompleteOnce:是。

这个流程遵守一条核心规则:恢复可以延续任务,但不能改写任务。

用户问的是当前行情,规划失败后不能把任务改成“解释行情的一般知识”;用户问的是个人持仓,证据不可用时不能拿历史摘要补成当前状态。

失败恢复首先要保存的是任务身份、已经确认的实体、时间范围、证据需求和已验证片段,而不是尽快凑出一段正文。

四、运行状态需要记录哪些信息

要让失败可恢复,系统必须保存比错误字符串更丰富的状态。一个教学级运行记录可以包含以下字段:

{
  "input": {
    "question": "分析某个指数最近一段时间的表现",
    "requiresCurrentEvidence": true
  },
  "runId": "run-example",
  "clientMessageId": "message-example",
  "phase": "planning",
  "status": "planning_failed",
  "failure": {
    "category": "validation",
    "retryable": true,
    "reason": "planner_output_missing"
  },
  "checkpoint": {
    "lastValidTask": null,
    "evidenceNeeds": [],
    "validatedParts": []
  },
  "downstreamCalls": {
    "tools": 0,
    "answerModel": 0,
    "reviewer": 0
  },
  "delivery": {
    "terminal": "failed",
    "replayable": true
  }
}

phase 表示失败发生在哪里,status 表示业务终态,category 用于区分协议、供应商、权限或时限问题。checkpoint 只保存已经确认且允许继续使用的语义。

这个示例把“需要当前证据的分析请求”明确投影成规划失败,而不是把问题改写为一般知识问答。输入是否需要当前证据,是后续能否降级的关键槽位;一旦该槽位为真,系统就不能用无来源常识补齐结果。

downstreamCalls 是很重要的负向证据。只断言页面显示了失败还不够,还要证明失败之后没有偷偷调用工具、回答模型或评审器。

clientMessageId 用于识别同一个逻辑请求。网络重连时应重放已存在的运行,而不是创建一个新运行并重复调用上游资源。

4.1 合法检查点必须保持单调

检查点不是任意缓存,而是“到目前为止已经被验证、允许继续依赖的最小状态”。恢复过程应满足单调性:没有新证据,就不能让已确认事实集合变大。

设某时刻的可信状态为 K(t)。一次不包含新证据的重试,只允许保持或缩小它:

没有新证据时:K(t+1) ⊆ K(t)
取得并验证新证据后:K(t+1) = K(t) ∪ NewVerifiedFacts

这条规则阻止恢复逻辑把模型猜测、旧上下文或默认值悄悄加入可信状态。

多轮对话尤其需要这条边界。用户说“年份改成 2024,其他不变”,但本轮规划失败时,系统可以保留上一轮已经确认的标的和任务结构,却不能自动接受“2024”已经绑定成功。新值仍然属于未确认输入。

4.2 失败所有权决定谁可以恢复

“失败发生在哪一层”不仅用于日志分类,也决定恢复权限。

规划层可以重建计划,但不能复用一个从未合法存在的工具请求;工具层可以在同一计划下换用允许的后备来源,却不能修改用户目标;回答层可以复用已验证证据,却不能重新抓取更多私人数据。

如果恢复者拥有的权限超过失败层,就会出现越级修复。最典型的例子,是回答生成器发现缺数据后自行选择新工具。它绕过了规划和权限检查,即使最终数字正确,过程仍不可审计。

五、判定规则:何时重试,何时停止

不同失败不能共用一个重试开关。可以按“是否已有合法状态”和“重试是否会重复副作用”做判断。

一个可操作的重试资格可以写成:

RetryAllowed = TransientFailure
            ∧ ValidCheckpoint
            ∧ BudgetRemaining
            ∧ NoUnreplayableSideEffect
            ∧ SameAuthorizedTask

临时故障只是第一个条件。没有合法检查点、预算耗尽、已经产生不可安全重复的副作用,或重试会改变原任务时,都必须停止。

场景默认处理原因
规划输出为空或结构非法返回 planning_failed,允许显式重试没有合法计划,不能进入工具阶段
已有合法计划,临时供应商错误在共享预算内有限重试任务语义未丢失,重试目标明确
工具已成功,回答续写失败复用工具结果,只恢复回答阶段从头执行会重复成本和外部调用
已产生合法部分回答保留片段,不静默重写重写可能改变已经交付的事实
浏览器普通断线解除订阅,不取消服务端运行网络连接不等于业务生命周期
用户显式停止取消运行并写入唯一终态用户意图高于自动恢复
结果格式错误且修复预算耗尽失败关闭,不降级为自由文本自由文本会绕过结构与证据约束

重试预算必须共享,而不是每个子调用重新领取一份。否则一个看似“最多重试一次”的规则,经过多阶段嵌套后可能膨胀成多次外部请求。

对于模型评测和独立审核,重试策略还应更严格。交互式对话可以为临时网络问题做有限恢复,评测若自动重试却会改变样本成本与可比性,因此通常应保持零重试或单独记录每次尝试。

重试次数也不宜由每个函数各自维护。假设规划、工具和回答都允许“两次重试”,嵌套执行可能把一次用户请求扩张为多轮外部调用。共享预算让所有阶段从同一余额扣减,才能形成真正的全局上限。

这里还有一个容易忽略的区分:幂等不等于可免费重试。一个只读请求即使不会写数据,也可能消耗付费额度、占用稀缺租约或触发上游限频。副作用除了数据库写入,还包括成本与容量。

六、三个真实案例:错误为何总被归到错误层

案例一:工具成功了,失败发生在续答

一次组合查询连续多次表现为“网络错误”。拆开时间线后发现,工具在约 0.15—0.29 秒内已经成功,真正的失败发生在工具返回后的第二轮模型续答:等待约 30 秒仍没有形成正文。

如果按页面文案修网络层,问题不会消失;如果整条请求重跑,又会重复已经成功的工具调用。

正确做法是保存工具结果,把恢复范围限制在回答阶段。同时,无正文、无合法工具调用的流结束不能记为完成,而应进入明确的空完成终态。

案例二:墙钟很长,不代表执行超时

一次检查的总墙钟约 360 秒,其中排队约 347 秒,实际执行约 13 秒并成功结束。若只记录总耗时,会得出“模型执行了六分钟”的错误结论。

同样地,工具返回 unavailable 与工具根本没有被选择也是两类问题。前者说明调用链存在但数据不可用,后者说明计划、权限或调度链路在工具之前就断了。

这类诊断要求同时记录排队、实际执行、结束原因、工具调用和来源。最终文案只是结果,不是根因。

案例三:最危险的恢复,是把失败任务换成容易回答的任务

在一组固定的 48 条真实验证记录中,8 个运行里的 10 次规划调用出现了同类现象:模型持续输出内部推理片段(reasoning),却始终没有给出合法计划正文,最终到达阶段截止时间。

旧恢复逻辑随后构造了一个通用知识任务。实体、时间范围和证据需求在转换中丢失,系统没有调用原本需要的结构化工具,却仍然进入回答阶段。

其中一个简单请求经历两次完整规划和长时间排队,端到端耗时被放大到约 672 秒。问题不只是慢,而是失败后的任务已经不再是用户最初交付的任务。

修复后的策略是把它记录为 planning_failed。首轮请求没有合法计划时,只保存未知等待状态;多轮纠正失败时,可以保留上一轮合法任务作为安全检查点,但不能猜测本轮的新实体或新时间。

失败路径还必须满足三个零调用:工具调用为零、回答模型调用为零、评审调用为零。这样才能证明系统没有通过另一条隐蔽路径继续生成答案。

七、流式交付还需要一个独立状态机

业务运行和浏览器连接不是同一个生命周期。把二者绑在一起,会导致用户刷新页面时取消后台运行,或者断线重连后重复执行同一任务。

graph TD
Start --> Running
Running --> Detached
Detached --> Running
Running --> Completed
Running --> Failed
Running --> Cancelled
Detached --> Completed
Detached --> Failed
Completed --> Replayed
Failed --> Replayed
Cancelled --> Replayed

关系或转移条件:

  • Running → Detached:浏览器普通断线。
  • Detached → Running:同身份重新订阅。
  • Running → Completed:唯一完成终态。
  • Running → Failed:类型化失败终态。
  • Running → Cancelled:用户显式停止。
  • Detached → Completed:后台完成。
  • Detached → Failed:后台失败。
  • Completed → Replayed:重放已存结果。
  • Failed → Replayed:重放失败信息。
  • Cancelled → Replayed:重放取消状态。

每个运行只能持久化一个终态。完成、失败和取消互斥,后续重连只能重放,不能再把失败改成完成。

分块交付时也要保留对象边界:第一个回答块已提交、第二个块被拒绝,应留下第一个块并把整次结果标为部分;不能因第二块失败而回滚已提交内容,也不能把第一块当作完整答案。

这种约束可以理解为终态封印:一旦运行进入 terminal,后续事件只能补充读取视图,不能改变业务结论。若确实需要继续,必须创建一个带因果关联的新运行,而不是篡改旧运行。

新运行与旧运行之间应记录 causedBy 或等价关系。这样可以回答:这是用户主动追问、失败后的显式重试,还是系统自动恢复?没有因果身份的“继续执行”,很容易在统计中被误算为同一次成功。

如果 HTTP headers 已经到达,但正文和结束标记之前发生未知中断,也不应直接命名为 timeout。没有明确的截止时间证据时,使用 stream_interrupted 或保留 unknown,比编造精确根因更可靠。

八、安全边界:可靠性修复不能偷偷扩大权限

可靠性问题很容易诱导出一些看似有效的捷径:增加所有 timeout、自动切换模型、用关键词直接选择工具、失败后生成通用回答。这些办法可能提高表面的回答率,却会改变原有权限和证据边界。

一套安全的恢复机制至少守住以下约束:

  1. 没有合法计划,不调用任何需要该计划授权的工具。
  2. 已成功的外部调用不因下游失败而自动重放。
  3. 失败恢复不新增模型、工具、数据范围或执行权限。
  4. 当前事实缺少证据时,不用常识文本补齐。
  5. 日志只保存阶段、耗时、尝试次数和类型化原因,不记录完整提示词、原始工具载荷或私人数据。
  6. 自动重试、模型对冲和权限扩大必须作为独立设计评审,不能混入一次 bug 修复。

这些约束的价值在于:系统即使没有恢复成功,也不会因为“努力恢复”而做出更危险的事情。

九、验证失败机制,不能只看最终页面

可靠性测试要同时覆盖正向结果和负向调用。下面是一组可复用的检查面:

检查面需要证明的事实
规划失败产生类型化终态,没有进入取证和回答
工具后续答失败工具结果被复用,没有重复工具调用
空完成无正文且无合法工具调用时不能标记 completed
普通断线服务端运行继续,同身份可以重放
显式取消运行停止,只留下一个 cancelled 终态
多轮纠正失败保留上一合法任务,不采用未经确认的新值
诊断信息能区分排队、执行、无进展和整体截止时间

在本次受控窗口中,规划失败改造经过 84 项聚焦测试和 693 项相关回归;更大范围的离线检查中,Agent 侧 2,557 项、根侧 1,773 项通过。

这些数字证明的是本地契约与回归范围,不证明长期生产稳定性。修复后的真实规划失败率、供应商长尾表现、浏览器重连成功率和用户重试结果仍属于未采集项。

9.1 指标也要按阶段定义

上线后的监控不应只看总成功率。至少需要分别观察规划合法率、必需证据满足率、可见正文率、唯一终态率和显式重试成功率。

这些指标的分母必须保留原始请求,而不是只统计走到本阶段的样本。例如可以报告“证据满足数/合法计划数”,但还要保留“合法计划数/总请求数”,否则上游规划失败会从视野中消失。

阶段耗时也要同时给排队与执行分布。只看端到端 P95 无法判断该扩容、换模型还是修复状态机;只看模型执行 P95 又会忽略用户真实等待。

9.2 故障注入要验证“不发生什么”

可靠性测试最有价值的部分往往是否定断言:规划失败后工具调用为零,普通断线后业务取消为零,重放时新增模型调用为零,终态之后状态改写为零。

正向测试只能证明某条成功路径存在,负向测试才证明危险旁路不存在。对于带权限和外部成本的 Agent,这类零调用证据与最终正文同等重要。

同样,局部实现绿色不等于整个长期变更已经完成。实现证据、运行证据和完成声明必须分别记录。

十、把失败设计成一等结果

流式 Agent 的可靠性,不是想办法让每个请求都返回一段话,而是让每个请求都能诚实地结束。

规划失败就停在规划失败,证据不可用就披露证据缺口,回答中断就保留已验证片段,浏览器断线就等待重放。每一种状态都有自己的恢复权限,也有明确禁止跨越的边界。

最值得警惕的不是红色错误页,而是一个已经失去原任务、失去证据需求,却仍然显示 completed 的绿色结果。

可靠恢复的目标可以压缩成三句话:先判断失败发生在哪个阶段;只从最后一个合法检查点继续;宁可给出可重试的失败,也不要用无依据的回答伪装成功。

当失败成为结构化、可观察、可重放的一等结果,系统才真正拥有了恢复能力。否则所谓兜底,只是在把问题藏得更深。

证据边界与材料来源

本文根据以下工程复盘提炼,正文已将项目模块翻译为通用机制:

  • 规划失败安全终态
  • 阶段化对话时间预算
  • 对话续答、流式恢复与空完成
  • 48 条运行中的超时与工具调用审计
  • Research Runtime 的当前发布与交付合同