最近一两年,大模型API的普及速度远超我的预期。最早我们做智能客服,得自己训练模型、部署推理服务,一晃几年过去,现在一个SpringBoot应用调用大模型语音助手、内容生成、意图识别,基本上就是加依赖、写配置、调接口三件事。但这三件事做起来,工程上的差别其实不小。同样是“让AI说话”,有人直接在业务代码里堆HTTP请求,有人用Spring AI这种抽象层把模型当成普通Bean注入。我两个方案都在真实项目里用过,今天想把SpringBoot快速接入AI的这两条主流路线,完整拆开来聊一聊。
这套内容适合正在做AI应用落地的后端开发者、团队里负责技术选型的人,以及想快速把大模型能力塞进已有SpringBoot项目但不知道从哪入手的同学。我会把两条路的核心思路、关键代码、踩坑点全部过一遍,最后会给出我在实际项目里的选择建议。
1. SpringBoot接AI,为什么绕不开这两条路
先说一个基本认知:大模型和传统中间件不太一样。它既不是数据库,也不是消息队列,而是一个通过网络暴露的推理服务。SpringBoot要接它,本质上就是解决“怎么把一个远程服务变成项目里顺手可用的能力”。
1.1 第一条路:把AI当作一个HTTP服务来调
这是最原始也最直观的方式。大模型厂商开放HTTP接口,我们用自己的HTTP客户端构造请求、发送数据、接收响应。整个过程就像是调用一个外部REST API,唯一不同只是这个API“特别聪明”。
这条路的好处是没有中间层,什么请求格式、响应结构、异常处理、重试机制,全都自己控制,出了任何问题都可以直接查看原始日志,不存在框架把错误吞掉的情况。代价是你要自己处理的细节比较多:签名、鉴权、消息组织、流式解析、对话历史维护,每一样都需要动手。
1.2 第二条路:用Spring AI把模型变成基础设施
Spring AI是近几年出现的官方生态项目,思路很像早年集成数据库的做法——把复杂的东西封装成自动配置和模板类,开发者只需要定义接口、写配置,剩下的交给框架。它提供的ChatClient类似于我们熟悉的RestTemplate或者JdbcTemplate,屏蔽了不同模型厂商之间的协议差异。
这条路的最大优势是省事。对话、提示词、结构化输出、多轮上下文,都有现成的组件,团队上手快,代码风格统一。缺点是抽象带来的黑盒效应,真出了问题,排查链路会拉长,不熟悉底层协议的人容易被框架日志绕晕。
1.3 两条路的选型,其实取决于你的项目阶段
我自己的经验是:如果你的目标是“快速验证”“做一个Demo跑通”“只有极少数接口需要AI”,那条HTTP裸调就是最合适的,项目里多一个依赖都觉得重;但如果你要做的是产品级功能,AI能力会渗透到多个模块,团队里多人协作,那我强烈建议直接上Spring AI。
这里补充一个容易忽略的点。两者的边界并不是固定的。先做HTTP裸调理解原理,后续再迁移到Spring AI,这个顺序我认为是最平滑的。因为Spring AI本身也是基于HTTP实现的,你理解了底层,看框架源码就会非常亲切,遇到问题也知道去哪里找。
2. 方式一:原生HTTP接入,把核心链路彻底打透
我先用RestClient演示,因为Spring Boot 3.2以后它已经是官方推荐的同步HTTP客户端,写法比RestTemplate简单很多,也比OkHttp这种第三方包少一个依赖。
2.1 准备阶段:密钥、依赖与基础配置
新建一个SpringBoot项目,版本选3.2以上,Java版本至少17。只引入spring-boot-starter-web就够了,不需要额外装别的客户端依赖,RestClient是spring-web自带的。
密钥信息放在application.yml里,注意不要让密钥出现在代码里,更不要提交到Git仓库。
ai: api-key: ${AI_API_KEY} base-url: https://api.your-model-provider.com/v1 model: model-identifier这里每个字段都是什么意思,我展开说一句。api-key是你在模型服务商控制台申请的调用凭证,它的本质是一个令牌,每次请求带着它,服务端才知道你是谁、按什么规则计费。base-url是服务地址,不同厂商的路径格式不同,但核心的对话补全接口基本都在/v1/chat/completions这个风格下。model是模型标识,不同模型能力差异很大,比如有的擅长长文本,有的响应速度更快,这个参数需要根据业务场景提前选定。
2.2 搭建HTTP客户端,设置统一请求头
用RestClient初始化的方式也很简单,我们把它定义成Spring容器里的一个Bean,这样业务代码里随处可以注入。
@Configuration public class AiClientConfig { @Bean public RestClient aiRestClient(@Value("${ai.base-url}") String baseUrl, @Value("${ai.api-key}") String apiKey) { return RestClient.builder() .baseUrl(baseUrl) .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Content-Type", "application/json") .requestInterceptor((request, body, execution) -> { long start = System.currentTimeMillis(); try { return execution.execute(request, body); } finally { long cost = System.currentTimeMillis() - start; // 这里可以把耗时输出到日志 System.out.println("AI接口调用耗时: " + cost + "ms"); } }) .build(); } }这个配置类里的请求拦截器是很多人的盲区。别看它只有几行,它在生产环境里特别好用,每次AI调用的耗时都会留痕。我见过不少线上问题,最后就是靠这层耗时日志定位到是模型响应太慢还是网络链路太长。
2.3 构造对话请求,完成第一次智能交互
大模型对话接口的请求体,核心就是一个消息列表。列表里每一条消息都有角色和内容,角色由system、user、assistant扮演,分别用来设定AI人设、用户输入、AI历史回答。
@Service public class ChatService { private final RestClient aiRestClient; public ChatService(RestClient aiRestClient) { this.aiRestClient = aiRestClient; } public String chat(String userMessage) { Map<String, Object> requestBody = new HashMap<>(); requestBody.put("model", model); requestBody.put("messages", List.of( Map.of("role", "system", "content", "你是一位Java开发助手"), Map.of("role", "user", "content", userMessage) )); requestBody.put("temperature", 0.7); Map<String, Object> response = aiRestClient.post() .uri("/chat/completions") .body(requestBody) .retrieve() .body(new ParameterizedTypeReference<Map<String, Object>>() {}); // 下面这一段就是拆响应的过程 List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices"); Map<String, Object> firstChoice = choices.get(0); Map<String, Object> message = (Map<String, Object>) firstChoice.get("message"); return (String) message.get("content"); } }这段代码里我用了ParameterizedTypeReference来接收响应类型,这是泛型反序列化的关键。如果你直接用Map.class,某些框架在类型擦除时可能会出现问题,虽然大多数情况下能跑通,但推荐写清楚。
温度参数temperature的作用经常被误解。它控制的是回答的随机性,取值通常在0到1之间,越接近0回答越确定、越接近1回答越发散。做客服、做数据提取这类任务建议设低一点,做创意文案可以动态调高。
2.4 流式响应SSE,解决“打字机”效果
如果你只是想拿最终结果,上面那种全量返回的方式足够用了。但如果你想实现ChatGPT那种一个字一个字往外蹦的效果,就必须走流式。
核心原理是SSE,也就是服务端推送事件。HTTP响应不再是普通JSON,而是一行一行的事件数据。每收到一行,就解析出一个增量文本片段,拼接到前端。
RestClient同步网关对流式支持相对弱一些,我生产环境里更推荐WebClient,它是Spring WebFlux的客户端,天然支持流式。如果你不想引入WebFlux,那用Java原生HttpClient配合BodyHandlers.ofInputStream()也能逐行读流,只是代码会多一些。
@Service public class StreamingChatService { private final WebClient webClient; public StreamingChatService(WebClient.Builder webClientBuilder, @Value("${ai.base-url}") String baseUrl, @Value("${ai.api-key}") String apiKey) { this.webClient = webClientBuilder .baseUrl(baseUrl) .defaultHeader("Authorization", "Bearer " + apiKey) .build(); } public Flux<String> streamChat(String userMessage) { Map<String, Object> requestBody = new HashMap<>(); requestBody.put("model", model); requestBody.put("messages", List.of( Map.of("role", "user", "content", userMessage) )); requestBody.put("stream", true); return webClient.post() .uri("/chat/completions") .bodyValue(requestBody) .retrieve() .bodyToFlux(String.class) .filter(line -> line.startsWith("data: ")) .map(line -> line.substring(6)) .filter(data -> !"[DONE]".equals(data)) .map(this::parseDelta); } }流式解析里最烦的就是数据格式偶发不一致。有的行是合法的JSON,有的行被切成了半截,有的注释行混在里面,所以解析时要做好防御。我一般在parseDelta方法里吞掉所有解析异常,返回空字符串,保证整个流不中断。
2.5 多轮对话的记忆处理
HTTP方式接入AI,对话记忆是一个必须自己动手解决的问题。大模型接口本身是没有记忆的,你每次调用它,都必须把之前的对话历史重新传递一次。
所以你需要自己维护一个会话列表。最简单的实现就是用ConcurrentHashMap存储sessionId对应的消息数组,请求时把历史记录追加到当前消息之前,达到上下文窗口上限后,做数组截断或摘要压缩。
public String chatWithMemory(String sessionId, String userMessage) { List<Map<String, String>> messages = conversationStore.getOrDefault(sessionId, new ArrayList<>()); messages.add(Map.of("role", "user", "content", userMessage)); Map<String, Object> requestBody = new HashMap<>(); requestBody.put("model", model); requestBody.put("messages", messages); Map<String, Object> response = aiRestClient.post() .uri("/chat/completions") .body(requestBody) .retrieve() .body(new ParameterizedTypeReference<Map<String, Object>>() {}); String assistantReply = extractContent(response); messages.add(Map.of("role", "assistant", "content", assistantReply)); conversationStore.put(sessionId, messages); return assistantReply; }这里的坑是token上限。每个模型都有最大上下文长度,超出会报错。我习惯在每次追加消息后检查列表长度,超过一定数量就把最早的消息移除,或者用一句话总结历史再拼进system消息。
2.6 方式一的避坑清单
HTTP裸调方式我踩过的坑不少,挑几个典型的说:
鉴权头写错是最常见的,有些厂商接受Authorization: Bearer,有些只接受单独的api-key头,两者搞混就是401。建议把凭证逻辑单独抽出来,换厂商时只改一处。
JSON响应结构不一定完全相同。同样是对话接口,有的厂商把内容放在choices[0].message.content,有的放在output.choices[0].text。写解析代码时不能假设全行业统一,一定要打印原始响应,用真实结构去写。
超时参数必须显式设置。模型推理时间通常比普通接口长,默认的连接超时根本不够用。我一般把连接超时设为10秒,读取超时设为60秒,流式场景下还需要按需调整。
3. 方式二:Spring AI接入,让AI变成项目里随手可用的Bean
如果你的需求不止一个对话入口,而是要在好几个服务里都用到AI,或者希望团队里代码风格统一,那Spring AI值得认真用起来。
3.1 引入依赖,版本对齐很关键
Spring AI目前还在快速演进,版本策略和普通Spring组件不太一样。我建议用最新稳定版,并且尽量与你用的Spring Boot大版本对齐。
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model</artifactId> <version>1.0.0</version> </dependency>引入依赖后直接运行,大概率会报一个找不到类路径的错误。原因是不用配置文件的自动发现机制要扫描所有模型厂商的类,必须在配置里显式指定provider。
3.2 配置模型提供商与密钥
Spring AI的配置非常像传统数据库配置,intuitive不多说,直接上代码。
spring: ai: provider: api-key: ${AI_API_KEY} base-url: https://api.your-model-provider.com/v1 options: model: model-identifier temperature: 0.7这个配置结构里有个容易踩坑的地方:provider这个单词只是一个占位。真实项目里,这里要替换成你实际使用的模型提供商名称,比如openai、qwen、ollama、mistral等。每个供应商的自动装配类会根据这个名字去加载,写错了就注入不了任何AI客户端。
3.3 用ChatClient完成基本对话
配置完成之后,最核心的注入对象就是ChatClient。
@Service public class AiAssistant { private final ChatClient chatClient; public AiAssistant(ChatClient.Builder chatClientBuilder) { this.chatClient = chatClientBuilder .defaultSystem("你是一位严谨的Java开发助手") .build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }这段代码和HTTP裸调方式对比,简洁程度是明显的。你不必自己组装消息列表、不必解析choices结构、不必写请求头,ChatClient帮你把这一切都处理好了。
ChatClient还有一个我很喜欢的能力:它可以用类似流式API的写法,很方便地切换到流式返回。
public Flux<String> askStreaming(String question) { return chatClient.prompt() .user(question) .stream() .content(); }.stream().content()返回的是一个Flux,前端通过WebSocket或SSE拿到这个流,就可以做出流式打字机效果。在响应式接口里,这个写法几乎零成本。
3.4 Prompt模板化,把重复的提示词收拢起来
在实际项目里,让调用方直接传用户消息往往不够,我们经常要在前面拼上一大段系统提示词,里面还有动态参数。Spring AI对这类需求支持很到位,它支持类似Mustache的模板语法。
假设我们有这么一段模板:
你是一个善于分析用户情绪的助手。 用户最近的订单金额是{{amount}}元,购买次数是{{count}}次。 请结合这些信息回答:{{question}}在Java里这么用:
@Value("classpath:prompts/assistant.st") private Resource promptTemplate; public String analyzeOrder(String question, double amount, int count) { PromptTemplate template = new PromptTemplate(promptTemplate); Prompt prompt = template.create(Map.of( "amount", amount, "count", count, "question", question )); return chatClient.prompt(prompt).call().content(); }使用模板的最大好处是提示词与代码解耦。产品同学想调整话术,只需要改模板文件,完全不需要动代码、重新发版。我尤其推荐把系统级提示词全部外置为模板资源放在classpath里。
3.5 结构化输出,省去JSON解析的脏活
HTTP裸调方式里,让AI返回JSON并解析是一件非常繁琐的事情。你要在提示词里千叮万嘱“只返回JSON不要返回解释”,然后回到代码里还要面对偶尔多出的前后缀。
Spring AI提供了结构化输出能力,思路是配置一个转换器,让你把AI回答直接映射到POJO里。
先定义返回结构体:
public record CinemaInfo(String name, String address, int phone) { }然后改造调用:
public CinemaInfo extractCinemaInfo(String text) { return chatClient.prompt() .user("从下面文本中提取影院信息:\n" + text) .call() .entity(CinemaInfo.class); }.entity(CinemaInfo.class)的核心机制是框架在幕后做了两件事:一是构造一个附加指令,告诉模型“你应该根据字段名输出JSON”;二是把返回的JSON自动反序列化到目标类。你完全不用手写ObjectMapper转换逻辑。
有一点要提醒:结构化输出对模型能力有一定要求。如果底座模型比较弱,偶尔也会出现字段缺失或JSON格式错误。对于生产环境的强要求场景,我还是会在外面包一层异常捕获,兜底返回null或者使用默认值。
3.6 多轮对话与记忆组件
Spring AI对对话记忆也做了抽象,最简单的方式是使用ChatMemory接口配合MessageWindowChatMemory实现。
@Service public class ConversationService { private final ChatClient chatClient; private final ChatMemory chatMemory; public ConversationService(ChatClient.Builder builder, ChatMemory chatMemory) { this.chatMemory = chatMemory; this.chatClient = builder .defaultChatMemory(chatMemory) .build(); } public String chat(String conversationId, String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }当你调用了.defaultChatMemory(chatMemory)之后,ChatClient会以某种机制为每次对话关联记忆。内部实现基于一个ID与消息窗口的关联,把历史消息追加到prompt里,窗口大小可以配。
spring: ai: chat: memory: window-size: 20窗口越大,上下文越丰富,但token消耗也越大,响应速度越慢。一般对话场景20条是个不错的起点,具体还需要根据模型的最大上下文和业务复杂度调整。
4. 两种方式的常见问题与排查实录
这一部分我想原原本本地把我在实际接入过程中遇到的高频问题列出来,每一个都经历过“查了半天资料才发现原因”的痛,写下来当速查表。
4.1 401鉴权失败,到底哪里不对
HTTP裸调和Spring AI两种方式都可能出现鉴权失败。排查的时候我一般按这个顺序走:
第一,确认API Key本身没有过期、没有前后空格。很多人把Key从环境变量里复制出来时带了换行符,肉眼看不出来但接口就会报错。第二,确认Authorization头格式,Bearer后面必须有一个空格。第三,确认当前使用的模型服务商是否与base-url一致,混用不同厂商的地址和Key必然失败。
Spring AI项目还要额外看一下配置里的provider名称是否与模型服务商匹配。很多框架自动装配链对不上就会注入失败,启动日志里的错误信息一定不要跳过。
4.2 模型名不存在或不可用
这个问题在接入新模型时特别常见。报错信息通常是“model not found”或者“invalid model”。原因多数是配置里写的模型标识和厂商实际支持的标识不完全一致。
模型标识本身区分大小写,而且不同版本会有不同的别名。我建议到模型服务商官方文档里复制模型ID,自己手敲很容易出错。如果用的是本地模型环境,还要确认这个模型是否已经拉取到本地目录、名称是否正确。
4.3 流式数据解析乱码或漏字
流式接入的典型问题就是输出时好时坏。一部分原因是SSE的数据不是一次一行的,在多字节字符的情况下,数据可能被拆成片段,需要在解析层做好缓冲。
我最后采取的方案是:在SSE解析处维护一个StringBuilder字节缓冲,只有等到一行完整的事件数据才去解析。同时要注意编码统一,所有地方都强制使用UTF-8,任何一环用了默认编码就会出现乱码。
另外还有一点别忽略:有些服务商的流式数据里会夹杂“data: [DONE]”之外的额外字段,比如ping心跳包、注释文本。解析时遇到这些非数据行要直接跳过,否则整个JSON解析会连锁出错。
4.4 上下文无限膨胀,费用也跟着爆
多轮对话越聊越长,这是所有接大模型的项目后期必定遇到的问题。消息列表无限增长,会导致两个后果:一是请求体越来越大,延迟越来越高;二是token费用成倍增加。
解决办法不外乎三种。一是固定窗口,只保留最近N条消息;二是做历史摘要,每聊到一定轮数就请模型把之前的对话浓缩成一个摘要,后续只传摘要;三是做语义检索,只将相关的历史提问注入上下文。实际项目里,摘要方案最实用,效果也最稳定。
4.5 超时、连接池耗尽
普通接口调用请求量一大,连接池可能先扛不住。AI接口的响应时间本来就比普通接口长,尤其是流式场景,一个请求可能占用几十秒连接,默认连接池很容易被打满。
我处理这个问题的思路是把AI调用单独划分一个连接池,与业务接口隔离。在RestClient或WebClient的配置里,显式调大最大连接数和空闲连接超时时间。流式接口还要额外注意释放问题,确保每次请求结束后连接能回到池里,而不是被占着一直到超时。
4.6 框架版本不一致导致类找不到
Spring AI对外部依赖版本很敏感。我见过不少人在旧版Spring Boot项目里强行加新版Spring AI依赖,结果启动就报NoClassDefFoundError。
如果项目里Spring Boot版本偏低,我建议不要强行升级整个项目的Spring版本,而是先用HTTP裸调的方式把AI能力接起来。等整个项目的Spring Boot升级到支持Spring AI的版本后,再进行技术栈替换,风险会小很多。
5. 实践建议:先裸调理解原理,再上框架提效
说实话,我并不认为这两种方式有绝对的优劣之分。它们解决的是不同阶段的诉求。
如果这是你第一次在SpringBoot里接AI,我建议无论如何都要先走一遍HTTP裸调。自己构造请求、解析响应、维护会话,这些过程会让你对AI接口的本质有极强的掌控感。以后不管换什么模型服务商、引入什么框架,你都能很快看懂它的实现逻辑。
如果这是团队内部某个稳定业务要接入AI,或者AI能力会覆盖多个业务模块,那直接用Spring AI会省下大量重复代码。它的配置抽象、提示词模板、结构化输出,在生产环境里真的能减少很多低级bug。
如果你是个人开发者做创业项目,想最快速度把功能demo跑起来,我的建议又会不一样。先用Spring AI这种偏向开箱即用的框架做出第一版,快速收集用户反馈,等确定有业务价值之后,再回头优化链路细节。
最后说一个我自己的观点。大模型接入的复杂度,其实不在接口调用这一层,而在业务场景里怎么把模型能力用对。接一个对话接口,也许半天就能跑通;但让AI在业务系统里稳定地干活,还需要在提示词设计、上下文管理、错误降级、成本控制这些地方下很大功夫。无论选哪条技术路线,这些功课都省不了。先跑通,再打磨,是这条路上最务实的走法。