⚡ 电商小二 · 深度拆解手册

这不是速查表,是一份带你从"知道有这个东西"到"能给别人讲清楚为什么这么设计"的工程级解读。

📂 手册目录

  1. 请求生命周期追踪 — 一条消息从 HTTP 到回复的完整数据流转
  2. 领域模型深度拆解 — Message / Context / State 的设计推理
  3. 流程配置体系 — YAML → Pydantic 模型 → 运行时推进
  4. 三层架构 — API / Service / Repository 的职责边界
  5. 引擎核心 — DialogueEngine + TurnPlanner 的 LLM 调用链
  6. 防幻觉校验 — 9 种 ClarifyReason + task 四重校验
  7. 命令处理 — 4 种 Command 在 5 种场景下的状态变化
  8. Action 系统 — 3 种回复模式 + 自动注册机制
  9. FlowExecutor — 双循环执行器的心脏
  10. 知识检索与闲聊 — Provider 抽象 + 规则兜底
  11. 设计模式总结 — 10 个值得记住的工程模式
  12. 渐进式练习题 — 从理解到设计的 20 道题

项目一句话

用 LLM 理解用户意图 + 用代码执行具体动作,通过工程化方式驯服 LLM 的不确定性,保证业务结果稳定可控的电商智能客服系统。

三条轨道

轨道触发场景核心组件是否调 LLM
Task步骤明确的业务任务(退款、查物流)TurnPlanner → Validator → CommandProcessor → FlowExecutor规划阶段调 1 次
Knowledge查询型问题(政策、商品信息)KnowledgeHandler → ProviderRegistry → Responder生成回复调 1 次
Chitchat轻量自然对话(打招呼、兜底)ChitChatHandler → ChitChatResponder调 1 次

一、请求生命周期追踪

追踪一条 {"sender_id": "u1", "text": "我要退款"} 从 HTTP 进入到回复返回的完整数据流转。这是理解整个系统的主线。

1.1 全链路鸟瞰

前端 POST /api/chat → ChatRequest (schema) → _build_user_message() → UserMessage (domain) → DialogueService.process_message()
Repository.load_state() → DialogueState (JSON→对象) → Engine.process_message() → TurnPlanner.predict() → LLM 返回 JSON
TurnPlan.from_dict() → Validator.validate() → TaskHandler.handle() → CommandProcessor.run() → FlowExecutor.run_task()
ActionRunner.run() → list[BotMessage] → commit_pending_turn() → Repository.save_state() → ChatResponse (schema)

1.2 逐层数据快照

阶段 1:HTTP 入口 — schema 翻译

📥 前端发来的 JSON
{
  "sender_id": "u1",
  "text": "我要退款"
}

FastAPI 自动将 JSON 反序列化为 ChatRequest(schema 层)。然后 Web 层手动翻译为 domain 层:

🔄 _build_user_message() 翻译结果
UserMessage(
    sender_id="u1",
    message_id="uuid-自动生成",
    type=MessageType.TEXT,       # 有 text → TEXT
    text="我要退款",
    object=None                   # 没带对象
)

阶段 2:Service 三步编排

📖 Repository.load_state("u1") — 从数据库读出状态
# 数据库 dialogue_states 表:sender_id="u1", state_json='{"sender_id":"u1",...}'
# 反序列化后:
DialogueState(
    sender_id="u1",
    active_task=None,            # 当前没有活跃任务
    paused_tasks=[],             # 没有挂起任务
    active_system_task=None,
    focused_object=None,
    sessions=[Session(...)],     # 之前的会话历史
    current_session_id="abc-123",
    pending_turn=None
)

阶段 3:Engine 处理 — 5 个步骤

步骤方法做了什么state 变化
①_prepare_session检查会话是否超时(60分钟),没超时就续期last_activity_at 更新
②_begin_turn创建 pending_turn,装入用户消息pending_turn = Turn(user_message, bot_messages=[])
③_handle_text_message调 LLM → 校验 → 分发到轨道见下方详细追踪
④commit_pending_turn把 pending_turn 追加到 session.turnssession.turns.append(pending_turn); pending_turn=None
⑤组装返回包装成 ProcessResult无

阶段 3 详细追踪:_handle_text_message

🧠 TurnPlanner.predict() — 7 个 prompt 字段组装
{
  "user_message": "我要退款",
  "current_conversation": "USER: 之前对话...\nBOT: 之前回复...",
  "active_task_json": null,                    # 当前无活跃任务
  "interrupted_tasks_json": "[]",              # 无挂起任务
  "focused_object_json": null,                # 无聚焦对象
  "available_flows_json": '{"flows":[{"id":"refund_request","name":"退款申请",...},...]}',
  "knowledge_intents_json": '[{"id":"product_info","description":"商品信息咨询"},...]'
}

这 7 个字段填入 Jinja2 模板 turn_plan.jinja2,通过 LangChain 链 prompt | llm | JsonOutputParser 调用 LLM:

🤖 LLM 返回的 JSON
{
  "task": {
    "commands": [
      {"command": "start_flow", "flow": "refund_request"}
    ]
  },
  "knowledge": null,
  "chitchat": null
}
📋 TurnPlan.from_dict() 解析结果
TurnPlan(
    task=TaskTurnPlan(commands=[
        StartFlowCommand(command="start_flow", flow="refund_request")
    ]),
    knowledge=None,
    chitchat=None
)
🛡️ Validator 校验 — 四重检查全部通过
第一重: commands 非空?          ✓ [StartFlowCommand(...)]
第二重: 命令类型合法?           ✓ StartFlowCommand 在 allowed 中
第三重: 不能多个 start_flow?    ✓ 只有 1 个
第四重: flow "refund_request" 存在? ✓ 在 flows 里找到
→ TurnPlanValidationResult(valid=True)
⚙️ CommandProcessor.run() — 改变 state
# _handle_start_flow 执行后:
state.active_task = TaskContext(
    flow_id="refund_request",
    step_id="start",            # 初始 step
    slots={}
)
state.active_system_task = StartedSystemContext(
    flow_id="system_task_started",
    started_flow_id="refund_request",
    started_flow_name="退款申请"
)
🔄 FlowExecutor.run_task() — 双循环推进
# 外层循环第 1 轮:
#   advance_until_action:
#     current_active_task = active_system_task (系统优先)
#     flow = system_task_started, step = start
#     _run_step(start) → StaticLink → step = acknowledge
#     _run_step(acknowledge) → ActionFlowStep(action="action_response")
#       → ActionCall(action_name="action_response", args={text:"好的,我们先处理退款申请。"})
#     action_name != action_listen → 执行 action
#   ActionRunner 执行 → BotMessage(text="好的,我们先处理退款申请。")
#   → acknowledge.next = end → step = end → EndFlowStep → active_system_task = None

