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.ts 里 shouldRetry 谓词该编码的决策。
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. 今日实战
- 在
/chat包装层加一个 error 映射函数:上游 429 →rate_limited、上游 5xx/网络错 →upstream_error、wall-clock 超时 →timeout、参数 / 鉴权错 →invalid_request。 - 重试用
src/agent/runtime/resilience.ts的retry,注入shouldRetry谓词(仅 429/5xx/超时重试),sleep注入 no-op 供测试。 - 注入 429 场景:mock 上游连续返回 429,断言包装层最终返回
error.code === 'rate_limited'且经过了退避序列(断言backoffDelay输出)。 - 注入超时场景:mock 上游永不返回,断言顶层超时后返回
error.code === 'timeout',流被干净终止。 - 新增 3 个错误用例测试,跑
pnpm test。
4. 今日实测 / 产出
src/agent/runtime/resilience.ts:已构建+已测(backoffDelay/retry/CircuitBreaker/withFallbacktested)。- 本日新增产出: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.ts(backoffDelay/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 正确性又出能力数字)。