No.11约 1,865 字 · 阅读 26 分钟Agent 开发 次阅读

【意图识别】规则、语义路由和 LLM 三级兜底的一次实验

意图识别
目录

给金融线索业务做一个意图分类器。输入一句话,判断它是 5 个意图里的哪一个,或者不属于任何一个(unknown):

线索提报、线索全生命周期跟踪反馈、线索分析、产品准入分析、产品知识问答。

单靠一种方案都有明显短板:正则写不全所有说法,语义路由分不清近义意图,直接上大模型又慢又贵。所以用了三级级联,便宜的先跑,跑不动的交给下一级。

三级结构

flowchart TD
    A["用户输入"] --> B{"L1 规则匹配"}
    B -->|"命中"| C["直接返回意图"]
    B -->|"未命中"| D{"L2 语义路由<br/>相似度 ≥ 阈值?"}
    D -->|"命中"| E["返回意图"]
    D -->|"未命中 (name=None)"| F{"L3 大模型兜底"}
    F -->|"识别出意图"| G["返回意图"]
    F -->|"无法判断"| H["返回 unknown"]

L1:正则

只写有把握的 pattern,命中就出,不命中不猜:

RULES = {
    "线索提报": r"提报(一条|一下|流程)|录入系统|新建一条线索|登记一下",
    "线索全生命周期跟踪反馈": r"到哪一步|上次跟进|卡在哪个环节|有人跟进吗|反馈",
}

这层最容易出的问题是一个词写太宽。最初”线索提报”里有个裸的 提报,结果”上周提报的那条线索,后续有人跟进吗?“(应该是跟踪反馈)被它截胡了。收窄成 提报(一条|一下|流程) 就修好了。

L2:semantic-router

用 semantic-router 把每个意图的校准说法编码成向量,输入进来算相似度,返回最接近的意图。关键不在算相似度,在 fit():

router = IndexedRouter(encoder=FastEmbedEncoder(name="jinaai/jina-embeddings-v2-base-zh"),
                       routes=[Route(name=k, utterances=v) for k, v in CALIB.items()])
router.fit(X=FIT_X, y=FIT_Y)
router.confidence_threshold = 0.5

FIT_Y 里那些 None 是给路由器看”哪些句子不属于任何意图”的,教它学会拒识。没有这一步,它会对任何输入都硬塞一个意图名,L3 就永远轮不到了。

L3:LLM + 结构化输出

用 with_structured_output 直接拿 Pydantic 对象,省掉”让模型只输出意图名、再用正则兜底解析”那套:

class Intent(BaseModel):
    intent: str = Field(description="用户意图")
    confidence: float = Field(description="置信度 0.0 ~ 1.0")

model = init_chat_model("deepseek-flash", model_provider="deepseek",
                        extra_body={"thinking": {"type": "disabled"}})\
        .with_structured_output(Intent, method="json_schema", strict=True)

测试集要分层

最开始我拿 35 条测试跑,全对,还挺高兴。后来发现不对:30 条域内样本里 29 条直接被正则命中,L2 只被验证了 1 条。也就是说 L2 的真实水平根本没测出来,那个 100% 是虚的。

测试集得按”这一条该由哪一层接住”来分:

graph TD
    T["测试集 62 条"] --> A["L1 层样本 29 条<br/>规则可命中"]
    T --> B["L2 层样本 25 条<br/>绕规则·语义可辨"]
    T --> C["L3 层样本 1 条<br/>绕规则·绕语义"]
    T --> D["OOS 样本 7 条<br/>域外·应拒识"]
  • L1 层:常规说法,规则能命中。
  • L2 层:故意绕开所有正则、但语义清楚的口语变体,比如”新谈了个客户,帮我把他存进线索库”(提报)、“这客户到底有没有戏?“(分析)。
  • L3 层:连语义路由都匹配不上(相似度低于阈值)的间接说法,逼它走到大模型。
  • OOS:域外输入,测拒识。

三类数据不能混:建路由的 utterances、校准集、测试集各是各的。拿测试集去建路由,等于先看答案再考试。

