Spring AI 生产避坑指南与 RAG 内存向量库实战
本文覆盖 Spring AI 应用落地过程中最常踩的五个生产级陷阱------Token 成本失控、API Key 泄露、并发限流、输出不稳定、日志监控缺失,并给出对应的解决方案与代码实现。随后从零讲解 RAG(检索增强生成)的核心原理,并基于 Spring AI 的
SimpleVectorStore完成一个内存向量库的完整实战。面向有 Spring Boot 基础、正在用 Spring AI 做大模型应用开发的后端工程师。
一、为什么要单独聊"避坑"
Spring AI 的 API 封装得很优雅,写个 Demo 十分钟就能跑起来。但 Demo 和生产之间隔着的,往往不是功能,而是成本、安全、稳定性这些非功能性问题。下面这五个坑,几乎每个把 AI 功能带上线的团队都会踩到至少两三个。
二、陷阱一:Token 成本失控
大模型 API 按 Token 计费,一个中文字大约消耗 1.5~2 个 Token,英文单词大约 1~2 个 Token。看起来单次调用不贵,但量一上来就不是小数目了。
2.1 一笔账
以通义千问为例(输入 0.04 元/千 Token,输出 0.12 元/千 Token),一个典型的 RAG 问答请求:
| 项目 | Token 量 | 单价 | 费用 |
|---|---|---|---|
| System Prompt | ~200 | 0.04 元/千 | 0.008 元 |
| 对话历史(最近 5 轮) | ~1500 | 0.04 元/千 | 0.06 元 |
| RAG 检索注入文档片段 | ~1500 | 0.04 元/千 | 0.06 元 |
| 模型输出 | ~500 | 0.12 元/千 | 0.06 元 |
| 单次合计 | ~3700 | ~0.188 元 |
日均 10 万次调用,一天就是 1.88 万元。一个月下来,这笔钱足够让老板找你谈话了。
2.2 四个控制手段
1. 精简 System Prompt
System Prompt 每次请求都会带上,长度乘以日请求量就是固定成本。1000 Token 的 System Prompt,10 万次/天就是 1 亿 Token。去掉废话、用最精炼的语言描述角色和规则。
2. 限制对话历史轮数
不要无限保留历史消息,用 MessageWindowChatMemory 控制只保留最近 N 条:
java
MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder()
.maxMessages(10) // 只保留最近 10 条消息
.build();
3. 控制输出长度
输出 Token 的单价通常是输入的 2~3 倍。对于情感分析、分类判断这类场景,用结构化输出约束模型只返回必要字段,比如用数字编码代替自然语言描述:
规则:1=积极,2=中立,3=消极。只返回数字,不要解释。
4. Token 用量监控
在代码层面拿到每次调用的 Token 消耗,做持续监控:
java
ChatResponse response = chatClient.prompt()
.user(message)
.call()
.chatResponse();
Usage usage = response.getMetadata().getUsage();
log.info("输入Token={}, 输出Token={}, 合计={}",
usage.getPromptTokens(),
usage.getCompletionTokens(),
usage.getTotalTokens());
// 费用估算(以通义千问为例)
double cost = usage.getPromptTokens() / 1000.0 * 0.04
+ usage.getCompletionTokens() / 1000.0 * 0.12;
更进一步,可以用 Advisor 做全局 Token 统计,按用户维度累计消耗:
java
@Component
public class TokenUsageAdvisor implements CallAdvisor {
private final ConcurrentHashMap<String, LongAdder> userTokenCount = new ConcurrentHashMap<>();
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
ChatClientResponse response = chain.nextCall(request);
ChatResponse chatResponse = response.chatResponse();
if (chatResponse != null
&& chatResponse.getMetadata() != null
&& chatResponse.getMetadata().getUsage() != null) {
var usage = chatResponse.getMetadata().getUsage();
String userId = (String) request.context()
.getOrDefault("userId", "anonymous");
long total = usage.getTotalTokens() != null ? usage.getTotalTokens() : 0L;
userTokenCount.computeIfAbsent(userId, k -> new LongAdder()).add(total);
log.info("[Token统计] userId={}, prompt={}, completion={}, total={}, 累计={}",
userId, usage.getPromptTokens(), usage.getCompletionTokens(),
total, userTokenCount.get(userId).sum());
}
return response;
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE; // 最后执行,确保拿到完整响应
}
}
生产环境中,
userTokenCount应替换为 Redis 或数据库存储,否则应用重启数据就丢了。
三、陷阱二:API Key 泄露
这个问题说起来每个人都知道,但每年仍然有大量 API Key 被提交到 GitHub 上。Key 里充了钱,被人扫到就是直接的财务损失。
3.1 错误做法
yaml
# application.yml ------ 千万别这么干
spring:
ai:
openai:
api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxx
配置文件跟着代码提交到 Git 仓库,公开仓库直接裸奔,私有仓库也只是多了一层权限控制。
3.2 正确做法
方案一:环境变量
yaml
spring:
ai:
openai:
api-key: ${AI_API_KEY}
本地开发时设置环境变量,CI/CD 环境通过 Secret 管理注入。
方案二:本地开发用 .env 文件 + .gitignore
创建 application-local.yml,写入实际 Key,然后在 .gitignore 中排除:
gitignore
application-local.yml
启动时指定 --spring.profiles.active=local 即可。
方案三:配置中心
生产环境使用 Nacos、Apollo 等配置中心统一管理,Key 不落地到代码仓库。
四、陷阱三:并发打爆模型 API
大模型 API 都有速率限制------每分钟多少次请求、每小时多少 Token 额度。超限后要么排队等待,要么直接报 429 错误。如果不在自己的接口层做限流,等于把流量控制完全交给了上游,一旦触发限流,所有用户都会受影响。
4.1 Guava RateLimiter 方案
在 Advisor 层用令牌桶算法做用户级限流:
java
@Component
public class RateLimitAdvisor implements CallAdvisor {
private static final double PERMITS_PER_SECOND = 2.0; // 每用户每秒最多 2 次
private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
String userId = (String) request.context()
.getOrDefault("userId", "anonymous");
RateLimiter limiter = limiters.computeIfAbsent(userId,
k -> RateLimiter.create(PERMITS_PER_SECOND));
if (!limiter.tryAcquire()) {
throw new RuntimeException("请求过于频繁,请稍后再试");
}
return chain.nextCall(request);
}
@Override
public int getOrder() {
return 10; // 在日志之后、记忆之前执行
}
}
Controller 层传入 userId:
java
return chatClient.prompt()
.user(message)
.advisors(a -> a.param("userId", userId))
.call()
.content();
4.2 为什么在 Advisor 层做
把限流逻辑放在 Advisor 中而不是 Controller 中,有两个好处:
- 全局生效:所有经过该 ChatClient 的调用都会被限流,不需要在每个 Controller 里重复写
- 与业务解耦:限流策略的调整(改 QPS、改限流维度)不需要动业务代码
需要引入 Guava 依赖:
xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.2.1-jre</version>
</dependency>
五、陷阱四:响应内容不稳定
大模型的输出是概率性的。即使你要求 JSON 格式输出,也可能在极小概率下返回不符合格式的内容------多一个换行、少一个引号、输出带 Markdown 代码块标记。
5.1 兜底策略
java
// 用 Optional 做兜底,避免解析失败直接抛异常到前端
public String safeCall(String message) {
try {
return chatClient.prompt()
.user(message)
.call()
.content();
} catch (Exception e) {
log.error("AI 调用失败", e);
return "抱歉,当前服务繁忙,请稍后再试";
}
}
5.2 结构化输出 + 重试
对于需要结构化返回的场景,建议:
- 使用 Spring AI 的
BeanOutputConverter做自动解析 - 解析失败时进行有限次重试(2~3 次)
- 重试仍失败则返回兜底值
5.3 压力测试
上线前必须做批量测试------不是跑 3、5 次没问题就行,要跑几百上千次,观察极小概率事件是否出现。重点关注:
- 输出格式是否偶尔乱掉
- 长文本场景下是否被截断
- 并发调用下是否有竞态问题
六、陷阱五:缺少日志和监控
调用大模型在一定程度上是个黑盒。请求发出去了,响应回来了,中间发生了什么、花了多少 Token、耗时多长,如果没有日志,出了问题完全无从排查。
6.1 自定义 LoggingAdvisor
java
@Component
public class LoggingAdvisor implements CallAdvisor, StreamAdvisor {
private static final Logger log = LoggerFactory.getLogger(LoggingAdvisor.class);
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
long start = System.currentTimeMillis();
String userMessage = request.prompt().getContents();
log.info("[AI调用] 用户消息: {}", userMessage);
ChatClientResponse response = chain.nextCall(request);
long elapsed = System.currentTimeMillis() - start;
String aiReply = response.chatResponse().getResult().getOutput().getText();
log.info("[AI调用] 模型回复({}ms): {}", elapsed,
aiReply.length() > 100 ? aiReply.substring(0, 100) + "..." : aiReply);
return response;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // 最先执行,覆盖完整链路耗时
}
}
6.2 生产级踩坑清单
把上面五个陷阱汇总成一张检查表,上线前逐项过一遍:
| 序号 | 风险点 | 后果 | 解决方案 |
|---|---|---|---|
| 1 | Token 成本不受控 | 月底账单爆炸 | 精简 Prompt + 限制历史 + 用量监控 |
| 2 | API Key 硬编码 | 泄露导致资金损失 | 环境变量 / 配置中心 |
| 3 | 不做限流 | 触发上游速率限制,全体用户受影响 | Guava RateLimiter + Advisor |
| 4 | 不处理输出异常 | 极小概率格式错误导致前端崩溃 | try-catch 兜底 + 重试 + 压测 |
| 5 | 无日志监控 | 出问题无法排查 | LoggingAdvisor + TokenUsageAdvisor |
| 6 | 对话历史无限增长 | 费用线性增长,响应变慢 | MessageWindowChatMemory 限制轮数 |
| 7 | 同步调用阻塞线程 | 高并发下线程池耗尽 | 流式调用 + 异步处理 |
七、RAG 核心原理
上面聊的是"怎么不出事",接下来聊"怎么做更多的事"------用 RAG 让大模型具备回答私有数据问题的能力。
7.1 为什么需要 RAG
大模型有三个天然短板:
- 知识截止日期:训练数据有时间边界,2024 年之后发生的事它不知道
- 不了解私有数据:公司文档、退货政策、内部规范,模型一概不知
- 上下文窗口有限:不可能把所有资料塞进 System Prompt------一个企业知识库可能有几千几万份文档
有人会问:把资料全塞进 Prompt 不行吗? 不行。原因有三:窗口装不下、Token 费用炸裂、信息太杂模型反而找不到重点。
RAG 的思路是:不全塞,只检索与当前问题最相关的几段内容,注入到 Prompt 中。
7.2 两阶段流程
阶段一:离线入库(知识打包)
原始文档(PDF/Word/网页/数据库)
→ 文本提取
→ 切片(每段几百字)
→ Embedding 向量化(文本 → 浮点数数组)
→ 存入向量数据库
阶段二:在线问答(检索增强)
用户提问
→ 问题 Embedding(同样的向量化过程)
→ 在向量数据库中做相似度检索
→ 取出最相关的 Top-K 文档片段
→ 拼接到 Prompt 中
→ 调用大模型生成回答
7.3 为什么相似度检索能找到相关内容
传统关键词搜索(如 ES)靠的是词频匹配------"退货政策"和"商品退换规定"用关键词很难关联上。但向量检索靠的是语义相似度。
Embedding 模型会把文本映射到一个高维空间中的坐标点。语义相近的文本,在这个空间中的距离就近:
| 文本 | 向量(示意,实际维度远大于此) |
|---|---|
| "商品退换规定" | [0.82, 0.15, 0.73, ...] |
| "退货政策" | [0.80, 0.18, 0.71, ...] |
| "今天天气怎么样" | [0.12, 0.91, 0.05, ...] |
"退货政策"和"商品退换规定"的向量非常接近,而"今天天气怎么样"则距离很远。这就是向量检索能做到语义级匹配的原因。
八、Spring AI 中的 RAG 实现
Spring AI 提供了完整的 RAG 支持链路:Embedding Model(向量化)、VectorStore(向量存储)、文档切片工具、QuestionAnswerAdvisor(自动检索注入)。
8.1 支持的向量数据库
Spring AI 支持的向量数据库相当丰富:
| 向量数据库 | 适用场景 |
|---|---|
SimpleVectorStore(内存) |
开发调试、快速验证 |
| PostgreSQL + pgvector | 中小规模生产推荐,复用现有 PG 基础设施 |
| Milvus | 大规模向量检索,性能优异 |
| Elasticsearch | 已有 ES 集群的团队 |
| Redis | 需要低延迟的场景 |
| Chroma、Pinecone、Weaviate | 各有特色,按需选择 |
开发阶段用 SimpleVectorStore(内存实现)就够了,零依赖、零配置。生产环境推荐 PostgreSQL + pgvector,成本低且运维体系成熟。
8.2 引入依赖
使用内存向量库只需要加一个依赖:
xml
<!-- Spring AI 内存向量库 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store-simple</artifactId>
</dependency>
版本由
spring-ai-bom统一管理,不需要单独指定 version。
8.3 完整代码实战
下面用一个完整的例子演示:初始化内存向量库、写入文档、基于用户提问做 RAG 检索问答。
Step 1:初始化向量库并写入文档
java
@RestController
@RequestMapping("/api/rag")
public class RagDemoController {
private final ChatClient chatClient;
private final SimpleVectorStore vectorStore;
public RagDemoController(ChatClient.Builder builder, EmbeddingModel embeddingModel) {
// 1. 初始化内存向量库
this.vectorStore = SimpleVectorStore.builder(embeddingModel).build();
// 2. 准备演示文档(实际项目中从 PDF/数据库/API 加载)
List<Document> documents = List.of(
new Document("公司退换货政策:购买后 7 天内可无理由退货,商品需保持原包装完好。"
+ "退货运费由买家承担,退款将在收到退货后 3 个工作日内原路返回。"),
new Document("会员积分规则:每消费 1 元积 1 分,积分可在下次购物时抵扣,"
+ "100 积分抵扣 1 元。积分有效期为自获得之日起 12 个月。"),
new Document("配送说明:默认使用顺丰快递,下单后 48 小时内发货。"
+ "偏远地区可能延迟 1-2 天。满 99 元包邮,不满 99 元收取 8 元运费。")
);
// 3. 写入向量库(自动完成 Embedding + 存储)
vectorStore.add(documents);
// 4. 构建 ChatClient
this.chatClient = builder
.defaultSystem("你是一个客服助手,根据提供的上下文信息回答用户问题。")
.build();
}
}
关键点:vectorStore.add(documents) 这一步背后做了两件事------调用 EmbeddingModel 把每段文本转成向量,然后存入内存。
Step 2:构建 QuestionAnswerAdvisor
java
private QuestionAnswerAdvisor createRagAdvisor() {
return QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(3) // 检索最相关的 3 段
.similarityThreshold(0.5) // 相似度阈值
.build())
.build();
}
QuestionAnswerAdvisor 是 Spring AI 提供的开箱即用的 RAG Advisor。它会在调用模型之前,自动把用户问题向量化、检索向量库、将检索结果注入到 Prompt 中。
Step 3:问答接口
java
@GetMapping
public String ask(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.advisors(createRagAdvisor()) // 挂载 RAG Advisor
.call()
.content();
}
Step 4:测试
请求:
GET /api/rag?question=退货需要什么条件
响应:
根据公司退换货政策,您在购买后 7 天内可以无理由退货,但商品需要保持原包装完好。
退货运费由买家承担,退款将在收到退货后 3 个工作日内原路返回。
模型的回答完全基于我们写入向量库的文档内容,而不是凭空编造。这就是 RAG 的效果------模型不知道的知识,通过检索注入给它。
8.4 背后发生了什么
当用户问"退货需要什么条件"时,QuestionAnswerAdvisor 在幕后完成了这些事:
-
将"退货需要什么条件"通过
EmbeddingModel转成向量 -
在
SimpleVectorStore中做余弦相似度检索 -
"退换货政策"那段文档的相似度最高,被检索出来
-
将检索到的文档片段拼接到用户 Prompt 中,形成类似这样的最终 Prompt:
【上下文信息】
公司退换货政策:购买后 7 天内可无理由退货,商品需保持原包装完好...【用户问题】
退货需要什么条件 -
把这个增强后的 Prompt 发给大模型,模型基于上下文生成回答
整个过程对调用方透明,只需要挂一个 Advisor 就搞定了。
九、RAG 的局限性
RAG 虽然强大,但不是银弹。了解它的边界,才能在实际项目中做出正确的架构决策。
9.1 检索质量决定回答质量
如果向量检索没有找到相关文档,或者找到的文档不够准确,模型就没有正确的上下文可用。这时候它要么说"我不知道",要么开始编造------后者更危险。
9.2 切片策略影响效果
文档切片是 RAG 流水线中最"手艺活"的环节:
- 切太短:语义不完整,检索到了也看不懂
- 切太长:一段话混入多个主题,相似度计算不准
- 切片重叠:相邻片段有部分重叠内容,可以缓解语义断裂问题
Spring AI 提供了 TokenTextSplitter 等切片工具,支持按 Token 数切分并设置重叠量。实际项目中需要根据文档特点反复调优。
9.3 多跳推理能力有限
如果一个问题需要关联多段文档才能回答(比如"A 政策中提到的 B 规则具体是什么"),单纯的 Top-K 检索可能只找到其中一段。这种场景需要更复杂的 Agent 架构来解决,后续文章会专门讨论。
9.4 Embedding 模型的影响
不同的 Embedding 模型,向量化的质量差异很大。选型时需要关注:
- 是否支持中文语义
- 向量维度与检索性能的权衡
- 模型本身的调用成本
十、总结
本文覆盖了两个核心主题:
生产避坑方面,五个高频陷阱和应对方案:
- Token 成本 → 精简 Prompt + 限制历史 + 用量监控 Advisor
- Key 泄露 → 环境变量 / .gitignore / 配置中心
- 并发限流 → Guava RateLimiter + Advisor 层拦截
- 输出不稳定 → try-catch 兜底 + 结构化输出 + 批量压测
- 日志监控 → LoggingAdvisor + TokenUsageAdvisor
RAG 实战方面,从原理到代码完整走通了一遍:
- 两阶段流程:离线入库(切片 + Embedding + 存储)、在线问答(检索 + 注入 + 生成)
- 基于
SimpleVectorStore+QuestionAnswerAdvisor的内存向量库实战 - RAG 的局限性:切片质量、检索精度、多跳推理
这些内容构成了 Spring AI 应用从"能跑"到"能上线"的关键一步。
本系列后续内容 :下一篇将深入 Prompt 工程实战,覆盖多场景 Prompt 模板设计、Few-Shot 技巧、Prompt 模板外部化管理等内容,帮助你在 Spring AI 项目中写出更高质量的 Prompt。