Skill 越装越多,上下文也越来越长:把能力索引、发现和正文读取分开
本文要解决的问题:Skill 正文虽然已经按需读取,但完整目录、用途说明以及工具参数里的名称枚举仍会重复进入请求。随着 Skill 增多,模型还没开始工作,输入就已经被能力索引占据。我们给索引设置预算,增加分页查询,并让读取入口的 Schema 不再随名称列表增长。
版本说明:本文记录 tsm-agt 当前本地实现,配套代码尚未提交、推送。正文统一使用 GitHub
main链接,代码同步前已有文件可能仍显示旧实现。所有示例仅用于说明机制。
一、先解释 Skill 是什么
在这个项目里,指令型 Skill 是一份可复用的工作方法。
它通常以 SKILL.md 为入口,包含适用场景、操作步骤、约束,以及对其他资料的引用。例如,"接口变更检查"Skill 可以指导 Agent 检查参数兼容性、寻找调用方、运行相关测试并记录验证结果。
一个简化的文件结构:
text
review-api/
├── SKILL.md
└── references/
└── checklist.md
SKILL.md 示例:
markdown
---
name: review-api
description: 检查接口变更、参数兼容性及相关验证结果
---
# 接口变更检查
先阅读当前接口和调用方,明确变更影响。
根据实际变更选择验证入口,记录观察结果。
需要完整检查项时,读取 references/checklist.md。
模型读取 Skill 后,仍通过普通工具访问文件、运行命令或修改代码。Skill 提供工作方法,不会凭空产生新的权限。
项目还存在代码型 Skill:它具有可执行处理器和参数定义,走工具调用路径。本文主要讨论指令型 Skill 的上下文加载,不把两者混在一起。
相关类型见 skills.py,目录处理见 skill_catalog.py。
二、原来已经按需读正文,为什么还需要改
一个容易混淆的地方是:"正文按需读取"和"能力目录不会膨胀"并不是同一件事。
改造前,指令型 Skill 已经通过 skill.load 读取正文,模型并不是一开始就收到所有 SKILL.md。
但初始请求还会携带两类不断增长的数据:
- 完整的 Skill 目录,包括名称、说明及来源等元数据。
skill.load参数 Schema 中列举全部名称的enum。
假设安装了 300 个 Skill,即使用户只需要其中一个,300 个名称仍可能同时出现在目录和工具定义中。
更麻烦的是,新增一个 Skill,本来只是增加一条能力记录,却可能改变整个读取工具的参数定义。
| 问题 | 具体表现 | 这次处理方式 |
|---|---|---|
| 索引无预算 | 安装越多,初始目录越长 | 给初始目录设置条目预算 |
| 名称重复 | 名称既在目录里,又出现在 Schema 枚举里 | 移除枚举,读取时查真实注册表 |
| 目录裁剪后无入口 | 没展示的 Skill 容易被模型忽略 | 增加 skill.discover 查询 |
| 版本信息混用 | 目录变化与正文变化都可能影响提示词 | 区分内容修订与模型可见目录变化 |
因此,这次不是把所有正文从"全量加载"改成"延迟加载",而是继续收紧正文之前的索引层。
三、改造后的完整使用流程
text
本地发现内置、用户 Skill
→ 构建后台完整目录
→ 模型先看到有预算的简短索引
→ 已知确切名称时,直接 skill.load
→ 名称未知或索引未展示时,skill.discover 查询
→ skill.load 读取 SKILL.md
→ 按正文需要,再读取 references 等关联资源
→ 使用普通工具完成任务
| 层次 | 主要回答的问题 | 进入上下文的内容 |
|---|---|---|
| 初始索引 | 大概有哪些工作方法 | 部分名称、简短说明、来源信息 |
| 目录查询 | 当前任务适合哪个 Skill | 匹配的目录项和当前修订信息 |
| 正文读取 | 这个 Skill 具体怎么做 | 所选 SKILL.md |
| 关联资源 | 当前步骤还需要什么细节 | 按需读取的参考资料 |
如果用户明确点名了 review-api,没有必要强制先搜索一次。渐进加载的目标是减少无关内容,不是给所有任务增加固定调用次数。
四、初始索引怎样控制大小
当前 _bounded_catalog 使用以下规则:
- 按名称排序,使相同目录的输出顺序稳定。
- 单条用途说明最多保留 256 个字符。
- 保留名称、简短说明、来源和覆盖信息。
- 使用 UTF-8 JSON 条目大小累计,条目预算为 8,000 字节。
- 告诉模型完整数量、是否还有未展示条目,以及查询入口。
简化后的结构:
json
{
"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 没出现在初始索引里时,需要由查询入口补足。
五、为什么要去掉名称枚举
旧的读取参数类似:
json
{
"name": {
"type": "string",
"enum": ["review-api", "verify-change", "write-report"]
}
}
新的参数定义只要求非空字符串:
json
{
"name": {
"type": "string",
"minLength": 1
}
}
去掉枚举并不意味着任何名称都可以执行。
调用时,Provider 仍然刷新目录、查找真实注册项;找不到会返回 SKILL_NOT_FOUND。约束从"把全部名称复制进每次请求的 Schema",转为"执行前查询当前注册表"。
这样增加一份指令型 Skill,不再因为名称枚举而扩大 skill.load 的 Schema。但目录消息仍可能变化,代码型 Skill 也仍可能增加原生工具,因此不能宣称整个工具集合永远固定。
六、模型如何找到没有展示的 Skill
目录查询示例:
json
{
"query": "接口",
"offset": 0,
"limit": 5
}
当前查询是对名称和用途说明做大小写不敏感的子串匹配,并按名称排序。分页默认 20 条,上限 50 条,通过 next_offset 继续读取。
这不是向量检索,也不额外调用一个模型来选择 Skill。其可解释性较好,但对同义词、描述质量和查询措辞有依赖。
描述可以写成"检查接口变更、参数兼容性及相关验证结果",避免只有"高效助手"之类难以检索的泛称。这里的优化发生在能力声明与检索质量上,而不是靠 Runtime 猜测某几个用户关键词后直接隐藏工具。
查询结果仍受一般工具输出治理约束。分页限制了返回条数,不能保证任意大条目都不会触发输出截断。
七、正文与参考文件怎样读取
读取主文件:
json
{
"name": "review-api"
}
读取正文引用的资源:
json
{
"name": "review-api",
"resource": "references/checklist.md"
}
省略 resource 时读取 SKILL.md。指定资源时,需要使用 Skill 目录内的有效相对文件路径。
正文和资源作为工具结果进入历史,而不是直接拼接成高权限系统指令。文件读取也沿用资源范围和错误处理约束,不能把一个 Skill 当成任意文件系统读取入口。
Skill 的验证要求可以指导模型选择检查方式,但不能授予工作区写权限,也不能替代真实执行结果。
八、Skill 发生变化时,怎么避免静默切换
skill.load 可以携带目录返回的修订值:
json
{
"name": "review-api",
"catalog_revision": "发现时返回的修订值"
}
如果修订不匹配,返回 SKILL_CATALOG_CHANGED 和当前目录数据,让模型明确看到变化后再读取。
这里有两种不同的版本语义:
| 数据 | 关注什么变化 |
|---|---|
| 目录内容修订 | 包括当前实现所计算的正文内容变化,用于显式读取核对 |
| 窗口内目录差异 | 关注可见目录字段,忽略单独的 revision 字段变化 |
因此,仅编辑正文,并不会因为修订值变化就自动替换前面的目录消息。已经读过的旧正文也不会被后台扫描悄悄改写;显式重读才能得到当前内容。
这个取舍保持了历史可解释性,也意味着模型不能假设"我之前读过,所以现在文件肯定没变"。需要最新内容时,应重新观察。
窗口侧处理见 context_window.py(新增,待同步)。
九、这次实际减少了什么
把输入拆开,可以看到改造范围:
text
改造前:完整目录 + 全部名称枚举 + 被读取的正文 + 对话历史
改造后:有预算的索引 + 固定读取/查询 Schema + 查询结果 + 被读取的正文 + 对话历史
已经能够从实现确认的收益:
- 初始索引不再随所有说明无限增加。
- 名称不会再通过
enum在读取 Schema 中完整重复一遍。 - 未展示的指令型 Skill 仍有查询路径。
- 正文和关联资源只在实际读取后进入历史。
仍然存在的成本:
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 场景下,还可以继续评估目录分类、别名及检索质量,但应先用实际命中率和额外交互成本说明问题,再决定是否增加更复杂的检索层。
本次改造先建立了清晰的边界:后台保存完整能力,初始上下文承担有限索引,需要方法时读取正文,需要细节时读取资源。