第6b章:API 通信层 — 重试、流式与降级工程
定位:本章分析 CC 的 API 通信韧性子系统——指数退避重试、529 过载降级、双重看门狗流式中断检测与 Fast Mode 缓存感知重试。前置依赖:第3章(Agent Loop)。适用场景:想理解 CC 的重试策略、流式处理、降级工程的读者,或自己需要构建稳健 API 通信层的开发者。
services/api/目录不是一个 SDK 封装层——它是 Agent 的控制平面(Control Plane)。模型降级、缓存保护、文件传输、Prompt 回放调试,这些决策全部发生在这一层。本章聚焦其中最核心的韧性子系统:重试、流式和降级。文件传输通道(Files API)和 Prompt Replay 调试工具分别在本章末尾和第 29 章补充分析。
为什么这很重要
一个 Agent 系统的可靠性并不取决于模型有多智能,而取决于它在最差网络条件下是否还能正常工作。想象一个开发者在火车上使用 Claude Code 处理一个紧急 bug:WiFi 时断时续,API 偶尔返回 529 过载错误,一次流式响应在收到一半时突然中断。如果通信层没有足够的韧性设计,这位开发者要么看到一个莫名其妙的崩溃,要么不得不反复手动重试,浪费宝贵的上下文窗口。
Claude Code 的通信层解决的正是这类问题。它不是一个简单的"失败了就重试"的包装器,而是一个多层防御系统:指数退避避免雪崩效应、529 计数器触发模型降级、双重看门狗检测流式中断、Fast Mode 缓存感知重试保护成本、持久模式支持无人值守场景。这些机制共同构成了一个核心工程理念:通信失败是常态而非异常,系统必须在每一层都有预案。
更值得关注的是这套系统的可观测性设计。每次 API 调用都会发出三个遥测事件——tengu_api_query(请求发出)、tengu_api_success(成功响应)、tengu_api_error(失败响应)——配合 25 种错误分类和网关指纹检测,让每一次通信故障都可追溯、可诊断。这是一个经过真实生产流量锤炼的系统,它的每一行代码都映射着一个曾经发生过的故障场景。
源码分析
交互式版本:点击查看重试与降级动画 — 4 种场景(正常/429 限流/529 过载/Fast Mode 降级)的时序图动画。
6b.1 重试策略:从指数退避到模型降级
Claude Code 的重试系统实现在 withRetry.ts 中,核心是一个 AsyncGenerator 函数 withRetry(),它在重试等待期间通过 yield 向上层传递 SystemAPIErrorMessage,让 UI 可以实时显示重试状态。
常量与配置
重试系统的行为由一组精心调校的常量控制:
| 常量 | 值 | 用途 | 源码位置 |
|---|---|---|---|
DEFAULT_MAX_RETRIES | 10 | 默认重试预算 | withRetry.ts:52 |
MAX_529_RETRIES | 3 | 连续 529 过载后触发模型降级 | withRetry.ts:54 |
BASE_DELAY_MS | 500 | 指数退避基数(500ms × 2^(attempt-1)) | withRetry.ts:55 |
PERSISTENT_MAX_BACKOFF_MS | 5 分钟 | 持久模式最大退避上限 | withRetry.ts:96 |
PERSISTENT_RESET_CAP_MS | 6 小时 | 持久模式绝对上限 | withRetry.ts:97 |
HEARTBEAT_INTERVAL_MS | 30 秒 | 心跳间隔(防止容器空闲回收) | withRetry.ts:98 |
SHORT_RETRY_THRESHOLD_MS | 20 秒 | Fast Mode 短重试阈值 | withRetry.ts:800 |
DEFAULT_FAST_MODE_FALLBACK_HOLD_MS | 30 分钟 | Fast Mode 冷却时间 | withRetry.ts:799 |
10 次重试预算看似慷慨,但结合指数退避(500ms → 1s → 2s → 4s → 8s → 16s → 32s × 4),总等待约 2.5-3 分钟。实际实现还会在每次退避上叠加 0-25% 的随机抖动(Jitter,withRetry.ts:542-547),避免多个客户端在同一时刻同步重试导致雷群效应(Thundering Herd)。这是一个经过权衡的设计:足够多以应对短暂的网络抖动,又不至于让用户在 API 彻底不可用时等待太久。
重试决策:shouldRetry 函数
shouldRetry() 函数是重试系统的核心决策器,定义在 withRetry.ts:696-787。它接收一个 APIError,返回一个布尔值。分析其所有返回路径,可以归纳为三类:
绝不重试的情况:
| 条件 | 返回 | 原因 |
|---|---|---|
| Mock 错误(测试用) | false | 来自 /mock-limits 命令,不应被重试覆盖 |
x-should-retry: false(非 ant 用户或非 5xx) | false | 服务端明确指示不重试 |
| 无状态码且非连接错误 | false | 无法判断错误类型 |
| ClaudeAI 订阅用户的 429(非企业版) | false | Max/Pro 用户的限额是小时级别的,重试无意义 |
总是重试的情况:
| 条件 | 返回 | 原因 |
|---|---|---|
| 持久模式下的 429/529 | true | 无人值守场景需要无限重试 |
| CCR 模式下的 401/403 | true | 远程环境的认证是基础设施管理的,短暂失败可恢复 |
| 上下文溢出错误(400) | true | 可解析错误消息并自动调整 max_tokens(withRetry.ts:726) |
错误消息包含 overloaded_error | true | SDK 在流式模式下有时无法正确传递 529 状态码 |
APIConnectionError(连接错误) | true | 网络瞬断是最常见的暂时性错误 |
| 408(请求超时) | true | 服务端超时,重试通常能成功 |
| 409(锁超时) | true | 后端资源竞争,重试通常能成功 |
| 401(认证错误) | true | 清除 API key 缓存后重试 |
| 403(OAuth token 被撤销) | true | 另一个进程刷新了 token |
| 5xx(服务端错误) | true | 服务端内部错误通常是暂时的 |
条件重试的情况:
| 条件 | 返回 | 原因 |
|---|---|---|
x-should-retry: true 且非 ClaudeAI 订阅用户,或虽为订阅用户但属于企业版 | true | 服务端指示重试,且用户类型支持 |
| 429(非 ClaudeAI 订阅或企业版) | true | 按量计费用户的速率限制是短暂的 |
这里有一个值得注意的设计决策:对于 ClaudeAI 订阅用户(Max/Pro),即使 x-should-retry header 为 true,也不重试 429 错误。原因在源码注释中说得很清楚:
// restored-src/src/services/api/withRetry.ts:735-736
// For Max and Pro users, should-retry is true, but in several hours, so we shouldn't.
// Enterprise users can retry because they typically use PAYG instead of rate limits.
Max/Pro 用户的速率限制窗口是数小时级别的——重试只会白白消耗时间,不如直接告诉用户。这是理解用户场景后的差异化决策,而非一刀切的重试策略。
错误分类的三层漏斗
Claude Code 的错误处理不是一个扁平的 switch-case,而是一个三层漏斗结构:
classifyAPIError() — 19+ 种具体类型(用于遥测和诊断)
↓ 映射
categorizeRetryableAPIError() — 4 种 SDK 类别(用于上层错误展示)
↓ 决策
shouldRetry() — boolean(用于重试循环)
第一层 classifyAPIError()(errors.ts:965-1161)将错误细分为 25 种以上的具体类型,包括 aborted、api_timeout、repeated_529、capacity_off_switch、rate_limit、server_overload、prompt_too_long、pdf_too_large、pdf_password_protected、image_too_large、tool_use_mismatch、unexpected_tool_result、duplicate_tool_use_id、invalid_model、credit_balance_low、invalid_api_key、token_revoked、oauth_org_not_allowed、auth_error、bedrock_model_access、server_error、client_error、ssl_cert_error、connection_error、unknown。这些分类直接写入 tengu_api_error 遥测事件的 errorType 字段,使得线上问题可以精确归类。
第二层 categorizeRetryableAPIError()(errors.ts:1163-1182)将这些细分类型合并为 4 种 SDK 层面的类别:rate_limit(429 和 529)、authentication_failed(401 和 403)、server_error(408+)、unknown。这一层为上层 UI 提供简化的错误展示。
第三层就是 shouldRetry() 本身,做最终的布尔决策。
这种三层设计的好处是:诊断信息可以非常详细(25 种分类),而决策逻辑保持简洁(true/false)。两个关注点完全解耦。
529 过载的特殊处理
529 错误在 Claude Code 的重试系统中有特殊地位。一个 529 意味着 API 后端容量不足——不同于 429(用户限速),这是系统级别的过载。
首先,不是所有请求源(Query Source)都会重试 529。FOREGROUND_529_RETRY_SOURCES(withRetry.ts:62-82)定义了一个白名单,只有前台请求(用户正在等待结果的请求)才会重试:
// restored-src/src/services/api/withRetry.ts:57-61
// Foreground query sources where the user IS blocking on the result — these
// retry on 529. Everything else (summaries, titles, suggestions, classifiers)
// bails immediately: during a capacity cascade each retry is 3-10× gateway
// amplification, and the user never sees those fail anyway.
这是一个系统级减载(load shedding)策略:当后端过载时,后台任务(摘要生成、标题生成、建议生成)立即放弃而不是加入重试队列。每一次重试都是对过载后端的 3-10 倍放大效应——减少不必要的重试是缓解级联故障的关键。
其次,连续 3 次 529 后会触发模型降级。这个逻辑在 withRetry.ts:327-364:
// restored-src/src/services/api/withRetry.ts:327-351
if (is529Error(error) &&
(process.env.FALLBACK_FOR_ALL_PRIMARY_MODELS ||
(!isClaudeAISubscriber() && isNonCustomOpusModel(options.model)))
) {
consecutive529Errors++
if (consecutive529Errors >= MAX_529_RETRIES) {
if (options.fallbackModel) {
logEvent('tengu_api_opus_fallback_triggered', {
original_model: options.model,
fallback_model: options.fallbackModel,
provider: getAPIProviderForStatsig(),
})
throw new FallbackTriggeredError(
options.model,
options.fallbackModel,
)
}
// ...
}
}
FallbackTriggeredError(withRetry.ts:160-168)是一个专用的错误类。它不是普通的异常——它是一个控制流信号,被上层 Agent Loop 捕获后触发模型切换(通常从 Opus 降级到 Sonnet)。这种用异常做控制流的模式在很多场景中是反模式,但在这里是合理的:降级事件需要穿透多层调用栈到达 Agent Loop,异常是最自然的向上传播机制。
同样重要的是 CannotRetryError(withRetry.ts:144-158),它携带了 retryContext(包含当前模型、thinking 配置、max_tokens 覆盖等),让上层在决定如何处理失败时有足够的上下文信息。
6b.2 流式处理:双重看门狗
流式响应是 Claude Code 用户体验的核心——用户看到文字逐渐出现,而不是等待一个漫长的空白页。但流式连接比普通 HTTP 请求脆弱得多:TCP 连接可能被中间代理静默关闭,服务端可能在生成过程中挂起,SDK 的超时机制只覆盖初始连接而不覆盖数据流阶段。
Claude Code 在 claude.ts 中用两层看门狗解决这个问题。
Idle Timeout 看门狗(中断型)
// restored-src/src/services/api/claude.ts:1877-1878
const STREAM_IDLE_TIMEOUT_MS =
parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000
const STREAM_IDLE_WARNING_MS = STREAM_IDLE_TIMEOUT_MS / 2
Idle 看门狗的设计是一个经典的两阶段告警模式:
- 警告阶段(45 秒):如果 45 秒没有收到任何流式事件(chunk),记录一条警告日志和诊断事件
cli_streaming_idle_warning。此时流可能只是慢,不一定死了。 - 超时阶段(90 秒):如果 90 秒完全没有事件,判定流已死。标记
streamIdleAborted = true,记录performance.now()快照(用于后续度量 abort 传播延迟),发送tengu_streaming_idle_timeout遥测事件,然后调用releaseStreamResources()强制中断流。
每当收到一个新的流式事件时,resetStreamIdleTimer() 重置两个定时器。这确保了只要流还活着——即使很慢——看门狗不会误杀它。
// restored-src/src/services/api/claude.ts:1895-1928
function resetStreamIdleTimer(): void {
clearStreamIdleTimers()
if (!streamWatchdogEnabled) { return }
streamIdleWarningTimer = setTimeout(/* 警告 */, STREAM_IDLE_WARNING_MS)
streamIdleTimer = setTimeout(() => {
streamIdleAborted = true
streamWatchdogFiredAt = performance.now()
// ... 日志和遥测
releaseStreamResources()
}, STREAM_IDLE_TIMEOUT_MS)
}
注意看门狗需要通过环境变量 CLAUDE_ENABLE_STREAM_WATCHDOG 显式启用。这说明该功能仍处于渐进式上线阶段——先在内部和小范围用户中验证,再推广到所有用户。
Stall 检测(日志型)
// restored-src/src/services/api/claude.ts:1936
const STALL_THRESHOLD_MS = 30_000 // 30 seconds
Stall 检测与 Idle 看门狗解决的是不同的问题:
- Idle = "完全没有收到任何事件"(连接可能已经死了)
- Stall = "收到了事件,但两个事件之间间隔太大"(连接还活着,但服务端很慢)
Stall 检测只记录不中断。当两个流式事件之间的间隔超过 30 秒时,它累加 stallCount 和 totalStallTime,并发送 tengu_streaming_stall 遥测事件:
// restored-src/src/services/api/claude.ts:1944-1965
if (lastEventTime !== null) {
const timeSinceLastEvent = now - lastEventTime
if (timeSinceLastEvent > STALL_THRESHOLD_MS) {
stallCount++
totalStallTime += timeSinceLastEvent
logForDebugging(
`Streaming stall detected: ${(timeSinceLastEvent / 1000).toFixed(1)}s gap between events (stall #${stallCount})`,
{ level: 'warn' },
)
logEvent('tengu_streaming_stall', { /* ... */ })
}
}
lastEventTime = now
一个关键的细节:lastEventTime 在第一个 chunk 到达后才开始设置,避免将 TTFB(Time to First Token,首 token 延迟)误判为 stall。TTFB 可以合法地很高(模型在思考),但一旦开始输出,后续事件间隔就应该稳定。
两层看门狗的协作关系可以用下图表示:
graph TD
A[流式连接建立] --> B{收到事件?}
B -->|是| C[resetStreamIdleTimer]
C --> D{距上一事件 > 30s?}
D -->|是| E[记录 Stall<br/>不中断]
D -->|否| F[正常处理事件]
E --> F
B -->|否, 等待中| G{已等待 45s?}
G -->|是| H[记录 Idle Warning]
H --> I{已等待 90s?}
I -->|是| J[中断流<br/>触发回退]
I -->|否| B
G -->|否| B
非流式回退
当流式连接被看门狗中断或因其他原因失败时,Claude Code 会回退到非流式(Non-streaming)请求模式。这个逻辑在 claude.ts:2464-2569。
回退时记录两个关键信息:
fallback_cause:'watchdog'(看门狗超时)或'other'(其他错误),用于区分触发原因。initialConsecutive529Errors:如果流式失败本身是 529 错误,将计数传递给非流式的重试循环。这确保了 529 计数在流式→非流式的切换中不会重置:
// restored-src/src/services/api/claude.ts:2559
initialConsecutive529Errors: is529Error(streamingError) ? 1 : 0,
非流式回退有独立的超时配置:
// restored-src/src/services/api/claude.ts:807-811
function getNonstreamingFallbackTimeoutMs(): number {
const override = parseInt(process.env.API_TIMEOUT_MS || '', 10)
if (override) return override
return isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ? 120_000 : 300_000
}
CCR(Claude Code Remote)环境默认 2 分钟,本地环境默认 5 分钟。CCR 的超时更短是因为远程容器有 ~5 分钟的空闲回收机制——一个 5 分钟的 hang 会让容器被 SIGKILL,不如在 2 分钟时优雅超时。
值得一提的是,非流式回退可以通过 Feature Flag tengu_disable_streaming_to_non_streaming_fallback 或环境变量 CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK 禁用。禁用的原因在源码注释中有清楚的解释:
// restored-src/src/services/api/claude.ts:2464-2468
// When the flag is enabled, skip the non-streaming fallback and let the
// error propagate to withRetry. The mid-stream fallback causes double tool
// execution when streaming tool execution is active: the partial stream
// starts a tool, then the non-streaming retry produces the same tool_use
// and runs it again. See inc-4258.
这是一个真实的生产事故(inc-4258)催生的修复:当流式过程中已经开始执行工具,然后回退到非流式重试时,同一个工具会被执行两次。这种"部分完成 + 完整重试 = 重复执行"是所有流式系统的经典陷阱。
6b.3 Fast Mode 缓存感知重试
Fast Mode 是 Claude Code 的加速模式(详见第21章),它使用独立的模型名称来获得更高的吞吐量。Fast Mode 下的重试策略有一个独特的考量:Prompt Cache。
当 Fast Mode 遇到 429(速率限制)或 529(过载)时,重试决策的核心在于 Retry-After header 告知的等待时间(withRetry.ts:267-305):
// restored-src/src/services/api/withRetry.ts:284-304
const retryAfterMs = getRetryAfterMs(error)
if (retryAfterMs !== null && retryAfterMs < SHORT_RETRY_THRESHOLD_MS) {
// Short retry-after: wait and retry with fast mode still active
// to preserve prompt cache (same model name on retry).
await sleep(retryAfterMs, options.signal, { abortError })
continue
}
// Long or unknown retry-after: enter cooldown (switches to standard
// speed model), with a minimum floor to avoid flip-flopping.
const cooldownMs = Math.max(
retryAfterMs ?? DEFAULT_FAST_MODE_FALLBACK_HOLD_MS,
MIN_COOLDOWN_MS,
)
const cooldownReason: CooldownReason = is529Error(error)
? 'overloaded'
: 'rate_limit'
triggerFastModeCooldown(Date.now() + cooldownMs, cooldownReason)
这个设计背后的成本权衡是:
| 场景 | 等待时间 | 策略 | 原因 |
|---|---|---|---|
Retry-After < 20s | 短暂 | 原地等待,保留 Fast Mode | 缓存不会因为 <20s 的等待而失效,保留缓存可以大幅减少下次请求的 token 成本 |
Retry-After ≥ 20s 或未知 | 较长 | 切换到标准模式,进入冷却 | 缓存可能已失效,不如立即切换到标准模式恢复可用性 |
冷却期的下限是 10 分钟(MIN_COOLDOWN_MS),默认值是 30 分钟(DEFAULT_FAST_MODE_FALLBACK_HOLD_MS)。设置下限的目的是防止 Fast Mode 在限速边缘反复切换(flip-flopping),造成用户体验的不稳定。
另外,如果 429 是因为额外用量(Overage)不可用——即用户的订阅不支持超额使用——Fast Mode 会被永久禁用而非临时冷却:
// restored-src/src/services/api/withRetry.ts:275-281
const overageReason = error.headers?.get(
'anthropic-ratelimit-unified-overage-disabled-reason',
)
if (overageReason !== null && overageReason !== undefined) {
handleFastModeOverageRejection(overageReason)
retryContext.fastMode = false
continue
}
6b.4 持久重试模式
设置环境变量 CLAUDE_CODE_UNATTENDED_RETRY=1 后,Claude Code 进入持久重试模式(Persistent Retry Mode)。这个模式为无人值守场景(CI/CD、批处理、ant 内部自动化)设计,其核心行为是:对 429/529 无限重试。
持久模式的三个关键设计:
1. 无限循环 + 独立计数器
普通模式下 attempt 从 1 增长到 maxRetries + 1 后循环终止。持久模式通过在循环末尾钳制 attempt 值来实现无限循环:
// restored-src/src/services/api/withRetry.ts:505-506
// Clamp so the for-loop never terminates. Backoff uses the separate
// persistentAttempt counter which keeps growing to the 5-min cap.
if (attempt >= maxRetries) attempt = maxRetries
persistentAttempt 是一个独立的计数器,只在持久模式下递增,用于计算退避延迟。它不受 maxRetries 限制,所以退避时间会持续增长直到 5 分钟上限。
2. 窗口级速率限制感知
对于 429 错误,持久模式会检查 anthropic-ratelimit-unified-reset header 中的重置时间戳。如果服务端告知"5 小时后重置",系统会直接等待到重置时间,而不是傻傻地每 5 分钟轮询一次:
// restored-src/src/services/api/withRetry.ts:436-447
if (persistent && error instanceof APIError && error.status === 429) {
persistentAttempt++
const resetDelay = getRateLimitResetDelayMs(error)
delayMs =
resetDelay ??
Math.min(
getRetryDelay(persistentAttempt, retryAfter, PERSISTENT_MAX_BACKOFF_MS),
PERSISTENT_RESET_CAP_MS,
)
}
3. 心跳保活
这是持久模式中最巧妙的设计。当退避时间很长(比如 5 分钟)时,系统不是做一次 sleep(300000),而是将其切片为多个 30 秒的片段,每个片段后 yield 一个 SystemAPIErrorMessage:
// restored-src/src/services/api/withRetry.ts:489-503
let remaining = delayMs
while (remaining > 0) {
if (options.signal?.aborted) throw new APIUserAbortError()
if (error instanceof APIError) {
yield createSystemAPIErrorMessage(
error,
remaining,
reportedAttempt,
maxRetries,
)
}
const chunk = Math.min(remaining, HEARTBEAT_INTERVAL_MS)
await sleep(chunk, options.signal, { abortError })
remaining -= chunk
}
心跳机制解决了两个问题:
- 容器空闲回收:CCR 等远程环境会将长时间无输出的进程判定为空闲并回收。每 30 秒的 yield 在 stdout 上产生活动,防止被误杀。
- 用户中断响应:在每个 30 秒片段之间检查
signal.aborted,确保用户可以随时中断长时间的等待。如果是一次性的sleep(300s),用户按 Ctrl-C 后需要等到 sleep 结束才能生效。
源码中的 TODO 注释揭示了这个设计的权宜性质:
// restored-src/src/services/api/withRetry.ts:94-95
// TODO(ANT-344): the keep-alive via SystemAPIErrorMessage yields is a stopgap
// until there's a dedicated keep-alive channel.
6b.5 API 可观测性
Claude Code 的 API 可观测性系统实现在 logging.ts 中,围绕三个遥测事件构建:
三事件模型
| 事件 | 触发时机 | 关键字段 | 源码位置 |
|---|---|---|---|
tengu_api_query | 请求发出时 | model, messagesLength, betas, querySource, thinkingType, effortValue, fastMode | logging.ts:196 |
tengu_api_success | 成功响应时 | model, inputTokens, outputTokens, cachedInputTokens, ttftMs, costUSD, gateway, didFallBackToNonStreaming | logging.ts:463 |
tengu_api_error | 失败响应时 | model, error, status, errorType(25 种分类), durationMs, attempt, gateway | logging.ts:304 |
这三个事件构成了一个完整的请求漏斗:query → success/error。通过关联 requestId,可以追踪一个请求从发出到完成的完整生命周期。
TTFB 与缓存命中
成功事件中最关键的性能指标是 ttftMs(Time to First Token)——从请求发出到第一个流式 chunk 到达的时间。这个指标直接反映了:
- 网络延迟(客户端到 API 端的往返时间)
- 排队延迟(请求在 API 后端排队的时间)
- 模型首 token 生成时间(与 prompt 长度和模型大小相关)
缓存相关字段(cachedInputTokens 和 uncachedInputTokens 即 cache_creation_input_tokens)让团队可以监控 Prompt Cache 的命中率,这直接影响成本和 TTFB。
网关指纹检测
logging.ts 中一个容易被忽略的功能是网关检测(detectGateway(),logging.ts:107-139)。它通过响应 header 的前缀来识别请求是否经过了第三方 AI 网关:
| 网关 | Header 前缀 |
|---|---|
| LiteLLM | x-litellm- |
| Helicone | helicone- |
| Portkey | x-portkey- |
| Cloudflare AI Gateway | cf-aig- |
| Kong | x-kong- |
| Braintrust | x-bt- |
| Databricks | 通过域名后缀检测 |
检测到网关后,gateway 字段会被加入成功和错误事件。这让 Anthropic 团队可以诊断"某些网关环境下的特定错误模式"——例如,如果通过 LiteLLM 代理时 404 错误率异常高,可能是代理配置问题而非 API 问题。
错误分类的诊断价值
错误事件中的 errorType 使用 classifyAPIError() 的 25 种分类。相比简单的 HTTP 状态码,这些分类提供了更精确的诊断信息:
| 分类 | 含义 | 诊断价值 |
|---|---|---|
repeated_529 | 连续 529 超过阈值 | 区分偶发过载和持续不可用 |
tool_use_mismatch | 工具调用/结果不匹配 | 提示上下文管理有 bug |
ssl_cert_error | SSL 证书问题 | 提示用户检查代理配置 |
token_revoked | OAuth token 被撤销 | 提示多实例竞争 token |
bedrock_model_access | Bedrock 模型访问错误 | 提示用户检查 IAM 权限 |
模式提炼
模式一:有限重试预算 + 独立降级阈值
- 解决的问题:无限重试导致用户等待和成本失控;同时,不同错误类型需要不同的耐心阈值
- 核心做法:设定全局重试预算(10 次),同时为特定错误(529 过载)设定独立的子预算(3 次)。子预算耗尽触发降级而非放弃。两个计数器独立运行,互不干扰
- 前置条件:必须有明确的降级方案(fallback model);降级本身不应消耗主预算
- 源码引用:
restored-src/src/services/api/withRetry.ts:52-54—DEFAULT_MAX_RETRIES=10,MAX_529_RETRIES=3
模式二:双重看门狗(日志型 + 中断型)
- 解决的问题:流式连接可能无声死亡——TCP keepalive 无法覆盖应用层的静默挂起
- 核心做法:设置两层检测器。Stall 检测(30 秒)在事件间隔过大时只记录日志和遥测,不干预流——因为慢不等于死。Idle 看门狗(90 秒)在完全无事件时中断连接并触发回退——因为 90 秒无活动的流几乎确定已经死了
- 前置条件:需要有非流式回退路径;看门狗需要可配置(不同网络环境的阈值不同)
- 源码引用:
restored-src/src/services/api/claude.ts:1936— Stall 检测,restored-src/src/services/api/claude.ts:1877— Idle 看门狗
模式三:缓存感知的重试决策
- 解决的问题:重试可能导致 Prompt Cache 失效,而缓存失效意味着更高的 token 成本和更长的 TTFB
- 核心做法:根据预期等待时间做差异化决策。短等待(<20 秒)→ 保留缓存原地等待,因为缓存在 20 秒内不会过期;长等待(>=20 秒)→ 放弃缓存切换模式,因为等待的时间成本超过了重建缓存的成本
- 前置条件:API 需要提供
Retry-Afterheader;需要有替代模式可切换 - 源码引用:
restored-src/src/services/api/withRetry.ts:284-304
模式四:心跳保活
- 解决的问题:长时间 sleep 期间进程无输出,可能被宿主环境判定为空闲并回收
- 核心做法:将一次长 sleep 切片为 N 个 30 秒片段,每个片段之后 yield 一个消息保持流活跃。同时在每个片段之间检查中断信号,确保用户可以随时取消
- 前置条件:调用方需要是
AsyncGenerator或类似的协程结构,能在等待期间产出中间结果 - 源码引用:
restored-src/src/services/api/withRetry.ts:489-503
6b.5 文件传输通道:Files API
services/api/ 目录中还有一个常被忽视的子系统——filesApi.ts,它实现了与 Anthropic Public Files API 的文件上传/下载功能。这不是一个简单的 HTTP 客户端,而是服务于三个不同场景的文件传输通道:
| 场景 | 调用方 | 方向 | 用途 |
|---|---|---|---|
| 会话启动文件附件 | main.tsx | 下载 | --file=<id>:<path> 参数指定的文件 |
| CCR 种子包上传 | gitBundle.ts | 上传 | 远程会话的代码库打包传输(详见第 20c 章) |
| BYOC 文件持久化 | filePersistence.ts | 上传 | 每轮结束后上传修改过的文件 |
FilesApiConfig 的设计揭示了一个重要约束——文件操作需要 OAuth session token(而不是 API key),因为文件与会话绑定:
// restored-src/src/services/api/filesApi.ts:60-67
export type FilesApiConfig = {
/** OAuth token for authentication (from session JWT) */
oauthToken: string
/** Base URL for the API (default: https://api.anthropic.com) */
baseUrl?: string
/** Session ID for creating session-specific directories */
sessionId: string
}
文件大小上限为 500MB(MAX_FILE_SIZE_BYTES,第 82 行)。下载使用独立的重试逻辑(3 次指数退避,基数 500ms),而不是复用 withRetry.ts 的通用重试——因为文件下载的失败模式(超大文件、磁盘空间不足)与 API 调用的失败模式(429/529 过载)不同,需要独立的重试预算。
Beta 头部 files-api-2025-04-14,oauth-2025-04-20(第 27 行)表明这是一个仍在演进中的 API——oauth-2025-04-20 启用了 Bearer OAuth 在公共 API 路径上的认证支持。
用户能做什么
-
理解 529 和模型降级的关系。连续 3 次 529 过载后,Claude Code 会自动降级到 fallback 模型(通常从 Opus 降到 Sonnet)。如果你发现回答质量突然下降,可能是因为模型被降级了——检查终端输出中的
tengu_api_opus_fallback_triggered事件。这不是 bug,而是系统在保护可用性。 -
利用 Fast Mode 的缓存窗口。Fast Mode 下的短暂 429(Retry-After < 20 秒)不会导致缓存失效——Claude Code 会原地等待保留缓存。但超过 20 秒的等待会触发至少 10 分钟的冷却期,期间切换到标准速度。如果你频繁看到 Fast Mode 冷却,可能需要降低请求频率。
-
持久重试模式(v2.1.88 仅限 Anthropic 内部构建)。
CLAUDE_CODE_UNATTENDED_RETRY=1启用无限重试(带指数退避,上限 5 分钟),支持按anthropic-ratelimit-unified-resetheader 等到限额重置。如果你在构建自己的 Agent,这种"心跳保活 + 限额感知等待"的模式值得借鉴。 -
TTFB 是最关键的延迟指标。在
--verbose模式下,Claude Code 报告每次 API 调用的 TTFB(Time to First Token)。如果这个值异常高(>5 秒),可能表示 API 端过载或你的网络有问题。同时关注cachedInputTokens字段——如果这个值持续为 0,说明你的 Prompt Cache 没有命中,每次请求都在付全价(详见第 13 章)。 -
自定义流式超时阈值。如果你的网络环境延迟较高(例如通过 VPN 或卫星链路访问 API),默认的 90 秒 Idle Timeout 可能太激进。通过设置
CLAUDE_STREAM_IDLE_TIMEOUT_MS环境变量(同时需要CLAUDE_ENABLE_STREAM_WATCHDOG=1)可以调整超时阈值。 -
通过
CLAUDE_CODE_MAX_RETRIES调整重试预算。默认 10 次重试适合大多数场景,但如果你的 API 提供商经常返回暂时性错误,可以适当增加;如果你希望更快地得到失败反馈,可以减少到 3-5 次。
版本演化:v2.1.100 — Bedrock/Vertex 设置向导与模型升级
以下分析基于 v2.1.100 bundle 信号对比,结合 v2.1.88 源码推断。
交互式云平台设置向导
v2.1.100 为 AWS Bedrock 和 Google Vertex AI 引入了完整的交互式设置向导,取代了 v2.1.88 中需要手动配置环境变量的方式。以 Bedrock 为例(Vertex 流程对称),完整的设置生命周期由 3 个事件覆盖:
tengu_bedrock_setup_started → tengu_bedrock_setup_complete / tengu_bedrock_setup_cancelled
tengu_vertex_setup_started → tengu_vertex_setup_complete / tengu_vertex_setup_cancelled
设置向导从一个统一的平台选择菜单触发——用户可以选择 Bedrock、Vertex、或 Microsoft Foundry(oauth_platform_docs_opened 会打开对应的文档页面)。完成设置后,认证方式(auth_method)被记录在遥测中。
模型自动升级检测
v2.1.100 最有趣的新增是模型自动升级检测。当 Anthropic 发布新模型版本时,系统会自动检测用户当前配置是否可以升级:
检测流程:
upgrade_check(检查是否有可用升级)
→ probe_result(探测新模型在用户的 Bedrock/Vertex 账户中是否可用)
→ upgrade_accepted / upgrade_declined(用户决策)
→ upgrade_relaunch(升级后重启)/ upgrade_save_failed(保存失败)
从 bundle 中提取到的探测逻辑揭示了一个精巧的设计:
// v2.1.100 bundle 逆向 — Bedrock 升级探测
// 1. 检查未固定的模型层级
d("tengu_bedrock_default_check", { unpinned_tiers: String(q.length) });
// 2. 对每个未固定层级,探测新模型是否可用
let w = await Za8(O, Y.tier); // Za8 = probeBedrockModel
d("tengu_bedrock_probe_result", {
tier: Y.tier,
model_id: O,
accessible: String(w)
});
关键设计决策:
- 只检查"未固定"(unpinned)的模型层级——如果用户通过环境变量显式固定了模型 ID,系统不会建议升级
- 已拒绝的升级通过
bedrockDeclinedUpgrades/vertexDeclinedUpgrades持久化到用户设置中,不会反复提示 - 默认模型不可用时触发
default_fallback——自动切换到同层级的备选模型
Mantle 认证后端
v2.1.100 引入了 mantle 作为第五种 API 认证后端(与 firstParty、bedrock、vertex、foundry 并列)。通过 CLAUDE_CODE_USE_MANTLE 环境变量启用,CLAUDE_CODE_SKIP_MANTLE_AUTH 可跳过。Mantle 使用 anthropic. 前缀的模型 ID(如 anthropic.claude-haiku-4-5),暗示这是 Anthropic 托管的企业级认证通道,区别于直接 API 调用。
API 重试增强
新增 tengu_api_retry_after_too_long 事件,表明 v2.1.100 对 Retry-After header 值过大的情况增加了特殊处理——当 API 返回的重试等待时间超过合理阈值时,系统可能选择放弃等待而直接报告错误,避免用户长时间无响应。