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 又基于新行为写死了文档契约——模型侧从未同步,属
双胞胎代码只改一半的漏改型回归(连注释都原样留在旧分支里)。
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 已写明应传播
✅ 方案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/100 · 重试功能可用,但异常契约破坏且错误伪装成正常输出
💡 健康得分 55/100。错误可见性仅 30——用户显式排除在 retry_on 之外的异常,
本应由自己的代码接住,却被中间件降级成一条聊天消息,
所有基于异常的防御体系被绕过。
接入 ARK OutputValidator(伪回答拦截)+
CircuitBreaker(连续失败熔断)后,
被吞的异常 1 秒内重新可见,可信度分可回到 90+。
本报告由 ARK 生成 · 智能体健康感知系统
langchain-ai/langchain#38893
· 锚定自真实 Issue(open · bug/langchain · 零网络最小复现 · 契约文档 #38884 佐证)