Skip to content

feat(agents): 网络类错误持续重试中间件,断网恢复后任务自动继续 - #1007

Open
zgpnuaa wants to merge 6 commits into
xerrors:mainfrom
zgpnuaa:feat/network-retry
Open

feat(agents): 网络类错误持续重试中间件,断网恢复后任务自动继续#1007
zgpnuaa wants to merge 6 commits into
xerrors:mainfrom
zgpnuaa:feat/network-retry

Conversation

@zgpnuaa

@zgpnuaa zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

现象

断网时,模型调用报 OpenAIConnectionErrorModelRetryMiddleware 只重试 3 次就放弃,把错误文本写成 assistant 消息、Run 标记 completed。用户恢复网络后,任务已经「假完成」,还得手动重来。

对标

Claude Code 断网恢复后能自动继续——网络错误不烧尽重试预算、按退避持续重试、重试期间任务挂起而非取消。

机理

现有的 ModelRetryMiddleware(max_retries=2) 对所有错误一视同仁,都只重试固定次数。但网络错误是可自愈的(恢复后重试就成功),逻辑错误不可自愈(重试也没用),两者没有区分。

改进方法

新增 NetworkRetryMiddleware,挂在 ModelRetryMiddleware 内层(先拦截异常):

  • 按异常类名 + 消息关键词识别网络类错误(connection/timeout/5xx),排除 ratelimit/auth/invalid_request;
  • 网络类错误指数退避重试(2s→30s 封顶),总预算 YUXI_NETWORK_RETRY_BUDGET_SECONDS(默认 600s)内不抛错;
  • 预算耗尽或非网络错误透传原语义;CancelledError 不吞。

中间件顺序是关键:langchain 中间件列表排后者为内层(先拦截),必须放 ModelRetry 之内——ModelRetry 默认 on_failure=continue 会把异常吞成错误 AIMessage,NetworkRetry 若在外层就永远看不到网络异常。

效果

断网 100+ 秒任务保持 running,恢复后自动继续完成,零干预;正常逻辑错误仍按原语义快速失败。附 6 个单测覆盖错误分类、退避重试成功、非网络错误透传、预算耗尽、取消不吞。

@xerrors

xerrors commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Codex Review:

预 review 基于 8948ab3bc444

发现 1 个需修复的问题。

[P2] 外层 ModelRetry 会重置网络重试预算。 network_retry.py:89 每次 awrap_model_call 都重新记录 started;graph.py:61 又把它放在默认重试 2 次的 ModelRetry 内层。持续断网时,每轮预算耗尽后外层会再次进入一个全新的 600 秒预算,配置的“总预算”实际可重复 3 次。

已按实际装配顺序组合两个中间件,用虚拟 monotonic/sleep 与持续 ConnectionError 复现:budget_seconds=600max_retries=2,累计等待 1800 秒(外层额外退避设为 0)。最终外层仍返回含错误 AIMessage 的成功 ModelResponse,所以也不能据当前测试宣称已消除“假完成”。请让同一次模型调用的预算跨外层重试共享,或避免外层再次重试已耗尽的网络错误,并明确预算耗尽后的失败语义。

验证:该 head 独立快照执行 PYTHONPATH=package:server python -m pytest test/unit/agents/test_network_retry.py -q --disable-warnings --tb=short,17 passed;另执行了上述双中间件组合复现。新增单测只覆盖单个中间件,未覆盖实际组合。未运行真实断网/恢复/用户取消的 worker E2E;请补充此证据和 tracked decision。

- retry_on=retry_non_network_errors: 预算耗尽后网络错误直接失败,不再被 on_failure 吞成假完成
- 预算不再被外层放大(max_retries+1 次全新 600s)
- 组合装配顺序的回归测试(虚拟时钟) + 非网络错误重试语义不变
- tracked decision
@zgpnuaa

zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

感谢 review,复现准确,已修复(最新 head 085025c9)。

[P2] 已修复:预算由 NetworkRetry 独家拥有,外层不再重试网络错误

确认根因:NetworkRetryMiddleware.awrap_model_call 每次调用都重新记录起始时间,而外层 ModelRetryMiddleware 仍重试网络类错误,于是每轮都开启一个全新的 600s 预算(实际 600s × (max_retries+1)),且耗尽后被 on_failure="continue" 吞成含错误文本的 AIMessage

采用你给的第一个方向(避免外层再次重试已耗尽的网络错误):

  • 新增 retry_non_network_errors(exc)is_network_error(exc) 为真时返回 False,否则回落到 default_retry_on(exc)(保留 ModelError.is_retryable 等默认语义);
  • graph.pyModelRetryMiddleware(max_retries=..., retry_on=retry_non_network_errors)

ModelRetryMiddlewareretry_on 返回 False 的异常直接抛出、不经过 on_failure(源码注释与实现一致),因此:

  • 预算不再被放大:NetworkRetryMiddleware.budget_seconds 是真实上限;
  • 预算耗尽后的失败语义明确:抛出的仍是原始网络异常(保留 error_type/error_message 归因),Run 以 failed 结束,不再出现「假完成」;
  • 非网络错误的重试语义不变。