flowchart LR
    U["建路由 utterances<br/>(CALIB)"] --> R["Router"]
    F2["校准集<br/>(FIT_X / FIT_Y)"] --> R
    T2["测试集<br/>(62 条)"] -.->|"只评估<br/>不参与构建"| R

踩过的坑

graph LR
    A["bge-small<br/>9 错"] -->|"换 jina"| B["jina-base-zh<br/>4 错"]
    B -->|"补 CALIB 5 句"| C["5 错"]
    B -->|"补 CALIB 2 句"| D["6 错"]
    B -.->|"回退原 CALIB"| E["4 错"]

embedding 模型才是 L2 的瓶颈

bge-small-zh-v1.5(512 维)分不清这几个近义意图,更麻烦的是它从不说”不知道”。对口语化的陌生说法也给出 0.8+ 的高置信度,然后硬塞一个错误意图。结果两件事都坏:近义意图高置信度误判,同时 L3 根本触达不了——因为 L2 从来不拒识。

换成 jina-embeddings-v2-base-zh(768 维)后,错误从 9 掉到 4,L3 才第一次真正接到域内样本。

补校准句是打地鼠

jina 会把”分析""跟踪”的句子系统性判成”准入”。第一反应是给弱势意图补校准句。结果:

  • 补 5 句 → 5 错(治好 2 条,引入 3 条新错)
  • 补 2 句 → 6 错
  • 回退原始 3 句/意图 → 4 错

原因是 router.fit() 是全局阈值优化,不是线性叠加,补一句会挪动整个决策边界,经常治一个引一个。

LLM 兜底的 prompt 也得给定义

L3 最早的 prompt 只列了 5 个意图名,没定义,还写”无法判断输出 unknown 不要猜”。结果”还款期限是多久”这种明显的问题,模型因为对不上抽象标签就保守地回了 unknown。改成每个意图给一句话定义 + 一个示例:

- 产品知识问答:询问产品本身的属性,如额度、利率、期限、材料、流程。例:这个产品利率是多少

结果

层级拦截层内准确率
L1 规则29 (46.8%)100%
L2 语义路由25 (40.3%)88.0%
L3 大模型8 (12.9%)87.5%

总准确率 93.55%(62 条,4 错),OOS 拒识 100%。混淆矩阵几乎是对角线,剩下的 4 个错集中在两处:3 条被判成”产品准入分析”(jina 对”准入”有偏向),1 条”产品知识问答”漏进 unknown。

看指标别只看总数,要看错在哪一层、往哪个方向偏。混淆矩阵比一个百分比有用。

生产上注意这几点

  1. 测试集按层级分,明确每层该接住哪些样本,不然会拿到虚高数字。
  2. 建路由 utterances、校准集、测试集分开,别混。
  3. 近义意图多的场景,先换更强的 embedding(或上 reranker),再谈调阈值、补校准句。
  4. fit() 用 held-out 查询,别用存储的 utterances 本身——它们和自己匹配约等于 1.0,会让阈值学偏。
  5. LLM 兜底的 prompt 给定义 + 示例,光列标签名加一句”不要猜”会让模型过度保守。
  6. 误分类(判错)和误拒(该接没接/该拒没拒)是两种问题,修法不一样。
  7. 每层都打日志,记下输入、命中结果、置信度。级联系统出问题没日志只能靠猜。
  8. API key 走环境变量,别硬编码进源码,迟早会进 git 历史。

结论

L1 负责稳,L2 决定准不准,L3 兜底。系统准确率基本卡在 L2 的 embedding 区分度上,这个靠调阈值、补校准句救不回来。

这次实验真正有用的不是 93.55% 这个数,是两件事:测试集怎么设计才敢信指标,以及每一层的瓶颈在哪。知道了这两点,后面换 embedding、上 reranker、换更强的 LLM,都清楚该往哪使劲。


依赖

semantic-router[fastembed]   # 语义路由
fastembed                    # 本地 embedding,不用调 API
langchain                    # init_chat_model + with_structured_output
pydantic                     # 输出 schema

附录:完整代码

# 三级级联:规则 → 语义路由 → LLM 兜底
import csv, re, os, logging
from collections import Counter, defaultdict

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s", datefmt="%H:%M:%S")
log = logging.getLogger("cascade")

from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field

INTENTS = ["线索提报", "线索全生命周期跟踪反馈", "线索分析", "产品准入分析", "产品知识问答"]
PROMPT_V2 = """你是意图分类器。从以下5个意图中选一个并输出置信度,无法归入任一类时输出 unknown、置信度0。
- 线索提报:把新客户/新线索录入系统、登记建档。例:帮我把这个客户登记一下
- 线索全生命周期跟踪反馈:查询线索的进展、状态、谁在跟进、有无新回复。例:这条线索到哪一步了
- 线索分析:评估线索质量、成交概率、给线索排序。例:这条线索值不值得跟
- 产品准入分析:判断客户或行业是否符合准入资格。例:这个客户能不能做我们的产品
- 产品知识问答:询问产品本身的属性,如额度、利率、期限、材料、流程。例:这个产品利率是多少
只输出意图名或 unknown,不要解释。"""
os.environ["DEEPSEEK_API_KEY"] = os.environ.get("DEEPSEEK_API_KEY", "")  # 从环境变量读,别硬编码


class Intent(BaseModel):
    intent: str = Field(description="用户意图")
    confidence: float = Field(description="置信度 0.0 ~ 1.0")


model = init_chat_model(
    model="deepseek-flash",
    model_provider="deepseek",
    extra_body={"thinking": {"type": "disabled"}},
).with_structured_output(Intent, method="json_schema", strict=True)


RULES = {
    "线索提报": r"提报(一条|一下|流程)|录入系统|新建一条线索|登记一下",
    "线索全生命周期跟踪反馈": r"到哪一步|现在.*状态|上次跟进|卡在哪个环节|有人跟进吗|反馈",
    "线索分析": r"分析.*(质量|线索)|转化可能性|哪个更优先|跟丢的原因|梳理下规律|总结",
    "产品准入分析": r"准入|能不能做|符不符合.*条件|能不能进",
    "产品知识问答": r"额度是多少|利率|申请材料|办理流程|有什么区别|支持哪些行业",
}


def rule_level(text):
    for name, pat in RULES.items():
        if re.search(pat, text):
            log.info("L1 rule 命中 | %s -> %s | pattern=%s", text, name, pat)
            return name, pat
    log.info("L1 rule 未命中 | %s", text)
    return None, None


from semantic_router import Route, IndexedRouter
from semantic_router.encoders import FastEmbedEncoder

CALIB = {
    "线索提报": ["请帮我把这个客户提交上去", "新谈了一个客户,需要录入", "销售给了一个客户资料,帮忙登记"],
    "线索全生命周期跟踪反馈": ["这条线索进展到哪里了", "帮我看下这条线索的最新动态", "这条线索最近有没有人处理"],
    "线索分析": ["评估一下这条线索靠不靠谱", "这批线索哪个最值得优先跟", "帮我复盘这条线索为什么没成"],
    "产品准入分析": ["这个客户做我们的产品合规吗", "这家企业能不能准入", "查一下这个行业的准入要求"],
    "产品知识问答": ["这个产品最高能贷多少", "申请这个产品要准备什么", "这两个产品有什么不同"],
}

encoder = FastEmbedEncoder(name="jinaai/jina-embeddings-v2-base-zh")
router = IndexedRouter(encoder=encoder, routes=[Route(name=k, utterances=v) for k, v in CALIB.items()])

FIT_X = [u for v in CALIB.values() for u in v] + ["今天深圳天气怎么样?", "帮我请个假", "中午吃什么好"]
FIT_Y = [k for k, v in CALIB.items() for _ in v] + [None, None, None]
router.fit(X=FIT_X, y=FIT_Y)
router.confidence_threshold = 0.5


def router_level(text):
    choice = router(text)
    if choice is None or getattr(choice, "name", None) is None:
        log.info("L2 router 拒识(落L3) | %s", text)
        return None, choice
    log.info("L2 router 命中 | %s -> %s | conf=%.3f", text, choice.name, getattr(choice, "confidence", 0.0))
    return choice.name, choice