# 外层循环第 2 轮:
#   advance_until_action:
#     current_active_task = active_task (系统过场已结束)
#     flow = refund_request, step = start
#     _run_step(start) → StaticLink → step = ask_order_number
#     ask_order_number 是 CollectFlowStep:
#       slot "order_number" 未填 → 激活 CollectSystemContext
#       → 陷入 system_collect_information 流程
#       → ActionCall(action_name="action_response", args=context.response)
#     action_name != action_listen → 执行 action
#   ActionRunner 执行 → BotMessage(text="请告诉我你的订单号。")
#   → listen step → ActionCall(action_name="action_listen") → break!

# 最终返回: [BotMessage("好的,我们先处理退款申请。"), BotMessage("请告诉我你的订单号。")]

阶段 4:收尾

💾 state 最终状态(保存到数据库)
DialogueState(
    sender_id="u1",
    active_task=TaskContext(flow_id="refund_request", step_id="ask_order_number", slots={}),
    paused_tasks=[],
    active_system_task=None,         # 系统过场已结束
    sessions=[..., Session(turns=[..., Turn(
        user_message=UserMessage(text="我要退款"),
        bot_messages=[
            BotMessage(text="好的,我们先处理退款申请。"),
            BotMessage(text="请告诉我你的订单号。")
        ]
    )])],
    pending_turn=None                # 已 commit
)
🔑 核心理解

一条"我要退款"经过:4 层翻译(JSON→schema→domain→state)、1 次 LLM 调用(TurnPlanner)、4 重校验(Validator)、2 阶段处理(CommandProcessor + FlowExecutor)、双循环推进(外层执行 action、内层推进 step),最终产生 2 条回复(系统过场 + 业务追问)。

二、领域模型深度拆解

2.1 消息模型 (messages.py)

类核心字段设计意图
FocusedObjectid, type, title, attributes用户点击卡片时携带的"东西"——订单或商品。attributes 存额外信息(价格、状态等),用 dict 保持灵活
UserMessagesender_id, message_id, type, text, objecttype 区分 TEXT/OBJECT。text 和 object 二选一:打字走 text,点卡片走 object
BotMessagetext, object更简单——机器人只需回复内容。一轮可能产生多条(过场+追问)
ProcessResultsender_id, message_id, messages引擎返回值——把本轮所有 BotMessage 打包给 Service 层

2.2 上下文模型 (contexts.py) — 最核心的设计

为什么要把上下文分成两类?

用户在退款流程中途说"先查下物流"——此时退款任务还没做完,但物流查询要开始了。如果不分开记录,系统就分不清"用户的任务"和"系统插播的过场话"。

TaskContext — 用户的事

class TaskContext(BaseModel):
    flow_id: str           # 哪种表单(退款单/物流单)
    step_id: str | None    # 填到哪格了
    slots: dict = {}       # 已填好的内容

SystemContext — 系统的过场

5 个子类,每个对应一种系统过场场景:

子类触发时机系统说的话特殊字段
StartedSystemContext新任务开始"好的,我们先处理退款申请。"started_flow_name
InterruptedSystemContext任务被打断"先把退款放一放,先处理物流。"interrupted_flow_name + started_flow_name
CanceledSystemContext任务被取消"退款申请已为你取消。"canceled_flow_name
ResumedSystemContext任务被恢复"我们继续刚才的退款。"resumed_flow_name
CollectSystemContext需要收集槽位"请告诉我你的订单号。"slot_name + response

序列化难题与解决方案

问题

SystemContext 有 5 个子类,从数据库读 JSON 时不知道该实例化哪个子类。用父类 model_validate 只能得到基类字段,丢失子类字段。

解决方案:Pydantic Discriminated Union(区分联合类型)

  1. 每个子类用 Literal 固定 flow_id 值作为"身份标签"
  2. 用 Annotated[Union[...], Field(discriminator="flow_id")] 组合
  3. 创建 TypeAdapter 实例,用 validate_python() 反序列化
# 每个子类固定 flow_id
class StartedSystemContext(SystemContext):
    flow_id: Literal["system_task_started"] = "system_task_started"

# 联合类型 + 适配器
SystemContextUnion = Annotated[
    StartedSystemContext | InterruptedSystemContext | ...,
    Field(discriminator="flow_id")
]
system_context_adapter = TypeAdapter(SystemContextUnion)

# 反序列化时自动分发
obj = system_context_adapter.validate_python(data)
# data["flow_id"] == "system_task_started" → 自动创建 StartedSystemContext

2.3 对话状态 (state.py) — 聚合根

DialogueState 是整个系统的"中央仓库",以 sender_id 为键,一个用户一份状态。

字段分组

分组字段含义
用户任务active_task当前在做什么
paused_tasks挂起了哪些(栈结构,LIFO)
active_system_task系统在插播什么过场
聚焦对象focused_object用户当前关注的订单/商品
会话历史sessions所有历史会话(保留)
current_session_id当前活跃会话
pending_turn正在处理的轮次(暂存区)

pending_turn 的两步提交

为什么不能直接写进 session.turns?

场景 1:处理中途出错 — 如果直接写进 turns,留下半成品 Turn(有用户消息但回复不完整),下次读取就乱了。

场景 2:决定不入库 — 非法消息/重复消息应该丢弃。如果已塞进 turns,删起来麻烦。

