API 韧性
为截断、输入过长和瞬态故障设计有边界的恢复路径。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
API 韧性
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 错误先归一化,再按类型选择恢复动作。
- 恢复请求不能污染正式历史,也不能重复执行 Hook。
- 重试次数、退避和最终失败都要可观察。
顺着原文把边界看清
01导读:问题背景与本章目标openai.APIStatusError: Error code: 529 - {'type': 'error', 'error': {'type': 'overloadederror', 'message': 'Overloaded'}}⌄
凌晨两点,手机响了。
一个跑了三个小时的 Agent 任务挂了。日志最后一行:
openai.APIStatusError: Error code: 529 - {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}}没有重试,没有等待,没有任何恢复动作。直接崩。
这不是罕见的 bug。429 是标准限流信号,一些 OpenAI 兼容端点也会用 529 表示临时过载;输入超过 context window 时,端点通常返回结构化错误。问题不是 API 会不会出错,而是 Agent 能否在明确边界内恢复。
第 10 章已经有记忆、上下文压缩、规划工具和动态 Prompt,但模型请求仍然直接穿过唯一 Loop。第 11 章只增加一层能力:让同一个逻辑模型请求处理输出截断、输入过长和瞬态 API 故障。它同时保证不污染正式历史、不重复执行 Hook,也不隐藏失败。
02三种故障,三条路三种故障看起来都像“模型没给出可用答案”,但触发信号和恢复动作完全不同:⌄
三种故障看起来都像“模型没给出可用答案”,但触发信号和恢复动作完全不同:
| 故障 | 供应商无关信号 | 恢复动作 |
|---|---|---|
| 输出被截断 | ModelReply.finishReason === "length" | 首次提升输出预算;之后在请求窗口内续写 |
| 输入上下文过长 | ModelPromptTooLongError | 保留当前 system prompt,响应式压缩请求快照一次 |
| 临时故障 | ModelRateLimitError / ModelOverloadedError | 遵守 Retry-After 或指数退避;连续 3 次 529 后切 fallback |
这三条路径统一放在 RecoveryManager.complete() 中。公共 AgentRunner 仍然只负责组装当前请求、执行工具、追加最终回复和运行 Hook。恢复层不会重新进入外层 Loop,所以一次 API 重试不会被计成新的 Agent turn。
03先归一化错误,禁止匹配异常文本原文用 "429" in str(exc)、"prompt" in str(exc) 判断故障。这会把业务错误、代理页文本甚至程序异常误判成可重试故障。⌄
原文用 "429" in str(exc)、"prompt" in str(exc) 判断故障。这会把业务错误、代理页文本甚至程序异常误判成可重试故障。
当前实现把供应商对象限制在 [openai-chat.ts](code/chapters/ch11/src/adapters/openai-chat.ts)。适配器只读取 OpenAI SDK 的稳定结构:
APIError.statusAPIError.errorAPIError.headersAPIError.requestID
分类规则是显式的:
const PROMPT_TOO_LONG_CODES = new Set([
"context_length_exceeded",
"max_context_window",
"prompt_is_too_long",
"prompt_too_long",
]);
if (status === 429) {
const retryAfter = headers.get("retry-after");
throw new ModelRateLimitError("OpenAI request was rate limited", {
...(retryAfter === null ? {} : { retryAfter }),
});
}
if (status === 529) {
throw new ModelOverloadedError("OpenAI model was overloaded");
}
if (status === 400 && errorCode !== undefined && PROMPT_TOO_LONG_CODES.has(errorCode)) {
throw new ModelPromptTooLongError("OpenAI prompt exceeded the model context window");
}errorCode 只允许来自平坦 error.code/type,或最多一层 error.error.code/type。实现不读取 message,不递归搜索任意 JSON,也不根据异常字符串猜测。未知 400、其他 5xx、连接错误、超时和程序错误都原样传播,本章不会擅自重试。
三种内部异常定义在 model.ts。类型与 HTTP status 是不变量:rate-limit 只能携带 429,overloaded 只能携带 529,prompt-too-long 只能携带 400。这样恢复层可以按 TypeScript 类型分派,而不会收到语义矛盾的对象。
成功的 HTTP 200 同样是不可信边界。 错误路径只覆盖“请求失败”,但供应商返回 200 不代表结构可信。normalizeResponse() 在把响应交给恢复层之前逐字段验证:必须恰好一个 choice,message.role 只能是 assistant,finish_reason 只能落在白名单内;content 必须是 string | null,refusal 为字符串时可以回退为最终内容;tool_calls 必须逐项带完整 id、name 和 arguments;usage 的三个计数必须是可枚举的非负整数。响应里出现旧式 function_call,或任何字段类型与契约不符,都会立即抛出 OpenAIResponseError,不会把残缺对象送进 RecoveryManager 或 canonical history。这个验证层也解释了为什么要保留 content_filter:适配器把它识别为合法 finish_reason 是为了忠实传递供应商语义,最终是否接受由 Loop 决定。
请求侧同样有 wire format 映射。toOpenAIMessage() 把 ChatMessage 转换为 OpenAI Chat Completion 消息参数:system/user 映射到同名字段,tool 必须携带 tool_call_id,assistant 按需要附加 tool_calls;toOpenAITool() 把内部工具 schema 固定映射为 type: "function" 加 name/description/parameters。core 不导入 OpenAI SDK 类型,所有供应商细节都收在 adapter 内,恢复层只面对 model.ts 中稳定的 ModelRequest 与 ModelReply 契约。
04路径一:输出截断只存在于请求窗口finishReason === "length" 是一个正常响应,不是输入过长异常。首次遇到它时,恢复层把输出预算从 8000 提升到 64000,完全丢弃残缺回复,再重试同一个请求:⌄
finishReason === "length" 是一个正常响应,不是输入过长异常。首次遇到它时,恢复层把输出预算从 8000 提升到 64000,完全丢弃残缺回复,再重试同一个请求:
if (reply.finishReason === "length" && !state.hasEscalated) {
state.currentMaxTokens = config.escalatedMaxTokens;
state.hasEscalated = true;
continue;
}RecoveryConfig 会在构造时验证 escalatedMaxTokens <= modelMaxTokens。预算不能靠“先发请求再看供应商是否接受”来试错。
如果提升后仍然截断,才进入续写。中间片段和续写提示只追加到 requestMessages 局部快照:
const fragment = reply.message.content;
if (reply.message.toolCalls.length > 0 || fragment === null || fragment.length === 0) {
throw new RecoveryRetriesExhausted(
"length recovery requires a non-empty text-only assistant fragment",
);
}
requestMessages = Object.freeze([
...requestMessages,
reply.message,
userMessage(CONTINUATION_PROMPT),
]);当前续写提示是:
export const CONTINUATION_PROMPT =
"Continue exactly where you left off. Do not repeat any text, no apology, no recap. Pick up mid-thought.";默认最多注入 3 次续写提示。最终成功后,所有文本片段按原样拼接,不自动补空格;最终响应携带的工具调用会完整保留。只有这一个合并后的 assistantMessage(...) 能返回公共 Loop 并进入 canonical history。
这条边界同时解决两个问题:
- 首次残缺回复、续写提示和中间片段不会污染会话历史。
- 带未完成
tool_calls的截断响应不会被追加,从源头避免孤儿工具调用。
05路径二:输入过长只压缩请求快照一次输入过长来自适配器抛出的 ModelPromptTooLongError,不能拿 finishReason === "length" 代替。恢复层复用第 8 章的 CompactionManager.compactOnPromptTooLong():⌄
输入过长来自适配器抛出的 ModelPromptTooLongError,不能拿 finishReason === "length" 代替。恢复层复用第 8 章的 CompactionManager.compactOnPromptTooLong():
const [leadingSystem, compactableHistory] = splitLeadingSystem(requestMessages);
const outcome = await runBounded((signal) =>
compaction.compactOnPromptTooLong(
compactableHistory,
{ retryCount: promptTooLongRetries },
signal,
),
);
requestMessages = Object.freeze([...leadingSystem, ...outcome.history]);首条动态 system prompt 必须保持请求首位;其后的请求快照按完整消息组压缩。assistant 的全部工具调用和对应 tool results 仍然是不可拆分单元。
这里没有修改原数组。canonical history、原始 ModelRequest 和调用方持有的只读 messages 快照都不修改。响应式 transcript 记录的是“被压缩前的请求快照”,不是 canonical history 的别名;完整会话追溯仍由第 8 章主动压缩 transcript 负责。
同一个逻辑请求只允许响应式压缩一次。第二次输入过长会由 PromptTooLongRetryError 明确失败。压缩及其模型摘要也处在同一个取消和总时限边界内,不能借摘要调用绕开 deadline。
06路径三:429、529、Retry-After 与 fallback0.5s -> 1s -> 2s -> 4s -> 8s -> 16s -> 32s -> 32s ...⌄
没有有效 Retry-After 时,默认退避基线为:
0.5s -> 1s -> 2s -> 4s -> 8s -> 16s -> 32s -> 32s ...每次再增加 0..base*25% 的可注入抖动。默认最多 10 次瞬态尝试,退避封顶 32 秒。
429 的 Retry-After 优先级更高。当前解析器接受非负秒数和带时区的 RFC HTTP-date。空值、负数、NaN、Infinity、坏日期或无时区日期会抛出 InvalidRetryAfterError,并且不 sleep、不重试。
如果等待会达到或越过当前 turn 的剩余时限,恢复层直接抛出 RecoveryDeadlineExceeded。它不会先睡到超时再报告失败。
529 还维护连续计数:
state.consecutive529 += 1;
if (state.consecutive529 >= config.overloadFallbackThreshold) {
state.currentModel = config.fallbackModel;
state.consecutive529 = 0;
}阈值默认是 3。第三次 529 更新状态并完成本次退避,下一次,也就是第 4 个请求,才显式携带 fallback model。429、输入过长或成功响应都会打断“连续 529”的含义。
07取消与总时限是恢复的一部分重试次数不是唯一边界。十次退避在最大抖动下,仅等待就可能超过 159 秒,所以默认 turn 总时限设为 300 秒,并允许测试或调用方显式缩短:⌄
重试次数不是唯一边界。十次退避在最大抖动下,仅等待就可能超过 159 秒,所以默认 turn 总时限设为 300 秒,并允许测试或调用方显式缩短:
const config = new RecoveryConfig({
primaryModel: "primary-model",
fallbackModel: "fallback-model",
initialMaxTokens: 8_000,
escalatedMaxTokens: 64_000,
modelMaxTokens: 64_000,
maxContinuations: 3,
maxTransientAttempts: 10,
totalTimeoutSeconds: 300,
});CancellationToken、单调时钟、UTC 时钟、sleeper 和 jitter 都可注入。RecoveryManager 在模型调用、退避 sleep 和响应式压缩外层统一竞争取消事件与剩余 deadline。取消或超时会将同一个 AbortSignal 传给 ModelClient 和 HistorySummarizer,并等待被中止操作收束;不会留下后台请求或工作区写入。
CancellationToken 的订阅契约是明确的:cancel() 幂等,只触发一次,触发后清空监听器;subscribe() 拒绝非函数参数;如果令牌已经取消,新订阅会立即执行并返回空操作退订函数。#runBounded() 在每次模型调用、sleep 和压缩前都会重新检查取消与 deadline,再把 AbortSignal 传给底层操作。取消或超时后,它会先 abort 再等待 Promise 收束,避免把“已中止但仍在写”的副作用带出恢复边界。
所有耗尽路径都有 typed failure:
RecoveryCancelledErrorRecoveryDeadlineExceededRecoveryRetriesExhaustedInvalidRetryAfterErrorPromptTooLongRetryError
08接入唯一 Loop,而不是复制 Loopexport interface ModelRequestExecutor {⌄
公共运行时只新增一个窄协议:
export interface ModelRequestExecutor {
beginTurn(): void;
complete(request: ModelRequest): Promise<ModelReply>;
}AgentRunner.run() 在现有 memory turn lifecycle 之后调用一次 beginTurn()。每个外层模型轮只调用一次 complete();内部重试不会重新运行 UserPrompt Hook、history preparation、动态 Prompt、工具 schema 快照或 Stop Hook。
P01-P10 不注入执行器,仍直接调用 raw ModelClient,原有 length guard 继续作为明确失败。P11 的 Bootstrap 才创建 RecoveryManager,并要求显式 RecoveryConfig。能力位与配置必须成对:给 P10 传 recoveryConfig 报错,P11 不传也报错(否则静默退化成裸模型);第 11 章起 .env 需四字段,OPENAI_FALLBACK_MODEL 必填,file:// 协议的 baseUrl 被当没填。10 次重试等待总和正好解释 300 秒默认时限。
Loop 还负责把 finishReason === "content_filter" 转为显式失败。适配器能解析 content_filter,因为它属于 OpenAI 合法的 finish_reason;但被内容过滤的回复既不是最终答案,也不能当作可重试的瞬态错误,因此 loop.ts 在写入 history 前抛出 AgentRunError("Model response was blocked by the content filter")。这避免模型输出被过滤后仍以“成功”身份污染正式历史,也避免恢复层把它误判为长度或限流。
Bootstrap 复用同一个 CompactionManager 处理主动和响应式压缩,但 ModelHistorySummarizer 继续绑定 raw model。如果把 summarizer 绑定到 RecoveryManager,路径会变成“输入过长 -> 压缩 -> 摘要 -> 恢复 -> 再压缩”的递归。
同理,本章只包装主 Loop 请求。memory selector/extractor、压缩摘要器和一次性 subagent 仍使用 raw model;给它们增加独立恢复状态需要新的会话所有权契约,不能共享主 Agent 的 RecoveryState。
09与 Claude Code 的差异Claude Code 的恢复系统比教学版 P11 覆盖更广的生产场景。CC 的查询引擎定义了 17+ 个 reason/transition,每轮 LLM 调用后根据 stopreason 和错误类型路由到专门恢复路径;P11 只实现长度升级→续写→耗尽、prompttoolong→响应式压缩→耗尽、429/529→退避→fallback→耗尽三条路径,其余错误原样失败。上述 CC 实现细节来自 learn-claude-code/s11errorrecovery/README.md。⌄
Claude Code 的恢复系统比教学版 P11 覆盖更广的生产场景。CC 的查询引擎定义了 17+ 个 reason/transition,每轮 LLM 调用后根据 stop_reason 和错误类型路由到专门恢复路径;P11 只实现长度升级→续写→耗尽、prompt_too_long→响应式压缩→耗尽、429/529→退避→fallback→耗尽三条路径,其余错误原样失败。上述 CC 实现细节来自 learn-claude-code/s11_error_recovery/README.md。
退避公式。 CC 使用 min(500 × 2^(attempt-1), 32000) + random(0~25%)(毫秒),attempt 表示第几次重试。P11 应用 baseDelaySeconds × 2^attempt(默认 0.5s),封顶 maxDelaySeconds(默认 32s),再叠加可配置的 jitterRatio(默认 0.25)。CC 从 attempt=1 开始(attempt-1 让首次等待 500–625ms),P11 从 attempt=0 开始(2^0 = 1),同样得到 0.5s + 抖动。两条曲线实质相同,参数都可配置。
Fallback 切换。 CC 在切换到备用模型时清空当前轮所有 pending messages,并显示 "Switched to {model} due to high demand"。P11 只将 currentModel 从 primary 改为 fallback,messages 和 histories 原封不动。CC 的清空策略能避免不同模型 prompt 格式不兼容导致的 tokenization 错误;P11 保守地保留上下文,假设两模型 prompt 结构兼容。
续写收益递减。 CC 的 tokenBudget.ts 追踪每次 continuation 的 token 增量,连续 3 次每次不足 500 token 时主动停止续写,避免无限追加无实质进展的片段。P11 只检查固定的 maxContinuations 计数(默认 3 次),不评估每次 output 的实际质量变化。
流式恢复。 CC 在流式模式下会将 429/529 等错误缓冲,不中断已有的流式输出,等待流结束再决策重试;用户对后端恢复几乎无感知。P11 当前的 ModelClient 和 RecoveryManager 都走 request/response 模式,一次 LLM 调用要么返回要么抛错,不支持流式恢复路径。
10运行与验证Set-Location 'F:\笔记\Agent实操\code'⌄
两种入口共用同一个 P11 Bootstrap:
Set-Location 'F:\笔记\Agent实操\code'
npm run ch11 -- --prompt "检查当前工作区并给出结论"
npm run agent-tutorial -- run --chapter 11 --prompt "检查当前工作区并给出结论"离线测试使用确定性模型、FakeClock、RecordingSleeper 和模拟 OpenAI SDK 状态对象,不需要 API Key,也不会访问网络:
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch11
npm exec vitest run chapters/ch11/tests/openai-chat.test.ts chapters/ch11/tests/config.test.ts
npm run typecheck
npm run lint
npm run format:check当前实现有意不重试未知 5xx、连接重置、DNS、TLS、认证和流式中断。没有明确 typed signal、幂等语义和测试前,这些错误原样失败比“看起来更坚强”的广泛重试更安全。
11小结API 韧性不是把每个异常都吞掉,而是让每条恢复路径有可验证的输入、状态和终止条件:⌄
API 韧性不是把每个异常都吞掉,而是让每条恢复路径有可验证的输入、状态和终止条件:
- 输出截断只在请求窗口内升级和续写,正式历史只接收最终完整回复。
- 输入过长只压缩一次请求快照,完整消息组和当前 system prompt 不被拆坏。
- 429/529 只由结构化信号驱动,退避、fallback、取消和总时限都可测试。
- 未知错误不重试,所有耗尽路径明确失败。
下一篇进入任务系统。Agent 已经能从 API 故障中恢复,但项目任务仍需要跨进程持久化、依赖解锁和原子认领。第 12 章会把会话 TODO 与真正的 JSON Task DAG 分开建模。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始