def small_model_level(text):
    prompt = PROMPT_V2.format(text=text)
    intent = model.invoke(input=prompt, temperature=0.1)
    log.info("L3 small 输出 | %s -> %s | conf=%.2f", text, intent.intent, intent.confidence)
    raw_text = str(intent.intent).strip()
    if raw_text in INTENTS or raw_text == "unknown":
        return raw_text, raw_text
    for name in INTENTS:
        if name in raw_text:
            return name, raw_text
    return "unknown", raw_text


def report_metrics(results):
    INTENT_ORDER = INTENTS + ["unknown"]
    N = len(results)
    ok = sum(1 for r in results if r[5])
    log.info("===== 指标汇总 =====")
    log.info("总览 | 样本 %d | 正确 %d | 错误 %d | 准确率 %.2f%%", N, ok, N - ok, ok / N * 100)

    wrong = [r for r in results if not r[5]]
    if wrong:
        log.info("错误样例 %d 条:", len(wrong))
        for r in wrong:
            log.info("  #%s gold=%s -> %s | 层级=%s | %s", r[0], r[2], r[4], r[3], r[1])

    lvl = Counter(r[3] for r in results)
    prev = N
    for L in ("L1_rule", "L2_router", "L3_small"):
        n = lvl[L]
        log.info("拦截 %s | %d 条 | 占总样本 %.1f%% | 占上级未命中 %.1f%%", L, n, n / N * 100, n / prev * 100)
        prev -= n

    for L in ("L1_rule", "L2_router", "L3_small"):
        sub = [r for r in results if r[3] == L]
        ok_l = sum(1 for r in sub if r[5])
        log.info("层准确率 %s | %d/%d = %.1f%%", L, ok_l, len(sub), ok_l / len(sub) * 100)

    for g in INTENT_ORDER:
        sub = [r for r in results if r[2] == g]
        ok_g = sum(1 for r in sub if r[5])
        log.info("意图准确率 %s | %d/%d = %.1f%%", g, ok_g, len(sub), ok_g / len(sub) * 100)

    oos = [r for r in results if r[2] == "unknown"]
    oos_ok = sum(1 for r in oos if r[5])
    mis_reject = [(r[0], r[2]) for r in results if r[2] != "unknown" and r[4] == "unknown"]
    log.info("拒识 | OOS %d/%d = %.1f%% | 域内误拒 %s", oos_ok, len(oos), oos_ok / len(oos) * 100, mis_reject)

    mat = defaultdict(Counter)
    for _, _, g, _, p, _ in results:
        mat[g][p] += 1
    log.info("混淆矩阵 (gold\\预测):")
    log.info("  " + "".join(f"{p[:4]:>7s}" for p in INTENT_ORDER))
    for g in INTENT_ORDER:
        log.info("%-6s %s", g[:4], "".join(f"{mat[g][p]:>7d}" for p in INTENT_ORDER))


TEST = [(int(r["id"]), r["text"], r["gold"]) for r in csv.DictReader(open("week1-utterances.csv", encoding="utf-8"))]

results = []

with open("week1_cascade.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    w.writerow(["id", "text", "gold", "solved_at", "rule", "router", "router_raw", "small", "small_raw", "correct"])
    for i, text, gold in TEST:
        rule, _ = rule_level(text)
        if rule:
            w.writerow([i, text, gold, "L1_rule", rule, "", "", "", "", rule == gold])
            results.append((i, text, gold, "L1_rule", rule, rule == gold))
            continue
        rname, rraw = router_level(text)
        if rname:
            w.writerow([i, text, gold, "L2_router", "", rname, str(rraw), "", "", rname == gold])
            results.append((i, text, gold, "L2_router", rname, rname == gold))
            continue
        sname, sraw = small_model_level(text)
        w.writerow([i, text, gold, "L3_small", "", "", "", sname, str(sraw), sname == gold])
        results.append((i, text, gold, "L3_small", sname, sname == gold))

report_metrics(results)