W2 教程:超时、重试与错误分类 ------ 让 AI 调用不再"偶发卡死"
本周交付物 :一个
LlmClient,能把"模型调用"这件事变得像数据库调用一样可预期 ------ 超时可控、失败可分类、重试有边界、日志可追溯。前置要求:W1 已完成(能成功调一次模型,能看到 token 数)。
本周耗时:4 小时主块 + 2 小时轻任务。
0. 先看这个:为什么 W2 比 W1 重要
W1 你做到的是"能跑"。但"能跑"和"能上线"之间,隔着一段所有后端都熟悉、但在 AI 场景里被重新发明的路。
看三个真实会发生的事:
场景 A:某天下午三点,网关开始限流(返回 429)。你的接口没有任何处理,直接把 429 抛给了前端。前端弹"系统错误"。用户重试了三次,还是错。客服收到 8 个投诉。你排查了两小时,发现网关那边只返了 3 秒错误 ------ 实际影响却被放大成了半小时。
场景 B :你把 max_tokens 从 1024 改成 2048,但参数名拼错了一个字母。网关返回 400。框架自带的重试机制不知道 400 不该重试,于是老老实实重试了 2 次。你的日志里出现 3 条一模一样的报错,留给排查者的第一印象是"是不是网关挂了三次",而不是"我的代码写错了"。
场景 C:大促期间,有一个请求卡在"等模型响应"上,因为没人设读超时。这个线程一直不释放。线程池满了。整个服务不可用,但 CPU 和内存都正常,监控上什么也看不出来。你重启了服务,问题消失 ------ 然后在下个流量高峰再次出现。
这三个场景的共同点是:它们都不是 AI 的问题,是分布式调用的老问题。 你作为后端已经知道该怎么处理 HTTP 调用,W2 要做的是把这套经验迁移到 LLM 调用上,并且识别出 LLM 特有的那几个差异点。
1. 两层超时:为什么必须设两个
注意:超时时间是有两个概念,一个是连接超时(connectTimeout,建议配置3秒,如果不配置系统会等很长时间),一个是请求超时(readTimeout,建议配置30秒,如果不配置,系统会一直等着响应)
这是 LLM 调用和普通 HTTP 调用最大的差异点,也是最多人踩的坑。
普通 REST 调用通常 100ms 返回。你设一个 5 秒超时,够了。但 LLM 调用是 3~30 秒量级,而且它的失败方式有两种,对应两个完全不同的阶段:
| 阶段 | 发生什么 | 该设哪个超时 | 不设的后果 |
|---|---|---|---|
| TCP 握手 | 你的机器要连上网关。网关宕机、DNS 挂了、防火墙挡了 | connectTimeout,建议 3 秒 |
操作系统默认可能等 75 秒。3 个并发就把 Tomcat 线程池拖死 |
| 等模型说话 | 连接已建立,请求已发出,模型正在推理(可能需要 20 秒) | readTimeout,建议 30 秒(看模型大小) |
线程永远挂着,直到网关那边超时断开。可能是几分钟 |
只设一个会怎样? 大多数 HTTP 客户端库,如果只给一个叫 timeout 的参数,它通常只作用于读超时(或者只作用于连接超时,视库而定)。这就是为什么"我明明设了超时,还是卡死了"。
在 LangChain4j 里怎么写
OpenAiChatModel.builder() 上有两个位置需要设:
java
OpenAiChatModel.builder()
.baseUrl(b.baseUrl)
.apiKey(b.apiKey)
.modelName(b.modelName)
.timeout(b.readTimeout) // ← 读超时
.httpClientBuilder(new JdkHttpClientBuilder()
.connectTimeout(b.connectTimeout) // ← 连接超时
.readTimeout(b.readTimeout))
.build();
注意两点:
.timeout(...)是 LangChain4j 自己的参数,.httpClientBuilder(...)里的才是底层 HTTP 客户端的。两个都要设,因为你不知道框架内部哪个生效。JdkHttpClientBuilder来自langchain4j-http-client-jdk。这个包在 W1 的 pom 里已经显式声明了 ------ 如果你跳过了那一步,现在会报"找不到符号"。它是 runtime scope 的传递依赖,编译期不可见,必须自己写进 pom。
怎么验证它真的生效
这是本周的第一个动手环节。不要靠读文档相信它生效了,要制造故障。
FaultGateway 里有一个 #[slow] 场景:收到请求后睡 5 秒再返回。你把 readTimeout 设成 800ms,如果超时真的生效,你应该在约 0.8 秒后拿到一个 TIMEOUT 错误,而不是等 5 秒拿到正常响应。
这个验证在后面的故障演练里会自动跑(场景 6),实测结果:attempts=2,总耗时约 1.6 秒(800ms × 2 次 + 少量退避)。数字对得上,说明超时生效。
2. 框架自带的 maxRetries,为什么不能直接用
OpenAiChatModel 有一个 maxRetries 参数,默认值是 2。看起来"框架帮我重试了,我不用管",实际上它有三个行为会让你在生产里吃亏。
我读了 RetryUtils.withRetry 的源码,行为是这样的:
行为一:只放弃 NonRetriableException,其他全重试
抛出 NonRetriableException → 立即放弃
抛出其他任何异常 → 重试
"其他任何异常"包括什么?包括你自己的 NullPointerException、ClassCastException、参数拼写错误导致的 IllegalArgumentException。
这意味着:你写错一行代码,同一次调用里这段坏代码被执行 3 遍。日志里出现 3 条报错。你排查时会先怀疑"网关不稳定",然后才想到看自己的代码。
正确的做法是反过来:默认可重试,白名单式地认定哪些不该重试。因为"不认识的错误"重试 3 遍,代价远大于收益。
行为二:退避参数是固定的 1 秒起步
delay * 1.5^retry + 0~20% 加性抖动,delay 默认 1000ms。
问题在于:这个退避不感知网关的 Retry-After 头。如果网关明确告诉你"请等 10 秒",框架还是按 1 秒、1.5 秒的节奏重试,你会在网关还没恢复的时候继续打它 ------ 从"重试"变成"攻击"。
行为三:没有总时限
maxRetries=2 意味着最多 3 次尝试。按 1 秒 + 1.5 秒的退避算,加上每次调用的耗时,一次逻辑调用最长可能拖到 10 秒以上(如果每次调用本身要 3 秒)。
你没法回答"这个接口最长多久返回"这个问题。这在前端体验上是致命的:用户不知道自己是该等还是该刷新。
所以这一周的第一件事
在 LlmClient 里写:
java
.maxRetries(0) // 关掉框架重试,重试权收归自己
然后自己写循环。这样你才知道每一次重试发生在什么时候、为什么、等了多久。
3. 错误三分法:RETRIABLE / NEED_HUMAN / FATAL
这是 W2 的核心思想。所有错误,不管它叫什么名字、来自哪一层,最后都必须落进这三类之一。
| 分类 | 含义 | 该怎么做 | 典型错误 |
|---|---|---|---|
| RETRIABLE | 现在不行,等一会可能行 | 退避后重试 | 429、500、502、503、读超时、连接重置 |
| NEED_HUMAN | 重试多少次都一样,得有人改东西 | 立刻失败,上报 | 400(参数错)、401(密钥错)、404(模型名错)、余额不足 |
| FATAL | 内容本身不行,换时间也没用 | 立刻失败,且不要记成服务故障 | 内容审核拦截、超出上下文长度 |
为什么是三类而不是两类
很多人只分"可重试 / 不可重试"。这不够,因为 NEED_HUMAN 和 FATAL 的处理方式完全不同:
NEED_HUMAN→ 要告警 ,要进值班流程,因为这是配置或代码问题,不修就一直错。FATAL→ 不要告警。内容被审核拦截是业务正常的一部分(用户发了违规内容),如果把它算进"服务错误率",你的可用性看板会莫名其妙地变红,然后所有人开始忽略告警。
ErrorKind 这个枚举里有个方法专门表达这个区别:
java
public enum ErrorKind {
RETRIABLE(true), NEED_HUMAN(false), FATAL(false);
private final boolean retryable;
public boolean retryable() { return retryable; }
/** 只有这一类算"服务自身的故障",才该进错误率看板 */
public boolean countsAsServiceFault() { return this == RETRIABLE; }
}
分类逻辑怎么写
ErrorClassifier 用了三层判断,顺序很重要:
第一层:LangkChain4j 的异常类型。 框架已经把一部分异常分好了类,直接用:
RetriableException
├─ RateLimitException → 429
├─ TimeoutException → 超时
├─ InternalServerException → 5xx
└─ UnresolvedModelServerException
NonRetriableException
├─ InvalidRequestException → 400
├─ AuthenticationException → 401
└─ ModelNotFoundException → 404
第二层:网络异常。 这些框架不一定包成自己的类型,需要遍历 cause 链找 IOException / SocketTimeoutException / ConnectException。
第三层:消息文本兜底。 只有当上面两层都没命中时才用,靠关键字匹配("rate limit"、"insufficient_quota" 等)。
最关键的约定写在 Javadoc 里 :三层都没命中时,默认归为 NEED_HUMAN,不是 RETRIABLE。
理由:一个你不认识的异常,重试 3 遍只会让日志更乱、用户等更久、问题被藏得更深。让它快速失败、暴露出来,是更负责任的选择。这条规则和"框架默认什么都重试"正好相反,是有意的。
4. 退避策略:从"全抖动"到"下界抖动"
这部分是本周唯一一处我在实测中发现原方案有问题、然后改掉了实现 的地方。值得单独说,因为它是很好的例子:看起来正确的教科书方案,在你的场景下可能是错的。
标准方案长什么样
重试退避的经典做法叫 exponential backoff with jitter (指数退避 + 抖动),AWS 那篇著名的博客推荐的是 full jitter:
等待时间 = random(0, min(cap, base * 2^attempt))
好处是:抖动范围大,多个客户端同时失败时重试时间点分散得开,不会形成"第二次冲击波"。
它的问题
我在写 W2 的故障演练第 8 个场景时,想验证"总预算 500ms 的护栏会不会提前截断"。设置了 baseDelay = 2秒、totalBudget = 500ms,预期是"预算不够,第一次失败就直接放弃,attempts = 1"。
实测结果是 attempts = 2。
排查发现:full jitter 取的是 random(0, 2000ms),这次抽到了 63ms。63ms + 第一次调用的耗时,还是小于 500ms 的预算,所以预算检查放行了,于是打了第二次。
63ms 的退避等于没有退避。对一个正在限流的网关来说,这恰恰是我们最该避免的行为 ------ 你说你在"退避重试",实际上你在"连续猛打"。
改成了什么
java
// 取下界抖动:在 [capped*(1-ratio), capped] 之间随机
long low = (long) (capped * (1.0 - jitterRatio));
long pick = ThreadLocalRandom.current().nextLong(Math.max(1, low), capped + 1);
baseDelay = 2秒、ratio = 1.0 时,等待落在 [1秒, 2秒]。既保留了"不同客户端错开"的好处,又保证退避真的在退避。
这个改动告诉你的三件事
- 教科书方案有前提假设。 Full jitter 假设你的目的是"最大化分散",但你的真实目的可能是"既要分散、又要真的等待"。目的不同,公式该不同。
- 断言要写"你真正想保证的事"。 场景 8 的断言最后写的是
attempts < maxAttempts(远少于 5 次),而不是attempts == 1。因为我要保证的是"预算护栏生效",不是"恰好重试一次"。写死成 1,下次调baseDelay就会误报失败,然后你开始怀疑正确的代码。 - 实测才能发现。 这个 bug 靠读代码是看不出来的 ------ 代码完全符合 full jitter 的定义。是"跑了、断言了、数字对不上"才把它逼出来的。
5. 结构化的调用日志
W1 的 CostMeter 记了 token 和成本。W2 在此基础上加两个字段,都是后面几周会反复用到的:
trace_id
每次逻辑调用生成一个,同一次调用里的所有重试共用一个 trace_id。
为什么重要:假设某次调用重试了 3 次最后成功。你在日志里搜这 3 条记录,如果它们没有共同标识,你没法判断"这是 1 次调用重试了 3 次"还是"3 次独立调用各失败 1 次"。这两个结论指向完全不同的排查方向。
实现很简单:
java
public static String newTraceId() {
return Long.toHexString(System.nanoTime()) + "-" + Long.toHexString(ThreadLocalRandom.current().nextLong());
}
不是 UUID 是因为:可读、短、本地生成无依赖。W18 接 OpenTelemetry 时会换成标准的 W3C trace context,但现在这样就够用。
prompt_version
每次调用带上 prompt 的版本号("v1.0.0")。W7 开始你会改 prompt,改完之后效果变好还是变坏,没有这个字段你根本对不上账。
PII 脱敏:顺序是有讲究的
电商场景的日志里会出现手机号、身份证、银行卡、订单号。直接打日志 = 数据泄露。
CallLog.maskPii 的实现,正则的顺序不能换:
java
r = r.replaceAll("(?<!\\d)(\\d{6})\\d{8}(\\d{3}[0-9Xx])(?!\\d)", "$1********$2"); // 身份证 18 位
r = r.replaceAll("(?<!\\d)(\\d{4})\\d{8,12}(\\d{4})(?!\\d)", "$1********$2"); // 银行卡
r = r.replaceAll("(?<!\\d)(1[3-9]\\d)\\d{4}(\\d{4})(?!\\d)", "$1****$2"); // 手机号 11 位
r = r.replaceAll("\\bSN(\\d{4})\\d{8}(\\d{4})\\b", "SN$1********$2"); // 订单号
为什么身份证必须在手机号前面?
一个 18 位身份证号,比如 110101199003078888,它的前 11 位 11010119900 匹配不上手机号规则(手机号要求 1[3-9] 开头,第 2 位是 1,不匹配 1)。这个例子碰巧安全。
但如果身份证是 13810119900307888 8 ------ 第 2 位是 3 ------ 前 11 位 13810119900 完全符合手机号格式 。如果先跑手机号规则,身份证的前 11 位会被当成手机号脱敏成 138****9900,剩下 3078888 裸露在日志里。既泄露了信息,又让脱敏后的字符串完全无法追溯。
所以顺序是:长的、格式严格的先匹配。身份证(18 位固定)→ 银行卡(16~20 位)→ 手机号(11 位)。
另一条注意:所有正则都用了 (?<!\d) 和 (?!\d) 前后向断言。不加的话,一个 30 位的流水号会从中截取 11 位当手机号脱敏,结果面目全非。
6. 故障演练:8 个场景,8 条断言
这是 W2 的验收方式。不是"跑一下没报错",是断言每个场景下 kind、code、attempts 三个值精确匹配。
为什么要用假网关
因为真实网关不会按需返回 429。你没法控制它什么时候限流、什么时候慢。等它自然发生,可能等一周,而且发生时你正在处理别的事。
FaultGateway 是一个本地 HTTP 服务(端口 18082),靠请求体里的标记切换行为:
| 标记 | 行为 | 用来验证 |
|---|---|---|
#[429] |
永远返回 429 | 重试到上限后放弃 |
#[429x2] |
前 2 次 429,第 3 次正常 | 重试真的能救回来 |
#[500] |
永远 500 | 可重试但重试完还是失败 |
#[400] |
永远 400 | 不该重试 |
#[401] |
永远 401 | 不该重试 |
#[slow] |
睡 5 秒 | 读超时生效 |
#[reset] |
直接断开连接 | 网络抖动 |
#[ok] |
正常返回 | 基线 |
启动方式和 W1 的假网关一样。selfcheck-w2.sh 会自动启停。
实测结果(8/8 通过)
PASS 限流(永远429) 失败 kind=RETRIABLE code=RATE_LIMIT attempts=3(期望 3)
PASS 限流(2次后恢复) 成功,attempts=3(期望 3)
PASS 服务端错误(永远500) 失败 kind=RETRIABLE code=SERVER_ERROR attempts=2(期望 2)
PASS 参数错误(400) 失败 kind=NEED_HUMAN code=BAD_REQUEST attempts=1(期望 1)
PASS 鉴权失败(401) 失败 kind=NEED_HUMAN code=AUTH_FAILED attempts=1(期望 1)
PASS 读超时(慢响应) 失败 kind=RETRIABLE code=TIMEOUT attempts=2(期望 2)
PASS 连接重置 失败 kind=RETRIABLE code=UNKNOWN attempts=2(期望 2)
PASS 预算耗尽的快速失败 attempts=1(应 < 5)耗时=2ms
看第 4、5 行:attempts=1。这就是分类器在起作用 ------ 400 和 401 被识别为 NEED_HUMAN,shouldRetry 返回 false,一次都不重试。如果这里显示 3,说明分类错了,你的日志会被垃圾报错填满。
看第 2 行:attempts=3 且成功。这是重试存在的全部意义:网关抖了 2 次,第 3 次好了,用户什么都没感觉到。
演练过程中修的三个真问题
这部分比结果本身更值得读 ------ 因为这三个问题都很典型。
问题 1:网关的恢复计数器在场景间串了
第一次跑,场景 2(#[429x2])失败,attempts=1,直接成功。日志显示 scene=429 hit#5 (已达恢复次数,返回正常)。
原因:FaultGateway 用 RECOVER_AFTER 记录"失败几次后恢复",HITS 记录"这个场景被打了几次"。这两个都是全局的 。场景 1 已经把 429 打了 3 次,场景 2 再进来时 hit 从 4 开始,早过了 RECOVER_AFTER=2 的门槛,于是第一次就拿到 200。
这是测试用例之间没有互相隔离 。修法不是改断言(那是掩盖问题),而是在每个 drill 开始前调 /__reset 清空网关状态:
java
private static void resetGateway() { /* POST /__reset */ }
这条教训值得记住:当你发现一个测试"结果不对",第一反应应该是"我的测试写对了吗",而不是"我的代码错了吗"。这次是测试的错。但如果顺序反了,你会去改一段正确的重试代码,把它改坏。
问题 2:full jitter 让退避失效(第 4 节已详述)
场景 8 期望 attempts=1,实测 attempts=2,因为抖动抽到了 63ms。改实现为下界抖动。
问题 3:我猜的错误码,和框架实际给的码不一样
写断言时我凭印象猜:读超时的 code 是 READ_TIMEOUT,连接重置是 CONNECTION_CLOSED。
实测出来是:读超时 → TIMEOUT(框架的 TimeoutException 被识别了),连接重置 → UNKNOWN(底层是 IOException: HTTP/1.1 header parser received no bytes,三层判断都没命中,落到默认兜底)。
猜 API 名字是最常见的时间浪费。 正确做法是先跑一次、把实际的 kind/code 打出来、再拿真实值写断言。这也是为什么 W2FailureDrill 的失败信息里会把实际值和期望值并排打出来 ------ 就是为了这个。
7. 把 LlmClient 用起来
W1 的 W1CostLab 是直接用 ChatModel 的。现在换成 LlmClient,你会得到重试、日志、trace_id:
java
LlmClient client = LlmClient.builder()
.fromEnv() // 读 LLM_BASE_URL / LLM_API_KEY / LLM_MODEL
.scene("after-sales-qa") // 场景名,日志里和后面的成本看板都靠它
.promptVersion("v1.0.0")
.readTimeout(Duration.ofSeconds(30))
.retryPolicy(RetryPolicy.interactive())
.build();
try {
LlmClient.Result r = client.chat("我买的手机屏幕碎了,能退吗?");
System.out.println(r.text());
System.out.println("attempts=" + r.attempts() + " 耗时=" + r.latencyMs() + "ms 成本=" + r.costCny());
} catch (LlmCallException e) {
switch (e.kind()) {
case RETRIABLE -> { /* 已经重试过了还是不行 → 走降级:返回兜底话术 */ }
case NEED_HUMAN -> { /* 告警,这是配置/代码问题 */ }
case FATAL -> { /* 内容问题,正常拒绝,不要告警 */ }
}
}
两个预设策略,按场景选:
| 预设 | maxAttempts | baseDelay | maxDelay | totalBudget | 适合 |
|---|---|---|---|---|---|
RetryPolicy.interactive() |
3 | 300ms | 4s | 20s | 用户在线等,比如客服对话 |
RetryPolicy.batch() |
4 | 1s | 20s | 90s | 后台跑批,比如批量给历史工单打标 |
降级接口 :chatOrEmpty() 失败时不抛异常,返回 Optional.empty()。适合"拿不到 AI 结果就用规则兜底"的场景。
8. 本周自测清单
跑:
bash
cd 教程/W1-W4/ecom-agent-lab
bash selfcheck-w2.sh
逐条对照,全部打勾才算过:
- 输出结尾是
通过 8 / 8 - 场景 4(400)和场景 5(401)显示
attempts=1------ 证明不该重试的错误一次都没重试 - 场景 2(2次后恢复)显示
成功,attempts=3------ 证明重试真的救回了一次失败 - 场景 6(读超时)的后端日志里
latency_ms在 800ms 附近,不是 5000ms ------ 证明读超时生效 - 网关统计里
"400": 1、"401": 1------ 网关侧确认只收到 1 次请求 - 随便找一条成功日志,能看到
trace_id/prompt_version/scene/input_tokens/output_tokens/cost_cny六个字段
加练(选做,20 分钟)
把你 W1 用的真实网关接进来,跑 W1CostLab 那 20 条售后问题,但用 LlmClient。然后:
- 故意把
apiKey改错一个字符,观察是不是只失败 1 次 (不是 3 次)、kind是不是NEED_HUMAN。 - 故意把
readTimeout设成 200ms(比模型响应速度快),观察attempts是否等于maxAttempts、总耗时是否被totalBudget拦住。
这两条能验证:在真实网关上,你的分类器和退避逻辑一样成立。
9. 一个必须说清楚的取舍
RetryPolicy 里的 retryAfterOf() 目前是显式的空实现:
java
private static Duration retryAfterOf(Throwable e) {
// 若你的网关会把 Retry-After 塞进错误消息,这里可以加解析。
// 留成显式的空实现,比假装支持更诚实。
return null;
}
LangChain4j 没有把 HTTP 响应头暴露到异常里,所以拿不到 Retry-After。我选择留一个空方法 + 注释,而不是写一段"看起来会解析、实际永远返回 null"的代码。
如果你们的网关会返回 Retry-After,并且你希望利用它,两条路:
- 简单版:让网关把秒数塞进错误消息(比如
"Too many requests, retry after 3s"),用正则从e.getMessage()里捞。 - 彻底版:不依赖 LangChain4j 的 model 层,自己用
java.net.http.HttpClient直接发 OpenAI 兼容请求,你能拿到完整的HttpResponse。代价是失去 LangChain4j 的抽象(后面接 memory、tool 都要自己实现)。
我建议现在选简单版。 因为 W2 的目标是"调用可控",不是"完美利用每一个 HTTP 头"。等 W8 你开始做多模型路由时,如果那时还觉得需要,再换。
10. 常见问题
Q:maxRetries(0) 会不会有副作用?
A:不会。它只是关掉框架内部的循环。你的循环在外面,次数由 RetryPolicy.maxAttempts 控制。唯一的注意点:maxRetries=0 意味着框架不再重试,你的循环必须写对,否则一次都不重试。用 selfcheck-w2.sh 验证。
Q:为什么 LlmClient 不自己处理 Retry-After,要留给上层?
A:因为它拿不到。见第 9 节。这不是设计选择,是 API 限制,所以我把它显式暴露出来而不是藏起来。
Q:trace_id 用 nanoTime + random,跨机器会不会撞?
A:会。但当前阶段不影响 ------ 你在单机排查。W18 接 OpenTelemetry 时会替换成标准 trace context。现在用 UUID 的代价是日志变长、不可读,收益不抵。
Q:attempts 到底指"总共调了几次"还是"重试了几次"?
A:总共调了几次,从 1 开始。attempts=1 表示一次成功没重试;attempts=3 表示失败了 2 次、第 3 次成功。这个定义写在 Result 的字段注释里,别改。
Q:读超时设 30 秒会不会太长?
A:看你模型。W1 实测那 20 条售后问题,耗时 2995~15057ms,P95 约 10 秒。30 秒给了 3 倍余量。如果你用更小的模型(比如 7B 级),可以降到 10 秒。但不要低于你的 P99 ------ 否则正常慢请求会被当故障重试,白白增加成本。
Q:我的网关连不上,FaultGateway 能测什么?
A:不能用。FaultGateway 是 W2 自带的独立假网关,监听 18082 端口,跟你的真实网关没有任何关系。如果你想验证真实网关的错误分类,需要让网关那边配合返回各种错误码 ------ 或者等 W4 自己做一层 mock 网关。当前先跑通自带演练。
11. 三个待核实项
和 W1 一样,这些需要你在自己环境里确认,不要当成已知事实:
-
你们的错误码是不是标准 OpenAI 格式。
ErrorClassifier的消息兜底匹配是照着 OpenAI 的错误文案写的("rate limit"、"insufficient_quota")。如果内部网关的中文错误消息是"请求过于频繁",匹配不上,会落到NEED_HUMAN默认分支 ------ 结果是"限流时不重试",效果变差但不危险。建议:拿到真实网关的 429 响应体,看文案,必要时往ErrorClassifier的消息模式里加一条。 -
网关会不会主动断开长连接。 如果网关侧有 60 秒的空闲连接回收,而你的
readTimeout设了 60 秒以上,实际超时由网关决定,你的配置形同虚设。建议:问一下网关的同学,或者故意发一个必然很慢的请求(超长 prompt)看多久被断开。 -
JdkHttpClientBuilder是不是你想要的 HTTP 客户端。 它是 JDK 自带的,零额外依赖,缺点是连接池行为不如 OkHttp 可调(比如 no proxy、keep-alive 时长)。如果你的网关在代理后面,可能需要换成OkHttpClientBuilder(要加langchain4j-http-client-okhttp依赖)。建议:W2 先用 JDK 的跑通,等遇到实际问题再换。
12. 本周小结
你现在有:
- 一个
LlmClient,两层超时 + 分类重试 + 总预算 + 结构化日志 - 一个
ErrorClassifier,三分类,默认不重试 - 一个
RetryPolicy,指数退避 + 下界抖动 +Retry-After预留 - 一个
FaultGateway,8 种故障按需复现,不花钱 - 一个
W2FailureDrill,8 条断言,实证你的逻辑对不对
下一周(W3)预告:Prompt 工程与结构化输出。你会把"自由文本回复"变成"能直接塞进业务对象里的 JSON",并且处理模型不听话的情况(返回的 JSON 缺字段、多字段、格式错)。W2 的分类器和重试在那里会派上新用场 ------ 因为"模型输出格式不对"也需要重试策略。
附:文件清单
ecom-agent-lab/
├── pom.xml
├── cp.txt # mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
├── selfcheck-w1.sh
├── selfcheck-w2.sh # ← 本周验收
├── w1-cost-log.csv
├── src/main/java/com/company/lab/
│ ├── W1Hello.java # W1:第一次成功调用
│ ├── CostMeter.java # W1:成本计算
│ ├── W1CostLab.java # W1:20 条售后问题实测
│ ├── FakeGateway.java # W1:简易假网关(18081)
│ ├── FaultGateway.java # W2:故障注入网关(18082)
│ ├── ErrorKind.java # W2:三分类枚举
│ ├── LlmCallException.java # W2:统一异常
│ ├── ErrorClassifier.java # W2:三层分类
│ ├── RetryPolicy.java # W2:退避策略
│ ├── CallLog.java # W2:结构化日志 + PII 脱敏
│ ├── LlmClient.java # W2:主交付物
│ └── W2FailureDrill.java # W2:8 场景演练
附:实测数据速查(写简历/汇报时可用)
W2 演练的实测结论,可直接作为"我做过什么"的素材:
- 8 种故障场景全部按预期分类,400/401 场景确认零重试 (
attempts=1) - 429 场景确认重试 3 次后放弃,恢复型场景确认第 3 次成功
- 读超时设为 800ms 时,端到端耗时约 1.6 秒(2 次尝试),证明超时配置生效
- 发现并修复 full jitter 导致退避失效的问题(实测抖动后可低至 63ms,改为下界抖动后最短 1000ms)
- 建立"测试用例隔离"规范:每个 drill 前重置网关状态,避免计数器串扰导致假红