如果你最近正好在 Spring Boot 项目里接 Spring AI,那你大概率已经尝过它的甜头了:一段 ChatClient 调通之后,自然语言问答、结构化输出、知识库问答都能塞进业务系统。我是在一个餐饮 SaaS 平台里做智能客服和菜品问答功能,前前后后折腾了一个多月,一共踩了 14 个坑。这里面最让我后怕的一个,不是报错百出,而是发版前自动化测试全绿,回归也跑完了,结果一上线产品就是坏的。客户问它“宫保鸡丁怎么做”,它一脸歉意回一句“抱歉,我暂时无法回答”。这篇内容不是把坑名罗列一下就完事,每个坑我都写了现象、根因和改法。尤其是最后一个测试全绿的坑,值得翻来覆去多看两遍。
1. Spring AI 项目背景与整体方案:餐饮 SaaS 的智能问答是怎么搭起来的
1.1 为什么选择 Spring AI,而不是自己拼一套 SDK
先说背景。我们是一个餐饮 SaaS 平台,客户是几百家餐厅和连锁品牌,原来系统里已经有菜单管理、订单、后厨库存这些模块。这次要做的功能很明确:让用户在小程序或者后台以自然语言提问,比如“今天推荐什么菜”“宫保鸡丁的做法”“哪些菜适合做成半份”,同时基于餐厅自己的知识库和菜品数据来回答。
当时团队里摆着两条路。一条是引入 Python 开发一个独立的 AI 服务,用 LangChain 或者 LlamaIndex 这类框架做 RAG;另一条是用 Spring AI,直接在 Java 服务里把对话和检索能力嵌进去。最后选了 Spring AI,理由很实际:我们的技术栈本来就是 Spring Boot,AI 能力以依赖的方式嵌进现有服务,不需要单独维护一套 Python 进程,也就不用处理两套服务之间的认证、容器部署、监控割裂这些问题。Spring AI 在 Spring 生态里把模型接入、提示词模板、结构化输出、向量检索这些环节都封装成了 AutoConfiguration,调试起来和写普通 Spring 接口差不多。
不过这里要提醒一句:Spring AI 的版本演进非常快,从 0.8.x 一路走到 1.0、2.0,API 变化幅度不小,网上很多教程用的写法可能来自旧版本。这个特性决定了踩坑概率比普通库高,后面几条坑里有好几个都和版本绑定相关。
1.2 整体链路长什么样
我们最终实现的链路是这样一条:先把餐厅上传的菜品文档和操作手册做解析,按固定块大小切成 chunk,通过 Embedding 模型转成向量,写入 PGVector 向量库。用户提问时,先把问题向量化,然后从向量库里相似度检索出候选文档片段,再把这些片段拼进系统提示词里,交给 ChatModel 生成最终回答。整体模块划分如下:
- 文档接入层:负责解析 PDF、Word、Excel 菜谱文件,做清洗和切分
- 存储层:PGVector 负责向量数据,PostgreSQL 本身继续存业务表
- 检索层:封装 VectorStore 的相似度搜索,加 metadata 过滤
- 生成层:ChatClient 负责对话,结构化输出和工具调用也在这层
- 网关层:流式响应对接小程序和后台
这套链路看着清晰,但正因为环节多,每个环节都可能有“看起来正常、实际已经坏了”的情况。尤其是测试环境和生产环境的数据量完全不同,某些参数在测试环境里永远测不出问题,这就是坑 13 的温床。
2. 前 7 个踩坑记录:版本、配置、流式与结构化输出
2.1 版本依赖的坑:自动配置不生效,Bean 找不到
第一个坑非常低频但很气人。项目原本的 Spring Boot 3.3 升到 3.4,然后引入 Spring AI 的某个 1.0.0 版本,启动时控制台没有任何报错,但注入 ChatModel 的地方直接抛NoSuchBeanDefinitionException。一开始我以为是包没引全,反复检查spring-ai-openai-spring-boot-starter明明在 pom 里。后来发现根子在于版本对齐问题,Spring AI 的各个子模块版本不一致,自动配置类被跳过。解决方案是引入spring-ai-bom,用 dependencyManagement 统一管理所有 Spring AI 相关包的版本,避免 starter 的传递依赖把版本带歪。
第二个版本相关的坑,是追新版本太激进。Spring AI 曾经有大量 milestone 和 RC 版本,拿正式业务去追新版本,三天两头遇到 API 签名变化。比如一个版本里ChatClient的 builder 方法名变了,编译时被 IDE 标红,还能立刻发现;最怕的是同一个方法名行为变了,比如原先返回String,新版返回Map,测试又没覆盖到,上线才知道坏了。我的建议是:生产项目锁定一个已经发布至少两个月的稳定版本,不要为了新功能去追技术热词。
2.2 模型接入配置的坑:BaseURL、API Key 和模型名
第三个坑很多人都遇到过,而且报错信息极其迷惑。我们一开始用 OpenAI 兼容的接口,application.yml 里配置了 API Key 和 BaseURL,但是启动后调用模型一直返回 401。查了半天发现配置里写的是spring.ai.openai.api-key,而实际版本要求的是spring.ai.openai.api-key加上spring.ai.openai.base-url,这俩都能认;问题出在base-url结尾多了一个斜杠,框架拼接请求路径时出现了双斜杠,服务端直接拒绝。这类配置错位在真实项目中特别常见,排查方法也不复杂:把配置项打印到启动日志里,肉眼检查一遍 URL 拼接。
第四个坑和 Ollama 本地模型有关。测试阶段为了省钱,我们用本地 Ollama 跑模型,配置了spring.ai.model.chat=ollama,结果注入ChatModel之后一调用就报“model not found”。原因是 Ollama 提供方除了要指定spring.ai.model.chat,还必须单独指定模型名spring.ai.ollama.chat.options.model,否则框架拿到的 model name 是空的。这个坑本身不大,但它提醒我:Spring AI 的多提供方机制里,每个模型提供方都有自己的隐藏依赖配置项,不要只看主开关。
2.3 流式输出卡住与结构化输出被 Markdown 包裹
第五个坑:用流式输出的时候,接口一直转圈,前端拿到的是一个空响应。Spring AI 的流式接口返回的是Flux,而我们在 Controller 里直接返回 Flux,理论上没问题,但项目里有一个全局 Filter 拦截了所有响应,把响应体整体读进了内存做日志记录。响应流被消费之后,前端自然什么也收不到。这是典型的 Servlet 过滤器和响应式流冲突。解决方式有两种:Filter 里排除/ai/stream这类流式路径,或者改用 WebFlux 场景下的专用处理方式。如果你在 Spring MVC 项目里集成流式 AI 响应,建议先把过滤器链和孩子跑一遍,再谈流式效果。
第六个坑:结构化输出。我们希望模型返回一个 JSON,包含菜品名称、配菜列表、热量估算,结果模型返回的时候在外面包了一层 Markdown 代码块:
{ "dishName": "宫保鸡丁", "ingredients": ["鸡腿肉", "花生米", "干辣椒"] }前端拿到的是```json ... ```,直接JSON.parse崩了。后来改用 Spring AI 的BeanOutputConverter,让模型从 schema 层面知道自己必须输出纯 JSON,这才稳定。这个坑在测试阶段其实也出现过,但因为测试断言只检查“返回字符串里包含菜品名”,所以一直没被发现,直到联调前端才暴露。
第七个坑:Function Calling 工具调用时,方法参数类型和模型传参对不上。我们给模型开放了一个查询菜品库存的工具,Java 方法签名里参数是Long skuId,但模型传回来的是字符串"4",框架在反序列化时某些版本会直接报类型转换错误。解决方法是把参数定义成String,在业务方法内部自己转成Long,并且给字段加@JsonProperty明确命名。顺带一提,工具调用的 JSON Schema 最好通过框架自动生成,不要手写 schema,手写一旦字段类型写错,报错信息会非常难查。
3. 最深的坑:测试全绿,产品却坏在 RAG 检索参数上
3.1 现象与第一波排查
发版前,我们的自动化测试跑了一遍,全部通过,测试覆盖率报告也好看,回归用例一条没挂。但上线后,客服那边陆续反馈:用户问“这道菜辣不辣”“有没有适合小孩的菜”,AI 的回答全是“我暂时无法回答”。我当时第一反应是模型 API 是不是挂了,结果查看日志,模型调用都正常,200 响应,token 也没超。又怀疑是向量库初始化失败,打开 PGVector 一查,数据都在,chunk 有几万条。
真正让我毛骨悚然的是,我在测试环境里用同样的问法试了一遍,AI 回答正常,而且回答内容里有料;生产环境同样的问题,向量检索结果却是空的。两边代码完全一样,模型也一样,唯一不同的就是向量库里的数据规模。那一刻我意识到:测试全绿不代表产品没坏,而是测试环境根本没有模拟出生产环境的数据特征。
3.2 根因拆解:topK、相似度阈值和测试样本的三重共谋
这个坑最终的根因可以拆成三个因素叠加。
第一个因素是topK设置太小。我们当时检索配置写死了 topK=4,也就是向量检索只返回最相似的 4 个 chunk。测试环境的知识库只有 36 条 chunk,用户随便一个问题,正确答案往往就是最相似的前 1 名,topK=4 绰绰有余。但生产环境有 2 万多条 chunk,同一个“宫保鸡丁怎么做”的问题,正确答案片段和用户查询的 embedding 距离在 0.58 左右,而其他菜谱里也有不少相似度在 0.5x 的干扰片段,正确答案被挤到了第 6 名、第 7 名,直接被 topK=4 截掉了。
第二个因素是相似度阈值一刀切。我们又在检索后面加了similarityThreshold=0.75的过滤条件,本意是过滤掉不相关的片段。测试环境里答案和查询的距离在 0.2~0.3,相似度换算成阈值没问题;但生产环境里真实文档的语义距离普遍没有测试环境那么理想,正确答案的相似度只有 0.62,被阈值一刀切地滤掉了。检索结果为空,AI 拿不到任何上下文,只能回复“我暂时无法回答”。
第三个因素是测试断言写得太宽松。我们当时的集成测试只检查“响应非空”和“响应包含菜品名”。只要模型生成了一段包含菜品名的套话,测试就过了,完全不校验“回答内容是否来自检索到的上下文”。这就导致一个非常尴尬的情况:即使检索链路整个坏了,模型只要凭常识说一句“我可以帮你查询更多菜品信息”,测试照样绿。这三个因素叠加在一起,就是我标题里说的“测试全绿但产品是坏的”。
3.3 修复方案:参数阶段化和测试样本真实化
修复这个坑分两层。第一层是检索参数调整。我们不再用一个全局阈值通吃所有场景,而是把链路拆成两段:初检阶段用 topK=20 召回足够多的候选,精排阶段再用模型或者规则做一次重排,只把质量最高的 3~5 段放进提示词。相似度阈值改成可配置项,并且按知识库规模动态调整:小知识库阈值可以高,大知识库阈值必须放低,否则召回率会崩。
第二层是测试样本重建。我们把测试夹具里“用答案原文当查询”的坏习惯删掉,改成收集真实用户提问,按领域分类,每类至少准备 5 个自然语言变体,比如“这个菜辣不辣”“有没有辣味的菜”“孩子能吃吗”。测试断言也升级了:不仅要检查响应非空,还要检查响应里是否出现了预期知识库片段中的关键实体,同时要求检索结果不为空。对于“无检索结果”的场景,专门写一条失败用例,确保系统不会在空上下文下硬编答案。
这里还要多说一句,我们后来加了一条兜底:如果 VectorStore 检索结果为空或者精排后没有合格片段,AI 必须明确回答“根据店内菜单暂时没有找到相关信息”,并且附带一个转人工提示,绝不能编造菜谱。这条规则我建议所有做 RAG 的项目都抄走,幻觉比答非所问可怕得多。
3.4 我给团队定下的检索链路红线
这个坑之后,我在团队里立了几条死规矩,写进代码评审清单里。第一,任何 RAG 接口都必须提供检索追踪信息,至少要把召回 chunk 的文档 ID 和相似度分数带在响应体里,哪怕前端不展示。这样出了问题,看日志就能立刻判断是检索问题还是生成问题。第二,测试环境必须有一个“最小真实库”,不是造 20 条手工数据就完了,要从生产库抽样一部分真实文档去做脱敏切片,保证数据分布接近真实情况。第三,自动化测试里必须包含“干扰项测试”:比如在正确文档之外,故意塞入几篇主题相近但内容完全不对的文档,看检索链路能不能把真正相关的文档捞出来。如果测试数据全都是“指哪打哪”,那这套测试的防御能力基本为零。
4. 剩余七坑速查与自动化测试重做方案
4.1 剩余七个坑速查表
为了不占太多篇幅,剩下的坑我用表格列出来,每一条都值得对号入座。
| 坑位 | 现象 | 根因 | 解法 |
|---|---|---|---|
| 坑 8 | 本地用 SimpleVectorStore 一切正常,切到 PGVectorStore 后结果不对 | 两套存储的 embedding 维度和索引构建方式不同,测试环境与生产环境漂移 | 测试和生产使用同一种 VectorStore,不允许环境差异 |
| 坑 9 | 换了 embedding 模型后,已有向量库查不到数据 | 不同模型输出的向量维度不一致,或者模型字典不一致 | 换模型必须重建索引,并做向量维度校验 |
| 坑 10 | metadata 过滤条件时灵时不灵 | 数字字段传成了字符串,PGVector 做了隐式转换,部分版本过滤失效 | 显式声明 metadata 字段类型,过滤参数与存储类型严格一致 |
| 坑 11 | 模型接口偶发超时,系统自动重试导致重复扣费 | 默认重试没有上限,单位时间重试次数过高 | 配置最大重试次数和退避策略,超预算熔断 |
| 坑 12 | 高并发下接口整体卡住 | 嵌入计算和模型调用占满应用线程,Tomcat 线程池被打爆 | 把 AI 调用放到独立线程池,或者启用虚拟线程并设置超时 |
| 坑 13 | 文档切分不合理,回答总是丢失一半信息 | chunk_size 太大或 overlap 太小,关键上下文被切断 | 按段落语义切分,chunk_size 控制在 500~800,overlap 留 50 |
| 坑 14 | 用答案原文当查询,测试全部命中 | 测试样本与真实用户 query 分布不一致 | 用真实用户提问语料建设测试集,禁止用原文片段充当 query |
坑 8 和坑 9 特别值得强调。SimpleVectorStore 是内存实现,重启就没了,它最大的价值是本地联调,不是测试环境的模拟。如果你测试环境用内存向量库、生产环境用 PGVector,那你测的就是两套系统。我们后来统一在测试环境也起一个独立的 PGVector 实例,发现问题的时间至少提前了一个量级。
4.2 测试策略重做:从“响应非空”走向“断言有效”
经过这次事件,我们彻底重做了 AI 相关功能的自动化测试。原来的测试金字塔里,AI 集成测试放在最上层,数量少且断言弱;现在我们把测试拆成了三层。
第一层是纯单测,mock ChatModel 和 VectorStore,只测提示词模板、参数组装、业务分支。这一层跑得快,可以覆盖大部分逻辑错误。第二层是集成测试,用真实的 embedding 模型和真实的 PGVector,配上“最小真实库”,重点验证检索召回和提示词拼装的正确性。这一层断言不能只看响应字段,而是要检查:Top 返回的检索结果里是否包含预期文档;如果检索结果为空,系统是否走了兜底分支;生成结果中是否包含上下文里的关键实体。
第三层是端到端抽样,只保留 10 个核心业务场景,比如菜品推荐、过敏原查询、热量估算。这 10 个场景必须在真实模型调用下跑通。问“什么是过敏原”这类问题,断言里必须出现具体的过敏原清单,而不是一句“请咨询店员”。这种断言方式比“响应非空”严格得多,虽然一开始会有几条用例因为提示词不稳定而挂掉,但改完提示词之后,整体稳定性明显提升。
这里有个经验:AI 生成的文本天然有随机性,测试断言不要过度依赖精确字符串匹配。更好的做法是“实体召回率”断言,比如“回答里必须包含花生、鸡肉、辣椒三个实体”,只要核心信息都在,就算模型换了表达方式也能稳定通过。这套思路用在我们的测试里之后,再也没有出现“测试全绿但产品是坏的”的情况。
4.3 调试 RAG 链路时我最常用的几个技巧
最后分享几个调试技巧,都是我撞了南墙之后沉淀下来的。第一个技巧是可以把 ChatModel 临时替换成一个 echo 模型:不真实调用大模型,而是把拼接好的 Prompt 原样返回。这样你就能看到向量检索之后,系统到底把哪些片段喂给了模型,是不是漏了关键文档,是不是提示词顺序不对。这一步能把“模型回答不对”和“检索内容不对”两个问题彻底分开。
第二个技巧是给 VectorStore 的查询参数打日志。在测试用例里打印 topK、阈值、返回条数和相似度分数,连续跑几次就能看到参数变化对召回率的影响。我们最终把阈值从 0.75 调到 0.6,就是靠日志里的分数分布做的决定,而不是拍脑袋。
第三个技巧是每次修改向量检索逻辑之后,跑一遍专门构造的“干扰项回归”。准备 10 道问题,每道问题在知识库里放一篇正确答案和两篇干扰文档,正确答案和干扰文档的主题必须相近。比如问题是“番茄炒蛋要放糖吗”,正确答案来自一个写“番茄炒蛋做法”的文档,干扰项来自一个写“番茄蛋汤做法”的文档。如果检索链路把番茄蛋汤捞进来而漏掉番茄炒蛋,那说明切分或者排序有问题。这种测试对 RAG 项目来说比任何覆盖率数字都有价值。
我个人在实际操作里最大的感受是:AI 项目的测试,难点不在于让用例跑起来,而在于让用例具备“区分好坏模型/坏检索”的能力。如果你写的测试无论系统多烂都能通过,那这个测试本质上没有保护价值。Spring AI 本身不复杂,复杂的是你围绕它建立的工程保障体系。希望这 14 个坑能帮你少走几段弯路,尤其是最后一个,千万别再犯。