
1. 从一次线上故障说起为什么我要重新审视 Spring AI去年底我们团队把一个内部知识问答系统从手写 HTTP 调模型的方案迁移到了 Spring AI。迁移之前那套代码里散落着各种RestTemplate拼 JSON 的逻辑换一个模型厂商就要改一遍请求体结构流式输出还得自己处理 SSE 分帧维护起来非常痛苦。迁移之后代码量砍掉了将近六成但上线第二周就出了个不大不小的事故某个接口在并发上来之后响应时间从 800ms 飙到 6s日志里全是连接池等待。排查下来发现是ChatClient的默认配置在高并发下没有复用底层连接加上我们没做超时控制请求堆积把线程池占满了。这件事让我意识到Spring AI 这类框架把调用大模型这件事的门槛降得很低但降低门槛不等于降低复杂度。它把协议适配、消息抽象、流式处理这些脏活封装掉了可一旦你要把它放进真实的生产环境还是得搞清楚它内部到底做了什么、哪些参数会影响性能、哪些默认值需要改。这篇内容就是把我这段时间从踩坑到跑通的经验整理出来围绕 Spring AI 的核心升级点、对话机器人的两种输出模式、RAG 知识库落地、以及智能体工具调用这几块讲清楚为什么这么设计和实际怎么用。适合谁看如果你有 Java 和 Spring Boot 基础想用 Spring AI 快速搭一个能对话、能查知识库、能调工具的应用那这篇基本能覆盖你从零到跑通的全过程。如果你已经在用但被流式输出、RAG 召回率、工具调用这些问题卡住里面关于参数取舍和排查思路的部分应该对你有用。全文基于 Spring Boot 3.x 和 Spring AI 的稳定版本展开涉及具体配置的地方我会说明版本差异。2. Spring AI 到底抽象了什么核心升级的底层逻辑2.1 从拼请求体到对话模型抽象的转变在 Spring AI 出现之前Java 生态里调大模型的主流做法无非两种一是直接用各家厂商的 HTTP 接口自己拼 JSON二是用 LangChain4j 这类偏 Python 思路移植过来的库。第一种做法的痛点在于每个厂商的请求结构、鉴权方式、流式协议都不一样OpenAI 用messages数组有些厂商用prompt字符串切换成本极高。第二种做法功能全但 API 设计偏脚本化和 Spring 的依赖注入、配置管理风格不太搭。Spring AI 的核心思路是引入一层统一的对话模型抽象。它定义了ChatModel和StreamingChatModel两个顶层接口前者负责同步返回后者负责流式返回。所有厂商的实现都归到这两个接口下面比如对接 OpenAI 协议的是OpenAiChatModel对接本地 Ollama 的是OllamaChatModel。这样一来你的业务代码只依赖接口换模型只需要换配置和依赖代码基本不动。这个抽象里最关键的是Prompt和Message这两个概念。Message分四种角色SystemMessage系统指令、UserMessage用户输入、AssistantMessage模型回复、ToolResponseMessage工具调用结果。Prompt则是这些消息的集合外加一组ChatOptions。为什么要这么设计因为现在主流模型的对话接口本质上都是消息列表进、消息出把这层结构显式建模出来多轮对话、上下文拼接、工具调用结果回填这些操作就有了统一的载体不用再手动维护一个ListMapString, String。2.2 ChatClient 的链式 API 与 Advisor 机制如果说ChatModel是底层引擎那ChatClient就是给业务用的方向盘。它提供了一套流式fluentAPI写起来大概是这样String answer chatClient.prompt() .system(你是一个严谨的技术助手) .user(解释一下什么是 RAG) .call() .content();这种写法比直接操作ChatModel舒服很多但真正有价值的是它背后的Advisor 机制。Advisor 可以理解成一条拦截器链请求发出前和响应返回后都能插入处理逻辑。Spring AI 内置了几个常用的 Advisor比如MessageChatMemoryAdvisor负责把历史对话自动拼进上下文QuestionAnswerAdvisor负责在请求前做向量检索并把检索结果塞进 prompt。这个设计的巧妙之处在于它把对话记忆和知识库检索这两个 RAG 场景里最常见的需求从业务代码里剥离出来变成了可插拔的组件。你不需要在每次调用前手动查数据库、拼上下文只要在构建ChatClient的时候挂上对应的 Advisor 就行。我实测下来这种写法在维护多轮对话时特别省心因为记忆的存取逻辑被统一收口了不会出现这个接口记得历史、那个接口忘了的情况。2.3 结构化输出让模型返回能直接用的对象早期用大模型最烦的一件事就是模型返回的是一段自然语言你还得写正则去解析。Spring AI 的结构化输出能力解决了这个问题。它通过StructuredOutputConverter接口把模型的文本输出转换成 Java 对象。最常用的是BeanOutputConverter你给它一个类它会自动生成对应的 JSON Schema 塞进 prompt引导模型按格式输出拿到结果后再反序列化成对象。record Recipe(String name, ListString ingredients, int minutes) {} Recipe recipe chatClient.prompt() .user(给我一个番茄炒蛋的菜谱) .call() .entity(Recipe.class);这里有个经验结构化输出的稳定性高度依赖模型能力。小参数量的本地模型经常不按 Schema 来会多输出解释性文字导致反序列化失败。我的做法是在 prompt 里明确要求只输出 JSON不要任何额外说明同时在代码里加一层容错解析失败时重试一次。另外字段类型尽量用包装类型和List避免用复杂的嵌套泛型能显著降低解析失败率。3. 对话机器人的两种输出模式同步与流式的取舍3.1 同步调用适合什么场景同步调用就是.call()它会等模型把整段回复生成完一次性返回。这种模式实现简单适合后台任务、批处理、以及那些对首字延迟不敏感的场景。比如你要批量给一批文档生成摘要或者做离线的内容分类用同步调用最省事。但同步调用有个绕不开的问题大模型的生成是逐 token 的整段生成完可能要十几秒甚至更久。如果前端是网页用户点了发送之后要盯着空白屏幕等十几秒体验非常差。而且同步调用会一直占着 HTTP 连接并发一高Tomcat 的线程池很快就被打满。我们那次线上故障本质上就是同步调用 没设超时 连接不复用三个问题叠加的结果。同步调用的配置里有几个参数必须显式设置。超时时间建议按业务场景定一般对话类设 30 到 60 秒长文本生成设 120 秒以上。重试次数不要设太多模型调用失败往往是上游限流或网络问题重试两三次就够了重试太多反而会放大上游压力。这些参数在ChatOptions或者底层 HTTP 客户端里配置具体位置随版本略有差异配置前建议先看一眼对应版本的文档。3.2 流式输出的实现要点流式输出用.stream()返回的是一个FluxString响应式流。它的价值在于首字延迟模型生成第一个 token 就立刻推给前端用户马上能看到内容在打字感知上的等待时间大幅缩短。FluxString stream chatClient.prompt() .user(写一段关于 Spring AI 的介绍) .stream() .content(); stream.subscribe(chunk - { // 推送到 SSE 或 WebSocket });这里有几个实操细节值得说。第一流式接口最好用 SSEServer-Sent Events或者 WebSocket 推给前端SSE 更简单单向推送够用WebSocket 适合需要双向交互的场景。第二流式响应一定要处理异常和完成信号否则模型中途出错或者连接断开前端会一直转圈。第三流式场景下不要做复杂的后处理比如在subscribe里做数据库写入因为每个 token 都会触发一次性能会崩。正确做法是先把完整内容拼起来在onComplete回调里统一处理。还有一个容易被忽略的点流式输出和对话记忆的配合。如果你挂了记忆 Advisor流式返回的内容需要完整拼回历史记录否则下一轮对话模型就失忆了。Spring AI 的 Advisor 在流式场景下会自动处理这件事但如果你自己手写记忆逻辑一定要记得在流结束时把完整回复存进去。3.3 两种模式混用的工程实践真实项目里同步和流式往往是混用的。我的做法是面向用户的对话界面用流式后台的批处理和工具调用用同步。比如用户问一个问题前端走流式展示但如果这个问题触发了工具调用比如查数据库工具调用本身是同步的等工具返回结果后再把最终答案流式推给用户。这种混用对代码组织有要求。我一般会把ChatClient的构建逻辑抽成一个配置类同步和流式各建一个实例共享同一套 Advisor 配置。这样既保证了行为一致又避免了每次调用都重新构建客户端。另外流式接口的线程模型和同步不一样响应式流默认跑在 Reactor 的调度器上如果你在流里调用了阻塞的 JDBC 操作一定要切到弹性线程池否则会阻塞事件循环这个坑我在早期踩过表现是并发一上来整个服务就卡死。4. RAG 知识库落地从向量检索到召回率优化4.1 RAG 的基本链路与 Spring AI 的对应组件RAG检索增强生成的核心思路是用户提问时先从知识库里检索出相关内容把这些内容作为上下文一起喂给模型让模型基于这些内容回答而不是靠它自己的记忆。这样做的好处是回答有据可依能大幅降低胡编乱造的概率而且知识更新只需要更新知识库不用重新训练模型。一条完整的 RAG 链路包含几个环节文档读取、文本切分、向量化、存储、检索、重排、拼装 prompt。Spring AI 对每个环节都提供了抽象。文档读取用DocumentReader文本切分用TextSplitter向量化用EmbeddingModel存储用VectorStore检索和拼装则通过QuestionAnswerAdvisor串起来。这里最关键的两个组件是EmbeddingModel和VectorStore。EmbeddingModel负责把文本转成向量它的质量直接决定检索效果。VectorStore负责向量的存储和相似度检索Spring AI 支持多种实现包括内存版、PGVector、Redis、Milvus 等。选哪个取决于你的数据量和部署条件小规模验证用内存版就够生产环境建议用 PGVector 或专门的向量数据库。4.2 文本切分最容易被低估的环节很多人做 RAG 效果不好第一反应是换更好的模型但实际问题往往出在文本切分上。切分粒度太粗检索出来的内容包含大量无关信息会稀释关键信息切分太细又会丢失上下文模型拿到的是碎片理解不了完整语义。我的经验是切分要结合文档结构来定。对于技术文档按标题层级切分效果最好每个小节作为一个 chunk保留标题作为元数据。对于没有明显结构的纯文本用固定长度加重叠的方式长度一般设 500 到 1000 个字符重叠 100 到 200 个字符。重叠的作用是防止关键信息正好被切在边界上导致两边都不完整。Spring AI 的TokenTextSplitter是按 token 数切分的比按字符数更贴近模型的实际处理单位。但要注意不同模型的 tokenizer 不一样中文场景下按 token 切分和按字符切分的差异会比较明显。我一般会先用小批量数据试切看看切出来的 chunk 长度分布是否合理再决定参数。4.3 召回率优化的几个实用手段RAG 效果差八成是召回环节出了问题。所谓召回率就是该被检索出来的内容实际被检索出来的比例。优化召回率我总结了几个实际有效的手段。第一是调整相似度阈值和返回条数。默认配置往往返回固定条数但有些问题可能只有一条相关内容硬凑五条反而引入噪声。我的做法是设一个相似度阈值低于阈值的直接丢弃同时限制最大返回条数。阈值设多少需要根据你的 embedding 模型和数据实测一般从 0.7 左右开始调。第二是混合检索。纯向量检索对语义相似但用词不同的内容效果好但对精确匹配比如产品型号、专有名词反而不如关键词检索。把向量检索和关键词检索的结果做融合能明显提升召回。Spring AI 本身对混合检索的支持还在演进实践中可以自己实现一个简单的融合逻辑把两路结果按权重合并。第三是重排。检索出来的候选内容用一个更精细的模型重新排序把最相关的排到前面。重排模型比 embedding 模型更重但只对少量候选做处理成本可控。这一步对最终效果提升很明显尤其是候选内容较多的时候。第四是查询改写。用户的问题往往口语化、信息不全直接拿去检索效果不好。可以先用模型把问题改写成更适合检索的形式或者生成多个相关查询分别检索再合并。这个手段在复杂问题上效果显著但会增加一次模型调用要权衡延迟。优化手段主要解决的问题成本适用场景调整阈值和条数噪声过多或召回不足低所有场景优先做混合检索专有名词、精确匹配召回差中含大量术语的知识库重排相关内容排名靠后中高候选多、精度要求高查询改写问题口语化、信息不全中面向普通用户的问答4.4 知识库更新的工程细节知识库不是建一次就完事文档会更新、会新增。这里有个坑向量库里的旧数据不会自动失效。如果一份文档更新了你重新切分入库旧版本的 chunk 还在库里检索时可能同时召回新旧两个版本模型就会拿到矛盾的信息。我的做法是给每个 chunk 打上文档 ID 和版本号更新时先按文档 ID 删除旧 chunk再插入新的。Spring AI 的VectorStore提供了按条件删除的接口用起来还算方便。另外删除和插入最好放在一个事务性的流程里避免删了没插进去导致数据丢失。如果向量库不支持事务至少要保证删除操作可回滚或者用先插新、再删旧的顺序牺牲一点存储空间换取安全性。5. 智能体与工具调用让模型能动手做事5.1 工具调用的本质与 Tool 注解大模型本身只能生成文本它没法查数据库、没法调接口、没法读文件。所谓智能体Agent本质上是让模型在需要的时候请求调用某个工具由外部代码执行工具再把结果回填给模型模型基于结果继续推理。这个循环可以重复多轮直到模型认为不需要再调工具给出最终答案。Spring AI 里定义工具非常直观用一个Tool注解标注方法就行Component public class WeatherTools { Tool(name getWeather, description 查询指定城市的当前天气) public String getWeather(ToolParam(description 城市名称) String city) { // 实际查询逻辑 return 晴25 度; } }这里Tool的name和description非常关键。模型是靠 description 来判断该不该调这个工具的描述写得含糊模型就可能该调的时候不调或者不该调的时候乱调。我的经验是description 要写清楚这个工具做什么、什么情况下用、参数是什么含义宁可啰嗦一点也不要图省事写一句话。name则要保证唯一且语义清晰多个工具名字相近会让模型混淆。5.2 工具调用的完整流程与常见问题一次完整的工具调用大概是这样用户提问 → 模型判断需要调工具 → 返回工具调用请求包含工具名和参数→ 框架执行对应方法 → 把结果作为ToolResponseMessage回填 → 模型基于结果生成最终回答。Spring AI 把这套流程封装在ChatClient里你只要把工具注册进去剩下的自动完成。但实际用起来有几个常见问题。第一是参数解析失败模型给出的参数格式和方法的参数类型对不上比如方法要int模型给了25字符串。解决办法是参数类型尽量用String在方法内部自己转换容错性更好。第二是工具调用死循环模型反复调同一个工具或者两个工具互相调。这通常是因为工具返回的结果没有让模型满意模型以为没调成功。解决办法是在工具返回里明确说明状态比如查询成功结果是……让模型知道可以继续了。同时要设一个最大调用轮数防止无限循环。第三是工具执行超时。工具方法里如果有网络请求或数据库查询一定要设超时否则模型那边等着整个请求就卡住了。我一般会给工具方法加一个统一的超时包装超时后返回一个明确的错误信息给模型让模型基于错误信息决定是重试还是换个思路。5.3 多工具协作与智能体的边界当工具数量多起来之后怎么组织就成了问题。我的建议是按领域分组比如天气相关的工具放一个类订单相关的放另一个类每个类里的工具数量控制在十个以内。工具太多会让模型的选择难度上升准确率下降。如果确实需要很多工具可以考虑分层先让模型选领域再在领域内选具体工具。关于智能体的边界我想强调一点不是所有任务都适合做成智能体。智能体的优势在于处理需要多步推理、动态决策的任务比如帮我查一下最近的订单如果超过三天没发货就催一下。但如果任务流程是固定的比如根据用户 ID 查订单状态那直接写个接口调用就行套一层智能体反而增加了不确定性和延迟。判断标准很简单如果这个任务的执行路径需要根据中间结果动态变化那适合智能体如果路径固定那就用普通代码。6. 工程化落地中的性能与稳定性问题6.1 连接管理与超时控制回到开头那个故障根因就是连接管理没做好。大模型调用本质上是 HTTP 请求底层连接池的配置直接影响并发能力。默认配置往往偏保守高并发下会出现连接等待。我的做法是显式配置连接池的最大连接数、每路由最大连接数、连接超时和读取超时。最大连接数要根据你的并发量和模型响应时间来估算粗略的公式是并发数 × 平均响应时间 ÷ 单连接可复用次数。实际配置时留一定余量但也不要设太大否则可能把上游打挂。超时控制要分层次。连接超时设短一点比如 5 秒连不上就快速失败。读取超时设长一点因为模型生成确实慢但也不能无限等一般 60 到 120 秒。另外流式请求的超时要单独考虑因为流式响应是持续返回的读取超时应该按两次数据之间的间隔来算而不是整个响应时间。6.2 限流、降级与成本控制大模型调用是有成本的而且上游通常有限流。生产环境必须做限流防止突发流量把配额打满。限流可以按用户、按接口、按全局三个维度做。按用户限流防止单个用户刷爆按接口限流保护关键业务按全局限流兜底。降级策略也要提前设计。当模型调用失败或者超时时是返回缓存结果、返回兜底话术还是直接报错这取决于业务。对于问答类场景返回当前繁忙请稍后再试比直接抛异常体验好。对于关键业务可以准备一个规则引擎作为兜底模型不可用时走规则。成本控制方面除了限流还可以做缓存。相同或相似的问题如果短时间内重复出现可以直接返回缓存结果。缓存的 key 可以用问题的 embedding 做相似度匹配也可以用问题文本的哈希做精确匹配。精确匹配实现简单相似匹配效果更好但成本高看场景选。6.3 可观测性日志、指标与追踪大模型应用的可观测性和传统应用不太一样。传统应用看 QPS、响应时间、错误率就够了大模型应用还要看 token 消耗、首字延迟、工具调用次数、检索命中率这些指标。日志方面请求和响应内容要记录但要注意脱敏和存储成本。完整的 prompt 和 response 对排查问题很有用但可能包含敏感信息而且量大。我的做法是记录摘要信息长度、token 数、耗时到常规日志完整内容按采样率记录到单独的存储保留一段时间后清理。指标方面除了常规的接口指标我会额外埋几个每次调用的输入输出 token 数、首字延迟、流式输出的总时长、工具调用的成功率和耗时、检索的召回条数和相似度分布。这些指标能帮你快速定位问题比如首字延迟突然升高可能是上游限流或者网络问题召回条数异常可能是知识库更新出了问题。追踪方面一次用户请求可能涉及多次模型调用和工具调用用分布式追踪把它们串起来排查问题时能看清完整链路。Spring AI 本身对可观测性有支持可以对接 Micrometer 等标准组件具体配置参考对应版本文档。7. 我踩过的几个坑和对应的解法7.1 流式输出中文乱码这个问题在早期版本里比较常见表现是流式返回的中文出现乱码或者被截断。根因是流式返回是按字节分片的一个中文字符占多个字节如果分片正好切在字符中间就会出问题。解决办法是确保编码统一用 UTF-8并且在拼接时按字符而不是按字节处理。如果用的是 SSE要确保响应头里的 charset 设置正确。这个问题在新版本里基本被框架处理掉了但如果你自己手写流式处理还是要留意。7.2 对话记忆导致上下文超长挂了记忆 Advisor 之后历史对话会一直累积聊得久了上下文就超了模型的窗口限制。表现是模型开始报错或者回复质量下降因为关键信息被挤掉了。解决办法是限制历史消息的条数或 token 数超出后丢弃最早的消息。更精细的做法是做摘要把早期对话压缩成一段摘要保留既省 token 又不丢关键信息。Spring AI 的记忆组件支持配置窗口大小具体参数看版本。7.3 向量检索返回不相关内容这个问题很常见用户问 A检索出来一堆 B。排查下来往往是几个原因embedding 模型不适合中文、切分粒度不对、相似度阈值太低。我的排查顺序是先看检索出来的内容相似度分数分布如果普遍偏低说明 embedding 模型或者切分有问题如果分数高但内容不相关说明 embedding 模型对这类语义的区分度不够考虑换模型或者加关键词检索兜底。7.4 工具调用参数类型不匹配前面提过模型给的参数类型和方法签名对不上。除了把参数都设成String自己转换还有一个办法是在ToolParam的 description 里明确说明格式比如城市名称中文例如北京。描述越具体模型给对格式的概率越高。另外对于枚举类型的参数把可选值列在 description 里能显著降低出错率。8. 一些关于选型和演进的个人看法关于模型选型我的观点是不要一上来就追求最强模型。先用一个中等能力的模型把链路跑通把 RAG、工具调用、流式输出这些工程问题解决掉再根据实际效果决定要不要换更强的模型。很多时候效果不好不是模型的问题而是检索、切分、prompt 的问题换模型解决不了。关于本地模型和云端模型的选择本地模型的优势是数据不出内网、成本可控劣势是能力通常弱于云端模型而且需要自己维护推理服务。我的建议是如果数据敏感度高或者调用量很大可以考虑本地部署如果只是验证阶段或者调用量不大用云端模型更省事。Spring AI 的好处是切换成本低配置改一下就行所以不用太纠结先跑起来再说。关于 Spring AI 本身的演进从我这段时间的使用来看它在快速迭代API 偶尔会有调整。我的建议是锁定一个稳定版本不要盲目追新升级前先看变更日志评估影响面。另外社区里关于 RAG 和智能体的最佳实践还在不断沉淀多看看别人的踩坑记录能少走不少弯路。最后分享一个我自己的习惯每次接入一个新的模型或者新的组件我都会先写一个最小的验证程序把最基本的调用跑通确认网络、鉴权、参数都没问题再往业务代码里集成。这个习惯帮我省了很多时间因为问题定位的范围被限制在了最小程序里不用在复杂的业务代码里大海捞针。