一、能跑的代码和能用的代码,差在哪
先把前几篇的代码摆出来看。它没写错的任何一件事:
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
// 鉴权头此处略去,见第六节:凭证从配置注入,不写死在代码里
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
逻辑是对的,路径也是通的。它的问题在别处:没有一个地方回答了"如果出错了怎么办"。
顺着问下去,会碰到一串它没回答的问题:
- 网络连不上,要等多久才放弃?是一直卡着吗?
- 上游排队慢,首字两分钟不回来,能撑多久?
- 生成到一半流断了,业务该做什么?
- 报 429 了,直接失败还是重试?重试几次?
- 上次超时的请求,其实上游可能已经处理了,再发一次会不会重复扣费?
- HTTP 200 但响应体里带了个
error,这种情况算什么错?
这些问题的答案,才是"封装"这件事的真正内容。把 HTTP 请求发出去,只是顺带做的一步。
所以下面的顺序是按"出错时会发生什么"来排的,而不是按代码结构排。
二、超时:一个值远远不够
最常见的写法是设一个总超时,比如 30 秒。对大模型调用来说,这一个值几乎一定是不合适的。
原因在于,一次大模型请求里有四个性质完全不同的时间段:
- 连接建立:TCP 握手加 TLS。正常就几百毫秒。它慢或者超时,说明是网络或 DNS 的问题,跟模型无关。
- 首字时间(TTFB):请求发出到收到第一个数据块。正常几百毫秒到两三秒。它超了,说明上游在排队、限流或者过载。
- 块间隔:相邻两个数据块之间的间隔。正常几十毫秒。它超了,说明流卡住了——这是唯一真正表示"这个连接已经废了"的信号。
- 总时长:从发出到全部收完。它由回答长度决定,波动极大。
前三个指标都相对稳定,可以用固定阈值判断。只有第四个不一样:同一个接口,短问题可能 1 秒答完,让它写一篇三千字的方案可能要 90 秒。你想用一个值同时管住这四种情况,就必然要在某一边妥协。
妥协的后果很具体:
- 按总时长设成 60 秒去保证长回答能写完 → 网络断开时,你要等 60 秒才等到失败,而且这段时间连接和线程都占着。
- 按总时长设成 5 秒去快速失败 → 所有长回答都被你自己掐断,业务直接不可用。
分开设,就有意义了
四个值分开设之后,每个阈值只承担一件事,判断起来就清楚了:
- 连接超时 5 秒:超过就是网络问题,立刻失败,不要重试太多次(网络断了重试也是白等)。
- 首字超时 15 秒:超过就是上游排队或过载,可以重试,也可以降级到备用模型。
- 块间隔超时 20 秒:超过就是流卡死,立刻断开并按"中断"处理。这个值给得宽一点没关系,因为正常的间隔只有几十毫秒,20 秒已经是很宽松的判断。
- 总时长:给一个很大的兜底值(比如 300 秒),只用来防止极端情况下的资源泄漏,不作为常规判断依据。
有没有发现,这样一拆,每个数字都变得好解释了。以前那个"30 秒"你答不出为什么是 30,现在每个值背后都有一个具体的失败模式。
JDK HttpClient 上怎么落
这里要说清楚一个容易踩的地方:JDK 自带的 HttpClient,connectTimeout 管的是连接建立,这个直接能用:
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
但 HttpRequest.timeout() 管的是整个请求的完成时间。对流式调用来说,这就是"总时长"——你一旦设了它,长回答会被硬掐断。所以流式场景下通常不设它,或者只设一个很大的兜底值。
那首字和块间隔怎么管?JDK HttpClient 没有直接提供按块计时的钩子,需要自己加一层。可行的做法是在读流的循环里记时间戳,配合一个定时任务在超时后打断读取:
long lastDataAt = System.currentTimeMillis();
// 巡检任务:发现间隔超限就取消当前请求
ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor();
ScheduledFuture<?> guard = watchdog.scheduleAtFixedRate(() -> {
long gap = System.currentTimeMillis() - lastDataAt;
if (gap > GAP_TIMEOUT_MS) {
currentFuture.cancel(true); // 打断读取
}
}, 1, 1, TimeUnit.SECONDS);
lastDataAt 在每收到一个数据块时更新,首字阶段从请求发出时刻起算。这个写法不优雅但有效,实际项目里够用。
如果你用的是 OkHttp 或 Apache HttpClient,它们提供了更细粒度的读超时配置,可以把上面这层自己省掉。选型时值得把这个当作一个考虑因素——流式场景对超时粒度是有要求的,客户端库支持得越细,你要自己写的东西越少。
三、重试:先问能不能重试,再问重试几次
"失败了重试三次"是很多人的默认写法。但在大模型调用里,这个默认动作可能让成本直接翻几倍。
先分类:什么错值得重试
可以重试:
- 网络层的瞬时异常,连接被重置、DNS 临时失败。这类错误重试一次往往就好了。
- 429(限流)。这明确表示"现在不行,待会儿来",重试是对的,但必须配合退避,立刻重试只会继续被拒。
- 5xx(服务端错误)。上游临时故障,可以重试。
- 首字超时。请求可能根本没被上游受理,重试是安全的。
不要重试:
- 400 系列里的参数错误。你的请求格式写错了,重试一万次还是同样的错误,只是浪费时间。
- 401 / 403。凭证无效或没权限,重试没有任何意义。这类错误要立刻暴露出来,别被重试掩盖了——这是最烦的一种情况:因为重试逻辑存在,上线后你只看到"重试三次都失败了",看不到真正的原因。
- 内容被安全策略拦截。重试同一个输入,结果一样。
绝对不能重试:
流式请求已经开始推送给用户的。 这时候内容已经吐出去半句了,重试会让用户看到重复或互相矛盾的两段内容。这种情况只能按"中断"处理,要么提示用户可以重试,要么从断点续写。
退避必须带抖动
定个"等 1 秒再重试"看起来合理,但如果同时有 200 个请求都失败了,它们会在同一秒一起回来,把上游再冲一次。这就是重试风暴。
所以退避要加随机抖动:
long base = 500L * (1L << attempt); // 500ms, 1s, 2s, 4s
long jitter = ThreadLocalRandom.current().nextLong(base / 2);
long delay = base + jitter; // 抖动打散重试时刻
Thread.sleep(delay);
指数退避负责给上游留出恢复时间,抖动负责避免大家同时回来。两个缺一不可。
重试会放大成本,这一条要算清楚
如果一次重试 3 遍,最坏情况下你的 token 消耗是请求数的 4 倍(原始 1 次 + 重试 3 次)。而且是那种"用户看不到任何好处"的消耗——重试的是失败请求,产出的内容用户根本没拿到。
所以几个克制的原则:
- 重试次数别超过 2 次。第 3 次基本可以确定不是瞬时问题了。
- 只有幂等的调用才自动重试。如果你的调用会写库、会扣费、会触发下游动作,自动重试可能造成重复写入,这时候要么不自动重试,要么先做幂等设计。
- 流式请求的重试要格外保守。前面说了,已经输出的内容没法撤回。
四、异常分类:把"错误"拆成能被处理的东西
封装的另一个核心价值,是把底层五花八门的失败,收敛成上层能写 catch 的几种类型。
为什么要收敛?因为上层的处理逻辑其实只有几种:重试、降级、提示用户、告警。如果底层往上抛的是"IOException"这种笼统的东西,上层就没法判断该做哪一种,只能一律当"未知错误"返回给用户。用户体验就是"系统繁忙"——什么信息都没有。
一个够用的分类是这样的:
public class LlmException extends RuntimeException {
private final Kind kind;
public enum Kind {
NETWORK, // 网络问题 —— 可重试
TIMEOUT_TTFB, // 首字超时 —— 可重试
TIMEOUT_GAP, // 流卡死 —— 不可重试,按中断处理
RATE_LIMITED, // 限流 —— 退避后重试
AUTH, // 鉴权失败 —— 立刻告警,别重试
BAD_REQUEST, // 参数错误 —— 开发问题,直接暴露
CONTENT_FILTER, // 内容被拦 —— 提示用户换说法
TRUNCATED, // 输出被截断 —— 结果不完整
UPSTREAM // 上游服务错误 —— 可重试
}
}
有了这个分类,上层的逻辑就能写得很直白了:
try {
return client.chat(prompt);
} catch (LlmException e) {
if (shouldRetry(e.getKind())) {
/* 退避重试 */ }
else if (e.getKind() == Kind.CONTENT_FILTER) {
/* 提示用户 */ }
else {
/* 告警 + 降级 */ }
}
一个专门的坑:HTTP 200 里藏着的错误
很多模型接口在参数错误或鉴权失败时,不返回 4xx,而是返回 200,然后在响应体里给一个 error 对象:
{
"error": {
"code": "AuthenticationError",
"message": "Authentication failed. Please check your configuration."
}
}
流式接口更容易这样,因为 SSE 连接一旦建立就是 200,错误只能塞在数据块里。
所以客户端的判断顺序应该是:先看 HTTP 状态码,再解析响应体,检查里面有没有 error 字段。只看状态码就以为成功了,是这里最常见的一个疏漏——尤其流式场景下,你会拿到一个 200,然后一堆解析不出内容的空数据,排查半天才发现是鉴权配置有问题。
TRUNCATED 这个类型也值得单独说。它不是"错误",而是一种需要把握的状态:finish_reason 是 length,说明回答被 max_tokens 截断了。如果你的业务会把结果存下来,这个状态必须在落库前判断——否则数据库里会积累一堆半截的记录,而且事后极难从内容上分辨哪条是完整的。
五、HttpClient 不要每次 new
这一条简单但影响不小。HttpClient.newHttpClient() 每次调用都会新建一个连接池和一个线程池。如果你每个请求都 new 一个,结果是:
- 每次请求都重新做 TCP 握手和 TLS 协商,延迟白白多出几百毫秒;
- 高并发下线程数失控,每个客户端还各自持有几个线程,几百个请求就能把内存吃光。
正确做法是整个应用共用一个实例:
public final class LlmHttpClientHolder {
private static final HttpClient INSTANCE = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.executor(Executors.newFixedThreadPool(16)) // 显式给线程池,别用默认的
.build();
public static HttpClient get() {
return INSTANCE; }
}
顺带一个细节:JDK HttpClient 默认的 executor 用的是 CachedThreadPool,在流式场景下会创建大量线程。显式指定一个固定大小的线程池,在高并发时更可控。池大小按你的并发量估,不需要很大——因为流式请求大部分时间在等 IO,不是在占 CPU。
HttpClient 本身是线程安全的,可以放心共享。
六、配置外置:凭证不要放在代码里
前几篇的示例里,凭证是直接写死在常量里的。那只是为了方便阅读,真实项目里不能这样:
- 代码提交到仓库,凭证就跟着进了版本历史,删都删不干净;
- 换环境(测试/预发/生产)要改代码重新打包;
- 多个模型(主用、备用)的地址和凭证散落在各处,改一处漏一处。
至少要抽出一份配置:
llm:
primary:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen3.8-max
# 凭证不写在这里:从环境变量或配置中心注入
timeouts:
connect: 5s
ttfb: 15s
gap: 20s
total: 300s
retry:
max-attempts: 2
base-delay: 500ms
凭证通过环境变量或配置中心注入,不落仓库。超时和重试参数外置之后,调优不用重新发版——这一点在线上出问题时会救命,因为你往往需要临时把某个超时调大来观察现象。
七、最终形态
把上面的东西合起来,客户端对外应该只暴露很窄的一组方法:
public interface LlmClient {
/** 非流式:拿完整结果 */
String chat(String prompt, LlmOptions options);
/** 流式:逐块回调,返回完整结果供落库 */
String chatStream(String prompt, LlmOptions options, Consumer<String> onChunk);
}
内部实现要负责的事,按重要性排:
- 超时分层:连接、首字、块间隔、总时长四道,各自独立判断;
- 异常分类:把底层失败翻译成上层能处理的
Kind; - 重试策略:只对可重试类型生效,指数退避带抖动,默认最多 2 次;
- 中断处理:流断了要能识别出"这是半截内容",不落库、不重试;
- 连接复用:单例 HttpClient,固定大小线程池;
- 配置注入:凭证、地址、超时、重试全部外置。
注意这里没有一件事是"怎么把请求发出去"。那是整个封装里最简单的一步。
八、小结
回到开头那个问题:能跑的代码和能用的代码差在哪?
差在它有没有回答"出错时怎么办"。前几篇的代码在顺利路径上和现在这份是等价的,功能一样、结果一样。区别只在出问题的那一刻:一个给你一句 IOException,另一个告诉你"这是首字超时,可以重试,已经重试过 2 次,建议降级"。
而大模型调用恰恰是一个失败率不低的场景——上游排队、限流、偶发超时都是常态。所以这层封装不是"架构洁癖",它直接决定了线上出问题时,你是能快速定位,还是只能对着"系统繁忙"发呆。
下一篇
客户端有了,下一个问题是它要装什么。多轮对话是最典型的使用场景,也是最容易做错的一个:上下文怎么传、传多少、超长了丢哪部分、会话状态存在哪。这些问题处理不好,会出现"模型答着答着忘了前面说过什么"这种让人抓狂的现象,而且原因往往不在模型身上。
本文是「用 Java 接入大模型」系列第四篇。前三篇分别讲了把流式输出跑通、流式与非流式怎么选、关键参数怎么调,建议按顺序看。文中超时与重试取值基于通义千问的 OpenAI 兼容接口实践,具体以你使用的模型文档为准。