Dify 中级实验(14):对话变量与状态管理——如何让工作流记住多轮对话的状态?
1. 业务场景
先讲一个我们实际遇到的场景。
一家 SaaS 公司的产品团队要做一个「客户需求调研」:用户在对话框里逐步回答 5 个问题(姓名、联系方式、年龄、使用场景、确认信息),系统边问边记。听起来简单,但他们用普通工作流搭出来的第一版彻底翻车——用户回答完第一个问题,进入第二轮时,上一轮说的内容全被清空了,系统像个失忆的人,一遍遍重复问同样的问题。普通工作流每次执行都是「失忆」的:上一轮说了什么,这一轮全忘。
我们第一次接这类需求时,第一反应也是「把上一轮的回答存进变量不就行了」。真正动手才发现——普通工作流每次执行都是「失忆」的:开始变量只在当轮有效,节点变量算完即弃,不引入跨轮的存储机制,多轮对话根本做不成。
这不是个例。任何「要跨多轮对话收集信息、且每轮都要记得前面说了什么」的场景都是这个模式:客服工单要逐步收集故障信息、报名系统要分步收集报名资料、体检问卷要按步骤采集健康数据、多轮引导式销售要边聊边记录客户意向——没有状态管理,多轮对话就做不成。
2. 场景痛点
这个流程的痛点,在需求调研系统的开发过程中体现得最直接:
- 每轮都失忆,用户被反复问:用户答完姓名,下一轮系统又问「请提供姓名」——信息没有跨轮保存,用户体验极差,调研根本走不完 5 步。
- 状态不知道存哪:把数据塞进前端会话,用户能篡改、刷新就丢;自己在服务端写存储,又要搭数据库、写接口,为了一个问卷功能成本失控。
- 信息校验没有载体:用户填的邮箱格式不对、年龄超出范围,系统想「记住这个错误、下一轮重问」,但没有地方存放校验状态和错误记录。
- 变量作用域混乱:开始变量、节点变量、对话变量、环境变量分不清,改错变量导致互相覆盖,数据张冠李戴,排查半天。
本质上,多轮对话应用的骨架就是「状态」——每轮要读什么、改什么、写回什么、持久化到哪里,这套机制不建立起来,对话应用就永远只能是一问一答的玩具。
3. 方案:为什么是对话变量 + 变量赋值器
Dify 提供了专门解决这个问题的原生能力——对话变量(Conversational Variables):跨轮次持久化、按 session 隔离、服务端存储,类似 Web 的 Session 机制,客户端无法篡改。配合**变量赋值器(Assigner)**节点把计算出的新值显式写回对话变量,就构成了「读 → 改 → 写回 → 持久化」的规范姿势。
选它的理由:
- 平台原生、零成本:不用自己搭存储,对话变量由 Dify 服务端管理,跨轮次、按会话隔离,天然可靠;
- 作用域清晰:对话变量只在对话型应用(advanced-chat/chatflow)有效,和开始变量/节点变量/环境变量各司其职,不容易混;
- 写回路径显式:LLM 不能写变量,统一走「代码节点算值 → Assigner 落盘」,状态变更可追踪、可审计。
这篇文章我们就用它搭一个「多轮问卷收集系统」:用户说「开始问卷」→ 系统逐步提问 5 步 → 用户回答 → 系统校验并记录 → 下一轮接着问 → 最后确认完成。
4. 整体架构
链路很清晰:入口无变量 → LLM 读对话变量分析用户回复 → 代码节点算新值 → Assigner 写回对话变量 → Answer 回复用户。关键设计是「LLM 只读、代码算值、Assigner 落盘」三层分工——任何一步缺失,状态就断链。
5. 模块设计
5.1 对话变量声明(开始节点)
conversation_variables:
- id: b9d9cc2d-2843-420e-9f8d-7f7c8a66a4a1 # 必须是合法 UUID!
name: collected_info
value_type: object
value: {}
- id: ca0ca474-becf-4284-bcde-03d30f6cf850
name: current_step
value_type: number
value: 0
- id: 1f257899-86c4-446a-bfaa-3ab23ac13fb9
name: total_steps
value_type: number
value: 5
- id: c9bfa3d7-9300-44e6-ab61-5949d8cd92ae
name: conversation_history
value_type: array[string]
value: []
- id: 443a1074-97a1-41a6-bfe6-b46e1b7bbaf1
name: validation_errors
value_type: array[string]
value: []5.2 分析用户回复(LLM)——三花括号读对话变量
你是一个问卷助手,正在收集用户信息。
当前进度:第{{#conversation.current_step#}}步,共{{#conversation.total_steps#}}步
已收集的信息:{{#conversation.collected_info#}}
对话摘要历史:{{#conversation.conversation_history#}}
历史校验错误:{{#conversation.validation_errors#}}
问卷内容:
步骤1:收集姓名
步骤2:收集联系方式(邮箱或手机号)
步骤3:收集年龄范围
步骤4:收集使用场景
步骤5:确认信息
用户当前回复:{{#sys.query#}}
根据对话历史和当前回复,判断:
1. 用户是否回答了当前问题?
2. 提取新的信息(extracted_info 只放本轮新出现的字段)
3. 当前答案是否合理?
输出 JSON:
{
"is_valid": true/false,
"extracted_info": {"要更新的字段": "值"},
"next_action": "ask_next" / "reask" / "confirm" / "complete",
"message_to_user": "对用户说的话"
}
5.3 更新对话变量(Code)
def main(current_step: int, collected_info: dict, llm_text: str, total_steps: int) -> dict:
import json, re
step = int(current_step) if str(current_step).isdigit() else 0
total = max(int(total_steps), 1) if str(total_steps).isdigit() else 5
info = collected_info if isinstance(collected_info, dict) else {}
analysis = {}
m = re.search(r'\{.*\}', (llm_text or "").strip(), re.DOTALL)
if m:
try:
analysis = json.loads(m.group(0))
except Exception:
analysis = {}
new_info = analysis.get("extracted_info", {})
if isinstance(new_info, dict):
for k, v in new_info.items():
if v and k not in info: # 不覆盖已有信息
info[k] = v
action = analysis.get("next_action", "reask")
if action == "ask_next":
step += 1
elif action == "complete":
step = total
step = max(0, min(step, total)) # 越界钳制
progress = "步骤 {}/{}".format(step, total)
message = analysis.get("message_to_user") or "请输入信息({})".format(progress)
return {"collected_info": info, "current_step": step, "last_action": action,
"progress_pct": round(step / total * 100), "message": message}5.4 持久化:变量赋值器(Assigner)
代码节点只是「算出了新值」,必须显式写回对话变量才会跨轮生效。新版 Dify 用 Assigner 节点(旧版在结束节点做输出映射——漏掉这步,变量永远不更新):
- data:
assigned_variable_selector: [conversation, collected_info] # 写回目标(对话变量)
input_variable_selector: [cd_update, collected_info] # 值来源(代码节点输出)
write_mode: over-write
title: 持久化已收集信息
type: assigner
id: asg_info答案节点直接回复
{{#cd_update.message#}},一轮闭环完成。
6. 运行验证
| 输入 | 预期 | 实测 |
|---|---|---|
| 开始问卷 | 欢迎语,询问姓名 | 助手回复「请输入您的姓名」 |
| 张三 | 记录姓名,询问联系方式 | current_step 1→2,collected_info 含 name |
| zhangsan@email.com | 记录邮箱,询问年龄 | 逐步推进,信息不丢失 |
| 25-30 | 记录年龄,询问场景 | collected_info 累积 3 项 |
| 主要用于客户管理 | 进入确认环节,回显全部信息 | 汇总展示姓名/邮箱/年龄/场景 |
| 确认无误 | 问卷完成,进度 100% | current_step=5,回复完成语 |
| (刷新页面后继续同会话) | 状态保持 | 对话变量跨轮持久化,未归零 |
7. 实战坑
| 坑 | 现象 | 修复 |
|---|---|---|
| 对话变量 id 不是 UUID | 保存/运行报 SQL 500 错误 | id 必须是合法 UUID(如 b9d9cc2d-2843-420e-9f8d-7f7c8a66a4a1) |
| 代码节点算完不持久化 | 每轮变量都回到初始值,问卷永远停在第一步 | 用 Assigner 节点(或结束节点映射)显式写回对话变量(实验文档设计约束) |
| 想在 LLM 节点里直接改对话变量 | 改不动/行为异常 | LLM 只读,写操作统一走「代码节点算值 → Assigner 落盘」(实验文档设计约束) |
| 在 workflow(非对话型)里找对话变量 | 根本没有这个配置项 | 对话变量仅 advanced-chat/chatflow 支持(实验文档设计约束) |
| extracted_info 覆盖已有字段 | 用户改口后旧值被新值覆盖 | 合并逻辑加「已存在则不覆盖」保护(实验文档设计约束) |
8. 实验文档及源码获取
- 实验文档(完整操作步骤):DIFY-15:对话变量与状态管理.md
- 源码(可直接导入):dify102_15_对话问卷系统.yml
文章聚焦核心配置与采坑点;实验的完整分步操作(节点搭建/参数表/调试指引)见实验文档原文。
- Dify 中级实验(01):参数提取器实战——如何从自然语言中提取结构化数据?
- Dify 中级实验(02):问题分类器——智能路由引擎如何四路分发?
- Dify 中级实验(03):模板转换实战——如何用零 Token 完成文本加工?
- Dify 中级实验(04):迭代进阶——如何批量处理数据并守住性能边界?
- Dify 中级实验(05):并行执行——如何让多路任务同时跑?
- Dify 中级实验(06):变量聚合——如何确定性合并多路分支结果?
- Dify 中级实验(07):子工作流——如何把公共逻辑做成可复用积木?
- Dify 中级实验(08):代码节点进阶——如何用标准库处理文件与数据?
- Dify 中级实验(09):HTTP 节点进阶——如何搞定认证、分页与错误重试?
- Dify 中级实验(10):知识库深度调优——如何科学评估检索质量?
- Dify 中级实验(11):高级 RAG 流水线——如何搭建多路检索与精排?
- Dify 中级实验(12):Agent 深度配置——如何让智能体自主调用工具?
- Dify 中级实验(13):多 Agent 协作——如何编排多个智能体分工干活?
- Dify 中级实验(14):对话变量与状态管理——如何让工作流记住多轮对话的状态?
- Dify 中级实验(15):条件分支高阶策略——多条件路由如何避免分支爆炸?
- Dify 中级实验(16):错误处理与降级——工作流如何有尊严地失败?
- Dify 中级实验(17):调试监控与性能优化——响应慢和 Token 超支如何定位?
- Dify 中级实验(18):插件开发入门——如何把工作流变成 Agent 可调用的工具?
- Dify 中级实验(19):综合实战——如何把 19 个实验串成一条生产级流水线?
- Dify 中级实验(20):综合实战——自动化报告生成流水线如何从数据到周报一步到位?