这不是速查表,是一份带你从"知道有这个东西"到"能给别人讲清楚为什么这么设计"的工程级解读。
用 LLM 理解用户意图 + 用代码执行具体动作,通过工程化方式驯服 LLM 的不确定性,保证业务结果稳定可控的电商智能客服系统。
| 轨道 | 触发场景 | 核心组件 | 是否调 LLM |
|---|---|---|---|
| Task | 步骤明确的业务任务(退款、查物流) | TurnPlanner → Validator → CommandProcessor → FlowExecutor | 规划阶段调 1 次 |
| Knowledge | 查询型问题(政策、商品信息) | KnowledgeHandler → ProviderRegistry → Responder | 生成回复调 1 次 |
| Chitchat | 轻量自然对话(打招呼、兜底) | ChitChatHandler → ChitChatResponder | 调 1 次 |
追踪一条 {"sender_id": "u1", "text": "我要退款"} 从 HTTP 进入到回复返回的完整数据流转。这是理解整个系统的主线。
{
"sender_id": "u1",
"text": "我要退款"
}
FastAPI 自动将 JSON 反序列化为 ChatRequest(schema 层)。然后 Web 层手动翻译为 domain 层:
UserMessage(
sender_id="u1",
message_id="uuid-自动生成",
type=MessageType.TEXT, # 有 text → TEXT
text="我要退款",
object=None # 没带对象
)
# 数据库 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
)
| 步骤 | 方法 | 做了什么 | 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.turns | session.turns.append(pending_turn); pending_turn=None |
| ⑤ | 组装返回 | 包装成 ProcessResult | 无 |
{
"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:
{
"task": {
"commands": [
{"command": "start_flow", "flow": "refund_request"}
]
},
"knowledge": null,
"chitchat": null
}
TurnPlan(
task=TaskTurnPlan(commands=[
StartFlowCommand(command="start_flow", flow="refund_request")
]),
knowledge=None,
chitchat=None
)
第一重: commands 非空? ✓ [StartFlowCommand(...)]
第二重: 命令类型合法? ✓ StartFlowCommand 在 allowed 中
第三重: 不能多个 start_flow? ✓ 只有 1 个
第四重: flow "refund_request" 存在? ✓ 在 flows 里找到
→ TurnPlanValidationResult(valid=True)
# _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="退款申请"
)
# 外层循环第 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("请告诉我你的订单号。")]
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 条回复(系统过场 + 业务追问)。
| 类 | 核心字段 | 设计意图 |
|---|---|---|
FocusedObject | id, type, title, attributes | 用户点击卡片时携带的"东西"——订单或商品。attributes 存额外信息(价格、状态等),用 dict 保持灵活 |
UserMessage | sender_id, message_id, type, text, object | type 区分 TEXT/OBJECT。text 和 object 二选一:打字走 text,点卡片走 object |
BotMessage | text, object | 更简单——机器人只需回复内容。一轮可能产生多条(过场+追问) |
ProcessResult | sender_id, message_id, messages | 引擎返回值——把本轮所有 BotMessage 打包给 Service 层 |
用户在退款流程中途说"先查下物流"——此时退款任务还没做完,但物流查询要开始了。如果不分开记录,系统就分不清"用户的任务"和"系统插播的过场话"。
class TaskContext(BaseModel):
flow_id: str # 哪种表单(退款单/物流单)
step_id: str | None # 填到哪格了
slots: dict = {} # 已填好的内容
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(区分联合类型)
Literal 固定 flow_id 值作为"身份标签"Annotated[Union[...], Field(discriminator="flow_id")] 组合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
DialogueState 是整个系统的"中央仓库",以 sender_id 为键,一个用户一份状态。
| 分组 | 字段 | 含义 |
|---|---|---|
| 用户任务 | active_task | 当前在做什么 |
paused_tasks | 挂起了哪些(栈结构,LIFO) | |
active_system_task | 系统在插播什么过场 | |
| 聚焦对象 | focused_object | 用户当前关注的订单/商品 |
| 会话历史 | sessions | 所有历史会话(保留) |
current_session_id | 当前活跃会话 | |
pending_turn | 正在处理的轮次(暂存区) |
场景 1:处理中途出错 — 如果直接写进 turns,留下半成品 Turn(有用户消息但回复不完整),下次读取就乱了。
场景 2:决定不入库 — 非法消息/重复消息应该丢弃。如果已塞进 turns,删起来麻烦。
方案:begin_turn → 暂存到 pending_turn → 引擎处理、填充回复 → commit_pending_turn → 追加到 session.turns。处理失败只丢 pending_turn,turns 始终干净。
def current_active_task(self):
return self.active_system_task or self.active_task
系统过场优先于业务任务——因为过场必须先说完("好的,我们先处理退款"),然后才能让位给业务任务继续推进。
一张表两列:sender_id(主键)+ state_json(TEXT)。整个 DialogueState 序列化成一个 JSON 字符串。
对话状态是"整份读、整份写"——每次处理消息都把整个 state 读出来、改完再写回去。从不按内部字段检索(比如"查所有正在退款的用户")。所以整存 JSON 简单够用,符合 DDD 聚合根的持久化单元理念。生产环境有复杂检索需求时再拆表或上 MongoDB。
两个 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] # 全局槽位字典
| 连接类型 | YAML 写法 | 语义 |
|---|---|---|
StaticLink | next: ask_order_number | 必走——直接跳到目标 step |
ConditionalLink | - if: "slots.get('product_id')" | 条件成立时走 |
FallbackLink | - else: missing_context | 前面条件都不满足时走 |
没有 collect 时,每收集一个槽位要写两步:一步问(action_response)、一步等(action_listen)。5 个槽位要写 10 步。
有了 collect,只需写一步:type: collect, slot_name: xxx, response: {text: "问什么"}。等待逻辑由 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,这样同一套"回复+等待"逻辑可以复用。
- id: start
type: start
next:
- if: "slots.get('product_id')"
then: respond # 有 product_id → 直接回复
- else: missing_product # 没有 → 先问
条件表达式可以读取 slots、context、flow_id、step_id。多个条件按顺序判断,命中第一个成立的分支。
| 领域模型 (domain — 对内) | 接口模型 (schema — 对外) | |
|---|---|---|
| 位置 | atguigu/domain/ | atguigu/api/schemas.py |
| 代表 | UserMessage, BotMessage, DialogueState | ChatRequest, ChatResponse |
| 用途 | 系统内部流转、业务逻辑 | 和 HTTP 打交道,校验和序列化 |
Web 层负责两次手动翻译:ChatRequest → UserMessage(进),ProcessResult → ChatResponse(出)。FastAPI 负责自动的 JSON ↔ schema 转换。
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
引擎是纯计算、无副作用的——给它一个 state 和 message,它就地修改 state、返回回复,不碰数据库。好处:① 引擎可独立测试,不依赖数据库;② 事务边界清晰,读写都在 Service 里;③ 引擎"就地修改"state 对象,Service 手里的 state 和引擎改的是同一个引用。
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。一条语句搞定插入和更新,避免"先查再决定"的两次往返和并发问题。
先写一个只回复固定话的占位 Engine,让整条链路(HTTP → Service → Repository → DB)当场跑通。下一节换真引擎时,方法签名不变,上下游一行不改。这是"面向接口编程"的好处——契约不变,实现可替换。
引擎自己不干活,持有一堆"专员",只负责调度:
| 专员 | 职责 |
|---|---|
TurnPlanner | 调 LLM,把用户意图变成 TurnPlan |
TurnPlanValidator | 检查 TurnPlan 是否可靠可执行 |
ClarifyResponder | 校验失败时生成澄清回复 |
TaskHandler | 执行 task 轨道 |
KnowledgeHandler | 执行 knowledge 轨道 |
ChitChatHandler | 执行 chitchat 轨道 |
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)
| 情况 | 处理 |
|---|---|
| 当前没有会话(新用户) | 直接 start_session() |
| 有会话,距上次活动 > 60 分钟 | close → reset_runtime_state → start_session(清掉旧任务,重新开始) |
| 有会话,未超时 | 更新 last_activity_at(续期) |
| 字段 | 类别 | 不传的后果 |
|---|---|---|
user_message | 判断输入 | 无从判断 |
current_conversation | 判断输入 | 看不懂依赖上下文的短句("算了"、报号码) |
active_task | 当前状态 | 分不清"回答当前流程"还是"开新业务" |
interrupted_tasks | 当前状态 | 没法正确生成 resume_flow |
focused_object | 当前状态 | 理解不了"这个""它"等指代 |
available_flows | 可选范围 | 瞎编 flow id |
knowledge_intents | 可选范围 | 瞎编 intent id |
三组信息:前两个让 LLM "看懂用户说什么",中间三个让 LLM "知道现在是什么处境",后两个给 LLM "划定能选什么"。
传给 LLM 的 flows 清单去掉了 steps 字段——LLM 只需"业务说明书"(有哪些业务可办),不需要流程内部步骤。省 token 又防干扰。
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 字典 |
| 命令 | 参数 | 用户说法举例 | 对应的 state 方法 |
|---|---|---|---|
StartFlowCommand | flow: str | "我要退款" | start_active_task + start_active_system_task(Started) |
SetSlotsCommand | slots: dict | "订单号是 A001" | set_slots |
CancelFlowCommand | 无 | "算了不退了" | cancel_active_task + start_active_system_task(Canceled) |
ResumeFlowCommand | flow: 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 一样——用映射表实现多态分发。这是项目中反复出现的设计模式。
| LLM 翻车情况 | 不校验的后果 |
|---|---|
| 三个轨道全 null | 引擎不知道走哪条轨道 |
| 同时填 task + knowledge | 引擎一次只处理一条,不知道先走哪个 |
| task 有值但 commands 为空 | 知道要办业务,却没指令可执行 |
| 编了不存在的 flow("tuikuan_flow") | 下游查流程表 → 找不到 → 崩溃 |
| 一次两个 start_flow | 同时开两个流程,状态混乱 |
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: 命中即合法,不需校验
| 重 | 检查 | 失败原因码 | 防御什么 |
|---|---|---|---|
| 1 | commands 非空 | MISSING_TASK_COMMANDS | LLM 说办业务但没给指令 |
| 2 | 命令类型合法 | INVALID_TASK_COMMANDS | 防御性编程——不假设上游干净 |
| 3 | 不能多个 start_flow | MULTIPLE_TASK_FLOWS | 系统一次只能有一个活跃任务 |
| 4 | flow 必须真实存在 | UNKNOWN_TASK_FLOW | 最典型的防幻觉——LLM 编的 flow id 查不到 |
| 原因码 | 组 | 触发条件 | 基础话术 |
|---|---|---|---|
| MISSING_TRACK | 轨道层 | 三个都 null | "你想先处理业务还是先咨询信息?" |
| MULTIPLE_TRACKS | 轨道层 | 同时命中多个 | "我们先处理一个,你想先办哪个?" |
| MISSING_TASK_COMMANDS | task | commands 为空 | "你这次想办理什么业务?" |
| INVALID_TASK_COMMANDS | task | 不认识的命令类型 | 通用兜底 |
| MULTIPLE_TASK_FLOWS | task | 多个 start_flow | 通用兜底 |
| UNKNOWN_TASK_FLOW | task | flow 不存在 | 通用兜底 |
| MISSING_KNOWLEDGE_INTENT | knowledge | intents 为空 | "你想了解商品信息还是售后规则?" |
| MISSING_FOCUSED_OBJECT | knowledge | 需要对象但没有 | "请先发送你想咨询的对象" |
| OBJECT_REQUIRES_INTENT | 对象消息 | 只发对象没说意图 | "你想查状态、查物流,还是退款?" |
第一步:按 reason 用 if-else 写死一句兜底话术 → 保证永远有一句能用的话
第二步:把基础话术 + 上下文喂给 LLM 改写 → 让追问更自然
LLM 调用失败也有话说,LLM 成功则说得好听。下限由规则兜住,上限由 LLM 拉高。
| 场景 | 当前状态 | 对象能否填槽 | 处理 |
|---|---|---|---|
| A | 流程正收集 order_number | 能 | 转 SetSlotsCommand → 推进流程 |
| B | 没有 active_task | 不能(没流程) | OBJECT_REQUIRES_INTENT 澄清 |
| C | 流程在收集 refund_reason | 不能(槽不匹配) | 不打断,空命令让流程继续 |
对象消息能填槽时,不直接改 state,而是构造 SetSlotsCommand 再交给 TaskHandler。这样文本填槽和点卡片填槽走同一条链路,下游 CommandProcessor / FlowExecutor 不需要区分来源——殊途同归。
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
run 遍历命令列表,_apply 按类型分发到 4 个 _handle_* 方法。顺序很重要——必须先 start_flow 创建活跃任务,set_slots 才有地方填。
这是命令处理中最复杂的部分。同一个 start_flow 命令,根据当前 state 不同,行为完全不同:
| 分支 | 条件 | 行为 | 系统过场 |
|---|---|---|---|
| ① 无活跃任务 | active_task is None | 直接创建新任务,step 指向 start | Started |
| ② 同一个流程 | flow_id == active_task.flow_id | 不重复创建,step 重置为 start | Started |
| ③ 有活跃任务,不同流程 | active_task 存在,flow_id 不同 | 当前任务压入 paused_tasks,创建新任务 | Interrupted |
| ④ 恢复挂起的同名任务 | flow_id 在 paused_tasks 中 | 从 paused_tasks 弹出,设为 active | Resumed |
| ⑤ 同名任务在挂起栈 | flow_id 在 paused_tasks 但也有 active | 从 paused_tasks 删除(合并) | Started |
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)
ResumeFlowCommand.flow 是 str | None。用户说"继续刚才的"→ flow=None → 恢复栈顶(LIFO)。用户说"继续刚才的退款"→ flow="refund_request" → 精确匹配,可以跨过栈顶。
| 场景 | 条件 | 行为 |
|---|---|---|
| 取消活跃任务 | active_task 存在 | active_task = None(直接丢弃,不进 paused_tasks) |
| 取消挂起任务 | active_task 是别的任务,paused_tasks 里有同名 | 从 paused_tasks 删除 |
取消 vs 打断的本质区别:打断进 paused_tasks(可恢复),取消直接丢弃(不可恢复)。
Action (基类)
├── name: str # action 名称
├── async run(call, state) → ActionResult
│
├── ActionResponse # 内置:回复用户
├── ActionListen # 内置:等待用户输入(哨兵)
│
├── ActionLookupOrderStatus # 自定义:查订单状态
├── ActionLookupLogistics # 自定义:查物流
└── ActionRecommendSimilar... # 自定义:推荐相似商品
| mode | 机制 | 字段 | 适用场景 |
|---|---|---|---|
static | Jinja2 渲染 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
class ActionResult(BaseModel):
slot_updates: dict = {} # 要回写的槽位
messages: list[BotMessage] = [] # 要回复的消息
Action 执行后可能产生两类输出:① 槽位更新(比如查到订单状态后写入 slot);② 回复消息。FlowExecutor 会把 slot_updates 写回 state,messages 收集到最终返回列表。
ActionRegistry:name → Action 实例的映射表。启动时自动扫描注册。
ActionRunner:接收 ActionCall,从 Registry 查到对应 Action,执行 run(),返回 ActionResult。
# 所有 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)
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
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
| step 类型 | 处理 | 返回 ActionCall? | 推进 step? |
|---|---|---|---|
start | 读 next,推进到下一步 | 否 | 是 |
end | 结束流程:active_task = None(或 end_system_task) | 否 | 否(流程结束) |
collect | 检查 slot 是否已填:已填→推进;未填→激活 CollectSystemContext | 否 | 视情况 |
action | 返回 ActionCall,由外层循环决定执行 | 是 | 执行后由外层推进 |
用户说"我要退款",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 不执行任何操作,它的唯一作用是告诉外层循环:"该等用户输入了,停下来。"它是流程推进的暂停信号。
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
当流程遇到 collect step 且对应 slot 未填时,FlowExecutor 会检查 focused_object——如果聚焦对象的类型正好匹配(比如收集 order_number 且 focused_object.type == "order"),自动填入并继续推进,不需要等用户输入。
ChitChatHandler.handle(state):
→ ChitChatResponder.respond(user_message, recent_turns)
→ HistoryBuilder 渲染消息和历史
→ load_prompt("chitchat_respond")
→ prompt | llm | StrOutputParser()
→ [BotMessage(text=LLM回复)]
没有流程推进、没有知识检索、没有命令处理——就是把用户的话和对话历史交给 LLM,让它自然回一句。
KnowledgeHandler.handle(state, turn_plan):
① 从 turn_plan.knowledge.intents 获取意图列表
② 对每个意图:
- 查 KnowledgeIntent → 获取 provider_ids
- 通过 KnowledgeProviderRegistry 获取对应的 Provider
- 调 provider.retrieve(state) → list[KnowledgeChunk]
③ 汇总所有 chunks → KnowledgeResponder 生成回复
| Provider | 检索方式 | provider_id | 典型意图 |
|---|---|---|---|
ProductAPIProvider | 调电商后端 API 获取商品实时数据 | api.product | product_info |
OrderAPIProvider | 调电商后端 API 获取订单实时数据 | api.order | order_info |
FAQProvider | 从 FAQ 知识库检索 | faq.default | refund_policy, return_policy |
RAGProvider | 从向量数据库检索(RAG) | rag.default | platform_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)
| 角色 | 怎么用 | 在哪用 |
|---|---|---|
| LLM 的分类词表 | 把 id + description 喂给 LLM,约束它只能从这几类里选 | TurnPlanner 的 knowledge_intents_json |
| 校验白名单 | LLM 返回的 intent id 必须能查到 | TurnPlanValidator._validate_knowledge |
| 路由元数据 | 按 provider_ids 查哪些知识源,按 requires_object 判断要不要聚焦对象 | KnowledgeHandler |
product_info: requires_object = "product" → 必须先有一个聚焦的商品order_info: requires_object = "order" → 必须先有一个聚焦的订单refund_policy: requires_object = None → 不需要对象,直接查 FAQ校验时,如果意图需要对象但当前没有对应类型的 focused_object → MISSING_FOCUSED_OBJECT。
| # | 模式 | 在哪用 | 为什么好 |
|---|---|---|---|
| 1 | 两步提交 | pending_turn | 处理失败不污染历史,可丢弃不入库 |
| 2 | 多态反序列化 | Command.from_dict / FlowStep.from_dict / SystemContextUnion | 用映射表分发,新增类型只加一行映射 |
| 3 | 面向接口占位 | 占位 Engine → 真 Engine | 契约不变先打通骨架,再逐个替换 |
| 4 | I/O 在两端、计算在中间 | Service 三步编排 | 引擎纯计算无副作用,可独立测试 |
| 5 | 校验只判断、不回复 | Validator vs ClarifyResponder | 职责单一,校验逻辑保持干净 |
| 6 | 规则保底 + LLM 润色 | ClarifyResponder / ActionResponse(rephrase) | 下限由规则兜住,上限由 LLM 拉高 |
| 7 | 对象归一化为命令 | 对象消息 → SetSlotsCommand | 殊途同归,下游不区分输入来源 |
| 8 | collect 语法糖 | collect = action_response + action_listen | 配置关注业务需要什么,不关注交互细节 |
| 9 | 哨兵模式 | action_listen | 用特殊值控制流程暂停,简洁有效 |
| 10 | 整存 JSON(聚合根持久化) | DialogueState → state_json | 一表两列,整存整取,调试直观 |
| 场景 | 机制 | 区分字段 |
|---|---|---|
| Command | COMMAND_NAME_TO_CLASS 映射表 | command |
| FlowStep | STEP_TYPE_TO_CLASS 映射表 | type |
| SystemContext | Pydantic Discriminated Union | flow_id (Literal) |
从理解到设计的 20 道题。建议先自己想,再展开看答案。
电商小二 · 深度拆解手册 — 基于 11 篇项目笔记 + 优化文档整理
掌握一个项目的标志:你能解释每个设计决策的"为什么",而不只是"是什么"