方案:begin_turn → 暂存到 pending_turn → 引擎处理、填充回复 → commit_pending_turn → 追加到 session.turns。处理失败只丢 pending_turn,turns 始终干净。

current_active_task 的优先级

def current_active_task(self):
    return self.active_system_task or self.active_task

系统过场优先于业务任务——因为过场必须先说完("好的,我们先处理退款"),然后才能让位给业务任务继续推进。

持久化:整存 JSON

一张表两列:sender_id(主键)+ state_json(TEXT)。整个 DialogueState 序列化成一个 JSON 字符串。

为什么不用多张表?

对话状态是"整份读、整份写"——每次处理消息都把整个 state 读出来、改完再写回去。从不按内部字段检索(比如"查所有正在退款的用户")。所以整存 JSON 简单够用,符合 DDD 聚合根的持久化单元理念。生产环境有复杂检索需求时再拆表或上 MongoDB。

三、流程配置体系

3.1 YAML → Python 对象的映射

两个 YAML 文件:user_flows.yml(业务流程)和 system_flows.yml(系统流程)。由 FlowLoader 加载成 FlowsList 对象。

数据模型层次

FlowsList
├── flows: list[Flow]           # 所有流程
│   └── Flow
│       ├── id, name, description
│       ├── slots: list[FlowSlot]   # 槽位定义
│       └── steps: list[FlowStep]   # 步骤列表
│           ├── StartFlowStep
│           ├── EndFlowStep
│           ├── ActionFlowStep      # action + args
│           └── CollectFlowStep     # slot_name + response
└── slots: dict[str, FlowSlot]  # 全局槽位字典

步骤之间的连接 (links.py)

连接类型YAML 写法语义
StaticLinknext: ask_order_number必走——直接跳到目标 step
ConditionalLink- if: "slots.get('product_id')"条件成立时走
FallbackLink- else: missing_context前面条件都不满足时走

3.2 collect step 的语法糖

collect = action_response + action_listen

没有 collect 时,每收集一个槽位要写两步:一步问(action_response)、一步等(action_listen)。5 个槽位要写 10 步。

有了 collect,只需写一步:type: collect, slot_name: xxx, response: {text: "问什么"}。等待逻辑由 system_collect_information 系统流程统一处理。

system_collect_information 的工作原理

# 系统流程定义
system_collect_information:
  steps:
    - id: ask
      type: action
      action: action_response
      args: context.response      # ← 关键!参数来自上下文
      next: listen
    - id: listen
      type: action
      action: action_listen
      next: end

当业务流程遇到 collect step 时,系统把 collect 的 response 放入 CollectSystemContext,然后进入 system_collect_information 流程。context.response 就是那个 response,这样同一套"回复+等待"逻辑可以复用。

3.3 条件跳转示例

- id: start
  type: start
  next:
    - if: "slots.get('product_id')"
      then: respond            # 有 product_id → 直接回复
    - else: missing_product    # 没有 → 先问

条件表达式可以读取 slots、context、flow_id、step_id。多个条件按顺序判断,命中第一个成立的分支。

四、三层架构

4.1 两套数据模型

领域模型 (domain — 对内)接口模型 (schema — 对外)
位置atguigu/domain/atguigu/api/schemas.py
代表UserMessage, BotMessage, DialogueStateChatRequest, ChatResponse
用途系统内部流转、业务逻辑和 HTTP 打交道,校验和序列化

Web 层负责两次手动翻译:ChatRequest → UserMessage(进),ProcessResult → ChatResponse(出)。FastAPI 负责自动的 JSON ↔ schema 转换。

4.2 Service 层的三步编排

async def process_message(self, user_message: UserMessage) -> ProcessResult:
    # ① 加载 (I/O)
    dialogue_state = await self.repository.load_state(user_message.sender_id)
    # ② 处理 (计算 — 纯内存,不碰数据库)
    process_result = await self.engine.process_message(dialogue_state, user_message)
    # ③ 保存 (I/O)
    await self.repository.save_state(dialogue_state)
    return process_result
I/O 在两端、计算在中间

引擎是纯计算、无副作用的——给它一个 state 和 message,它就地修改 state、返回回复,不碰数据库。好处:① 引擎可独立测试,不依赖数据库;② 事务边界清晰,读写都在 Service 里;③ 引擎"就地修改"state 对象,Service 手里的 state 和引擎改的是同一个引用。

4.3 Repository 层

load_state — 读取

  1. 按 sender_id 查 SQL
  2. 查到 → json.loads → DialogueState.model_validate
  3. 没查到 → 返回全新的空 DialogueState(新用户自动给空状态,上层不用区分老/新用户)

save_state — 写入(upsert)

insert_stmt = insert(DialogueStateRecord).values(
    sender_id=dialogue_state.sender_id, state_json=state_json
)
update_stmt = insert_stmt.on_duplicate_key_update(
    state_json=insert_stmt.inserted.state_json
)
await self.session.execute(update_stmt)
await self.session.commit()

用 MySQL 的 on_duplicate_key_update——主键冲突时自动转 UPDATE。一条语句搞定插入和更新,避免"先查再决定"的两次往返和并发问题。

4.4 占位引擎模式

先写一个只回复固定话的占位 Engine,让整条链路(HTTP → Service → Repository → DB)当场跑通。下一节换真引擎时,方法签名不变,上下游一行不改。这是"面向接口编程"的好处——契约不变,实现可替换。

五、引擎核心

5.1 DialogueEngine — 调度中心

引擎自己不干活,持有一堆"专员",只负责调度:

专员职责
TurnPlanner调 LLM,把用户意图变成 TurnPlan
TurnPlanValidator检查 TurnPlan 是否可靠可执行
ClarifyResponder校验失败时生成澄清回复
TaskHandler执行 task 轨道
KnowledgeHandler执行 knowledge 轨道
ChitChatHandler执行 chitchat 轨道

5.2 process_message 五步

