
千笔-AIWritePaper · https://www.aiwritepaper.com
线上 Agent 挂掉时,最危险的操作是「反正再跑一遍」。OpenAI Agents SDK 把运行失败收进 AgentsException 族:回合耗尽、模型行为异常、调用方误配、模型超时、工具超时、护栏触发等。它们有的挂着 run_data 快照,有的根本不在这个族里(例如缺 OPENAI_API_KEY 时的 openai.OpenAIError)。能不能重试,取决于异常类型 + 是否幂等 + 是否已经产生写副作用,不取决于值班同学的耐心。
本文在 openai-agents 0.23.1 下用脚本化假模型离线实跑:故意触发回合耗尽、未知工具、坏结构化 JSON、误配 call_model_input_filter、缺密钥,以及超时后盲目整段重试导致 charge 副作用翻倍。全部结果进 _w/agents-exception-smoke/ 的 CSV 与 smoke_output.txt。

图:上排为 AgentsException 族与恢复分类;中部为 Runner.run 成功/抛错路径与 run_data 字段;底部为 F1至F5 实测禁区。
目标说明
读完你应能独立完成五件事。
- 按类型把失败分进三桶:条件可重试、不可重试、必须人工介入;并把缺密钥等基础设施错误单独隔离。
- 在
except AgentsException里读取exc.run_data(若有),把raw_responses、new_items、last_agent写入结构化日志。 - 用
max_turns故意打爆一轮,确认抛出MaxTurnsExceeded且run_data.raw_responses条数等于预算。 - 对写工具路径禁用「整段 Runner.run 重试」;超时只允许在幂等读路径或带幂等键的补偿路径重试。
- 把 5 条失败样本写进禁区表和告警分级:盲目重试翻倍、原样重跑耗尽、吞异常伪造成功、缺密钥空转、坏工具 JSON 伪健康。
适用与边界
适合:已经在跑 Runner.run / run_sync / run_streamed,需要统一异常出口、日志字段与告警;要把「再试一次」写成可执行策略,写进口头习惯不够。
不讲:tool 循环与单工具超时调参细节、as_tool、MCP 接线、guardrails 设计、lifecycle hooks、ModelSettings、RunContext、SQLiteSession 落盘(见 10-02 A)。护栏触发类异常只在分类表里占位,本文不展开 tripwire 业务规则。
失败含义:假模型按脚本返回调用,用来固定触发路径;「有没有挂上 run_data」「缺密钥是否属于 AgentsException」「坏工具 JSON 会不会默默回灌错误串」由 SDK 与 Runner 决定。ModelTimeoutError 的分类用例直接构造异常对象验证分桶;真实超时依赖 ModelSettings.timeout,以你安装版本为准。
机制:异常族、返回路径、日志字段
| 类型 | 典型触发 | 恢复分桶 | 告警建议 |
|---|---|---|---|
MaxTurnsExceeded |
max_turns 用尽 |
人工介入:加预算或拆任务 | P1/P2,带 run_data |
ModelBehaviorError |
未知工具、坏结构化 JSON、非法工具载荷 | 不可重试:修工具表或输出约束 | P1,禁止自动重跑 |
UserError |
调用方误配(如 filter 返回类型错) | 不可重试:修代码/配置 | P1,走发布门禁 |
ModelTimeoutError |
模型调用超过 timeout | 仅幂等可读路径可重试 | P2,写路径要幂等键 |
ToolTimeoutError |
工具 timeout_behavior="raise_exception" |
视工具是否幂等 | P2 |
| 护栏 tripwire 诸类 | 输入/输出/工具护栏触发 | 默认人工或降级策略 | 按业务定级 |
openai.OpenAIError 等 |
缺密钥、凭据、网络基建 | 不在 AgentsException 族;禁盲目重试 | P0 配置/密钥 |
Runner.run 成功时返回 RunResult(读 final_output)。失败时抛上述异常;Runner 会尽量把当时的 input、new_items、raw_responses、last_agent、护栏结果塞进 exc.run_data(RunErrorDetails)。日志至少落四类字段:exc_type、exc_message、run_id/trace_id、run_data 摘要(响应条数、最后 agent 名、new_items 类型直方图)。告警分级建议:配置/密钥 → P0;Behavior/User → P1 且禁止自动重跑;MaxTurns → P1/P2 并附预算;Timeout → P2,由幂等标记决定是否进入重试队列。
可选的 error_handlers(如 "max_turns" / "model_refusal" / "invalid_final_output")可以把部分错误收成受控 final_output,它替代抛错,不会替你撤销已经执行过的写工具。把它当成产品文案降级可以,当成「副作用自动回滚」不行。
python
from agents import Agent, Runner, MaxTurnsExceeded, ModelBehaviorError, UserError
from agents import ModelTimeoutError, AgentsException
def classify(exc: BaseException) -> str:
if isinstance(exc, MaxTurnsExceeded):
return "human_intervene"
if isinstance(exc, ModelTimeoutError):
return "retryable_if_idempotent"
if isinstance(exc, (ModelBehaviorError, UserError)):
return "non_retryable"
if isinstance(exc, AgentsException):
return "agents_other_no_blind_retry"
return "infra_config_no_retry"
try:
result = await Runner.run(agent, user_input, max_turns=8)
except AgentsException as exc:
log.exception("agent_failed", extra={
"exc_type": type(exc).__name__,
"recover": classify(exc),
"raw_n": len(exc.run_data.raw_responses) if exc.run_data else 0,
"agent": exc.run_data.last_agent.name if exc.run_data else None,
})
raise
离线 smoke:6 条通过 + 5 条失败
text
P1 PASS 回合耗尽 MaxTurnsExceeded+run_data -> raw_responses=3 new_items=6 last_agent=budget side_effects=3
P2 PASS 未知工具名触发 ModelBehaviorError -> Tool no_such_tool not found
P3 PASS output_type 解析失败 ModelBehaviorError -> Invalid JSON when parsing model output
P4 PASS call_model_input_filter 误配 UserError -> must return a ModelInputData instance
P5 PASS 缺 OPENAI_API_KEY 落在 infra 层 -> openai.OpenAIError agents_exc=False
P6 PASS ModelTimeoutError 分类为条件可重试 -> timeout_seconds=1.5
F1 FAIL 超时后盲目整段重试导致写副作用翻倍 -> charge:99 × 2
F2 FAIL 回合耗尽后原样重跑仍耗尽 -> second MaxTurnsExceeded
F3 FAIL 吞掉 ModelBehaviorError 伪造成功 -> returned_ok=True
F4 FAIL 缺密钥盲目重试空转 -> OpenAIError × 3
F5 FAIL 坏工具 JSON 未抛异常仅回灌错误串 -> raised=False saw_tool_error_string=True
CSV rows: 11
逐条读法。
- P1:
max_turns=3时模型每轮都调charge,抛MaxTurnsExceeded;run_data.raw_responses恰好 3,且副作用日志已有 3 条。预算耗尽前写工具可能已经发生。 - P2:模型点名不存在的工具,默认行为抛
ModelBehaviorError(可改为tool_not_found_behavior="return_error_to_model"回灌,那是另一条产品策略)。 - P3:
output_type为 Pydantic 模型时,助手只回非 JSON 文本,抛ModelBehaviorError: Invalid JSON when parsing model output。 - P4:
call_model_input_filter返回了普通字符串,抛UserError。这是调用方代码病,重试无意义。 - P5:不设密钥走默认真模型路径,得到
openai.OpenAIError,它落在 AgentsException 族外。只写except AgentsException会漏掉它。 - P6:
ModelTimeoutError(1.5)进入「条件可重试」桶;是否真重试还要看路径幂等。 - F1:工具已
charge:99,随后模拟超时,调用方把整段Runner.run再跑一遍,副作用变成两条。这是写路径盲目重试的标准事故。 - F2:第一次
max_turns=2耗尽后,不改预算、不拆任务,原样再跑,第二次仍然MaxTurnsExceeded。 - F3:
except ModelBehaviorError后直接return {"ok": True},监控显示成功,用户拿到空结果。 - F4:缺密钥连打三次,三次同一基建错误,告警风暴与空耗。
- F5:工具参数是
{not-json时,SDK 默认把错误串写进function_call_output让模型继续,整次 run 成功返回。没有异常不等于工具健康。
生产禁区表
| 编号 | 禁区 | 实测证据 | 替代做法 |
|---|---|---|---|
| D1 | 超时后整段重跑含写工具的 run | F1:charge:99 × 2 |
写工具带幂等键;超时只重试未确认的读步骤 |
| D2 | MaxTurns 后原样同预算重跑 | F2:第二次仍耗尽 | 升预算、拆子任务,或走 error_handlers["max_turns"] 给受控文案 |
| D3 | 吞掉 Behavior/User 当成功 | F3:ok=True |
必须记账并失败返回;CI 禁止裸 except |
| D4 | 缺密钥/凭据当可重试 | F4:空转 × 3 | 启动期校验密钥;该类进 P0,重试队列直接拒绝 |
| D5 | 「没抛异常」当成工具成功 | F5:错误串回灌 | 扫描 function_call_output 错误模式;指标单独计数 |
| D6 | 只 catch AgentsException |
P5:OpenAIError 漏网 | 外层再接基建异常,字段进同一日志模式 |
告警与重试对照
| 分桶 | 谁进 | 自动动作 | 人工动作 |
|---|---|---|---|
| 条件可重试 | 幂等读上的 ModelTimeoutError |
有限次退避重试 | 超限后升级 |
| 不可重试 | ModelBehaviorError / UserError |
不重试,记 P1 | 修工具表、输出约束或配置 |
| 人工介入 | MaxTurnsExceeded |
可选受控降级文案 | 加预算或拆任务,并看 run_data |
| 基建禁重试 | 缺密钥等非 Agents 异常 | 拒绝入队 | 修密钥与网络 |
最小复现:断言「写副作用不会因超时翻倍」
smoke 里用内存列表当账本。你的项目里换成数据库幂等表即可。
python
SIDE = []
@function_tool
def charge(amount: int) -> str:
SIDE.append(f"charge:{amount}")
return f"charged:{amount}"
# 业务层:同一 payment_id 只允许成功落账一次
async def run_payment(payment_id: str):
if already_charged(payment_id):
return "duplicate-ignored"
await Runner.run(pay_agent, f"pay {payment_id}", max_turns=5)
mark_charged(payment_id)
CI 断言:模拟「成功扣费 + 随后超时 + 调用方重试」后,SIDE.count("charge:99") == 1(或账本行数唯一)。谁把整段 run 当重试单元,谁就红。
常见误判
- 「catch 住了 AgentsException 就等于覆盖所有运行失败」:P5 证明缺密钥走另一条继承树。
- 「run 返回了 final_output 说明工具都成功」:F5 证明坏 JSON 可以只回灌错误串仍算跑完。
- 「error_handlers 接住 max_turns 就等于没副作用」:P1 在抛错前已经执行了 3 次
charge;handler 只改返回,不回滚。 - 「超时都可以重试」:只有幂等读或带幂等键的写才可以;F1 是反例。
与已写主题的咬合
| 相邻主题 | 关系 | 一句话 |
|---|---|---|
| tool 循环 / 超时专文 | 单工具超时策略 | 本篇管异常出口与是否允许整段重跑 |
| guardrails 专文 | tripwire 如何设计 | 本篇只把 tripwire 放进分桶表 |
| ModelSettings | timeout 等旋钮来源 | 本篇消费超时异常的恢复边界 |
| SQLiteSession(10-02 A) | 失败时历史是否仍在 | 异常与 session 正交;run_data 不替代会话库 |
日志字段最小集
生产里建议每条失败日志固定带这些键,方便和 trace 对上。
| 字段 | 来源 | 用途 |
|---|---|---|
exc_type |
type(exc).__name__ |
分桶与看板 |
recover |
上文 classify(exc) |
决定是否入重试队列 |
message |
str(exc) 截断 |
值班可读 |
raw_n |
len(run_data.raw_responses) |
对照 max_turns |
item_hist |
new_items 的 type 直方图 | 看失败前是否已调工具 |
last_agent |
run_data.last_agent.name |
多 agent 时定位 |
side_effect_hint |
业务侧幂等表查询 | 判断能否安全重试 |
trace_id |
RunConfig / 追踪系统 | 串联模型与工具跨度 |
缺 side_effect_hint 时,超时重试决策会退化成猜。P1 已经证明回合耗尽前 charge 可以发生三次;你若只看异常类型、不看账本,就会把「已经扣过」的请求再次送进 Runner。
与 error_handlers 的边界
SDK 允许为 "max_turns"、"model_refusal"、"invalid_final_output" 注册 handler,返回受控 final_output。这在产品层很有用,例如告诉用户「本轮步数不够,请缩小范围」。工程上要同时记住三件事。
- handler 成功后,调用方看到的是正常
RunResult,监控若只盯异常率会漏报。 include_in_history默认可能把降级文案写入会话;设False可避免污染下一轮上下文。- 已执行的写工具不会因为 handler 而回滚。P1 的三次
charge在抛MaxTurnsExceeded之前已经进了副作用日志。
因此 handler 属于对外文案与流程降级,不属于恢复禁区的豁免条款。禁区表里的 D1/D2 仍然成立。
验收清单
- 所有
Runner.run调用点有统一classify+ 结构化日志(含run_data摘要)。 - 写工具具备幂等键;超时重试测试断言副作用不翻倍。
-
MaxTurnsExceeded不进入无脑重试队列。 -
ModelBehaviorError/UserError禁止自动重跑,CI 有裸 except 检查。 - 启动期校验 API 密钥;缺密钥进 P0 而非重试。
- CI 跑
_w/agents-exception-smoke/smoke.py,P 系列 PASS,F 系列保持可复现。
当天 30 分钟落地
- 5 分钟:搜仓库里所有
Runner.run,列出 except 范围是否覆盖非 Agents 异常。 - 10 分钟:接入上文
classify,日志加上exc_type/recover/raw_n。 - 5 分钟:给支付/写库类工具补幂等键,加一条「超时重试不翻倍」测试。
- 5 分钟:MaxTurns 与 BehaviorError 的告警从「自动重跑」改成「工单」。
- 5 分钟:把
agents-exception-smoke.csv存进仓库,升级 SDK 时复跑对照。
配置对照:四个问题定恢复策略
| 问题 | 是 | 否 |
|---|---|---|
| 失败路径上是否已执行写工具? | 禁止整段重跑;查幂等表 | 才可考虑有限次退避 |
| 异常是否为 Behavior / User? | 修代码与工具表 | 再看超时或 MaxTurns |
| 是否 MaxTurnsExceeded? | 人工改预算或拆任务 | 不要用同预算重跑碰运气 |
| 是否族外基建错误? | P0 修密钥与网络 | 不要丢进 Agents 重试队列 |
这四问可以直接做成网关中间件的分支。谁跳过第一问,谁就会复现 F1。
踩坑
- 把「再跑一遍」写进 on-call 手册的第一步:F1/F2/F4 都会被手册合法化。
- 只在网关层 catch Exception 并返回 200:F3 的假阳性会直接进用户报表。
- 看见 tool output 里有英文 error 仍展示「已完成」:F5 的静默失败最常见于演示环境。
- 用升
max_turns掩盖未知工具或坏 JSON:那是把 BehaviorError 熬成 MaxTurns,run_data 更难看。 - 假设
error_handlers会撤销工具副作用:SDK 文档写明它不重放、不回滚副作用。
总结
AgentsException 族给出的是可观测的失败类型 。它本身不附带「自动恢复许可证」。Runner.run 成功走 RunResult,失败走带(或不带)run_data 的异常;缺密钥等基建错误还在族外。生产能不能扛住,取决于分桶、幂等与告警:超时后整段重跑、耗尽后原样重跑、吞掉 BehaviorError、缺密钥空转、把错误串回灌当成健康,都会在账本或监控里留下可复现伤口。把 5 条失败写进禁区表,升级 SDK 时复跑同一份 smoke。本文实跑版本为 openai-agents 0.23.1,字段名以你安装的版本为准;若 run_data 或 error_handlers 语义有变,先以官方 Running agents / Exceptions 文档与本目录 CSV 对照。
写作与实验流程可用千笔-AIWritePaper 整理实验记录与禁区表,站点见文首链接。
text
P1 PASS 回合耗尽 MaxTurnsExceeded+run_data -> msg='Max turns (3) exceeded' run_data=True raw_responses=3 new_items=6 last_agent=budget side_effects=3
P2 PASS 未知工具名触发 ModelBehaviorError -> type=ModelBehaviorError msg='Tool no_such_tool not found in agent tools' class=non_retryable
P3 PASS output_type 解析失败 ModelBehaviorError -> type=ModelBehaviorError msg='Invalid JSON when parsing model output'
P4 PASS call_model_input_filter 误配 UserError -> type=UserError msg='call_model_input_filter must return a ModelInputData instance'
P5 PASS 缺 OPENAI_API_KEY 落在 infra 层 -> type=openai.OpenAIError agents_exc=False class=infra_config_no_retry