大模型意图识别翻车后,我们换掉了词典和分类 Prompt
一次生产 RAG 意图识别重构实录 ------ 当「加词典 + 写分类 Prompt」救不了 case #17,我们用语义路由 + LLM 兜底让线上线下统一精准

写在前面:一个让我们翻车的 case
生产环境里,用户问了句:「你今年多大了」。
旧代码把它判成了 simple(简单问题),于是欢快地进检索、翻知识库、硬凑了一个设备参数的答案。用户一脸懵:我跟你聊年龄,你跟我报心跳间隔?
我们一开始以为这是小问题------加几个关键词、补一句「多大了→闲聊」的规则就行。直到同事一句话点醒我:
「大厂根本就不是靠新增词典跟提示词来划分意图的。」
这句话把我们整个识别模块推倒重做了。下面把「翻车 → 纠错 → 重构 → 实测」一次性讲透,所有代码、数据均来自真实落地。
完整代码已开源 · GitHub 搜索
enterprise-ai觉得这篇实战复盘有用?点个 Star ⭐ / Fork 拿去改 ------ 你的 star 是我继续把踩坑写成文章的底气,评论区也欢迎一起讨论。
一、那个让我们翻车的 case #17
翻旧代码,根因很清晰,而且不止一个:
python
# langgraph_rag_agent.py · 旧 node_classify(已废弃)
if self.fast_mode or not history_text:
# 快速模式或无历史:跳过 LLM,用规则快速分类
qtype = self._quick_classify(query)
return {"query_type": qtype, ...}
result = self.llm.chat(system, user, task="classify", user=self.username)
三个坑叠在一起:
| 坑 | 现象 |
|---|---|
线上写死 fast_mode=True |
web 服务启动时写死 fast_mode=True,于是 node_classify 永远走 if self.fast_mode 分支 → 永远只调规则 _quick_classify,从不调 LLM |
| deepseek 配置是空改 | 我们早把 classify 路由改成 [deepseek-chat, local-qwen],但因为上一行,线上压根用不上,配置纯摆设 |
classify_source 是死字段 |
之前有人加了 classify_source 字段想做 bad case 归因,但三处 return 一个都没赋值------出问题分不清是 LLM 判的还是规则判的 |
而 _quick_classify 这套规则,正是 case #17 的元凶:「你今年多大了」和「心跳间隔是多少」字面都含「是多少」,词典只见 token、几乎一致,补丁永远补不完。
二、为什么「加词典 + 写 Prompt」救不了
被同事点醒后,我重新梳理了「意图识别」这件事的范式演进:
古典 NLU 范式(Rasa / Dialogflow / LUIS)------也就是我们旧代码那套:
- 手写意图词典(关键词、正则)
- 写个分类 Prompt,让模型「从 X / Y / Z 里选一个」
- 规则优先级兜底
它的致命缺陷是只在字面层做文章。「你今年多大了」和「心跳间隔是多少」在 token 层面高度重叠,靠补词典永远在打补丁。
大厂在 LLM 时代的做法------语义向量路由 + LLM 原生路由:
- 每个意图用一批示例话语代表,离线用 embedding 模型 embed 成「意图质心(centroid)」
- 线上 query 实时 embed → 和哪个 centroid 余弦最像 → 就是那个意图
- 无关键词、无分类 Prompt,天然抗改写、抗同义、抗字面碰撞
这正好戳中 case #17:两个问句字面都含「是多少」,但在 bge-m3 的 1024 维向量空间里离得很远,一判即分。
而我们的项目已经具备 bge-m3 管线 (advanced_rag_agent._make_embedder(),走 VM 的 Ollama,1024 维),直接复用,零新增模型成本。
三、架构:统一智能体,线上线下共用
核心思路:把 node_classify 里硬编码的三分支,抽成独立的 IntentClassifier 组件------不再因为 fast_mode 决定走不走模型,线上 CLI 共用同一套判定。
bash
用户输入 query + history
│
▼
┌───────────────────────────── IntentClassifier ─────────────────────────────┐
│ │
│ L1 语义路由(主路径·零模型成本) │
│ bge-m3 embed 示例 → centroid → 余弦相似度 → 最近意图即判定 │
│ │ 高置信 且 非歧义 │
│ ▼ │
│ 返回(source=semantic) │
│ │ 低置信 或 歧义 │
│ ▼ │
│ L2 LLM 原生路由(仅兜底歧义) │
│ deepseek JSON 结构化输出:意图当 tools 裁决 │
│ │ 仍低置信 │
│ ▼ │
│ L3 兜底:→ clarify / oos(不硬猜) │
│ │
│ 旁路:经验缓存命中 → 跳过 L1/L2 │
│ 降级:bge-m3 离线 → _quick_classify(词典仅作第二道防线) │
└──────────────────────────────────────────────────────────────────────────────┘
│
▼
IntentResult(intent, confidence, source, candidates) → 喂给 bad case 归因
关键设计点:
- L1 语义路由是主路径,零 LLM 成本:每次请求多一次 bge-m3 embed(几毫秒),比调 LLM 便宜得多
- L2 只在歧义时调 deepseek:把意图当 tool 让模型裁决,而不是「写个分类 Prompt 让它选」
source字段真正落地:每次判定记录来源(semantic / semantic:ambiguous→llm / lexical / cache / fallback),这是 bad case 归因的命脉- 词典降级保留 :Ollama 离线时自动退回
_quick_classify,不丢 case #17 的修复成果
四、核心代码(实战,可直接抄)
4.1 意图用 examples 定义,而不是 keywords
yaml
# config/intents.yaml
_meta:
embed_model: bge-m3
threshold: 0.60 # L1 语义路由判定门槛(低于则触发 L2)
ambiguity_gap: 0.08 # 第一/第二意图相似度差,小于则视为歧义
llm_tiebreak: true # L1 歧义时是否调 LLM 兜底
default_intent: simple # 全失败时兜底
centroid_fail_cooldown: 60 # centroid 构建失败后的重试冷却秒数(防离线风暴)
intents:
chitchat:
description: 闲聊、问候、感谢、身份/年龄、情绪、寒暄
examples:
- 你好
- 在吗
- 你是谁
- 你今年多大了 # ← case #17 就在这里
- 今天天气怎么样
simple:
description: 单一事实查询,一次检索即可作答
examples:
- 心跳间隔是多少
- 波特率是多少
- 定位精度是多少
complex:
description: 多维度复合问题,需多轮检索或任务拆解
examples:
- 定位方式有哪些,各自精度如何,续航怎样
- 怎么配置设备,配置项有哪些,分别什么作用
comparison:
examples:
- A 和 B 的定位方式有什么区别
- 对比一下两种通讯协议的优缺点
oos:
examples:
- 帮我写一首诗
- 明天股票会涨吗
clarify:
examples: [这个怎么弄, 那个参数是什么意思]
feedback:
examples: [这个答案不对, 谢谢,回答得很好]
加意图只改这个 yaml,不动任何路由代码。 这就是配置化带来的可扩展性------意图清单从 3 条(闲聊/简单/复杂)扩到 7 条,零侵入。
4.2 IntentClassifier.classify:三级路由主逻辑
python
# intent_classifier.py(节选,已落地)
def classify(self, query, lexical_fn=None, llm_fn=None, use_cache=True):
# 0. 经验缓存命中 → 直接短路
if use_cache and (hit := self._cache.get(cache_key)):
return IntentResult(hit.intent, hit.confidence, "cache",
candidates=hit.candidates)
# 1. L1 语义路由(主路径)
self._ensure_centroids()
if self._ready:
qv = self._embed(query)
sims = {it: _cosine(qv, c) for it, c in self._centroids.items()}
ranked = sorted(sims.items(), key=lambda kv: kv[1], reverse=True)
top1, s1 = ranked[0]
top2, s2 = ranked[1] if len(ranked) > 1 else (None, -1.0)
if s1 >= self._threshold and (top2 is None or (s1 - s2) >= self._ambiguity_gap):
return self._finish(top1, s1, "semantic", candidates=top_cands)
# 歧义或低置信 → L2 LLM 兜底
if self._llm_tiebreak and llm_fn is not None:
raw = llm_fn(query, list(self._intents.keys()))
parsed = self._parse_llm_intent(raw)
if parsed and parsed in self._intents:
return self._finish(parsed, max(s1, 0.5),
"semantic:ambiguous->llm", candidates=top_cands)
# L2 不可用/失败 → 词典降级
return self._lexical_or_default(query, max(s1, 0.4), lexical_fn, ...)
# 2. embedder 不可用 → 词典降级
return self._lexical_or_default(query, 0.0, lexical_fn, ...)
注意 _ensure_centroids 里有一道双重检查锁 + 构建失败冷却------这是后面 Code Review 才补的铁壁(见第六节 R1/R3)。
4.3 node_classify 委托组件,彻底删掉 fast_mode 网关
python
# langgraph_rag_agent.py · 新 node_classify
clf = getattr(self, "intent_classifier", None)
if clf is not None:
res = clf.classify(query,
lexical_fn=self._quick_classify,
llm_fn=self._llm_classify_json)
qtype = res.intent
# P3b:低置信进 bad case(仅语义路由在线但没把握时;词典降级不刷屏)
if res.confidence < 0.5 and res.source not in ("lexical", "lexical:fallback", "fallback:default"):
self.memory_store.add_bad_case(
query, source="intent_lowconf", suite="intent",
expected=res.intent, root_cause=res.source,
diagnosis=f"candidates: {cands_txt}")
这一处改动是根治关键 :删掉 if self.fast_mode 网关分支后,线上(fast_mode=True)和 CLI 走的是同一条语义路由,deepseek 配置终于不再是空改。
五、实测:case #17 真的好了
裸语义路由(最严苛条件:不带 L2 兜底、不带词典降级)的黄金抽样,真实 bge-m3 跑出来:
| query | 判定 | 置信 | 来源 | 期望 |
|---|---|---|---|---|
| 你今年多大了 | chitchat | 0.854 | semantic | chitchat ✅ |
| 你是谁啊 | chitchat | 0.804 | semantic | chitchat ✅ |
| 在吗 | chitchat | 0.774 | semantic | chitchat ✅ |
| 心跳间隔是多少 | simple | 0.838 | semantic | simple ✅ |
| 波特率怎么设置 | simple | 0.754 | semantic | simple ✅ |
| 对比A协议和B协议的优劣 | comparison | 0.824 | semantic | comparison ✅ |
| 帮我写一首诗 | oos | 0.815 | semantic | oos ✅ |
| 明天股票会涨吗 | oos | 0.732 | semantic | oos ✅ |
准确率 10/12 = 83% 。两个 miss 都在 complex↔simple 边界(「排障类」「双参数设置类」示例覆盖不足),且生产路径恒传 llm_fn(deepseek 兜底),实际准确率高于此下限。
请求流程端到端跑通:

六、Code Review 抓出的 3 个真实缺陷
代码写完还没松口气,一次独立 Code Review 抓出 3 个我亲手写的真实缺陷,逐个修了:
| # | 级别 | 缺陷 | 处置 |
|---|---|---|---|
| R1 | 高 | centroid 构建失败无冷却 :Ollama 离线时每请求重试整轮示例 embed(chitchat 先失败 = 8 次 HTTP/请求);若 VM 端口被防火墙丢包(非拒绝),每请求叠加数十秒超时------线上事故级 | ✅ 复用 kb_version 的 fail-cooldown 模式,失败后 60s 内直接降级 lexical 不重试 |
| R2 | 高 | 消解归因不一致 :非 fast_mode 有历史时,消解 LLM 可翻盘 qtype,但 classify_source 仍记语义来源------归因撒谎,bad case 无法定位真实决策者 |
✅ semantic 高置信判定不被消解翻盘(只采纳 resolved_query);lexical 路径保留纠偏但追加 ` |
| R3 | 中 | 并发安全:web 服务多线程共享同一 IntentClassifier,centroid 构建/缓存读写无锁 | ✅ 双重检查锁 + 缓存锁 |
另有 R4--R6 小项(AgentState 字段未声明、fallback:default 守卫遗漏、死配置 ollama_base)一并修了。
测试结果:全量 107 断言全绿(意图组件 16 + coverage_gaps 46 + evolution_p1 16 + review_fixes 29),其中 6 条专门验证这 3 个缺陷的修复(含冷却防风暴、归因一致性 ×2)。
七、写在最后
这次重构最大的收获不是「又多了一个分类器」,而是方法论上的纠偏:
在 LLM 时代做意图识别,堆词典和写分类 Prompt 是古典思路的惯性,解决的是「字面层」问题;真正的杠杆是语义向量空间------让「你今年多大了」和「心跳间隔是多少」在 1024 维里自然分开,而不是在 token 层死磕补丁。
遗留的两个增强点(也是下一步计划):
- 补 complex 排障类 examples:把 golden 从 83% 拉到 90%+
- centroid 本地落盘:重启免重算 + 离线可用
代码已全部落地、测试全绿、未提交,GitHub 搜索 enterprise-ai 即可拿到全部实现。
觉得有用?GitHub 搜索
enterprise-ai拿完整代码 · 点个 Star ⭐ 支持一下 你在意图识别上踩过什么坑?评论区聊聊,一起把坑填平。