☰
Spring AI实战:从零搭建Spring Boot大模型应用
2026/10/19 9:50:44 网站建设 项目流程

如果你最近在Spring Boot项目里接过大模型、顺手做过AI功能,大概率会遇到那种很别扭的时刻:模型提供方的SDK只管它自己,远程调用、参数命名、鉴权方式和Spring的习惯相去甚远;prompt拼接散落在Controller和Service里,输出格式一会儿纯文本一会儿带markdown,上下文稍微一长就得手动拼历史消息。

Spring AI就是来填这个空档的。它把"和模型对话"抽象成一套Spring风格的组件,让你像写普通Bean一样调用大模型能力,并且把输出解析、记忆管理、向量检索这些周边脏活一并收进统一框架。这篇文章我会先用一个真实视角讲清楚Spring AI到底解了什么题,然后带你搭出一个能跑的Spring Boot工程,实现两个入门案例:一问一答和结构化信息抽取,再聊聊多轮对话、记忆窗口和实测中最容易踩的坑。适合刚开始接触Java侧AI开发的开发者,也适合想评估"要不要把Spring AI引进来"的团队存量代码负责人。

1. Spring AI是什么:解决"接模型"之前的那堆脏活

1.1 在Spring Boot里调大模型,真正难的是什么

先说个扎心的结论:把一次模型调用从"能通"变成"好维护",复杂度和工程量大约是调用本身的十倍。

我第一次在项目里接入大模型时,直接调的是模型服务商提供的HTTP接口。刚开始一切正常,一个API地址加上一个鉴权头就能拿到回复。但随着需求变多,问题接踵而来:

  • 每个服务商的API风格不一样,有的用流式、有的要求补全prompt结构,有的把返回参数藏在多层嵌套JSON里,换一家就要重写一版调用代码;
  • 想让模型按我的格式返回数据,需要在prompt里反复强调"输出JSON、不要解释、不要markdown",但模型偶尔还是会把废话一起返回;
  • 多轮对话时没人帮我维护消息列表,要么我手动把之前所有消息塞进prompt,要么只能放弃上下文;
  • 请求超时、重试、并发限制、token计量这些工程问题全部要自己造轮子,而且每个模型服务商的标准还不统一。

这些其实不是"AI问题",而是"集成问题"。Spring生态最擅长解决这种集成问题,IoC容器、自动配置、统一抽象、Client封装,一套组合拳下来完全能把模型调用收敛成几个接口。Spring AI就是顺着这个思路长出来的项目。

1.2 核心抽象地图:从Message到ChatModel再到向量库

如果你之前看过一些零散教程,可能会被一堆概念弄晕。实际上Spring AI的抽象设计很规整,核心就这么几层:

抽象概念作用我常用的入口
Message一条对话消息,分SystemMessage、UserMessage、AssistantMessage组装上下文
Prompt一次模型输入的完整封装,内部是一组Message发给ChatModel
ChatModel同步对话模型客户端,负责把Prompt发给模型服务并收回结构化响应主要调用入口
StreamingChatModel异步/流式对话模型客户端,返回Flux打字机场景
ChatResponse模型返回的完整响应,包含token用量和结果列表取回content
OutputConverter把模型输出解析成Java对象结构化返回
PromptTemplate带占位模板的Prompt构造器消息模板化
VectorStore / ChatMemory向量库抽象和聊天记忆抽象RAG、多轮对话

打个比方,你以前是直接给模型服务商打工,每个细节都要亲自盯;现在Spring AI相当于一个翻译和管家,它帮你统一了"写信、寄信、收信、拆信"的流程,你只需要指定要寄给谁、内容是什么。

但这里提醒一句:Spring AI还处于高速演进期,不同小版本的类名和包名会有调整。后面所有案例代码我会以"理解核心概念"为主,不把某个版本的写死当标准答案,你自己落地时务必以当前版本的实际API为准。

2. 先把环境搭起来:依赖、配置、连接检查

2.1 依赖怎么选,版本为什么不能乱抄

搭建Spring AI项目前,先把基础环境确认好:JDK 17以上,Spring Boot 3.x。这两个少一个都可能遇到兼容性报错。

依赖方面,Spring AI官方按模型服务商拆了多个starter,也提供了一个统一入口。我建议入门阶段别贪多,先引入一个能完成基础对话的starter加Web支持就够了,否则多个模型的自动配置Bean互相抢,排查起来很头疼。

我当时用的pom结构大概是这样的:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.5</version> </parent> <properties> <java.version>17</java.version> </properties> <dependencyManagement> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>

