Spring AI 生产避坑指南与 RAG 内存向量库实战

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 中,有两个好处:

  1. 全局生效:所有经过该 ChatClient 的调用都会被限流,不需要在每个 Controller 里重复写
  2. 与业务解耦:限流策略的调整(改 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 结构化输出 + 重试

对于需要结构化返回的场景,建议:

  1. 使用 Spring AI 的 BeanOutputConverter 做自动解析
  2. 解析失败时进行有限次重试(2~3 次)
  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

大模型有三个天然短板:

  1. 知识截止日期:训练数据有时间边界,2024 年之后发生的事它不知道
  2. 不了解私有数据:公司文档、退货政策、内部规范,模型一概不知
  3. 上下文窗口有限:不可能把所有资料塞进 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 在幕后完成了这些事:

  1. 将"退货需要什么条件"通过 EmbeddingModel 转成向量

  2. SimpleVectorStore 中做余弦相似度检索

  3. "退换货政策"那段文档的相似度最高,被检索出来

  4. 将检索到的文档片段拼接到用户 Prompt 中,形成类似这样的最终 Prompt:

    【上下文信息】
    公司退换货政策:购买后 7 天内可无理由退货,商品需保持原包装完好...

    【用户问题】
    退货需要什么条件

  5. 把这个增强后的 Prompt 发给大模型,模型基于上下文生成回答

整个过程对调用方透明,只需要挂一个 Advisor 就搞定了。


九、RAG 的局限性

RAG 虽然强大,但不是银弹。了解它的边界,才能在实际项目中做出正确的架构决策。

9.1 检索质量决定回答质量

如果向量检索没有找到相关文档,或者找到的文档不够准确,模型就没有正确的上下文可用。这时候它要么说"我不知道",要么开始编造------后者更危险。

9.2 切片策略影响效果

文档切片是 RAG 流水线中最"手艺活"的环节:

  • 切太短:语义不完整,检索到了也看不懂
  • 切太长:一段话混入多个主题,相似度计算不准
  • 切片重叠:相邻片段有部分重叠内容,可以缓解语义断裂问题

Spring AI 提供了 TokenTextSplitter 等切片工具,支持按 Token 数切分并设置重叠量。实际项目中需要根据文档特点反复调优。

9.3 多跳推理能力有限

如果一个问题需要关联多段文档才能回答(比如"A 政策中提到的 B 规则具体是什么"),单纯的 Top-K 检索可能只找到其中一段。这种场景需要更复杂的 Agent 架构来解决,后续文章会专门讨论。

9.4 Embedding 模型的影响

不同的 Embedding 模型,向量化的质量差异很大。选型时需要关注:

  • 是否支持中文语义
  • 向量维度与检索性能的权衡
  • 模型本身的调用成本

十、总结

本文覆盖了两个核心主题:

生产避坑方面,五个高频陷阱和应对方案:

  1. Token 成本 → 精简 Prompt + 限制历史 + 用量监控 Advisor
  2. Key 泄露 → 环境变量 / .gitignore / 配置中心
  3. 并发限流 → Guava RateLimiter + Advisor 层拦截
  4. 输出不稳定 → try-catch 兜底 + 结构化输出 + 批量压测
  5. 日志监控 → LoggingAdvisor + TokenUsageAdvisor

RAG 实战方面,从原理到代码完整走通了一遍:

  • 两阶段流程:离线入库(切片 + Embedding + 存储)、在线问答(检索 + 注入 + 生成)
  • 基于 SimpleVectorStore + QuestionAnswerAdvisor 的内存向量库实战
  • RAG 的局限性:切片质量、检索精度、多跳推理

这些内容构成了 Spring AI 应用从"能跑"到"能上线"的关键一步。


本系列后续内容 :下一篇将深入 Prompt 工程实战,覆盖多场景 Prompt 模板设计、Few-Shot 技巧、Prompt 模板外部化管理等内容,帮助你在 Spring AI 项目中写出更高质量的 Prompt。

相关推荐
最初的↘那颗心3 小时前
Spring AI Alibaba 多模态全家桶:图片理解、图片生成与语音合成实战
spring boot·大模型·多模态·通义千问·spring ai
CoderJia程序员甲4 小时前
GitHub 热榜项目 - 日榜(2026-03-16)
人工智能·ai·大模型·github·ai教程
Bruce_Liuxiaowei6 小时前
深入浅出:清理 OpenClaw 会话记录的完整操作解析
人工智能·大模型·智能体·openclaw
爱打代码的小林7 小时前
基于 LangChain + 通义千问 + FAISS 构建 RAG 问答系统
langchain·大模型·faiss·rag
小王努力学编程8 小时前
LangGraph——AI应用开发框架
服务器·人工智能·python·ai·langchain·rag·langgraph
大傻^8 小时前
【Spring AI -01】Spring AI 2.0 架构重构深度解析:从单体核心到模块化领域的演进
人工智能·spring·架构·spring ai·模块化设计·java 21·架构迁移
灵机一物9 小时前
灵机一物AI智能电商小程序(已上线)-从“帮我买抽纸”到自动下单支付——大模型驱动全链路自动购物系统实战
人工智能·python·elasticsearch·小程序·fastapi·rag·langgraph
gujunge18 小时前
Spring with AI (2): 评估答案——UnitTest引入
ai·大模型·llm·openai·qwen·rag·spring ai·deepseek
xier_ran1 天前
【第二周】 RAG与Agent实战16:ChatPromptTemplate的使用 —— 打造有记忆的AI
人工智能·langchain·rag