Java转AI Agent教程(第二周)超时重试与错误分类

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();

注意两点:

  1. .timeout(...) 是 LangChain4j 自己的参数,.httpClientBuilder(...) 里的才是底层 HTTP 客户端的。两个都要设,因为你不知道框架内部哪个生效。
  2. 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秒]。既保留了"不同客户端错开"的好处,又保证退避真的在退避。

这个改动告诉你的三件事

  1. 教科书方案有前提假设。 Full jitter 假设你的目的是"最大化分散",但你的真实目的可能是"既要分散、又要真的等待"。目的不同,公式该不同。
  2. 断言要写"你真正想保证的事"。 场景 8 的断言最后写的是 attempts < maxAttempts(远少于 5 次),而不是 attempts == 1。因为我要保证的是"预算护栏生效",不是"恰好重试一次"。写死成 1,下次调 baseDelay 就会误报失败,然后你开始怀疑正确的代码。
  3. 实测才能发现。 这个 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。然后:

  1. 故意把 apiKey 改错一个字符,观察是不是只失败 1 次 (不是 3 次)、kind 是不是 NEED_HUMAN。
  2. 故意把 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 一样,这些需要你在自己环境里确认,不要当成已知事实:

  1. 你们的错误码是不是标准 OpenAI 格式。 ErrorClassifier 的消息兜底匹配是照着 OpenAI 的错误文案写的("rate limit"、"insufficient_quota")。如果内部网关的中文错误消息是"请求过于频繁",匹配不上,会落到 NEED_HUMAN 默认分支 ------ 结果是"限流时不重试",效果变差但不危险。建议:拿到真实网关的 429 响应体,看文案,必要时往 ErrorClassifier 的消息模式里加一条。

  2. 网关会不会主动断开长连接。 如果网关侧有 60 秒的空闲连接回收,而你的 readTimeout 设了 60 秒以上,实际超时由网关决定,你的配置形同虚设。建议:问一下网关的同学,或者故意发一个必然很慢的请求(超长 prompt)看多久被断开。

  3. 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 前重置网关状态,避免计数器串扰导致假红
相关推荐
lucas_AI1 小时前
1000 美元、12 小时、零人工:开源模型 GLM 5.3 把 Firefox 的「补丁窗口」打成了自动化通道
人工智能
青少儿编程课堂1 小时前
2026蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:堆排序与优先队列解析
c++·python·算法·bfs·信息学竞赛
听风吹等浪起1 小时前
第27章:EfficientNet全家桶|钢材表面6类缺陷分类完整Pytorch实现
pytorch·分类·数据挖掘
Eiceblue1 小时前
用 Python 给 Word 文档加水印:文字、图片与批量处理
开发语言·vscode·python·word
Hommy881 小时前
【直播切片工作台】第23章:媒体探测与转码:ffprobe 与 ffmpeg 封装
人工智能·ffmpeg·直播切片
Leo.yuan1 小时前
企业业务人员如何独立完成数据分析?2026年自助BI工具选型与应用指南
大数据·数据库·人工智能
俊哥V1 小时前
每日 AI 研究简报 · 2026-10-30
人工智能·ai
Android小码家1 小时前
framework&framework-minus-apex&android.jar的区别
android·python·jar
博谷1 小时前
两张图谱:品牌事实与 AI 认知之间
人工智能