证据

新增 test_composed_middlewares_honor_budget_and_fail_explicitly:按真实装配顺序组合 ModelRetryMiddleware(retry_on=retry_non_network_errors)NetworkRetryMiddleware(budget_seconds=600),用虚拟时钟驱动持续 ConnectionError,断言

  • 累计等待 0 < sum(sleeps) <= 600(未被放大成 3 份);
  • 最终 pytest.raises(FakeError) —— 异常显式抛出,而不是返回含错误文本的响应。

另有 test_non_network_error_still_retried_by_outer_model_retry 确认非网络错误仍由外层按 max_retries 重试成功。

tracked decision:docs/develop-guides/decisions/implemented/2026-09-10-network-retry-budget-ownership.md

验证:test/unit/agents 156 passed;ruff check/format/select-I 通过。未运行真实断网/恢复 worker E2E。

@xerrors

xerrors commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Codex Review:

仅供参考:本轮预 review 基于 085025c9830b,不是正式批准或合并结论。

复查上次预算问题:本次修复已阻止外层 ModelRetry 为网络错误反复开启新预算。 retry_on=retry_non_network_errors 已在主图装配,内层预算耗尽后网络异常直接抛出,外层不再返回错误 AIMessage;非网络错误继续交给原来的重试判定。tracked decision 和组合回归已补充。

实际验证:当前 head 独立快照执行 PYTHONPATH=package:server python -m pytest test/unit/agents/test_network_retry.py -q --disable-warnings --tb=short,19 passed。新增测试按实际顺序组合两个中间件,虚拟时钟断言 600 秒预算不被放大,并断言耗尽时异常向外抛出;本轮未发现新的可确认缺陷。

范围限制:这里确认的是单次模型调用的重试预算。未执行真实断网恢复、用户取消、worker 最终持久状态的 E2E,不把中间件抛异常等同于已经验证完整 Run 的失败/恢复结果。

@xerrors

xerrors commented Sep 10, 2026

Copy link
Copy Markdown
Owner

很好的角度,不过我还有有一个小建议,直接继承原有的 model_retry 的 middleware,直接放在一个中间件里面,由一个开关控制以及单独的阈值 处理 network 的相关错误。

仅是建议,看一下是否可行

按作者建议,把 NetworkRetryMiddleware 改为继承 ModelRetryMiddleware,
在一个中间件内区分两类错误的重试维度:

- 网络错误:按 network_budget_seconds(默认600s) 预算退避重试,耗尽显式抛出,
  Run 以 failed 结束,不再假完成;
- 非网络错误:复用父类 max_retries/retry_on(on_failure) 语义。

移除了 retry_non_network_errors 谓词与 graph 里的双中间件装配,避免因装配顺序
或外层重试网络错误而放大预算。网络重试参数用 network_ 前缀与父类非网络重试的
initial_delay/max_delay 区分。

子智能体 graph 同步改用 NetworkRetryMiddleware,补齐其断网恢复能力
(此前只用裸 ModelRetryMiddleware,断网时仍会假完成)。
@zgpnuaa

zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

感谢建议,合并方案可行,已按此重构(最新 head 56ece83c)。

采纳:合并为继承 ModelRetryMiddleware 的单中间件

NetworkRetryMiddleware 改为继承 ModelRetryMiddleware,一个中间件内区分两类错误的重试维度:

  • 网络错误:按 network_budget_seconds(默认 600s,环境变量 YUXI_NETWORK_RETRY_BUDGET_SECONDS)预算退避重试,耗尽后显式抛出(Run 以 failed 结束),不经过 on_failure
  • 非网络错误:复用父类的 max_retries/retry_ondefault_retry_on,保留 ModelError.is_retryable 判定)/on_failure 语义。

开关与阈值对应:max_retries 是非网络错误的次数阈值,network_budget_seconds 是网络错误的预算阈值;同步 wrap_model_call 与异步 awrap_model_call 对称实现。

随之删除 retry_non_network_errors 谓词,以及 graph 里的双中间件装配——此前「网络错误不归外层管」这个不变量要靠装配顺序 + 谓词两处共同维持,现在合并后预算天然只属于单中间件,不再有放大风险。网络重试参数用 network_ 前缀(network_budget_seconds/network_initial_delay/network_max_delay)与父类非网络重试的 initial_delay/max_delay 区分。

一并补齐:子智能体 graph 也改用 NetworkRetryMiddleware

重构时发现 subagent/graph.py 此前一直用裸 ModelRetryMiddleware(),断网时子智能体的模型调用仍会「假完成」。既然本 PR 的目标是「断网恢复后任务自动继续」,子智能体同样是独立 run、同样会断网,故同步改用 NetworkRetryMiddleware() 补齐其断网恢复能力——否则主智能体续跑时子智能体已假完成、成果作废。

