返回 AICAP-180
B8 · Day 74真 API + 流式 + Docker

typed 错误 + 重试

Day 73 把真模型流式接通、测了 TTFT,但真流式必然会遇上游故障:限流、瞬时 5xx、超时、流中断。今天在 B1→B18 曲线上补「韧性」这块——把 Day 71 契约里的 4 类 typed error code 落到执行层:

阶段: B8 · 真 API + 流式 + Docker(Day 71-80) 标签: #retry #backoff-jitter #circuit-breaker #error-contract

今日导引(由浅入深)

Day 73 把真模型流式接通、测了 TTFT,但真流式必然会遇上游故障:限流、瞬时 5xx、超时、流中断。今天在 B1→B18 曲线上补「韧性」这块——把 Day 71 契约里的 4 类 typed error code 落到执行层:

  • 哪类该重试。
  • 退避公式怎么算。
  • 流断了怎么干净终止。

这是 B9(部署 + 韧性 + 可观测)的预热,也是 agent API「能被生产依赖」的硬门槛。明天(Day 75)会把整个 eval 套件当集成测试夹具跑 API,今天的 error code 契约正是那些 contract 用例要断言的对象。

最小可判定产出:注入 429 与超时场景,断言 /chat 包装层返回结构化 error code 而非裸 500,新增 3 个错误用例测试。

1. 机理精读

1.1 upstream 故障要分类,不同类走不同重试策略

三类:

  • 429(限流):上游说「你太快了」,正确响应是指数退避 + jitter 后重试——直接重发只会加剧拥塞。
  • 5xx(瞬时服务端错):上游临时抽风,通常可重试。
  • 4xx(客户端错,429 除外):请求本身有问题(参数错、鉴权失败),重试无意义,应不重试直接返回。

这个二分(4xx 不重试 / 429+5xx 可重试)正是 Day 71 那 4 类 error code 的语义来源,也是 resilience.tsshouldRetry 谓词该编码的决策。

1.2 流中断要可恢复或干净终止,绝不能吞

流式比一次性请求多一个失败面:流到一半断了。两条出路:

  • 要么可恢复(SSE 的 Last-Event-ID 续传,但模型流通常不支持续传,因为生成不可重放)。
  • 要么干净终止:向客户端发一个明确的 error 帧或关闭连接并返回 typed upstream_error/timeout,而不是静默挂起或吐半截就没了。

「吞掉错误」是最坏反模式——客户端会永远等。

1.3 契约测试断言 error code 而非 message

这是 Day 71 契约可被信赖的执行保证:

  • 测试要断言 error.code === 'rate_limited',而不是断言 message 文本含某个词。
  • 理由:message 是给人看的、会改文案;code 是给机器看的、是稳定契约。

断言在 code 上,你随便改 message 都不破坏契约;断言在 message 上,改一个字就红一片测试且误导调用方。

1.4 退避公式引 AWS Builders' Library(经典打底)

引 AWS Builders' Library: Timeouts/Retries/Backoff。full jitter 退避

sleep = random(0, min(cap, base * 2^attempt))

把重试时刻打散,避免大量客户端同时退避、同时再次冲击上游形成「重试风暴」(thundering herd)。纯指数退避(无 jitter)会让所有失败客户端在同一时刻同步重试——表面退避了,实则把瞬时尖峰平移到下一个时刻。jitter 是把同步打成异步的关键。

circuit breaker 三态(closed/open/half-open)是另一层保护:上游持续失败时熔断,快速失败而非继续打死上游。

2. 推导 / 手算 / 代码走读

本日复用 src/agent/runtime/resilience.ts 已构建+已测的原语,不重写。走读真实符号与行为:

2.1 backoffDelay(attempt, opts)

  • raw = min(base*factor^attempt, max),再叠 delta = raw * jitter * (rng()*2-1),返回 max(0, round(raw+delta))
  • 默认 base=100ms, factor=2, max=10_000ms, jitter=0.2,即 ±20% jitter 的截顶指数退避。
  • 手算 attempt 序列(rng 取中值 0.5 → delta=0,看纯指数部分):attempt 0→100ms、1→200ms、2→400ms、3→800ms…直到截顶 10s。
  • jitter=0.2 时 attempt 2 的实际范围是 400 ± 80 = [320, 480]ms(因 rng()*2-1 ∈ [-1, 1])。

2.2 retry(fn, opts)

  • 循环 attempt=0..retries(默认 retries=3,共 4 次尝试)。
  • 失败时 if (attempt === retries || !shouldRetry(e)) break,否则 await sleep(backoffDelay(attempt, opts))
  • shouldRetry 默认 () => true——本日要注入一个只对 rate_limited/timeout/upstream_error 返回 true、对 invalid_request 返回 false 的谓词。
  • sleep 可注入,单测时传 no-op,无需真定时器。

