跳转至

语义路由器(Semantic Skill Router)

Ethan 决定"该给 Agent 注入哪个技能"的第二条通道。关键词匹配负责精度,语义路由负责召回——两者叠加,而非替代。

这是 Ethan 区别于"纯关键词触发"技能系统的核心设计之一。本文剖析它为什么这么做、以及为什么可以做得这么轻。


1. 问题:关键词匹配的天花板

最初的技能匹配是 trigger 子串匹配:Skill 在 frontmatter 里写一串关键词,用户输入命中其一就注入。

它的优点很实在——精度高、强拒识、零成本。"安装技能"命中 skills-manager,一个字都不会错。

但它的召回是脆的。同一个意图,换个说法就漏:

用户实际说的 期望命中 关键词结果
给客户带句话说会议改三点 lark-im ❌(没有"发消息""飞书")
这篇 pdf 的实验部分讲了啥 paper-analysis ❌(没有"精读""论文分析")
心情很差想找人说说话 companion-listen ❌(没有"陪伴""倾听")

在一份手工评测集上,修完 trigger bug 的关键词匹配 召回只有 ~0.24。也就是说,四个该触发的意图里,三个被漏掉了。

漏召回不是"不报错",而是用户体验的隐性流失:技能在那儿,却因为没说对词而没被唤起。


2. 设计约束:先想清楚不能要什么

Ethan 跑在用户自己的硬件上(NAS、迷你主机、旧笔记本),这决定了语义匹配方案的硬边界:

  • 没有 GPU:方案必须在 CPU 上毫秒级出结果。
  • 不能拖大依赖:不能为了召回率把 PyTorch(几百 MB)拽进主依赖。
  • 启动要快:不能每次建 Agent 都现训一个模型。
  • 必须可降级:用户不装、离线、模型缺失时,要安静退回关键词匹配,绝不崩。
  • 隐私:query 不出本机。

这串约束直接排除了"调一个 LLM 做意图分类"——太重、要联网或要大模型权重。也排除了把整个分类逻辑塞进 prompt 让主模型自己判——那是 token 成本,且每轮都付。


3. 架构:把"难"和"轻"拆开

核心洞察是:"读懂一句中文"很难,但"给读懂之后的结果分个类"极简单。于是把它拆成两段,难的那段白嫖现成的。

   "给客户带句话说会议改三点"
   ┌──────────▼───────────┐
   │  ① 编码器 (BGE INT8)  │   ← 难。预训练模型,下载即用,冻结不动
   │   句子 → 512 维向量    │
   └──────────┬───────────┘
              │   [0.03, -0.81, 0.12, ... ]   512 个数
   ┌──────────▼───────────┐
   │  ② LR 头 (9 类分类)    │   ← 轻。本项目训练,~4600 个数,几秒训完
   │   向量 → 概率最高的类   │
   └──────────┬───────────┘
        "lark-im" (p=0.99)  或  "others" → 不注入

① 编码器:语义的"坐标系"

BGE-small-zh-v1.5 把任意中文句子映射成一个 512 维向量。它的关键性质是:意思相近的句子,向量也相近——"发个飞书"和"带句话给客户"用词毫无重叠,向量却几乎重合。这正是关键词做不到的"换说法也认得"。

这一段是整个方案里唯一"难"的部分(让机器读懂中文),但它由智源研究院预训练好,Ethan 直接当黑盒下载使用,自己不训练、不微调。运行时用 INT8 动态量化的 ONNX(24MB),CLS pooling + L2 归一化,onnxruntime CPU 执行。

② LR 头:在坐标系上"画边界"

句子有了坐标,剩下的就是判断它落在哪个技能的"区域"里。这件事用最古典的逻辑回归(LogisticRegression)就够了:

  • 权重矩阵 coef 形状 (9, 512),外加 9 个 intercept——总共约 4600 个浮点数,一个 npz 文件 ~20KB。
  • 推理就是一次 softmax(coef · emb + intercept),取 argmax。几次乘加,纳秒级。
  • 运行时只依赖 numpy,不引入 sklearn(训练时才用)。

9 个类 = 8 个内置技能 + 一个 others。训练在几千条 query → 标签 上跑,几秒完成。这就是为什么"模型不难、2-3 小时搞定"——真正花时间的是造数据,不是训模型。


4. 为什么是 embedding + LR,而不是别的

方案 召回 CPU 延迟 依赖体积 离线 否决理由
纯关键词 ~0.24 极快 0 召回太低
锚点 max-cosine ~0.60 同下 单一余弦阈值,拒识弱
embedding + LR 头 0.835 毫秒 ~24MB
微调小 LLM 做分类 慢/需 GPU 数百 MB 太重,违背自托管约束
prompt 里让主模型判 取决主模型 0 每轮付 token,且慢

早期版本用过"锚点 max-cosine":把每个技能的 trigger+description 编码成锚点,query 算最大余弦,超阈值即命中。它比关键词强,但拒识全靠一个余弦阈值——阈值高了漏召回,低了乱触发,没有中间地带。换成 LR 头 + others 真实类后,边界是从数据里学出来的,比拍一个阈值稳得多。


5. 关键设计:开集拒识(怎么学会"不触发")

路由器最难的不是"认出该触发的",而是"认出不该触发任何技能的"——用户问"一加一等于几""番茄炒蛋怎么做",正确答案是什么都不注入,交给 LLM 正常对话。

很多分类器在这里栽跟头:它们只会在已知类里选一个,遇到没见过的输入就强行归到最近的类(乱触发)。

