上一篇我们把 Spring AI Alibaba 的架子搭起来了,能跑通基本的对话,也能看到模型返回的普通文本结果。但真正把它放进业务系统里,事情远没有这么简单:多轮会话怎么接住上下文,模型输出怎么直接变成 Java 对象,还有怎么让模型去调用我们自己的服务,这些才是从 Demo 走向工程真正要跨过的坎。
这篇“下篇”我聚焦在三个核心场景:结构化输出、函数调用、多轮记忆,外加一些真实项目中容易踩坑的细节。适合已经搭过基础环境的同学,也适合那些正在读 Spring AI Alibaba 源码但被配置绕晕的开发者。
1. 整体设计:从“会聊天”到“能干活”的差距在哪
1.1 单纯接 API 和用 Spring AI Alibaba 的区别
上篇我们最终得到的是一个可以直接和通义千问对话的ChatClient,这看起来很爽,但如果你在做业务系统,很快就会发现裸调 API 有几个很别扭的地方。
第一,上下文管理要自己手写。把历史消息拼成一个 List,塞进请求体,每轮对话都要带上全部历史,还得担心 token 膨胀。第二,模型输出是文本,想拿一个结构化字段,比如订单状态、金额、用户意图,只能自己解析字符串,模型一旦多解释两句,解析就崩。第三,想把本地服务能力暴露给模型,比如查库存、下单、发通知,得自己设计一套工具描述协议,再把模型返回的工具调用参数解析出来,自己写调度。
Spring AI Alibaba 这套东西,本质上是把这三件事封装成了 Spring 风格的组件。你只需要声明配置、定义方法、标注注解,剩下的上下文拼接、工具参数解析、JSON Schema 生成,都由框架帮你完成。它不是一个花架子,而是把 LLM 接入 Java 应用时那些重复劳动压缩掉,让代码回归到业务本身。
1.2 为什么我这次把三个能力放在一起讲
刚开始接触 Spring AI 的人,往往把多轮对话、结构化输出、函数调用当成三个独立功能,一个个去试。但真实业务里它们是缠在一起的:用户说一句“帮我把订单 10086 改到明天发货”,你得先把用户语句中的意图识别出来,再调用修改发货时间的服务,最后用一段清晰的话回复用户。
这里面的链路是:函数调用负责把“改时间”这个动作落到真实系统,多轮记忆负责让模型记住订单号、记住用户之前的偏好,结构化输出负责让模型给出可以被程序直接使用的回复模板。三个能力单独看都只是 API 特性,合在一起才是完整的 Agent 雏形。所以这篇下篇,我刻意把它们的实现放到同一个项目上下文里讲,方便你理解它们是怎么协作的。
2. 核心能力拆解:模型配置、记忆、结构化与工具调用
2.1 多模型切换与关键参数
Spring AI Alibaba 的模型接入,配置入口在application.yml。我们上篇已经配置过基础 key,这次把细节展开:
spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.7 top-p: 0.9 max-tokens: 2000这里有几个参数我建议花时间调一下。temperature控制随机性,数值越低回答越保守,适合信息抽取、分类、结构化输出;数值越高越有创造性,适合文案生成和头脑风暴。我在实际项目里的习惯是:业务规则判断用 0.2 以下,开放对话用 0.7 左右,写营销文案再往上加到 0.9。top-p是另一个采样开关,一般保持默认 0.9 就行,不需要和 temperature 同时大改,否则输出会变得不太可控。max-tokens则决定了作文长度,注意短短回复在max-tokens设置为过小的时候容易被截断,那种“话说到一半戛然而止”的现象往往就是它导致的。
DashScope 目前几个模型之间的差异也值得你了解:qwen-turbo 胜在便宜和快,适合简单问答和意图分流;qwen-plus 是综合性价比选择,我大部分业务默认用这个;qwen-max 能力最强,适合复杂推理、长文本规划。实际使用可以做成配置项,在代码里动态切换模型,而不是改配置重启服务。
chatClient.prompt() .user("用一句话解释什么是 DDD") .options(ToolCallingChatOptions.builder() .model("qwen-max") .temperature(0.3) .build()) .call() .content();2.2 多轮会话记忆的正确打开方式
模型本身不记上下文,所谓多轮对话,本质上是把历史消息再发给它。Spring AI Alibaba 借助 Spring AI 的ChatMemory帮你做这件事。最简单的方式是使用MessageWindowChatMemory,它像一个滑动窗口,只保留最近 N 条消息:
var chatMemory = MessageWindowChatMemory.builder() .maxMessages(10) .build(); var chatClient = ChatClient.builder(chatModel) .defaultSystem("你是订单助手,回答要简洁。") .chatMemory(chatMemory) .build();但这里有一个非常容易踩的坑:MessageWindowChatMemory默认是全局共用的。如果所有用户都共用一个ChatClient,那用户 A 的消息就会出现在用户 B 的会话里。正确做法是按会话维度隔离,给每个会话一个独立的 memory 实例,或者在ChatClient的 prompt 调用时传入 distinct id 来区分会话。
chatClient.prompt() .user("我的订单 10086 发货了吗") .conversationId("user-001") .call() .content();在我自己的项目里,我倾向于自己实现一个ChatMemory接口,把历史消息存到业务数据库或 Redis,key 就是用户 ID 加会话 ID。这样做的好处是服务重启后历史还能恢复,也方便人工介入查看模型到底收到了什么上下文。自己做的时候别忘记maxMessages收缩,否则长时间对话 token 开销会快速增长,最后既慢又贵。
2.3 结构化输出:让模型返回我们能直接用的 JSON
早期使用大模型做信息抽取最痛苦的就是解析文本。Spring AI 把“让模型输出 JSON”这个能力封装得非常直接。先定义一个 Java record:
public record OrderInfo( String orderId, String status, String amount ) {}然后调用时直接使用entity类型转换:
OrderInfo result = chatClient.prompt() .user("提取订单信息:订单 10086 已支付,金额 299 元") .call() .entity(OrderInfo.class);这背后的逻辑是:框架会自动生成一个 JSON Schema 描述你的 Java 类型,把它传给模型,模型按这个 Schema 输出 JSON,框架再把 JSON 反序列化成 Java 对象。
这里有一个我不能不提的细节:模型返回的 JSON 不一定完全符合你的字段要求,比如日期格式变了、数字变成字符串,或者模型自说自话多加了字段。所以我在生产环境里不会完全信任entity(),而是会再加一层手动校验。自己写一个转换器,先用BeanOutputConverter拿到 Schema,再对输出结果做字段级校验,不满足就重新生成,或者抛异常走兜底逻辑:
var converter = new BeanOutputConverter<>(OrderInfo.class); String output = chatClient.prompt() .user("提取订单信息:订单 10086 已支付,金额 299 元") .options(ToolCallingChatOptions.builder() .internalJsonSchema(converter.getJsonSchema()) .build()) .call() .content(); String cleaned = cleanJson(output); // 去掉 markdown 代码块标记 OrderInfo order = converter.convert(cleaned);为什么这么谨慎?因为大模型的输出自带概率性,你永远不能假设它 100% 遵守约束。把输出当成不可靠数据来做防御式编程,这是和 LLM 集成最核心的心法。
2.4 函数调用:让模型能操作真实系统
函数调用是 Spring AI Alibaba 真正值钱的地方。它实现了从“模型生成文本”到“模型触发业务动作”的跨越。实现方式也很 Spring:在一个 Bean 方法上打@Tool注解,模型就可以在需要的时候调用它。
@Tool(description = "查询指定城市的实时天气") public String getWeather(String city) { return city + ":多云,26℃"; }调用时把工具传给 ChatClient:
String answer = chatClient.prompt() .user("北京今天适合穿短袖吗?") .tools(weatherService) .call() .content();运行过程是:框架把方法名、参数描述、方法说明生成一份工具列表,随用户问题一起发给模型。模型看到“北京今天适合穿短袖吗”这个问题时,判断需要天气信息,于是返回一个调用getWeather的意图,参数为北京。框架收到意图后,反射调用 Java 方法,把返回结果拼到上下文里,再让模型基于真实天气数据组织最终回答。
这个机制在真实验证中确实能跑通,但有几个前提。一是模型要对工具描述理解准确,所以@Tool里的 description 要写清楚,参数名也要尽量语义化。第二个常见问题是工具方法抛异常时链路会断裂,我建议在工具方法内部做 try-catch,返回一段可读的兜底信息,比如“天气服务暂时不可用”,而不是让异常直接冒泡。三是工具调用结果返回后,模型需要重新生成一层语言组织,多轮一来一回会增加延迟,所以工具粒度不要太碎,避免一个业务动作要连续调用七八次工具。
3. 实操记录:一个带记忆和工具调用的订单助手
3.1 工程目录和依赖准备
我这次用一个的“订单助手”把各部分串在一起。项目结构大致是:
src/main/java/com/example/orderassistant/ ├── OrderAssistantApplication.java ├── model/OrderInfo.java ├── service/OrderService.java ├── config/ChatClientConfig.java └── controller/ChatController.java依赖方面,核心还是上篇那个 starter。需要注意 Spring AI 相关的版本迭代很快,不同小版本之间 API 会有些微差别,我给出的写法以当前主流版本为准,如果你用的版本有出入,优先参考官方文档调整。
<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> </dependency>3.2 配置一个有记忆的 ChatClient
在配置类里创建ChatClient,把所有会话级别的行为集中管理:
@Configuration public class ChatClientConfig { @Bean public ChatClient chatClient(ChatModel chatModel, OrderService orderService) { var memory = MessageWindowChatMemory.builder() .maxMessages(20) .build(); return ChatClient.builder(chatModel) .defaultSystem("你是订单助手,请结合用户的历史消息回答,回答前先确认必要信息。") .chatMemory(memory) .defaultTools(orderService) .build(); } }这里把OrderService里的工具方法提前注册为默认工具,这样后续调用就不需要每次.tools()再指定。注意我使用的是defaultTools,而不是在每次请求时传工具,对于像订单服务这种核心能力,默认注入更省事,但缺点是每条请求的工具描述都会变大,token 开销略高。
3.3 服务层写真实的订单操作
订单服务里我写了两个简单的工具方法:一个查询订单状态,一个修改订单备注。这只是演示,真实系统里同样位置可以替换成查数据库、调用下游接口。
@Service public class OrderService { @Tool(description = "根据订单 ID 查询订单状态") public String queryOrder(String orderId) { if ("10086".equals(orderId)) { return "订单 10086 已支付,待发货"; } return "订单不存在"; } @Tool(description = "修改订单备注") public String updateOrderNote(String orderId, String note) { // 真实项目里这里会落库 return "订单 " + orderId + " 备注已更新为:" + note; } }这里有个细节:一个工具方法最好不要设计成超过三四个参数,太多的参数会让模型的工具描述变得特别长,也增加了模型填错参数的概率。如果确实需要复杂参数,我建议把参数聚合成一个 record,框架会把它转成嵌套的 JSON Schema,模型反而更容易理解。
3.4 Controller 里做流式输出
对话接口我通常搭配流式输出,因为大模型生成文本有延迟,如果等全部生成完再返回,用户会以为系统坏了。用 Spring AI 的Flux接口可以做到打字机效果:
@PostMapping(value = "/chat", produces = "text/plain;charset=UTF-8") public Flux<String> chat(@RequestBody ChatRequest request) { return chatClient.prompt() .user(request.message()) .conversationId(request.sessionId()) .stream() .content(); }前端拿到流式内容逐段显示,体感比硬等“一次性交答案”好太多。需要注意生产环境要做会话 ID 的合法性校验,避免用户猜测别人的会话 ID 读取上下文。
做到这一步,一个支持多轮、能查订单和改备注的助手已经能跑了。测试时我习惯先问一次“我的 10086 订单到哪一步了”,再追问“帮我把备注改成加急”,模型会基于上一次查询的结果,直接触发备注修改工具,而不是重复问订单号。这就是多轮记忆加上函数调用协同工作的直观效果。
4. 常见问题与排查经验速查
4.1 我实际遇到并且修掉的高频问题
下面这些坑都是我在跑 Spring AI Alibaba 项目时实际遇到的,整理成一个速查表,每次排查时先对照一下。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 请求超时或者连接中断 | 网络不稳定、模型服务端繁忙、请求内容过长 | 配置更长的连接和读取超时;重试时采用退避策略;精简 system prompt 和工具描述 |
| 接口返回 429 / 限流 | 单账号并发过高,超过了 DashScope 配额 | 控制客户端并发,做本地限流;必要时升级账号配额;对限流错误做指数退避重试 |
| entity() 转换时报 JSON 解析失败 | 模型返回内容里带了 markdown 代码块标记,或者字段类型和定义不一致 | 先做清洗,去掉多余的 ```json 标记;对强校验字段加自定义反序列化逻辑 |
| 工具方法一直没有被调用 | 模型没理解工具用途;工具描述不够详细;当前模型版本不支持函数调用 | 优化 @Tool description;显式在 prompt 里要求“如果需要,你可以调用工具”;切换更强的模型 |
| 多轮对话串上下文 | ChatMemory 是全局共用没有按会话隔离 | 使用 per-user conversationId,或按会话维度创建独立 memory |
| 回答太长被截断 | max-tokens 设置偏小 | 按业务内容调整最大 token 数;对翻译、总结类任务尤其注意 |
4.2 结构化输出不稳时的兜底方案
模型输出 JSON 结构不稳是必然的,我再怎么调 prompt 也只能降低概率,没法消灭它。所以我写了个小工具函数,专门清洗模型输出。
public String cleanJson(String raw) { if (raw.startsWith("```")) { raw = raw.replaceAll("```(?:json)?", "").trim(); } int start = raw.indexOf('{'); int end = raw.lastIndexOf('}'); if (start >= 0 && end > start) { return raw.substring(start, end + 1); } return raw.trim(); }这只是第一层保护。更严密的做法是把 JSON 解析失败当成一种业务异常处理,触发一次带错误提示的重试:“你上次返回的格式不符合要求,请直接输出合法 JSON,不要加解释文字。” 第二次生成的成功率会明显提高。重试之后仍然失败,再走人工介入或者默认值兜底。要记住,LLM 应用里错误处理不是“有没有问题”的问题,而是“问题出现后怎么自愈”的问题。
4.3 并发和性能调优的建议
实际把服务推上线之后,我做的第一件事永远是压测。Spring AI Alibaba 底层调用 DashScope 是远程网络请求,单路延迟通常在几百毫秒到几秒,吞吐瓶颈往往卡在远程 IO 上。优化手段有几个,越早做越好。
第一个是连接池。调整 HTTP 客户端的连接池大小和超时时间,避免每次请求都重建连接。第二个是控制最大并发,因为模型服务端有速率限制,放纵并发只会触发 429,进而引发重试风暴。第三个是缓存,对于同样的用户问题,可以在业务层做语义缓存,同一个问题命中后直接返回上次结果,能省下大量调用成本。最后一个建议是,工具调用链路里尽量让工具快速返回,不要在工具方法里做重型计算,否则整个过程延迟会叠加,用户体感会非常差。
5. 一点个人体会
从上一篇的基础配置到这篇的完整功能,Spring AI Alibaba 给我的整体感觉是:它把 Java 生态和 LLM 之间的最后一公里铺得比较平,尤其是函数调用和结构化输出,省掉了大量手写协议代码的时间。但它毕竟还处在快速迭代期,版本差异带来的兼容性问题确实存在,我在升级依赖时就碰到过工具注解不生效、配置项改名这类情况。
所以我的建议是,如果你要用它做正式项目,第一件事不是急着写业务,而是把依赖版本固定下来,并且把核心能力都写成一个独立模块做回归测试。多加一层输出校验,多缓存一层结果,多隔离一份会话记忆,这些看着不起眼的防御措施,才是 LLM 应用能不能稳定跑下去的关键。
最后分享一个我觉得很实用的小技巧:在开发阶段,把spring.ai.dashscope相关的日志级别调到 DEBUG,你能直接看到每次请求发给模型的完整内容,包括 prompt 拼成了什么样、函数调用的参数是什么、返回结果有没有被正确解析。这些信息比任何文档都更能帮你理解 Spring AI Alibaba 内部的工作方式,排查问题的时候事半功倍。