2.3 CircuitBreaker.exec(fn)

  • getState()==='open' 时直接 throw new Error('circuit open')
  • 成功则 failures=0, state='closed';失败则 failures++,达 threshold(默认 5)即 state='open', openedAt=now()
  • getState() 在 open 且超 cooldownMs(默认 5000)后转 half-open。三态齐全,now() 可注入。

2.4 withFallback(primary, fallback)

  • primary 抛错则跑 fallback,返回 { value, usedFallback }——给 upstream_error 一个降级出路(注释示例:DeepSeek-V3 → Qwen3)。

2.5 本日新增工作

写一个 /chat 包装层,把上游抛出的错误映射成 4 类 typed error code,注入 429 与超时场景,断言包装层返回 rate_limited/timeout 而非裸 500。退避序列断言可直接复用 resilience 现有单测模式(注入固定 rng + no-op sleep,断言 backoffDelay 输出序列)。

3. 今日实战

  1. /chat 包装层加一个 error 映射函数:上游 429 → rate_limited、上游 5xx/网络错 → upstream_error、wall-clock 超时 → timeout、参数 / 鉴权错 → invalid_request
  2. 重试用 src/agent/runtime/resilience.tsretry,注入 shouldRetry 谓词(仅 429/5xx/超时重试),sleep 注入 no-op 供测试。
  3. 注入 429 场景:mock 上游连续返回 429,断言包装层最终返回 error.code === 'rate_limited' 且经过了退避序列(断言 backoffDelay 输出)。
  4. 注入超时场景:mock 上游永不返回,断言顶层超时后返回 error.code === 'timeout',流被干净终止。
  5. 新增 3 个错误用例测试,跑 pnpm test

4. 今日实测 / 产出

  • src/agent/runtime/resilience.ts已构建+已测backoffDelay/retry/CircuitBreaker/withFallback tested)。
  • 本日新增产出:3 个错误用例 passing test(待补写并跑 pnpm test
  • 退避序列断言可直接复用 resilience 现有单测模式(注入固定 rng + no-op sleep)。

5. 常见误区 / 陷阱

  • 无 jitter 的纯指数退避:会造成同步重试风暴(thundering herd),必须用 full jitter 把重试时刻打散。
  • 对 4xx 也重试:参数错 / 鉴权错重试无意义且浪费配额,shouldRetry 必须对 invalid_request 返回 false。
  • 吞掉流中断错误:流断了静默挂起会让客户端永远等,必须干净终止 + 返回 typed error。
  • 断言 error message 而非 code:改文案就破坏测试与契约,永远断言稳定的 error.code

6. 学习资源(每条带 YYYY-MM)

  • AWS, "Timeouts, retries, and backoff with jitter"(Amazon Builders' Library,持续更新;full jitter 公式经典打底)。
  • Michael Nygard, "Release It!"(2nd ed., 2018;circuit breaker 三态模式原始出处,经典打底)。
  • 本仓代码:src/agent/runtime/resilience.tsbackoffDelay/retry/CircuitBreaker/withFallback,B9,纯函数无 key)。
  • Anthropic / DeepSeek API "Errors & rate limits" 文档(官方,2026 现行;429/5xx 语义与重试建议)。

SOTA检查 (2026-06 更新)

  • 当前主流:AWS Builders' Library 退避 + jitter 公式(full jitter)仍是 SOTA 实践;circuit breaker 三态(closed/open/half-open)为标准——src/agent/runtime/resilience.ts 已实现两者。
  • 是否仍 SOTA:是。退避+jitter+熔断是分布式调用韧性的稳定基线,无被取代迹象。
  • 过时黑名单
    • 避免无 jitter 的纯指数退避(同步重试风暴)。
    • 避免对 4xx 盲目重试。
    • 避免吞掉流中断。
  • 下次复查点:B9(部署 + 韧性 + 可观测)整合时,复验熔断阈值 / 退避参数是否需按真实上游 429 速率调参;接入 OTel 后复验 error code 是否完整打入 span。

衔接

  • 昨天:Day 73 — 模型流式接入(DeepSeek 真流式 + TTFT)。
  • 今天:把 4 类 typed error code 落到执行层——分类重试、full jitter 退避、流干净终止,复用 resilience.ts,新增 3 个错误用例测试。
  • 明天:Day 75 — API 接 eval 套件(把 eval 任务当 contract 用例跑 /chat,既测 API 正确性又出能力数字)。