
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~2000.04 元/千0.008 元对话历史最近 5 轮~15000.04 元/千0.06 元RAG 检索注入文档片段~15000.04 元/千0.06 元模型输出~5000.12 元/千0.06 元单次合计~3700~0.188 元日均 10 万次调用一天就是1.88 万元。一个月下来这笔钱足够让老板找你谈话了。2.2 四个控制手段1. 精简 System PromptSystem Prompt 每次请求都会带上长度乘以日请求量就是固定成本。1000 Token 的 System Prompt10 万次/天就是 1 亿 Token。去掉废话、用最精炼的语言描述角色和规则。2. 限制对话历史轮数不要无限保留历史消息用MessageWindowChatMemory控制只保留最近 N 条MessageWindowChatMemory chatMemory MessageWindowChatMemory.builder() .maxMessages(10) // 只保留最近 10 条消息 .build();3. 控制输出长度输出 Token 的单价通常是输入的 2~3 倍。对于情感分析、分类判断这类场景用结构化输出约束模型只返回必要字段比如用数字编码代替自然语言描述规则1积极2中立3消极。只返回数字不要解释。4. Token 用量监控在代码层面拿到每次调用的 Token 消耗做持续监控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 统计按用户维度累计消耗Component public class TokenUsageAdvisor implements CallAdvisor { private final ConcurrentHashMapString, 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 错误做法# application.yml —— 千万别这么干 spring: ai: openai: api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxx配置文件跟着代码提交到 Git 仓库公开仓库直接裸奔私有仓库也只是多了一层权限控制。3.2 正确做法方案一环境变量spring: ai: openai: api-key: ${AI_API_KEY}本地开发时设置环境变量CI/CD 环境通过 Secret 管理注入。方案二本地开发用 .env 文件 .gitignore创建application-local.yml写入实际 Key然后在.gitignore中排除application-local.yml启动时指定--spring.profiles.activelocal即可。方案三配置中心生产环境使用 Nacos、Apollo 等配置中心统一管理Key 不落地到代码仓库。四、陷阱三并发打爆模型 API大模型 API 都有速率限制——每分钟多少次请求、每小时多少 Token 额度。超限后要么排队等待要么直接报 429 错误。如果不在自己的接口层做限流等于把流量控制完全交给了上游一旦触发限流所有用户都会受影响。4.1 Guava RateLimiter 方案在 Advisor 层用令牌桶算法做用户级限流Component public class RateLimitAdvisor implements CallAdvisor { private static final double PERMITS_PER_SECOND 2.0; // 每用户每秒最多 2 次 private final MapString, 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 层传入 userIdreturn chatClient.prompt() .user(message) .advisors(a - a.param(userId, userId)) .call() .content();4.2 为什么在 Advisor 层做把限流逻辑放在 Advisor 中而不是 Controller 中有两个好处全局生效所有经过该 ChatClient 的调用都会被限流不需要在每个 Controller 里重复写与业务解耦限流策略的调整改 QPS、改限流维度不需要动业务代码需要引入 Guava 依赖dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version33.2.1-jre/version /dependency五、陷阱四响应内容不稳定大模型的输出是概率性的。即使你要求 JSON 格式输出也可能在极小概率下返回不符合格式的内容——多一个换行、少一个引号、输出带 Markdown 代码块标记。5.1 兜底策略// 用 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 自定义 LoggingAdvisorComponent 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 生产级踩坑清单把上面五个陷阱汇总成一张检查表上线前逐项过一遍序号风险点后果解决方案1Token 成本不受控月底账单爆炸精简 Prompt 限制历史 用量监控2API Key 硬编码泄露导致资金损失环境变量 / 配置中心3不做限流触发上游速率限制全体用户受影响Guava RateLimiter Advisor4不处理输出异常极小概率格式错误导致前端崩溃try-catch 兜底 重试 压测5无日志监控出问题无法排查LoggingAdvisor TokenUsageAdvisor6对话历史无限增长费用线性增长响应变慢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 引入依赖使用内存向量库只需要加一个依赖!-- Spring AI 内存向量库 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-vector-store-simple/artifactId /dependency版本由spring-ai-bom统一管理不需要单独指定 version。8.3 完整代码实战下面用一个完整的例子演示初始化内存向量库、写入文档、基于用户提问做 RAG 检索问答。Step 1初始化向量库并写入文档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 加载 ListDocument 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构建 QuestionAnswerAdvisorprivate 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问答接口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 限制历史 用量监控 AdvisorKey 泄露→ 环境变量 / .gitignore / 配置中心并发限流→ Guava RateLimiter Advisor 层拦截输出不稳定→ try-catch 兜底 结构化输出 批量压测日志监控→ LoggingAdvisor TokenUsageAdvisorRAG 实战方面从原理到代码完整走通了一遍两阶段流程离线入库切片 Embedding 存储、在线问答检索 注入 生成基于SimpleVectorStoreQuestionAnswerAdvisor的内存向量库实战RAG 的局限性切片质量、检索精度、多跳推理这些内容构成了 Spring AI 应用从能跑到能上线的关键一步。本系列后续内容下一篇将深入Prompt 工程实战覆盖多场景 Prompt 模板设计、Few-Shot 技巧、Prompt 模板外部化管理等内容帮助你在 Spring AI 项目中写出更高质量的 Prompt。