- docs/CALM设计对照.md:CALM 四步架构、Commands DSL 清单、逐层对照表(含代码位置) - 明确采纳项(取消意图分类/理解与执行分层/槽位确认)、简化项、差异缺口清单 - 记录超出 CALM 的偏好学习增量,以及对外表述口径与生产落地映射 - README:修正 docs/ 目录引用,补充设计来源章节
17 KiB
CALM 设计对照:从 Rasa CALM 到本 Demo
本文说明本项目「商品发布 AI 助手(Copilot / Agent)」的架构来源与设计取舍。 核心参考范式为 Rasa CALM(Conversational AI with Language Models), 项目在意图理解层与流程执行层的分层方式,直接借鉴了 CALM 的四步架构。
1. 参考源
| 编号 | 来源 | 类型 | 链接 |
|---|---|---|---|
| R1 | Rasa CALM 原始论文(arXiv:2402.12234) | 一手 · 论文 | https://arxiv.org/pdf/2402.12234 |
| R2 | Rasa Pro 文档 · Dialogue Understanding | 一手 · 官方文档 | https://rasa.com/docs/rasa-pro/concepts/dialogue-understanding |
| R3 | Rasa Pro 文档 · Conversation Repair | 一手 · 官方文档 | https://rasa.com/docs/rasa-pro/concepts/conversation-repair |
| R4 | Rasa Studio 可视化流程编排 | 一手 · 官方文档 | https://rasa.com/docs/studio/user-guide/create-flow |
| R5 | 《大模型与任务型对话的结合:Rasa CALM》 | 二手 · 社区解读 | https://mp.weixin.qq.com/s/L_0IeGVeTml_N-Ypb6AwEQ |
| R6 | tod-in-context-learning 参考实现 | 一手 · 代码 | https://github.com/RasaHQ/tod-in-context-learning |
证据分级说明:R1–R4、R6 为论文/官方文档/官方代码仓库(一手);R5 为第三方技术解读(二手,用于快速建立术语认知,关键结论均回溯至 R1/R2 核对)。
2. CALM 要解决什么问题
2.1 传统任务型对话的 pipeline
业界标准做法是三段式:
语义理解(意图识别 + 词槽抽取)→ 对话管理(状态更新 + 策略选择)→ 话术生成
其内核是一条强假设:
f(对话状态) = 对话 action
而对话状态的编码强依赖意图类别的识别。一旦遇到预置意图之外、或本身就模糊的表达,整条链路就会退化成僵硬的分支判断——鲁棒性不足。
典型反例(来自 R5 的转述):
- 用户说「订餐厅」——可能是订中餐、订西餐,也可能只是查询,本身就难以归入单一类别;
- 用户说了一段很长的澄清文本——无法映射到预设类别体系里。
2.2 CALM 的主张
意图分类不是一件合理的事情,因为人类表达意图可不仅仅是一个类别。
既然分类不合理,那就取消意图分类,让大模型直接把用户意图表达为一组动作(Commands)——即论文所述 Commands as a Domain-Specific Language。
3. CALM 的四步架构
flowchart TD
A["用户最新消息 + 对话历史"] --> B["① LLM 生成 Commands<br/>f(任务说明, 全部 flow 描述,<br/>当前 flow 与 slots, 对话历史) = commands"]
B --> C["② 校验 Commands 合理性<br/>对话管理引擎更新对话状态<br/>(当前 flow + slots)"]
C --> D["③ Conversation Repair<br/>判断是否需修复并更新状态"]
D --> E["④ 规则引擎按确定逻辑执行<br/>输出 Action 及对应话术"]
E --> F["表单/系统状态变更 + 回复用户"]
style B fill:#EEEDFE,stroke:#534AB7
style E fill:#E1F5EE,stroke:#0F6E56
架构的关键判断:只有第 ① 步用大模型,其余三步都是确定逻辑。
这一点决定了系统的工程性质:大模型负责它擅长的「理解」,规则引擎负责它擅长的「确定执行」。可解释性、可测试性、可兜底性都建立在这条边界上。
3.1 Commands DSL 清单
| Command | 语义 | 示例 |
|---|---|---|
StartFlow(flow_name) |
启动一个业务流程 | StartFlow(transfer_money) |
SetSlot(slot, value) |
填充槽位 | SetSlot(recipient, John) |
CancelFlow() |
取消当前流程 | 用户说「算了」「stop that」 |
Clarify(flow_1, flow_2, ...) |
歧义澄清 | 用户只说「contacts」→ Clarify(list, add, remove) |
SkipQuestion() |
跳过当前追问 | 用户说「下一个问题」 |
SearchAndReply() |
走知识库作答 | 非任务型知识问询 |
ChitChat() |
闲聊 | 无其他可执行动作时兜底 |
HumanHandoff() |
转人工 | 用户表达不满或明确要求 |
关键设计:SetSlot 中的值不再是通用实体(如「John」被识别为 person、45 被识别为 number),而是直接用 flow 定义中的槽位语义来承接——理解与流程定义在同一套语汇下对齐,消除了 NLU 中间表示的语义损耗。
3.2 Flow 的定义形态
flow = description + steps,其中 steps 只允许若干枚举类型(action / collect / set_slots / noop),并支持 next 分支与 if 条件:
flows:
transfer_money:
description: This flow lets users send money to friends and family.
steps:
- collect: transfer_money_recipient
description: the name of a person
- id: "ask_amount"
collect: transfer_money_amount_of_money
- action: check_transfer_funds
next:
- if: not slots.transfer_money_has_sufficient_funds
then:
- action: utter_transfer_money_insufficient_funds
next: "ask_amount"
- else: transfer_money_final_confirmation
配套三个配置文件:domain.yml(话术/action/slot 声明)、config.yaml(LLM 版本与超时)、endpoints.yml(服务地址)。
4. 本 Demo 的逐层对照
本 Demo 是纯前端可交互原型(无后端、不接真实 LLM),因此第 ① 步的「理解」以规则/关键词实现,但分层结构与数据契约完全按 CALM 的形态设计,替换为 LLM function calling 即可落地(见 §7.1)。
| # | CALM 概念 | 本 Demo 的实现 | 代码位置 |
|---|---|---|---|
| 1 | 意图不再分类,直接生成 Commands | 不走 intent 分类,直接产出「工具序列 + 槽位值」 | app/src/lib/agent.js |
| 2 | SetSlot(slot, value) |
parseBrief(text) → { dest, days, type, theme } |
agent.js:26 |
| 3 | StartFlow(flow_name) 序列 |
parseMultiTools(text) → 命中的工具类型数组(保序去重) |
agent.js:52 |
| 4 | Commands DSL 的取值域 | toolKeywords 关键词表(8 工具)+ DEST_MAP / THEME_MAP / TYPES 值域映射 |
agent.js:11,19,23,40 |
| 5 | 全量流程触发 | agentIntentRe 命中 → 启动 8 步全量流程 |
agent.js:8 |
| 6 | flows.yml:flow 定义(description + steps) |
agentSteps(8 步自主发品流程定义)/ ocStepsDef(6 步一键发品流程定义) |
data/genData.js:10,22 |
| 7 | actions/*.py:action 实现 |
buildGen(type, ctx) → { pre, card, data } 结构化生成 |
data/genData.js:54 |
| 8 | domain 中的 action / slot 声明 | cardMap(工具 → 表单卡片锚点)/ actionLabel(工具 → 动作名) |
data/genData.js:48,32 |
| 9 | 对话状态:当前 flow + slots | form(槽位)+ generatedRef(各工具最新产物)+ serialRef(当前等待点) |
store/useAppStore.js:42,63,64 |
| 10 | 规则引擎确定性执行 | agentRun / multiToolRun 的 for 循环 + 任务清单状态机 |
useAppStore.js:325,351 |
| 11 | slot 的 ask_before_filling |
serialConfirm:生成卡片后 Promise 挂起,等 5s 自动倒计时或手动确认 |
useAppStore.js:204,236 |
| 12 | 重新收集槽位 / 分支重走 | serialRegen「换一版」:重新生成当前步骤卡片并重置倒计时 |
useAppStore.js:214 |
| 13 | Conversation Repair(偏题/打断) | sendMessage 开头:若存在等待中的步骤,先自动确认收口再处理新消息 |
useAppStore.js:383 |
| 14 | ChitChat() / KnowledgeAnswer() 兜底 |
CANNED 关键词兜底 + 3 条上下文感知的闲聊回复 |
useAppStore.js:29,410 |
| 15 | 执行过程的可见性(flow steps 逐条执行) | 任务清单卡片:todo → doing → done 三态 + 每步完成备注 |
useAppStore.js:253,265;components/TaskCardView.jsx |
| 16 | 最终确认(final confirmation) | 生成卡片的「⚡ 一键填入」与「🔄 换一版」双动作 | components/GenCardView.jsx |
4.1 一次完整交互的等价展开
以 CALM 的样例句式对照本 Demo 的实际行为:
用户:帮我发个海南亲子 6 天自由行
CALM 侧等价输出:
StartFlow(publish_product)
SetSlot(dest, 海南(三亚))
SetSlot(theme, 亲子家庭)
SetSlot(days, 6天5晚)
本 Demo 实际执行:
parseBrief() → { dest: '海南(三亚)', theme: '亲子家庭', days: '6天5晚' }
applyBriefToForm()→ 槽位回写左侧表单
agentIntentRe → 命中「发」→ 启动 8 步全量流程
agentRun() → 逐工具 buildGen → 生成卡片 → 等确认 → applyGen 回填
5. 采纳了什么
5.1 取消意图分类,直接生成动作(采纳)
本 Demo 不存在 intent 分类器。用户的自然语言直接映射为「要启动哪些流程 + 各槽位取值」,与 CALM 的主张一致。
这条选择在本场景的价值尤其明显:商家说「帮我优化一下」,实际上可能是要标题、要详情、要主图——强行归入一个 intent 会立刻失真;而输出「工具序列」天然可容纳这种多目标表达(parseMultiTools 返回的就是一个数组)。
5.2 理解层与执行层分离,且只有理解层是「智能」的(采纳)
这是 CALM 最值得复用的工程判断,也是本 Demo 的核心架构约束:
- 理解层(
lib/agent.js):唯一需要「聪明」的地方,负责把自然语言转成结构化动作; - 执行层(
store/useAppStore.js的四条编排流):纯确定性循环,每一步都可解释、可中断、可测试; - 定义层(
data/genData.js):流程与产物以声明式数据描述,与执行引擎解耦。
由此得到三个直接收益:
- 可解释:每一步「为什么调用这个工具」都能回溯到一次解析结果;
- 可中断:执行中任何时候都能插入确认、换版、切换自动/手动模式;
- 可测试:解析层与生成层可分别单测,无需端到端跑 UI。
5.3 human-in-the-loop 的槽位确认(采纳并强化)
CALM 在 flow 中通过 ask_before_filling: true 让槽位在填充前先向用户确认。本 Demo 把这个机制从「槽位级」提升到「模块级」:
serialConfirm让每个工具生成后先出卡片,用户确认才回填表单;- 支持
autoConfirmMode运行中随时切换(⚡自动 5s / ✋手动无限等); serialRegen提供「换一版」——等价于 CALM 重新收集槽位后重走分支。
对 B 端商家场景而言,这个机制的必要性高于 C 端对话:AI 生成的内容会直接进入商品详情页,落库前必须由人过一道眼。
6. 简化了什么
| CALM 的做法 | 本 Demo 的简化 | 简化理由与代价 |
|---|---|---|
| 第 ① 步由 LLM 生成 Commands | 关键词表 + 正则解析(toolKeywords / parseBrief) |
纯前端 demo 不引入模型依赖;代价是泛化能力受限,仅覆盖演示话术。替换点已预留(见 §7.1) |
flow 定义用 YAML 配置 + 支持 next 分支与 if 条件 |
agentSteps / ocStepsDef 为硬编码声明式数组,线性执行 |
演示流程本身线性(8 步固定顺序);代价是新增流程或分支需改代码 |
对话状态收敛为一个 dialogue_state 对象 |
状态分散在 form / generatedRef / serialRef |
复用 React 单向数据流;代价是缺少统一的「当前 flow / 当前槽位」视图 |
| Commands 显式落成可审计的中间表示 | 解析结果直接驱动执行,无 commands 中间层 |
少一次数据转换;代价是失去命令级日志与回放能力(见 §7.2) |
7. 差异与演进方向
7.1 已被预留的替换点:意图理解接 LLM
lib/agent.js 的三个导出(agentIntentRe / parseBrief / parseMultiTools)构成一个窄接口:入参为自然语言,出参为「工具序列 + 槽位值」。
落地真实 LLM 时,只需把这三个函数替换为一次 function calling 调用,执行层(agentRun / multiToolRun / startOC)零改动:
自然语言
├─ 现状:关键词/正则解析(前端模拟)
└─ 目标:LLM function calling,按 CALM 的 Prompt 模板组织
f(全部 flow 描述, 当前 flow 与 slots, 对话历史, 用户消息) = commands
执行层:不变
CALM 的 Prompt 模板(R1/R2)可直接作为契约蓝本:把「可用流程及其描述与槽位」「当前 flow 与槽位取值」「可用动作枚举」「槽位填充约束」四段填进 system prompt,要求模型先输出分析、再逐行输出动作。
7.2 尚未覆盖的 CALM 能力(下一版清单)
| 缺口 | 现状行为 | 补齐后的目标行为 | 对应 Command |
|---|---|---|---|
| 歧义澄清 | 用户只说「帮我优化一下」→ 落到闲聊兜底 | 反问「您是要优化标题、详情还是配图?」 | Clarify(title, intro, detail, ...) |
| 显式取消 | 说「算了」→ 落到闲聊兜底 | 中止当前流程、回滚已生成步骤 | CancelFlow() |
| 跳过当前步骤 | 说「跳过这步」→ 落到闲聊兜底 | 跳过当前槽位收集,继续后续步骤 | SkipQuestion() |
| 转人工 | 无 | 输出转人工指令并携带上下文 | HumanHandoff() |
| 命令中间表示 | 解析结果直接执行 | 生成 commands[] → 校验 → 执行,可日志化、可回放、可单测 |
— |
| 对话状态视图 | 状态散在三个 ref | 统一 dialogueState = { activeFlow, currentStep, slots } |
— |
| 流程声明式配置 | 硬编码数组 | 外置 flows.json(description + steps + next 分支) |
— |
其中**「命令中间表示」优先级最高**。它是 §5.2 三项收益(可解释/可中断/可测试)的落点:一旦 commands 成为显式数据结构,就可以做命令级单测、命令级日志、以及「同一段对话回放不同模型输出」的评估——这正是 CALM 把 Commands 称为 DSL 的用意。
8. 超出 CALM 的部分:偏好学习
CALM 的边界止于「理解 + 执行」,不处理跨会话的用户习惯沉淀。本 Demo 在此之上加了一层产品化增量:
| 机制 | 实现 | 代码位置 |
|---|---|---|
| 偏好沉淀 | 每次发品写入 5 条偏好条目(路线天数、人群文案风格、话术偏好、定价策略、促销习惯) | lib/oc.js:28 |
| 跨会话持久化 | localStorage(键 fliggy_oc) |
lib/oc.js:8,16 |
| 成长等级 | Lv.1 通用模板(0–2 次)→ Lv.2 风格匹配(3–9 次)→ Lv.3 爆款复刻(10+ 次) | lib/oc.js:21 |
| 下次预填 | 一键发品弹窗自动回填上次配置并提示风格沿用语 | store/useAppStore.js:419 |
| 双通道沉淀 | 对话式自主发品与弹窗式一键发品共用同一套记忆 | store/useAppStore.js:305 |
定位说明:CALM 解决「怎么把话说准、把事做对」;偏好学习解决「怎么越用越贴合这个商家」。二者正交——偏好作为上下文注入理解层的 Prompt,不改变执行层的确定性。
9. 对外表述口径
需要向他人解释本项目的设计来源时,建议采用如下表述(避免把 CALM 的作用夸大或缩小):
任务型对话的经典做法是把用户表达归入一个预设意图类别,再走状态机选动作。这个假设在商家场景下很容易失效——「帮我优化一下」可能 одновременно 指向标题、详情和主图。
我参考的是 Rasa CALM 的思路:不做意图分类,让模型直接生成一组动作(启动哪个流程、填哪些槽位),再交给确定性的流程引擎执行。这套架构里只有「理解」这一步是模型在做事,剩下的执行、确认、修复全是确定逻辑——这正好满足商家侧对可解释、可中断、可兜底的要求。
在这之上我做了一层 CALM 没有的东西:商家使用习惯库。每次发品沉淀偏好,下次自动预填并按商家风格生成,让产品「越用越好用」。
10. 落地对应关系(接真实 LLM / 后端 Agent Loop)
| 本 Demo(前端原型) | 生产环境对应物 |
|---|---|
lib/agent.js 关键词解析 |
LLM function calling,按 CALM Prompt 模板构造上下文 |
data/genData.js buildGen() |
各工具的生成服务(Prompt 见 Prompt提示词设计文档.md) |
store/useAppStore.js 编排流 |
后端 Agent Loop:命令生成 → 校验 → 状态更新 → 执行 → 回填 |
agentSteps / ocStepsDef |
流程配置中心(flow 声明式定义,支持分支与版本) |
lib/oc.js localStorage |
商家画像 / 习惯库服务,作为个性化上下文注入 Prompt |
serialConfirm 前端确认 |
同构的落库前人工复核节点(保持前端一致交互) |
| 任务清单卡片 | 执行过程可观测性:每步工具调用、耗时、结果状态 |
附:术语对照
| CALM 术语 | 本项目术语 | 说明 |
|---|---|---|
| Command | 动作 / 指令 | 理解层的输出单元 |
| Flow | 工具流程 / 任务清单 | 如「8 步自主发品」「6 步一键发品」 |
| Flow steps | 流程步骤 | agentSteps 的每一项 |
| Slot | 表单字段 / 上下文槽位 | 目的地、天数、类型、人群 |
| Action | 工具(Tool) | 8 个生成工具 |
| Dialogue Understanding | 意图解析 | parseBrief / parseMultiTools |
| Conversation Repair | 打断收口 / 兜底回复 | sendMessage 的等待点收口与闲聊兜底 |
ask_before_filling |
逐工具确认 | serialConfirm 卡片确认 |