注意:依赖坐标和BOM版本在不同时间差异很大,直接抄一段旧代码大概率启动报错。我的建议是打开官方文档的版本说明页,找到当前release对应的BOM版本,再对照你选的模型服务商starter去配。

2.2 一份最基础的application.yml模板

配置是整个Spring AI项目中信息密度最高、也最容易被忽略的部分。先用一张最精简的模板铺底:

spring: application: name: spring-ai-demo ai: model: provider: ${AI_PROVIDER} api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} options: model: ${AI_MODEL_NAME} temperature: 0.8

我说说每项是干什么的:

  • provider:你选哪类模型平台,这个值会决定自动装配哪个Provider的客户端;
  • api-key:鉴权密钥,强烈建议从环境变量读,不要硬编码在配置文件里再提交到Git;
  • base-url:模型服务的根地址。不同平台这个地址差异很大,服务商会在控制台或文档里标清楚;
  • options.model:模型的精确名称标识。模型名拼错是最常见的启动后调用报错原因,你看着像、模型服务端不认;
  • temperature:采样随机性,数值大回复越发散,数值小越稳定。结构化输出场景我通常会调到0.2以下。

顺带一提,如果你在本地有自建的模型推理环境,可以通过base-url直接指过去。这样开发和联调都不用消耗线上额度,等代码稳定后再切到云端模型服务,业务代码一行不用动,这算是Spring AI抽象带来的一个隐藏福利。

2.3 跑case前先确认这三点

工程启动不报错,不代表模型调用一定能通。我在教学和自测时都会先做三个小检查,能省掉后面排查的很多时间:

第一,配置里的密钥和模型名是否正确。前者环境变量可能没加载到,后者可能填了英文别名而不是服务商要求的版本标识。第二,网络连通性。生产环境通常有严格网络安全策略,域名不通、证书不被信任、防火墙拦截都很常见,先单独发一个最小HTTP请求验证地址和鉴权是否正常。第三,看启动日志里是否出现了你这个Provider对应的自动配置类。如果你引入了依赖但配置不完整,Spring Boot可能不会装配ChatModel,运行时会收到NoSuchBeanDefinitionException。

这三个检查做完,基本就排除了"配置层面"一半以上的问题,后面写代码时更安心。

3. 第一个入门案例:完成一次可控的问答调用

3.1 用ChatModel把一句话变成一段回复

一个最简单也最有代表性的用法,是把聊天功能封装成一个REST接口。我用ChatModel作为核心依赖,通过构造函数注入,Controller只做接口透传:

@RestController @RequestMapping("/api/ai") public class AiChatController { private final ChatModel chatModel; public AiChatController(ChatModel chatModel) { this.chatModel = chatModel; } @GetMapping("/ask") public String ask(@RequestParam String question) { Prompt prompt = new Prompt(question); ChatResponse response = chatModel.call(prompt); return response.getResult() .getOutput() .getContent(); } }

这个案例流程很典型:你创建一个Prompt,把用户问题放进去,调用ChatModel的call方法,然后从ChatResponse里把模型生成的文本取出来。

很多人第一次写会踩一个认知偏差:以为response对象的toString就能当回复用。千万别。打开调试器你会看到ChatResponse是个多层嵌套结构,真正的正文藏在getResult().getOutput().getContent()里,后面所有场景都要走这条取链。

3.2 响应对象拆解:Generation、AssistantMessage、Metadata

既然上面提到了嵌套,我就把ChatResponse的内部结构拆开说。

ChatResponse里主要有三块:

  • 一个或多个Generation结果,每个Generation对应一条模型回复;
  • 每个Generation中再包一层AssistantMessage,它本身又包含content(正文内容)和metadata(消息级元数据);
  • ChatResponse最外层还有请求级metadata,通常包含token用量、响应批次等统计信息。

想让接口返回更丰富的内容,比如把token用量和回复正文一起返回前端,可以这样组织:

@GetMapping("/askWithMeta") public Map<String, Object> askWithMeta(@RequestParam String question) { ChatResponse response = chatModel.call(new Prompt(question)); Generation generation = response.getResult(); return Map.of( "content", generation.getOutput().getContent(), "usage", response.getMetadata().getUsage() ); }

这个能力在线上很有用,能帮你看清一次对话消耗了多少token、有没有因为长度限制被截断,也能为后续做成本统计打好数据基础。

3.3 流式输出:让回复逐字出现

当模型思考时间较长时,用户界面一直在转圈会非常煎熬。解决办法是使用流式响应,让模型生成多少就传多少,前端像收到打字机输出一样逐字渲染。

Spring AI的StreamingChatModel接口专门干这个。用法如下:

