资讯动态

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

发布时间:2026/10/19 14:03:57 来源:尧图企业网站定制
上一篇我们把 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接口把历史消息存到业务数据库或 Rediskey 就是用户 ID 加会话 ID。这样做的好处是服务重启后历史还能恢复也方便人工介入查看模型到底收到了什么上下文。自己做的时候别忘记maxMessages收缩否则长时间对话 token 开销会快速增长最后既慢又贵。2.3 结构化输出让模型返回我们能直接用的 JSON早期使用大模型做信息抽取最痛苦的就是解析文本。Spring AI 把“让模型输出 JSON”这个能力封装得非常直接。先定义一个 Java recordpublic 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℃; }调用时把工具传给 ChatClientString 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 groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId /dependency3.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;charsetUTF-8) public FluxString 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 内部的工作方式排查问题的时候事半功倍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