这篇是 Spring AI Alibaba 实战的下篇,接着上篇那个能跑通的 HelloWorld 往后聊。上篇的内容不算多,就是把依赖配好、模型服务连上、让 ChatClient 回一句话。但真正从 Demo 走到业务,你会发现事情远没有那么简单:一个应用里可能要用好几个模型,模型得会调服务端的工具,有些回答还必须返回固定结构的 JSON,再加上知识库和 Agent 的编排,随便一环出问题,得到的结果就会很不靠谱。这篇就是围绕这几个点,把我实际踩过的坑和验证过的方案整个捋一遍。适合已经跑通快速开始,准备把 Spring AI Alibaba 用到真实项目里的同学。如果你连 ChatClient 都还没调通,建议先回头看上篇,否则后面配置里的很多概念会显得很跳。接下来我尽量用“一段代码加一段解释”的方式,不讲空理论,全是实打实验证过的操作。
1. 决定能不能上线的三个核心能力
1.1 上篇留下的问号
上篇的 Demo 让模型回了一句“你好呀”,看起来挺顺利,但它本质上只是把一段文本发给模型,再把模型输出打出来。真实的业务场景里,我们真正需要解决的是三个问题:第一,对话之外,模型如何访问到业务系统里的实时数据;第二,模型返回的字符串如何直接变成下游能消费的 JSON 对象;第三,当需要给模型加记忆、加知识库、加多步任务时,代码结构要怎么写才不容易崩。
很多刚接触 Spring AI Alibaba 的人会有种错觉,觉得它就是一个 HTTP 客户端封装,调一下接口就完事了。实际上这个框架把“模型能力”和“业务能力”之间那条缝补上了不少。比如工具调用,你只需要定义一个普通方法,加个注解,模型就能在你允许的范围内“使用”这个方法,像给一个刚入职的新员工配了内部系统账号一样,它自己去查、自己去算,然后给你结论。再比如结构化输出,你告诉它“我要一个 UserInfo 对象”,它就会尝试按字段填数据,而不是甩给你一段需要正则解析的原文。
1.2 这篇的适用场景与阅读建议
如果你是做架构评审、技术选型,或者正在写一个 AI 功能的方案,这篇也能帮上忙。里面涉及的不只是 API 怎么调,更多的是边界怎么划分:哪些活应该让模型自己判断,哪些必须由代码兜底;知识库的召回到底应该信多少;Agent 的步数怎么限制。这些直接决定上线之后是省心还是修 Bug 修到怀疑人生。
读的时候,我建议你先把代码跑通,再回来看注意事项。代码部分我用的是 Spring AI Alibaba 当前主分支的常见写法,不同版本之间方法名可能会有调整,但思路基本一致。后面每一章我都会先说做法,再解释“为什么这么做”,最后把容易踩坑的点单独挑出来提醒。
2. 多模型接入与运行时切换:绕开 Bean 冲突是关键
2.1 为什么一个应用里要放多个模型
我在实际项目里很少只用一个模型。原因很直接:不同任务对模型的要求完全不同。抽标题、做分类这种任务,结果相对固定,用轻量模型就够了,速度快、成本低;深度问答、复杂 Agent 推理,则必须上效果更好的大模型,不然答案质量没法看。如果所有流量都走同一个最强模型,成本会飞速上涨,响应时间也扛不住。
Spring AI Alibaba 把模型能力抽象成了ChatModel接口。它是整个大模型调用的入口,你可以像操作一个 Service 一样去操作它。默认情况下,框架会根据你引入的依赖自动创建一个ChatModelBean。但如果你需要多个模型,就不能只依赖默认装配了,得手动把不同配置的ChatModel分别注册成 Bean,然后按业务场景选择。
一个比较清晰的配置方式是,在配置类里给每个模型起一个业务名字。示意代码如下:
@Configuration public class MultiModelConfig { @Bean("lightChatModel") public ChatModel lightChatModel(ChatModelBuilder builder) { return builder .defaultOptions(ChatOptions.builder() .model("light-model") .temperature(0.2) .maxTokens(1024) .build()) .build(); } @Bean("heavyChatModel") public ChatModel heavyChatModel(ChatModelBuilder builder) { return builder .defaultOptions(ChatOptions.builder() .model("heavy-model") .temperature(0.8) .maxTokens(2048) .build()) .build(); } }这里我没有写具体服务商相关配置,因为不同模块需要的 key 和地址不一样,但整体套路就是这样:通过 Builder 设置模型名和默认参数,再把构建好的对象注册为 Bean。注意不要直接定义两个普通ChatModelBean 然后不指定名字,Spring 启动时会因为不知道注入哪个而报NoUniqueBeanDefinitionException,这个错我在搭建环境时碰到过好几次。
2.2 按业务场景做运行时路由
有了多个 Bean 之后,下一步是怎么在请求里动态选择模型。最简单的方案是用Map<String, ChatModel>注入,Spring 会把容器里所有ChatModel类型的 Bean 放到这个 Map 里,key 就是 Bean 名称。调用时根据业务参数取出对应的那个模型,取不到就落到默认值上。
@RestController public class ChatController { private final Map<String, ChatModel> modelMap; public ChatController(Map<String, ChatModel> modelMap) { this.modelMap = modelMap; } @PostMapping("/chat") public String chat(@RequestBody ChatRequest req) { ChatModel model = modelMap.getOrDefault(req.modelKey(), modelMap.get("lightChatModel")); if (model == null) { throw new IllegalArgumentException("modelKey 不存在"); } return model.call(new Prompt(req.message())) .getResult() .getOutput() .getContent(); } }这里有两个细节。第一,Bean 名称来自配置类里的方法名,所以@Bean("lightChatModel")里的名字一定要和业务约定对齐,别搞个modelA、modelB,过几天自己都忘了是干嘛的。第二,不要想当然用modelMap.get,一旦前端传了不存在的 key,直接空指针很难看。先getOrDefault再判断,或者干脆在 gateway 层做白名单校验。
如果你需要更灵活的路由规则,比如根据用户等级、当前并发数、费用策略来决定走哪个模型,可以把这段逻辑封装成一个ModelRouterService,对外只暴露chat(routerKey, prompt)方法。这样上层就不需要知道模型列表的细节。
2.3 temperature、maxTokens 怎么调才合理
模型参数里最容易纠结的两个是temperature和maxTokens。我的经验是:凡是需要“确定性”的任务,比如实体抽取、意图分类、关键词提取,temperature调到 0.1~0.3,越低越好,没特殊需求别超过 0.5。而需要发散、生成文案的任务,可以调高到 0.8 左右,但不要超过 1,否则模型会开始自由发挥,甚至胡编。
maxTokens这个参数很多人一开始不设置,结果模型输出到一半被截断,接口返回的内容不完整。我建议提前估算:如果要求生成一段 2000 字左右的周报,maxTokens至少给到 2500 以上,因为中文状态下每个 token 对应字数并不固定,保守一点更稳。框架返回结果里有个finishReason,如果它等于length,就代表输出被上限截断了。代码里可以针对这个做处理,要么换更大上限的模型,要么给用户提示“生成内容过长”。
var response = chatModel.call(new Prompt(req.message(), ChatOptions.builder().maxTokens(2048).build())); String finishReason = response.getResult().getMetadata().getFinishReason(); if ("length".equals(finishReason)) { // 处理截断逻辑,而不是直接把残缺文本返回给前端 }参数配置不是越多越好,很多情况下能少碰就少碰。模型调用方最怕的是参数口径不统一,同一个功能有人传了temperature=0.8,有人没传,线上表现就很随机。所以我会在默认配置里把基础参数定好,个别场景再用运行时参数覆盖。
3. 工具调用与结构化输出:让模型真正成为接口
3.1 用注解声明一个可被调用的工具
模型本身是不了解业务系统数据的,它不知道你的数据库里有哪些订单,也不知道外部接口的返回格式。想让模型具备访问这些信息的能力,最标准的方式是工具调用,英文里叫 function calling。Spring AI Alibaba 里实现起来的体验比较直观,一个普通方法加个@Tool就能让自己“变成”模型可调用的功能。
举个例子,假设我要让模型回答“北京今天适合穿短袖吗”,它必须先知道北京的天气,所以我写一个查天气的工具:
@Component public class WeatherTool { @Tool(description = "根据城市名获取当天的天气情况") public String getWeather(String city) { // 这里替换成真实天气接口调用 return city + " 当前天气:晴,温度 26℃,微风"; } }然后在构建ChatClient时把这个工具类注册进去:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(WeatherTool.class) .build(); String content = chatClient.prompt("北京今天适合穿短袖吗?") .call() .content();当模型接收到“北京今天适合穿短袖吗”这个问题时,它会根据工具描述判断“我需要先获取北京的天气”,然后返回一个工具调用请求。框架拿到这个请求后,在本地找到WeatherTool.getWeather方法并执行,再把执行结果打包给模型,模型最终给出“北京今天 26℃,适合穿短袖”这样的回答。整个过程对用户来说完全透明。
这里我特别想提醒一点:@Tool方法尽量使用基本类型作为参数。模型在输出工具参数时是基于文本的,如果参数过多且类型复杂,出错概率会变大。能拆成两个简单工具,就不要硬塞成一个复杂工具。
3.2 工具调用的幕后循环,搞清楚才能不迷路
很多同学会疑惑:模型到底是怎么知道要调用工具的?其实它并没有真正“运行”代码,它只是在生成的文本里选择了一个我们约定的 JSON 格式,这个格式描述了方法名和参数值。比如模型可能会返回这样一段结构:{"name": "getWeather", "arguments": {"city": "北京"}}。框架收到后负责解析、反射调用、拿到返回值,然后追加到上下文中,再发给模型。整个过程就像是一个“模型提需求、框架执行、结果反馈给模型”的循环。
理解这一点很重要。工具描述写得越清晰,模型就越知道什么场景要用它;返回值越是结构化,模型就越容易继续处理。以前我把一个工具描述写成“获取数据”,结果模型压根没调用,后来改成“根据城市名获取当天的天气情况,如果城市参数缺失,返回错误提示”,效果立竿见影。所以工具描述不是写给人看的,是写给模型看的,要把触发条件、参数含义说清楚。
3.3 让模型直接返回 JSON 对象
如果说工具调用是模型“走进”了业务系统,那结构化输出就是让模型的返回结果能被程序直接消费。实现方式很简单,使用ChatClient的entity方法:
public record UserInfo( String name, String address, String urgency ) {} UserInfo info = ChatClient.builder(chatModel) .build() .prompt("张三,住杭州,需要马上处理") .entity(UserInfo.class);调用之后,框架会引导模型输出符合UserInfo结构的 JSON,并反序列化成一个对象。以前我遇到过一个场景,用户的留言里包含“名字”“地址”“紧急程度”,如果用正则去抠,各种情况防不胜防。用结构化输出以后,代码里直接拿对象,省了一堆字符串处理。
不过结构化输出也有翻车的时候。类字段如果没有语义明确的注释,模型可能填错字段,比如把“紧急”填进“地址”。解决办法很简单:给字段加@JsonProperty标注,或者在 prompt 里给一个完整的示例。另外,如果设置了temperature比较高,JSON 格式偶尔会不稳定,出现多余的逗号或缺失引号,所以抽取任务一定要把温度调低。框架通常也支持指定responseFormat为 JSON 对象,遇到格式不对时可以显式开启。
3.4 工具调用里的三个“不要”
我在这块踩过不少坑,挑三个最典型的分享。
第一,不要在工具方法里放长事务或重计算。模型可能会在一轮对话里连续调用多个工具,如果每个工具都去操作数据库并开启事务,并发一高,连接池很快被耗尽。工具方法应该是轻量的,尽量只做查询和计算,必要时把耗时的数据更新放到异步任务里。
第二,不要盲目相信工具返回的内容。模型拿到工具返回值后,可能为了最终答案的连贯性,对数值做“润色”,把不确定的数据说得很确凿。所以我建议关键数据在工具内部就完成校验并返回结构化错误,让模型在拿到错误后重新询问或给出“暂时无法获取”的回应,而不是编造结果。
第三,不要用两个描述相似的工具。比如一个叫“获取订单”,一个叫“查询订单详情”,模型很容易选错。宁可少定义工具,把逻辑合并,也别让模型去做模棱两可的选择题。
4. 知识库与 RAG 落地:让回答有据可循
4.1 先决定用哪种向量库,再动手
RAG(检索增强生成)是目前让模型回答专业知识最实用的一种路径。思路是:把业务文档切成片段,用向量模型转成向量,存进向量库;用户提问时,先把问题变成向量,在库里找最接近的片段,再把这些片段拼到模型输入里,让模型基于这些参考资料回答。
Spring AI Alibaba 对向量库做了抽象,比如VectorStore接口,所以底层实现可以换。本地开发阶段,我用的是内存版向量库,零搭建成本,进程一启动就能用。数据量上去了,可以切到支持持久化的向量库,比如 PGVector、Redis 等。我建议不要一上来就接太重的中间件,先拿内存库把流程跑通,确认检索效果可以了再迁,迁移只影响配置和索引初始化那部分代码,业务逻辑基本不用动。
4.2 Embedding 模型和切分策略,直接决定检索质量
很多人关心选哪个 embedding 模型,却没有花时间设计切分策略。实际上,切分不合理,再好的向量模型也救不回来。
拿一个帮助文档举例:如果按“每 200 个字符切一段”,很可能把两个无关的 FAQ 拼在一起,或者把一个完整的操作步骤从中间切开,检索到的片段语义不完整。我现在的做法是:优先按照文档结构切分。有明确章节的按标题切;每一条 FAQ 单独作为一段;合同或规范类文档尽量保留条款编号,再按自然段落合并到 2000 字以内的完整语义块。
切分之后还有一个容易忽略的点:元数据。给每个Document加上来源、标题、章节号等字段,一方面可以在检索时做过滤,另一方面能够让模型在回答时知道“这个信息来自哪份文档”。生成结果时把来源也输出给前端,会显著提高可信度。
还有一点必须记住:写入时用的 embedding 模型和查询时用的 embedding 模型要保持一致。如果中途换了模型,向量空间变了,旧数据不重新生成的话,检索结果会非常随机。这个坑我踩过,表现是“之前能搜到,换模型之后什么都搜不到”。
4.3 一个最小可运行的 RAG 流程示例
代码上并不复杂。我先定义一个VectorStore的 Bean,然后写入文档,再查询。
@Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { return new SimpleVectorStore(embeddingModel); } // 初始化数据 vectorStore.add(List.of( new Document("Spring AI Alibaba 支持工具调用,可以让模型获取实时业务数据。", Map.of("source", "doc-1")) )); // 查询 SearchRequest request = SearchRequest.builder() .query("模型能不能获取实时数据?") .topK(3) .build(); List<Document> docs = vectorStore.similaritySearch(request);这里是一个全链条:query先被 embedding 成向量,然后在向量库里做相似度检索,返回最接近的 3 个文档片段。下一步就是把docs的内容拼到 prompt 里,交给ChatClient生成答案。
有一点要特别注意:不要在每次用户请求里去加载文件、切分和 embedding。这些都是相对重的操作。文档应该启动时加载一次,或者定时增量更新。否则你的 RAG 接口瓶颈不在模型,而在你自己的文档解析流程。
4.4 TopK 和重排,别让答案被无关片段带偏
检索回几个片段合适?不是越多越好。topK如果太大,会把一些相似度偏低的无关内容也塞给模型,模型反而容易被错误信息带偏。我的经验是topK取 3~5 个,并且要结合相似度阈值过滤,低于某个分数就直接不要。
如果你想进一步提高答案质量,可以加一步重排。也就是先把候选片段用topK=10都召回来,再用一个专门的 rerank 模型或者简单的关键词加权重新排序,最后只把得分最高的 3 个片段给模型。没有重排能力时,可以在提示词里加一句“只根据提供的资料回答,如果资料没有提到,就明确说不知道”。实测下来,这句提示能明显减少模型一本正经地胡说八道。
5. 拆解一个完整 Agent 示例:从工具调用到流程编排
5.1 Agent 的本质是什么
Agent 听起来很高深,但其实本质就是“模型 + 工具 + 循环决策”。模型本身不会执行操作,它负责理解目标、规划步骤;工具负责真正干活;每执行完一个工具,模型会观察结果,再决定下一步做什么。这个过程可以重复多轮,直到模型认为目标已经完成。
Spring AI Alibaba 帮我们做了大量底层工作,开发者不需要自己维护“调模型、解析 tool call、执行、再调模型”这个循环。但在写业务代码时,你仍然要清楚这个循环的存在,否则你会想不通“为什么一个简单问题,模型要调好几次工具”。
我比较喜欢把模型比作一个刚毕业但脑子转得很快的新员工,工具则是给他开的内部系统权限。他能不能把活干好,一半靠他的能力,一半靠你把权限设计得是否顺手。如果权限边界不清,今天能查订单明天也能删数据,那迟早出事。
5.2 一个“行程规划 Agent”的代码骨架
我用一个最常见的规划类需求来演示:让 Agent 帮用户规划一日游。它至少需要三个信息:天气情况、景点门票、路线是否合理。对应三个工具,我把它们注册到同一个ChatClient里。
public record TourPlan( String city, List<String> spots, String route, double budget ) {}三个工具分别定义:WeatherTool.getWeather(city)、TicketTool.getTicketPrice(spot, date)、RouteTool.calculateRoute(spots, startPoint)。然后构建调用:
TourPlan plan = ChatClient.builder(chatModel) .defaultTools(WeatherTool.class, TicketTool.class, RouteTool.class) .build() .prompt("帮我规划杭州一日游,预算 500 元,从西湖出发") .entity(TourPlan.class);模型在生成最终TourPlan前,会先依次触发几个工具:查一下天气,查一下几个景点的门票,再算一下路线。框架会自动把每个工具的结果回填给模型。最后模型整合所有信息,输出一个结构化的行程。
这里有一个小技巧:prompt里最好把约束条件写完整,比如“从西湖出发”。否则模型缺少起点信息,可能会默认从市中心开始算,导致路线结果和用户预期不一致。你交代的信息越完整,Agent 规划出来的结果就越可用。
5.3 Agent 的边界和兜底,比模型能力更影响体验
Agent 在实际使用中最大的风险不是“不聪明”,而是“不可控”。模型可能会为了完成用户指令,编造一个根本不存在的景点门票价格,也可能在缺参数时自己脑补一个参数值继续跑。所以每个工具的入参都要做校验,缺参数就返回“缺少某某参数”这样的结构化错误,让模型知道信息不够,需要追问用户。
另外一个很容易忽略的问题是迭代次数。如果模型在某个工具调用上反复失败,它可能会无限循环下去。虽然框架本身有保护机制,但应用层还是应该加一个计数器或超时控制,比如超过 5 轮工具调用直接终止,返回“处理超时,请重新描述需求”。在我维护的一个内部 AI 服务里,很大一部分异常都来自 Agent 第一步就选错了工具,后来我调整了工具描述的顺序和措辞,错误率才明显降下来。
边界意识和兜底机制,决定了 Agent 是拿来“演示”还是拿来“上线”。不要指望模型每次都能完美地自主规划,很多实际需求需要在关键动作前跟用户确认一下。这种“人机协作”的模式在工程上更稳定,也是我越来越倾向的做法。
6. 性能、安全与问题排查:上线前必看的实录
6.1 并发场景下的线程池与超时配置
大模型请求是典型的 IO 密集型操作,单次耗时动辄几百毫秒到几秒。如果在同步 Servlet 线程里直接去等模型返回,Tomcat 线程很容易被打满。我见过有些应用上线后,并发一上来,所有请求都卡在模型调用上,其他普通的接口也跟着超时。这个问题的根源不是模型慢,而是线程模型不合理。
一个可行的做法是把模型调用放到独立的异步线程池里,Web 层用异步返回。同时给外部模型调用配置两套超时:连接超时和读取超时。连接超时一般设 5 秒以内,读取超时根据业务可以放到 30 秒左右,要看模型服务商的实际响应时间。如果同一个服务要承载多个模型,还需要给每个模型做独立的限流,别让一个高并发场景把另一个模型的路由也拖垮。
6.2 常见异常速查表
| 异常现象 | 可能原因 | 处理办法 |
|---|---|---|
| 启动时报 Bean 冲突 | 定义了多个ChatModel且没有区分名称 | 给每个 Bean 加上@Bean("xxx"),用Map注入 |
| 调用模型超时 | 网络慢、输出内容过长、服务商拥堵 | 调大读取超时,减少maxTokens,增加重试 |
| 工具始终没有被调用 | 工具描述不清晰,或者提示词没有触发场景 | 重写@Tool描述,补充触发条件 |
| 返回内容不是合法 JSON | 温度太高、字段语义不明确 | 降低 temperature,字段加@JsonProperty,指定 responseFormat |
| RAG 检索结果差 | 切分不合理、embedding 模型不一致 | 按语义重新切分,统一 embedding 模型 |
| 上下文超长 | 多轮历史全部塞给模型 | 裁剪历史,做摘要压缩,或使用记忆机制 |
这张表是我维护的项目里按真实故障频率整理出来的,每一条背后都有对应的线上问题。排查时先按表格定位方向,再去看日志,能省很多时间。
6.3 从一次线上故障里学到的事
我之前负责过一个小型 AI 问答服务,测试环境一切正常,一上线就出现大量慢请求。最初以为是模型服务出了问题,后来把调用日志打开,才发现时间主要消耗在文档处理上。原来每次请求进来,代码都重新读取原始文件、切分、embedding、写入向量库,等于做了一遍 RAG 的初始化过程。模型本身只花了两秒,但前期的文档处理已经花了十几秒。
从那以后,我养成了几个检查习惯:第一,外部调用必须单独记录耗时,不能混在业务日志里;第二,工具调用里如果有同步 IO,要确认是不是每次都执行了;第三,上线前会用并发脚本压一遍模型路由和 RAG 接口,而不是只跑通单个请求;第四,看到返回结果里finishReason异常时,先判断是不是参数配置问题,再怀疑模型能力。
把这些检查项变成习惯之后,我的 AI 服务上线后出问题的频率降低了很多。这篇写到的多模型切换、工具调用、RAG、Agent,其实每一步到最后都指向同一个核心:把模型的不可控性,用工程手段尽量约束住。我自己在实际操作中的体会是,Spring AI Alibaba 给了很好的编排能力,但真正的项目质量,还是取决于你对工具边界和异常场景的设计。希望这些踩坑记录能帮你少走几段弯路。