async def process_message(self, dialogue_state, user_message) -> ProcessResult:
    # 1. 准备会话(超时判断 + 续期/新建)
    self._prepare_session(dialogue_state)
    # 2. 开启本轮 turn(begin → pending_turn)
    self._begin_turn(dialogue_state, user_message)
    # 3. 按消息类型分流
    if user_message.type is MessageType.TEXT:
        messages = await self._handle_text_message(dialogue_state)
    else:
        dialogue_state.set_focused_object(user_message.object)
        messages = await self._handle_object_message(user_message, dialogue_state)
    # 4. 回复写入 turn + commit
    dialogue_state.pending_turn.bot_messages.extend(messages)
    dialogue_state.commit_pending_turn()
    # 5. 组装返回
    return ProcessResult(sender_id=..., message_id=..., messages=messages)

_prepare_session 的三种情况

情况处理
当前没有会话(新用户)直接 start_session()
有会话,距上次活动 > 60 分钟close → reset_runtime_state → start_session(清掉旧任务,重新开始)
有会话,未超时更新 last_activity_at(续期)

5.3 TurnPlanner — LLM 调用链

7 个 prompt 字段

字段类别不传的后果
user_message判断输入无从判断
current_conversation判断输入看不懂依赖上下文的短句("算了"、报号码)
active_task当前状态分不清"回答当前流程"还是"开新业务"
interrupted_tasks当前状态没法正确生成 resume_flow
focused_object当前状态理解不了"这个""它"等指代
available_flows可选范围瞎编 flow id
knowledge_intents可选范围瞎编 intent id

三组信息:前两个让 LLM "看懂用户说什么",中间三个让 LLM "知道现在是什么处境",后两个给 LLM "划定能选什么"。

available_flows 去掉 steps

传给 LLM 的 flows 清单去掉了 steps 字段——LLM 只需"业务说明书"(有哪些业务可办),不需要流程内部步骤。省 token 又防干扰。

LangChain 链式调用

prompt_template = PromptTemplate.from_template(template=prompt_text, template_format="jinja2")
chain = prompt_template | llm | JsonOutputParser()
llm_response_dict = await chain.ainvoke(inputs_prompt)
return TurnPlan.from_dict(llm_response_dict)
环节做什么
prompt把变量填进 Jinja2 模板,渲染成最终提示词
llm把提示词发给大模型,拿回文本回复
JsonOutputParser把模型返回的 JSON 文本解析成 Python 字典

5.4 Command 与 TurnPlan 数据模型

四种 Command

命令参数用户说法举例对应的 state 方法
StartFlowCommandflow: str"我要退款"start_active_task + start_active_system_task(Started)
SetSlotsCommandslots: dict"订单号是 A001"set_slots
CancelFlowCommand无"算了不退了"cancel_active_task + start_active_system_task(Canceled)
ResumeFlowCommandflow: str | None"继续刚才的退款"resumed_active_task + start_active_system_task(Resumed)

多态反序列化

class Command(BaseModel):
    command: str
    @classmethod
    def from_dict(cls, data):
        clz = COMMAND_NAME_TO_CLASS[data["command"]]
        return clz(**data)

COMMAND_NAME_TO_CLASS = {
    "start_flow": StartFlowCommand,
    "set_slots": SetSlotsCommand,
    "cancel_flow": CancelFlowCommand,
    "resume_flow": ResumeFlowCommand,
}

和 FlowStep 的 STEP_TYPE_TO_CLASS 一样——用映射表实现多态分发。这是项目中反复出现的设计模式。

六、防幻觉校验

6.1 为什么 LLM 输出不能直接信

LLM 翻车情况不校验的后果
三个轨道全 null引擎不知道走哪条轨道
同时填 task + knowledge引擎一次只处理一条,不知道先走哪个
task 有值但 commands 为空知道要办业务,却没指令可执行
编了不存在的 flow("tuikuan_flow")下游查流程表 → 找不到 → 崩溃
一次两个 start_flow同时开两个流程,状态混乱

6.2 校验流程 — 层层收窄

validate(state, turn_plan, flow_list, intents):
    active_tracks = _active_tracks(turn_plan)
    
    # 第一层:轨道数量
    if not active_tracks:        → MISSING_TRACK
    if len(active_tracks) > 1:   → MULTIPLE_TRACKS
    
    active_track = active_tracks[0]
    
    # 第二层:具体轨道内部
    if active_track == "task":       → _validate_task (四重校验)
    if active_track == "knowledge":  → _validate_knowledge
    # chitchat: 命中即合法,不需校验

6.3 task 四重校验

重检查失败原因码防御什么
1commands 非空MISSING_TASK_COMMANDSLLM 说办业务但没给指令
2命令类型合法INVALID_TASK_COMMANDS防御性编程——不假设上游干净
3不能多个 start_flowMULTIPLE_TASK_FLOWS系统一次只能有一个活跃任务
4flow 必须真实存在UNKNOWN_TASK_FLOW最典型的防幻觉——LLM 编的 flow id 查不到

6.4 9 种 ClarifyReason 全表

原因码组触发条件基础话术
MISSING_TRACK轨道层三个都 null"你想先处理业务还是先咨询信息?"
MULTIPLE_TRACKS轨道层同时命中多个"我们先处理一个,你想先办哪个?"
MISSING_TASK_COMMANDStaskcommands 为空"你这次想办理什么业务?"
INVALID_TASK_COMMANDStask不认识的命令类型通用兜底
MULTIPLE_TASK_FLOWStask多个 start_flow通用兜底
UNKNOWN_TASK_FLOWtaskflow 不存在通用兜底
MISSING_KNOWLEDGE_INTENTknowledgeintents 为空"你想了解商品信息还是售后规则?"
MISSING_FOCUSED_OBJECTknowledge需要对象但没有"请先发送你想咨询的对象"
OBJECT_REQUIRES_INTENT对象消息只发对象没说意图"你想查状态、查物流,还是退款?"

6.5 ClarifyResponder 两段式设计

规则保底 + LLM 润色

第一步:按 reason 用 if-else 写死一句兜底话术 → 保证永远有一句能用的话

第二步:把基础话术 + 上下文喂给 LLM 改写 → 让追问更自然