验证

  • test_network_retry.py 重写为单中间件语义:20 passed(网络错误预算重试成功/耗尽显式抛出/取消不吞、非网络错误按 max_retries 重试后 continue、ModelError.is_retryable=False 立即抛出、虚拟时钟断言预算不被放大);
  • test_summary_graph_config.py 顺序断言同步改为 NetworkRetryMiddleware(chatbot 与 subagent 两个 graph 都覆盖);
  • 全量 unit 1949 passed / 52 skipped
  • ruff check + ruff format --check + verify_engineering_contracts.py 通过。

未运行真实断网/恢复 worker E2E;预算约束用虚拟时钟单测精确断言,真实断网恢复如需要我再补。

@xerrors

xerrors commented Sep 11, 2026

Copy link
Copy Markdown
Owner

建议简化一下这个文件。主要问题是继承了 ModelRetryMiddleware,却又在同步、异步入口复制了父类的非网络重试逻辑,包括次数统计、退避计算和失败处理,还依赖 langchain.agents.middleware._retry 私有模块。
可以保留一个中间件,在内部用 handler 包装网络重试,再交给父类的 wrap_model_call / awrap_model_call 执行。通过公开的 retry_on 排除网络异常,其他异常沿用父类默认策略,避免预算耗尽后再次重试或被转成错误消息。
注意:网络预算起点和退避进度要放在单次入口调用中,不能随着父类的非网络重试而重置。建议补充“网络异常 → 非网络异常 → 网络异常”的回归测试。
另外,is_network_error 可以改成模块内部的 _is_network_error,删除 init.py 中的导出;异常链可以边遍历边判断,无需先构建 chain 列表。CancelledError 不会被 except Exception 捕获,可以删除单独捕获后直接 raise 的分支;GraphBubbleUp 的显式透传则需要保留。
同步和异步入口保留少量重复即可,不必为了减少行数再引入复杂抽象。

另外,就是需要完成真实的网络断网测试

按作者建议简化:网络重试改为闭包包装 handler,预算内吞网络异常退避重试,
非网络异常原样抛出交给父类 wrap_model_call/awrap_model_call 按 retry_on(排除
网络异常) + max_retries/on_failure 处理。不再复制父类的次数统计/退避计算/失败
处理,不再依赖 langchain.agents.middleware._retry 私有模块。

- is_network_error 改私有 _is_network_error,删除 __init__.py 导出;
- 异常链边遍历边判断,不构建中间列表;
- 删除 CancelledError 单独捕获(CancelledError 继承 BaseException);
- 补网络异常→非网络异常→网络异常回归测试,预算起点跨父类重试保持。
@zgpnuaa

zgpnuaa commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

感谢建议,已按此简化(最新 head f98590b8)。

采纳:handler 包装 + retry_on 排除,不再复制父类逻辑

NetworkRetryMiddleware 改为用闭包包装 handler_wrap_network_retry/_awrap_network_retry 在预算内吞掉网络异常退避重试,非网络异常原样抛出,交给父类 wrap_model_call/awrap_model_callretry_on=_retry_non_network_errors 处理。这样:

  • 不再复制父类的次数统计、退避计算(calculate_delay)、失败处理(_handle_failure);
  • 不再 import langchain.agents.middleware._retry 私有模块——_retry_non_network_errors 只依赖公开的 ModelError
  • 网络预算起点与退避进度在闭包创建时固定,跨父类的非网络重试保持,不被放大。

其余简化:is_network_error → 私有 _is_network_error(删除 __init__.py 导出);异常链边遍历边判断,不构建中间列表;删除 CancelledError 单独捕获(继承 BaseException 不会被 except Exception 捕获);GraphBubbleUp 透传保留。

新增回归:网络异常 → 非网络异常 → 网络异常

  • test_network_then_non_network_error_routes_to_parent_retry:网络异常重试后遇到非网络异常,交给父类按 max_retries 重试成功;
  • test_network_budget_survives_parent_retry:虚拟时钟断言预算起点跨父类重试保持,累计网络退避 ≤ 单次预算(若随父类重试重置会被放大,此用例证伪)。

test_network_retry.py 22 passed;全量 unit 1950 passed / 52 skipped(一个 skill 测试在全量下偶发、单独复跑通过,与本次无关);ruff + verify_engineering_contracts.py 通过。

关于真实断网测试

真实断网/恢复 E2E 需要在运行时把模型 API host 断掉再恢复(如 worker 容器内改 /etc/hosts 或 iptables DROP),依赖可注入网络故障的运行拓扑,当前单测环境不提供。预算与恢复语义已用虚拟时钟精确断言(退避递增、600s 上限、耗尽显式抛出、预算跨父类重试不被放大)。若你希望以真实断网作为合并前提,我可以按 test/e2e 的 deterministic replay 拓扑补一个故障注入用例,请你确认这个方向。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants