我是如何把生产环境的意图识别准确率从 86% 优化到 97% 的(附源码)

151 阅读21分钟

我在一个 ToB 的 SaaS 系统里做 AI 助手,用户用自然语言操作业务——「帮某某充值200」「这个月经营怎么样」「补货建议」——系统理解意图后调 API 执行。最终做到了 57 个意图、7 个业务域

这篇文章是一份完整的意图识别工程实录:遇到什么问题 → 怎么解决 → 解决方案又带来什么新问题 → 如何再解决,以及最终如何把核心逻辑抽离成了一个可复用的开源库。


目录


第一阶段:把所有希望寄托在一个 prompt 上

遇到的问题

最初的方案很直接:把所有意图的名称和描述塞进一个 prompt,让 LLM 返回匹配结果:

{"intent": "recharge", "confidence": 0.9, "params": {"name": "某某", "amount": 200}}

一个 prompt 同时做三件事:识别意图、提取参数、输出置信度

实际跑起来,两个问题立刻暴露:

  1. 。50+ 个意图的完整描述塞进 prompt,约 5000 token,LLM 每次调用 3-4 秒。对话系统的用户期望秒级响应。
  2. 不准。选项太多,LLM 经常在语义相近的意图之间漂移。比如用户说「消费情况」,它可能匹配到客户查询、消费记录、报表统计中的任何一个。

我的解决方案

把 50+ 个意图按用户心智模型划分为 7 个语义域(客户、商品、库存、订单、服务、营销、报表),每个域配置一组关键词。用户输入先过一遍关键词匹配,确定落在哪 1-2 个域,只把该域的 5-10 个意图送进 LLM。

同时把 prompt 格式压缩——从多行描述改成单行紧凑格式,每个意图从 ~100 token 压到 ~40 token。

再把意图识别模型从通用推理模型降级为速度更快的轻量模型(意图识别不需要强推理能力)。

效果:延迟从 3-4s 降到 0.5-1.2s,识别准确率也有提升(候选少了,LLM 更容易选对)。

但这带来了新问题 ↓


第二阶段:域预筛选——从 3s 降到 0.5s,但带来了新问题

遇到的问题

域预筛选基于关键词,而关键词永远不够全

每次联调都会发现新的漏词:

  • 「消费」这个高频词,我没放进任何域的词库 → 「消费情况」「消费记录」全部匹配失败
  • 「今天店里情况怎么样」→ 没命中报表域也没命中任何任务锚点 → 走了闲聊
  • 「营业额」在报表域的词库里,但不在任务型意图的锚点里 → 经营报告任务没被注入候选

更致命的是:你不知道你漏了什么,直到用户说了那句话。

除了漏词,还有误伤。我设了「怎么操作」「怎么做」等关键词用于匹配操作指南类意图。结果「下个月怎么做能盈利」被「怎么做」三个字命中,路由到了操作指南检索——但这明明是一个经营策略问题,不是系统操作教学。

我的解决方案

三层防御

  1. 负向排除:给容易误伤的关键词加排除模式。比如「怎么做」命中操作指南,但「怎么做能」「怎么做才能」这种模式排除(这是策略类问题)。

  2. 多域叠加:一个关键词可以命中多个域,取并集。「消费」同时指向订单域和报表域。

  3. 全量兜底:如果没有任何域命中,不拒绝,而是用全量意图(压缩格式)做一次识别。宁可慢一点也不漏掉。

但说实话,这只是在打补丁。关键词方案的上限就在那里——它能解决 70% 的路由问题,剩下 30% 需要语义理解。

这个认知为后面的 embedding 通道埋下了种子。

回头看,我认为这个阶段最重要的收获不是具体的关键词怎么配,而是意识到:基于规则的召回和基于语义的理解,是两种根本不同的能力。后来的双通道架构,起点就在这里。

但更根本的问题是 ↓


第三阶段:架构分层——让 LLM 只做理解,代码做决策

遇到的问题

做了域预筛选之后准确率提升了,但一类问题始终解决不好:LLM 同时做理解和决策,两者的逻辑互相打架。

具体来说,我的 prompt 要求 LLM 做三件事:

  1. 识别涉及哪些意图(NLU)
  2. 给出置信度,低于 0.6 就走闲聊(路由决策)
  3. 判断是单意图还是多意图需要编排(编排决策)

结果:

  • 置信度是个坑。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:

  1. 「充值怎么操作」→ 被匹配到了充值意图(缺参数→开始追问客户名和金额)。但用户想问的是怎么操作系统,不是要执行充值。
  2. 「下个月怎么做能盈利」→ 被匹配到了利润分析任务或操作指南。但这是一个经营策略问题,不是数据查询,也不是系统操作教学。
  3. 「分析一下」→ 被匹配到了经营策略建议。但用户刚查了某个客户的详情,说的是「分析一下这个客户」。

这三个问题的共同点是:它们需要在意图匹配之前先回答一个更基本的问题——「这是不是一个技能问题?」

我的解决方案

在意图识别之前加一层 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_chatLLM 给低置信度,被阈值拦掉
短词命令 → 操作指南~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.10embedding 权重极低,意图树推理主导
δ0.10Top1-Top2 差距阈值,低于此值判定为歧义

为什么 α = 0.10? 在我当前的数据集上(57 个意图),LLM 推理能 cover 大多数场景,embedding 更多是「召回安全网」的角色。但我认为这个比例不是普适的——如果意图数量增长到 200+,或者扩展到多行业、多语言场景,LLM 会逐渐力不从心,embedding 作为「语义空间召回」的主力价值会显现出来。α 的最优值应该随着系统规模动态调整,这也是为什么我把标定工具做成了开源库的一部分。

三态判决

不做二选一的强制路由,而是承认不确定性:

判决条件处理策略
CLEARTop1 领先明显直接路由
AMBIGUOUSTop1-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-fusionembedding + LLM 双通道意图多、口语化、需要语义推理

核心差异:当纯 embedding 不够准(间接表达、语义归纳类查询),纯 LLM 又太慢或置信度不稳定时,双通道融合提供了一个用最快模型达到最强模型效果的中间方案。

开源地址github.com/Liyuan1992/…

intent-fusion-architecture.png


为什么这是一个通用结构?

写完这个系统之后,我试着从更抽象的角度理解为什么双通道融合有效。

我认为核心原因是:用户输入本质上是不完整、不确定的信息。 而不同方法的区别在于如何利用这些信息:

  • 纯规则:信息利用率低,但确定性高
  • 纯 embedding:利用表层语义,但缺乏推理能力
  • 纯 LLM:推理能力强,但稳定性差

单一方法,本质上都是对输入信息的「单视角解释」,因此一定存在盲区。

Recall + Reasoning + Ranking 的结构,我理解为:

  1. Recall:从不同视角尽可能「保留信息」——不遗漏可能性
  2. Reasoning:对候选进行语义推理——补全隐含信息
  3. 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_texttree_text

4. 先判断问题类型,再挑具体意图。 操作教学、经营策略、数据查询、执行动作——这些是不同类型的问题,不应该在同一条链路里竞争。

5. 不要低估短句和口语的破坏力。 用户不会按你的意图描述说话。「涨点价」「货还够不够」「最近出了啥」——这些口语化表达必须在 embed_text 中显式覆盖。

6. 歧义和多意图是两件事。 「查一下某某」是歧义(同实体多种查询方式),不是多意图(充值+加积分)。歧义选最优,多意图走编排。

7. 很多「识别错误」不是识别层的错。 可能是缺上下文(「分析一下」不知道分析谁),可能是缺对话状态管理(「好的」不知道是接受提议还是新话题)。归因到意图识别之前,先检查上下游。

8. 测试集和标定工具是必需品,不是锦上添花。 没有标注 case 跑回归测试,你永远不知道改了一处是不是引入了三处 regression。标定工具(网格搜索)让你不用凭感觉调参。

9. 单一信号一定有盲区,多信号融合才是出路。 embedding 和 LLM 各有擅长和不擅长的场景,没有一个能覆盖全部 case。与其追求单一方案的完美,不如让多个不完美的信号互相补位。这个原则不只适用于意图识别——上面那节已经展开讨论了。


如果你也在做意图识别、Agent 工具路由、或者任何需要「从一堆候选中选最对的那个」的系统,欢迎试用 intent-fusion 和反馈。