OpenCode Skill 加载机制解析
OpenCode Skill 加载机制解析
本文只聚焦当前 dev 分支下 OpenCode 的 skill 加载链路,目标是回答三个问题:
- skill 从哪里被发现
- skill 怎么暴露给模型
- skill 正文什么时候真正进入上下文
相关源码文件
packages/opencode/src/skill/index.tspackages/opencode/src/skill/discovery.tspackages/opencode/src/tool/skill.tspackages/opencode/src/session/system.tspackages/opencode/src/command/index.tspackages/opencode/src/tool/registry.ts
一、整体设计:两阶段加载
OpenCode 对 skill 不是“一启动就全部读入 prompt”,而是分成两步:
- 先建立 skill 索引
- 再按需加载某个 skill 的正文
对应链路是:
Skill.available()/Skill.all()/Skill.get()- 触发
packages/opencode/src/skill/index.ts里的懒加载扫描
- 触发
SystemPrompt.skills()- 把 skill 名称和简介写进 system prompt
SkillTool.execute()- 当模型真正决定使用某个 skill 时,再把对应
SKILL.md正文注入上下文
- 当模型真正决定使用某个 skill 时,再把对应
这套设计的核心目标是节省上下文,同时保留按需扩展能力。
二、skill 从哪里来
核心文件:packages/opencode/src/skill/index.ts
Skill 命名空间负责把各处的 SKILL.md 收拢成统一结构:
namedescriptionlocationcontent
扫描来源
load() 会依次扫描四类来源:
1. 外部全局 / 项目级目录
~/.claude/skills/**/SKILL.md~/.agents/skills/**/SKILL.md- 当前项目向上查找的
.claude/skills/**/SKILL.md - 当前项目向上查找的
.agents/skills/**/SKILL.md
2. OpenCode 配置目录
对 Config.directories() 中的每个目录扫描:
{skill,skills}/**/SKILL.md
3. 配置里的本地路径
来自:
cfg.skills.paths
4. 配置里的远程 URL
来自:
cfg.skills.urls
远程 skill 不会直接参与扫描,而是先交给 packages/opencode/src/skill/discovery.ts 下载到本地缓存。
三、远程 skill 如何落地到本地
核心文件:packages/opencode/src/skill/discovery.ts
这个文件只做一件事:
- 把远程技能源 URL 变成“本地 skill 目录列表”
远程格式
给一个 URL,例如:
https://example.com/.well-known/skills/
系统会去读取:
https://example.com/.well-known/skills/index.json
要求 index.json 至少满足:
- 顶层有
skills - 每个 skill 有
name - 每个 skill 有
files files中必须包含SKILL.md
缓存位置
下载后的文件统一落在:
Global.Path.cache/skills/<skill-name>/
返回值
Discovery.pull(url) 不返回解析后的 skill,而是返回这些本地目录。
之后 skill/index.ts 再把这些目录按普通目录继续扫描,纳入统一缓存。
所以它和 skill/index.ts 的职责边界很清晰:
discovery.ts负责下载index.ts负责解析和缓存
四、模型如何知道“有哪些 skill 可以用”
核心文件:packages/opencode/src/session/system.ts
SystemPrompt.skills(agent) 会:
- 检查当前 agent 是否允许使用
skill - 调用
Skill.available(agent)取可见的 skill 列表 - 用
Skill.fmt(list, { verbose: true })生成<available_skills>结构 - 把这段内容注入 system prompt
这里注入的是:
- skill 名字
- skill 描述
- skill 文件位置
不是 skill 正文。
这意味着模型先拿到的是“目录索引”,而不是“完整规则正文”。
五、skill 正文什么时候真正加载
核心文件:packages/opencode/src/tool/skill.ts
真正把 skill 内容注入对话上下文的是 SkillTool。
它的执行过程是:
- 先根据当前 agent 读取
Skill.available(ctx?.agent) - 把可用 skill 列表写入 tool description
- 模型调用工具时传入
name execute()内部调用Skill.get(name)- 走
ctx.ask(...)做 skill 权限确认 - 输出:
<skill_content name="...">- skill 正文
- skill 基础目录
- skill 目录下抽样文件列表
最关键的一点是:
- system prompt 只放“技能索引”
- tool 调用才放“技能正文”
这就是 OpenCode skill 的按需加载机制。
六、skill 为什么还能作为 /命令
核心文件:packages/opencode/src/command/index.ts
命令系统会把三类东西统一成 Command.Info:
- 内建命令
- MCP prompt
- skill
其中 skill 相关逻辑是:
- 遍历
Skill.all() - 如果命令表里没有同名项
- 注册一个
source: "skill"的命令 template直接返回skill.content
这表示 skill 有两种进入使用流程的方式:
方式一:模型自主决定
通过 skill tool 按任务匹配自动加载
方式二:用户显式指定
通过 /skill-name 直接把该 skill 的正文作为命令模板使用
这使得 skill 同时属于:
- 工具系统
- 命令系统
七、tool registry 在这条链上的角色
虽然本文重点不是 packages/opencode/src/tool/registry.ts,但它在链路里不可缺。
它的作用很简单:
- 把
SkillTool注册到工具列表里
如果没有这一步,即使:
session/system.ts已经把 skill 列表告诉模型
模型也没有真正可调用的 skill 工具。
所以它的角色是“暴露工具能力”,不是“发现或解析 skill”。
八、关键调用链
可以把最重要的调用路径记成下面三条。
1. 建立 skill 索引
Skill.available()
-> ensure()
-> load()
-> scan()
-> add()
2. 远程 skill 接入
load()
-> Discovery.pull(url)
-> 下载到 cache
-> scan()
-> add()
3. 按需加载 skill 正文
SkillTool.execute()
-> Skill.get(name)
-> 输出 <skill_content ...>
九、学习这一块时最该抓的点
1. skill 是懒加载的
不是进程启动时就把所有 SKILL.md 一次性塞进上下文。
2. skill 先作为索引暴露,再作为正文加载
这一点贯穿了 session/system.ts 和 tool/skill.ts 的分工。
3. skill 既是工具,又是命令
这也是为什么理解 skill 不能只看 tool/skill.ts,还要看 command/index.ts。
十、为什么 skill 里写 references/ 有时会跑到项目目录找
这部分是实际使用 skill 时很容易踩到的坑。
结论先说:
- OpenCode 会把 skill 基础目录告诉模型
- 但不会在工具层自动把 skill 内的相对路径改写为 skill 目录下的绝对路径
- 所以如果模型直接把
references/foo.md原样传给文件工具,工具通常会去项目工作目录找,而不是去 skill 目录找
1. skill tool 做了什么
在 packages/opencode/src/tool/skill.ts 里,加载 skill 后会输出:
Base directory for this skill: ...Relative paths in this skill ... are relative to this base directory.<skill_files> ... </skill_files>
这说明 OpenCode 已经意识到 skill 目录里可能还有:
references/scripts/templates/
这样的配套文件。
但这里做的事情本质上是“告诉模型规则”,不是“替模型重写路径”。
2. 文件工具为什么还是会去项目目录找
因为大多数工具对相对路径的默认解析基准仍然是 Instance.directory,也就是当前项目目录。
典型例子:
read 工具
packages/opencode/src/tool/read.ts 中:
- 如果路径不是绝对路径
- 就执行
path.resolve(Instance.directory, filepath)
也就是说:
references/foo.md
会被解析成:
<项目目录>/references/foo.md
而不是:
<skill目录>/references/foo.md
glob 工具
packages/opencode/src/tool/glob.ts 中:
- 默认搜索目录是
Instance.directory - 相对
path也会相对Instance.directory解析
bash 工具
packages/opencode/src/tool/bash.ts 中:
workdir默认值是Instance.directory
如果 skill 让模型执行 scripts/run.ts,但模型没显式传 workdir,那命令默认也会在项目目录执行。
3. 根因是什么
根因不是 skill 没提供目录信息,而是:
tool/skill.ts只把 skill 目录信息作为文本和 metadata 提供给模型read/glob/bash等工具没有统一实现“当前 active skill 的相对路径解析器”
所以现在这套机制更像:
- 系统给模型一个提示
- 模型自己记得就会先转成 skill 目录下的绝对路径
- 模型忘了,工具就回落到项目目录默认解析
十一、仅靠提示词能不能避免这个问题
可以缓解,但不能从机制上彻底消除。
1. 为什么只能缓解
因为错误的根源在工具实现层:
- 相对路径默认还是相对项目目录
所以提示词最多只能提高模型“主动转换为绝对路径”的概率,不能保证每次都成功。
2. 提示词里最有效的写法
如果想只靠 SKILL.md 改善这个问题,规则要写得非常硬,不能只写一句:
- “相对路径相对于 skill 目录”
更有效的写法应当是这种步骤式规则:
文件访问规则
在调用任何文件工具前:
- 先从已加载的 skill 输出中读取 skill base directory
- 把
references/、scripts/、templates/等相对路径先转成绝对路径 - 只把绝对路径传给
read、glob、grep、write、edit
Bash 执行规则
执行 skill 目录下的脚本时:
- 不要依赖默认工作目录
- 总是显式设置
bash.workdir - 默认把
bash.workdir设为 skill base directory
禁止式规则
可以明确写:
- 不要直接把
references/foo.md这类相对路径原样传给工具 - 不要假设 skill 相对路径会自动映射到 skill 目录
3. 实际边界
即使提示词写得更硬,也仍然只能做到:
- 降低出错率
而不能做到:
- 从系统机制上保证不出错
原因很简单:
- 现在没有代码层的自动路径重写
- 最终还是依赖模型是否遵守这段路径规则