☰
[AI工程]Spring AI 第十七篇:对话式 UX 重构——从 8 字段表单到多轮槽位填充,哪些环节真的值得改
2026/9/28 4:15:57 网站建设 项目流程

💡上一篇把航司客服做成 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,不走文本输入
追问服务端模板文案与正文同样式,但带"跳过"入口
系统提示校验失败 / 转人工与助手气泡视觉区分,避免用户以为是模型在拒绝

三条约定:

  1. 确认动作绝不能只有"打字确认"这条路。卡片按钮回传actionId,服务端按 ID 执行落库参数(第十八篇 5.2 的原因:别让用户确认的内容和实际执行的不是同一份)。
  2. 消息体带turnId+createdAt。重连、乱序、翻页都要靠它。
  3. 不要展示模型的思考过程文本。它经常包含未经校验的业务判断(“我看这单应该可以免费退”),一旦被截图就是客诉证据。要展示推理,走第十三篇的 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取消能停工具断开工后长耗时工具在超时内终止停止按钮只停显示
8turnId幂等同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 实战

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询