
去年年底我们在做一个工单智能助手最初用的是 Spring AI 1.0当时被三个问题折磨得够呛模型返回的 JSON 字段时有时无、工具调用偶尔会抽风、多轮对话记忆一长就串台。后来 Spring AI 1.1 发布我们把核心链路重写了一遍用上了 Structured Output、Tool Calling、Memory 这三件套才算真正把对话能力稳定地搬上了生产环境。这篇不是官方文档的翻译是我自己从踩坑、排查到最终落地的一套实战记录。文章会围绕一个库存查询助手的完整示例展开Step by Step 讲清楚 Spring AI 1.1 的 Structured Output 如何保证 JSON 结构不飘、Tool Calling 如何让模型可靠地调用你的业务方法、Memory 如何在多轮对话中不丢上下文以及三者在生产环境中组合使用时那些文档里绝不会有的坑——包括进程崩溃、内存溢出、并发会话串数据等问题。无论你是刚接触 Spring AI还是已经在生产环境被模型输出的不确定性折磨过这篇文章都值得花十分钟读完。很多结论我都是通过实际压测和堆栈分析得出来的可以直接抄作业。1. 为什么是这三件套先搞清楚 Spring AI 1.1 到底改了什么先说结论Spring AI 1.1 对比 1.0最大的变化不是新增了多少 API而是把与 LLM 交互时的不确定性当做一等公民来对待了。1.0 时代我们写代码基本就是 ChatClient 发消息、拿 String 响应然后用 Jackson 手动 parse。一旦模型在 JSON 里多一个逗号、少一个引号或者干脆输出一段解释文字解析期就直接炸了。Structured Output结构化输出解决的是模型输出不可信的问题。它让模型按照你给定的 JSON Schema 输出并且在框架层面对输出做校验和修复而不是把脏活累活留给业务代码。Tool Calling工具调用解决的是模型只能聊天不能干活的问题。模型本身不会查询数据库、不会调外部 API但你可以暴露一批 Java 方法比如查库存、查订单模型在理解用户意图后会自己决定调哪个方法、传什么参数再把方法返回的结果组织成自然语言回复。说白了就是把模型的嘴和业务的手接起来。Memory记忆解决的是多轮对话没上下文的问题。用户说刚才那个订单呢、再加两个如果没有记忆机制模型根本不知道刚才和那个指的是什么。Spring AI 1.1 的 ChatMemory 抽象把消息历史管理做成了可插拔组件可以在内存、数据库或 Redis 之间自由切换。这三个能力单独拎出来都有人写过教程但真正生产落地时它们不是三个孤立的功能而是同一条请求链路里的三个环节用户输入 - 召回历史消息Memory - 判断是否需要调工具Tool Calling - 生成回复Structured Output 约束格式如果只学单个特性组合起来大概率会踩到比单个特性更隐蔽的坑。这篇文章后面的所有示例都会放在同一条链路里讲。提示文中代码基于 Spring Boot 3.4 Spring AI 1.1.0 版本。不同小版本的 API 可能有细微差异但核心思路是通用的。2. Structured Output 落地从 JSON 字符串到类型安全中间隔着多少坑2.1 三种结构化输出方式怎么选Spring AI 1.1 的结构化输出我实际用下来有三种实现路径方式核心 API适用场景坑点提示词约束.system(只返回JSON) 手动解析快速验证极不稳定模型经常夹带私货BeanOutputConverter.output(BeanOutputConverter.class)简单对象映射泛型丢失、字段描述不足时解析失败深度结构化输出.structuredOutput(SchemaType.JSON_SCHEMA)生产环境主推API 较新版本兼容要小心我在 1.0 时代用的就是提示词约束 手动 parse每次上线都提心吊胆。模型偶尔会在 JSON 前面加一句好的以下是您要的结果导致 Jackson 直接抛JsonParseException。1.1 版本里我强烈建议直接用第三种的变体——通过StructuredOutputConverter接口配合自定义 POJO 的方式。2.2 实战示例用 Record 定义输出结构我先定义了一个工单结果的结构public record OrderResult( String orderId, String status, ListItemInfo items, String summary ) { public record ItemInfo(String skuId, int quantity, String name) {} }然后配置 ChatClient 的时候顺手绑定输出转换器Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem( 你是一个工单助手。请根据用户的问题以 JSON 格式输出结果。 ) .build(); } // 在业务方法中 OrderResult result chatClient.prompt() .user(查询订单 ORD-2024-001 的详细信息) .call() .entity(OrderResult.class);看起来很简单对吧但这里藏着一个 1.1 版本最典型的坑如果 OrderResult 里有泛型字段比如ListItemInfo.entity(OrderResult.class)会丢失泛型信息Jackson 反序列化时可能拿到一堆LinkedHashMap赋值给ItemInfo时直接ClassCastException。2.3 泛型擦除与 ParameterizedTypeReference我排查这个问题的过程很典型本地单测没问题一到真实调用就偶发报错。后来在日志里看到异常堆栈才意识到问题不是模型输出错了而是框架在把 JSON 绑定到 Java 类型时根本不知道ListItemInfo里的元素类型是什么。解决办法是使用ParameterizedTypeReference显式携带泛型信息OrderResult result chatClient.prompt() .user(查询订单 ORD-2024-001 的详细信息) .call() .entity(new ParameterizedTypeReferenceOrderResult() {});注意new ParameterizedTypeReferenceOrderResult() {}后面的{}不能省它创建了一个匿名内部类Type 信息就是靠这个匿名类保留下来的。省掉花括号泛型一样是擦除状态。这个坑我印象太深了当时线上大概有 5% 的请求会报错都是偶发性的查了很久才发现是泛型擦除的锅。2.4 字段描述比字段类型更重要另一个让我大跌眼镜的坑模型输出 JSON 的结构没错但字段值经常一本正经地胡说八道。比如quantity字段我期望的是数字模型可能输出数量充足这种字符串或者把规格描述塞进数字字段里。原因很简单我给模型提供的 JSON Schema 里只有字段类型和字段名没有任何语义说明。模型不知道quantity到底指什么、取值范围是什么、单位是什么。解决方式是给字段加描述。在 Spring AI 1.1 里可以用JsonClassDescription和JsonPropertyDescription注解JsonClassDescription(订单查询结果) public record OrderResult( JsonPropertyDescription(订单号格式为 ORD- 开头的字符串) String orderId, JsonPropertyDescription(订单状态PENDING-处理中SHIPPED-已发货COMPLETED-已完成CANCELLED-已取消) String status, JsonPropertyDescription(订单中包含的商品条目列表) ListItemInfo items, JsonPropertyDescription(对这个订单的一句话摘要不超过50字) String summary ) { public record ItemInfo( JsonPropertyDescription(商品的SKU编号例如 SKU-10086) String skuId, JsonPropertyDescription(购买数量必须为正整数) int quantity, JsonPropertyDescription(商品名称) String name ) {} }加了描述之后字段乱值率肉眼可见地下降。这个逻辑其实很朴素对模型来说你给的约束越明确它的自由发挥空间就越小。生产环境里字段描述写得好不好直接决定你后续要不要写一堆防御性解析代码。2.5 兜底策略模型死活不给合法 JSON 怎么办就算加了 Schema 和描述模型输出也可能因为各种原因不合规。我的兜底方案分三层框架层开启.defaultSystem(请严格按照要求的格式输出)加 Schema 约束降低出错概率。解析层拿到的字符串先做一次净化处理掉模型常见的前缀后缀垃圾。业务层一旦解析失败不允许直接 500而是返回降级文案提示用户重新描述需求。第三层很容易被忽略但它才是生产环境真正的保命符。我见过太多团队在结构化输出上较劲却没有想过万一模型连续三次输出都不合法你的接口到底该返回什么用户看到一段 JSON 解析异常报错体验直接归零。3. Tool Calling 让模型真正动手注册、描述与死循环的斗智斗勇3.1 从查不到到自己查一个典型的工具调用链路结构化输出解决的是怎么把话说清楚Tool Calling 解决的是怎么把事办了。我们的工单助手最初只会基于训练数据里的静态知识回答问题一遇到帮我查一下库存就抓瞎。后来用 Tool Calling 接入了库存查询接口效果天翻地覆。先定义一个工具方法Component public class InventoryTools { Tool(name query_inventory, description 根据SKU编号查询商品当前库存数量) public InventoryInfo queryInventory( ToolParam(description 商品的SKU编号例如 SKU-10086) String skuId) { // 实际的库存查询逻辑 return inventoryService.queryBySku(skuId); } }然后注册到 ChatClientBean ChatClient chatClient(ChatClient.Builder builder, InventoryTools tools) { return builder .defaultSystem(你是工单助手可以查询库存信息。) .defaultTools(tools) .build(); }就这么简单模型就能在用户问SKU-10086 还有货吗的时候自己决定调用query_inventory方法然后把返回的库存信息组织成回答。3.2 工具描述就是模型的API 文档写不好就调错工具这段是我最想强调的工具方法名和描述写得草率模型就会在几个工具之间反复横跳甚至调用完全不相关的工具。举个例子我有一次给两个工具分别写了这样的描述Tool(name query_inventory, description 查询库存) public InventoryInfo queryInventory(...) Tool(name query_order, description 查询订单) public OrderInfo queryOrder(...)用户问订单 ORD-1001 里的商品还有库存吗模型居然先调了query_order拿到订单详情然后又调query_inventory把订单里的每个 SKU 都查了一遍库存。虽然结果碰巧是对的但多花了两轮调用延迟翻倍。后来我把描述改成下面这种带场景、带参数说明、带返回说明的风格Tool(name query_inventory, description 当用户询问某个商品是否有货、库存数量是多少、何时补货时使用此工具。参数 skuId 为商品唯一编码。返回当前可售库存数量。)模型选错的概率大幅降低。原理也很简单LLM 在决定调用哪个工具时本质是在做文本匹配。你的描述越接近用户的真实问法匹配越准。3.3 参数绑定失败的排查不是模型蠢是你的类型坑了它工具调用里另一个高频坑是参数绑定。Spring AI 1.1 解析模型返回的 tool call 参数时用的是 JSON 到方法参数的绑定。模型传进来的参数经常不是严格的 JSON 类型。我遇到过最离谱的一次方法签名是long skuId模型传了skuId: SKU-10086——带引号好在 Spring AI 会尝试类型转换字符串转 long 失败了工具调用直接报错整个对话中断。排查链路大概是这样的先看日志里有没有ToolExecutionException有的话基本就是参数绑定失败。打开 Spring AI 的 debug 日志查看模型原始返回的 tool call JSON 是什么样。对比方法签名确认类型是否匹配。解决方式有两种一是把参数类型放宽用String接住再自己解析二是在ToolParam描述里明确参数格式比如纯数字不要带引号。两种我都用过生产环境建议双管齐下。3.4 模型死循环反复调用同一个工具怎么破还有一个让我凌晨三点爬起来查日志的问题模型陷入工具调用的死循环。用户问了一个稍微复杂的问题模型需要查库存、查订单、算总价结果它反复调用同一个工具停不下来。原因是默认的maxIterations最大迭代次数在某些版本里没有硬性限制或者值设置得过大。模型在连续多轮工具调用中忘了自己已经查过某个数据于是重复查。解决办法是在流式构建时限制最大调用轮次ChatClientResponse response chatClient.prompt() .user(查询订单 ORD-1001 中所有商品的库存并计算缺货商品数量) .call(); // 或者通过 options 设置 ToolCallingChatOptions.builder() .maxIterations(5) // 最多允许5轮工具调用 .build();把最大迭代次数压到 5 轮之后死循环问题基本绝迹。用户的问题真的需要超过 5 轮工具调用才能解决的话说明这个任务应该拆分成多个步骤交互而不是让模型一口气串完。4. Memory 不是缓存多轮状态管理的正确姿势与持久化方案4.1 从查不到刚才的订单到恭喜你有了记忆说句实话我一开始对 Memory 是有轻视的——不就存消息历史吗用 Redis List 存一下不就行了真正做完才发现Spring AI 1.1 的 Memory 抽象解决的不只是存储而是如何在有限的 Token 预算内保留对当前对话最有用的信息。没有 Memory 的时候用户说刚才那个订单帮我再加两件模型会礼貌地回复您说的是哪个订单呢——因为它根本不知道刚才是哪次。加上记忆之后模型能准确引用前几轮提到过的订单号。体验差异是根本性的。4.2 MessageWindowChatMemory 项目实战配置Spring AI 1.1 提供了一个开箱即用的实现MessageWindowChatMemory它维护一个滑动窗口只保留最近 N 条消息。这个 N 不是拍脑袋定的需要结合模型上下文长度和单条消息平均 Token 来估算。我在项目里的用法Bean ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(30) // 最多保留30条消息 .build(); } Bean ChatClient chatClient(ChatClient.Builder builder, ChatMemory memory) { return builder .defaultSystem(你是工单助手。) .defaultChatMemory(memory) .build(); }然后每次请求带上会话 IDString reply chatClient.prompt() .user(刚才那个订单帮我再加两件) .context(ctx - ctx.put(ChatMemory.CONVERSATION_ID, session-12345)) .call() .content();CONVERSATION_ID是维度maxMessages是容量。同一个会话 ID 下的消息会被归入同一个窗口超过 30 条时最早的消息自动淘汰。4.3 线程安全与并发会话串台一场险些造成线上事故的教训这个坑我必须单独拎出来说因为它是我们在压测阶段发现的差点带上生产。MessageWindowChatMemory内部用的是 ConcurrentHashMap不同会话 ID 之间数据隔离做得很好。但如果你在代码里误用了同一个会话 ID或者在同一个请求里并发修改同一个会话问题就来了。我们的真实场景是同一个用户会同时开两个浏览器 Tab 询问不同的问题后端用登录用户 ID 作为会话 ID。结果两个 Tab 的上下文互相污染用户 A 在 Tab1 问库存Tab2 收到的回答竟然带着 Tab1 的上下文。排查链路是这样的先看日志里请求的CONVERSATION_ID确认两个请求确实用了同一个 ID。发现会话 ID 的粒度错了——不应该用用户 ID应该用会话 ID一个 Tab 一个会话。修改前端在建立连接时初始化独立的会话 ID问题解决。4.4 内存版只配测试生产环境必须自己做持久化要特别强调MessageWindowChatMemory是纯内存实现进程一重启所有会话记忆全部丢失。而且当并发会话数上来了内存里存的消息对象堆积非常容易触发OutOfMemoryError。生产环境我做了两件事第一实现ChatMemory接口把消息历史存 Redis。每次读写都走 Redis Hash天然支持会话维度隔离和过期时间Component public class RedisChatMemory implements ChatMemory { private final StringRedisTemplate redisTemplate; Override public ListMessage get(String conversationId, int lastN) { // 从 Redis 读取该会话最近的 N 条消息并反序列化为 Message } Override public void add(String conversationId, ListMessage messages) { // 追加消息到 Redis List并裁剪长度 } Override public void clear(String conversationId) { // 删除 Redis Key } }第二给每个会话设置过期时间。大多数客服场景超过 30 分钟没有新消息的会话上下文基本没用了。在 Redis 里设置 30 分钟过期既节省内存又避免用户隔天回来发现记忆混乱。4.5 Token 膨胀是隐形的成本杀手记忆窗口不是越长越好。maxMessages(50)看起来很美好但每条消息拆成user和assistant两部分模型每次请求要把整个窗口的 Token 都算进去。按平均每条 200 Token 算50 条就是 10000 Token一次请求光历史消息就要烧掉不少钱。我建议用maxMessages(20)搭配一个摘要化策略对话超过 20 轮时把之前的消息压缩成一段摘要比如让模型用一句话总结之前聊了什么既保留关键信息又控制 Token 成本。这个方案我们上线后接口平均延迟下降了 30%Token 消耗下降了接近一半。5. 三件套组装进生产并发场景下的组合拳与崩溃排错实录5.1 一个完整的 Service记忆召回、工具调用、结构化输出一条链前面三章分别讲了三个能力的实战这一章把它们串起来展示真实的业务 Service 长什么样。下面是一个库存查询助手的核心实现Service public class AssistantService { private final ChatClient chatClient; public AssistantService(ChatClient.Builder builder, InventoryTools tools, ChatMemory chatMemory) { this.chatClient builder .defaultSystem( 你是工单助手可以查询订单和库存信息。回答要简洁、准确。 用户问题涉及订单时先查订单涉及库存时先查库存 两者都涉及时按顺序分别查询。 ) .defaultTools(tools) .defaultChatMemory(chatMemory) .defaultOptions(ToolCallingChatOptions.builder() .maxIterations(5) .build()) .build(); } public AssistantResult chat(String sessionId, String userMessage) { return chatClient.prompt() .user(userMessage) .context(ctx - ctx.put(ChatMemory.CONVERSATION_ID, sessionId)) .call() .entity(AssistantResult.class); // 结构化输出 } }请求进来之后框架的处理顺序是根据sessionId从 Memory 召回最近的消息历史拼接进 Prompt。Prompt 发送给模型模型判断是否需要调用工具。如果需要框架自动执行对应 Java 方法把结果返回给模型模型继续生成回复。最终回复通过AssistantResult的结构化约束输出框架保证返回的是合法 JSON并完成 POJO 绑定。这套链路我在本地压测过并发 50 个会话、每个会话 10 轮对话接口 P95 稳定在 3 秒内含模型推理时间没有出现串会话和解析异常。前提是下面几个生产配置都到位。5.2 生产环境必须压测的三个参数三件套组合使用后接口不再是一个简单的 HTTP 请求它内部有多次模型调用、工具调用、内存读写。生产环境我压测时重点调了三个参数第一超时时间。默认的连接超时往往不够用。工具调用一轮就要 2-3 秒如果模型决策要调 3 个工具整体可能超过 10 秒。我把 RestClient 的 connect/read 超时都调到了 30 秒避免代理层把慢请求掐断spring: ai: openai: base-url: ${LLM_BASE_URL} api-key: ${LLM_API_KEY} chat: options: temperature: 0.2第二并发线程池。每个 ChatClient 请求会阻塞一个 Tomcat 线程。如果接口被大量并发打到线程池满了之后后面的请求会排队。我用虚拟线程Spring Boot 3.4 默认开启之后问题缓解了很多。第三回调与重试。模型接口偶尔会返回 429 或 5xxSpring AI 自带重试机制但默认的重试策略在某些版本里退避时间太短容易把下游打挂。我手动配置了重试间隔和最大次数Bean RetryTemplate retryTemplate() { return RetryTemplate.builder() .maxAttempts(3) .exponentialBackoff(1000, 2, 10000) .build(); }5.3 崩溃排错实录0xc0000005、OutOfMemoryError 与堆转储分析这一节必须写因为我们在压测过程中真的遇到过进程崩溃而且不是普通的业务异常——是 JVM 直接退出日志里出现process exited with code 3221225477也就是 Windows 下的0xc0000005内存访问违规。第一次看到这个错误码我很懵一开始以为是 Spring AI 的 bug后来一步步排查才知道问题出在 JVM 启动参数上——MaxRAMPercentage设得太高加上本机内存不足JVM 在 GC 时尝试分配内存失败触发了 native 层的崩溃。简单说不是框架的错是内存不够分了。排查过程中我们做了四件事打开 crash 日志JVM 崩溃时会在工作目录下生成hs_err_pidxxx.log查看崩溃线程的栈帧确认是 GC 线程还是编译线程。看堆内存使用用jstat -gcutil pid观察老年代使用率是否持续高位。导出堆转储用jmap -dump:formatb,fileheap.hprof pid抓一份堆快照。用 Eclipse MAT 分析打开堆转储查看Dominator Tree找谁占了最多的内存。分析结果出人意料占用内存最多的不是消息历史而是日志框架缓存。我们当时在每次工具调用时打印了全量 Prompt而 Prompt 里包含了完整的历史消息几十个并发会话下日志缓冲瞬间吃满内存。把日志级别从 DEBUG 调回 INFO、去掉大对象打印后内存占用骤降 60%。另外还有一个典型的OutOfMemoryError: Insufficient memory场景Native Memory 泄漏。Java 堆没问题但 Metaspace 或 Direct Memory 被撑爆。这是我在排查另一个问题时遇到的现象是系统运行几天后突然 OOM堆转储分析 Java Heap 却很健康。后来通过jcmd pid VM.native_memory查看 Native Memory 跟踪信息才发现是某个库在不停地分配 Direct Buffer 没释放。定位到具体框架代码后加了一个缓冲池复用问题解决。5.4 结构化输出与工具调用组合时的隐藏坑格式约束优先级最后分享一个比较隐蔽的问题当工具调用返回结果时结构化输出的约束可能会被跳过。具体表现是模型调用了工具、拿到了库存数据然后直接返回了一大段自然语言描述完全不按照AssistantResult的 JSON Schema 来。原因在于工具调用本身是一次独立的内部请求模型在工具调用模式下更倾向于自由发挥结构化约束可能在内部切换时丢失。我的解决方式是把结构化约束同时写进 System Prompt而不是只依赖.entity()方法.defaultSystem( 你是工单助手。 你的回答必须是一个 JSON 对象包含以下字段 orderId: string, status: string, items: array, summary: string。 不要输出 JSON 以外的任何内容。 )双保险之下这个问题基本没有再出现过。6. 两个提高排错效率的小技巧同行之间的通用经验聊点运维层面的通用经验比功能代码更重要。如果你也打算在生产环境大规模使用 Spring AI 三件套下面两个技巧能帮你省掉大量排查时间。第一个技巧给每次请求生成 TraceId 并贯穿日志。对话场景下一次用户请求会触发多轮内部调用记忆召回、工具执行、模型生成没有 TraceId 的话排查问题就是在日志海里捞针。我是在 Filter 里生成 TraceId放进 MDC然后在 ChatClient 的每个环节都打上这个 TraceId。这样一次请求的所有相关日志都能用grep traceid一次性捞出来。第二个技巧工具调用开启请求响应快照。Spring AI 的ToolCallingManager支持配置工具调用结果的缓存但更重要的是把模型每次返回的原始响应记录下来。我写了一个简单的 ChatClient 拦截器把模型决策的工具名称、参数 JSON、执行结果、耗时都打到独立日志文件。上线初期靠这份日志我几乎每天都能发现模型选错工具或者参数传错的情况及时优化工具描述。public class TracingToolCallInterceptor implements ToolCallingInterceptor { Override public ToolResponse intercept(ToolRequest request, ToolCallingInterceptorChain chain) { long start System.currentTimeMillis(); log.info(ToolCall start: name{}, args{}, request.name(), request.arguments()); try { ToolResponse response chain.next(request); log.info(ToolCall end: name{}, cost{}ms, result{}, request.name(), System.currentTimeMillis() - start, response); return response; } catch (Exception e) { log.error(ToolCall error: name{}, args{}, request.name(), request.arguments(), e); throw e; } } }这段代码看着不起眼但它把我排查问题的平均时间从小时级压缩到了分钟级。模型是概率系统出问题不丢人丢人的是你不知道它哪一步出的问题。7. 说点真话这套组合拳在生产环境的真实取舍如果你已经跟着前面的代码把三件套全部接上了恭喜你你已经超过了大多数只会在 Demo 里用 Spring AI 的开发者。不过我必须说几句实在的第一不要神话结构化输出。它能显著降低解析失败率但无法做到 100%。只要你用 LLM就必须接受偶发的不确定性这件事。方案上做好重试、降级、兜底心态上也要接受——这不是框架 bug而是这种架构模式的固有特征。第二工具数量不是越多越好。我见过有人在系统里挂了 30 多个工具结果模型选错的概率急剧上升。模型面对大量工具时就像一个人面对 30 个按钮按错的概率肯定比面对 3 个按钮高。工具数量控制在 10 个以内每个工具的描述写清楚比堆数量有用得多。第三Memory 的持久化一定要提前做。内存版再好用生产环境也一定要换成 Redis 或数据库实现。我见过最惨痛的案例同事在测试环境跑了两个星期积累了满满的内存记忆一次发版重启全部丢失用户回来问我的上下文呢只能苦笑。第四组合使用时记得做全链路可观测。三件套里任何一个环节出问题表象都可能一样——用户觉得AI 变笨了或者回答变慢了。没有全链路日志和监控你就只能靠猜。我个人在实际操作中的体会是Spring AI 1.1 的三件套设计得确实比 1.0 时代成熟许多它把 LLM 应用开发从调 API提升到了构建可靠对话服务的层面但这并不意味着你可以不干活了——框架解决了 80% 的通用问题剩下 20% 的领域细节比如工具描述怎么措辞、记忆窗口调多大、结构化字段怎么设计依然需要你根据自己的业务场景反复打磨。这篇文章写的这些坑都是我用真实流量和加班换来的如果你能少踩几个就算没白写。