最近在做客服工单自动录入的 Demo 时,被一个问题卡了很久:用户分好几轮才把订单号、收货地址、问题描述说全,而模型每一轮都是无状态的,上一句说了什么它根本不记得;更麻烦的是,这些信息最终要写进数据库,不可能把聊天原文直接怼进去。把 Spring AI 的对话记忆和结构化输出接在一起跑通之后,这个链路才算彻底顺了。这篇就把我实测下来的设计方案、核心代码和踩坑记录整理出来,给同样在折腾这两块的人做个参照。
我以前也试过"先把所有聊天记录攒起来,最后一次性丢给模型抽取"的土办法,结果对话稍微长一点,Token 直接爆掉,而且模型经常把早期无用信息当成目标字段。真正在生产可用的方案里,对话记忆不只是一个存储功能,它是消息历史的编排策略;结构化输出也不只是解析 JSON,它是约束模型表达方式的手段。两件事单独做都不复杂,组合在一起时,才会遇到真正的边界问题。
1. 对话记忆与结构化输出:表面看是两件事,本质都在解决"上下文不可控"
1.1 很多项目不是缺模型能力,而是缺上下文管理
最初接到这个需求时,团队里有人觉得很简单:把用户聊天记录存进 Redis,下一次提问时把最近几条拼到 Prompt 里,再由模型做信息抽取,这不就行了?实际跑了两轮就露馅了。
模型接口本身是无状态的,每次请求都像第一次见面。你让它"提取订单号",它确实会提取,但第二句如果用户说"地址是上海市XX路 100 号",模型并不知道这个地址属于这个订单还是另一个东西。没有上下文管理,结构化输出就是空转。
真正的问题不是"存不存得下",而是"在什么时机、用哪些消息、以什么顺序去构造一次请求"。Spring AI 里的 ChatMemory 并不是一个简单的存储接口,它管理的是消息生命周期:谁应该保留、谁应该被裁剪、系统提示词如何与历史消息共存。
1.2 常见误区:记忆=存聊天记录,结构化=解析 JSON
这个误区我见过不止一次。把记忆理解成一个 Map,key 是会话 ID,value 是字符串列表,这是很多自定义方案的起点,也是后来维护成本暴增的起点。
记忆至少要回答三件事:
- 保留什么:用户最后一句、系统提示词、前面对话中已经确认过的关键事实,这些是有用的;寒暄、重复确认、过期的临时状态,这些是有害的。
- 保留多久:窗口大小应该按 Token 算还是按条数算,不同模型的上下文窗口不一样。
- 如何裁剪:直接丢掉最老的,还是把早期内容压缩成摘要,这决定了对话超过一定长度后模型是否"失忆"。
结构化输出的误区更普遍:很多人觉得只要在 Prompt 里写一句"输出 JSON",然后拿到字符串再用 Jackson 转成对象就行。实际模型输出的 JSON 经常带着 ```json 标记、多出逗号、字段缺失,甚至把枚举值写成一句话描述。Spring AI 的 OutputConverter 做的事情比JSON.parse多得多:它会在请求阶段把目标结构的 Schema 描述注入 Prompt,在响应阶段对格式做清洗和校验。
1.3 绑定使用的典型场景
这两个功能单独拆开都能用,但真正发挥价值的是下面这类场景:
| 场景 | 记忆端要解决什么 | 结构化输出端要解决什么 |
|---|---|---|
| 客服工单自动录入 | 用户分多轮补充订单号、地址、问题描述 | 输出字段需要直接对应工单表结构 |
| 智能填表/预约助手 | 需要记住用户之前填过的值 | 生成结构化表单对象,防止脏数据入库 |
| 内容分类助手 | 需要记住用户对类别的偏好调整 | 输出稳定的枚举值和置信度 |
| 数据分析 Agent | 需要引用前面几轮的筛选条件 | 输出可执行的查询参数对象 |
我自己做的是客服工单自动录入,所以后面的代码示例基本围绕这个场景展开。
2. 对话记忆的落地细节:ChatMemory 不是缓存,是消息编排策略
2.1 消息历史怎么做:理解角色分配与三类消息
在 Spring AI 的模型抽象里,消息主要分三类:
- SystemMessage:系统级指令,不随对话变化。
- UserMessage:用户输入。
- AssistantMessage:模型上一次的输出。
记忆的核心任务就是把这三类消息按正确顺序送进模型请求。很多人自定义记忆时只存 UserMessage,遇到模型需要参考自己之前回答的场景,效果就会打折。比如用户先问"我订单还没收到怎么办",模型回答"请提供订单号",用户接着给出订单号,如果历史里没有 AssistantMessage,模型就不明白"请提供订单号"这个上下文自己已经说过了。
Spring AI 的ChatMemory接口设计得很收敛:
public interface ChatMemory { void add(ChatMemory.ConversationId conversationId, List<Message> messages); List<Message> get(ChatMemory.ConversationId conversationId, int lastN); void clear(ChatMemory.ConversationId conversationId); }它不关心消息是存在内存还是 Redis,只负责按会话维度读写消息列表。具体存储实现由你自己选择。
2.2 窗口裁剪与 Token 预算:别等到 200 条消息才处理
推荐使用MessageWindowChatMemory,它是官方提供的内存版实现,核心配置是两个参数:
ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .maxMessageSizeBytes(4096) .build();maxMessages限制注入 Prompt 的历史消息条数,maxMessageSizeBytes限制单条消息体积。这里我踩过一个大坑:只看条数不看字节数。用户某条消息里贴了一大段日志,单条消息直接 8KB,窗口就算只有 10 条,Prompt 也被撑爆。所以这两个参数必须同时设。
Token 预算可以按这个公式估算:
可用历史 Token = 模型上下文长度 - 系统提示词占用 Token - 业务场景固定指令 Token - 结构化输出的格式说明 Token - 预留的生成 Token以 8K 上下文的模型为例:系统提示词 500 Token,固定指令 300 Token,格式说明 200 Token,生成预留 500 Token,剩下约 6500 Token 给历史消息。一条中文消息约 1.5 到 2 个 Token/字,折算下来历史消息总量不宜超过 3000 到 4000 字。所以窗口设置 20 条只是起步,真正要参考的是 Token 预算。
2.3 会话隔离:conversationId 管理不当会出现串话
对话记忆最危险的不是"记不住",而是"记串了"。所有用户共享一个全局记忆的话,用户 A 说"我家地址是上海",用户 B 查询工单时模型也会默认地址是上海,这种事故在测试期很难发现,因为测试时往往只有一个用户。
Spring AI 的所有记忆操作都绑定conversationId。我用的方案是网关层直接生成 UUID,把它放在请求头里传给后端,后端用ChatMemory.ConversationId包一层:
ChatMemory.ConversationId conversationId = ChatMemory.ConversationId.from(headerConversationId);需要注意:同一个业务会话 ID 不能复用给多个任务。我见过一个系统把登录会话 ID 直接当成对话 ID 用,用户打开两个页面,消息互相穿插,最后的抽取结果全乱了。业务上应该单独维护对话 ID,与登录态解耦。
2.4 接入 ChatClient:推荐用 Advisor 而不是手动拼装
Spring AI 提供两种接记忆的方式,一种是手动把历史消息拼到请求里,一种是用 Advisor 自动处理。我一开始图省事选择手动拼装,后来发现要处理裁剪、清空、跨请求传递 conversationId 一堆细节,代码越来越脏。后来改成 Advisor:
@Configuration public class AiConfig { @Bean public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory) .build()) .build(); } }调用端只需要传conversationId即可:
String answer = chatClient.prompt() .user("请帮我查一下订单状态") .advisors(a -> a.param("chatMemoryConversationId", conversationId)) .call() .content();Advisor 会自动走一遍"读取历史 -> 组装消息 -> 调用模型 -> 写回新消息"的流程。这样手动管理消息顺序的问题基本被框架兜住了。
3. 结构化输出:从"让模型生成"到"让代码可用"
3.1 OutputConverter 到底做了什么
很多教程只告诉你用BeanOutputConverter,但不讲它为什么能工作。核心机制是:
- 在请求阶段,利用 Jackson 从目标类型反向生成 JSON Schema 描述,拼进 Prompt,告诉模型"务必按这个结构输出"。
- 在响应阶段,对模型输出做清洗,剥离 Markdown 代码块标记,再执行 JSON 反序列化。
- 反序列化失败时抛异常,由业务层决定怎么兜底。
有一点需要明确:它并不能 100% 保证模型输出合法 JSON。所谓"结构化输出"是一个概率性的约束,不能当成硬性契约。我的理解是:它能显著降低解析失败率,但你要按 99% 可用而不是 100% 可用来设计整体方案。
3.2 BeanOutputConverter 与 record 类型配合
给一个最简单的例子。假设工单对象设计成 record:
public record TicketInfo( @JsonPropertyDescription("用户姓名,如果不知道则为空") String customerName, @JsonPropertyDescription("联系电话,必须是11位手机号或带区号的座机") String contactPhone, @JsonPropertyDescription("问题类别") String category, @JsonPropertyDescription("详细问题描述") String description ) {}使用时注意,要用 record 而不是普通 class,能省掉一堆 getter 和构造器。代码这样写:
BeanOutputConverter<TicketInfo> converter = new BeanOutputConverter<>(TicketInfo.class); String response = chatClient.prompt() .system("你是客服工单系统,从对话中提取信息,不要推断不存在的内容。") .user("用户说:我的手机尾号2025,订单号是10086,一直没收到货。") .advisors(a -> a.param("format", converter.getFormat())) .call() .content(); TicketInfo ticketInfo = converter.convert(response);关于converter.getFormat(),这个格式化指令必须出现在 Prompt 的最后面,太靠前容易被长对话冲淡。我用 Advisor 参数注入到 user 消息末尾,而不是扔进 system 提示里,实测效果好很多。
3.3 嵌套结构、列表、枚举:别让 JSON 字段没法入库
真实工单字段不会像上面那么简单,往往有地址对象、产品列表、紧急程度枚举。此时需要在 record 里嵌套定义:
public record OrderIssueTicket( @JsonPropertyDescription("用户姓名") String customerName, @JsonPropertyDescription("收货地址") Address address, @JsonPropertyDescription("问题商品列表") List<String> productNames, @JsonPropertyDescription("紧急程度:LOW/MID/HIGH") Priority priority ) { public record Address( @JsonPropertyDescription("省") String province, @JsonPropertyDescription("市") String city, @JsonPropertyDescription("详细地址") String detail ) {} public enum Priority { LOW, MID, HIGH } }这里我的经验是:枚举字段一定要在@JsonPropertyDescription里把所有可选值写清楚。模型对英文枚举的感知很弱,你不给它选项,它倾向于输出"中等优先级"这种中文描述,反序列化必然失败。而列表字段最好限制为"问题商品列表"这类有边界的集合,别让模型自由发挥成一个多级嵌套结构,嵌套越深,失败率越高,解析成本也越高。
3.4 模型输出 JSON 不规范时的兜底
我在项目里遇到过三种高频失败形态:
- 输出被包裹在
json ...代码块里。 - 输出末尾被截断,少了右花括号。
- 字段值混入解释性文字,比如
"描述": "用户说: 我订单没收到"。
BeanOutputConverter本身会处理代码块剥离,但截断问题它不负责。我的流程是先走一次converter.convert(response),捕获JsonParseException之后再尝试清理:
public TicketInfo convertSafely(String raw) { try { return converter.convert(raw); } catch (RuntimeException ex) { String cleaned = raw.trim(); if (cleaned.startsWith("```json")) { cleaned = cleaned.replace("```json", "").replace("```", ""); } // 如果截断,尝试补右括号 if (!cleaned.endsWith("}")) { cleaned = cleaned + "}"; } try { return converter.convert(cleaned); } catch (RuntimeException ignored) { // 还是失败就返回空对象,让业务流程重试 return null; } } }注意补右括号是"能用但不优雅"的手段,它只能救回少量被截断的响应。真正有效的做法是降低maxTokens的冲动,给生成阶段留足长度,同时在 Prompt 里强调"不要输出任何解释,只输出 JSON"。
4. 组合实战:带记忆的多轮信息抽取与工单入库
4.1 数据模型与 Prompt 设计思路
单轮抽取很容易,难的是用户像挤牙膏一样,三轮才把信息说全。所以 Prompt 设计时要传达一个关键原则:不知道的字段留空,绝对不要编造。
系统提示词我这样写:
你是客服工单信息抽取助手。 当前对话可能包含多轮用户消息,请结合上下文抽取工单信息。 注意: 1. 只抽取用户在对话中直接说出的信息,不要推理和补充。 2. 未提供的字段一律输出空字符串或 null。 3. 输出必须与给定的 JSON 格式一致。 4. 如果用户本轮给出了冲突信息,以最新消息为准。这个 Prompt 和 OutputConverter 的格式指令会合并成最终请求。不要用复杂比喻去"帮助理解",信息抽取任务最重要的是规则清晰、边界明确。
4.2 完整实现代码:ServiceImpl 里怎么串起来
以下是本轮实战中的核心代码,去掉业务无关干扰后保留主干:
@Service public class TicketExtractService { private final ChatClient chatClient; private final BeanOutputConverter<OrderIssueTicket> converter = new BeanOutputConverter<>(OrderIssueTicket.class); public TicketExtractService(ChatClient chatClient) { this.chatClient = chatClient; } public OrderIssueTicket extractFromConversation(String conversationId, String userMessage) { String format = converter.getFormat(); String response = chatClient.prompt() .system("你是客服工单信息抽取助手。" + "只抽取用户实际提供的信息,不要推断。" + "未提供的字段输出 null。" + "如果用户本轮有冲突信息,以最新消息为准。") .user(userMessage + "\n\n" + format) .advisors(a -> a.param("chatMemoryConversationId", conversationId)) .call() .content(); return convertSafely(response); } }注意几个细节:
- 没有把整个历史消息手动塞进 Prompt,记忆全部交给 Advisor 完成。
format每轮都会追加,而不只是第一轮,防止模型在中后期忘记输出结构。- 没有在方法里额外保存"中间抽取结果"到数据库,每轮结果直接覆盖返回,由业务层决定是否落库。
4.3 实测效果:三轮对话后的完整结果
我用一段模拟对话做回归测试:
用户第一轮:我手机订单一直没收到,能帮我查吗? 用户第二轮:订单号是20250101,收货地址是上海市浦东新区XX路100号。 用户第三轮:产品是蓝牙耳机和充电器,问题是很急,麻烦尽快处理。每一轮调用extractFromConversation后,解析出的对象字段如下:
| 轮次 | customerName | address | productNames | priority |
|---|---|---|---|---|
| 第一轮 | null | null | null | null |
| 第二轮 | null | 上海/浦东/XX路100号 | null | null |
| 第三轮 | null | 上海/浦东/XX路100号 | [蓝牙耳机, 充电器] | HIGH |
客户姓名从头到尾没出现,模型没有乱编,这是预期的。第四轮用户补一句"我叫李明"后,再抽取一次:
customerName: 李明 address: 上海市浦东新区XX路100号 productNames: [蓝牙耳机, 充电器] priority: HIGH到这里,整条链路才真正闭环。多轮缺失字段被逐步补全,记忆和结构化输出没有互相干扰。
5. 坑都踩平了:记忆污染、JSON 截断与并发顺序错乱
5.1 记忆污染:上一个任务的残影干扰当前抽取
这是我遇到的第一个严重问题。某个会话第一次抽取的是"售后工单",第二次用户问"顺便帮我查一下积分",模型在抽取第二次工单时居然把"查积分"也塞进了问题描述里。
根因是对话记忆是连续累积的,模型拿到的是整个会话历史,它并不知道"当前抽取目标"是什么,以为所有话题都属于当前工单。
解决思路也很直接:在 Prompt 中动态注入当前任务边界,明确"以下对话中,只抽取与售后工单相关的信息,其他话题忽略"。同时在 Advisor 里对历史消息做一次过滤,去掉与当前任务无关的、由模型生成的旧抽取结果。我是通过自定义 Advisor 实现的,核心逻辑是遍历历史消息,遇到包含"工单抽取结果"字样的 AssistantMessage 就跳过。
5.2 JSON 被截断:不是模型笨,是生成长度预算给少了
刚开始我把maxTokens设得很小,想着工单字段不多,512 Token 足够了。结果产品列表稍微长一点,模型输出在数组中间被切断,整个响应无法解析。
这个坑的教训是:生成 Token 预算要按最坏情况算。一个工单对象可能有 10 个字段,其中描述字段可能 100 字,产品列表可能 5 项,512 Token 其实非常紧张。我调整到 1024 Token 后,截断问题基本消失。
如果模型支持,还可以开启responseFormat相关的原生结构化模式,Spring AI 的ChatClient会根据模型自动选择走 json_schema 还是 prompt 约束,能进一步降低截断概率。
5.3 并发写同一会话导致消息顺序错乱
线上出现过一个诡异现象:用户在一轮对话里发了 5 条短消息,模型第一轮回复时,历史里居然只剩最后两条。排查后发现是前端并发发送消息,同一个 conversationId 下多个线程同时写入 ChatMemory。
内存版MessageWindowChatMemory对单线程是安全的,但多线程并发写同一个会话时,lastN的截取和写入顺序会出现竞态。处理方式有两种,要么在业务层对同一会话的调用加锁串行化,要么按消息序号做事务性追加。考虑到客服场景本身不适合并发交错,我选择了对同一 conversationId 加 JVM 锁。
5.4 自查清单与回归测试
最后放一份我在项目里沉淀的检查清单,每次改完代码都会跑一遍:
- 同一会话连续三轮抽取,字段能逐步补全。
- 新会话不带上一次会话的任何信息。
- 客户名缺失时,模型不会编一个"先生/女士"。
- 用户中途切换话题时,当前抽取结果不会被污染。
- 超长描述输入后,系统提示和格式指令仍完整保留。
- 连续 20 次调用,JSON 解析失败率低于 2%。
回归测试我会用 Spring Boot Test 直接调 Service 层,填入上面那组模拟对话,断言最终对象的地址和商品列表,不用每次都打真实模型,节省成本也稳定。
写到这里,我想把最值得记的一点单独说出来:对话记忆和结构化输出,单独看都是框架里的一个开关和一段配置,真正决定项目成败的是对边界的处理。我的做法是把记忆当成"策略"而不是"存储",把结构化输出当成"约束"而不是"解析",两者通过明确的 Prompt 任务边界互相隔离,再通过 conversationId 串联起来。这套思路落进客服工单场景后效果明显,换成订单查询、线索清洗这些场景,改改 Prompt 和数据结构就能复用。