💡上一篇把航司客服做成 RAG 问答之后,产品同学提了个看起来很小的需求:"能不能让用户一句话就把改签办完,别再让他填那张 8 个字段的表。"我一开始的判断是:这不就是多加几轮追问吗,两小时的活。
真做起来才发现难的不是让模型多问几句,而是三件事同时成立:模型得知道还缺哪个字段、得知道哪些字段不能由它自己填、还得在用户中途说"算了不退了"的时候干净地退出而不留下一半状态。更麻烦的是 2.0 的对话记忆接口和 1.x 长得不一样——ChatMemory.get(conversationId, lastN)这个签名在新版本里已经没了,窗口大小改由构造参数决定。
所以这一篇不谈"聊天界面多好看",只谈后端要还的债:意图与槽位怎么用结构化输出落地、多轮状态存在哪、流式和中断怎么处理、以及哪些表单压根不该改成对话。第十七篇:对话式 UX 重构,从 8 字段表单到多轮槽位填充,到底哪些环节值得改
1. 对话式不是"少几个输入框"
先把判据说清楚,否则很容易做成一个更难用的表单。
表单和对话的差别不在交互形式,在谁持有流程状态:
表单:状态在前端 + 数据库 用户填 8 格 --> 一次提交 --> 校验 --> 落库 对话:状态在服务端会话 用户说话 --> 模型抽字段 --> 缺什么问什么 --> 确认 --> 落库 ↑ 这一层是你新写的代码,不是框架给的| 维度 | 表单 | 对话式 | 谁承担成本 |
|---|---|---|---|
| 字段缺失 | 前端红框提示,用户自己补 | 模型判断缺哪个槽,主动追问 | 后端要存"当前已收集到什么" |
| 字段非法 | 正则 / 类型校验,即时 | 模型可能给出2026-13-45 | 服务端必须二次校验,不能信模型 |
| 中途改主意 | 刷新页面即回到干净状态 | "算了不改成后天了"要能撤销 pending 动作 | 会话状态机要可回退 |
| 上下文引用 | 不支持("上次那个单号"要重打) | 天然支持 | 记忆窗口 + 隔离(见第 3 节) |
| 用户输入成本 | 高(8 格,2 个下拉,1 个日期控件) | 低(一句话) | — |
| 可测性 | 高(E2E 脚本稳定) | 低(同输入不同问法) | 评测集,见第十二篇 |
判断标准我浓缩成一句:字段多、有依赖关系、用户记不住规则的场景才值得改;两三个字段能选完的,别改。
| 场景 | 建议 | 理由 |
|---|---|---|
| 改签(订单号 / 目标日期 / 差价确认 / 座位偏好) | ✅ 改 | 4 个字段有依赖(先有单才能改),且"目标日期"用户会说话表达(“下周三下午”) |
| 修改手机号 | ❌ 不改 | 2 个字段,一次表单更快,且涉敏感操作走对话反而多一轮 |
| 报销单(十几字段 + 明细行) | ✅ 改,但保留表单兜底 | 明细行用自然语言录入省大量时间;金额校验仍需前端展示 |
| 密码重置 | ❌ 不改 | 安全路径不该引入模型判断,见第十八篇 |
笔者(后端 & 架构)的顺序是:先做对话填单 + 表单确认这种混合模式,跑三个月,看用户是否真的少打电话,再考虑把整条流程改成纯对话。
2. 槽位抽取:用结构化输出,不要用"多轮自由聊"
这是全篇最值钱的一节:把 NLU 那套意图分类 + 实体抽取,换成一次带 schema 的模型调用。
2.1 传统 NLU 的三件套,在 LLM 时代塌缩成一步
传统:ASR/文本 --> 意图分类模型 --> 实体抽取(NER)--> 槽位映射表 --> 状态机追问 现在:文本 --> 一次 LLM 调用(system 里写清 schema)--> Record 对象省掉的是意图分类器和槽位映射表;没省掉的是追问逻辑——它从"模型自己决定问什么"变成"你根据缺失字段决定问什么"。别指望模型在一个自由对话里既理解又推进流程,那会把不可控的地方堆得最多。
2.0.1 的落点就是.call().entity(...)(ChatClient$CallResponseSpec有entity(Class<T>)、entity(ParameterizedTypeReference<T>)、entity(StructuredOutputConverter<T>)三族重载),配合一个 Record:
publicrecordChangeFlightIntent(@NullableStringbookingCode,// 不给 required:缺失就是"要问",不是"报错"@NullableStringtargetDate,// ISO yyyy-MM-dd,归一化在服务端做@NullableStringseatPreference,StringuserUtterance){// 原话留一份,用于审计与追问复述}@ServicepublicclassSlotFillingService{privatestaticfinalStringSYSTEM=""" 你是航空客服的意图抽取器。只做抽取,不做回答。 规则: 1. 只输出 JSON,字段缺失时留空,严禁编造订单号或日期; 2. 相对日期("下周三")按 today 参数换算成 yyyy-MM-dd; 3. 用户没提到的槽位必须为空,不要根据常识补全。 """;publicChangeFlightIntentextract(StringuserText,StringconversationId){returnthis.chatClient.prompt().system(SYSTEM).user(u->u.text(userText).param("today",LocalDate.now().toString()).param("conversationId",conversationId)).advisors(a->a.param(ChatMemory.CONVERSATION_ID,conversationId)).call().entity(ChangeFlightIntent.class);}}@ToolParam那套 required 语义在这里不适用(这是输出不是工具入参),但**“宁缺毋滥"必须写在 system 里**——模型最擅长的就是把缺失字段漂亮地编出来。这也是第七篇讲的 schema 校验之外的第二道:语义层的"不许补全”。
2.2 追问由代码生成,不由模型生成
拿到 intent 之后,缺什么、先问哪个,用普通 Java 决定:
publicDialogueTurnadvance(StringconversationId,StringuserText){ChangeFlightIntentcollected=this.state.merge(conversationId,this.slotFillingService.extract(userText,conversationId));Optional<String>missing=firstMissing(collected);// 顺序 = 业务优先级if(missing.isPresent()){returnDialogueTurn.ask(missing.get(),collected);// 问法固定模板,别每次让模型现编}Validationverdict=this.bookingService.validateChange(collected);// 日期合法性、票种是否可改if(!verdict.ok()){returnDialogueTurn.reject(verdict.reason());// 拒绝理由来自服务端,不来自模型}returnDialogueTurn.confirm(collected,verdict.diffAmount());// 复述式确认卡片}两个刻意的选择:
- 问法用模板。让模型自由组织追问话术,同一会话三轮三种问法,前端气泡很难看,评测也没法写断言。把话术收敛到
messages.properties里,模型只负责抽取。 - 校验永远在服务端。
2026-13-45这种日期模型会照样输出;票种不可改这种业务规则模型不可能知道。对话式的"聪明"只体现在理解输入,不体现在判断合法性。
StructuredOutputValidationAdvisor(在spring-ai-client-chat的 advisor 包里)可以再加一层:输出不符合 schema 时自动带错误信息重试。它管格式,不管语义——这两件事别混。
3. 多轮状态:ChatMemory在 2.0.1 的真实形状
这一节先讲版本断层,因为照抄 1.x 示例会直接编译不过或者语义变了。
3.1 三处必须知道的差异
| 事项 | 1.x 常见写法 | 2.0.1 实际 | 影响 |
|---|---|---|---|
| 取历史 | chatMemory.get(conversationId, lastN) | ChatMemory只有get(String)——没有 lastN 重载 | 窗口大小改由MessageWindowChatMemory.builder().maxMessages(n)决定,是构造期配置不是读取参数 |
| 隔离会话 | 有的示例漏传conversationId | 文档明确:省略该参数运行时抛IllegalArgumentException,没有默认值 | 每个请求都要.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id)) |
| Advisor 构造 | 直接new MessageChatMemoryAdvisor(chatMemory)多个重载 | MessageChatMemoryAdvisor.builder(ChatMemory)起 builder,.order(int)默认HIGHEST_PRECEDENCE + 200 | 与工具 Advisor 的顺序要显式想清楚(3.3) |
接口本身很小(javap全量):
publicinterfaceChatMemory{StringCONVERSATION_ID="conversationId";defaultvoidadd(StringconversationId,Messagemessage);// 委托给 List 版本voidadd(StringconversationId,List<Message>messages);List<Message>get(StringconversationId);voidclear(StringconversationId);}publicinterfaceChatMemoryRepository{List<String>findConversationIds();List<Message>findByConversationId(StringconversationId);voidsaveAll(StringconversationId,List<Message>messages);voiddeleteByConversationId(StringconversationId);}也就是说:记忆的"策略"在MessageWindowChatMemory(截多少条),"存储"在ChatMemoryRepository(存哪),两件事已经彻底拆开。2.0.1 官方 repository starter 有五种:spring-ai-starter-model-chat-memory-repository-{jdbc,redis,cassandra,neo4j,mongodb},另有 in-memory 实现,具体选型对比见 第六篇。
@BeanChatMemorychatMemory(JdbcChatMemoryRepositoryrepo){returnMessageWindowChatMemory.builder().chatMemoryRepository(repo).maxMessages(20)// 一轮"用户 + 助手"是 2 条,20 条 ≈ 10 轮.build();}maxMessages对对话式填单特别关键:窗口太小,第 5 轮追问时模型已经不记得用户第 1 轮说的订单号了;窗口太大,Token 和成本线性上涨(第三篇、第十三篇)。我的经验值:槽位数量 × 2 + 4起步,再按评测集调。
3.2 真正该单独存的,不是聊天记录
对话式流程有个陷阱:把"当前收集到哪些字段"寄托在模型从历史里重新读出来。窗口一裁、或者用户插了一句闲聊,抽取就飘。正确做法是会话状态与消息历史分开存:
// 消息历史:给模型看(ChatMemory,Redis / JDBC)// 收集状态:给代码看(自己的表,带 TTL)@Document("dialogue_slot_state")// 或一张 MySQL 表publicclassDialogueState{@IdStringconversationId;StringflowId;// "CHANGE_FLIGHT"ChangeFlightIntentcollected;// 已收集的槽位InstantupdatedAt;intaskCount;// 追问次数,超过 3 次转人工StringpendingActionId;// 待确认动作,见第十八篇的 decision}这样"用户说算了"只要把pendingActionId清掉、状态置空,不依赖模型理解"算了"之后历史里那句话还在不在。
TTL必设。Redis 的话直接EXPIRE;用 JDBC 的话加个定时清理。会话状态留着不动,半年后会有一堆半死流程等着人工排查。
3.3 记忆 + 工具在同一轮里不打架
第十一篇 里那句"难的是让记忆、检索、工具各只干自己该干的那一次",在 2.0.1 有具体的机制支撑。ChatClient.Builder自动注册工具 Advisor 时会检查链路上有没有记忆 Advisor:
booleanhasDownstreamMemoryAdvisor=this.advisors.stream().anyMatch(a->ainstanceofMemoryAdvisor&&a.getOrder()>configuredOrder);this.advisors.add(this.toolCallingAdvisorBuilder.copy().conversationHistoryEnabled(!hasDownstreamMemoryAdvisor)// ← 有外层记忆就关掉内部历史.build());数值关系是这样的:ToolCallingAdvisor.DEFAULT_ORDER = HIGHEST_PRECEDENCE + 300,记忆 Advisor 默认HIGHEST_PRECEDENCE + 200(Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER)。200 < 300,所以默认配置下记忆 Advisor 在外层,框架据此关掉工具循环的内部历史,避免每一轮工具迭代都重写一遍会话记忆。
如果你把记忆 Advisor 的 order 手调到 400(想让它只作用于最后一次模型调用),这个自动判断会反过来——conversationHistoryEnabled变true,工具循环自己维护历史,而记忆在循环内层反复读写。这类"顺序改了个数字,行为静默变化"的坑,我在第十一篇 5.2 记过一次,对话式场景更容易踩到,因为追问轮数天然多。
4. 流式、中断、重定向
对话式对延迟极其敏感:一个"追问"要 3 秒才开口,用户就以为卡住了。
4.1 端点
2.0.1 的流式出口是Flux(.stream().content()返回Flux<String>,.chatClientResponse()返回Flux<ChatClientResponse>),WebFlux 侧直接吐 SSE 就行:
@GetMapping(value="/api/assistant/stream",produces=MediaType.TEXT_EVENT_STREAM_VALUE)publicFlux<ServerSentEvent<String>>stream(@RequestParamStringconversationId,@RequestParamStringtext,Authenticationauth){Flux<ServerSentEvent<String>>tokens=this.chatClient.prompt().user(text).advisors(a->a.param(ChatMemory.CONVERSATION_ID,conversationId)).toolContext(Map.of("userId",auth.getName())).stream().content().map(chunk->ServerSentEvent.builder(chunk).event("token").build());returntokens.concatWith(Flux.just(ServerSentEvent.<String>builder("[DONE]").event("done").build()));}细节和坑见 第三篇(多模型与流式);MCP 传输层那边 SSE 已经在 2.0.0 起被标记 deprecated,那是另一件事,别把两处混起来看。
4.2 流式 + 工具轮次:显示什么
流式下工具执行会在中间产生若干轮模型调用。ToolCallingAdvisor的流式实现内部要聚合消息再决定是否继续循环,所以你在Flux里看到的顺序不是"模型一字一句往外蹦",而是先可能有一段工具过程、然后才是正文。前端别按"纯文本流"渲染,否则气泡会闪回。
我的做法是三类事件分开:
event: tool_start data: {"name":"getBookingDetails","arguments":"{...}"} ← 折叠显示 event: tool_end data: {"name":"getBookingDetails","durationMs":312} ← 只给耗时与名字,不给原文 event: token data: 正文增量 event: action_card data: {"type":"confirm","actionId":"pa-7f3","summary":"改签到 2026-10-03"}tool_end里刻意不回传工具返回值全文:那里面常有乘客姓名和证件号,前端用不到,且它一旦进浏览器 DOM 就等于出网边界又外移了一层。要看明细去点审计页(需要另一个权限)。这也和第十八篇的观测内容开关是同一个原则。
4.3 中断和改主意
用户点"停止"、或者在流式过程中又发了一句话,都要能收敛:
// 前端"停止" = 关闭 EventSource;服务端要在订阅取消时把工具链掐掉returntokens.doOnCancel(()->this.dialogueState.markAborted(conversationId));三件事必须做:
Flux取消不等于工具停止。正在执行的工具方法是自己跑的,要往下传中断(协程式取消在 Java 里最实用的做法是给工具方法一个可查的abortFlag,或让 Service 层的调用带超时)。长耗时工具(导出、批处理)尤其。- 半截状态要有名字。我用的就是 3.2 里的
pendingActionId:markAborted只做一件事——清空它。历史消息保持原样,不删。 - "改主意"要能识别。在 slot 抽取的 system 里加一条:用户表达撤销意图时输出
intent=ABORT。这比在历史里让模型"自己想起来"稳。
4.4 重连与幂等
SSE 断了要重连,重连后不能把上一段重复执行一遍。做法:每次用户输入带一个turnId(客户端生成 UUID),服务端按turnId幂等——重复提交直接返回已缓存的那轮结果。这个开关一旦漏掉,用户网络抖一下就能改签两次。
5. 前端:消息流怎么渲染才不像玩具
后端作者视角,只讲接口约定,不讲组件选型。
| 气泡类型 | 触发 | 渲染要求 |
|---|---|---|
| 用户 | 本地即时 | 立即上屏,不等服务端;失败再标红重试 |
| 助手正文 | event: token增量 | 打字机效果;光标只在最后一个 token 气泡上 |
| 工具过程 | tool_start/tool_end | 默认折叠,一行"正在查询订单…(312ms)";展开只显示脱敏摘要 |
| 确认卡片 | action_card | 结构化渲染,按钮回调actionId,不走文本输入 |
| 追问 | 服务端模板文案 | 与正文同样式,但带"跳过"入口 |
| 系统提示 | 校验失败 / 转人工 | 与助手气泡视觉区分,避免用户以为是模型在拒绝 |
三条约定:
- 确认动作绝不能只有"打字确认"这条路。卡片按钮回传
actionId,服务端按 ID 执行落库参数(第十八篇 5.2 的原因:别让用户确认的内容和实际执行的不是同一份)。 - 消息体带
turnId+createdAt。重连、乱序、翻页都要靠它。 - 不要展示模型的思考过程文本。它经常包含未经校验的业务判断(“我看这单应该可以免费退”),一旦被截图就是客诉证据。要展示推理,走第十三篇的 trace 页面给内部人员看。
Q:要不要在前端做"意图路由"(比如猜用户想改签就直接开对话)?
可以让前端做建议(猜到了就把流程名作为参数带上,减少抽取歧义),但路由结论必须由服务端抽取给出。前端猜错的代价是用户被卡在错误流程里,比多问一句难受得多。
6. 混合模式:我更推荐先做这个
纯对话在真实业务里最常见的失败不是技术问题,是用户不敢:看不清要提交什么、改了什么、能不能撤回。所以我推荐的落地形态是"对话输入 + 表单确认":
用户:帮我改到下周三下午,靠窗 ↓ [模型] 抽取 → {targetDate: 2026-10-07, seatPreference: WINDOW, bookingCode: null} ↓ [服务端] 缺 bookingCode → 查该用户近期订单(可信 userId,见第十八篇)→ 唯一命中就补上 ↓ [渲染] 表单卡片:出发日期 09-30 → 10-07(周三)下午 | 座位 靠窗 | 差价 ¥240 ↓ [用户] 点"确认" → 服务端按落库参数执行,不再问模型用户得到对话的省事,也保留表单的可见性和可控性。三个实现要点:
- 补槽优先用数据,不用追问。能从"该用户名下唯一在途订单"推出的字段,就别问用户。追问次数是这套体验的头号杀手,我在
DialogueState.askCount上设了 3 次上限,超过直接转人工。 - 卡片是结构化 DTO,不是 HTML 字符串。后端出
ChangeConfirmCard,前端渲染;评测时断言 DTO 就够,不用碰 DOM。 - 对话与表单共享同一个校验入口。否则两条路径的校验规则一定会漂移,最后变成"网页上能提交、对话里报错"。
7. 上线自检清单
| # | 检查项 | 怎么验 | 不过关的表征 |
|---|---|---|---|
| 1 | 每个请求都传CONVERSATION_ID | 漏传必须抛IllegalArgumentException(这是框架行为,别 catch 掉) | 会话串号,A 看到 B 的订单 |
| 2 | 槽位状态独立于消息历史 | 手工把maxMessages调到 4,流程仍能走完 | 换个说法就"忘"了订单号 |
| 3 | 模型不许补全缺失字段 | 单测:只说"我要改签",intent 三个字段必须全空 | 冒出一个不存在的 bookingCode |
| 4 | 校验只在服务端 | 断言2026-13-45会被拒 | 模型给的日期直接进库 |
| 5 | 确认走actionId | 断言确认执行不再调模型 | 用户确认 A 参数,执行了 B |
| 6 | 流式事件分型 | 前端能区分 token / tool / card 三类 | 工具执行时正文气泡闪回 |
| 7 | 取消能停工具 | 断开工后长耗时工具在超时内终止 | 停止按钮只停显示 |
| 8 | turnId幂等 | 同turnId重发两次,只有一次生效 | 网络抖动导致重复改签 |
| 9 | 追问轮数上限 | 三轮问不全转人工 | 用户和模型互相打转,最后打电话 |
| 10 | 有对话流程的评测集 | 第十二篇那套用例里加"改主意"“插话”"信息不全"三类 | 改 prompt 后通过率悄悄掉 |
最后总结
- 对话式重构的成本不在模型调用,在状态机:槽位状态、待确认动作、追问计数、幂等键,全是普通 Java 代码,框架不会替你写。
- 槽位抽取交给一次
.entity(Record.class)调用,追问话术和校验规则留在代码里;这是我把不可控范围压到最小的分界线。 - 2.0.1 的
ChatMemory读取接口去掉了lastN(窗口在MessageWindowChatMemory.maxMessages上定),CONVERSATION_ID漏传直接抛异常——照抄 1.x 示例一定翻车,这两处是硬断层。 - 记忆与工具同轮时,
ChatClient.Builder会按 order 关系自动决定conversationHistoryEnabled;手改记忆 Advisor 的 order 会连带改变工具循环的历史行为,这类静默变化比报错危险。 - 流式要分三类事件(token / 工具过程 / 动作卡片),工具返回值不进前端;重连要有
turnId幂等。 - 表单 + 对话的混合模式是我的默认推荐:对话负责省输入,表单负责看得懂、可撤回。
- 对于后端 / 架构开发者,我个人更关注判据本身:字段少、无依赖、涉安全的流程就别改对话式了——这一篇里的改动清单,用在两三处真正高频的入口上,收益比全站改造大得多。
参考资料 & 致谢
[1] Spring AI Reference(2.0.1)
[2] Spring AI - Chat Memory
[3] Spring AI - Advisors
[4] spring-projects/spring-ai - GitHub
[5] Spring AI 第三篇:多模型、流式输出与工具调用
[6] Spring AI 第五篇:Advisor 对话拦截
[7] Spring AI 第六篇:对话记忆的数据库与 Redis 实现
[8] Spring AI 第七篇:结构化输出
[9] Spring AI 第十一篇:基于航空智能客服的 RAG 实战