# Semantic Router — 一个关于"钱烧光了才学会的教训"的故事

> 🔷 Powered by halfmoon82 🔷  
> © 2026 halfmoon82. All rights reserved.

---

## 写在前面

这不是一篇技术手册。

技术手册我们已经写了很多了——有9KB的英文版，有39KB的中文Word，有部署清单，有故障排查表。那些文件告诉你**怎么用**。

这篇文章想告诉你**为什么**，以及我们**怎么一步一步踩进去的**。

如果你正在考虑"要不要给我的 AI 代理装一个语义路由"，建议你先读完这篇。它会帮你少烧一些 token。

---

## 一切的起点：一个看起来"没什么大不了"的问题

故事从一个简单的场景开始。

我们有一个 AI 代理，连接在 Discord 频道里。它平时用 claude-haiku 回答快速查询，用 claude-opus 写代码，用 gemini-pro 生成内容。这个分工逻辑上很清晰——不同的任务用不同的模型，既节省成本，又发挥各模型的所长。

**问题是：代理怎么知道该用哪个模型？**

一开始的答案是——用户手动告诉它。发消息的时候加个前缀，比如 `/code 写个爬虫`，或者每次对话开始都说一遍"我现在要写代码，请切换到智能模式"。

这当然很蠢。没有人会这样和自己的 AI 助手说话。

---

## 第一版：关键词匹配，够用就好

于是我们写了第一版语义检查。逻辑极其简单：

- 消息里出现"代码"、"写程序"、"bug"→ 切智能池
- 消息里出现"搜索"、"查一下"→ 切高速池
- 消息里出现"写文章"、"翻译"→ 切人文池
- 其他情况→ 保持当前

大约两百行 Python，运行良好。

代理开始自动切换模型了，用户不需要说任何指令。感觉很棒。

**然后开始烧 token。**

---

## 第一个坑：声明本身污染了上下文

为了让用户知道代理用的是哪个模型，我们设计了一个"声明"机制：每次回复的开头，代理都会输出一行：

```
【语义检查】P1-执行开发任务｜模型池:【智能池】｜实际模型:claude-opus-4.6
```

设计意图很好——透明度，可追溯。

但我们没有意识到一个问题：这行声明会进入**对话历史**。下一轮对话时，这行声明出现在上下文里。语义检查读取上下文，看到了"执行开发任务"、"智能池"这些词——然后误判为"当前正在进行开发任务"，继续切到智能池，继续输出声明，继续污染上下文。

一个完美的闭合回路，把自己绕进去了。

更糟糕的是，每轮对话历史里都有这些声明，导致 LLM 的 **prefix cache 完全失效**。每轮输入都不一样（因为声明里有浮点数 `ctx_score` 每次都变），provider 无法复用缓存，input tokens 每轮都按全价计算。

后来我们估算了一下，一个活跃会话**每天因为这个问题多消耗 6-10M tokens**。按 cache_read 是 input 1/10 价格来算，这相当于 90% 的 input 成本是白白浪费掉的。

**修复方案**：
1. 在读取上下文时，先过一遍 `_sanitize_context_text()`，把所有声明行、Skill Trigger 注入、系统提示词全部剥离
2. 声明中移除所有浮点数等易变字段，保持稳定的路由标签格式
3. 普通消息不再注入声明到 `prependContext`，让对话历史保持干净

---

## 第二个坑：Cron Job 把用户吓坏了

大约是在某个下午，用户在 Discord 里和代理讨论一个复杂项目，谈了大概二十分钟，上下文建立得很好。

然后，对话突然断了。

代理重置了。新的会话，空白的上下文。之前讨论的所有内容都消失了。

这种情况每隔几分钟到二十分钟就发生一次。毫无规律，让人抓狂。

**排查过程耗费了大量时间和 token**，因为我们一直在错误的方向上找问题——是模型出了问题？是 Gateway 崩溃了？是配置有什么 bug？

