用 Java 接入大模型(四):把调用封装成可复用的客户端

简介: 本文是Java接入大模型系列第四篇,聚焦将零散调用封装为健壮客户端。核心不在于“如何发送请求”,而在于系统性应对各类失败:分层超时(连接/首字/块间隔/总时长)、精准重试(区分可重试与不可重试错误)、异常分类(统一LlmException)、流中断识别、连接复用及配置外置。真正区分“能跑”与“能用”的,正是对出错场景的周全设计。

一、能跑的代码和能用的代码,差在哪

先把前几篇的代码摆出来看。它没写错的任何一件事:

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_reasonlength,说明回答被 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 兼容接口实践,具体以你使用的模型文档为准。

目录
相关文章
|
13天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
|
13天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。
|
19天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
12天前
|
IDE 开发工具
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
Qoder国际版上线全新内置大模型Sonus(/ˈsoʊnəs/),全球领先,专精超长任务执行与电脑操作(Computer Use)。配合Qoder桌面端0.2.3版本,可自主完成编程、金融建模、科研及表格制作等复杂工作。现全面支持Qoder全系产品,效率提升3.2倍。
1468 8
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
|
14天前
|
缓存 人工智能 自然语言处理
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
本文是阿里云百炼平台Qwen3.8-Flash大模型的选型接入指南,作为兼顾性能与响应速度的高性价比多模态模型,它支持百万级上下文窗口、全场景多模态输入与完整智能体能力矩阵,适配编程辅助、智能体协作等核心场景。文中同步梳理了最新下调的阶梯定价、夜间4折等优惠活动,搭配OpenAI兼容流式调用示例,帮助开发者低成本快速落地高并发AI应用。
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
|
13天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1988 15
|
7天前
|
缓存 IDE Java
【保姆级】Android Studio下载、安装和汉化教程(2026最新)
Android Studio 是 Google 官方推出的免费 Android 应用开发集成环境,基于 IntelliJ IDEA,内置模拟器、调试器、性能分析及 Compose 界面工具,功能全面,文档丰富,是安卓开发首选工具。(239字)
|
18天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1689 4
|
12天前
|
人工智能 安全 JavaScript
DeepSeek Harness开源Agent运行框架实战:4种安装方式、WebUI启动、插件管理与排坑全流程
随着AI Agent技术快速发展,单纯依靠大模型对话能力,很难完成复杂的自动化任务。模型需要具备读取本地文件、执行脚本、访问网页、操作文件系统、拆分复杂任务并分步执行的能力。DeepSeek Harness,简称DSH,是开源的AI Agent执行运行框架,遵循“Agent = 大模型 + Harness执行底座”的设计理念,为大模型提供一套安全可控的工具调用、任务编排、沙箱执行与插件扩展能力。它提供Web可视化界面与完整命令行工具,支持插件化扩展,能够让大模型自主拆解复杂需求,调用各类工具分步完成目标,无论是本地电脑调试,还是部署在云服务器上长期运行智能体任务都十分合适。本文为从0到1完整保
889 0
|
14天前
|
缓存 JSON API
阿里云千问Qwen3.8‑Max深度解析:核心能力、订阅计费规则、API接入配置与生产落地完整教程
Qwen3.8‑Max作为千问系列新一代MoE架构旗舰基座,总参数量达到2.4万亿,激活参数950亿,是面向复杂专业任务、长周期智能体、工程级代码开发、多模态深度解析的高阶大模型,原生支持文本、图像、视频多模态输入,最大上下文窗口达到百万Token,最大输出Token支持131072,内置深度思考推理链路,在编程、科研、法律金融专业分析、长视频文档解析、自主Agent任务等场景能力表现突出。很多开发者在项目前期直接接入该旗舰模型,却对模型能力边界、多种计费模式、订阅套餐权益、API参数配置、上下文缓存优化缺乏完整认知,出现成本失控、接口报错、长文本信息丢失、深度思考模式额外消耗大量Token等
955 3

热门文章

最新文章