Ethan 的做法是把 others 当成一个真实的第 9 类来训练,并在训练集里专门掺入 trap 样本(看起来像某技能、实则无关的句子)。于是"拒识"不是事后加的阈值规则,而是模型本身学到的能力。

route_scored(query):
    label, prob = LR_head.predict(encode(query))
    if label == "others":            return None   # 学出来的拒识
    if prob < FLOOR:                  return None   # 低置信兜底(可调)
    if label not in 已加载的技能:      return None   # 标签存在但技能没装
    return (label, prob)

三道闸:others 主力拒识,FLOOR 是低置信的二次兜底(当前从模型元数据读,默认 0.0,即主要靠 others),最后一道保证不会路由到当前根本没加载的技能。


6. 为什么敢"宁可多召回":注入 ≠ 执行

一个容易忽略但很重要的论证:在 Ethan 的架构里,注入一个技能 ≠ 执行它

技能注入只是往 system prompt 里塞一段"软引导",告诉 LLM"这里有个 X 技能可用"。真正要不要调用工具、执行操作,仍然由 LLM 在 medium/full 轨自主决定。

这让误召回的代价变得很低:

  • 漏召回的代价:技能没被提及,LLM 可能错过更优解法。但 LLM 始终能看到完整的 available_skills 列表,有机会自补。
  • 误召回的代价:多注入一段没用的 prompt,污染一点 context。被强模型在后续轨道轻松忽略,不会触发任何不可逆操作

代价不对称 → 可以适当偏向召回。这就是为什么 FLOOR 取在低位、others 兜底就够,不必为了拦截而牺牲召回。

高风险技能(如 legal-assistant)不走这套——它由 modes 隔离,根本不进 normal 路由。安全边界由架构保证,不靠路由器自觉。


7. 与关键词的协同:叠加,不是替代

语义路由不是来取代关键词的。registry.match() 里两者串联:

match(query):
    matched = 关键词子串匹配(query)        # 第一层:保 head 精度 + 强拒识
    if 路由器可用:
        name = 路由器.route(query)         # 第二层:补 tail 召回
        if name and name 不在 matched:
            matched += [name]
    return matched
  • 关键词打底:永远先跑,保证"说对词"的精确命中和强拒识。这也是为什么模型没装时一切照旧。
  • 语义补充:只把关键词漏掉的、语义上确信的命中加进来。

两层各管一段:关键词管 head(高频、固定说法),语义管 tail(换了说法的长尾)。


8. 评测:为什么这个 0.851 可信

刷分容易,刷出"看起来高、实际泄漏"的分更容易。这套评测刻意规避了几个常见陷阱:

  • 独立手写评测集:评测 query 由人手写,措辞风格刻意与训练样本不同,规避近重复泄漏(训练里见过几乎一样的句子 → 测出来虚高)。
  • macro 口径:按 8 个技能逐类算 P/R/F1 再平均,不让样本多的类掩盖样本少的类。
  • others 单独报拒识率:开集能力单独看,不混进分类指标。
  • test 只跑一次:FLOOR 等超参在 val 上选定,test 不参与调参。

结果(独立集,INT8,macro):

方案 P R F1 备注
纯关键词 0.76 0.24 0.29 召回是硬伤
锚点 max-cosine ~0.60 阈值方案天花板
embedding + LR 头 0.878 0.835 0.851 当前工作点

召回从 0.24 提到 0.835,精度还更高了。


9. 工程:分发、降级、缓存

  • 运行时模型托管在 GitHubllm011/router-models),首次用到时按 raw URL 下载(ONNX + LR 头 + tokenizer 全套),缓存到本地后离线可用。也可 ethan router pull 预拉(Docker / 离线场景)。包本身不携带模型文件
  • 下载落点:优先包内 router_models/,只读安装(site-packages 不可写)时回退 ~/.ethan/models/bge-small-zh/
  • 静默降级:缺 [router] 依赖、模型没下、离线无缓存——任一不满足,route() 返回 Nonematch() 退回纯关键词。零行为变化默认,不抛异常。
  • 零冷启动探测available 只检查模型文件是否存在(model_present()),不触发模型加载——建 Agent 时只问"能不能用",不为一句"在吗"白白 load 模型。
  • 进程级单例:编码器和 LR 头各只加载一次;锚点不再每请求重算(LR 头与技能集合无关,只用技能名做命中过滤)。
  • tokenizer 鲁棒性:本地 tokenizer 文件缺失时,AutoTokenizer 会退化成只有特殊 token 的残缺词表(所有词变 [UNK] → 路由失效)。代码检测词表过小(< 1000)则回退基座 repo,避免这个隐蔽的失效模式。

10. 局限与边界

  • 8 个技能的封闭集:LR 头只认训练时的 8 类 + others。新增技能要补样本、重训头(几秒的事,但需要数据)。未在 LR 标签里的技能,靠关键词兜底。
  • 中文为主:BGE-small-zh 是中文模型,英文 query 召回会弱。
  • 依赖样本质量:换芯后真正的工作量在造数据。样本覆盖不到的说法,仍会漏。

相关文档

  • Skill 系统 — 技能加载、注入、渠道过滤
  • 三档推理引擎 — 快/中/慢轨路由(与本文的"语义路由器"是两套独立机制,勿混淆)
  • 代码:ethan/skills/router.py(路由器)、ethan/skills/registry.py(关键词 + 语义协同)
  • 训练与评测:paper_work/router_dataset/(样本生成器 + train_lr_router.py + 独立评测集)