真正的原因，说出来有点让人哭笑不得：

我们有一个 Cron Job，叫 `cloudflared-watchdog`，每五分钟运行一次，检查一下 cloudflared 进程是否健在。它发出的消息是：

```
"检查 cloudflared 进程状态"
```

这条消息进入了用户的 Discord 直连会话。语义检查看到它，在 tasks.json 里找不到任何匹配的关键词，于是走到 embedding 评分——发现和当前对话内容毫无关联，得分 < 0.08，触发了 C-auto 分支：**强制重置会话**。

系统工作完全正常。规则是对的，算法是对的，执行是正确的。

**但结果是灾难性的**——因为我们从来没想过，后台定时任务和用户实时对话会共享同一个会话上下文。

这个 bug 修复起来只需要两行配置：把 Cron Job 的 `sessionKey` 设为 null，`sessionTarget` 设为 `"isolated"`。

但发现这个根因，花了我们不知道多少个小时和多少 token。

---

## 第三个坑：Embedding 一直没有真正启用

v7.0 的架构设计里，有一个自以为很先进的功能：**embedding 相似度计算**。

用向量距离来判断当前消息和历史上下文的语义关联度，比 Jaccard 系数更准确，能处理同义词和语义漂移。

设计文档写得很漂亮。测试用例全部通过。部署到生产环境，完美运行。

**实际上完全没有运行**。

后来 QA 审计的时候，跑了 16 条 E2E 测试，发现 **100% 降级到了 Jaccard**，embedding 从来没有被调用过。

原因？`openai` Python 包没装。`OPENAI_API_KEY` 环境变量没设置。而 lovbrowser API 本身也不支持 embedding 模型。

代码里有完整的降级逻辑，所以系统没有崩溃，只是悄悄地用了精度更低的方案，没有报错，没有警告，一切看起来都正常。

**修复方案**是引入本地 embedding 模型 `bge-base-zh-v1.5`（768维，对中文语义理解更好），完全离线运行，零 API 成本，推理速度 ~1.6ms/call。从此 embedding 真正跑起来了。

---

## 第四个坑：追问被当成新话题

系统稳定下来之后，开始出现一种奇怪的用户投诉。

用户刚刚让代理写完一段代码，想知道进度，于是说：

> "怎么样了？"

会话被重置了。

或者说：

> "完成了吗？"

会话被重置了。

这类短句的问题在于：从 embedding 角度来看，"怎么样了"和刚才讨论的代码内容几乎没有语义重叠——向量距离太远，被判断为新话题，触发 C-auto。

但显然，这是一句追问，是对当前任务的延续。

**修复方案**分两步：
1. 扩展延续关键词列表，从 11 个增加到 32 个，加入"怎么样"、"如何"、"结果呢"、"搞定没"、"完成了吗"等追问类词汇
2. 识别"疑问句 + 省略主语"的句型，对这类句子施加上下文补偿（score +0.25），防止误触 C-auto

---

## 第五个坑：Skill Trigger 声明又形成了新的递归

等到我们引入 Skill Trigger 机制（让系统自动识别并触发相关技能）之后，历史惊人地重演了。

Skill Trigger 的声明格式是：

```
【Skill Trigger】本轮命中技能：semantic-router（dispatch_id=ab907fbf6aab）
请优先按该技能流程执行当前任务；若技能不可用，必须明确说明原因并给出降级方案。
```

这段文字进入了上下文。语义检查读取上下文，看到了"semantic-router"、"dispatch_id"、"技能"这些词，fit_gate 认为这是一个新的技能触发请求，于是再次触发……

无限循环。

症状是：某些技能会被反复触发，每次触发都会消耗 token，直到某个超时机制介入。

**修复**：在 `_sanitize_context_text()` 和 `strip_declaration_injection()` 里新增对 `【Skill Trigger】` 开头内容的过滤，在任何路由计算之前剥离干净。注意 `is_system_message()` 不修改——我们只剥离声明文本，不影响技能触发本身的能力。

