0. 先说结论和适用边界#
我没有一上来就做复杂的路由模型,而是按成本从低到高拆了三层:
| 阶段 | 做法 | 适合场景 | 代价 |
|---|---|---|---|
| Phase 1 | 按领域重组目录,只加载相关分类 | 立刻止血 | 低 |
| Phase 2 | 建向量索引,按查询找候选 skills | skills 持续增长 | 中 |
| Phase 3 | 引入 retrieve-and-rerank 路由 | 大规模、多团队复用 | 高 |
实际落地时,最有用的是前两层组合:先用目录分层把搜索空间砍掉,再用语义检索做细筛。它没有“全自动”那么漂亮,但稳定、可解释,出问题也容易回退。
这篇文章适合两类场景:
- skills 数量已经多到影响启动、响应速度或 context window
- 团队开始维护多个领域的 agent 能力,需要让 skill 选择可解释
不适合的场景:
- 只有十几个 skills,且没有 context 压力
- skill 内容质量还很差,description 没写清楚
- 还没有稳定任务样本,无法评估检索是否正确
先判断自己是不是这个问题,再决定要不要做路由。
1. 问题现场#
当时 Codex CLI 报错类似这样:
Error: Your input exceeds the context window of this model
Expected: < 1M tokens
Actual: 450K tokens (123 skills)
根因很直接:.codex/skills/ 下有 123 个 skills,每个 skill 都有一份 SKILL.md,有些还带很长的参考文档。启动时如果把这些说明都放进上下文,模型还没看到用户任务,已经背上了很重的历史包袱。
这里有一个容易误判的点:450K < 1M 并不代表安全。实际请求里还会有系统提示、开发者约束、仓库上下文、用户输入、工具返回和未来多轮对话。只看静态 tokens,很容易低估后续膨胀。
所以这个问题不是“再换一个大窗口模型”就能彻底解决。窗口越大,浪费也会越大。真正要改的是 skills 的加载方式。
1.1 判断是不是 skills 导致的#
先不要急着改架构。可以用一个很粗的检查判断 skills 是否已经过量:
find "$HOME/.codex/skills" -name "SKILL.md" | wc -l
find "$HOME/.codex/skills" -name "SKILL.md" -print0 | xargs -0 wc -c | tail -1
如果 SKILL.md 数量很多,或者总大小已经明显膨胀,再继续做分层。
还要抽样看 description 质量:
find "$HOME/.codex/skills" -name "SKILL.md" | head -5 | xargs -I{} sed -n '1,12p' "{}"
如果 description 都写得很泛,先修 description。否则向量检索只会把“泛泛的描述”检索得更快。
2. 第一刀:按领域拆目录#
第一版没有写任何复杂代码,只是把 skills 从平铺目录改成领域目录。
~/.codex/skills/
├── core/
├── backend/
│ ├── springboot/
│ ├── django/
│ └── golang/
├── frontend/
├── devops/
├── data/
└── content/
这样做的价值不在于“目录更好看”,而是把加载边界变明确。
比如只处理 Spring Boot 任务时,不需要加载前端、博客写作、视频处理、图片生成这些 skill。直接从对应目录启动:
codex "检查 Spring Boot 安全配置" \
--skills-dir "$HOME/.codex/skills/backend/springboot"
如果是通用编码流程,只加载核心工作流:
codex "按 TDD 修这个 bug" \
--skills-dir "$HOME/.codex/skills/core"
这一步的收益很实在:不需要新服务,不需要模型,不需要额外依赖。缺点也明显,分类需要人维护,跨领域任务容易漏 skill。但作为止血方案,它是最稳的。
2.1 推荐的目录规则#
目录不要按“技术名”无限展开,先按使用场景划分:
| 目录 | 放什么 |
|---|---|
core | TDD、review、commit、verification 这类通用流程 |
backend | API、数据库、语言后端框架 |
frontend | React、Next.js、UI、E2E |
devops | Docker、Kubernetes、CI/CD、部署 |
content | 博客、文档、报告、PPT |
data | 数据库、ETL、分析、向量检索 |
判断一个 skill 应该放哪:看它最常和哪类用户任务一起出现,而不是看它用了什么技术。
2.2 验证目录分层是否有效#
重组后至少检查三件事:
find "$HOME/.codex/skills/core" -name "SKILL.md" | wc -l
find "$HOME/.codex/skills/backend" -name "SKILL.md" | wc -l
find "$HOME/.codex/skills/content" -name "SKILL.md" | wc -l
如果某个目录仍然有几十上百个 skills,说明分类还不够细,需要再拆一层。
3. 第二刀:给 skills 建语义索引#
目录分层解决的是粗筛问题。下一步才是语义检索:用户说“登录接口鉴权有问题”,系统应该能找到 security-review、api-design、backend-patterns 这类候选,而不是只靠文件名猜。
我用的方案比较朴素:
- ChromaDB 做本地向量库
- SentenceTransformers 做 embedding
- 每个
SKILL.md抽取标题、description、关键段落入库 - 查询时先按目录或 category 过滤,再取 top-k
核心流程大概是这样:
from chromadb import PersistentClient
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
client = PersistentClient(path="./embeddings")
collection = client.get_or_create_collection(
"skills",
metadata={"hnsw:space": "cosine"},
)
query = "Spring Boot 登录接口鉴权"
query_embedding = model.encode([query])[0].tolist()
results = collection.query(
query_embeddings=[query_embedding],
n_results=5,
where={"category": "backend"},
)
检索结果会像这样:
query: Spring Boot 登录接口鉴权
1. springboot-security similarity: 0.87
2. api-design similarity: 0.82
3. backend-patterns similarity: 0.78
这里不要太迷信分数。embedding 能帮你减少候选,但它不知道你当前仓库的真实边界,也不知道某个 skill 是否过期。我的做法是:语义检索只负责推荐,最终加载哪些 skill 仍然保留明确规则。
3.1 索引哪些内容#
不要把整个 skill 目录都塞进向量库。优先索引这些内容:
- YAML front matter 里的
name description- 一级和二级标题
- 使用场景、不要使用的场景
- 关键命令或关键 API 名称
参考资料、长示例和评测输出可以不进第一版索引。它们适合在 skill 被选中后再读取。
3.2 检索结果怎么验收#
准备 10-20 个真实任务样本,每个样本人工标注“应该命中的 skill”。例如:
| 任务 | 应命中 |
|---|---|
| “Spring Boot 登录接口鉴权失败,帮我 review” | springboot-security、security-review |
| “把生产升级整理成 Blowfish blog” | production-change-blog-writer、blowfish-hugo |
| “GitLab pipeline 失败,帮我查日志” | glab、gitlab-ci-patterns |
检索系统至少要输出:
- top-k 结果
- 分数
- 选中原因
- 没命中时的 fallback
4. 向量索引里几个容易踩的坑#
第一个坑是文件大小。不是每个 skill 都适合完整入库,太大的文件会拖慢索引,也会把一些低价值参考内容混进语义空间。
MAX_SKILL_SIZE = 100 * 1024
if skill_md.stat().st_size > MAX_SKILL_SIZE:
logger.warning("skip large skill file: %s", skill_md)
continue
第二个坑是编码。skills 来源复杂,有些文件不一定是 UTF-8。
try:
content = skill_md.read_text(encoding="utf-8")
except UnicodeDecodeError:
logger.warning("skip non-utf8 skill file: %s", skill_md)
continue
第三个坑是相似度计算。ChromaDB 返回的是距离,不是相似度,而且不同 collection 的距离空间不一样。如果没有显式配置 metadata={"hnsw:space": "cosine"},就不要把距离换算成余弦相似度。
distance = results["distances"][0][i]
# 只适用于 collection 使用 metadata={"hnsw:space": "cosine"} 的情况。
similarity = max(0, 1 - distance / 2)
如果使用默认 L2 距离,更稳的做法是直接展示 distance,或者在 collection 创建时先把距离空间固定下来。不要让展示分数影响最终是否加载某个 skill。
第四个坑是重复加载模型。embedding 模型启动一次不算贵,但每次命令都重新加载,体验会很差。
class SkillRetriever:
_model_cache = {}
def __init__(self, model_name="all-MiniLM-L6-v2"):
if model_name not in self._model_cache:
self._model_cache[model_name] = SentenceTransformer(model_name)
self.model = self._model_cache[model_name]
这些都不是很高级的问题,但如果不处理,检索系统会变得“不稳定但看起来很智能”。那比不用还麻烦。
4.1 最小错误处理清单#
写索引脚本时,至少处理这些边界:
| 边界 | 处理方式 |
|---|---|
| 空查询 | 直接拒绝 |
top_k 过大 | 限制在合理范围 |
| 数据库不存在 | 给明确错误 |
| skill 文件过大 | 跳过并记录 |
| 编码错误 | 跳过并记录 |
| 没有检索结果 | 回退到目录分类 |
这些处理不复杂,但能让后续排障轻很多。
5. 第三层:SkillRouter 适合更大的规模#
如果 skills 数量继续增长,只靠 embedding top-k 会遇到两个问题:
- 相似但不该用的 skill 会被召回
- 多领域任务需要更细的重排逻辑
这时可以考虑 retrieve-and-rerank,也就是先召回一批候选,再做更精细的排序。SkillRouter 这类方案就是这个方向。
flowchart LR
query["用户任务"] --> encode["任务编码"]
encode --> retrieve["召回候选
Top 50"]
retrieve --> rerank["重排
Top 5"]
rerank --> load["加载 1-3 个 skills"]
它的好处是路由更准,尤其适合上百、上千个 skills 的场景。代价是工程复杂度会上来:模型部署、延迟、评测集、回退策略都要补齐。
我的判断是:如果团队还没有稳定的目录分类和基础检索,不要急着上 reranker。先把前两层做扎实。
5.1 什么时候再上 reranker#
满足下面几个条件,再考虑第三层:
- skills 数量已经超过几百个
- 向量 top-k 经常召回相似但错误的 skill
- 团队有真实任务样本可以做评测
- 能接受额外推理延迟和模型维护成本
否则 reranker 会变成一个难排查的新黑盒。
6. 效果怎么衡量#
我不建议只看“减少了多少 tokens”。这个指标重要,但不够。真正要看四件事:
| 指标 | 说明 |
|---|---|
| Context 消耗 | 每次任务实际加载多少 skill 内容 |
| 命中率 | 该用的 skill 是否被找出来 |
| 误召回 | 不相关 skill 是否频繁被加载 |
| 可解释性 | 出错时能否判断为什么选了这个 skill |
按当时的粗略统计,几种方式的体感差异是这样的:
| 方案 | 加载规模 | Context 压力 | 准确性 | 维护成本 |
|---|---|---|---|---|
| 全量加载 | 123 个 | 很高 | 高,但浪费 | 低 |
| 目录分层 | 15-20 个 | 中 | 高 | 中 |
| 向量检索 | 5-10 个 | 低 | 中高 | 中 |
| 路由重排 | 1-3 个 | 很低 | 高 | 高 |
这里的数字不是实验室 benchmark,更像工程侧的容量估算。它能帮助判断方向,但不能替代真实任务评测。
6.1 建议的验收标准#
第一版不用追求极致。可以先定这几个门槛:
| 指标 | 建议门槛 |
|---|---|
| Top-5 命中率 | 80% 以上 |
| Top-1 命中率 | 60% 以上 |
| 平均加载 skills | 10 个以内 |
| 空结果率 | 5% 以下 |
| fallback 可用性 | 目录分类能兜底 |
如果这些指标达不到,优先改 description 和目录,而不是换模型。
6.2 验证矩阵#
落地时我会把验证拆成四类,避免只看单一的 token 数。
| 验证项 | 方法 | 通过标准 |
|---|---|---|
| 加载范围 | 统计每类任务实际加载的 skills 数量 | 普通任务加载 10 个以内 |
| 命中质量 | 用 10-20 个真实任务样本跑 top-k | Top-5 命中率达到 80% 以上 |
| 可解释性 | 检查每个命中结果是否能说清触发原因 | 不出现“分数高但原因不明”的常态 |
| 回退能力 | 故意让语义检索空结果 | 能回退到目录分类,不影响继续工作 |
7. 我会怎么落地#
如果现在重新做一次,我会按这个顺序:
先盘点 skills
删除重复、过期和几乎不用的 skill。很多 context 问题不是路由问题,而是库存问题。
建立目录边界
至少拆出
core、backend、frontend、devops、content、data。目录不是为了整洁,而是为了让加载范围可控。给每个 skill 写好 description
检索质量很大程度取决于 description。不要只写“用于某某技术”,要写清楚什么时候触发、解决什么问题、不要用于什么场景。
再做向量索引
只索引
SKILL.md的高价值段落,不要把所有参考资料一股脑塞进去。再考虑 reranker
当 skills 数量大到 top-k 经常误召回时,再加 SkillRouter 这类重排模型。
8. 最小可用脚本和验证#
先做一个能跑起来的版本,比一开始追求“完美路由”更有价值。
python generate_embeddings.py \
--skills-dir "$HOME/.codex/skills" \
--db-path "./embeddings"
python skill_retriever.py \
--query "Spring Boot 接口鉴权" \
--category "backend" \
--top-k 5
配套的输入校验别省:
if not query or not query.strip():
raise ValueError("query cannot be empty")
if top_k < 1 or top_k > 50:
raise ValueError("top_k must be between 1 and 50")
if not Path(db_path).exists():
raise ValueError(f"database does not exist: {db_path}")
这类小检查能省掉很多排障时间。
跑完脚本后,用固定样本验证:
python skill_retriever.py \
--query "把生产升级整理成 Blowfish blog" \
--category "content" \
--top-k 5
预期至少应该看到文档写作、博客发布、Blowfish 相关 skill。如果结果跑到无关的图片生成或前端 UI,说明 description 或 category 需要调整。
8.1 继续和停止条件#
这类优化容易从“小修加载策略”扩散成“重写整个 skill 系统”。我会先把继续和停止条件写清楚。
| 阶段 | 可以继续的条件 | 应该停止的信号 |
|---|---|---|
| 目录分层 | 目标目录下的 skill 数量明显下降,常用任务仍能找到对应 skill | 常用任务找不到关键 skill |
| 向量索引 | 固定样本 top-5 能命中预期 skill | description 太泛,命中结果无法解释 |
| 路由重排 | 已有真实样本集和可接受的延迟预算 | 还没有稳定目录和基础检索 |
如果某个阶段失败,先回退到上一层可解释方案。目录分层是最小 fallback;它不够智能,但最容易排查。
9. 常见问题#
问题:向量检索看起来分数很高,但结果不对
可能原因是 description 太泛,或者长文档里的高频词干扰了语义空间。先改 skill 描述,再考虑换 embedding 模型。
问题:目录分层后跨领域任务漏 skill
给跨领域任务保留 core + 当前领域的组合加载方式。例如写生产复盘时,至少加载 core、content、devops。
问题:skills 越整理越复杂
先删掉低价值 skill。路由系统解决不了库存污染,最多只是把污染藏得更深。
10. 这次复盘后的调整#
这次优化后,我对 skills 的看法变了:skill 不是越多越好,关键是能不能在正确的时机被加载。
几个原则后来一直沿用:
- 默认不全量加载
- 先用目录缩小范围
- description 要写触发条件,不只写功能介绍
- 检索结果要能解释
- 复杂路由必须有回退方案
最终目标不是把 context 压到最低,而是在“能找到正确 skill”和“不浪费上下文”之间取一个稳定平衡。
11. 遗留项#
这篇只沉淀了方向和最小闭环,还有几个问题需要后续再做:
| 遗留项 | 原因 | 后续动作 |
|---|---|---|
| 固定评测集 | 没有样本就很难判断路由质量 | 从真实任务中抽 10-20 条样本 |
| description 质量审计 | 检索效果主要取决于描述是否准确 | 先审高频 skill,再扩展到全量 |
| 路由延迟预算 | reranker 会增加响应时间 | 有稳定样本后再评估是否值得引入 |
可以复用的原则很简单:先降低加载范围,再提升检索准确性;先保留可解释 fallback,再引入更复杂的路由。