LLM 调用失败也有话说,LLM 成功则说得好听。下限由规则兜住,上限由 LLM 拉高。

6.6 对象消息处理 — 三场景

场景当前状态对象能否填槽处理
A流程正收集 order_number能转 SetSlotsCommand → 推进流程
B没有 active_task不能(没流程)OBJECT_REQUIRES_INTENT 澄清
C流程在收集 refund_reason不能(槽不匹配)不打断,空命令让流程继续
对象归一化为命令

对象消息能填槽时,不直接改 state,而是构造 SetSlotsCommand 再交给 TaskHandler。这样文本填槽和点卡片填槽走同一条链路,下游 CommandProcessor / FlowExecutor 不需要区分来源——殊途同归。

七、命令处理

7.1 TaskHandler 两阶段

async def handle(self, commands, state) -> list[BotMessage]:
    # 阶段1: CommandProcessor 改状态
    self.command_processor.run(commands, state, self.flows)
    # 阶段2: FlowExecutor 推进流程,生成回复
    messages = await self.flow_executor.run_task(state, self.flows, self.action_runner)
    return messages

7.2 CommandProcessor — 逐条应用

run 遍历命令列表,_apply 按类型分发到 4 个 _handle_* 方法。顺序很重要——必须先 start_flow 创建活跃任务,set_slots 才有地方填。

7.3 _handle_start_flow 的 5 种分支

这是命令处理中最复杂的部分。同一个 start_flow 命令,根据当前 state 不同,行为完全不同:

分支条件行为系统过场
① 无活跃任务active_task is None直接创建新任务,step 指向 startStarted
② 同一个流程flow_id == active_task.flow_id不重复创建,step 重置为 startStarted
③ 有活跃任务,不同流程active_task 存在,flow_id 不同当前任务压入 paused_tasks,创建新任务Interrupted
④ 恢复挂起的同名任务flow_id 在 paused_tasks 中从 paused_tasks 弹出,设为 activeResumed
⑤ 同名任务在挂起栈flow_id 在 paused_tasks 但也有 active从 paused_tasks 删除(合并)Started

7.4 _handle_resume_flow — 带回滚的恢复

def _handle_resume_flow(self, command, state, flows):
    # 1. 如果有活跃任务 → 先挂起(回滚到 paused_tasks)
    if state.active_task is not None:
        state.interrupted_active_task()
    
    # 2. 尝试恢复
    success = state.resumed_active_task(command.flow)
    
    if success:
        # 3a. 恢复成功 → Resumed 过场
        state.start_active_system_task(ResumedSystemContext(
            resumed_flow_id=state.active_task.flow_id,
            resumed_flow_name=flow.name
        ))
    else:
        # 3b. 没有可恢复的任务 → 走 start_flow 逻辑
        self._handle_start_flow(command, state, flows)
resume_flow 的 flow 参数

ResumeFlowCommand.flow 是 str | None。用户说"继续刚才的"→ flow=None → 恢复栈顶(LIFO)。用户说"继续刚才的退款"→ flow="refund_request" → 精确匹配,可以跨过栈顶。

7.5 _handle_cancel_flow — 取消的两种场景

场景条件行为
取消活跃任务active_task 存在active_task = None(直接丢弃,不进 paused_tasks)
取消挂起任务active_task 是别的任务,paused_tasks 里有同名从 paused_tasks 删除

取消 vs 打断的本质区别:打断进 paused_tasks(可恢复),取消直接丢弃(不可恢复)。

八、Action 系统

8.1 Action 框架

Action (基类)
├── name: str                    # action 名称
├── async run(call, state) → ActionResult
│
├── ActionResponse               # 内置:回复用户
├── ActionListen                 # 内置:等待用户输入(哨兵)
│
├── ActionLookupOrderStatus      # 自定义:查订单状态
├── ActionLookupLogistics        # 自定义:查物流
└── ActionRecommendSimilar...    # 自定义:推荐相似商品

8.2 ActionResponse 的 3 种模式

mode机制字段适用场景
staticJinja2 渲染 text 后直接回复text固定模板回复("请告诉我订单号")
rephrase先渲染 text 作为建议回复,再用 prompt 让 LLM 改写text + prompt有底稿但要更自然(兜底回复)
generate完全用 prompt 让 LLM 从零生成prompt无固定模板(知识检索回复)

变量引用

# 引用业务槽位
text: "订单{{ slots.order_number }}的退款已提交"

# 引用系统上下文
text: "好的,我们先处理{{ context.started_flow_name }}。"

# 系统流程中引用 collect 的 response
args: context.response

8.3 ActionResult

class ActionResult(BaseModel):
    slot_updates: dict = {}        # 要回写的槽位
    messages: list[BotMessage] = []  # 要回复的消息

Action 执行后可能产生两类输出:① 槽位更新(比如查到订单状态后写入 slot);② 回复消息。FlowExecutor 会把 slot_updates 写回 state,messages 收集到最终返回列表。

8.4 ActionRegistry + ActionRunner

ActionRegistry:name → Action 实例的映射表。启动时自动扫描注册。

ActionRunner:接收 ActionCall,从 Registry 查到对应 Action,执行 run(),返回 ActionResult。

8.5 自动注册机制

# 所有 Action 子类通过装饰器或约定自动注册
class ActionLookupOrderStatus(Action):
    name = "action_lookup_order_status"
    
# build_action_runner() 时自动扫描所有 Action 子类
def build_action_runner():
    registry = ActionRegistry()
    # 自动发现并注册
    for action_cls in discover_actions():
        registry.register(action_cls())
    return ActionRunner(registry)

九、FlowExecutor — 双循环执行器

9.1 外层循环 (run_task)

async def run_task(self, state, flows, action_runner):
    messages = []
    while True:
        # 1. 找下一个要执行的 action
        action_call = self.advance_until_action(state, flows)
        
        # 2. action_listen → 等用户输入,退出
        if action_call.action_name == "action_listen":
            break
        
        # 3. 其他 action → 执行
        action_result = await action_runner.run(action_call, state)
        state.set_slots(action_result.slot_updates)
        messages.extend(action_result.messages)
    
    return messages

