Appearance
13|错误处理:扣留还是抛出
上一篇的两层循环跑起来了。但循环跑着会出错:上下文超长、网络超时、工具执行失败。
第一反应是把错误抛给外层 QueryEngine,让外层决定怎么办。但过早抛出去有问题——外层可能直接结束会话,而内部其实还有压缩、重试的机会。本篇讲错误处理的核心设计决策:哪些错误内部扣留自己修,哪些直接抛外层。
先分清错误和状态
这是写恢复逻辑前必须建立的认知:OpenAI SDK 的异常和响应状态是两套东西,混在一起会写错。

异常是 HTTP 层抛出来的,SDK 映射成具体类。响应状态是请求成功返回后,挂在 response.status 上的字段。两者触发条件和处理方式完全不同:
| 情况 | 形式 | 判断方式 | 归属 |
|---|---|---|---|
| 上下文超长 | 异常 | BadRequestError,HTTP 400 | 可扣留,本篇 |
| 媒体过大 | 异常 | BadRequestError,HTTP 400 | 可扣留,本篇 |
| 触发限流 | 异常 | RateLimitError,HTTP 429 | 外层退避重试 |
| Key 无效 | 异常 | AuthenticationError,HTTP 401 | 不可扣留,配置问题 |
| 网络超时 | 异常 | APITimeoutError | 外层重试 |
| 输出 token 耗尽 | 响应状态 | response.status == "incomplete" | 继续工作,下一篇 |
最后一行是关键。输出 token 耗尽不会抛异常。模型在 max_output_tokens 上限处停下,请求仍然成功返回,只是 response.status 是 "incomplete"。这是"任务没写完"的状态,归下一篇的继续工作机制处理,不是错误恢复。把它当异常去 except 会什么也抓不到。
真正能扣留的是 BadRequestError。它对应的 400 响应体里藏着具体原因,要解析出来决定恢复动作。
哪些错误能扣留
不是所有 BadRequestError 都能扣留。能扣留的错误必须满足两个条件:有恢复路径(存在已知修复动作消除错误原因),且不破坏状态一致性(修复过程不丢用户数据、不产生副作用)。

上下文超长满足——压缩历史、裁剪附件后重发,错误原因消失。媒体过大也满足——压缩图片、转码视频后重发。但工具执行崩溃不满足,因为崩溃可能已经产生副作用,继续执行会让情况更糟。
python
from openai import BadRequestError, APIStatusError
def query_loop_with_recovery(state, client, settings, tools):
max_recovery = 3
recovery_count = 0
while recovery_count < max_recovery:
try:
response = client.responses.create(
model=settings.model, input=state.messages, tools=tools,
)
return response
except BadRequestError as e:
reason = classify_bad_request(e)
if reason == "context_too_long":
state = attempt_compact(state, client, settings)
recovery_count += 1
continue
elif reason == "media_too_large":
state = shrink_attachments(state)
recovery_count += 1
continue
else:
raise # 未知 400 原因,不冒险扣留
except APIStatusError:
raise # 401/404/429 等,不可扣留,交外层classify_bad_request 从异常携带的响应体里取原因。上下文超长时 message 通常带 "context length" 字样,具体字段名以账号实际返回为准:
python
def classify_bad_request(e: BadRequestError) -> str:
body = e.response.json().get("error", {})
message = (body.get("message") or "").lower()
if "context length" in message or body.get("code") == "context_length_exceeded":
return "context_too_long"
if "image" in message or "media" in message:
return "media_too_large"
return "unknown"扣留不是吞错
扣留不是把错误吞掉。每次扣留和恢复都要记录到 trace,事后能完整回放错误处理过程。把所有错误都吞掉会让系统变成黑盒,外层不知道里面发生了什么,调试无从下手。
python
state.trace.append({
"type": "recovery",
"reason": reason,
"recovery_count": recovery_count,
"action": "compact" if reason == "context_too_long" else "shrink_media",
})恢复动作本身也有成本。压缩要额外调用模型,增加延迟和 token 消耗。但如果压缩成功避免了会话中断,总体体验更好。
API 契约保护
错误扣留的深层动机是 API 契约保护。
假设 queryLoop 把 BadRequestError 直接抛给 QueryEngine,QueryEngine 可能的选择:结束会话返回错误,或尝试压缩后重试。如果选 1,会话中断了,但内部其实还有压缩空间。如果选 2,恢复逻辑放到了外层,queryLoop 变成一碰就碎的脆弱组件。
更合理的契约是:queryLoop 承诺尽力自我修复可恢复的错误,只有确实无法修复时才抛出。QueryEngine 拿到的一定是最终错误,不需要再考虑"要不要试一次压缩"。
代价是平均延迟上升——每次扣留多执行一次恢复动作。用可预测的小延迟增长,换不可预测的大中断减少。告警排查这种"不能轻易放弃"的场景值得这个取舍;成本敏感的批量自动化场景把 max_recovery 压到 1 或 2,快速失败。无论设多少,上限必须存在。
恢复计数与降级
恢复不是无限次的。每次恢复失败,系统状态都在变化:上下文被压缩了,token 预算调整了,工具结果折叠了。连续失败说明问题不是偶然的,要升级处理。
第 3 次恢复失败后,queryLoop 抛出最终错误。QueryEngine 可以选择降级(切换到更小模型或简化任务)、终止(返回已收集的部分结果)、或人工介入。关键故障排查场景设 max_recovery=3~5 给系统更多机会,大量自动化请求场景设 1 或 2 避免额外成本。
不能扣留的
工具执行崩溃、权限被拒绝、数据损坏这些错误不能扣留。它们可能已经产生副作用,继续执行让情况更糟。网络超时、限流、Key 无效这些也不扣留——它们要么是外层重试策略,要么是配置问题,内部修复不了。
错误处理的关键不是"捕获所有异常",是分清每种错误的归属:哪些内部扣留自修,哪些抛外层处理,哪些是响应状态不归错误恢复。这个判断比统一 try-catch 复杂,但这是循环能稳定运行的基础。
错误之后
错误处理让循环能扛住中间错误。但还有一个问题:循环什么时候停?正常情况下,模型回答完了就该结束,但如果没有明确的停止条件,循环可能一直转下去。
下一篇讲停止条件。回答完成、步数上限、预算耗尽、连续被拒——这些条件怎么设,怎么避免过多条件互相冲突。