@RestController @RequestMapping("/api/ai") public class AiStreamController { private final StreamingChatModel streamingChatModel; public AiStreamController(StreamingChatModel streamingChatModel) { this.streamingChatModel = streamingChatModel; } @GetMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8") public Flux<String> stream(@RequestParam String question) { return streamingChatModel.stream(new Prompt(question)) .map(response -> response.getResult().getOutput().getContent()); } }

返回类型从String变成Flux ,业务层不用自己处理线程和循环,Spring AI内部已经按流式订阅把增量内容推送出来了。前端拿EventSource就能直接消费,这个接口形态在生产环境验证过,体验非常顺滑。

4. 第二个入门案例:用PromptTemplate和BeanOutputConverter做结构化抽取

4.1 为什么兜兜转转都要结构化成JSON

日常开发里,模型输出必须落进业务逻辑才有价值。举个例子,让你写一段代码从产品介绍里提取"产品名、适用人群、价格区间",你总不希望先拿到一段自然语言再自己用正则去抠字段。

最可靠的做法是让模型在prompt约束下直接输出JSON,再用解析器转成Java对象。Spring AI提供了一整套工具链:PromptTemplate负责把动态变量填进模板,BeanOutputConverter负责根据你要的Java类型生成JSON Schema,并把它拼进prompt让模型遵循,最后再把模型输出解析成目标对象。

4.2 动手实现一个"信息抽取"服务

我直接在思维方式最接近生产场景的一个Demo里写清楚了。首先定义一个Java record:

public record ProductInfo( String productName, String category, String targetUsers, String priceRange, String keyFeatures ) { }

然后创建抽取服务:

@Service public class InfoExtractionService { private static final BeanOutputConverter<ProductInfo> CONVERTER = new BeanOutputConverter<>(ProductInfo.class); private final ChatModel chatModel; public InfoExtractionService(ChatModel chatModel) { this.chatModel = chatModel; } public ProductInfo extract(String description) { PromptTemplate template = new PromptTemplate(""" 你是一个严谨的信息抽取助手。 请从下面的产品描述中提取指定字段,并严格使用要求的JSON格式输出。 不要输出任何解释,不要使用markdown代码块包裹JSON。 产品描述: {description} 输出格式: {format} """); Prompt prompt = template.create(Map.of( "description", description, "format", CONVERTER.getFormat() )); ChatResponse response = chatModel.call(prompt); return CONVERTER.convert(response.getResult().getOutput().getContent()); } }

几个关键设计点:

CONVERTER.getFormat()会在运行时根据ProductInfo的字段生成JSON Schema约束,比如"productName是字符串,targetUsers是字符串,keyFeatures是字符串",模型看到这个约束后会更愿意按Schema答题,准确性比单纯在prompt里写"输出JSON"高出不少。

PromptTemplate把带占位符文本和动态变量粘在一起,替代了传统字符串拼接,避免prompt里出现漏引号、少了换行这种低级错误。

最后CONVERTER.convert()负责把模型返回的JSON文本解析成ProductInfo对象。解析失败时会抛出异常,这要求我们考虑兜底方案。

4.3 模型不听指挥时的兜底策略

结构化输出大概率不是一次就成。我遇到过模型输出带了个```markdown前缀,遇到过JSON里多个逗号,遇到过字段直接被截断,还遇到过模型非要在JSON前面加一句"好的,我只抽取了如下字段"。

我的处理习惯是写一个重试+清洗机制:

  1. 尝试直接解析,如果成功就直接返回;
  2. 解析失败时,用正则把```json和```这类代码块标记剥掉;
  3. 清洗后再解析一次;
  4. 还不行就降低temperature重试,最多重试两次;
  5. 仍然失败就抛出统一业务异常,让上层走人工兜底。

很多教程不会讲这部分,但线上环境真正决定稳定性的恰恰是这些容错细节。一个只演示理想路径的AI接口,测试环境跑得再漂亮,上了生产面对千奇百怪的真实输入也容易翻车。

5. 上下文从哪来:多轮对话与短期记忆的作用

5.1 简单窗口是最快见效的记忆策略

大模型本身是无状态的。你不把以前的对话传回去,它就不记得你们三分钟前聊过什么。所以"对话记忆"本质上就是客户端维护一个历史消息列表,每次请求整包带上。

最朴素的实现是手动维护一个列表,每次追加用户输入和模型回复:

@Service public class ConversationService { private final List<Message> history = new ArrayList<>(); public ConversationService() { history.add(new SystemMessage("你是一个简洁友好的助手。")); } public String chat(String userText) { history.add(new UserMessage(userText)); Prompt prompt = new Prompt(history); ChatResponse response = chatModel.call(prompt); String answer = response.getResult().getOutput().getContent(); history.add(new AssistantMessage(answer)); if (history.size() > 12) { history.remove(1); history.remove(1); } return answer; } }

这里有个细节值得说透:为什么控制消息数而不是简单地全量保留?因为模型服务的上下文长度是有限的,你无限塞旧消息,最终会碰到token上限,接口报错或者模型直接忘了开头的内容。用固定窗口把历史限制在最近几轮,是一种成本极低且不会出错的保底方案。

上面的remove(1)写了两遍,是因为要同时删掉最早的一对用户问题和模型回复,只保留第0条的SystemMessage作为系统人设。

Spring AI其实还提供了更规范的ChatMemory抽象,支持不同的存储实现,比如内存版本和基于外部存储的版本。用起来比手写历史管理更干净,也更方便后面迁移。不过无论选哪种,核心思路和上面这段手动代码是一样的:清掉太久远的消息,把上下文控制在足够用又不超限的范围内。

5.2 从内存版到RAG:什么时候该升级

当你的对话场景需要参考指定文档内容回答时,单靠最近对话记忆是不够的,你需要检索增强生成,也就是常说的RAG。Spring AI也把 Embedding、向量库、检索这些环节做了抽象,你可以把业务文档切片、向量化后存入向量库,请求来时先做相似度检索,再把检索结果和用户问题拼在一起发给模型。

我把话先说清楚:RAG不是入门第一课,它的复杂度峰值在数据准备和向量库运维,而不是Spring AI本身。如果你刚上手,先用前面两节的内容把手感和链路跑通,等真实需求出现再来研究RAG也不迟。我见过不少团队一上来就堆向量库,结果基础对话逻辑都没理顺,排查问题反而更困难。

6. 实测中容易翻车的点,以及我的处理方案

6.1 版本更新太快,今天能用的代码明天就废

Spring AI到现在还属于快速演进期,包名、类名、方法签名时不时变化。我维护的一套基于某模拟项目X的AI服务,内部代码每次升一个小版本,几乎都要跟着改造两三个方法。

针对这个情况,我的长期策略是:

  • 无论多忙,依赖版本固定到具体数字,不用什么SNAPSHOT,避免一觉醒来依赖悄悄变化;
  • 升级前先看官方发版说明或样例代码差异,不盲改,先用一个最小工程验证新版API形态;
  • 把模型调用相关代码收敛在独立的Service层,不要让Controller直接依赖具体API类,这样未来改造范围是可控的。

6.2 结构化输出不是100%,要把容错写到位

我在4.3节已经讲了不少清洗和重试方法,这里再补一条认知:模型"按格式输出"本质上是一个概率行为。prompt写得再漂亮、Schema给得再清楚,面对长文本、复杂字段和特殊字符时依然可能翻车。

所以成熟项目的做法是:把转换失败当作预期内异常处理,有兜底有告警,而不是赌模型每一次都听话。你可以把失败样本打进日志,定期看一眼哪些字段最容易错,然后针对性调整Schema或模板措辞。

6.3 密钥、成本和超时:容易被忽略的线上三件事

密钥泄漏是最大的事故源。我见过的反面例子包括:把api-key提交到Git仓库、在前端代码里写key、把测试环境key直接用于生产。建议从第一天就把密钥放到环境变量或配置中心,并且按环境隔离。

成本控制方面,如果请求量变大,单次对话的token消耗会非常可观。建议在配置里限制maxTokens,并对每轮对话设置合理超时时间,防止某个异常问题让调用挂到天荒地老。

还有一点很多开发者一开始会忽略:不要轻易把敏感正文或客户数据扔给模型服务方。无论选择哪家服务商,先确认数据合规边界,该脱敏的脱敏,该禁止入参的场景直接禁止。这个意识和写key管理一样重要,发生一次事故就够呛。

7. 收尾:用个人Demo验证可行后的几点真实感想

我自己用这一套思路做的是一套"某信息摘要组件",专门把几百字的维护工单压缩成结构化报告。开发效率确实比裸调HTTP快不少,Spring AI把模型接入、流式、JSON转换这些动作收得比较干净,团队里其他成员读代码也更容易理解。

最后分享一个我现在已经固化的习惯:把模型类型、密钥、模型名全部放到环境变量层,测试环境用一个本地推理服务,正式环境切到云上的模型服务,业务代码完全无感。这样既保住了开发速度,也不把成本浪费在联调阶段。

如果你是第一次接触Spring AI,我建议你今天就动手把第3章的对话接口跑通,然后在此基础上扩展成结构化抽取。不用急着追求复杂架构,先让一个最小闭环真正落到你的工程里。反而比堆十几个依赖更能帮助你理解它。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询