语义路由器(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. 工程:分发、降级、缓存¶
- 运行时模型托管在 GitHub(
llm011/router-models),首次用到时按 raw URL 下载(ONNX + LR 头 + tokenizer 全套),缓存到本地后离线可用。也可ethan router pull预拉(Docker / 离线场景)。包本身不携带模型文件。 - 下载落点:优先包内
router_models/,只读安装(site-packages 不可写)时回退~/.ethan/models/bge-small-zh/。 - 静默降级:缺
[router]依赖、模型没下、离线无缓存——任一不满足,route()返回None,match()退回纯关键词。零行为变化默认,不抛异常。 - 零冷启动探测:
available只检查模型文件是否存在(model_present()),不触发模型加载——建 Agent 时只问"能不能用",不为一句"在吗"白白 load 模型。 - 进程级单例:编码器和 LR 头各只加载一次;锚点不再每请求重算(LR 头与技能集合无关,只用技能名做命中过滤)。
- tokenizer 鲁棒性:本地 tokenizer 文件缺失时,
AutoTokenizer会退化成只有特殊 token 的残缺词表(所有词变[UNK]→ 路由失效)。代码检测词表过小(< 1000)则回退基座 repo,避免这个隐蔽的失效模式。
10. 局限与边界¶
- 8 个技能的封闭集:LR 头只认训练时的 8 类 + others。新增技能要补样本、重训头(几秒的事,但需要数据)。未在 LR 标签里的技能,靠关键词兜底。
- 中文为主:BGE-small-zh 是中文模型,英文 query 召回会弱。
- 依赖样本质量:换芯后真正的工作量在造数据。样本覆盖不到的说法,仍会漏。