---

## 设计哲学：在踩坑之后沉淀下来的几条原则

### 1. 路由系统不能污染它正在路由的内容

最重要的一条教训。声明、标签、元数据——这些系统自身产生的内容，一旦混入用户对话流，就会引发级联错误。设计任何"自我描述"机制的时候，第一个问题永远是：**这些内容会反噬系统自身吗？**

### 2. 后台任务和前台会话必须物理隔离

不是逻辑隔离，是物理隔离。共享会话上下文意味着共享风险。Cron Job 不应该知道用户正在谈什么，用户也不应该被 Cron Job 打断。`sessionTarget: "isolated"` 这个字段，是我们在烧掉了大量 token 和用户耐心之后才加上去的。

### 3. 降级要有噪音

embedding 层静默降级到 Jaccard，没有报错，没有警告，让我们花了很长时间才发现问题。**好的降级机制应该是有噪音的**——它可以降级，但要让人知道它降级了。

### 4. 关键词决定方向，相似度决定是否重置

早期的设计把关键词命中和会话延续绑定在一起——命中关键词就认为是延续。这是错的。一个完全不相关的新话题，也可能碰巧包含某个关键词。**关键词只应该告诉系统该用哪个模型池，embedding 相似度才应该决定是否需要重开会话。** 这个分离，是 v7.6 最重要的架构决策。

### 5. 口语才是真正的输入

用户不会说"我需要执行一个代理任务"。他会说"帮我整理这些材料做成 PPT"。

`trigger_groups_all` 非连续词组命中机制，就是为了让路由系统理解真实的人类语言，而不是等用户说出工程师在设计时预想的关键词。这个机制本来已经在 skill-trigger 层实现了，只是没有接入 tasks.json 的路由层——一个把两个已有轮子接在一起的工作，但在接好之前，路由的口语化命中率一直是短板。

---

## 当前版本（v7.9.2）做到了什么

经过数十次迭代，当前版本在架构上已经相当成熟：

**四池架构**

| 池 | 模型 | 定位 |
|----|------|------|
| 高速池 | Gemini 2.5 Flash | 信息检索、快速查询、协调通知 |
| 智能池 | Claude Sonnet 4.6 | 开发、调试、系统运维 |
| 人文池 | Gemini 2.5 Pro | 内容创作、翻译、问答 |
| 代理池 | GPT-5.4 | 长上下文代理、Computer Use、专业知识工作 |

**五分支路由**

```
用户消息
    ├─ A: 关键词精确命中 → 切换到目标池（不重置会话）
    ├─ B: 延续关键词 / 追问句型 → 保持当前
    ├─ B+: 中等关联度 → 保持 + 漂移提示
    ├─ C: 新任务关键词 + 高关联度 → 切换模型（不重置）
    └─ C-auto: 低关联度 → 重置会话 + 切换模型
```

**四层防污染机制**

1. Cron Job 强制隔离会话，不进入用户渠道上下文
2. `_sanitize_context_text()` 在评分前剥离所有声明和系统注入
3. 声明格式移除易变字段，保证 LLM prefix cache 命中率
4. Skill Trigger 声明单独过滤，防止递归触发

---

## 给想使用这个技能的人

如果你只想快速上手，看 `SKILL.md`，跑一遍 `setup_wizard.py`，五分钟搞定。

如果你想理解为什么我们这么设计，这篇文章就是答案。

如果你遇到了我们没遇到过的新坑——欢迎告诉我们。这个系统的每一行代码，背后都有一笔真实的 token 账单。

---

> **作者**: halfmoon82  
> **版本对应**: Semantic Router v7.9.2  
> **最后更新**: 2026-03-06  
> **状态**: 生产级，持续迭代中
>
> 🔷 Powered by halfmoon82 🔷  
> © 2026 halfmoon82. All rights reserved.  
> MIT License — 自由使用，保留署名。
