一次促销活动中,下单 Agent 在上游网关 429 时"看起来自己好了":用户侧最终成功,财务侧却出现少量重复扣款。排查发现两层重试叠在一起。网关客户端按指数退避重打,Agent 运行时又在 ModelSettings.retry 里开了 runner 托管重试;中间那次模型调用其实已经触发过写工具,只是响应在返回途中被判为可重试失败。没有人配置"成功就重试",是重试边界把一次不确定的失败当成了可安全重放的运输错误。
这篇只讲模型调用层的托管重试:ModelRetrySettings、RetryPolicy 与 RetryDecision。它不是工具级的 failure_error_function,也不是 max_turns 那种轮次预算。结论在 openai-agents 0.23.1 的 retry.py 与 run_internal/model_retry.py 上核对过,配套离线脚本用假错误对象驱动 policy,结果落在 _w/fail-smoke/retry.csv。

图注 左侧为 opt-in 契约,右侧为四类种植失败。
先把默认行为说清楚
ModelSettings.retry 默认是 None。文档与源码一致:一般模型请求不会自动重试,除非你显式传入 ModelRetrySettings,并且 policy 决定要重试。
python
from agents import Agent, ModelSettings, ModelRetrySettings, retry_policies
agent = Agent(
name="checkout",
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=4,
backoff={
"initial_delay": 0.5,
"max_delay": 5.0,
"multiplier": 2.0,
"jitter": True,
},
policy=retry_policies.any(
retry_policies.provider_suggested(),
retry_policies.retry_after(),
retry_policies.network_error(),
retry_policies.http_status([408, 409, 429, 500, 502, 503, 504]),
),
)
),
)
三个字段的职责要分开记:
| 字段 | 含义 |
|---|---|
max_retries |
初次请求之后允许多少次重试 |
backoff |
policy 决定重试但未给出显式 delay 时的默认退避;可写 initial_delay / max_delay / multiplier / jitter |
policy |
运行时回调,吃 RetryPolicyContext,返回 bool 或 RetryDecision;不参与序列化 |
更关键的一点:runner 真正安排无状态重试时,需要 max_retries 有效且 policy 非空。只填了 max_retries、忘了 policy,不会出现"按次数盲重试"。只填了 policy、max_retries 为 None 或 0,也不会按你想象的次数去打。把重试当成"打开一个布尔开关"会误判。
未配置 backoff 时,实现里仍有默认退避常量可用:初始延迟约 0.25 秒、上限约 2.0 秒、倍数 2.0、默认带抖动。backoff.max_delay 只约束这套计算出来的退避,不封顶 policy 或 retry-after 给出的显式延迟。retry_policies.retry_after() 把服务端提示当成显式 delay,因此不会被 max_delay 裁掉。
RetryPolicyContext 里有哪些稳定事实
policy 回调拿到的上下文比"一个异常对象"更结构化。常用字段包括:attempt、max_retries、stream、原始 error、以及 normalized(含 status_code、retry_after、error_code、is_network_error、is_timeout、is_abort)。另外还有一组重放安全事实:response_started、replay_safety(safe / unsafe / unknown)、stateful_request(是否带了 previous_response_id 或 conversation_id)。
返回值两种写法。True / False 表示简单是否重试。需要覆盖等待时间、写明原因、或显式批准不安全重放时,返回 RetryDecision:
python
from agents import RetryDecision, RetryPolicyContext
def policy(ctx: RetryPolicyContext) -> bool | RetryDecision:
if ctx.normalized.is_abort:
return False
if ctx.replay_safety == "unsafe":
# 普通 retry=True 不会绕过重放保护
return RetryDecision(retry=False, reason="provider_replay_unsafe")
if ctx.normalized.status_code == 429:
return RetryDecision(retry=True, delay=ctx.normalized.retry_after, reason="rate_limit")
return retry_policies.network_error()(ctx)
approve_unsafe_replay=True 是单独开关。普通 RetryDecision(retry=True) 不能绕过提供方标成 unsafe 的重放保护。只有你确认"重复执行提供方侧可能已发生的工作可以接受"时,才允许打开它,并且仍应先看 response_started、replay_safety、stateful_request。
内置组合器在 retry_policies 上:never、provider_suggested、network_error、http_status([...])、retry_after、any、all。生产里优先组合这些,而不是手写一长串 except。
离线 smoke:四类种植失败
| case | 现象 | 说明 |
|---|---|---|
| F1_double_charge | 写工具无幂等,运输错误触发重试后副作用两次 | 模型层重试不等于业务可重入 |
| F2_policy_raises | policy 抛 ValueError,整轮失败 |
0.23.1 不会把 policy 异常吞成"默认重试"或"默认不重试" |
| F3_gateway_stack | 客户端库退避 + runner 退避同时开启 | 总等待与总请求数呈乘积放大 |
| F4_silent_middle | 最终成功,中间失败次数与最后错误码未落盘 | 成功率掩盖重试风暴 |
F1 是财务事故的典型形状。runner 托管重试的对象是模型请求。若上一次请求已经让模型发出写工具、本地也执行了,只是后续响应流失败,重放整次请求就可能再次执行副作用。提供方若标了 replay_safety="unsafe",默认会挡住;若你的 policy 为了"多抢救一次"写上 approve_unsafe_replay=True,等于亲手关掉这道闸。更常见的漏洞是:本地写工具在模型流式输出早期就执行了,而运输错误发生在收尾阶段,业务侧却没有任何幂等键。
F2 要按源码实话实说。_call_retry_policy 没有把 policy 异常包装成 RetryDecision(retry=False)。policy 抛错会向外冒泡,整轮失败。这和"失败回灌函数自己抛错会替换根因"是同类排障陷阱:日志里看到的是 policy 的 ValueError,不是原来的 503。有人以为写坏 policy 会自动回退到库默认重试,在 0.23.1 上不成立。
F3 是平台叠床架屋。服务网格、HTTP 客户端、SDK runner 各配一套 429/5xx 重试时,用户只看到"偶发变慢",容量侧看到的是尖刺。backoff.max_delay 管不住上游客户端,也管不住 retry-after 显式延迟。验收时要画一张"谁有权重试"的表,只留一层负责运输重试。
F4 出在指标。最终 success=1 不能证明中间没有打满重试。至少要落:每次尝试的错误码、attempt、是否使用了显式 delay、是否批准了 unsafe replay、以及重试前后的用量增量。没有中间态,你就无法区分"一次干净成功"和"四次流血后的成功"。
生产禁区与推荐默认
| 禁区 | 处理 |
|---|---|
| 无幂等写路径开模型重试 | 先做幂等键或把写操作移出可重放请求,否则保持 retry=None |
随手 approve_unsafe_replay=True |
仅个案批准,写入变更单与审计 |
| policy 里再发网络请求 | 回调应是纯决策,否则故障面嵌套 |
| 与网关/客户端双重退避 | 只保留一层运输重试 |
| 只看最终成功 | 必须打点中间失败与末次错误码 |
误以为只设 max_retries 就会重试 |
必须同时提供 policy |
只读检索 Agent 可以较宽松地使用 retry_policies.any(provider_suggested, network_error, http_status([...]))。下单、支付、改库存 Agent 的默认建议是:retry=None,运输层重试交给明确幂等的下游 API,或把"创建订单"做成带幂等键的后台任务,由任务系统重试,而不是由模型请求重试。
当天最小实验
九十分钟。
- 用假错误驱动四个 policy 分支:429 带
retry-after、连接错误、replay_safety=unsafe、policy 内抛错;把决策写入retry.csv。 - 断言:unsafe 且未批准时不重试;批准后才重试;policy 抛错时脚本捕获到的是政策异常而非原始 503。
- 叠一层假网关退避,打印总请求数,确认 F3 的乘积效应可见。
- 给只读 Agent 与写 Agent 各写一份推荐
ModelRetrySettings(写 Agent 可为None),附指标名清单。 - 在 runbook 加一句:最终成功必须能关联到中间失败次数,否则不算通过。
和用量、轮次预算的边界
重试消耗的是模型调用次数与费用,不一定消耗 max_turns 的同一种计数方式,但两者会在排障时被混为一谈。建议在看板拆开:model.calls、model.retries、agent.turns。一次用户问题若触发三次运输重试,仍可能只算很少的"业务轮次",费用却已经上去了。预发用合成 429 压一次,记下费用与延迟的分位数,再决定 max_retries。
另一个边界是会话回绕。托管重试会与会话、服务端 conversation 的回绕行为协同,避免把失败的半截输出当成终态写进历史。你的业务日志若只在"最终成功"时打点,就会漏掉回绕前的那几次失败。把 attempt 写进结构化日志,比事后从轨迹里猜要便宜。
内置 policy 怎么选型
不要一上来就手写 if status_code in ...。先用组合器表达意图,再在最外层加业务否决。
- 只要提供方明确建议重试:
provider_suggested()。它会吃适配器给出的建议,也包括部分无状态码的过载帧场景;它本身不会替你打开ModelRetrySettings。 - 只要带
retry-after才重试:retry_after()。适合你信任服务端提示、又不想对所有 5xx 一视同仁的场景。 - 运输层抖动:
network_error()。匹配连接、协议、超时一类失败。 - 明确状态码表:
http_status([408, 409, 429, 500, 502, 503, 504])。把列表写进配置,而不是散落在代码魔法数里。 - 组合:
any(...)任一命中即倾向重试;all(...)要求同时命中才重试。需要"既是网络错误又带 retry-after"时用all。 - 显式关闭:
never()。适合在继承来的默认 retry 上做局部否决。
业务否决放在最外层包装函数里:先看 ctx.stateful_request 与工具副作用标记,再决定是否把内层组合器的结果往外传。把业务否决写进内层状态码表,三个月后没人说得清为什么 503 在支付链路被放行。
流式与非流式不要共用同一套直觉
同一条 policy 在流式和非流式上看到的事实可能不同。ctx.stream 就是为此准备的。流式场景里,一旦响应事件已经开始,重放语义更接近"可能已经产生过副作用或半截输出"。源码侧会把一批早期事件类型视为相对安全,但你的业务工具若在早期事件之后就执行了,就不能再靠默认安全假设。
实操建议:流式写路径默认不重试;非流式只读路径才启用较宽的 any 组合。若必须对流式只读重试,把条件写进 policy:if ctx.stream and ctx.response_started: return False。把这条写成单测,比写在 wiki 里可靠。
变更单模板
发版时复制下列清单,逐项勾选。
- 本变更影响的 Agent 名单,以及每个 Agent 的
retry是None还是完整ModelRetrySettings。 max_retries、backoff 参数、policy 组合器列表;若有自定义回调,附函数名与单测路径。- 是否存在
approve_unsafe_replay的用例;若有,写明批准人与可接受的重复副作用范围。 - 与网关/HTTP 客户端重试的分工表:哪一层负责 429,哪一层关闭。
- 指标:
model.retries、末次错误码、unsafe 批准次数、重试后的用量增量。 - 回滚方式:配置回
retry=None是否足够,是否需要同时回滚客户端库重试。
缺第 3 或第 4 项,支付与库存类变更直接打回。
当天实验的 CSV 列定义
retry.csv 建议固定列,避免每个人临时加字段:
case,attempt,status_code,replay_safety,stateful,decision_retry,delay,reason,approve_unsafe,exception_type,notes
种植用例至少覆盖:rate_limit_429、connect_error、unsafe_no_approve、unsafe_approved、policy_raises、readonly_ok、write_path_none。其中 write_path_none 一行的预期是配置层面就不启用 retry,而不是靠 policy 临时返回 False------后者太容易在复制粘贴时丢掉。
跑完后用三条断言收口:unsafe 未批准不得出现 decision_retry=true;policy_raises 的 exception_type 必须是政策异常;写路径样例不得出现大于零的重试计数。三条全绿,才允许把配置贴进预发。
只读与写路径的配置分叉
同一个代码库里往往有多个 Agent。不要共用一份全局 ModelRetrySettings。推荐在组装层显式分叉:只读检索 Agent 注入较宽的 policy 组合;下单 Agent 注入 retry=None;对账只读但调用昂贵模型的 Agent 可以降低 max_retries 并打费用告警。分叉表提交进仓库,比口头约定"写路径小心一点"可靠。
总结
ModelRetrySettings 是 opt-in 的模型请求控制面:要同时给出次数上限与 policy,才谈得上 runner 托管重试。RetryDecision 把延迟、原因和不安全重放批准拆开,避免"想重试"被误读成"批准重放副作用"。生产禁区的核心不是会不会写指数退避,而是有没有把运输失败与业务可重入当成同一件事。读路径可救,写路径先幂等,再谈重试。