Java后端调用大模型:从HTTP手写到Spring封装与工程化实践 最近在项目里接了一个需求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解析库我用的是Jacksondependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.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; charsetutf-8) .header(Authorization, Bearer System.getenv(LLM_API_KEY)) .timeout(Duration.ofSeconds(60)) .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); HttpResponseString 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字段。对于401Key无效、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场景非常合适。代价是编程模型偏函数式上手需要适应一下调试时也不如同步代码直观。我整理了一个简单的对比方便你判断维度RestTemplateWebClient模型同步阻塞异步非阻塞高并发下线程占用高容易打满线程池低适合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; charsetutf-8) .build(); } }然后写ServiceService public class LlmChatService { private final WebClient webClient; public LlmChatService(Qualifier(llmWebClient) WebClient webClient) { this.webClient webClient; } public MonoString chat(String userMessage) { MapString, 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等特殊场景处理大模型应用最常见的体验是打字机效果也就是一个字一个字往外蹦。大多数模型服务都支持SSEServer-Sent Events流式返回请求体里加一个stream: true响应就会变成一段段data: {...}事件。WebClient处理SSE非常顺手public FluxString chatStream(String userMessage) { MapString, 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(ListChatMessage 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客户端、升级到开发框架、换一家模型供应商都只是替换一层实现的问题业务代码不用跟着动。我在这一点上吃过不少亏所以特别想强调出来。