最近在项目里接了一个需求:Java后端要调用大模型,让用户用自然语言直接查报表数据。说实话,一开始我有点不以为意,因为大模型对外提供服务的方式基本都是一个HTTP接口,看起来跟调用普通API没什么两样。但真正动手把这条路走通之后,我才发现从最原始的手写HTTP请求,到用Spring的RestTemplate/WebClient,再到专门为大模型场景设计的Java开发框架,这个链路里的工程化差距远比想象中大。这篇文章就把这条路上的核心内容、我踩过的坑、最终沉淀下来的做法一次讲完。适合正打算在Java后端接入大模型的开发者,不管你是第一次写调用代码,还是已经用某个HTTP客户端调通但觉得不够优雅,都能在这里找到参考。
1. 从“调接口”到“调大脑”:Java后端为什么绕不开大模型
1.1 一个看起来像普通API、实际上很不一样的“接口”
很多业务系统是用Java写的。用户想要AI能力,产品经理最顺口的说法就是“让机器人回答一下问题”。但对后端开发来说,这句话落地之后第一步就是:Java代码怎么把用户的请求发给大模型,再把模型生成的答案拿回来。
以我这次接的需求为例,一个基于Spring Boot的报表系统,需要在用户点“智能分析”时,把一段自然语言问题转换成SQL查询条件,并且用文字总结结果。最直接的实现方式,就是把问题和上下文拼成一个Prompt,调用大模型接口拿到回复。听起来很简单,但真做起来会发现,大模型的HTTP接口和普通业务API有几个本质差异。
普通接口响应大多在几十到几百毫秒,大模型一次推理动辄数秒,长文本场景甚至几十秒。拿一个默认5秒超时的HTTP客户端去调,十次有八次直接超时。普通接口的参数通常有严格字段定义,大模型则要拼历史消息、调温度参数、控制最大token数,还要考虑上下文窗口。普通接口返回结构相对固定,大模型返回的是自然语言,可能截断、可能格式不规范,甚至可能“一本正经地胡说八道”。
把这些差异都纳入设计之后,问题就变成了:调通一次很容易,但要用得稳、用得省钱、用得好维护,需要分层去做。
1.2 为什么前端不能直接调大模型,Java后端必须自己接
有人可能会问:既然模型厂商都提供JS SDK,为什么不直接在浏览器里调?这里有一个非常现实的安全问题。
调用大模型接口通常需要API Key,这个Key代表了费用和访问权限,放在前端等于公开暴露,别人拿走了就可以刷你的额度。我在一个项目里见过有人把Key直接写在前端打包产物里,上线第二天账单就异常了,损失只能算作交学费。除了安全,还有业务逻辑问题:用户提问之前要做鉴权、要查询权限、要做敏感信息过滤,这些都不能暴露在浏览器端。
所以大多数正经系统的架构,都是后端调用大模型,前端只跟后端通信。这也是Java开发者必须正面处理这件事的原因:整个企业级技术栈是Java写的,AI能力只是系统里的一个外部服务依赖,后端天然是这个依赖的唯一入口。
1.3 一次性理清:这个领域有几种调用路线
我在调研阶段把Java调用大模型的路线分成了三层。
第一层是直接用JDK或第三方库发HTTP请求,自己拼JSON、自己解析响应。优点是零依赖、容易理解,适合快速验证和极简场景。缺点是一切都要自己处理,从超时、重试到流式解析,工作量不小。
第二层是用Spring生态里成熟的东西,比如RestTemplate或WebClient,把请求封装成服务类,结合配置、超时、异常处理统一管理。这基本满足大部分业务集成的需要,也是我认为中小团队最合适的默认方案。
第三层是引入更上层的开发框架,这类框架专门为Java大模型应用设计,抽象了对话历史、工具调用、向量检索、模型切换等能力。适合做较复杂的Agent应用,或者你判断团队会长期投入AI业务。
下面我就按照这个路线,把每一层的关键细节和实践经验展开讲。
2. 第一层方案:手写HTTP请求调用大模型,一个可跑的最小样例
2.1 为什么先用手写HTTP,而不是直接上框架
我在很多地方看到新手一上来就引入某个Java大模型框架,结果接口文档还没读明白,先被框架的抽象概念绕晕。我个人的建议是,第一轮老老实实手写一个HTTP调用。目的不是让你在生产里一直这么写,而是让你理解大模型接口本身的协议。
大模型厂商的接口大体上有一个共同点:POST一个JSON到聊天补全路径,Header带授权信息,Body里写模型名、消息列表、参数,响应里返回生成内容和token用量。如果你把这个流程亲手走一遍,后面不管换什么框架,都能看懂它底层在做什么。
另一个理由是非Spring项目也能用。不是所有Java项目都用Spring Boot,有些老项目是普通Maven工程,往里面塞一套Spring依赖动静太大。手写HTTP请求只需要JDK和一个JSON库,够轻。
2.2 一个可运行的最小样例:JDK自带HttpClient就够了
JDK 11开始自带的java.net.http.HttpClient功能已经足够对付大模型接口,不需要引入Apache HttpClient或者OkHttp。我用它写了一个极简调用,整个代码不超过四十行。
先准备JSON解析库,我用的是Jackson:
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.1</version> </dependency>然后代码如下:
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; 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 MinimalLlmClient { private static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); String payload = """ { "model": "chat-model-x", "messages": [ {"role": "user", "content": "用一句话解释什么是数据库索引"} ], "temperature": 0.7, "max_tokens": 200 } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://llm.example.internal/v1/chat/completions")) .header("Content-Type", "application/json; charset=utf-8") .header("Authorization", "Bearer " + System.getenv("LLM_API_KEY")) .timeout(Duration.ofSeconds(60)) .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode root = MAPPER.readTree(response.body()); String content = root.path("choices").path(0).path("message").path("content").asText(); System.out.println(content); } }这里有三个关键点。
连接超时和整体超时要分开设置。connectTimeout是建立TCP连接的时间,一般10秒足够;timeout是整次请求的最大耗时,大模型接口我建议给60秒以上。很多人只设了连接超时,结果请求发出去了迟迟等不到响应,线程一直被挂着。
请求体里的model字段不是随便填的,你需要换成你实际开通的模型名,否则会报模型不存在。我在这类问题上吃过亏,所以建议把模型名放到配置里,而不是硬编码。
Authorization的格式是Bearer加一个空格加Key,不要少了空格,也不要多了一堆引号。这是个很蠢但特别容易犯的错。
2.3 响应解析与错误码这关,迟早要过
上面代码能跑通之后,紧接着就要面对两件事:解析正常响应、处理异常响应。
正常响应长这样:
{ "id": "chatcmpl-xxxx", "object": "chat.completion", "model": "chat-model-x", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "数据库索引本质上是一种数据结构..." }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 25, "completion_tokens": 120, "total_tokens": 145 } }解析时我建议使用JsonNode.path()而不是get(),因为path()在字段不存在时返回一个空节点,不会抛NullPointerException。像root.path("choices").path(0).path("message").path("content")这种链式写法,即使中间某层缺失,最后也会得到空字符串,程序不至于直接崩。
真正容易翻车的是错误响应。一般模型网关在出错时会返回这样的结构:
{ "error": { "message": "Rate limit reached", "type": "rate_limit_error", "code": 429 } }我之前习惯先看HTTP状态码,但实际操作中发现有些网关即使业务失败了,HTTP状态码仍然是200,错误被塞到JSON的error字段里。所以正确姿势是:先判断HTTP状态码,不在2xx范围就走异常逻辑;然后再看返回体里有没有error字段。
对于401(Key无效)、403(权限不足)、429(限流)、5xx(模型服务端异常),处理策略完全不同。429和5xx可以考虑重试,但401和403重试没有意义,反而可能让账号被临时风控。我的做法是写一个简单的响应分类方法,把这些状态码映射成业务异常类型,后续再在上面做重试策略。
3. 第二层方案:在Spring生态里把模型调用做成服务
3.1 RestTemplate还是WebClient:先把这个选择题做对
手写HTTP请求可以验证思路,但进到Spring项目里,基本不会有人在每个业务Service里裸写HttpClient。更常规的做法是用Spring生态已有的HTTP客户端,这时候第一个选择题就摆出来了:RestTemplate还是WebClient。
如果只是简单同步调用,RestTemplate确实够用,代码写起来也直接。但它有一个问题:每个同步请求会占用一个线程,直到响应返回才释放。大模型单次响应往往要好几秒,假设你的应用只有200个线程,那就意味着同时只能处理大约三四十个AI请求,其他业务请求全被堵住。流量一大,线程池很快被打满。
WebClient基于Reactor,底层是Netty,属于异步非阻塞模型。它在等待模型响应的时候不会长期占着一个线程,这对大模型这种长耗时IO场景非常合适。代价是编程模型偏函数式,上手需要适应一下,调试时也不如同步代码直观。
我整理了一个简单的对比,方便你判断:
| 维度 | RestTemplate | WebClient |
|---|---|---|
| 模型 | 同步阻塞 | 异步非阻塞 |
| 高并发下线程占用 | 高,容易打满线程池 | 低,适合IO密集型场景 |
| 接入Spring Boot | 非常容易 | 需要配置Reactor相关依赖 |
| 流式响应支持 | 支持但实现较繁琐 | 原生支持,处理SSE很顺手 |
| 学习成本 | 低 | 中等 |
我的建议是:新项目、AI接口为主的项目,直接上WebClient;老项目里只是零星一两个调用点,RestTemplate也可以接受,但要把连接池和线程池参数调好。
3.2 封装一个可复用的模型服务类
在Spring Boot项目里,我习惯把大模型调用收敛到单独的Service类,而不是分散在各个业务代码里。这样后续改模型、加日志、做监控都有统一入口。
先定义一个配置类,把WebClient实例建好:
@Configuration public class LlmWebClientConfig { @Value("${llm.gateway-url}") private String gatewayUrl; @Value("${llm.api-key}") private String apiKey; @Bean public WebClient llmWebClient(WebClient.Builder builder) { return builder .baseUrl(gatewayUrl) .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Content-Type", "application/json; charset=utf-8") .build(); } }然后写Service:
@Service public class LlmChatService { private final WebClient webClient; public LlmChatService(@Qualifier("llmWebClient") WebClient webClient) { this.webClient = webClient; } public Mono<String> chat(String userMessage) { Map<String, Object> requestBody = Map.of( "model", "chat-model-x", "messages", List.of( Map.of("role", "user", "content", userMessage) ) ); return webClient.post() .uri("/v1/chat/completions") .bodyValue(requestBody) .retrieve() .bodyToMono(JsonNode.class) .map(node -> node.path("choices") .path(0) .path("message") .path("content") .asText()); } }这里把请求体重构成了Map,让Jackson自动序列化成JSON,比手工拼字符串安全得多。在业务层调用时,如果使用Spring Web MVC,可以直接用block()把Mono变成同步结果,或者配合WebFlux保持异步。
有一点要特别注意:WebClient.Builder在Spring Boot里已经被预配置过,自动装配时往往会带一些默认设置。如果直接使用注入的WebClient.Builder,在某些版本里会丢失Jackson编码器等默认能力,所以上面的代码用了定制的Builder配置方式,避免踩这个隐性坑。
3.3 流式输出(SSE)等特殊场景处理
大模型应用最常见的体验是打字机效果,也就是一个字一个字往外蹦。大多数模型服务都支持SSE(Server-Sent Events)流式返回,请求体里加一个"stream": true,响应就会变成一段段data: {...}事件。
WebClient处理SSE非常顺手:
public Flux<String> chatStream(String userMessage) { Map<String, Object> requestBody = Map.of( "model", "chat-model-x", "messages", List.of(Map.of("role", "user", "content", userMessage)), "stream", true ); return webClient.post() .uri("/v1/chat/completions") .bodyValue(requestBody) .accept(MediaType.TEXT_EVENT_STREAM) .retrieve() .bodyToFlux(String.class) .filter(line -> line.startsWith("data:")) .map(line -> line.substring("data:".length()).trim()) .filter(line -> !line.equals("[DONE]")) .map(this::extractContentFromEvent); }代码里过滤掉[DONE]结束标记,再解析每一个事件里的增量内容。这里有个坑:有些网关返回的SSE事件里,内容字段不是标准JSON,而是包含转义字符的字符串,直接用ObjectMapper.readTree会解析失败。稳妥做法是先把字符串做一些容错处理,或者干脆按content字段的原始字符串截取。
流式请求对超时控制要求更高。这里指的不是整个请求的超时,而是“两个数据事件之间的最大间隔时间”。如果模型在思考,可能好几秒不发数据,但连接并没有断开。WebClient可以通过timeout(operator)对流上的每个元素设置超时,也可以用readTimeout配置调整Netty的读超时。我在实际项目里把空档超时设为30秒,比总超时短,这样既能容忍思考间隙,又能及时发现连接假死。
4. 第三层方案:按需引入大模型开发框架,别被“人人都在用”忽悠
4.1 大模型框架解决了哪些“最后一公里”问题
手写HTTP、用WebClient封装,这两个层次已经能覆盖很多业务场景。但如果你做一个多轮对话的智能客服,或者一个能调用多个工具的Agent,问题就会越来越复杂:对话历史谁来维护、上下文超长怎么裁剪、怎么让模型调用你写的Java函数、怎么方便地切换模型供应商。
这些需求催生了Java生态里专门的大模型开发框架。这类框架通常提供几个核心抽象:对话客户端、消息内存、助手接口、工具调用机制、向量检索集成。整套设计思路很多借鉴了Python生态里的概念,但针对Java的场景做了大量适配。
我把这类框架统称为“模型编排框架”,不点名具体是哪家,因为选型变化太快。核心思想是一致的:把模型调用从“发请求”提升到“配Agent”的层次,让开发者用声明式配置就能组合出复杂的AI行为。
4.2 典型能力:对话记忆、工具调用、向量检索
我第一直观感受是,这类框架最省事的地方是对话记忆。
手写多轮对话时,你得自己把历史消息拼进请求体,还要自己盯着token别超限。框架里的Memory组件会自动保存每条消息,发送前自动组装历史,超了还会用预设策略压缩或丢弃最旧消息。比如一个简单配置就能让Agent记住整个会话:
LlmAssistant assistant = LlmAssistant.builder() .model("chat-model-x") .memory(new WindowMemory(10)) .build();WindowMemory(10)表示保留最近10轮消息,这是很典型的配置。
工具调用(Function Calling)是另一个让我觉得框架值得用的点。不引入框架的时候,你需要在请求体里声明工具JSON Schema,解析模型返回的tool_calls,手工调用对应的Java方法,再把结果塞回模型。这套流程涉及大量的映射和状态管理,写起来很啰嗦。框架里通常只要注册一个方法,框架会处理声明、路由和结果回填。
向量检索场景也一样。做知识库问答时,用户问题需要先Embedding向量化,再去向量库召回,最后拼Prompt。模型编排框架往往直接提供Retriever抽象,把“召回+拼接”变成一个组件,不用自己写太多管道代码。
4.3 到底该手写、用HTTP框架、还是用大模型框架
我在这个选择题上的态度比较务实。不要因为社区里大家都在讨论某个框架就无脑引入,Java项目引入一个框架的成本比想象中高,尤其是它还可能带来一组新的依赖和配置约定。
我给自己定了一个简单决策逻辑:
| 场景 | 推荐方案 |
|---|---|
| 只想快速验证模型效果,跑通一个样例 | 手写HttpClient |
| 业务系统里少数几个地方需要AI能力 | Spring WebClient封装成一个Service |
| 多轮对话、Agent、工具调用、复杂提示词管理 | 考虑引入模型编排框架 |
| 前端打字机效果、并发量较高 | 优先WebClient,再决定是否用框架 |
另外还有一个很现实的因素:团队的维护能力。引入框架后,团队成员是否理解它的概念?出了问题能不能查源码?如果没人能接手,那再高级的抽象也只会变成事故现场。我经手的一个项目就是因为引入了某个开发框架,结果升级到新版本时API变了,团队里没人看得懂,最后只能回退到WebClient方案,白白浪费三天时间。
5. 工程化避坑清单:超时、限流、Key管理与压测实录
5.1 四个高频问题:超时、重试、限流、并发
先说超时。大模型接口的耗时波动很大,简单问题可能两秒,带上下文的长文本可能三十秒。我给团队定的规范是:连接超时10秒、请求总超时60秒、流式空档超时30秒。如果是非流式调用,可以根据模型能力适当调成120秒,但要注意网关侧可能也有超时限制,不是客户端设了就行。
重试要特别谨慎。我见过同事写了一个retryTemplate,遇到超时就重试三次,结果模型服务其实已经处理了请求,只是响应超时没回来,重试之后用户被重复扣费。大模型接口不像普通幂等POST,每次请求都会消耗token。重试策略应该只覆盖明确可重试的错误码,比如429限流和5xx服务端错误,同时加上退避机制。对于超时,要先判断是连接超时还是响应超时,连接超时可以重试,响应超时需要人工确认是否有成本影响。
限流这块,模型服务商一般都有自己的Rate Limit,但业务侧也得有限流意识。我在内部用一个简单的Semaphore控制并发:
private final Semaphore concurrencyLimiter = new Semaphore(20); public String callWithLimit(String message) { if (!concurrencyLimiter.tryAcquire()) { throw new TooManyRequestsException("当前AI请求过于繁忙"); } try { return chat(message); } finally { concurrencyLimiter.release(); } }这个信号量限制的是同时进行中的AI请求数量,超过就直接快速失败,而不是排队堆积,否则把线程池打满,所有请求都会卡死。实际值要根据模型延迟和业务容忍度调整,我当时的场景是单机限20,配合网关吞吐测试得到。
并发问题跟前面超时是连在一起的。用RestTemplate同步调大模型,并发一高线程池很容易耗尽。用WebClient会好很多,但也要注意连接池的maxConnections配置,Netty默认值不一定满足高并发场景。如果你用的是OkHttp之外的客户端,一定要看清楚连接池上限。
5.2 Key管理与多模型切换:一个容易被忽视的治理点
API Key的管理看起来简单,实际很考验工程能力。我见过直接把Key写死在代码里、随代码仓库提交、员工离职还能继续用旧Key的情况,这些都是隐患。
我的做法是:Key不落到代码或配置文件,统一放环境变量或配置中心,测试环境跟生产环境使用不同的Key。至少做到换人、换权限时不用动代码,改配置就能生效。
还有一个更上层的思考:不要只依赖某一家模型服务。一方面是为了避免单点故障,另一方面是不同模型在代码类、推理类、中文类场景各有优势。我在Service层定义一个通用接口,然后给不同模型供应商分别写实现,通过一个配置项切换激活哪家:
public interface ChatProvider { String chat(List<ChatMessage> messages); } @Component @ConditionalOnProperty(name = "llm.provider", havingValue = "providerA") public class ProviderAChatProvider implements ChatProvider { // ... } @Component @ConditionalOnProperty(name = "llm.provider", havingValue = "providerB") public class ProviderBChatProvider implements ChatProvider { // ... }这样切模型就只是改一个配置项。代价是各家API细节有差异,抽象时要尽量用统一的消息结构,把差异封装在各自的Provider里。
5.3 压测与监控的实操建议
大模型服务上线前,我建议至少做一轮基础压测。压测不能只盯着每秒请求数,更关键的是延迟分布和错误率。因为大模型请求耗时长,TPS不高不代表服务正常,p95延迟如果随着并发上升急剧恶化,说明连接池或线程池可能已经到瓶颈。
我当时的做法是:用一个脚本模拟用户连续提问,记录每次请求的耗时、状态码、token用量,绘制延迟曲线。观察点有三个:并发从5涨到50时,p95延迟是否保持平稳;429错误是否出现;内存和连接数是否异常增长。
监控方面,每次调用都应记录一组基础指标:模型名称、请求时间、prompt长度、生成token数、耗时、HTTP状态、错误类型。如果你用的是标准可观测性方案,就把这些作为调用链的Span属性打进去;如果项目比较轻,先打日志也行。成本估算也建议做,因为大模型按token计费,生成token数乘以单价就是实际成本,有了这个数据才能决定是否要对用户做调用次数限制。
我踩过的一个坑是没有监控生成token数,结果月底账单出来比预估高了三倍。后来把usage里的completion_tokens全部记录并聚合,才定位到是某个页面在后台偷偷调用了多次长文本生成。
5.4 我个人的最终方案与一个小建议
经过这几个项目的折腾,我现在的默认选择已经比较固定了。
普通的Java业务系统,我用WebClient封装一个LlmService,把模型接口、超时、重试、限流、日志统一收敛到这一层。这是性价比最高的方案,既能满足大多数场景,又能保持代码可读性。只有当业务里出现了真正的Agent逻辑,需要记忆、工具调用、复杂检索时,我才会引入模型编排框架,主要还是看重它对复杂对话状态的管理能力。
最后分享一个小建议:无论你最终选哪一层方案,一定要在一开始就把大模型调用当作一个正式的后端服务去治理,而不是当成一个工具函数随处调用。把这层抽象做好了,后面切换HTTP客户端、升级到开发框架、换一家模型供应商,都只是替换一层实现的问题,业务代码不用跟着动。我在这一点上吃过不少亏,所以特别想强调出来。