千笔-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,它替代抛错,不会替你撤销已经执行过的写工具。把它当成产品文案降级可以,当成「副作用自动回滚」不行。
fromagentsimportAgent,Runner,MaxTurnsExceeded,ModelBehaviorError,UserErrorfromagentsimportModelTimeoutError,AgentsExceptiondefclassify(exc:BaseException)->str:ifisinstance(exc,MaxTurnsExceeded):return"human_intervene"ifisinstance(exc,ModelTimeoutError):return"retryable_if_idempotent"ifisinstance(exc,(ModelBehaviorError,UserError)):return"non_retryable"ifisinstance(exc,AgentsException):return"agents_other_no_blind_retry"return"infra_config_no_retry"try:result=awaitRunner.run(agent,user_input,max_turns=8)exceptAgentsExceptionasexc:log.exception("agent_failed",extra={"exc_type":type(exc).__name__,"recover":classify(exc),"raw_n":len(exc.run_data.raw_responses)ifexc.run_dataelse0,"agent":exc.run_data.last_agent.nameifexc.run_dataelseNone,})raise离线 smoke:6 条通过 + 5 条失败
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 | 只 catchAgentsException | P5:OpenAIError 漏网 | 外层再接基建异常,字段进同一日志模式 |
告警与重试对照
| 分桶 | 谁进 | 自动动作 | 人工动作 |
|---|---|---|---|
| 条件可重试 | 幂等读上的ModelTimeoutError | 有限次退避重试 | 超限后升级 |
| 不可重试 | ModelBehaviorError/UserError | 不重试,记 P1 | 修工具表、输出约束或配置 |
| 人工介入 | MaxTurnsExceeded | 可选受控降级文案 | 加预算或拆任务,并看 run_data |
| 基建禁重试 | 缺密钥等非 Agents 异常 | 拒绝入队 | 修密钥与网络 |
最小复现:断言「写副作用不会因超时翻倍」
smoke 里用内存列表当账本。你的项目里换成数据库幂等表即可。
SIDE=[]@function_tooldefcharge(amount:int)->str:SIDE.append(f"charge:{amount}")returnf"charged:{amount}"# 业务层:同一 payment_id 只允许成功落账一次asyncdefrun_payment(payment_id:str):ifalready_charged(payment_id):return"duplicate-ignored"awaitRunner.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 整理实验记录与禁区表,站点见文首链接。
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