💡 这一篇对应课程的 07-项目_智能客服系统。它是整个系列里架构最重的一个项目:4 天、29 节视频、一个独立命名的 Agent 框架(atguigu_ai)加一个示例业务工程(ecs_demo),还有一个 640 行的 GraphRAG 实现。
**前面几篇的 RAG 都是"问一句答一句"。但真实客服不是这样:用户说"我要改地址",系统得知道他是谁、哪个订单、改收货人还是改地址、改完要不要确认、中途用户突然问"我昨天那个单到哪了"怎么办。这是流程,不是检索。而纯 LLM 驱动的对话恰恰最不擅长流程。
这个项目给出的答案很有意思:它没有把一切交给大模型,而是回到传统对话系统的骨架上(槽位、流程、策略、栈),只把最容易失败的那一环------自然语言理解------换成 LLM *。课件笔记里从头到尾没出现过"Rasa"三个字,但 DialogueStack / FlowStackFrame / FlowPolicy / version: "3.1" / collect / ask_before_filling 这套结构,做过 Rasa 的人一眼就认得出来。***
1. 先定性:这是一套 CALM 风格的架构
课件自研框架叫 atguigu_ai,业务示例叫 ecs_demo(电商客服)。
| 关键概念 | 本项目 | 对应 Rasa CALM |
|---|---|---|
| 对话状态 | DialogueStateTracker + dialogue_stack |
DialogueStack |
| 栈帧 | FlowStackFrame / SearchStackFrame / ChitChatStackFrame / CannotHandleStackFrame / CompletedStackFrame / HumanHandoffStackFrame |
同名 |
| 策略 | FlowPolicy + EnterpriseSearchPolicy + PolicyEnsemble |
FlowPolicy + EnterpriseSearchPolicy + PolicyEnsemble |
| NLU | LLMCommandGenerator 输出 DSL 命令 |
LLMCommandGenerator |
| 流程定义 | flow YAML:collect / ask_before_filling / persisted_slots / set_slots: - goto: / next: [{if,then},{else}] |
同名 |
| 版本号 | version: "3.1" |
Rasa 3.x domain/flow 规范标志 |
但有三处重要差异,避免误传:
- 没有
RulePolicy,没有stories.yml/rules.yml,没有 NLU 训练 pipeline 。day03 视频标题里的"flow 策略和 search 策略"指的是FlowPolicyvsEnterpriseSearchPolicy,不是 Rasa 传统的 RulePolicy。 FlowRetriever(用向量匹配 flow description 选流程)实现了但运行时根本没调用 ------全仓 grep 只在retrieval/__init__.py出现。实际实现是把全部 flow 的 name+description 塞进 prompt 让 LLM 选。笔记里说的"根据 flow 的 desc 匹配出 flow",是提示词召回,不是向量召回。config.yml全文只有两项:pipeline: [LLMCommandGenerator]与policies: [FlowPolicy, EnterpriseSearchPolicy{llm, vector_store}]。
四天大纲:
| Day | 模块 | 内容 |
|---|---|---|
| 01 | 架构原理(5 节,无笔记) | 对话系统概述 / 多轮对话_传统 pipeline / 多轮对话_LLM 驱动(重要) / 智能客服架构全局认知(重要)/ 智能对话架构原理 |
| 02 | 状态与栈(6 节) | 项目使用方式与调用过程 / 系统工程&用户工程 / Tracker 核心内容与基础操作 / 对话栈设计 / 对话栈实现 / 栈帧的设计和分类实现 |
| 03 | Flow 与策略(9 节) | tracker_store 持久化 / Flow 系统与 flow 定义规范 / Flow 加载&debug / Flow 解析 step_1 / step_collect debug / collect 步骤 2&推进 step / 聊聊 AI coding 和趋势 / 策略集成器实现 / flow 策略和 search 策略 |
| 04 | 检索、Action、DU、编排(9 节) | 检索策略回顾&自定义检索器调用 / 传统 RAG 要点回顾 / GraphRAG 实现思路 / GraphRAG Debug / Action 执行流程分析 / DU 模块&LLM 通用问题(重要) / DU 调用 debug(重要)/ LangGraph 编排 / CLI 模块调用逻辑 |
2. 三层数据结构:Tracker、对话栈、栈帧
这是全项目最值得学的部分。它回答了一个问题:多轮对话的状态到底存在哪里。
2.1 Tracker:对话栈是唯一状态源
python
class DialogueStateTracker:
sender_id # 会话 id ------ 有这个属性 tracker 才能按会话隔离
slots: Dict[str, Slot]
dialogue_turns: List[DialogueTurn]
dialogue_stack # ← 核心
latest_message
latest_action_name
flow_history
最关键的一个设计决定:
active_flow不是字段,而是从dialogue_stack派生的 property。
笔记里把对话栈称为"唯一状态源 "。这意味着"当前在哪个流程、走到哪一步"永远不需要同步、不可能不一致------它是被算出来的,不是被存起来的。任何做多轮状态管理的人都应该把这条刻在显示器上。
DialogueTurn = {user_message, bot_messages[], commands[], action_name, timestamp},一轮的生命周期是:
text
update_with_message() 开轮 --> add_bot_message() 累积 --> set_latest_action() --> finalize_turn() 落进 dialogue_turns
槽位有 6 类(text/bool/float/list/categorical/any),但只有两种映射方式,这条边界就是 LLM 与传统 NLU 的分工线:
text
from_llm --> LLM 从用户原话里提取
controlled --> Action 代码回填(查库、算出来的)
2.2 对话栈:为什么必须是栈
text
push / pop / top / is_empty / clear
push_flow(flow_id, step_id) / top_flow_frame() / active_flow_frame() / find_flow_frame()
has_flow() / get_all_flow_ids() / pop_to_flow(flow_id) / interrupt_top_flow()
栈打印长这样,很直观:
text
DialogueStack([Flow(物流查询@START) > Flow(订单查询@START)])
用栈的目的就四个字:中断、嵌套。 用户正在改地址,突然问"我昨天那个单到哪了"------
text
interrupt_top_flow() 把当前 Flow 帧标 INTERRUPTED 留在栈里
--> push(SearchStackFrame()) 处理知识问答
--> 搜索帧弹栈
--> 下层 Flow 帧仍在栈中,active_flow_frame() 自动恢复它
这个"压栈/弹栈即中断/恢复"的做法,比状态机 + 标志位的写法干净一个数量级。
2.3 栈帧分类与两个枚举
StackFrame 是 ABC,配 @register_frame_type 注册表------反序列化时按 frame_type 查表,未知类型直接 raise ValueError(f"Unknown frame type: ...")。
两个枚举值得单独看:
text
FrameState = ACTIVE / COMPLETED / INTERRUPTED / CANCELLED
(ACTIVE→complete/interrupt/cancel;INTERRUPTED 可回 ACTIVE)
FlowFrameType = REGULAR / INTERRUPT / LINK
FlowFrameType 的三态说明了一件事:同一个 flow 帧还要区分"正常执行 / 被打断 / 链接跳转"三种语义。这是流程嵌套做到位之后必然会冒出来的区分。
2.4 tracker_store 持久化
TrackerStore ABC(async save/retrieve/delete/keys)+ create_tracker(sender_id)------注意它从 domain.slots deepcopy 槽位定义,保证新会话槽位齐全,而不是建个空 dict。
| 类型 | 实现 |
|---|---|
json |
JsonTrackerStore(path="trackers"),每会话一个 {safe_id}.json,indent=2, ensure_ascii=False |
mysql |
表 trackers(sender_id PK, state TEXT, created_at, updated_at),整份 tracker JSON 存单列,upsert 语义 |
memory |
复用 JsonTrackerStore(path=None, in_memory=True),不另写一个类 |
序列化粒度 = 整个 tracker(含对话栈帧),持久化粒度 = 会话 id。
3. Flow:用 YAML 把业务流程写成可执行规范
day03 的核心。这是"LLM 不控制流程"这一理念的落地形式。
3.1 七种 StepType
text
ACTION 执行动作
COLLECT 收集槽位(最复杂,见 4.1)
CONDITION if / then / else
SET_SLOT 程序化写槽
LINK 切换 flow(结束当前)
CALL 调子 flow,完成后弹栈回 return_step(靠栈天然实现)
END
3.2 真实 flow 定义(ecs_demo/data/flows/flow_order.yml 逐字)
yaml
version: "3.1"
flows:
switch_user_id:
name: 切换账号
description: 切换账号、切换用户、更换用户ID
persisted_slots: # 流程结束后不重置下列槽
- user_id
steps:
- collect: user_id
description: 用户id
ask_before_filling: true
next: END
query_order_detail:
name: 查询订单详情
description: 查询订单详情
steps:
- set_slots: # 查询进行中,或3日内已完成的订单
- goto: action_ask_order_id_before_completed_3_days
- collect: order_id # button展示给用户,用户选择一个订单
next:
- if: slots.order_id != "false" # 选择的订单id不是false,进行下一步
then:
- action: action_get_order_detail
next: END
- else: END
三个"方言级"技巧,是我看这份 YAML 时停下来想了很久才反应过来的:
① set_slots: - goto: action_ask_xxx 不是真的设值。 它是借 goto 槽位驱动询问动作换数据源 ------同一份 action_ask_order_id 按 goto 的值去查不同状态的订单(待支付 / 已发货 / 3 日内完成),实现多入口复用。用"设槽"的语法做"路由"的事,第一次看会懵。
② 字符串 "false" 当哨兵值做取消分支。 注意条件是 slots.order_id != "false"------一个业务语义上的"用户点了取消"被编码成了槽位的字符串值。这是受限 DSL 下的必然妥协(见 3.4)。
③ 嵌套 then: [action, next] 允许在条件分支里内联子步骤序列。
persisted_slots 则是流程级的槽位生命周期控制:切换账号这个流程结束后,user_id 不清空(后续所有查询都要用),其他 flow 的槽位照常重置。
3.3 按钮旁路:不要让 LLM 读"是"
yaml
utter_ask_if_cancel_order:
- text: "是否取消此订单?"
buttons:
- title: "是"
payload: "/SetSlots(if_cancel_order=true)"
- title: "否"
payload: "/SetSlots(if_cancel_order=false)"
understand_node 开头就先检测 input_message.startswith("/SetSlots("),用正则
python
r'(\w+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^,\s]+))'
解析成 SetSlotCommand,数字转 int、true/false 转 bool,完全绕过 LLM。
这一条我认为是整个架构里性价比最高的设计:用户点按钮时,意图是 100% 确定的,花一次 LLM 调用去"理解"它是纯浪费------既费 token,又给幻觉开门。凡是能结构化的输入就不要过模型。
3.4 条件表达式是受限 DSL
_evaluate_condition 只支持三种 :==、!=、裸槽名布尔判断。实现是字符串切分而不是 eval (避免注入),先 replace("slots.", ""),比较时统一 str(actual) == expected,"true"/"false" 特殊转布尔。
文档表格里出现了
slots.balance >= slots.total_price,但源码没实现>=。这是文档与实现的偏差,别当已支持能力用。
由此推出 3.2 那个技巧的必要性:既然比较是字符串化 + 只有等/不等,那"取消"这种分支就只能靠 "false" 哨兵值表达。理解 DSL 的边界,才能理解它的 YAML 为什么长成这样。
4. FlowExecutor 与策略模块:谁在推进流程
4.1 collect 步骤:一个死循环的成因与解法
execute_next_step(tracker) 取当前帧 step_id → 按 7 类分发 → 返回 ExecutionResult{action, slot_to_collect, events, flow_completed, next_step_id, metadata}。
最复杂的 _execute_collect_step,判定逻辑是两个状态的交叉:
python
current_value = tracker.get_slot(slot_name)
flow_frame = tracker.dialogue_stack.top_flow_frame()
currently_collecting = flow_frame.slot_to_collect if flow_frame else None
if current_value is None:
need_ask = True
elif step.ask_before_filling and currently_collecting != slot_name:
need_ask = True
tracker.set_slot(slot_name, None) # 首次进入:清空并重问
elif step.ask_before_filling and currently_collecting == slot_name:
pass # 正在收集且用户刚填过:不重问,推进
代码注释直接写明这是"避免死循环:通过检查 slot_to_collect 判断是否已经询问过"。
这就是 ask_before_filling 的双刃剑 :YAML 里它的注释反复写"每次都重新询问,避免槽位残留",也就是它是为了清空脏槽位 而设的------但如果不和 slot_to_collect 交叉判断,它立刻变成死循环:每轮都清空、每轮都重问。
询问动作还有一条运行时降级链:
text
显式 action --> utter_ask_{slot}(并在 metadata["fallback_action"] 记下 action_ask_{slot})
--> action_node 检测到 utter 动作无响应时,才回落到 action 版本
副作用是:domain 里漏写 utter 不会报错,只会静默走 action。
而 advance_flow() 的实现简单到让人意外------只改栈帧的 step_id 字段。这就是"推进 step"的全部。
4.2 FlowPolicy(priority=100):一轮之内连续吃 step
text
无活动 flow --> 直接 abstain
frame.completing --> _reset_scoped_slots + end_flow + 返回 action_flow_completed
for _ in range(max_steps_per_turn): # 内层循环:能自动推进就一直推进,直到需要用户输入
...
两条硬性顺序约束,都来自注释:
"必须先处理
slot_to_collect,否则会走 action 分支而丢失 metadata 信息"------因为 collect 步骤会同时给出 action 和 slot_to_collect。
还有一个很聪明的前置动作 _preset_slot_to_collect_if_needed():推进后如果下一步是 collect,预先 把 slot_to_collect 设进栈帧------这样下一轮 understand 节点的 prompt 里就能写死"当前正在收集哪个槽",从而对 LLM 下硬约束。
4.3 EnterpriseSearchPolicy(priority=50):四级降级链
它 does_support_stack_frame 覆盖 Search / ChitChat / CannotHandle / Completed / HumanHandoff 五类帧(对 Completed 和 HumanHandoff 特判 needs_immediate_handling,不许 abstain)。
降级链在代码里是可量化的:
| 路径 | confidence | degradation_reason |
|---|---|---|
| 检索成功 | 0.9 | --- |
| 闲聊兜底 | 0.7 | CHITCHAT |
action_default_fallback |
0.5 | DEFAULT |
| 异常 | 0.3 | INTERNAL_ERROR |
四条路径都会 tracker.dialogue_stack.pop() + record_pattern(...)。
"检索成功"的判定有个拒识开关 :答案里含 [NO_RAG_ANSWER] 就不算成功,直接落到闲聊。一个字符串标记同时承担了拒识语义和降级控制。
4.4 PolicyEnsemble
text
按 priority 降序遍历 --> should_predict 过滤 --> 跳过 abstain --> 置信度阈值校验
--> 保留最高分 --> 一旦 confidence == 1.0 立即短路返回 --> 全部 abstain 则走默认动作兜底
1.0 短路这个优化值得注意:规则明确的场景(比如 flow 正常推进)根本不需要问后面的策略,省一次计算也省一次不确定性。
5. DU 模块:LLM 不生成回答,只生成命令
这是整个架构的枢纽,也是"LLM 驱动"四个字的真实含义。
5.1 命令 DSL
LLMCommandGenerator 让模型输出结构化命令文本,只做两件事:① 路由到不同对话类型;② 任务型对话时匹配出 flow。
| Command(来自 LLM) | Action(来自 Policy) |
|---|---|
| 解析意图,直接改 Tracker 状态(压栈帧、设槽位) | 执行业务,返回 ActionResult{responses, events} |
"Command 改状态、Action 改世界" 是本项目最该讲清的一层对比。
更妙的是回答类命令的栈帧化设计 :KnowledgeAnswerCommand 只 push 一个 SearchStackFrame,真正的 RAG 由 Policy 检测到帧之后执行。"LLM 决策"与"框架执行"彻底解耦------模型说"这是知识问答",但它永远不知道也不需要知道检索怎么做的。
5.2 prompt 结构(command_prompt.jinja2)
text
任务说明 --> 可用 Flows 和槽位(每个 flow 的 {name, description, slots[{name,description,type,allowed_values}]}
序列化成整行 JSON 塞进 prompt)--> 可用命令清单(5 组:flow 控制 / set slot / 回答 / 会话)
--> 命令生成规则 --> 决策规则表(Markdown 表格)--> 当前状态 JSON --> 对话历史
--> 任务:用户消息 """{{ user_message }}""",输出"每行一个命令"
决策规则表就是路由逻辑:
text
意图精确匹配 flow --> start flow
知识性且无匹配 Flow --> knowledge_answer
问候感谢 --> chitchat
不清晰 --> clarify
转人工 --> human_handoff
无法处理 --> cannot_handle
有两条反直觉但很关键的硬约束,明显是踩过坑之后加的补丁:
① 启动 Flow 时禁止从历史推断槽位值 ------"即使对话历史中提到过相关信息(如订单号、手机号等),也不要同时设置槽位,让系统通过流程引导用户重新提供。"
② 正在收集某槽位时的 6 条铁律 :只能设
{``{ current_slot }};严禁设其他槽位(即使用户提到了) ;严禁启动新流程(除非用户明确说取消/不要了/换一个) ;数字回复视为选列表项;文字回复直接作值。附示例用户说"2" → set slot <current> 2。
第 ② 条有代码兜底,即 CommandProcessor.force_slot_filling:正在收集某槽时,丢弃"设置其他槽位的 SetSlotCommand"和"StartFlowCommand",只保留当前槽的 SetSlot 与 CancelFlow 等。
为什么不许从历史填槽?因为订单号、手机号这类槽位的时效性极强 。上一轮提到的订单号可能已经被换过一次了,自动填进去就是"给用户取消了错误的单"。让流程重新问一遍,代价是一轮对话,收益是不会做错事。这是 LLM 应用里少见的"故意不聪明"的正确案例。
5.3 CommandParser 的健壮性
模型输出永远不完全听话,解析器要能扛:
text
剥 ```代码块 / 剥行内反引号 / 剥 - * • 列表前缀与 \d+. 序号前缀
按换行与 ; 双分割 / 跳过 # 和 // 注释行 / 逐行 regex 匹配
失败行记 (line, "Unrecognized command format") 而不整体崩溃
5.4 DU 模型怎么选:Qwen-8B + 离线蒸馏
day04 的"DU 模块&LLM 通用问题(重要)"给了 LLM 落地的五问清单,这是我认为整个文件夹里最实用的一页纸:
| 问题 | 课件结论 |
|---|---|
| ① 用什么尺寸模型 | 在线调用、从大到小试 |
| ② 要不要微调 | 业务知识不足 / 语气靠提示词效果差 / 蒸馏降本 / 需要拒识能力 时微调。"拒识能力:RAG 的生成器(就是 LLM)",靠正例 + 负例数据有效提升边界场景 |
| ③ 私有化 vs 商用在线 | 前期调研用在线快,定型后 vLLM 私有化 |
| ④ 要不要开思考 | 简单任务不开,复杂推理开(GraphRAG 那边就是显式保持思考模式开启) |
| ⑤ 硬件 | 熟悉 GPU 型号(H / L / A 系列) |
DU 的具体方案:选 Qwen-8B ,走离线蒸馏 ------在线(省钱)调 32B 生成高质量命令 → 用"输入 + 高质量答案"构造微调集 → 8B 做 SFT。该场景这种任务不需要 RLHF、不需要 thinking。 格式 shareGPT:
json
[{"role":"user","content":"我们构建的prompt"},
{"role":"assistant","content":"[start flow A\nset slot a=123\n]"}]
这是一个非常完整的"用大模型造小模型"的成本收敛样本------DU 是高频、格式固定、任务单一的调用,天然是蒸馏的最佳场景。
6. RAG 与 GraphRAG:框架留钩子,业务实现
6.1 扩展模型:只有两个钩子
InformationRetrieval ABC 刻意只留两个方法,注释直言"自定义检索器只需继承此类并实现这两个方法即可":
python
def connect(self, config): ... # 在 agent.load 期执行
async def search(self, query, top_k, tracker_state): ... # 在 EnterpriseSearchPolicy 期执行
tracker_state 传进来的目的是个性化检索 (取 slots.user_id 与历史)。这条链路 day04 随笔讲得很清楚:启动时建检索器(跑 connect),用户输入 → DU 生成"知识问答命令" → 检索策略执行(跑 search)。
框架定义接口、用户工程实现钩子------这是把"通用 Agent 框架"和"业务系统"分开的正确切法。
6.2 传统 RAG 的实体承载:create_indexing.py
text
SentenceTransformer(EMBEDDING_MODEL),默认 ../models/bge-base-zh-v1.5
vector_dim = 768(注释明确"与 bge-base-zh-v1.5 一致"), embed_batch_size = 64
7 个业务标签:Category1 / Category2 / Category3 / Trademark / SPU / SKU / Attr
每个标签建 {label}_vector 向量索引 + {label}_fulltext 全文索引
全文索引有个巧思 :Neo4j 原生全文索引不切中文词,所以先用 jieba 分词把 name 拼成 fulltext 属性写回节点(SET n.fulltext = row.fulltext),再对该属性建索引。
换嵌入模型必须同步改
vector_dim并重建索引------768 是和 bge-base-zh-v1.5 强绑定的。
6.3 GraphRAG:本质是 text2cypher 加了三道保险
ecs_demo/addons/information_retrieval.py,640 行,配置里以 vector_store: addons.information_retrieval.GraphRAG 声明。文件开头就是作者自己写的六步思路,search() 逐行对应:
python
user_id = tracker_state.get("slots", {}).get("user_id")
chat_history = get_chat_history(tracker_state, user_id)
route_res = await self.route_label(chat_history) # 1 LLM 判入口节点标签+实体
entry_nodes = await self.node_retrieval(route_res, 10) # 2 混合检索定位入口节点
cypher = await self.generate_cypher(query, entry_nodes) # 3 LLM 生成 DSL
errors = await self.validate_cypher(query, entry_nodes, cypher) # 4 反思
if errors:
cypher = await self.correct_cypher(...) # 5 校正
cypher = self.cypher_corrector(cypher) # 6 关系方向机械校正
records = self.driver.execute_query(cypher).records # 7 执行
每一道保险都有具体理由:
- 路由用 Pydantic 结构化输出 + 三层 try :
RouteItem{label, entity}走with_structured_output(RouteOutput),失败退ainvoke,再失败正则抽 JSON 兜底。 label == "User"直接 Cypher 精确查,不走向量 ------避免同名/相似用户误检。能确定的地方就别用向量。- 其余标签走
neo4j_graphrag.HybridRetriever(向量 + 全文)。 - 语法校验靠
try/except CypherSyntaxError把真实异常喂回 LLM,不是自己猜。 - 逻辑校验用独立 prompt 让 LLM 输出严格列表
"["错误1","错误2"]",检查四项:是否需要用户信息做过滤 / 语法 / 关系方向是否符合 schema / 变量定义 / 检索内容能否回答该问题。 - 最后一道静态保险是 LangChain 官方
CypherQueryCorrector(schema)做关系方向校正,注释警告"如果某个关系和其反向关系都不合法,会返回空字符串"。 - LLM 侧
ChatTongyi且明确注释"保持思考模式开启,有助于 Cypher 生成/校验等复杂任务的准确性"------正好呼应 5.4 的第 ④ 问。
6.4 RAG 生成侧的拒识硬编码
EnterpriseSearchPolicy.RAG_PROMPT_TEMPLATE 七条,逐字要点:
text
严格基于上述文档内容回答:
1 直接回答不加问候;2 禁止使用 emoji;3 专业简洁;
4 只陈述文档中明确提到的信息;5 产品名/品牌/规格必须准确引用;
6 最多 2-3 句话避免冗余;
7 如果文档信息不足以回答问题,仅回复 "[NO_RAG_ANSWER]"
第 7 条是整个系统的安全阀:它既是对用户负责的拒识,也是策略降级的触发器。这一条把 day04 笔记里那句"拒识能力靠 RAG 的生成器 + 正负例微调"落成了可运行代码。
7. 编排、生命周期与 11 个坑
7.1 LangGraph 主循环
text
START --> understand --> policy --(should_execute_action)--> action --> guard
^ |
|------------(should_continue)-----------------------------|
\--> response --> END
五个节点,各司其职:
| 节点 | 职责 |
|---|---|
understand |
生成命令 + 处理命令(改 Tracker) |
policy |
PolicyEnsemble 预测下一步动作 |
action |
执行动作 |
guard |
只做一件事:action_count >= max_actions 就强制 is_finished=True 并写 error |
response |
收口 |
状态 MessageProcessingState(TypedDict)分六组:核心状态(tracker/domain/flows)、输入输出、流程控制(is_finished / action_count / max_actions=10)、中间结果(current_commands / current_prediction / current_action_result)、调试(node_history、error)、组件引用(_command_generator / _command_processor / _policy_ensemble,通过 state 注入避免全局单例)。
最后这组值得单独夸:把重依赖塞进 state 而不是全局单例,是 LangGraph 项目里最容易被偷懒过去的可测试性设计。
7.2 生命周期:Agent 是"加载一个 tar.gz"
text
启动命令 --> agent.load(找到 tar.gz 压缩包并解压到临时目录)
--> 加载 domain / flow / config / endpoints
--> 动态加载自定义 action & 自定义检索器
--> 从 config.yml 的 policies[].vector_store 读出类路径,create_retriever() 实例化
--> 创建命令生成器、处理器、flow 策略、search 策略、llm 客户端
用户输入 --> 接口内 agent.handle_message
--> 每轮从 tracker_store 按会话 id 获取或创建 tracker
--> graph.ainvoke(state)
--> 解析结果并把最新 tracker 写回 tracker_store(下一轮读出不丢信息)
自定义 action 的注册机制:模块加载时内置动作自动进全局 ActionRegistry{register/get/get_all_names};用户工程的 actions/ 目录由 _load_custom_actions() 扫描 *.py(跳过 _ 前缀)、把父目录塞进 sys.path、importlib 导入、实例化其中的 Action 子类并注册,再同步回 domain。
内置 action 15+ 个,分四类:
text
系统类 action_listen / restart / session_start / default_fallback
Flow 控制 cancel_flow / change_flow / clean_stack / flow_completed
响应类 send_text / handle_help / clarify / chitchat_response
特殊类 extract_slots / trigger_search / human_handoff + ActionUtter 模板动作(支持 {slot} 替换)
CLI 侧:setup.py 的 entry_points 里 [console_scripts] atguigu = atguigu_ai.cli:main,所以 atguigu 命令等价于 from atguigu_ai.cli import main; main();@click.group + @click.command("run") + cli.add_command() 挂子命令。
inspect 调试页真正在用的接口只有三个 :/api/stream(WebSocket,核心通信通道)、/api/tracker/{session_id}/full(定时刷新展示 tracker 完整状态)、/api/flows(加载 flow 定义渲染 Mermaid 流程图,这就是 day03"Flow 加载&debug"的可视化工具 )。而 /api/messages(设计了但前端全走 WS)、/api/sessions/{id}、/reset、/api/domain、/health、/ 都没被调用------别把接口面当文档读。
7.3 11 个坑
| # | 坑 | 症状 / 解法 |
|---|---|---|
| 1 | LLM 命令解析失败 | No commands parsed from LLM output。解法:加 few-shot、temperature 降到 0.1 (demo 的 endpoints.yml 正是 temperature: 0.1)、限 max_tokens |
| 2 | Flow 死循环 | Max steps exceeded in flow:next 循环引用或条件未全覆盖。解法:确保有终止条件、补 else 分支 、显式 END。框架侧三道护栏:FlowPolicy.max_steps_per_turn、guard_node 的 max_actions=10、collect 用 slot_to_collect 判"是否已问过" |
| 3 | 槽位收集不准(LLM 同时设多个槽) | force_slot_filling 未生效,或 prompt 里槽位描述太弱。三件套:确认用了正确的 CommandProcessor + 强化 jinja2 里那 6 条硬约束 + 加槽位验证 |
| 4 | ask_before_filling 用错变死循环 |
它是清脏槽位的,必须与 slot_to_collect 交叉判断(见 4.1) |
| 5 | collect 分支顺序陷阱 | slot_to_collect 必须先于 action 判断,否则 metadata 丢失(源码注释原话) |
| 6 | utter_ask_ → action_ask_ 降级是运行时试探 |
domain 里漏写 utter 不会报错,只会静默走 action 版本 |
| 7 | 条件 DSL 是字符串化比较 | slots.order_id != "false" 里的引号是语义的一部分 ;写数值比较会得到意外结果;不支持 >= |
| 8 | 社区版 Neo4j 约束能力有限 | 属性存在/类型/键约束社区版不支持 (只有唯一性约束可用);向量/全文索引也不在课程那份 Neo4j 讲义里,得靠 neo4j-graphrag 包 + jieba 预分词自己补 |
| 9 | bge-base-zh-v1.5 ↔ vector_dim=768 强绑定 |
换模型要同步改维度并重建索引 ,EMBEDDING_MODEL 走环境变量 |
| 10 | FlowRetriever 是实现但未接线的死代码 |
全仓只在 retrieval/__init__.py 被导入导出。写文章/简历时说"语义召回选 flow"要注明这是预留能力,不是在用路径 |
| 11 | 开发期切换过 Python 版本 | zip 里 __pycache__ 同时有 cpython-310 与 cpython-312 的 .pyc;说明环境切换过,别当成多版本兼容的证据 |
密钥管理是这个项目做得最好的地方之一 :.env 只有 4 个变量名(DASHSCOPE_API_KEY / NEO4J_PASSWORD / MYSQL_PASSWORD / EMBEDDING_MODEL),endpoints.yml 全程用 ${DASHSCOPE_API_KEY} 占位符引用,shared/config.py 支持 ${VAR} 与 ${VAR:default} 两种正则替换。素材里没有明文密钥 ------这是相比其他几个项目(.env 里明文 key)最该被抄的做法。
最后总结
- 一句话理解这套架构 :
LLM 只负责"听懂"(生成 DSL 命令),Flow 负责"按流程走",Policy 负责"决定下一步动作",Action 负责"改外部世界",对话栈负责"记住现在在哪"。 - 它没有把一切交给大模型 ,而是逐模块替换 :NLU 意图分类 → LLM 生成
start flow;NLU 实体抽取 → LLM 生成set slot;DM 状态机/stories+rules → Flow YAML + 对话栈;策略打分 → 优先级 + abstain + 降级链;Fallback → 四级降级;KB 问答 → RAG + GraphRAG。这才是"LLM 驱动的多轮对话"在工程上的真实形态。 - 对话栈是全场最值钱的抽象 :
active_flow作为派生 property 而非字段,意味着状态不可能不一致;中断/切换/恢复/嵌套全部退化成压栈弹栈。 [NO_RAG_ANSWER]这一个字符串承担了两个职责(对用户的拒识 + 策略降级的触发器)。设计得极简,效果极好。- 最反直觉的一条 :"启动 Flow 时禁止从历史推断槽位"。故意让系统重新问一遍,是拿一轮对话换不会做错事。 订单号、手机号这种时效性极强的槽位,宁可笨也不能错。
- 凡是能结构化的输入就别过 LLM :按钮走
/SetSlots(...)旁路,User标签走精确 Cypher 不走向量。 - DU 是最佳蒸馏场景:高频、格式固定、任务单一。32B 在线造数据 → 8B SFT,不需要 RLHF、不需要 thinking。
- 三个必须诚实标注的缺口 :没有
RulePolicy、没有 stories/rules、FlowRetriever未接线、>=条件文档有实现无。笔记 43 万字符且从未出现"Rasa",架构渊源要自己判断,别让课件替你背书。 - 笔者(后端 & 架构)从这个项目里带走的东西只有一句:在多轮对话系统里,"状态放哪"这个问题必须先于"用哪个模型"回答。 这个项目先想清楚了对话栈是唯一状态源,然后才有资格谈 LLM 嵌在哪一层。
参考资料 & 致谢
3 Neo4j GraphRAG for Python - GitHub
5 Text-to-SQL Evaluation (原 Spider / 逻辑形式解析思路) - arXiv