我在一个 ToB 的 SaaS 系统里做 AI 助手,用户用自然语言操作业务——「帮某某充值200」「这个月经营怎么样」「补货建议」——系统理解意图后调 API 执行。最终做到了 57 个意图、7 个业务域。
这篇文章是一份完整的意图识别工程实录:遇到什么问题 → 怎么解决 → 解决方案又带来什么新问题 → 如何再解决,以及最终如何把核心逻辑抽离成了一个可复用的开源库。
目录
- 第一阶段:把所有希望寄托在一个 prompt 上
- 第二阶段:域预筛选——从 3s 降到 0.5s,但带来了新问题
- 第三阶段:架构分层——让 LLM 只做理解,代码做决策
- 第四阶段:问题分流——不是所有问题都该进意图匹配
- 第五阶段:三模型对比——发现单模型天花板
- 第六阶段:双通道融合——突破天花板
- 最终成绩与剩余问题
- 抽离为开源库
- 沉淀的设计原则
第一阶段:把所有希望寄托在一个 prompt 上
遇到的问题
最初的方案很直接:把所有意图的名称和描述塞进一个 prompt,让 LLM 返回匹配结果:
{"intent": "recharge", "confidence": 0.9, "params": {"name": "某某", "amount": 200}}
一个 prompt 同时做三件事:识别意图、提取参数、输出置信度。
实际跑起来,两个问题立刻暴露:
- 慢。50+ 个意图的完整描述塞进 prompt,约 5000 token,LLM 每次调用 3-4 秒。对话系统的用户期望秒级响应。
- 不准。选项太多,LLM 经常在语义相近的意图之间漂移。比如用户说「消费情况」,它可能匹配到客户查询、消费记录、报表统计中的任何一个。
我的解决方案
把 50+ 个意图按用户心智模型划分为 7 个语义域(客户、商品、库存、订单、服务、营销、报表),每个域配置一组关键词。用户输入先过一遍关键词匹配,确定落在哪 1-2 个域,只把该域的 5-10 个意图送进 LLM。
同时把 prompt 格式压缩——从多行描述改成单行紧凑格式,每个意图从 ~100 token 压到 ~40 token。
再把意图识别模型从通用推理模型降级为速度更快的轻量模型(意图识别不需要强推理能力)。
效果:延迟从 3-4s 降到 0.5-1.2s,识别准确率也有提升(候选少了,LLM 更容易选对)。
但这带来了新问题 ↓
第二阶段:域预筛选——从 3s 降到 0.5s,但带来了新问题
遇到的问题
域预筛选基于关键词,而关键词永远不够全。
每次联调都会发现新的漏词:
- 「消费」这个高频词,我没放进任何域的词库 → 「消费情况」「消费记录」全部匹配失败
- 「今天店里情况怎么样」→ 没命中报表域也没命中任何任务锚点 → 走了闲聊
- 「营业额」在报表域的词库里,但不在任务型意图的锚点里 → 经营报告任务没被注入候选
更致命的是:你不知道你漏了什么,直到用户说了那句话。
除了漏词,还有误伤。我设了「怎么操作」「怎么做」等关键词用于匹配操作指南类意图。结果「下个月怎么做能盈利」被「怎么做」三个字命中,路由到了操作指南检索——但这明明是一个经营策略问题,不是系统操作教学。
我的解决方案
三层防御:
-
负向排除:给容易误伤的关键词加排除模式。比如「怎么做」命中操作指南,但「怎么做能」「怎么做才能」这种模式排除(这是策略类问题)。
-
多域叠加:一个关键词可以命中多个域,取并集。「消费」同时指向订单域和报表域。
-
全量兜底:如果没有任何域命中,不拒绝,而是用全量意图(压缩格式)做一次识别。宁可慢一点也不漏掉。
但说实话,这只是在打补丁。关键词方案的上限就在那里——它能解决 70% 的路由问题,剩下 30% 需要语义理解。
这个认知为后面的 embedding 通道埋下了种子。
回头看,我认为这个阶段最重要的收获不是具体的关键词怎么配,而是意识到:基于规则的召回和基于语义的理解,是两种根本不同的能力。后来的双通道架构,起点就在这里。
但更根本的问题是 ↓
第三阶段:架构分层——让 LLM 只做理解,代码做决策
遇到的问题
做了域预筛选之后准确率提升了,但一类问题始终解决不好:LLM 同时做理解和决策,两者的逻辑互相打架。
具体来说,我的 prompt 要求 LLM 做三件事:
- 识别涉及哪些意图(NLU)
- 给出置信度,低于 0.6 就走闲聊(路由决策)
- 判断是单意图还是多意图需要编排(编排决策)
结果:
-
置信度是个坑。LLM 对任务型意图(「利润分析」「热销排行」)经常给 0.4-0.5 的置信度——不是没识别到,是对自己不确定。但我的阈值 0.6 把它们全拦掉了,一个阈值,干掉了一半的任务路由。
-
多意图检测不稳定。用户说「充值200并加100积分」,LLM 应该识别出两个意图。但它经常直接匹配第一个,把第二个吃掉。让 LLM 同时做 NLU 和编排判断,在 prompt 规则上是冲突的。
-
业务判例越堆越多。为了修单个 case,我往 prompt 里加规则:「消费情况→报表」「创建活动→不匹配查询」「入库和出库不要混淆」... prompt 从 10 行规则膨胀到 40 行,每修一个 case 可能引入两个 regression。本质上是在用自然语言写 if-else。
我的解决方案
核心原则:LLM 只做语言理解,路由决策交给代码。
旧流程:LLM → {"intent": "recharge"} 或 {"orchestrate": true} ← LLM 做决策
新流程:LLM → {"intents": [{recharge}, {add_points}]} ← LLM 只做 NLU
代码 → len(intents) > 1 → orchestrate ← 代码做决策
具体改了三件事:
1) Prompt 瘦身:删掉全部业务判例规则(40 行 → 5 行),LLM 只需要输出「这句话涉及哪些意图」,不需要做任何路由判断。
2) 结构校验层:在 LLM 输出之后、路由之前,加一层确定性的结构校验。每个意图预先声明它的动词类型(query/write/report/analyze)和参数语义模式(人名/手机号/金额/日期)。代码根据声明做评分排序:
score = 必填覆盖率 × 0.50 + 参数模式匹配 × 0.30 + 动词匹配 × 0.15 + LLM置信度 × 0.05
注意 LLM 置信度只占 5%。主决策靠结构化信号,LLM 置信度降到微调级别。
3) 四态裁决:
| 裁决 | 条件 | 动作 |
|---|---|---|
| CLEAR | 唯一候选或分差明显 | 直接执行 |
| AMBIGUOUS | 多候选分差小 | 追问用户或编排 |
| INSUFFICIENT | 候选确定但缺参数 | 槽位追问 |
| REJECTED | 全部淘汰 | 域引导拒绝或闲聊 |
这一步改完之后,prompt 里的业务判例全清了,但识别准确率反而提升了——因为原来那些规则本来就互相打架。
我后来把这个阶段总结为一条原则:系统中「规则」的唯一合法形式是结构化规则——基于 schema、类型、模式的判断。在 prompt 里用自然语言写的业务判例,本质上是不可维护的 if-else。
但新的问题出现了 ↓
第四阶段:问题分流——不是所有问题都该进意图匹配
遇到的问题
架构分层之后,意图匹配链路本身稳定了很多。但一类问题暴露了出来:有些用户输入根本就不该进入意图匹配。
三个典型 case:
- 「充值怎么操作」→ 被匹配到了充值意图(缺参数→开始追问客户名和金额)。但用户想问的是怎么操作系统,不是要执行充值。
- 「下个月怎么做能盈利」→ 被匹配到了利润分析任务或操作指南。但这是一个经营策略问题,不是数据查询,也不是系统操作教学。
- 「分析一下」→ 被匹配到了经营策略建议。但用户刚查了某个客户的详情,说的是「分析一下这个客户」。
这三个问题的共同点是:它们需要在意图匹配之前先回答一个更基本的问题——「这是不是一个技能问题?」
我的解决方案
在意图识别之前加一层 Problem Type 预分类,把用户输入先分成 5 类:
| 类型 | 含义 | 示例 | 后续处理 |
|---|---|---|---|
| ACTION | 明确执行动作 | 帮某某充值200 | 进入意图匹配 |
| QUERY | 明确查数据 | 查一下今天营收 | 进入意图匹配 |
| GUIDE | 问系统怎么操作 | 充值怎么操作 | 直接路由到操作指南 |
| STRATEGY | 问经营建议 | 下个月怎么盈利 | 进入策略建议通道 |
| CHAT | 闲聊 | 你好 | 闲聊 |
分类器两步走:先用规则(关键词+模式匹配),规则不确定时才调一次轻量 LLM(不注入意图列表,只判断问题类型)。
这样,操作教学和经营策略在入口就被分流了,不再和业务意图竞争。
我认为这一步揭示了一个容易被忽视的前提:不是所有用户输入都适合走意图路由。强结构任务(充值、出库)适合;弱结构查询(分析、报表)适合但需要更强的推理;开放问题(经营策略、操作教学)根本不该进入意图竞争。先判断「这是不是一个意图问题」,比「匹配哪个意图」更重要。
但 case 3(「分析一下」)还没解决。 单看这四个字,Problem Type 判定为 STRATEGY 完全合理。问题在于它不感知对话上下文。
所以又加了一层上下文拼装:在分类之前,先检查对话中是否有刚解析的实体(比如刚查了某个客户)。如果有,把实体信息拼入分类输入——「分析一下」变成「分析一下 [客户] 某某」,Problem Type 就能正确判定为 QUERY。
但更大的系统性问题还在后面 ↓
第五阶段:三模型对比——发现单模型天花板
遇到的问题
做了上面所有优化之后,我决定用数据说话——写了 104 条标注路由 case,覆盖正向匹配、边界表达、混淆干扰三类,跑了三个不同能力等级的 LLM:
| 模型 | 准确率 | 平均延迟 |
|---|---|---|
| 模型 A(轻量快速) | 80.8% | 2.2s |
| 模型 B(中等均衡) | 82.7% | 6.0s |
| 模型 C(强力推理) | 86.5% | 15.1s |
三个关键发现:
1) 中等模型性价比极低。 比轻量模型只提高 1.9%,延迟翻了 2.7 倍。意图识别不需要强推理能力,用贵模型是浪费。
2) 报表/分析域是最大的失败点。 轻量模型在分析域只有 47% 的准确率,强力模型有 87%。任务型意图(利润分析、热销排行、补货建议等)需要 LLM 做语义归纳推理,简单的关键词匹配和模式识别不够用。
3) 没有一个模型突破 87%。 27 条不同的失败 case 中,只有 4 条是三模型共同失败的——也就是说 73% 的失败是模型特异的。每个模型有自己擅长和不擅长的场景。
这意味着:如果能让不同能力互补,准确率应该能显著提升。
失败模式分析
把失败 case 按根因分类,发现了 5 种模式:
| 模式 | 占比 | 典型 case | 原因 |
|---|---|---|---|
| 任务型意图 → 闲聊 | ~60% | 「利润分析」「热销排行」→ free_chat | LLM 给低置信度,被阈值拦掉 |
| 短词命令 → 操作指南 | ~15% | 「充值」「盘点」→ guide_search | 规则层过早拦截,LLM 没机会处理 |
| 跨域语义歧义 | ~10% | 「消费情况」→ 客户查询而非报表 | LLM 选了相近但非最优的意图 |
| 口语化表达不识别 | ~10% | 「涨点价」「货还够不够」→ 失败 | 意图描述没覆盖口语 |
| 歧义误判为多意图 | ~5% | 「查一下某某」→ 8步编排 | 代码把歧义当成了多意图 |
其中占比最大的前两类(~75%),本质上都是同一个问题:单通道方案的天花板到了。
- 纯 LLM 方案:语义推理强,但置信度不稳定,对短词和口语表达识别弱
- 如果换成纯 embedding 方案:关键词匹配好,但间接表达不行(「我不想要了」匹配不到取消订单)
这直接指向了最终方案 ↓
第六阶段:双通道融合——突破天花板
核心洞察
回头看整个演进过程,我把最终方案总结为三层:
Intent Resolution = Recall(召回) + Reasoning(推理) + Ranking(排序)
- Recall:从候选池中快速找出可能相关的意图(embedding 向量检索)
- Reasoning:对候选做语义推理和消歧(LLM 在意图树上推理遍历)
- Ranking:多信号融合评分,输出最终判决(α 加权排序 + 三态决策)
我认为这个结构不仅适用于意图识别。RAG 里的文档选择、Agent 里的工具选择、推荐系统里的候选排序,本质上都是同一个问题:多信号 → 统一评分 → 排序决策。只是每个场景的 Recall 和 Reasoning 实现不同。
在意图识别这个具体场景下,Embedding(Recall)和 LLM(Reasoning)各有优劣,互补性极强:
| 用户说的 | 纯 Embedding | 纯 LLM | 融合 |
|---|---|---|---|
| 取消订单 | ✅ cancel_order | ✅ cancel_order | ✅ |
| 我不想要了 | ❌ 匹配不到 | ✅ cancel_order | ✅ |
| 今天店里怎么样 | ❌ → query_revenue | ✅ → daily_report | ✅ |
| 什么卖得最好 | ❌ → query_product | ✅ → hot_selling | ✅ |
方案设计
两个通道并行执行,结果融合为三态判决:
用户消息
│
├─────────────────────┬─────────────────────┐
│ │ │
┌───▼──────┐ ┌──────▼───────┐ │
│ Embedding │ │ 意图树推理 │ 并行执行
│ 通道 │ │ 通道 │ │
│ (5-15ms) │ │ (LLM 调用) │ │
└───┬──────┘ └──────┬───────┘ │
│ │ │
└──────────┬──────────┘─────────────────────┘
│
┌──────▼──────┐
│ 融合评分 │ final = α × emb + (1-α) × tree
└──────┬──────┘
│
┌──────▼──────┐
│ 三态判决 │ CLEAR / AMBIGUOUS / LOW
└─────────────┘
通道一:Embedding 召回
用 sentence-transformer 模型(bge-small 系列,~95MB)将用户消息和每个意图的描述编码为向量,cosine similarity 排序取 top-K。
速度极快(5-15ms),关键词匹配好,但间接表达不行。
通道二:意图树推理
受 VectifyAI 的 PageIndex 框架启发,将意图识别建模为 LLM 在层级树上的推理遍历。
不是把 50 个意图平铺给 LLM,而是构建一棵按语义域分组的树:
── 客户经营 ──
query_customer: 搜索客户,查客户详情
recharge: 充值,续费
analyze_customer: 分析客户,客户画像
── 经营分析 ──
daily_report: 经营报告,营收总结
profit_analysis: 利润分析,盈亏
hot_selling: 热销排行,畅销
...
LLM 用 CoT(Chain-of-Thought)推理:先判断属于哪个域,再在域内精确匹配。语义推理强,但依赖 LLM 可用性和响应速度。
我后来意识到,这棵意图树本质上是一个轻量的语义知识图谱:
domain是上层语义聚类,intent是节点,tree_text定义的不只是"这个意图是什么",更关键的是"它不是什么"——通过描述与相邻意图的边界(negative space),给 LLM 提供了推理路径。这和传统的 flat list 分类有本质区别。
关键设计:一个意图,两段文本
这是生产中得出的核心 insight:一段描述不可能同时对 embedding 和 LLM 都最优。
IntentEntry(
name="hot_selling",
domain="经营分析",
embed_text="热销排行, 卖得最好, 畅销商品, 什么卖得多, 爆款, 好卖",
tree_text="用户想看商品销量排行。区分:查营业额→daily_report;查员工业绩→staff_commission",
)
embed_text:关键词密集,同义词丰富——喂给 embedding 模型用户实际会说的话tree_text:描述性强,含消歧提示——告诉 LLM 这个意图是什么、以及不是什么
融合公式与参数标定
final_score = α × embedding_score + (1 - α) × tree_score
在 104 条标注 case 上做网格搜索,找到最优参数:
| 参数 | 最优值 | 含义 |
|---|---|---|
| α | 0.10 | embedding 权重极低,意图树推理主导 |
| δ | 0.10 | Top1-Top2 差距阈值,低于此值判定为歧义 |
为什么 α = 0.10? 在我当前的数据集上(57 个意图),LLM 推理能 cover 大多数场景,embedding 更多是「召回安全网」的角色。但我认为这个比例不是普适的——如果意图数量增长到 200+,或者扩展到多行业、多语言场景,LLM 会逐渐力不从心,embedding 作为「语义空间召回」的主力价值会显现出来。α 的最优值应该随着系统规模动态调整,这也是为什么我把标定工具做成了开源库的一部分。
三态判决
不做二选一的强制路由,而是承认不确定性:
| 判决 | 条件 | 处理策略 |
|---|---|---|
| CLEAR | Top1 领先明显 | 直接路由 |
| AMBIGUOUS | Top1-Top2 接近 | 执行 Top1,暗示 Top2(不烦用户追问) |
| LOW | 全部得分低 | 降级到闲聊/引导搜索 |
AMBIGUOUS 的 UX:不直接问「你要 A 还是 B?」(频率高了很烦),而是执行 Top1 并在结果末尾附一句「如果想看XX也可以告诉我」。
单通道降级
LLM 挂了 → embedding 仍然工作。embedding 出错 → LLM 仍然能推理。两个都挂 → LOW。
与参数提取并行
融合和参数提取同时进行,不增加额外延迟:
fusion_task = router.fuse(message, llm) # 决定调哪个意图
params_task = extract_params(message, llm) # 提取参数
fusion_result, params = await asyncio.gather(fusion_task, params_task)
融合决定 which,参数提取决定 what。两者不相互依赖。
最终成绩与剩余问题
逐阶段演进
| 阶段 | 测试集 | 准确率 | 关键变化 |
|---|---|---|---|
| 单模型基线 | 104 条 | 80.8% | 起点 |
| 双通道融合(初版) | 104 条 | 87.5% | +6.7%,意图树补上了任务型识别 |
| 双通道融合 + 口语优化 | 140 条 | 97.1% | +9.6%,embed_text 覆盖口语 + 编排检测修复 |
报表/分析域变化
这是改善最大的域——从单模型最差的 47% 到满分:
| 阶段 | 准确率 |
|---|---|
| 单模型(轻量) | 47% (7/15) |
| 单模型(强力) | 87% (13/15) |
| 双通道融合(扩展集) | 100% (21/21) |
剩余 4 条失败
140 条扩展回归中,4 条失败,全部是含指代词的短句:
| 消息 | 期望 | 分析 |
|---|---|---|
| 那个XX多少钱 | 查价格 | 「那个」需要对话上下文才知道指什么 |
| 把那个涨点价 | 改价 | 「那个」需要上下文 |
| 这个不卖了先撤下来 | 下架 | 「这个」需要上下文 |
| 那只XX什么时候来接 | 查寄养 | 「那只」需要上下文 |
结论:剩余失败全部属于对话上下文层(实体消解)的职责范围。意图识别层本身已无系统性短板。
抽离为开源库
做完整套系统之后,我发现双通道融合这个核心逻辑是可以独立抽离的——它不依赖具体业务域,不依赖意图定义格式,不依赖管道架构。
它只关心三件事:你有哪些意图、用户说了什么、LLM 怎么调用。
抽离成了 intent-fusion(MIT 协议):
from intent_fusion import IntentRouter, IntentEntry, FusionVerdict
entries = [
IntentEntry(
name="cancel_order",
domain="订单管理",
embed_text="取消订单, 不想要了, 退订",
tree_text="用户想取消一个已下的订单",
),
# ...
]
router = IntentRouter(entries)
result = await router.fuse("我不想要了", your_llm)
match result.verdict:
case FusionVerdict.CLEAR: execute(result.top.name)
case FusionVerdict.AMBIGUOUS: execute(result.top.name); suggest(result.runner_up.name)
case FusionVerdict.LOW: fallback()
内置标定工具
不需要自己写网格搜索——传入标注数据,自动找最优 α 和 δ:
python -m intent_fusion.calibration \
--cases your_cases.yaml \
--intents your_intents.yaml
# → Best: alpha=0.10, delta=0.10 → accuracy=0.971
和现有开源方案的定位差异
| 方案 | 方式 | 适合场景 |
|---|---|---|
| Semantic Router | 纯 embedding | 意图少、表达直接 |
| LangChain RouterChain | 纯 LLM | 对延迟不敏感 |
| intent-fusion | embedding + LLM 双通道 | 意图多、口语化、需要语义推理 |
核心差异:当纯 embedding 不够准(间接表达、语义归纳类查询),纯 LLM 又太慢或置信度不稳定时,双通道融合提供了一个用最快模型达到最强模型效果的中间方案。
为什么这是一个通用结构?
写完这个系统之后,我试着从更抽象的角度理解为什么双通道融合有效。
我认为核心原因是:用户输入本质上是不完整、不确定的信息。 而不同方法的区别在于如何利用这些信息:
- 纯规则:信息利用率低,但确定性高
- 纯 embedding:利用表层语义,但缺乏推理能力
- 纯 LLM:推理能力强,但稳定性差
单一方法,本质上都是对输入信息的「单视角解释」,因此一定存在盲区。
而 Recall + Reasoning + Ranking 的结构,我理解为:
- Recall:从不同视角尽可能「保留信息」——不遗漏可能性
- Reasoning:对候选进行语义推理——补全隐含信息
- Ranking:在多信号下做一致性决策——选择最优解释
这个结构的关键不是具体实现(embedding 还是 BM25,LLM 还是规则引擎),而是:通过多信号融合,最大化信息利用率,同时控制不确定性。
所以我认为它不仅适用于意图识别,也适用于其他需要「在不完整信息下选最优解释」的场景:
- RAG 文档选择:Recall 候选文档 → Reasoning 判断相关性 → Ranking 排序
- Agent 工具选择:Recall 可用工具 → Reasoning 判断可行性 → Ranking 决策
- 推荐系统:Recall 候选集 → Reasoning 用户意图 → Ranking 精排
我不敢说这是一个多么深刻的洞察——搜索和推荐领域大概早就这么做了。但至少对我来说,从意图识别这个具体问题出发,独立推导出这个结构,然后发现它和很多经典系统同构,这个过程本身是有收获的。
沉淀的设计原则
最后把踩坑过程中我认为最值得带走的几条原则总结一下:
1. LLM 只做理解,代码做决策。 让 LLM 告诉你「用户消息涉及哪些意图」,代码决定路由策略。不要让 LLM 同时做 NLU + 路由 + 编排判断。
2. 不要把 LLM 的 confidence 当硬开关。 LLM 置信度是不稳定的主观信号。用它做弱参考(5% 权重),不要用它做路由阈值。用结构化信号(必填参数覆盖率、动词匹配、参数模式)做主决策。
3. 一段描述不可能同时对两个通道最优。
embedding 需要关键词密集的同义词集合,LLM 需要描述性强且含消歧提示的自然语言。独立维护 embed_text 和 tree_text。
4. 先判断问题类型,再挑具体意图。 操作教学、经营策略、数据查询、执行动作——这些是不同类型的问题,不应该在同一条链路里竞争。
5. 不要低估短句和口语的破坏力。
用户不会按你的意图描述说话。「涨点价」「货还够不够」「最近出了啥」——这些口语化表达必须在 embed_text 中显式覆盖。
6. 歧义和多意图是两件事。 「查一下某某」是歧义(同实体多种查询方式),不是多意图(充值+加积分)。歧义选最优,多意图走编排。
7. 很多「识别错误」不是识别层的错。 可能是缺上下文(「分析一下」不知道分析谁),可能是缺对话状态管理(「好的」不知道是接受提议还是新话题)。归因到意图识别之前,先检查上下游。
8. 测试集和标定工具是必需品,不是锦上添花。 没有标注 case 跑回归测试,你永远不知道改了一处是不是引入了三处 regression。标定工具(网格搜索)让你不用凭感觉调参。
9. 单一信号一定有盲区,多信号融合才是出路。 embedding 和 LLM 各有擅长和不擅长的场景,没有一个能覆盖全部 case。与其追求单一方案的完美,不如让多个不完美的信号互相补位。这个原则不只适用于意图识别——上面那节已经展开讨论了。
如果你也在做意图识别、Agent 工具路由、或者任何需要「从一堆候选中选最对的那个」的系统,欢迎试用 intent-fusion 和反馈。