智能体项目复盘 · 智能客服篇:对话栈、Flow 与策略集成器——LLM 是怎么嵌进传统多轮对话架构的

💡 这一篇对应课程的 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 策略"指的是 FlowPolicy vs EnterpriseSearchPolicy,不是 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 嵌在哪一层。

参考资料 & 致谢

1 Rasa CALM 架构 - 官方文档

2 Rasa Flows - 官方文档

3 Neo4j GraphRAG for Python - GitHub

4 LangGraph 官方文档

5 Text-to-SQL Evaluation (原 Spider / 逻辑形式解析思路) - arXiv

6 BAAI bge-base-zh-v1.5 - Hugging Face

7 jieba 中文分词 - GitHub

8 Model Context Protocol - 官网

9 阿里云百炼 DashScope - 官方文档

相关推荐
正在走向自律18 小时前
AI Agent 落地这一年:从“能跑起来”到“敢放出去”
大数据·人工智能·ai agent·智能体·mcp·a2a
橙臣程19 小时前
RAG、SFT、RL 怎么选?基座模型越来越强,什么时候还值得微调
rag
积流成海1 天前
Agent演化史(九):多智能体的暗面,当一次 prompt 注入可以横向移动
prompt·agent·智能体·大模型应用
雪碧聊技术4 天前
RAG技术解析、落地指南(详解)
向量数据库·rag·增强检索生成
Deng桑5 天前
RAG的Java落地-从切分入库到检索问答
rag·检索增强生成·rag 落地实战
像风一样自由20205 天前
4.DockerCompose是什么为什么算法服务需要多个容器
人工智能·rag·智能体
呆某人5 天前
RAG-FastGPT安装
fastgpt·rag·glm-4-flash·embedding-3
我喜欢就喜欢5 天前
RAG 重排序 Rerank 实战:用 bge-reranker 把 Top-K 命中率拉回来
python·大模型·向量检索·rag·rerank
GJGCY5 天前
券商舆情智能体如何设计?事件聚类去重、主体消歧与风险事件抽取技术解析
经验分享·ai·金融·自动化·证券·智能体