9.2 内层循环 (advance_until_action)

def advance_until_action(self, state, flows) -> ActionCall:
    while True:
        # 1. 获取当前任务(系统优先)
        current = state.current_active_task()
        
        # 2. 没任务 → 返回 action_listen
        if current is None:
            return ActionCall(action_name="action_listen")
        
        # 3. 获取流程 + 当前 step
        flow = flows.get_flow_by_id(current.flow_id)
        step = flow.get_step_by_id(current.step_id)
        
        # 4. 运行 step
        action_call = self._run_step(state, step, flows)
        
        # 5. step 是 action 类型 → 返回
        if action_call is not None:
            return action_call

9.3 _run_step — 4 种 step 类型的处理

step 类型处理返回 ActionCall?推进 step?
start读 next,推进到下一步否是
end结束流程:active_task = None(或 end_system_task)否否(流程结束)
collect检查 slot 是否已填:已填→推进;未填→激活 CollectSystemContext否视情况
action返回 ActionCall,由外层循环决定执行是执行后由外层推进

9.4 完整执行示例追踪

用户说"我要退款",CommandProcessor 已创建 active_task(refund_request, step=start) + active_system_task(Started)。

=== 外层循环第 1 轮 ===
advance_until_action:
  内层循环:
    current = active_system_task (system_task_started)
    flow = system_task_started, step = start
    _run_step(start) → 推进到 acknowledge, 返回 None
    _run_step(acknowledge) → ActionFlowStep(action_response)
      → ActionCall(action_name="action_response", args={text:"好的..."})
      → 返回 ActionCall
    → 退出内层循环
  action_name="action_response" ≠ "action_listen"
  → ActionRunner 执行 → BotMessage("好的,我们先处理退款申请。")
  → acknowledge.next = end → 推进 step 到 end
  → _run_step(end) → EndFlowStep → active_system_task = None

=== 外层循环第 2 轮 ===
advance_until_action:
  内层循环:
    current = active_task (refund_request) [系统过场已结束]
    flow = refund_request, step = start
    _run_step(start) → 推进到 ask_order_number, 返回 None
    ask_order_number 是 CollectFlowStep:
      slot "order_number" 未填
      → 激活 CollectSystemContext(slot_name="order_number", response={text:"请告诉我..."})
      → 陷入 system_collect_information 流程
      → _run_step(ask) → ActionFlowStep(action_response)
        args = context.response = {text:"请告诉我你的订单号。"}
        → ActionCall(action_name="action_response")
      → 返回 ActionCall
    → 退出内层循环
  action_name="action_response" ≠ "action_listen"
  → ActionRunner 执行 → BotMessage("请告诉我你的订单号。")
  → ask.next = listen → 推进 step 到 listen
  → _run_step(listen) → ActionFlowStep(action_listen)
    → ActionCall(action_name="action_listen")

=== 外层循环第 3 轮 ===
advance_until_action:
  内层循环:
    current = active_system_task (system_collect_information)
    flow = system_collect_information, step = listen
    _run_step(listen) → ActionFlowStep(action_listen)
      → ActionCall(action_name="action_listen")
    → 返回 ActionCall
  action_name="action_listen" == "action_listen"
  → break! 退出外层循环

→ 返回 [BotMessage("好的,我们先处理退款申请。"), BotMessage("请告诉我你的订单号。")]
action_listen 是哨兵

action_listen 不执行任何操作,它的唯一作用是告诉外层循环:"该等用户输入了,停下来。"它是流程推进的暂停信号。

9.5 条件跳转的 eval

def _select_next_step(self, step, state):
    for link in step.next:
        if isinstance(link, StaticLink):
            return link.target
        elif isinstance(link, ConditionalLink):
            # eval 条件表达式
            if eval(link.condition, {"slots": state.active_task.slots, ...}):
                return link.target
        elif isinstance(link, FallbackLink):
            fallback = link.target
    return fallback

9.6 从 focused_object 自动填槽

当流程遇到 collect step 且对应 slot 未填时,FlowExecutor 会检查 focused_object——如果聚焦对象的类型正好匹配(比如收集 order_number 且 focused_object.type == "order"),自动填入并继续推进,不需要等用户输入。

十、知识检索与闲聊

10.1 ChitChatHandler — 最轻的轨道

ChitChatHandler.handle(state):
    → ChitChatResponder.respond(user_message, recent_turns)
        → HistoryBuilder 渲染消息和历史
        → load_prompt("chitchat_respond")
        → prompt | llm | StrOutputParser()
        → [BotMessage(text=LLM回复)]

没有流程推进、没有知识检索、没有命令处理——就是把用户的话和对话历史交给 LLM,让它自然回一句。

10.2 KnowledgeHandler — 三步流程

KnowledgeHandler.handle(state, turn_plan):
    ① 从 turn_plan.knowledge.intents 获取意图列表
    ② 对每个意图:
       - 查 KnowledgeIntent → 获取 provider_ids
       - 通过 KnowledgeProviderRegistry 获取对应的 Provider
       - 调 provider.retrieve(state) → list[KnowledgeChunk]
    ③ 汇总所有 chunks → KnowledgeResponder 生成回复

10.3 KnowledgeProvider 抽象

Provider检索方式provider_id典型意图
ProductAPIProvider调电商后端 API 获取商品实时数据api.productproduct_info
OrderAPIProvider调电商后端 API 获取订单实时数据api.orderorder_info
FAQProvider从 FAQ 知识库检索faq.defaultrefund_policy, return_policy
RAGProvider从向量数据库检索(RAG)rag.defaultplatform_rule, general_ecommerce
class KnowledgeProvider(ABC):
    provider_id = ""
    
    @abstractmethod
    async def retrieve(self, state: DialogueState) -> list[KnowledgeChunk]:
        pass

class KnowledgeProviderRegistry:
    def __init__(self):
        self.providers = {}  # provider_id → Provider 实例
    
    def get(self, provider_id) -> KnowledgeProvider | None:
        return self.providers.get(provider_id)

10.4 KnowledgeIntent 注册表的三重角色

