1. 先聊聊为什么要在 Java 里接大模型
先说一个现象:最近两三年,大模型几乎成了后端开发的"标配外挂"。你要是在 Java 团队里负责业务系统,大概率会遇到这种需求——做一个智能客服、写一个代码审查助手、给内部知识库加一个问答入口,甚至只是帮运营自动生成一段文案。需求听着不复杂,但真正落地的第一关就卡住了:怎么在 Java 代码里调用大模型?
很多人的第一反应是去查某个大模型平台的官方 SDK。可实际上,大部分主流大模型平台的核心接口就是一个 HTTP 接口,所谓 SDK 也不过是帮你封装了请求和响应。也就是说,搞懂了 HTTP 调用,你就搞懂了大模型调用的底层逻辑;再往上走,才会理解那些开发框架到底帮你省了什么事。
这篇文章我就从实际开发的角度,把 Java 调大模型这条路完整捋一遍。先带你从最原始的 HTTP 请求出发,把请求头、请求体、流式响应这些底层细节掰碎了讲;然后再说说为什么封装框架是必然趋势,以及怎么在真实项目里选型落地。适合正在接触大模型开发、想搞清楚底层原理,又不满足于"照着文档复制粘贴"的 Java 后端开发者。
我是做后端出身的,一开始也是拿 HttpClient 硬怼大模型接口,后来才逐步迁移到框架。这条路踩过的坑不少,但走完一遍之后,你会发现自己不仅会调大模型了,对 HTTP 协议、网络编程、并发控制的理解也能上一个台阶。
2. 从零开始的大模型调用:本质是一场 HTTP 交互
2.1 大模型 API 到底长什么样
先帮你把"大模型调用"这个听起来很高大上的事情去神秘化。你在代码里发出一个请求,把你想要的任务描述(Prompt)和一些参数发给大模型服务端,服务端经过推理之后返回文本结果。这个过程对 Java 程序来说,跟你调用任何一个第三方 RESTful 接口没有任何区别。
看一下最典型的请求结构。绝大多数大模型平台都采用 POST 方式,请求头里带上认证信息,请求体是 JSON 格式,里面至少包含模型名和消息列表。我拿一个最常见的标准形态举例,长这样:
{ "model": "some-model-name", "messages": [ { "role": "user", "content": "用一句话介绍 Java 中的函数式编程" } ], "temperature": 0.7, "max_tokens": 512 }对应的响应也很有规律,非流式情况下通常是一个 JSON,里面包含模型的回复内容、Token 使用量和请求 ID 等元数据:
{ "id": "chatcmpl-something", "object": "chat.completion", "created": 1734567890, "model": "some-model-name", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "函数式编程是一种以函数为基本构建块的编程范式……" } } ], "usage": { "prompt_tokens": 13, "completion_tokens": 42, "total_tokens": 55 } }理解这一点非常重要。因为一旦你明白了它就是一个标准的 JSON over HTTP 接口,你就不会对"调用大模型"产生畏惧感,后面不管你换什么平台、什么 SDK,核心思路都是相通的。唯一有所不同的是各家在消息格式、参数命名、鉴权方式上会有细微差异,但骨架永远是这个骨架。
小提示:不同的平台在角色命名上略有差异,有的用
system+user+assistant,有的叫instruction。但基本都是三条:系统设定角色、用户输入角色、模型回复角色。这个三元结构是一切对话式大模型应用的基础。
2.2 为什么必须先从手动 HTTP 请求开始
我见过不少刚接触大模型开发的同事,跳过了 HTTP 阶段直接上框架。结果一旦遇到问题,完全不知道底层发生了什么。比如响应突然截断了,框架把异常吞掉后返回空字符串,你在上层排查半天也不知道根因在哪。这就是"跳过底层直接上封装"的代价。
我的建议是:不管最终是否使用框架,第一步一定要用最原始的手段把 HTTP 调用跑通一遍。这不只是为了熟悉 API,更是为了建立"问题定位"的感觉。当你亲手写出请求、亲手打印出响应体、亲眼看到网络异常长什么样,后面再用框架时,你会自然地知道应该去哪里排查问题。
而且说句实在话,很多小型项目、脚本任务、内部工具,根本不需要引入一个重型框架。直接写一个几十行的 HTTP 调用工具类,就能稳定跑很久。框架是帮你省事的,不是给你增加玄学的。
3. 手动写 HTTP 调用的三种常用姿势
3.1 Java 原生 HttpClient:稳扎稳打的基础功
Java 11 之后,JDK 自带的java.net.http.HttpClient已经完全够用了。它在绝大多数场景下都不逊色于第三方 HTTP 库,而且不需要引入额外依赖。用原生 HttpClient 调大模型的代码并不复杂,核心逻辑三步走:构建请求、发送请求、解析响应。
我们先看一个最简单的非流式调用。下面这段代码用的是 JDK 17 的语法,我把关键逻辑都加了注释:
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; public class LlmHttpClient { private static final String API_URL = "https://your-llm-api.example.com/v1/chat/completions"; private static final String API_KEY = "sk-your-api-key"; private final HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); public String chat(String userMessage) throws Exception { // 1. 构造请求体 JSON String requestBody = """ { "model": "some-model-name", "messages": [ {"role": "system", "content": "你是一个简洁的助手"}, {"role": "user", "content": "%s"} ], "temperature": 0.7, "max_tokens": 1024 } """.formatted(userMessage); // 2. 构建 HTTP 请求 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(API_URL)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + API_KEY) .timeout(Duration.ofSeconds(30)) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); // 3. 发送请求并获取响应 HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); // 4. 解析响应(这里先用字符串返回,后面再看 JSON 解析) if (response.statusCode() == 200) { return response.body(); } else { throw new RuntimeException("调用失败,状态码: " + response.statusCode() + ", 响应: " + response.body()); } } }这段代码有几个细节值得展开说一下。
第一,连接超时(connectTimeout)和请求超时(timeout)是两码事。connectTimeout只管 TCP 连接建立的时间;timeout是整次请求的最长等待时间。大模型推理通常比较慢,尤其在某些高负载时段,一个请求等十几秒很正常,所以请求超时建议设置得宽裕一些,正常 30 秒起步。
第二,鉴权方式。现在主流大模型平台普遍采用Authorization: Bearer <token>这种形式。虽然有少部分平台用的是api-key这种自定义头,但 Bearer Token 是事实标准,你先把这个学会,以后遇到特殊平台再灵活变通。
第三,JSON 序列化。上面代码里我直接用文本拼接构造 JSON,这只是为了演示原理。在实际项目里千万不要手写 JSON,推荐使用 Jackson 或者 Gson。手动拼 JSON 不仅容易出现转义错误,还会造成潜在的注入风险。后面我会给一个更规范的写法。
原生 HttpClient 还有一个好兄弟叫sendAsync,也就是异步版本。它返回一个CompletableFuture,你可以在等待大模型响应期间干别的事情。这在做并发请求、批量处理场景下非常有用。比如你要同时对多个话题做摘要,用同步方式就是一个一个等,用异步方式可以并发发出多个请求,整体耗时能大幅缩短。
3.2 OkHttp:补偿了原生的几个痛点
JDK 原生 HttpClient 虽然够用,但用久了还是会觉得少点什么。最常见的一个痛点是:原生 HttpClient 默认没有连接池的超时淘汰机制,连接管理不如老牌 HTTP 库细致;另外一个痛点是请求响应日志全靠自己手动打。这时候 OkHttp 是一个很成熟的选择。
OkHttp 的优势主要体现在三方面:连接池复用更高效、拦截器机制非常灵活、超时配置更精细。尤其是拦截器,简直是调试大模型接口的神器。你可以写一个日志拦截器,把每个请求的 URL、请求头、请求体、响应状态、响应耗时全部打印出来,排查问题的时候一目了然。
简单看一下 OkHttp 版本的调用方式:
import okhttp3.*; import com.fasterxml.jackson.databind.ObjectMapper; public class LlmOkHttpClient { private final OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .addInterceptor(new HttpLoggingInterceptor()) // 日志拦截器 .build(); private final ObjectMapper mapper = new ObjectMapper(); public String chat(String userMessage) throws Exception { Map<String, Object> body = new HashMap<>(); body.put("model", "some-model-name"); body.put("messages", List.of( Map.of("role", "system", "content", "你是一个简洁的助手"), Map.of("role", "user", "content", userMessage) )); body.put("temperature", 0.7); body.put("max_tokens", 1024); String json = mapper.writeValueAsString(body); Request request = new Request.Builder() .url("https://your-llm-api.example.com/v1/chat/completions") .addHeader("Content-Type", "application/json") .addHeader("Authorization", "Bearer sk-your-api-key") .post(RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"))) .build(); try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { throw new RuntimeException("HTTP " + response.code() + ": " + response.body().string()); } return response.body().string(); } } }细心的你可能已经发现了,OkHttp 版比原生版多了一个writeTimeout。为什么要单独设置写超时?因为当你发送一个很大的消息体(比如一次请求里塞了好几千 Token 的上下文),写入环节也可能耗时。分三个超时维度分别设置,控制粒度自然更精细。
我在真实项目中用 OkHttp 还有一个体会:连接池复用对大模型频繁调用场景帮助很大。每次请求都新建连接,不仅慢,还可能把服务端的连接数打满。OkHttp 默认会复用连接池,长连接模式下整体性能提升非常明显。
3.3 Spring 生态的 RestTemplate 与 WebClient
如果你的项目本身已经是 Spring Boot 技术栈,那大概率不希望再单独维护一套 HTTP 调用逻辑。这时候有两个选择:同步场景用RestTemplate,异步和 Reactive 场景用WebClient。
RestTemplate 的用法最直观。你可以把它封装成一个配置类注入到 Spring 容器里,设置好拦截器和超时时间,然后业务代码里直接注入使用。不过要提醒一点:RestTemplate 在较新版本的 Spring 中官方已经标记为维护模式,新项目更推荐用RestClient或WebClient。RestClient 是 Spring 6.1 引入的新同步客户端,风格上更现代。但网上存量资料最多的还是 RestTemplate,初学者搜索时容易看到过时信息,注意甄别。
如果你要做的功能是那种"用户发起一次对话请求,后端转发给大模型,再把回复返回给前端",而且你的技术栈是 Spring WebFlux,那 WebClient 是天然的选择。因为它可以把大模型接口的响应流式转发给前端,实现类似 ChatGPT 那种一个字一个字蹦出来的效果。这一点上,同步的 RestTemplate 比较难做好。
@Service public class LlmService { private final RestClient restClient; public LlmService(RestClient.Builder builder) { this.restClient = builder .baseUrl("https://your-llm-api.example.com/v1/chat/completions") .defaultHeader("Authorization", "Bearer sk-your-api-key") .build(); } public String chat(String userMessage) { Map<String, Object> requestBody = Map.of( "model", "some-model-name", "messages", List.of( Map.of("role", "user", "content", userMessage) ), "temperature", 0.7 ); return restClient.post() .contentType(MediaType.APPLICATION_JSON) .body(requestBody) .retrieve() .body(String.class); } }RestClient 的写法非常简洁,它把请求构建、发送、响应解析的 API 设计得比较统一。从我的使用感受看,RestClient 相比 RestTemplate 最大的改进是:异常处理更清晰,Web 相关的语义更明确。现在新起的 Spring Boot 3.x 项目,如果不需要 WebFlux,我会优先用 RestClient。
4. 手动调用开始痛苦的时候:框架帮你解决了什么
4.1 手动调用的四大痛点
当你用 HTTP 方式调大模型超过一两个星期,一定会在某个时刻产生一种感觉:怎么每调一个新的接口都要重写一遍请求逻辑?这其实就是该上框架的信号了。
我总结下手动调用在真实项目里暴露出来的问题。
第一个痛点是协议细节被反复处理。Token 计数、消息格式转换、JSON 解析、错误码映射,每个大模型平台都有细微差别,每个接口你都要重新处理这些琐碎的边界情况。你本应该把精力花在业务逻辑上,结果被这些协议细节消耗了。
第二个痛点是流式响应处理麻烦。大模型最实用的一种调用方式是流式输出,即一边生成一边返回。如果你用原生 HttpClient 做流式读取,你需要处理 SSE(Server-Sent Events)协议,手动解析data:开头的行,处理[DONE]结束标志,还要管理好连接的生命周期。代码写起来又长又容易出错。
第三个痛点是并发与重试机制缺失。大模型服务端经常因为限流、瞬时过载返回 429 或者 5xx。生产环境必须有重试机制和退避策略。但手动调用时,这些逻辑要自己实现,很容易被忽略,或者写得太简单导致雪崩。
第四个痛点是上下文管理靠手动拼字符串。对话类应用必须维护历史消息数组,上一次的回复要拼接进下一次的请求里。当消息长度超过模型上下文窗口时,还要做截断或摘要。手动维护这件事,写出来的代码既繁琐又脆弱。
4.2 Java 生态里的两类大模型框架
Java 生态里做 AI 应用开发,现在比较成熟的有两条路线。
一条是基于现有大模型平台 API 的轻量级封装,代表性的有 LangChain4j。它把大模型调用、Prompt 模板、对话历史管理、文档知识检索(RAG)这些能力做成了统一的 Java API。你只要定义一个ChatLanguageModel接口,然后在配置里指定某个具体平台的实现,业务代码就不需要关心底层 HTTP 细节了。LangChain4j 的设计非常接近 Java 开发者的习惯,大量使用接口、Builder 模式和泛型,没有引入那种生硬的概念。
另一条是 Spring 官方在做的 Spring AI。Spring AI 的目标是把 AI 能力作为 Spring 生态的一等公民来支持,你可以像使用JdbcTemplate一样使用 AI 模板类。它有一个明显的特色:跟 Spring Boot 的配置体系深度绑定,比如在application.yml里配置 API Key、模型名这些参数,框架自动装配好相关的 Bean。对于资深的 Spring 开发者来说,上手成本几乎为零。
这里我不去争论哪个框架更好。我的观点是:如果你已经深度使用 Spring Boot,选 Spring AI 更契合;如果你需要一个不绑定 Web 框架的轻量解决方案,或者需要非常灵活的 prompt 模板和 RAG 能力,LangChain4j 会更顺手。
注意:框架不是银弹。无论用哪个框架,上面第一节讲的 HTTP 基础你依然需要掌握。因为框架的配置项、异常信息、调试日志里,处处都是 HTTP 层的概念。不懂底层,框架出了问题你根本无从下手。
5. 实操:手把手搭建一个可用的对话服务
5.1 环境准备与依赖引入
开始动手之前,先把环境准备好。我这里假设你已经有 Java 17 以上的 JDK 和一个 Maven 项目,然后准备一个大模型平台的 API Key。如果你手头没有,也可以先用官方提供的测试接口跑通流程。
无论选哪条路,都需要先把基础依赖引入 Maven 工程。用原生 HttpClient 的方式不需要额外依赖,只需要 Jackson 做 JSON 解析;用 LangChain4j 的方式需要引入对应的模块依赖。
以 LangChain4j 为例,如果你用的某个国内大模型平台兼容 OpenAI 协议,那么引入依赖如下:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-core</artifactId> <version>1.0.0-beta1</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>1.0.0-beta1</version> </dependency>如果是 Spring AI,引入方式更简单,直接在 Spring Boot 项目里加官方 starter 即可。需要注意的是,框架版本和大模型服务的协议版本之间偶尔会存在兼容性问题,引入依赖后先跑一次最简单的调用,确认链路是通的,再开始写业务代码。
5.2 底层 HTTP 直连的完整实现
下面我给出一个完整的、可以直接复用的原生 HTTP 调用实现。这个实现比前面的例子要完善得多,包含了 JSON 序列化、超时控制、非 200 状态的错误处理和简单的异常分类。
package com.example.llm; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.concurrent.CompletableFuture; /** * 基于 JDK HttpClient 的大模型调用封装, * 适合快速验证、内部工具使用。 */ public class LlmClient { private static final String AUTH_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; private final String apiUrl; private final String apiKey; private final ObjectMapper objectMapper; private final HttpClient httpClient; public LlmClient(String apiUrl, String apiKey) { this.apiUrl = apiUrl; this.apiKey = apiKey; this.objectMapper = new ObjectMapper(); this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); } /** * 构造对话请求体 */ private String buildChatRequest(String userMessage, String systemPrompt) throws Exception { ObjectNode root = objectMapper.createObjectNode(); root.put("model", "some-model-name"); ArrayNode messages = root.putArray("messages"); if (systemPrompt != null && !systemPrompt.isBlank()) { ObjectNode sysMsg = messages.addObject(); sysMsg.put("role", "system"); sysMsg.put("content", systemPrompt); } ObjectNode userMsg = messages.addObject(); userMsg.put("role", "user"); userMsg.put("content", userMessage); root.put("temperature", 0.7); root.put("max_tokens", 1024); return objectMapper.writeValueAsString(root); } /** * 同步调用 */ public String chatSync(String userMessage, String systemPrompt) throws Exception { HttpRequest request = buildRequest(buildChatRequest(userMessage, systemPrompt)); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); return extractContent(response); } /** * 异步调用 */ public CompletableFuture<String> chatAsync(String userMessage, String systemPrompt) { try { HttpRequest request = buildRequest(buildChatRequest(userMessage, systemPrompt)); return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(this::extractContent); } catch (Exception e) { return CompletableFuture.failedFuture(e); } } private HttpRequest buildRequest(String requestBody) { return HttpRequest.newBuilder() .uri(URI.create(apiUrl)) .header("Content-Type", "application/json") .header(AUTH_HEADER, BEARER_PREFIX + apiKey) .timeout(Duration.ofSeconds(60)) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); } /** * 从响应体中提取 assistant 返回的内容 */ private String extractContent(HttpResponse<String> response) { if (response.statusCode() != 200) { throw new LlmApiException("HTTP " + response.statusCode() + ": " + response.body()); } try { JsonNode root = objectMapper.readTree(response.body()); JsonNode contentNode = root.path("choices").path(0).path("message").path("content"); if (contentNode.isMissingNode()) { throw new LlmApiException("响应中未找到 content 字段: " + response.body()); } return contentNode.asText(); } catch (JsonProcessingException e) { throw new LlmApiException("响应解析失败: " + response.body(), e); } } }这个工具类放出来之后,我自己在真实项目里也染了几个项目。几点具体的取舍:
- 使用 Jackson 的
ObjectNode和ArrayNode来构建 JSON,避免手写字符串拼接的各种隐患。 - 提取内容的逻辑统一封装在
extractContent方法里,上层业务不需要关心 JSON 结构。以后如果换了平台,只需要改这一个方法。 - 同步和异步方法都提供了。如果一次要调多个模型,建议用异步版本加
CompletableFuture.allOf做并发聚合。
5.3 用框架封装的对比实现
同样的功能,如果用 LangChain4j 来实现,代码量会显著减少。核心代码如下:
package com.example.llm; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.message.chat.ChatMessage; import dev.langchain4j.message.chat.UserChatMessage; import dev.langchain4j.message.chat.SystemChatMessage; import java.time.Duration; import java.util.List; public class LangChain4jExample { public void chatExample() { // 创建模型实例 ChatLanguageModel model = OpenAiChatModel.builder() .apiKey("sk-your-api-key") .baseUrl("https://your-llm-api.example.com/v1") .modelName("some-model-name") .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build(); // 构造对话消息 List<ChatMessage> messages = List.of( new SystemChatMessage("你是一个 Java 开发助手,回答要简洁专业。"), new UserChatMessage("解释一下虚引用(PhantomReference)在内存管理中的作用") ); // 调用模型 String response = model.chat(messages); System.out.println(response); } }注意,这里我用的 LangChain4j 类名是简化举例,不同版本 API 命名可能会有差异,实际使用以你引入版本的官方文档为准。重点是感受框架思路:你不再需要关心 JSON 构建、HTTP 状态码判断、content 字段提取这些细节了,只需要关心业务本身——用什么样的消息去调用模型,得到什么结果。
如果你用 Spring AI,那更贴近 Spring 开发者的习惯。典型写法是这样的:
@Service public class AiAssistantService { private final ChatClient chatClient; public AiAssistantService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String ask(String question) { return chatClient.prompt() .systemMessage("你是一个专业的 Java 技术顾问,回答问题时请给出代码示例。") .userMessage(question) .call() .content(); } }这段代码可读性非常高。尤其是ChatClient,名字起得非常好,你可以把它理解成 Spring 对 JDBC 的封装——底层具体怎么连数据库你不用管,你只用JdbcTemplate执行业务 SQL 就行。Spring AI 的ChatClient就是大模型版的JdbcTemplate。
5.4 流式响应:从尾到头打通关键环节
流式响应是大模型应用和普通 HTTP 接口最大的区别。普通接口你等全部结果返回,然后整块展示;大模型调用流式响应让结果像打字机一样逐字出现,用户体验完全不同。
底层流式原理是这样的:大模型服务端返回的响应头里带有Content-Type: text/event-stream,响应体按 SSE 格式分块返回。每个数据块以data:开头,内容是一段 JSON,最后有一个[DONE]标志结束。
用 OkHttp 实现流式读取的代码结构是回调式的。你需要在 onResponse 回调里,从 response body 打开一个字符流,逐行读取。这里有个很关键的经验:如果你按字节流读取,再自己拼接字符串,很容易出现中文乱码。正确做法是包一个 BufferedReader,每次都按行读取。
用 LangChain4j 做流式更省心。你只需要注册一个流式处理监听器,框架帮你把 SSE 解析和行分割的工作做了:
model.chat("Java 有哪些最佳实践?", new StreamingResponseHandler<AiMessage>() { @Override public void onNext(String token) { // 每生成一部分内容,回调这个方法 System.out.print(token); } @Override public void onComplete(Response<AiMessage> response) { // 全部生成完成 System.out.println("\n--- 完成 ---"); } @Override public void onError(Throwable error) { // 错误处理 error.printStackTrace(); } });流式响应写起来不难,但有一个非常容易踩的坑:连接管理的生命周期。流式响应可能会持续几十秒,如果中间网络抖动,连接断了,而你的代码里没有对onError做处理,用户端就会一直转圈没有任何提示。我的习惯是:在onError里把"连接中断"这种事件及时反馈给前端,而不是默默吞掉异常。
6. 生产环境绕不开的痛点:常见问题速查
6.1 连接超时和读取超时怎么设置
大模型接口是出了名的"慢接口"。第一轮交互因为要加载模型,可能就要等 10 秒以上。我在最开始的项目里把所有超时都设成了 5 秒,结果线上频繁报超时,一开始还以为是网络问题,折腾了两天才发现是超时设置太激进。
我的建议是分场景设置:
| 场景 | 连接超时 | 读取超时 |
|---|---|---|
| 聊天/对话类 | 5~10 秒 | 60 秒以上 |
| 流式输出 | 5~10 秒 | 不设或设很长 |
| 简单分类/改写 | 5 秒 | 30 秒 |
| 批量离线任务 | 10 秒 | 120 秒以上 |
读写超时的本质是在等待服务端推理。尤其流式输出,模型生成一句话可能需要 20-30 秒,如果你设置 30 秒读取超时,可能正好卡在最后几个字上就断了。稳妥的做法是:流式场景不设置 readTimeout,而是通过其他手段做心跳检测。
6.2 响应内容被截断怎么办
有一次我调大模型做长文本摘要,返回的内容总是在大约 500 字左右戛然而止。排查后发现问题出在max_tokens参数——我把这个参数理解成了"最大输入长度",实际上它是生成部分的 Token 上限。
Token 这个单位也需要解释一下。Token 不等同于汉字,1 个汉字大约相当于 1.5 到 2 个 Token,英文一个单词通常约 1 到 2 个 Token。如果你设置max_tokens=512,实际能生成的中文文本大约只有 300 字左右。做长文本任务时,要么把这个参数调大,要么分批生成再拼接。
另外还要注意:不同的模型有各自的上下文窗口限制,比如某些模型是 32K Token,你要把输入的 Prompt 和生成的内容加在一起考虑,超了就会报错。错误信息通常类似"context length exceeded"。
6.3 上下文无限增长导致请求越来越慢
对话应用中常见的一个问题是:随着对话轮数增加,消息数组越来越长,每一次请求都携带全部历史消息,导致请求体越来越大、处理越来越慢,甚至直接超过上下文窗口。
解决办法通常是滑动窗口或者摘要压缩。滑动窗口是指只保留最近 N 轮消息,更早的消息丢弃。摘要压缩是把很早之前的整段对话大模型做一个摘要,以后请求只带摘要再加最近几轮。
这里有个小技巧:判断一条消息是否需要保留,可以按 Token 数量而不是轮数。比如设定"历史消息总 Token 超过 2000 就裁剪最老的",这样一个固定阈值规则实现简单、效果也不差。
6.4 限流报错(429)和重试策略
大模型平台普遍有限流策略。当你的应用请求频率过高,服务端会返回 429 状态码。我在一次活动页中踩过这个坑:用户一多,并发请求上来,瞬间把配额打满,所有用户都报错。
处理限流的常规做法是退避重试。所谓退避是指每次重试之前等待时间逐渐变长,比如第一次等 1 秒、第二次等 2 秒、第三次等 4 秒,最多重试 3 次。这个策略能避免在服务端还没恢复时持续压榨。Spring 的@Retryable注解配合@Recover就能实现,也有现成的库如 Resilience4j 可以用。
还有一个值得留意的点:重试只适合幂等请求。大模型调用整体上属于"幂等"的,因为同样的 Prompt 生成的结果虽然可能有随机性,但不会产生副作用。然而如果用户是在做支付、下单等环节的文案生成,你重试时要小心重复扣费(虽然概率很低,但第三方平台按 Token 计费时,重试即重复计费)。这种场景我会建议人工介入而不是无限自动重试。
6.5 JSON 响应解析失败的通用解法
大模型返回的格式偶尔会不符合规范。尤其是你用 JSON Mode 让模型输出结构化数据时,模型可能夹杂一些 Markdown 标记(比如把 JSON 包在代码块里),或者因为 Token 截断导致 JSON 不完整。
解决方案列表:
- 让模型输出的消息里明确说"只输出 JSON,不要包含任何其他文本或 Markdown 标记"。
- 在代码里对响应做一层清洗,比如剔除首尾的
json 和标记。 - 用 Jackson 解析失败时的 lenient 配置容忍部分错误。
- 如果截断了,可以捕获异常后重新以更小生成长度请求一次。
- 对关键 JSON 字段,解析失败时给出默认值,而不是让整个流程崩掉。
7. 最后说几句实在的
这篇文章从最底层的 HTTP 请求讲起,一直聊到了 LangChain4j 和 Spring AI 这类开发框架。核心想表达的其实是一句话:大模型调用并不神秘,它就是一个 HTTP 接口;框架的价值在于帮你处理重复琐碎的事情,而不是替你解决逻辑问题。
我自己的成长路径正好是反过来的,一上来先用了框架,等遇到框架解决不了的问题时,才回头补 HTTP 层的东西。当时有个项目要对接一个比较小众的平台,框架根本没有现成的实现类,我被迫自己看它的 API 文档,用 OkHttp 写了适配。写完那一刻才突然理解了框架内部是怎么工作的。从那以后,我养成了一个习惯:任何新技术,先看它最原始的形态,再思考如何抽象和封装。
后面如果你想继续深入,我建议沿着三条线走。第一条线是流式输出,你要自己用 OkHttp 实现一个 SSE 客户端,彻底搞懂它的事件格式。第二条线是函数调用(Function Calling),学会让模型在输出中请求调用你的业务工具。第三条线是 RAG,做一个简单的向量检索问答系统,会让你对"模型生成 + 知识检索"的完整链路有更扎实的体会。
最后分享一个小技巧:在自己电脑上调试大模型接口时,强烈建议用 curl 先手测一遍,确认网络和参数没问题,再去写 Java 代码。不然你很可能花半天时间排错,最后发现是某个参数名拼写错了。先把底层链路摸透,再让工具帮你飞起来。