Java 生态里做 AI 应用,过去两年一直有点尴尬:Python 那边 LangChain、LlamaIndex 生态成熟得像菜市场,Java 开发者想接个大模型,要么自己封装 HTTP 客户端,要么在几个半成品框架之间反复横跳。Spring AI 出现之后,这个局面算是有了正经解法——它把模型调用、提示词模板、结构化输出、向量检索、工具调用这些能力,用 Spring 一贯的抽象风格包了一层,让写惯了 Spring Boot 的人几乎零学习成本就能上手。我前后在两个内部项目里用它做过知识库问答和智能客服原型,踩的坑不算少,但整体体验是“终于不用自己造轮子了”。
这篇内容面向的是有 Java/Spring Boot 基础、想快速把大模型能力接进业务系统的开发者。我会从整体设计思路讲起,把核心抽象、依赖选型、实操步骤、参数配置、常见故障排查都拆开说清楚,中间穿插我自己踩过的坑和验证过的配置。读完你应该能独立搭起一个可运行的 Spring AI 项目,并且知道哪些地方容易翻车。
1. 整体设计与技术选型思路拆解
1.1 为什么 Java 开发者需要 Spring AI 这层抽象
先说清楚一个前提:直接调大模型 API 并不难,一个 RestTemplate 或者 WebClient 发个 POST 就完事了。难的是当业务从“调一次模型”变成“一个完整的 AI 应用”之后,你要处理的东西会指数级膨胀——多模型切换、提示词版本管理、流式输出、结构化解析、对话记忆、向量库对接、工具函数注册、可观测性。这些如果每个项目都从零写一遍,纯属浪费生命。
Spring AI 的价值就在于它把这些共性能力做成了统一的抽象层。它的核心接口设计非常“Spring”:ChatModel、EmbeddingModel、ImageModel、VectorStore、ChatClient,每个接口对应一类能力,具体实现由不同的 starter 提供。你写业务代码时面向接口,换模型供应商时只改依赖和配置,业务代码基本不动。这个思路和当年 Spring 对 JDBC 的抽象一模一样——JdbcTemplate屏蔽了数据库差异,ChatClient屏蔽了模型供应商差异。
我特别看重的一点是它的可移植性。举个实际场景:我们内部先用某家国内模型做原型,后来因为成本和合规要求要换另一家,如果代码里到处是硬编码的 HTTP 调用,迁移成本极高;而用 Spring AI,改一下pom.xml里的 starter 和application.yml里的几个配置项就完成了,业务层一行没动。这种“面向接口编程”的收益在 AI 应用这种供应商快速变化的领域尤其明显。
另一个容易被忽略的点是与 Spring 生态的无缝集成。自动配置、依赖注入、Actuator 健康检查、配置外部化,这些你熟悉的东西全都直接可用。比如你可以把模型调用的超时、重试策略通过application.yml配置,可以用@Bean自定义ChatClient,可以在测试里用 Mock 替换模型实现。这种“不用学新东西”的体验,对团队推广来说太重要了。
1.2 核心抽象层与模块划分
Spring AI 的模块划分比较清晰,理解这张“地图”能帮你少走很多弯路。核心模块大致分这么几层:
| 模块类别 | 代表接口/组件 | 作用 |
|---|---|---|
| 模型抽象 | ChatModel、EmbeddingModel、ImageModel | 屏蔽不同供应商的调用差异 |
| 高层门面 | ChatClient | 提供流式、链式、结构化输出的便捷 API |
| 提示词 | PromptTemplate、Prompt | 模板化提示词,支持变量替换 |
| 输出解析 | OutputParser、StructuredOutputConverter | 把模型返回的文本转成对象 |
| 记忆 | ChatMemory、ChatMemoryRepository | 多轮对话上下文管理 |
| 检索增强 | VectorStore、DocumentReader、Splitter | RAG 场景的文档处理与向量检索 |
| 工具调用 | @Tool、ToolCallback | 让模型调用你注册的 Java 方法 |
理解这张表的关键是分清“底层模型接口”和“高层门面”。ChatModel是最底层的调用入口,参数控制粒度细但用起来啰嗦;ChatClient是推荐日常使用的门面,链式调用写起来很顺,流式、结构化输出、工具调用、记忆都能通过它串起来。我的建议是:业务代码一律用ChatClient,只有在需要极细粒度控制(比如自定义底层参数、做框架级封装)时才直接碰ChatModel。
模块化带来的另一个好处是按需引入。你只做文本对话,就只引 chat 相关依赖;要做知识库,再加 vector store 和 document reader;要做图像生成,加 image 模块。不用一次性把所有东西都拖进来,依赖体积和启动时间都可控。
1.3 版本选择与依赖管理策略
版本这块必须重点说,因为 Spring AI 迭代速度很快,不同版本之间 API 有不小的变动,网上搜到的教程很可能和你实际用的版本对不上,这是新手最容易踩的坑。
我的建议是:优先用 Spring Boot 的 BOM 来统一管理版本。Spring AI 提供了对应的 BOM(物料清单),引入之后你就不需要给每个 Spring AI 依赖写版本号,避免版本冲突。具体做法是在pom.xml的dependencyManagement里导入 BOM,然后在dependencies里只写 groupId 和 artifactId。
选版本时注意两点:一是 Spring AI 的版本要和 Spring Boot 版本匹配,官方文档里有兼容性矩阵,别自己乱配;二是尽量选稳定发布版,不要用里程碑版做生产项目,除非你需要某个还没进正式版的新特性。我在一个项目里图新鲜用了里程碑版,结果一个ChatClient的 API 签名在下个小版本就变了,改了半天。
还有个小细节:如果你用的是 Gradle,BOM 的引入方式是通过platform(),和 Maven 略有不同,别照抄 Maven 的写法。依赖管理这块看着琐碎,但它是后面所有实操的地基,值得花十分钟搞明白。
2. 核心细节解析与实操要点
2.1 环境准备与项目骨架搭建
动手之前先把环境盘清楚。基础要求是 JDK 17 及以上(Spring Boot 3.x 的硬性要求),Maven 3.6+ 或 Gradle 7.x+,一个能访问模型服务的 API Key。IDE 用 IntelliJ IDEA 就行,它对 Spring 的配置提示支持最好。
项目骨架我推荐直接用 Spring Initializr 生成,选 Maven、Java 17、Spring Boot 3.x,依赖先勾一个Spring Web(后面写个 Controller 测试用)。生成后手动加 Spring AI 的 BOM 和 starter。为什么不直接在 Initializr 里勾 Spring AI?因为不同时期 Initializr 上的选项和实际稳定版可能不同步,手动加反而更可控。
目录结构上没什么特殊要求,按标准 Spring Boot 分层即可。我习惯这么组织:
src/main/java/com/example/ai/ ├── config/ # ChatClient、VectorStore 等 Bean 配置 ├── controller/ # 对外接口 ├── service/ # 业务逻辑,封装 AI 调用 ├── model/ # DTO、结构化输出实体 └── tool/ # 工具函数(@Tool 方法)把 AI 调用封装在 service 层是个好习惯,controller 只负责参数校验和响应组装。这样后面换模型、加缓存、加降级都在 service 层做,不影响接口层。我见过有人把ChatClient直接注入 controller 里调,原型阶段没问题,一旦要加统一的重试和日志就到处改,很痛苦。
2.2 模型接入配置的关键参数
配置是新手翻车重灾区,我把关键项拆开讲。以application.yml为例,核心配置大致长这样(不同供应商的 key 名称略有差异,以官方文档为准):
spring: ai: openai: api-key: ${AI_API_KEY} base-url: https://your-endpoint/v1 chat: options: model: your-chat-model temperature: 0.7 max-tokens: 2048 embedding: options: model: your-embedding-model几个参数值得单独说:
api-key 一定要走环境变量,绝对不要硬编码进配置文件然后提交到代码仓库。我见过真实事故:有人把 key 写进application.yml推到公开仓库,几小时内就被扫到并盗刷。用${AI_API_KEY}占位,本地开发在 IDE 的运行配置里设环境变量,线上用配置中心或密钥管理服务注入。
base-url这个参数很关键,尤其是用兼容接口的模型服务时。很多供应商提供 OpenAI 兼容的接口,你只需要把 base-url 指过去、model 换成对应的模型名就能用,不用换 starter。这是 Spring AI 抽象带来的直接好处。
temperature控制输出的随机性,取值一般 0 到 1(部分模型支持更大范围)。做事实性问答、代码生成建议调低(0.1~0.3),做创意文案可以调高(0.7~0.9)。这个参数没有“标准答案”,得根据你的场景实测。我的经验是:先设 0.7 跑通,再根据输出质量往两端调,别一上来就纠结。
max-tokens限制单次响应的最大长度。设太小会导致回答被截断,设太大浪费成本且可能拖慢响应。估算方法是:中文大约 1 个字对应 1~2 个 token(不同分词器有差异),英文大约 1 个单词对应 1.3 个 token。你要生成 500 字的中文回答,留 1000~1500 的余量比较稳妥。
注意:不同供应商对参数名的叫法不完全一致,有的叫
max-tokens,有的叫max-completion-tokens,配置前务必对照你所用 starter 的官方文档,别想当然。
2.3 ChatClient 的构建与三种调用方式
ChatClient是日常开发的主力,构建方式推荐用 Builder 注入ChatModel:
@Configuration public class AiConfig { @Bean public ChatClient chatClient(ChatModel chatModel) { return ChatClient.builder(chatModel) .defaultSystem("你是一个严谨的技术助手,回答简洁准确。") .build(); } }这里用defaultSystem设了全局系统提示词,相当于给模型定了个“人设”。实际项目里我建议把系统提示词抽到配置文件或数据库,方便运营调整而不用重新发版。
调用方式有三种,各有适用场景:
第一种,简单调用,chatClient.prompt().user("问题").call().content(),返回字符串。适合快速验证和简单问答。
第二种,流式调用,.stream().content()返回Flux<String>。适合需要“打字机效果”的对话界面。流式的好处是首字延迟低,用户感知快,但要注意前端得配合 SSE 或 WebSocket 接收。我在做客服界面时用的就是流式,体验比等整段返回好太多。
第三种,结构化输出,.call().entity(YourClass.class),直接把模型返回的 JSON 映射成 Java 对象。这个功能强烈推荐,它省掉了手动解析 JSON 的麻烦,而且框架会在提示词里自动加上格式约束。用的时候实体类字段别太复杂,嵌套层级控制在两三层以内,太复杂模型容易输出不合法的 JSON。
2.4 提示词模板与结构化输出的配合技巧
提示词模板用PromptTemplate,支持{变量}占位:
PromptTemplate template = new PromptTemplate(""" 请根据以下信息回答问题。 背景:{context} 问题:{question} 要求:只输出答案,不要解释。 """); Prompt prompt = template.create(Map.of("context", ctx, "question", q)); String answer = chatClient.prompt(prompt).call().content();模板的价值在于提示词与代码分离。把提示词写在 Java 字符串里,改一个字就要重新编译部署,这在需要频繁调优提示词的 AI 项目里是灾难。我的做法是把模板放到resources/prompts/目录下,用文件读取加载,甚至放到数据库里做版本管理。
结构化输出和模板配合时有个坑要注意:如果你用entity()做结构化输出,框架会自动在提示词末尾追加格式说明,这时候你自己的模板里就不要再重复写“请输出 JSON”,否则模型可能输出两段格式说明或者格式混乱。我第一次用的时候就犯了这个错,模板里写了“以 JSON 格式返回”,结果模型返回的内容里夹了一段解释文字,解析直接失败。
另一个技巧是给实体类加清晰的字段注释(用@JsonPropertyDescription之类的注解,具体看版本支持),框架会把这些描述带进提示词,帮助模型理解每个字段该填什么。字段名尽量用有意义的英文,别用a、b这种,模型看不懂。
3. 实操过程与核心环节实现
3.1 从零跑通第一个对话接口
理论说再多不如跑一遍。这一节我把完整流程走一遍,你可以直接照着做。
第一步,建项目、加依赖。pom.xml里先导入 BOM:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>你的版本号</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>然后加 starter(以某兼容 OpenAI 接口的供应商为例):
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency>第二步,配application.yml,把 api-key、base-url、model 填上,参数按上一节说的设。
第三步,写配置类和 Controller:
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @GetMapping("/simple") public String simple(@RequestParam String question) { return chatClient.prompt() .user(question) .call() .content(); } }第四步,启动,用浏览器或 curl 访问/api/chat/simple?question=你好,能看到模型返回就说明通了。
这一步看着简单,但新手常卡在几个地方:依赖版本不匹配导致启动报NoSuchMethodError;api-key 没设导致 401;base-url 末尾多了或少了斜杠导致 404。排查顺序建议是:先看启动日志有没有自动配置报错,再用 curl 直接打模型接口确认 key 和地址没问题,最后才怀疑代码。这个顺序能帮你快速定位问题在哪一层。
3.2 多轮对话与记忆管理实现
单轮问答跑通后,下一步基本都要做多轮对话。这里的关键是ChatMemory。默认情况下每次调用是独立的,模型不记得上一轮说了什么,你得把历史消息带上。
Spring AI 提供了ChatMemory抽象,配合ChatClient的 advisor 机制使用。核心思路是:给每个会话一个 ID,把该会话的历史消息存起来,每次调用时自动带上。存储可以用内存(适合原型)、也可以用数据库或 Redis(适合生产)。
配置大致是这样:
@Bean public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build() ) .build(); }调用时通过advisors参数传会话 ID:
chatClient.prompt() .user(question) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) .call() .content();这里有几个实操要点。第一,记忆是有窗口的。历史消息不能无限带,否则 token 消耗爆炸且可能超出上下文限制。要配置最大保留条数或 token 数,超出后按策略丢弃最旧的消息。我的经验是保留最近 10~20 轮比较合适,具体看你的场景和模型上下文长度。
第二,会话 ID 的生成和管理要谨慎。用用户 ID 做会话 ID 简单,但一个用户可能有多个对话;用随机 UUID 则要前端负责保存和传递。我倾向于“用户 ID + 会话 UUID”的组合,既隔离又可控。
第三,记忆存储要考虑并发和清理。内存存储重启就丢,只适合开发;生产用 Redis 或数据库时,要设置过期时间,否则历史数据越积越多。我见过一个项目忘了设过期,几个月后 Redis 内存告警,排查半天才发现是对话历史。
3.3 RAG 知识库问答的落地流程
RAG(检索增强生成)是 Spring AI 最实用的场景之一,也是问得最多的。整体流程分两段:离线建库和在线检索。
离线建库的步骤是:读文档 → 切分 → 向量化 → 存入向量库。Spring AI 提供了DocumentReader(读 PDF、Markdown、文本等)、TextSplitter(切分)、EmbeddingModel(向量化)、VectorStore(存储)几个组件,串起来就行。
切分参数是这里的关键。切太大,检索精度低且浪费 token;切太小,语义不完整。我的经验值是:每段 500~800 字符,段间重叠 100~200 字符。重叠是为了避免一句话被硬生生切断导致语义丢失。这个参数没有万能值,得根据你的文档类型调——技术文档可以切小点,叙述性文档切大点。
在线检索的流程是:用户提问 → 问题向量化 → 向量库相似度检索 Top-K → 把检索到的片段拼进提示词 → 调模型生成答案。代码大致:
List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder().query(question).topK(4).build()); String context = docs.stream() .map(Document::getText) .collect(Collectors.joining("\n---\n")); String answer = chatClient.prompt() .system("仅根据以下资料回答,资料中没有的信息请说不知道。\n" + context) .user(question) .call() .content();topK设多少?一般 3~5 比较合适。设太小召回不足,设太大噪声多且浪费 token。我实测下来 4 是个不错的起点。
这里有个极其重要的经验:系统提示词里一定要明确写“资料中没有的信息请说不知道”。不加这句,模型会一本正经地编造答案,这在知识库场景里是致命的。我早期做的一个内部问答,就是因为没加这句,模型把不存在的流程编得有模有样,差点误导同事。
3.4 工具调用让模型操作真实业务
工具调用(Function Calling / Tool Calling)是让模型从“只会说”变成“能做事”的关键。原理是:你把 Java 方法注册成工具,模型在需要时返回一个“我要调用某工具、参数是这些”的指令,框架执行方法后把结果回传给模型,模型再基于结果生成回答。
Spring AI 里用@Tool注解标记方法:
@Component public class OrderTool { @Tool(description = "根据订单号查询订单状态,订单号格式为 ORD 开头加数字") public String queryOrderStatus(String orderNo) { // 实际查库逻辑 return "已发货"; } }然后在调用时注册:
chatClient.prompt() .user("帮我查下订单 ORD12345 的状态") .tools(orderTool) .call() .content();几个要点。description 写得越清楚,模型调用越准。要说明工具干什么、参数什么格式、什么情况下用。我见过 description 只写“查询订单”,结果模型在用户问“怎么退款”时也去调它,因为描述太模糊。
工具方法要幂等且安全。模型可能重复调用同一个工具,也可能传错参数。涉及写操作(下单、扣款)的工具要格外小心,最好加二次确认或权限校验,别让模型直接触发敏感操作。
工具数量别太多。一次注册十几个工具,模型选择困难,调用准确率下降。我的做法是按场景分组,不同对话场景注册不同的工具集。
4. 常见问题与排查技巧实录
4.1 启动与依赖类问题速查
这类问题占了新手求助的一大半,我整理成表方便对照:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
启动报NoSuchMethodError/ClassNotFoundException | 版本不匹配,BOM 没生效或依赖冲突 | 用mvn dependency:tree看实际版本,确认 BOM 导入正确 |
启动报找不到ChatModelBean | starter 没引或自动配置未触发 | 检查依赖是否引入,看启动日志的自动配置报告 |
| 401 Unauthorized | api-key 没设或错误 | 确认环境变量生效,用 curl 直接验证 key |
| 404 Not Found | base-url 路径错误 | 检查末尾斜杠,对照供应商文档的完整路径 |
| 连接超时 | 网络或地址不可达 | 先用 curl 测通再排查代码 |
排查这类问题的核心思路是分层定位:先确认依赖层(版本对不对),再确认配置层(key、地址对不对),最后才是代码层。很多人一上来就怀疑代码,结果折腾半天发现是版本冲突。
提示:启动时加上
--debug参数可以看到自动配置报告,哪些配置生效、哪些没生效一目了然,这是排查 Bean 缺失问题的利器。
4.2 调用异常与输出质量问题的处理
调用能通但结果不对,这类问题更隐蔽。常见的有:
输出被截断。多半是max-tokens设小了。调大即可,但要注意成本和上下文限制。如果模型本身上下文窗口就小,那得考虑分段处理。
结构化输出解析失败。原因通常是模型返回的 JSON 不合法(多了说明文字、字段缺失、类型不对)。解决办法:一是简化实体类结构;二是提示词里明确要求“只输出 JSON,不要任何其他文字”;三是加容错,解析失败时降级为返回原始文本。我在生产代码里一定会包一层 try-catch,解析失败记日志并返回兜底内容,不能让整个接口挂掉。
回答质量不稳定。同一问题两次回答差异大,多半是 temperature 太高。事实性场景调到 0.2 以下会稳定很多。另外提示词要具体,模糊的提示词得到模糊的答案。
响应特别慢。先看是不是max-tokens设太大导致生成时间长;再看网络延迟;流式场景下首字延迟和总时长要分开看。如果模型服务本身慢,考虑加超时和降级策略。
4.3 成本控制与生产化注意事项
原型跑通和生产可用之间隔着一条河,成本和安全是两条主线。
成本控制方面,几个实操手段:一是缓存,相同问题直接返回缓存结果,能省不少;二是控制max-tokens和topK,别浪费;三是监控 token 消耗,按用户或接口维度统计,发现异常及时干预;四是简单任务用小模型,复杂任务才用大模型,分级处理。
安全方面,提示词注入是必须防的。用户输入里可能夹带“忽略之前的指令”之类的内容,诱导模型做不该做的事。防护手段包括:对用户输入做过滤、系统提示词里明确边界、敏感操作加人工确认、输出做内容审核。这块没有一劳永逸的方案,得持续对抗。
可观测性方面,建议把每次调用的模型、耗时、token 数、是否命中缓存都记下来。出问题时这些日志是排查的关键。Spring AI 和 Micrometer 有集成,可以接监控系统。
我在实际项目里的体会是:原型阶段怎么快怎么来,但一旦要上线,缓存、超时、降级、监控这四样一个都不能少。少一样,线上出问题时你就只能干瞪眼。尤其是超时,模型服务偶尔抽风是常态,没有超时保护,一个慢请求能把线程池拖垮。
最后分享一个我踩过的坑:早期做流式输出时,没处理客户端断开连接的情况,用户关掉页面后后端还在傻傻地生成,白白消耗 token。后来加了连接状态检测,客户端断开就取消生成,成本立竿见影地降下来。这类细节文档里不会写,但实际运营中很要命。