🛡️ ARK 智能体诊断报告

langchain-ai/langchain#38893 · ModelRetryMiddleware 把"不可重试异常"吞成一条正常 AIMessage——同一份契约在工具侧和模型侧行为相反 · 2026-07-24

📋

问题摘要

🟠

LangChain Agent 中间件对 retry_on 的官方契约是:不匹配的异常立即向上传播,不进入 on_failure 处理(#38884 文档明确)。 ToolRetryMiddleware 在 #38845 已按契约改为直接 re-raise, 但 ModelRetryMiddleware 漏改——不可重试异常被送进 _handle_failure, 在默认 on_failure="continue" 下被 转换成一条"正常"的错误文案 AIMessage, graph 若无其事继续跑,真实异常(类型、堆栈)就地蒸发。

中高 · 异常静默吞噬 同一契约双侧行为相反 用户显式排除的异常仍被吞

影响范围

所有使用 ModelRetryMiddleware + 自定义 retry_on 的 Agent

框架现状

⚠️ open · bug/langchain 标签 · 8 评论

ARK 方案

✅ OutputValidator + CircuitBreaker

🔍

根因定位

🧬 现象链

用户配置 retry_on=(ValueError,), 显式表达"其它异常我要自己处理" → 模型调用抛出 TypeError(不匹配)→ ModelRetryMiddleware 不重试,但也不抛出, 而是走 _handle_failure → 默认 on_failure="continue" 生成一条 "Model call failed after 1 attempt with TypeError: boom" 的 AIMessage → graph 把它当模型回答继续执行 → 上层 try/except 永远等不到那个 TypeError → 排障时只看到一条"模型说失败了"的聊天记录

⚙️ 根因

langchain/agents/middleware/model_retry.py 的 sync/async 两条路径仍保留 #38845 之前的旧代码: if not should_retry_exception(...): return self._handle_failure(...)。 #38845 只改了 tool_retry.py(改为 bare raise), #38884 又基于新行为写死了文档契约——模型侧从未同步,属 双胞胎代码只改一半的漏改型回归(连注释都原样留在旧分支里)。
💡 本质:这是典型的 "错误被转换成正常输出"问题——异常一旦被降级为普通消息, 下游所有基于异常的防御(try/except、重试编排、告警)全部失效。 错误处理路径必须与成功路径产出可区分的输出, 这正是 ARK OutputValidator(输出必须满足"真实回答"不变式)+ CircuitBreaker(连续失败快速熔断)的守备范围。
📊

关键证据

📄 复现(issue 提供零网络最小复现:同配置、同异常,双侧行为相反)

# retry_on=(ValueError,),handler 抛 TypeError(不匹配)
model_mw = ModelRetryMiddleware(max_retries=2, retry_on=(ValueError,))
result = model_mw.wrap_model_call(model_request, failing_handler)
# ❌ 模型侧:不抛异常,返回 ModelResponse
#    content = "Model call failed after 1 attempt with TypeError: boom"

tool_mw = ToolRetryMiddleware(max_retries=2, retry_on=(ValueError,))
tool_mw.wrap_tool_call(tool_request, failing_handler)
# ✅ 工具侧:TypeError 正常向上传播(#38845 修复后的契约行为)

📄 根因代码(model_retry.py,#38845 之前的旧逻辑原样残留)

if not should_retry_exception(exc, self.retry_on):
    # Exception is not retryable, handle failure immediately
    return self._handle_failure(exc, attempts_made)   # ❌ 吞掉转成 AIMessage

# 工具侧同一分支(#38845 修复后):
if not should_retry_exception(exc, self.retry_on):
    # Exception is not retryable, re-raise immediately
    raise                                              # ✅ 契约行为

失败可见性

静默(异常变聊天文本)

触发条件

retry_on 未覆盖的任意异常

文档契约

#38884 已写明应传播

🔧

ARK 一键修复

✅ 方案A(推荐):OutputValidator 拦截"伪装成回答的错误"

from ark import OutputValidator

# 不变式:模型输出必须是真实回答,不是错误文案
schema = {
    "type": "object",
    "fields": {
        "content":  {"type": "string", "min_length": 1,
                     "not_contains": ["Model call failed", "failed after"]},
        "is_error": {"type": "boolean", "equals": False},
    },
    "required": ["content"],
}
validator = OutputValidator(schema)

result = agent.invoke(task)
validator.validate({"content": result.content})
# 错误文案 AIMessage → 立即 ValidationError,而不是流向用户/下游节点

💡 被吞掉的 TypeError 在进入 graph 下一节点之前被拦下,异常重新变回异常。

✅ 方案B:CircuitBreaker 阻止"错误消息洪流"

from ark import CircuitBreaker

breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=30)

# 配合方案A:验证失败计入熔断
# 连续 3 次"伪回答" → 熔断打开 → 快速失败
# 防止配置错误/上游故障期间,错误文案持续污染会话与下游存储

💡 被降级的异常不再有重试语义,熔断器补回"连续失败必须停下"这层保护。

✅ 方案C:上游修复(对齐 #38845 的工具侧改法)

# model_retry.py sync/async 两处,对齐 tool_retry.py:
if not should_retry_exception(exc, self.retry_on):
    raise   # 与 #38884 文档契约一致:不匹配的异常立即传播

💡 上游一行修复即可对齐契约;但方案A/B 的输出不变式+熔断仍是长期防线——下一处"双胞胎漏改"出现时你第一时间知道。

📈

健康得分

55

55/100 · 重试功能可用,但异常契约破坏且错误伪装成正常输出

稳定性70
效率80
正确性(错误可见性)30

💡 健康得分 55/100。错误可见性仅 30——用户显式排除在 retry_on 之外的异常, 本应由自己的代码接住,却被中间件降级成一条聊天消息, 所有基于异常的防御体系被绕过。 接入 ARK OutputValidator(伪回答拦截)+ CircuitBreaker(连续失败熔断)后, 被吞的异常 1 秒内重新可见,可信度分可回到 90+

本报告由 ARK 生成 · 智能体健康感知系统
langchain-ai/langchain#38893 · 锚定自真实 Issue(open · bug/langchain · 零网络最小复现 · 契约文档 #38884 佐证)