W9 · 错误矩阵:重试、幂等、限流
本周交付 :
RetryingLlm(重试装饰器)+RateLimiter(令牌桶)+ 幂等账本 +drill w9预计耗时 :主块 4h(重构装饰器 1h / 限流 1h / 幂等 0.5h / 演练 1.5h)
前置 :W8 通过;W2 的
RetryPolicy/ErrorClassifier还在
1. 一句话
把重试从"每个实现里各写一遍"提成一个装饰器,然后用"实际调用次数"而不是"有没有抛异常"来验收。
2. 为什么要把重试提出来
W2 的重试写在 LlmClient 内部。到 W9 出问题了:
错误矩阵要统计"30 次请求里实际发生了多少次重试"。如果重试藏在模型实现内部,脚本模型和真模型就得各写一遍重试逻辑,两套逻辑迟早不一致,测出来的数字也就不敢信。
改成装饰器之后:
java
Llm llm = new RetryingLlm(new ScriptedLlm(), policy); // 脚本模型 + 真重试
Llm llm = new RetryingLlm(new LangChain4jLlm(...), policy); // 真模型 + 同一套重试
无论底层是谁,重试行为完全一致。 这是能测的前提。
顺带的好处:W11 之后加"多模型降级",也只是再套一层装饰器,不用改任何实现。
3. 四条验收线
3.1 重试次数有界------断言"调用次数",不是"有没有抛异常"
java
for (int i = 0; i < 10; i++) {
p.run("SN202610200001 我要退货 #[429]");
}
c.eq("实际调用次数有界(= 10 × 3)", 30, scripted.totalCalls());
如果只断言"抛了异常",你测不出"重试了 300 次"这种雪崩。
实测:429 / 500 / timeout 三种故障各 10 次请求,每次都是 3 次调用后放弃,共 30 次,全部降级为转人工,无异常逃逸。
3.2 不可重试的错误一次都不该重试
| 错误 | 码 | 调用次数 |
|---|---|---|
| 429 | RATE_LIMIT |
3 |
| 500 | SERVER_ERROR |
3 |
| 超时 | TIMEOUT |
3 |
| 401 | AUTH_FAILED |
1 |
401 重试三次只会让你多等 2 秒、多刷三条日志、多烧 3 次 token------鉴权失败重试没有意义。
这条能力来自 W2 的 ErrorClassifier。它默认"认不出来的错按 NEED_HUMAN 处理",就是为了防止这种情况。
3.3 幂等:重复提交不产生第二笔
java
var p1 = guard.propose("u1", "SN202610200001", new BigDecimal("50"), "外壳划痕");
var e1 = guard.execute(p1, Approval.system()); // R2-0001
var p2 = guard.propose("u1", "SN202610200001", new BigDecimal("50"), "外壳划痕");
var e2 = guard.execute(p2, Approval.system()); // R2-0001(同一个单号)
实测:两次返回同一个退款单号,movedMoney 仍然是 50 元。
为什么幂等键是 (userId, orderId) 而不是 proposalId?
因为重试场景下,第二次请求根本拿不到第一次的 proposalId------它是新生成的。业务键才是稳定的。
propose 遇到重复时返回原提案而不是抛异常,也是故意的:重复提交是正常业务(用户连点、消息重投),抛异常会让上层以为出错了,进而触发更多重试------正好放大问题。
3.4 限流:令牌桶 vs 漏桶
| 算法 | 特点 | 适用 |
|---|---|---|
| 漏桶 | 输出速率恒定,能整形流量 | 下游要求匀速时 |
| 令牌桶 | 允许突发(桶里攒令牌),长期速率受限 | 售后场景(用户一急就连发三条) |
实测:容量 5、每秒补 0.5,连发 6 次放行 5 次,第 6 次被拒并给出 retryAfterMs = 2000。
代价要说清楚:突发上限 = 桶容量,所以容量必须给得小,否则"突发"就等于"没限流"。容量 5 / 每秒 0.5 ≈ 每分钟 30 次。
为什么按用户限流而不是全局?
全局限流只保护自己不被网关打死,保护不了"一个用户吃光额度、其他人全部排队"。按用户限流的真正作用是:把一次异常行为的影响面锁在单个人身上。
4. 验收
bash
cd 教程/W1-W4/ecom-agent-lab
bash run.sh drill w9
实际输出:
故障 错误码 调用次数 最终结果 说明
429 LLM_RATE_LIMIT 30 全部降级 10 次请求 × 3 次尝试 = 30 次实际调用
500 LLM_SERVER_ERROR 30 全部降级
timeout LLM_TIMEOUT 30 全部降级
--- 不可重试的错误 ---
401 → LLM_AUTH_FAILED,实际调用 1 次
--- 幂等 ---
第一次:R2-0001,第二次:R2-0001,累计退款 50.00 元
--- 限流 ---
连发 6 次,放行 5 次,建议等待 2000ms
--- 雪崩检查 ---
20 次故障请求,最长状态路径 2 个状态
W9 错误矩阵:通过 15 / 失败 0
判定标准:
- 三种可重试故障各 10 次请求,实际调用次数 恰好 30(不是 10,也不是 300)
- 401 只调用 1 次
- 幂等:同一订单两次执行返回同一退款单号,金额只扣一次
- 限流:容量 5 时第 6 次被拒,且
retryAfterMs > 0 - 故障场景下最长状态路径 ≤ 4(说明没有雪崩式重入)
5. 本周踩的坑
坑 1:脚本模型抛自定义异常,分类器走不到第一层。
第一版 ScriptedLlm 抛 new RuntimeException("429 ..."),ErrorClassifier 只能走"文案兜底"那一层,连 code 都是 UNKNOWN。
改成抛 LangChain4j 自己的异常类型:
java
raw = new RateLimitException("429 Too Many Requests(脚本注入)");
throw wrap(raw); // wrap 里走 ErrorClassifier.verdict(),得到 RATE_LIMIT
这样测到的分类路径和真网关完全一致。自定义异常测出来的结论换个环境就不成立。
坑 2:脚本模型 + 装饰器不匹配,异常逃逸了。
RetryingLlm 只 catch LlmCallException。脚本模型直接抛 RuntimeException,于是"重试"根本没发生,异常一路冒到 drill 的 main。修法是脚本模型统一用 wrap() 包成 LlmCallException。
这条坑的教训是通用的:装饰器的契约是"只认一种异常",那么所有实现都必须遵守。 契约不写清楚,第三个实现就会破。
坑 3:把"没有雪崩"理解成"重试次数少"。
真正的雪崩不是重试次数多,是没有总预算 :每个请求重试 3 次,并发 100 就是 300 次同时打向一个正在 429 的网关。RetryPolicy.totalBudget + 总步数上限 + 按用户限流,三者缺一不可。
6. 自检清单
- 能说出为什么重试要做成装饰器而不是写在实现里
- 能说出 401 为什么一次都不该重试
- 能解释幂等键为什么用业务键而不是
proposalId - 能说出令牌桶和漏桶各适合什么场景
- 能解释"按用户限流"防的是什么(不是防自己被打死)
-
drill w915 条断言全绿 - 把
maxAttempts从 3 改成 1,看哪条断言变红(理解"次数有界"的测法)
7. 下一步
到这里,系统已经很稳了。但它有一个根本性缺口:
以上所有机制,都只能让模型"说错话"的概率下降。
没有一条能阻止"钱被退出去"。
W10 是 B 阶段唯一真正能防住资损的一周。