Skill 索引瘦身与渐进加载

3 阅读10分钟

Skill 越装越多,上下文也越来越长:把能力索引、发现和正文读取分开

本文要解决的问题:Skill 正文虽然已经按需读取,但完整目录、用途说明以及工具参数里的名称枚举仍会重复进入请求。随着 Skill 增多,模型还没开始工作,输入就已经被能力索引占据。我们给索引设置预算,增加分页查询,并让读取入口的 Schema 不再随名称列表增长。

版本说明:本文记录 tsm-agt 当前本地实现,配套代码尚未提交、推送。正文统一使用 GitHub main 链接,代码同步前已有文件可能仍显示旧实现。所有示例仅用于说明机制。

一、先解释 Skill 是什么

在这个项目里,指令型 Skill 是一份可复用的工作方法。

它通常以 SKILL.md 为入口,包含适用场景、操作步骤、约束,以及对其他资料的引用。例如,“接口变更检查”Skill 可以指导 Agent 检查参数兼容性、寻找调用方、运行相关测试并记录验证结果。

一个简化的文件结构:

review-api/
├── SKILL.md
└── references/
    └── checklist.md

SKILL.md 示例:

---
name: review-api
description: 检查接口变更、参数兼容性及相关验证结果
---

# 接口变更检查

先阅读当前接口和调用方,明确变更影响。
根据实际变更选择验证入口,记录观察结果。
需要完整检查项时,读取 references/checklist.md。

模型读取 Skill 后,仍通过普通工具访问文件、运行命令或修改代码。Skill 提供工作方法,不会凭空产生新的权限。

项目还存在代码型 Skill:它具有可执行处理器和参数定义,走工具调用路径。本文主要讨论指令型 Skill 的上下文加载,不把两者混在一起。

相关类型见 skills.py,目录处理见 skill_catalog.py。

二、原来已经按需读正文,为什么还需要改

一个容易混淆的地方是:“正文按需读取”和“能力目录不会膨胀”并不是同一件事。

改造前,指令型 Skill 已经通过 skill.load 读取正文,模型并不是一开始就收到所有 SKILL.md。

但初始请求还会携带两类不断增长的数据:

  1. 完整的 Skill 目录,包括名称、说明及来源等元数据。
  2. skill.load 参数 Schema 中列举全部名称的 enum。

假设安装了 300 个 Skill,即使用户只需要其中一个,300 个名称仍可能同时出现在目录和工具定义中。

更麻烦的是,新增一个 Skill,本来只是增加一条能力记录,却可能改变整个读取工具的参数定义。

问题具体表现这次处理方式
索引无预算安装越多,初始目录越长给初始目录设置条目预算
名称重复名称既在目录里,又出现在 Schema 枚举里移除枚举,读取时查真实注册表
目录裁剪后无入口没展示的 Skill 容易被模型忽略增加 skill.discover 查询
版本信息混用目录变化与正文变化都可能影响提示词区分内容修订与模型可见目录变化

因此,这次不是把所有正文从“全量加载”改成“延迟加载”,而是继续收紧正文之前的索引层。

三、改造后的完整使用流程

本地发现内置、用户 Skill
  → 构建后台完整目录
  → 模型先看到有预算的简短索引
  → 已知确切名称时,直接 skill.load
  → 名称未知或索引未展示时,skill.discover 查询
  → skill.load 读取 SKILL.md
  → 按正文需要,再读取 references 等关联资源
  → 使用普通工具完成任务
层次主要回答的问题进入上下文的内容
初始索引大概有哪些工作方法部分名称、简短说明、来源信息
目录查询当前任务适合哪个 Skill匹配的目录项和当前修订信息
正文读取这个 Skill 具体怎么做所选 SKILL.md
关联资源当前步骤还需要什么细节按需读取的参考资料

如果用户明确点名了 review-api,没有必要强制先搜索一次。渐进加载的目标是减少无关内容,不是给所有任务增加固定调用次数。

四、初始索引怎样控制大小

当前 _bounded_catalog 使用以下规则:

  • 按名称排序,使相同目录的输出顺序稳定。
  • 单条用途说明最多保留 256 个字符。
  • 保留名称、简短说明、来源和覆盖信息。
  • 使用 UTF-8 JSON 条目大小累计,条目预算为 8,000 字节。
  • 告诉模型完整数量、是否还有未展示条目,以及查询入口。

简化后的结构:

{
  "kind": "skill_catalog",
  "revision": "当前内容修订哈希",
  "skills": [
    {
      "name": "review-api",
      "description": "检查接口变更、参数兼容性及相关验证结果",
      "source": "user",
      "overrides": []
    }
  ],
  "total_skills": 300,
  "has_more": true,
  "discovery_tool": "skill.discover"
}

8,000 字节限制的是累计条目,不是包含外层字段后的整个消息硬上限,更不是“精确 2,000 tokens”。字段包装、字符编码、模型分词方式都影响最终输入。

此外,当前实现是按名称取能装下的前缀,不是根据本次用户意图进行智能排序。相关 Skill 没出现在初始索引里时,需要由查询入口补足。

实现见 CoreSkillToolProvider。

五、为什么要去掉名称枚举

旧的读取参数类似:

{
  "name": {
    "type": "string",
    "enum": ["review-api", "verify-change", "write-report"]
  }
}

