news 2026/10/19 14:03:52

Spring AI Alibaba实战:结构化输出、函数调用与多轮记忆全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI Alibaba实战:结构化输出、函数调用与多轮记忆全解析

上一篇我们把 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 内部的工作方式,排查问题的时候事半功倍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 14:02:01

Simulink实时修改参数:ExportedGlobal与External Mode在线调参实战

简介&#xff1a;这份资源面向需要在Simulink仿真过程中动态调整模块参数的开发者与学习者&#xff0c;核心是用S函数实现对模型内部参数的实时修改&#xff0c;并以正弦波形作为验证对象&#xff0c;在线改变其幅值与频率&#xff0c;直观观察参数变化对输出的影响。压缩包共2…

作者头像 李华
网站建设 2026/10/19 14:00:38

ESP32不依赖音频库:用I2S与SD卡直接播放WAV文件全攻略

简介&#xff1a;面向Arduino/ESP32开发者的音乐播放器示例工程&#xff0c;基于Arduino-ESP32库与I2S数字音频接口&#xff0c;实现了从SD卡读取WAV文件并连续播放的功能&#xff0c;解决数字音频传输中信号衰减与缓冲管理难题&#xff0c;适合物联网音频项目入门及功能扩展。…

作者头像 李华
网站建设 2026/10/19 14:00:28

深度强化学习从原理到实战:DQN、经验回放与训练避坑指南

[博主手记] 上周有个做图像识别的朋友问我&#xff1a;现在的模型动不动就是“预测”&#xff0c;但我想让程序自己学着玩游戏、控制机器人&#xff0c;该看哪一套东西&#xff1f;我让他别急着翻神经网络&#xff0c;先去看看强化学习。结果他抱着某个经典教材的第9章&#xf…

作者头像 李华
网站建设 2026/10/19 13:59:23

VLLM 0.7.3源码深度解析:PagedAttention v2与CUDA调度优化实战

简介&#xff1a;本资源为VLLM-0.7.3大模型推理框架完整开源源码包&#xff0c;面向AI工程师、大模型系统优化研究者及高性能推理部署实践者&#xff0c;聚焦解决超大规模语言模型在生产环境中推理速度慢、显存占用高、吞吐瓶颈突出等核心问题。压缩包共1896个文件&#xff0c;…

作者头像 李华
网站建设 2026/10/19 13:58:55

open-code-review:基于CLI+git diffs+LLM Agent的开放代码评审范式

1. “open-code-review”不是工具名&#xff0c;而是一套可落地的代码评审新范式你可能在 GitHub Trending 或某次技术分享里见过open-code-review这个词——它既不像eslint那样有明确的 CLI 入口&#xff0c;也不像SonarQube那样自带 Web 界面。它没有官方文档、没有 npm 包、…

作者头像 李华