Spring AI 零基础实战:从第一个聊天接口到企业级 RAG 知识库
上个月,Leader 找到你说:「咱们那个客服后台,每天有一半的咨询都是重复问题——退货流程、运费规则、发货时效。你能不能搞个 AI 自动回复?」
你的反应可能是:AI?那不得学 Python、搞模型部署、折腾 CUDA?写的可是 Java。
然后你发现了 Spring AI —— 一个直接把大模型能力接入 Spring Boot 生态的框架。它做的事情一句话就能说清楚:让你用写 Spring Boot 的方式调用大模型。@RestController 你怎么写,调用 ChatGPT/DeepSeek/通义千问你就怎么写。
这篇文章用一个完整的电商智能客服项目贯穿始终,从零搭建到上线,每一步都有能跑通的代码。
1. 为什么要在 Java 里调 AI
先回答一个问题:如果用 Python 包一个 AI 服务,Java 去调它,不也行吗?
确实行。但如果你只是想做「把用户问题发给大模型,把答案返回来」,引入一个 Python 服务就多了好几层复杂度:服务间通信、错误处理、部署和运维、两套代码库的维护。
Spring AI 让你在同一个 Spring Boot 应用里完成这些事:
- 用熟悉的
RestTemplate/WebClient的抽象去调大模型 - 用
@Service写你的 AI 业务逻辑 - 用 Spring 的配置管理来切换 AI 供应商(换一个 API Key 就从通义千问切到 DeepSeek)
- 不用学 LangChain,不用写 Python
如果你现在的项目是 Spring Boot,那引入 AI 能力只需要加一个依赖、写几行配置。
2. 10 分钟跑起来:第一个聊天接口
2.1 创建项目
用 Spring Initializr 或者在你的 pom.xml 里加这几个依赖:
<!-- Spring Boot 3.x Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI OpenAI Starter —— 兼容所有 OpenAI 格式的 API -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-openai</artifactId>
<version>1.0.0-M6</version>
</dependency>
为什么用 OpenAI Starter 而不是某个特定模型的 Starter?因为国内几乎所有大模型服务——DeepSeek、通义千问、智谱 GLM、Moonshot——都提供了 OpenAI 兼容的 API 端点。用这一个依赖,换个 Base URL 和 API Key 就能在它们之间切换,不需要改代码。
2.2 配置
application.yml:
spring:
ai:
openai:
# DeepSeek 的 OpenAI 兼容端点
base-url: https://api.deepseek.com/v1
api-key: ${DEEPSEEK_API_KEY}
chat:
options:
model: deepseek-chat
temperature: 0.7
${DEEPSEEK_API_KEY} 用环境变量注入,不要硬编码在配置文件里。如果你换通义千问,只需要改 base-url、api-key、model 三个值。
这里有一个容易忽略的点:temperature 控制回答的随机性。客服场景设 0.3~0.5 比较合适——太低了回答机械,太高了可能胡说。0.7 偏创意,0.1 偏保守。这个值你要根据业务场景在做测试时调整,不要一次设完就不管了。
2.3 第一行代码
@RestController
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping("/chat")
public String chat(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
启动项目,浏览器打开:
http://localhost:8080/chat?question=退货流程是什么
你会收到大模型的回复。
关键认知:ChatClient 是 Spring AI 的核心入口,类似于 RestTemplate 在 HTTP 调用中的地位。.prompt().user(...).call().content() 这个链式调用背后,Spring AI 帮你做了:序列化请求 → 调 OpenAI 兼容 API → 解析响应 → 提取文本内容。你不需要关心 HTTP 细节、Token 拼接、流式处理——至少入门阶段不需要。
2.4 加上系统提示词
裸调大模型有两个问题:第一,它不知道它是谁;第二,它可能回答超出客服范围的问题。
@GetMapping("/chat")
public String chat(@RequestParam String question) {
return chatClient.prompt()
.system("你是一个电商客服助手。只回答跟订单、退货、物流、商品相关的问题。" +
"如果用户问其他问题,礼貌地拒绝。语气友好简洁。")
.user(question)
.call()
.content();
}
system 消息是「角色设定」,它不会被用户看到,但会严重影响模型的行为。写 system prompt 有几点经验:
- 不要写「你不能做什么」的否定句式,要写「你只做什么」的限定范围——模型对否定指令的理解不稳定
- 给具体的结束条件:「如果不知道,直接说不知道,不要编造」
- 客服场景加上语气要求:「语气友好简洁,用口语化表达」
3. 让 AI 返回结构化数据
现在客服能聊天了,但 Leader 提了新需求:「能不能让 AI 判断用户的意图?是退货、查物流、还是投诉?」
这意味着我们不能只拿一段文本回来——我们需要 AI 返回一个 JSON。
3.1 定义输出结构
public record IntentResult(
@JsonProperty("intent") String intent, // "refund" / "logistics" / "complaint" / "other"
@JsonProperty("confidence") double confidence,
@JsonProperty("summary") String summary // 一句话概括用户诉求
) {}
3.2 让 AI 输出 JSON
@GetMapping("/intent")
public IntentResult detectIntent(@RequestParam String question) {
return chatClient.prompt()
.system("""
你是一个客服意图识别器。根据用户输入判断意图,以 JSON 格式返回。
意图分类:
- refund: 退货、退款、换货
- logistics: 物流查询、发货时间、快递
- complaint: 投诉、不满、要求赔偿
- other: 其他问题
返回格式:{"intent": "...", "confidence": 0.0-1.0, "summary": "..."}
只返回 JSON,不要加任何解释文字。
""")
.user(question)
.call()
.entity(IntentResult.class); // 自动反序列化为 Java 对象
}
.entity(IntentResult.class) 这行是 Spring AI 帮你做的事情:拿到模型返回的文本 → 自动解析 JSON → 映射到你的 Java Record。如果模型返回的不是合法 JSON(比如前面多了「好的,这是识别结果:」这种废话),这里会抛异常。
生产环境的经验:永远不要假设模型会严格遵守你的 JSON 格式要求。加上「只返回 JSON,不要加任何解释文字」这句话能解决大部分情况,但少数时候模型还是会加废话。更稳健的做法是自定义一个 Converter,在解析前先把文本里的 JSON 提取出来:
@Component
public class JsonExtractorConverter implements Converter<String, IntentResult> {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public IntentResult convert(String source) {
// 提取 { 到 } 之间的内容,防止模型加了额外文字
int start = source.indexOf('{');
int end = source.lastIndexOf('}');
if (start >= 0 && end > start) {
source = source.substring(start, end + 1);
}
try {
return objectMapper.readValue(source, IntentResult.class);
} catch (JsonProcessingException e) {
throw new RuntimeException("AI 返回了非法 JSON: " + source, e);
}
}
}
用 Converter 而不是直接 .entity() 的思路是:把容错逻辑封装起来,不要散落在每个调用点。你写得越健壮,线上半夜被叫醒的概率就越低。
4. 让 AI 读懂你的业务文档——RAG 实战
意图识别做好之后,Leader 说:「现在 AI 能知道用户要退货,但它不知道退货规则是什么。咱们有退货政策、运费标准、常见问题 FAQ 这些文档,能不能让 AI 去读?」
这就是 RAG(检索增强生成)要解决的问题:先检索相关文档,再把文档内容作为上下文喂给大模型。
4.1 RAG 三步走
整个 RAG 流程分三步,每一步 Spring AI 都有对应的抽象:
文档导入 → 向量化存储 → 检索 + 生成答案
(ETL) (VectorStore) (RetrievalAugmentation)
第一步:把文档拆成小块,转成向量,存进向量库。
为什么要拆小块?大模型一次能处理的上下文有限(比如 deepseek-chat 是 64K token)。如果你把整篇退货政策(可能几千字)全部塞进去,留给用户问题和历史对话的空间就不多了。拆成几百字的小块,每次只检索最相关的几块。
第二步:用户提问时,把问题也转成向量,在向量库里找最相似的文档块。
用向量的「距离」来度量语义相似度:「退货怎么操作」和「如何申请退款」距离很近,「退货怎么操作」和「今天天气不错」距离很远。
第三步:把找到的文档块拼进 system prompt,让模型基于这些资料回答。
4.2 代码实现
先用内存向量库快速跑通(生产环境换成 Redis 或 PostgreSQL + PGVector):
@Configuration
public class RagConfig {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return new SimpleVectorStore(embeddingModel);
}
// ETL 管道:读取文档 → 分块 → 向量化 → 存入向量库
@Bean
public CommandLineRunner loadDocuments(VectorStore vectorStore) {
return args -> {
// 读取 resources/faq/ 下的所有 txt 文件
Resource[] resources = new PathMatchingResourcePatternResolver()
.getResources("classpath:/faq/*.txt");
// TokenTextSplitter:按 token 数分块,每块 500 token,块间重叠 50 token
// 重叠是为了避免一个完整知识点被切在两块的边界上
TextSplitter splitter = new TokenTextSplitter(
500, 50, 10, 1000, true
);
List<Document> documents = new ArrayList<>();
for (Resource resource : resources) {
String text = resource.getContentAsString(StandardCharsets.UTF_8);
// 分块后每个 Document 带上来源文件名作为元数据
List<Document> splitDocs = splitter.split(
new Document(text, Map.of("source", resource.getFilename()))
);
documents.addAll(splitDocs);
}
// 批量向量化并写入向量库
vectorStore.add(documents);
System.out.println("已加载 " + documents.size() + " 个文档块");
};
}
}
这段代码在应用启动时自动执行,把 resources/faq/ 目录下的文档向量化后存入内存向量库。生产环境你不会每次都重新加载——数据量大时应该用计划任务增量同步。
第二步和第三步:检索 + 生成
@RestController
public class CustomerServiceController {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public CustomerServiceController(ChatClient.Builder builder, VectorStore vectorStore) {
this.chatClient = builder.build();
this.vectorStore = vectorStore;
}
@GetMapping("/ask")
public String ask(@RequestParam String question) {
// 1. 从向量库检索最相关的 3 个文档块
List<Document> relevantDocs = vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(3)
);
// 2. 把文档内容拼成上下文
String context = relevantDocs.stream()
.map(doc -> "【来源:" + doc.getMetadata().get("source") + "】\n" + doc.getContent())
.collect(Collectors.joining("\n\n"));
// 3. 用这个上下文构造 prompt
return chatClient.prompt()
.system("""
你是一个电商客服助手。请根据以下资料回答用户问题。
如果资料中没有相关信息,请如实说「抱歉,我目前没有这方面的信息」。
不要编造任何资料中没有的内容。
=== 参考资料 ===
%s
""".formatted(context))
.user(question)
.call()
.content();
}
}
这段代码三个步骤一一对应:检索 → 拼上下文 → 生成。withTopK(3) 取最相关的 3 个文档块——不要太多,多了会稀释关键信息;也不要太少,少了可能漏掉相关信息。3 到 5 是一个经验范围。
4.3 生产环境的向量库选择
SimpleVectorStore 是内存实现,重启就没了。生产环境推荐:
| 方案 | 适用场景 | 理由 |
|---|---|---|
| Redis Stack | 已有 Redis 的项目 | 零额外部署,spring-ai-starter-redis 直接切换 |
| PGVector | 已有 PostgreSQL 的项目 | 向量和业务数据同库,一条 SQL 搞定 |
| Elasticsearch | 已有 ES 的项目 | 全文搜索 + 向量搜索一体化 |
切换方式就是改一个 Bean,比如 Redis:
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel, RedisTemplate<String, Object> redis) {
return new RedisVectorStore(/* ... */);
}
Spring AI 的 VectorStore 接口屏蔽了底层实现差异,你的检索代码不需要改。
5. AI 帮你查订单——Function Calling
现在客服 AI 能回答政策问题了,但 Leader 又提了需求:「用户问『我的订单到哪了』,AI 能查出来吗?」
这需要 AI 能调用你的业务系统——查询数据库、调物流接口。在 AI 领域这个能力叫 Function Calling:你定义一些函数(工具)给 AI,AI 自己判断什么时候该调用哪个函数,调用完了把结果带回对话。
5.1 定义工具
@Component
public class OrderTools {
private final OrderService orderService;
public OrderTools(OrderService orderService) {
this.orderService = orderService;
}
@Tool(description = "根据用户手机号查询最近的订单列表")
public List<OrderInfo> getUserOrders(
@ToolParam(description = "用户手机号") String phone) {
return orderService.findByPhone(phone);
}
@Tool(description = "根据订单号查询物流详情")
public LogisticsInfo getLogistics(
@ToolParam(description = "订单号") String orderId) {
return orderService.getLogistics(orderId);
}
}
@Tool 和 @ToolParam 的 description 不是写给人看的注释——它们是写给 AI 看的。AI 会根据这些描述判断「用户问的是不是这个函数能解决的问题」。描述写模糊了,AI 就可能不调用或者乱调用。
描述写好的几个原则:
- 写清楚函数做什么,不要缩写
@ToolParam的描述写清楚参数长什么样:「用户手机号,11 位数字」- 函数返回值如果复杂,考虑在返回对象上标注字段含义
5.2 注册工具并使用
@RestController
public class AgentController {
private final ChatClient chatClient;
private final OrderTools orderTools;
public AgentController(ChatClient.Builder builder, OrderTools orderTools) {
this.orderTools = orderTools;
this.chatClient = builder
.defaultTools(orderTools) // 注册工具
.build();
}
@GetMapping("/agent")
public String agent(@RequestParam String question) {
return chatClient.prompt()
.system("""
你是电商智能客服。你可以查询用户的订单信息和物流状态。
当用户询问订单相关问题时:
1. 先获取用户手机号(如果用户没提供,先问手机号)
2. 用手机号查询订单
3. 整理成清晰的信息回复用户
回复时把关键信息(订单号、状态、物流)用简洁的格式展示。
""")
.user(question)
.call()
.content();
}
}
运行测试:
用户:我的订单到哪了?
AI:请问您的手机号是?
用户:13812345678
AI:[AI 自动调用 getUserOrders → 拿到订单列表 → 再调用 getLogistics → 整理回复]
您最近有两个订单:
1. 订单 ORD20240701001 — 已发货,快递单号 SF12345678,当前在【上海分拣中心】
2. 订单 ORD20240703005 — 正在拣货中
这里面发生了什么?
当你调用 .call() 时,Spring AI 做了这样一个循环:
- 把用户消息 + 可用工具列表发给大模型
- 大模型判断:我需要调
getUserOrders("13812345678") - Spring AI 拦截这个请求,在你的 JVM 里执行
getUserOrders方法 - 把函数返回值作为一条新消息发给大模型
- 大模型基于返回值生成最终回复
这就是一个最简单的 Agent 循环——模型思考、调用工具、拿到结果、再思考。Spring AI 帮你封装了这个循环,你的代码跟写普通 Service 没什么区别。
5.3 为什么这个能力很关键
没有 Function Calling 之前,你跟大模型说「帮我查订单」,它只能编一个。这是大模型最被诟病的问题——幻觉。
有了 Function Calling,大模型不需要「记住」订单数据,它只需要知道「有这个函数可以查」,数据还是从你的数据库实时拿的。AI 从「一本正经地胡说八道」变成了「知道该找谁要证据」。
6. 生产环境落地的几个关键点
以上代码跑通了 Demo。但 Demo 和上线的差距,就在这几个细节里。
6.1 流式输出(SSE)
聊天场景下,等 5 秒才一次性吐出全部回复,用户体验很差。流式输出让字一个一个蹦出来:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.stream()
.content();
}
前端用 EventSource 接收:
const eventSource = new EventSource('/chat/stream?question=退货流程');
eventSource.onmessage = (event) => {
// event.data 是一段一段的文本,追加到聊天框里
chatBox.innerHTML += event.data;
};
注意:Flux 默认在调用线程上发送数据。如果你的 SSE 连接很多,记得配置一个专门的 Scheduler,避免阻塞 Tomcat 线程。
6.2 异常处理
大模型 API 可能超时、限流、返回格式错误。不能把这些异常直接抛给用户。
@GetMapping("/chat")
public ResponseEntity<?> chat(@RequestParam String question) {
try {
String result = chatClient.prompt()
.user(question)
.call()
.content();
return ResponseEntity.ok(Map.of("answer", result));
} catch (AiClientException e) {
// API 层面的错误:超时、限流、认证失败
log.error("AI API 调用失败", e);
return ResponseEntity.status(502)
.body(Map.of("error", "AI 服务暂时不可用,请稍后重试"));
} catch (AiOutputParserException e) {
// 返回内容解析失败(比如期望 JSON 但拿到了文本)
log.error("AI 返回内容解析失败", e);
return ResponseEntity.status(500)
.body(Map.of("error", "系统处理异常,请重试"));
}
}
两种异常要区分处理:
AiClientException:外部服务挂了,返回 502,用户知道是临时问题AiOutputParserException:模型返回了不可解析的内容,这是你的解析逻辑不够健壮,要记录日志去修
6.3 费用控制
大模型 API 按 Token 计费。一个客服系统每天可能有上千次调用,如果不控制,月底账单能让人血压升高。
两个最有效的控制手段:
1. 缓存常见问题
@Cacheable(value = "faq_cache", key = "#question")
public String ask(String question) {
// RAG 查询 + 大模型调用
}
用户问「退货流程」和「怎么退货」虽然文字不同,但答案可能完全一样。用 Redis 缓存高频问题的答案,命中一次就省一次 API 调用。
2. 设置 Token 上限
spring:
ai:
openai:
chat:
options:
max-tokens: 500 # 单次回答最长 500 token
客服回答一般不需要太长,设 300~500 token 足够。既控制了成本,也避免了模型啰嗦。
7. 从 Demo 到上线
回顾一下我们做了什么:
- 一个
/chat接口 —— 10 分钟,AI 能聊天了 - 结构化输出 —— 意图识别能返回 JSON 了
- RAG —— AI 能查你的业务文档了
- Function Calling —— AI 能调你的数据库和接口了
- 流式输出 + 异常处理 + 缓存 —— 可以上线了
这五个步骤不是平级的:1 和 2 是基本功,3 和 4 是核心价值,5 是生产保障。
上线 checklist
在把 Spring AI 项目推上生产之前,按这个清单过一遍:
- [ ] API Key 用环境变量注入,不要提交到 Git
- [ ] 对话历史有没有做 Token 截断?(超过上下文限制会报错)
- [ ] Function Calling 的函数有没有做权限控制?(不能让用户通过 AI 间接调了不该调的接口)
- [ ] 有没有做用户输入过滤?(防止用户输入超长文本消耗大量 Token)
- [ ] 监控有没有加?(API 调用量、Token 消耗、响应时间、错误率)
- [ ] 缓存策略有没有定?(FAQ 类问题缓存,个性化问题不缓存)
下一步建议
如果你现在有一个 Spring Boot 项目,最推荐的切入点不是聊天机器人,而是用结构化输出改造一个现有的文本处理场景。比如:
- 商品评论的情感分析(几百条评论 → AI 自动分类好评/差评/中性)
- 客服工单的自动分类(用户填了工单内容 → AI 自动归入退货/物流/投诉)
- 订单备注的关键信息提取(「客户要求周五下午送到」→ AI 提取时间要求、特殊需求)
这些场景不需要 RAG 不需要 Function Calling,只需要「调 API → 拿 JSON → 写入数据库」。改动最小、见效最快、风险最低。等团队熟悉了 AI 调用的节奏,再上 RAG 和 Function Calling 解决更复杂的问题。
写于 2026 年 7 月 24 日