新的参数定义只要求非空字符串:

{
  "name": {
    "type": "string",
    "minLength": 1
  }
}

去掉枚举并不意味着任何名称都可以执行。

调用时,Provider 仍然刷新目录、查找真实注册项;找不到会返回 SKILL_NOT_FOUND。约束从“把全部名称复制进每次请求的 Schema”,转为“执行前查询当前注册表”。

这样增加一份指令型 Skill,不再因为名称枚举而扩大 skill.load 的 Schema。但目录消息仍可能变化,代码型 Skill 也仍可能增加原生工具,因此不能宣称整个工具集合永远固定。

六、模型如何找到没有展示的 Skill

目录查询示例:

{
  "query": "接口",
  "offset": 0,
  "limit": 5
}

当前查询是对名称和用途说明做大小写不敏感的子串匹配,并按名称排序。分页默认 20 条,上限 50 条,通过 next_offset 继续读取。

这不是向量检索,也不额外调用一个模型来选择 Skill。其可解释性较好,但对同义词、描述质量和查询措辞有依赖。

描述可以写成“检查接口变更、参数兼容性及相关验证结果”,避免只有“高效助手”之类难以检索的泛称。这里的优化发生在能力声明与检索质量上,而不是靠 Runtime 猜测某几个用户关键词后直接隐藏工具。

查询结果仍受一般工具输出治理约束。分页限制了返回条数,不能保证任意大条目都不会触发输出截断。

七、正文与参考文件怎样读取

读取主文件:

{
  "name": "review-api"
}

读取正文引用的资源:

{
  "name": "review-api",
  "resource": "references/checklist.md"
}

省略 resource 时读取 SKILL.md。指定资源时,需要使用 Skill 目录内的有效相对文件路径。

正文和资源作为工具结果进入历史,而不是直接拼接成高权限系统指令。文件读取也沿用资源范围和错误处理约束,不能把一个 Skill 当成任意文件系统读取入口。

Skill 的验证要求可以指导模型选择检查方式,但不能授予工作区写权限,也不能替代真实执行结果。

八、Skill 发生变化时,怎么避免静默切换

skill.load 可以携带目录返回的修订值:

{
  "name": "review-api",
  "catalog_revision": "发现时返回的修订值"
}

如果修订不匹配,返回 SKILL_CATALOG_CHANGED 和当前目录数据,让模型明确看到变化后再读取。

这里有两种不同的版本语义:

数据关注什么变化
目录内容修订包括当前实现所计算的正文内容变化,用于显式读取核对
窗口内目录差异关注可见目录字段,忽略单独的 revision 字段变化

因此,仅编辑正文,并不会因为修订值变化就自动替换前面的目录消息。已经读过的旧正文也不会被后台扫描悄悄改写;显式重读才能得到当前内容。

这个取舍保持了历史可解释性,也意味着模型不能假设“我之前读过,所以现在文件肯定没变”。需要最新内容时,应重新观察。

窗口侧处理见 context_window.py(新增,待同步)。

九、这次实际减少了什么

把输入拆开,可以看到改造范围:

改造前:完整目录 + 全部名称枚举 + 被读取的正文 + 对话历史
改造后:有预算的索引 + 固定读取/查询 Schema + 查询结果 + 被读取的正文 + 对话历史

已经能够从实现确认的收益:

  1. 初始索引不再随所有说明无限增加。
  2. 名称不会再通过 enum 在读取 Schema 中完整重复一遍。
  3. 未展示的指令型 Skill 仍有查询路径。
  4. 正文和关联资源只在实际读取后进入历史。

仍然存在的成本:

  • skill.discover 本身占用一个工具定义。
  • 名称未知时,会增加查询及后续模型交互。
  • 查询结果和正文一旦进入历史,后续请求仍会携带,直到压缩移出。
  • 当前目录修订计算仍可能读取正文生成哈希;这是本地计算开销,和把正文发给模型是两回事。

这次没有单独完成 Skill 场景的 tokenizer、计费或任务质量对照,因此不能把 MCP 的 73.84% 输入估算下降直接套到 Skill 上。

十、验证重点:裁剪之后仍然能找到、读到、处理变化

验证场景应证明的行为
新用户目录可以读取项目随包提供的 Skill,无需先写入用户目录
正文引用参考文件可以按相对路径继续读取资源
用户覆盖内置 Skill来源和覆盖关系能被观察到
目录修订变化旧修订读取明确返回变化,而非静默切换
名称不在注册表返回可恢复的找不到错误
读取后进入下一次请求正文作为历史结果可见,不重复复制到前置提示词

测试见 test_bundled_skills.py、test_user_skills.py和 test_skill_loading_errors.py。

十一、当前实现的边界

指令型 Skill 需要存在时,Provider 才注册相应的读取和发现入口;代码型 Skill 仍按原生工具暴露。当前实现没有把所有 Skill 一概转换成同一种延迟调用机制。

索引预算也不等于能力选择已经达到最优。大量 Skill 场景下,还可以继续评估目录分类、别名及检索质量,但应先用实际命中率和额外交互成本说明问题,再决定是否增加更复杂的检索层。

本次改造先建立了清晰的边界:后台保存完整能力,初始上下文承担有限索引,需要方法时读取正文,需要细节时读取资源。