角色怎么用在哪用
LLM 的分类词表把 id + description 喂给 LLM,约束它只能从这几类里选TurnPlanner 的 knowledge_intents_json
校验白名单LLM 返回的 intent id 必须能查到TurnPlanValidator._validate_knowledge
路由元数据按 provider_ids 查哪些知识源,按 requires_object 判断要不要聚焦对象KnowledgeHandler

10.5 requires_object 的作用

校验时,如果意图需要对象但当前没有对应类型的 focused_object → MISSING_FOCUSED_OBJECT。

十一、设计模式总结

10 个值得记住的工程模式

#模式在哪用为什么好
1两步提交pending_turn处理失败不污染历史,可丢弃不入库
2多态反序列化Command.from_dict / FlowStep.from_dict / SystemContextUnion用映射表分发,新增类型只加一行映射
3面向接口占位占位 Engine → 真 Engine契约不变先打通骨架,再逐个替换
4I/O 在两端、计算在中间Service 三步编排引擎纯计算无副作用,可独立测试
5校验只判断、不回复Validator vs ClarifyResponder职责单一,校验逻辑保持干净
6规则保底 + LLM 润色ClarifyResponder / ActionResponse(rephrase)下限由规则兜住,上限由 LLM 拉高
7对象归一化为命令对象消息 → SetSlotsCommand殊途同归,下游不区分输入来源
8collect 语法糖collect = action_response + action_listen配置关注业务需要什么,不关注交互细节
9哨兵模式action_listen用特殊值控制流程暂停,简洁有效
10整存 JSON(聚合根持久化)DialogueState → state_json一表两列,整存整取,调试直观

多态反序列化的三种实现

场景机制区分字段
CommandCOMMAND_NAME_TO_CLASS 映射表command
FlowStepSTEP_TYPE_TO_CLASS 映射表type
SystemContextPydantic Discriminated Unionflow_id (Literal)

十二、渐进式练习题

从理解到设计的 20 道题。建议先自己想,再展开看答案。

Level 1:理解层(能说清楚是什么)

Q1: pending_turn 为什么不能直接写进 session.turns?
问:为什么不把 Turn 直接追加到 session.turns,而要先放到 pending_turn?
两个原因:① 处理中途出错时,直接写进 turns 会留下半成品(有用户消息但回复不完整),下次读取就乱了;② 有些请求可能被判为非法/重复需要丢弃,如果已塞进 turns 再删就麻烦。两步提交:begin_turn → 暂存 → 处理 → commit → 追加到 turns。失败只丢 pending_turn,turns 始终干净。
Q2: active_system_task 为什么优先于 active_task?
问:current_active_task() 返回 self.active_system_task or self.active_task,为什么系统过场优先?
因为系统过场是插播的过场白(如"好的,我们先处理退款申请"),必须先说完,然后才能让位给业务任务继续推进。如果业务任务先执行,用户会先看到"请告诉我订单号",再看到"好的,我们先处理退款"——顺序就反了。
Q3: collect step 和 action_response + action_listen 有什么关系?
问:collect 是怎么省掉重复配置的?
collect = action_response + action_listen 的语法糖。没有 collect 时,每收集一个槽位要写两步(一步问、一步等)。有了 collect 只写一步,等待逻辑由 system_collect_information 系统流程统一处理。collect 的 response 被放入 CollectSystemContext,进入 system_collect_information 后 args=context.response 就拿到了那句话。
Q4: action_listen 的作用是什么?为什么不直接 break?
问:action_listen 不执行任何操作,为什么要有它?
它是哨兵——告诉外层循环"该等用户输入了,停下来"。不能直接 break 是因为 break 的条件需要判断当前 step 的 action 类型。action_listen 作为 ActionCall 返回到外层循环,外层判断 action_name == "action_listen" 就 break。这是一种用特殊值控制流程暂停的模式。
Q5: 为什么要分两套数据模型(domain vs schema)?
问:直接拿 domain 模型当接口模型不行吗?
① 职责清晰:schema 是给前端看的契约,domain 是给业务用的;② 内外解耦:内部重构不会破坏对外契约。Web 层做两次手动翻译:ChatRequest→UserMessage(进),ProcessResult→ChatResponse(出)。

Level 2:推理层(能推理出为什么)

Q6: TurnPlanner 传给 LLM 的 7 个字段,去掉哪个影响最大?
问:7 个 prompt 字段中哪个最关键?
available_flows 和 knowledge_intents 最关键——它们给 LLM 划定"能选什么"。不传的话 LLM 会瞎编 flow id 或 intent id,导致校验失败。其次是 active_task 和 interrupted_tasks——不传的话 LLM 分不清"回答当前流程"还是"开新业务",也没法正确生成 resume_flow。user_message 和 history 是基础判断输入,不传则无从判断。
Q7: 为什么校验器只返回原因码,不生成话术?
问:校验器为什么不管"怎么跟用户说"?
职责单一原则。校验器只关心"对不对",不关心"怎么说"。话术交给 ClarifyResponder,它可以根据同一个 reason 生成不同风格的话术(基础话术 + LLM 润色)。如果校验器也管话术,两个职责耦合在一起,修改话术要动校验逻辑。
Q8: 对象消息为什么不直接 set_slots,而是构造 SetSlotsCommand?
问:对象能填槽时,为什么不直接 state.set_slots(...)?
统一入口——文本消息填槽走的是"LLM 产出 SetSlotsCommand → TaskHandler",对象消息也走同一条路。下游 CommandProcessor / FlowExecutor 不需要区分"这个槽是打字填的还是点卡片填的"。而且填完槽位后流程还要推进(继续问下一个槽或执行 action),这套推进逻辑已在 TaskHandler 里实现好,对象消息只要转成命令就能直接复用。
Q9: save_state 为什么用 upsert 而不是先查再决定?
问:为什么用 on_duplicate_key_update?
先查再决定要两次数据库往返,还有并发问题(两个请求同时查到不存在,都去 insert,第二个失败)。upsert 是 MySQL 原生能力——主键冲突时自动转 UPDATE,一条语句搞定,既简洁又避免竞态。
Q10: FlowExecutor 的双循环各自负责什么?
问:为什么不能一个循环搞定?
外层循环负责"执行 action"——每轮拿一个 ActionCall,执行后收集消息和槽位更新。内层循环负责"推进 step"——沿流程图跳过 start/end/collect,直到遇到 action 类型的 step 才返回。分开是因为有些 step(start/end/collect)不需要执行 action,只是推进到下一步。如果用一个循环,每步都要判断"要不要执行",逻辑会混在一起。

Level 3:设计层(能自己设计方案)

Q11: 如果要新增一个"催发货"流程,需要改哪些文件?
问:新增流程的改动范围?
① user_flows.yml 加一个 flow 定义(start → collect order_number → action action_lookup_shipping_reminder → action_response → end);② 如果需要调电商 API,写一个自定义 Action(ActionCreateShippingReminder);③ slots 里声明新槽位。不需要改引擎代码——这就是配置驱动的好处。
Q12: 如果 LLM 返回了一个不存在的 knowledge intent,会怎样?
问:knowledge 轨道的防幻觉链路?
TurnPlanValidator._validate_knowledge 会检查:intents 不能为空(MISSING_KNOWLEDGE_INTENT);如果意图需要聚焦对象但当前没有对应类型的 focused_object(MISSING_FOCUSED_OBJECT)。注意:当前实现中 intents 列表里的每个 intent 都会查 intents 注册表,如果 intent id 不在注册表里会 KeyError——这是一个可以改进的点,可以加一个 UNKNOWN_KNOWLEDGE_INTENT 校验。
Q13: 连环打断场景下,paused_tasks 的状态变化
问:用户:查订单 → 查物流 → 退款,paused_tasks 怎么变?
① "查订单":active_task=order_status_query, paused_tasks=[]
② "查物流"(打断):active_task=logistics_tracking, paused_tasks=[order_status_query]
③ "退款"(打断):active_task=refund_request, paused_tasks=[order_status_query, logistics_tracking]
恢复时:用户说"继续"→ flow=None → pop() → 恢复 logistics_tracking(LIFO);用户说"继续刚才的订单状态查询"→ flow="order_status_query" → 精确匹配,跨过栈顶。
Q14: 为什么 available_flows 传给 LLM 时要去掉 steps?
问:去掉 steps 的好处?
① 省 token——steps 可能很长,LLM 不需要知道流程内部步骤;② 防干扰——LLM 只需"业务说明书"(有哪些业务可办),看到步骤细节可能被误导;③ LLM 的任务是判断"走哪个轨道/开哪个流程",不需要知道流程内部怎么走。
Q15: ClarifyResponder 的两段式设计,如果 LLM 调用失败了怎么办?
问:LLM 润色失败会怎样?
当前实现中,如果 LLM 调用抛异常,整个 respond 方法会抛出异常,导致本轮处理失败。改进方案:在 LLM 调用外加 try-except,失败时直接返回 build_clarify_message 生成的基础话术。这就是两段式的价值——基础话术保证了"永远有一句能用的话",只要加一层 try-expect 就能兜住。ActionResponse 的 rephrase 模式同理。
Q16: SystemContext 的序列化为什么要用 Discriminated Union?
问:为什么不用和 Command 一样的映射表方案?
两种方案都可以。映射表方案需要手动维护 {flow_id: 子类} 字典,每次新增子类要加一行。Discriminated Union 用 Literal 自动绑定,Pydantic 原生支持,类型更安全。项目里 Command 和 FlowStep 用映射表,SystemContext 用 Discriminated Union——两种方案都展示了,可以根据团队偏好选择。
Q17: 如果要支持"一句话多意图"(同时退款+查政策),系统应该怎么改?
问:当前系统一次只执行一个轨道,如何支持多意图?
当前设计:多意图 → MULTIPLE_TRACKS → 澄清让用户选一个。如果要支持:① 引擎改为可顺序执行多个轨道(先执行 task,再执行 knowledge);② 或者改为并行执行后合并回复。但这样状态管理会复杂很多——两个轨道都可能改 state,可能冲突。更务实的方案是保持当前设计,用澄清引导用户拆分——因为"一句话多意图"在实际场景中比例很低。
Q18: 会话超时后 reset_runtime_state_for_new_session 清了什么、留了什么?
问:超时重置的范围?
清掉运行时字段:active_task、paused_tasks、active_system_task、focused_object、pending_turn、current_session_id。保留 sessions(历史会话记录)——因为历史是只读的参考数据,不影响当前处理。这样超过 1 小时再回来,不会停在半截任务里,但历史对话仍可用于 LLM 的 current_conversation 字段。
Q19: FlowExecutor 中 collect step 已填和未填的处理差异
问:collect step 的 slot 已填和未填分别怎么走?
已填:直接推进到 next step(跳过收集)。未填:激活 CollectSystemContext(把 collect 的 response 放进去),然后陷入 system_collect_information 流程——先 action_response 说"请告诉我xxx",再 action_listen 等用户输入。下一轮用户回复后,CommandProcessor 的 set_slots 填入数据,FlowExecutor 再次推进到这个 collect step 时发现已填,就跳过了。
Q20: 整个系统中,LLM 被调用了几次?分别在哪?
问:一次完整对话处理中 LLM 的调用点?
最多 3 次:
① TurnPlanner(必须):用 JsonOutputParser 解析意图,输出 TurnPlan
② ClarifyResponder(校验失败时):用 StrOutputParser 改写澄清话术
③ ActionResponse(rephrase) 或 KnowledgeResponder(执行阶段):改写回复或从知识生成回复
闲聊轨道只调 1 次(ChitChatResponder)。知识轨道调 2 次(TurnPlanner + KnowledgeResponder)。任务轨道正常流程调 1 次(TurnPlanner),如果有 rephrase 类型的 action_response 则再加 1 次。

电商小二 · 深度拆解手册 — 基于 11 篇项目笔记 + 优化文档整理

掌握一个项目的标志:你能解释每个设计决策的"为什么",而不只是"是什么"