☰
深度剖析 OpenCode 中 Skills 的实现原理:从 SKILL.md 到 TaoToken 配置实战
2026/10/2 6:35:17 网站建设 项目流程

1. 从一次 Skill 加载失败说起:OpenCode Skills 的加载链路到底怎么走

如果你最近在折腾 OpenCode,大概率会遇到这样一个场景:明明在.opencode/skills/下放好了SKILL.md,frontmatter 也写了name和description,可模型就是"看不见"这个技能,调用skill工具时直接报Skill "xxx" not found。我第一次碰到这个问题时,盯着目录结构看了半小时,最后才发现是 frontmatter 的 YAML 缩进多了一个空格,导致ConfigMarkdown.parse解析出来的data里根本没有name字段,Info.pick({ name: true, description: true }).safeParse(md.data)直接success: false,注册环节被静默跳过。

这就是 OpenCode Skills 机制的一个典型特征:发现、解析、注册、注入、调用五个阶段里,任何一个环节失败,都不会给你特别醒目的报错,而是"技能凭空消失"。所以想真正跑通一个自定义 Skill,光会写SKILL.md不够,得把整条链路拆开看。

OpenCode 的 Skills 本质上是一套"按需加载的指令包"系统。它和传统的插件、工具调用不太一样:工具是模型主动调用的函数,而 Skill 更像是一份"说明书",平时只把标题和描述挂在系统提示里让模型知道"有这么个东西",等模型判断当前任务匹配某个 Skill 的描述时,才通过skill工具把完整正文加载进上下文。这种设计的好处是省 token——你放 20 个 Skill,系统提示里也只占几十行 XML;坏处是调试链路变长,出问题不好定位。

这篇文章我会按 OpenCode 源码里的真实执行顺序,把SKILL.md的 frontmatter 解析、四类来源的扫描优先级、内存注册表的构建、系统提示注入、以及skill工具的完整执行流程讲清楚。同时以接入 TaoToken 统一 Key/API 通道为实战场景,给你一份可以直接复制的config.toml、settings.json骨架和一个能跑通的自定义 Skill 示例。适合已经在用 OpenCode、想搞清楚 Skills 内部机制、或者想把自己的工作流封装成 Skill 的开发者。

2. TaoToken 前置准备:统一 Key 与 API 通道怎么配

在讲 Skill 注册之前,得先把模型通道打通,否则你连验证 Skill 的工具调用都跑不起来。OpenCode 支持多种 provider,我这里用 TaoToken 作为统一入口,原因是它把多家模型的 Key 收敛成一个 API Key,配置一次就能在 OpenCode 里切换模型,省得每个 provider 单独维护环境变量。

TaoToken 的 API 地址是https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 Base URL 用。官网入口是https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,注册后在控制台生成 API Key,格式通常是一串以sk-开头的字符串。

这里有个容易踩的坑:OpenCode 的 provider 配置里,Base URL 到底要不要带/v1,取决于你用的 SDK 类型。TaoToken 的兼容层同时支持 OpenAI 风格和 Anthropic 风格,如果你走 OpenAI 兼容模式,Base URL 填https://taotoken.net/api,SDK 内部会自己拼/v1/chat/completions;如果你手动填了/v1,反而会变成/v1/v1/chat/completions,直接 404。我实测下来,最稳的做法是 Base URL 只写到/api,让 SDK 处理路径拼接。

配置方式有两种:一种是写进 OpenCode 的全局配置文件,另一种是用环境变量。环境变量适合临时切换,配置文件适合长期使用。下面这份config.toml是 OpenCode 读取 provider 的标准位置,路径在~/.config/opencode/config.toml(Linux/macOS)或%APPDATA%\opencode\config.toml(Windows)。

# ~/.config/opencode/config.toml # TaoToken 统一通道配置,Base URL 只写到 /api [provider.taotoken] name = "TaoToken" npm = "@ai-sdk/openai-compatible" options = { baseURL = "https://taotoken.net/api" } [provider.taotoken.models."claude-sonnet-4-5"] name = "Claude Sonnet 4.5" [provider.taotoken.models."gpt-4o"] name = "GPT-4o" [provider.taotoken.models."deepseek-v3"] name = "DeepSeek V3"

注意npm字段指定的是@ai-sdk/openai-compatible,这是 OpenCode 用来对接 OpenAI 兼容接口的适配器。如果你的 OpenCode 版本较老,可能字段名是sdk而不是npm,以你本地opencode --version对应的文档为准。

API Key 不要硬编码进config.toml,用环境变量注入更安全。在~/.zshrc或~/.bashrc里加一行:

export TAOTOKEN_API_KEY="sk-你的实际key"

然后在config.toml里引用:

[provider.taotoken] name = "TaoToken" npm = "@ai-sdk/openai-compatible" options = { baseURL = "https://taotoken.net/api", apiKey = "{env:TAOTOKEN_API_KEY}" }

{env:TAOTOKEN_API_KEY}是 OpenCode 的变量插值语法,启动时会从环境变量读取。这样配置文件可以安全地提交到 dotfiles 仓库,Key 留在本地环境里。

如果你更习惯用settings.json风格(部分 OpenCode 发行版或插件生态会读这个文件),对应的骨架是这样:

{ "provider": { "taotoken": { "name": "TaoToken", "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "https://taotoken.net/api", "apiKey": "{env:TAOTOKEN_API_KEY}" }, "models": { "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5" }, "gpt-4o": { "name": "GPT-4o" } } } } }

配好之后,用opencode models命令应该能看到taotoken/claude-sonnet-4-5这类条目。如果看不到,先检查config.toml的 TOML 语法有没有写错——TOML 对引号和方括号很敏感,[provider.taotoken]这种表头写错一个字符,整个 provider 都不会加载。

3. SKILL.md 的 frontmatter 解析与四类来源扫描优先级

现在进入正题。OpenCode 里一个 Skill 的最小单元就是一个SKILL.md文件,它由两部分组成:顶部的 YAML frontmatter 和下面的正文。frontmatter 用---包裹,里面至少要有name和description两个字段。

--- name: taotoken-api-helper description: 当用户需要查询 TaoToken 可用模型、生成 API 调用示例或排查 401 错误时使用此技能 --- # TaoToken API 助手 你是一个专门帮助用户接入 TaoToken 的技能。 ## 可用模型列表 - claude-sonnet-4-5 - gpt-4o - deepseek-v3 ## 常见错误处理 如果遇到 401,检查 API Key 是否以 sk- 开头,以及环境变量 TAOTOKEN_API_KEY 是否已导出。

name是 Skill 的唯一标识,注册表以它为 key,重复的name会触发duplicate skill name警告,后加载的覆盖先加载的。description是给模型看的,模型靠它判断"当前任务要不要加载这个 Skill",所以描述要写清楚触发场景,别写成"这是一个很有用的技能"这种废话。

frontmatter 的解析由ConfigMarkdown.parse完成,它内部用 YAML 解析器把---之间的内容转成对象,正文部分单独提取。解析失败时,addSkill里的.catch()会捕获错误并通过Bus.publish(Session.Event.Error, ...)发一个事件,但不会中断整个扫描流程——这就是为什么一个坏文件不会影响其他 Skill 加载,但也容易被忽略。

接下来是发现逻辑。OpenCode 的 Skill 扫描是懒初始化的,通过state函数在第一次访问时触发,按优先级依次扫描四类来源,后加载的覆盖同名的先加载的。这个"后覆盖先"的规则很关键,意味着项目级 Skill 可以覆盖全局级同名 Skill,方便你在具体项目里定制行为。

第一类是外部兼容目录。OpenCode 会先扫全局 home 目录下的.claude/skills/和.agents/skills/,这两个目录是为了兼容其他 AI 工具的 Skill 格式。源码里的常量是:

const EXTERNAL_DIRS = [".claude", ".agents"] const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"

扫描时先检查Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS是否开启,如果没开,就遍历这两个目录,用Filesystem.isDir确认存在后再scanExternal。然后还会从当前工作目录向上查找,直到worktree根目录,把项目级的.claude和.agents也扫一遍。所以你的 Skill 可以放在~/.claude/skills/作为全局,也可以放在项目根目录的.claude/skills/作为项目级。

第二类是 OpenCode 自有目录。这是最推荐放自定义 Skill 的地方,支持skill/和skills/两种命名:

const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"

也就是说.opencode/skill/my-skill/SKILL.md和.opencode/skills/my-skill/SKILL.md都能被识别。扫描时通过Config.directories()拿到所有配置目录,再用Glob.scan匹配模式。这里有个细节:**/SKILL.md意味着你可以嵌套多层目录,比如.opencode/skills/backend/api-helper/SKILL.md,name字段才是唯一标识,目录层级不影响注册。

第三类是用户自定义路径。你可以在opencode.json里声明额外的 Skill 路径,支持~/前缀和相对路径展开。这对把 Skill 放在非标准位置的人很有用,比如你想把团队共享的 Skill 放在一个 git submodule 里。

第四类是远程 URL 下载。通过Discovery.pull(url)从远程服务器拉取 Skill,适合团队统一分发。不过远程拉取涉及网络和缓存,调试阶段建议先用本地文件跑通。

四类来源的扫描顺序决定了覆盖关系。实际执行时,外部兼容目录先扫,然后是 OpenCode 自有目录,再是用户自定义路径,最后是远程。同名 Skill 后扫到的会覆盖先扫到的。所以如果你在.claude/skills/和.opencode/skills/放了同名 Skill,.opencode里的会生效。这个规则可以用来做"全局默认 + 项目覆盖"的分层设计。

4. 注册表构建与系统提示注入:Skill 怎么被模型"看见"

每发现一个SKILL.md,addSkill函数就负责解析并注册到内存 map。注册表是一个以name为 key 的Record<string, Info>对象,存在state里。Info的数据模型是:

export const Info = z.object({ name: z.string(), description: z.string(), location: z.string(), content: z.string(), })

name从 frontmatter 提取,description用于 AI 判断,location是SKILL.md在磁盘上的绝对路径,content是正文内容。注册逻辑大致是:

const addSkill = async (match: string) => { const md = await ConfigMarkdown.parse(match).catch((err) => { Bus.publish(Session.Event.Error, { ... }) return undefined }) const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) if (!parsed.success) return if (skills[parsed.data.name]) { log.warn("duplicate skill name", { ... }) } skills[parsed.data.name] = { name: parsed.data.name, description: parsed.data.description, location: match, content: md.content, } dirs.add(path.dirname(match)) }

注意Info.pick({ name: true, description: true })只校验这两个字段,location和content是注册时手动填的。如果 frontmatter 缺name或description,safeParse返回success: false,直接return,Skill 被静默丢弃。这就是开头那个"技能凭空消失"的根因。

注册完成后,Skill 的执行分两个阶段:系统提示阶段(预告)和工具调用阶段(加载详情)。系统提示阶段由skills(agent)函数负责:

export async function skills(agent: Agent.Info) { if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return const list = await Skill.available(agent) return [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", Skill.fmt(list, { verbose: true }), ].join("\n") }

Skill.fmt(list, { verbose: true })会把所有可用 Skill 以 XML 格式列出来,注入系统提示。verbose: true是详细版,包含description,让模型能判断匹配度。这个 XML 片段就是模型"看见"Skill 的唯一途径——如果注册表里没有,系统提示里就不会出现,模型自然不知道有这个 Skill。

这里有个权限过滤的细节:PermissionNext.disabled(["skill"], agent.permission)会检查当前 agent 的权限配置,如果skill工具被完全禁用,整个 Skills 提示都不注入。所以如果你发现模型完全不提 Skill,先检查 agent 的 permission 配置里有没有把skill禁掉。

Skill.available(agent)会按 agent 权限过滤可用 Skill,不同 agent 看到的 Skill 列表可能不同。这给了你按 agent 定制能力集的空间,比如给"代码审查" agent 只开放审查相关的 Skill。

系统提示里的 XML 大概长这样:

<available_skills> <skill> <name>taotoken-api-helper</name> <description>当用户需要查询 TaoToken 可用模型、生成 API 调用示例或排查 401 错误时使用此技能</description> </skill> </available_skills>

模型看到这个列表后,如果判断当前任务匹配某个description,就会调用skill工具,传入name参数,进入第二阶段加载完整正文。

5. skill 工具执行全流程与常见报错排查

skill工具的定义在SkillTool里,它的execute方法分四步:查找 Skill、权限检查、收集附属文件、构造输出。

第一步查找:根据 LLM 传入的name,通过Skill.get(name)从注册表查找。找不到就抛错并列出所有可用名称:

const skill = await Skill.get(params.name) if (!skill) { const available = await Skill.all().then((x) => x.map((s) => s.name).join(", ")) throw new Error(`Skill "${params.name}" not found. Available: ${available}`) }

这个报错信息很实用,Available:后面会列出所有已注册的 Skill 名,对照一下就知道是名字拼错还是根本没注册上。

第二步权限检查:ctx.ask({ permission: "skill", patterns: [params.name], always: [params.name], ... })。如果用户配置了需要确认,会弹交互式确认框。always字段设为 Skill 名,意味着用户允许过一次后,后续同名 Skill 不再重复询问。

第三步收集附属文件:用Ripgrep.files()列出 Skill 目录下所有文件,排除SKILL.md本身,最多收集 10 个。这些文件路径以<file>...</file>标签形式包含在输出里,让模型知道目录下还有哪些脚本、模板可以配合使用。

第四步构造输出:返回一个结构化文本块,包含 Skill 名、完整正文、基准目录路径、附属文件列表:

<skill_content name="taotoken-api-helper"> # Skill: taotoken-api-helper (SKILL.md 完整正文) Base directory for this skill: file:///path/to/skill/dir Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory. Note: file list is sampled. <skill_files> <file>/path/to/skill/dir/scripts/check-key.sh</file> </skill_files> </skill_content>

这个输出被注入对话上下文,模型就能按正文指令执行任务了。

现在说几个我实际踩过的报错。第一个是Skill "xxx" not found. Available: ...,最常见原因是 frontmatter 的name和调用时传的不一致,或者 YAML 缩进错误导致name没解析出来。排查方法:用opencode的调试模式看注册表,或者临时在addSkill里加日志。更简单的办法是检查SKILL.md的 frontmatter 是不是严格以---开头和结尾,中间不能有空行。

第二个是401 Unauthorized,这个通常不是 Skill 的问题,而是 TaoToken 的 Key 没配好。检查TAOTOKEN_API_KEY环境变量是否导出,config.toml里的{env:TAOTOKEN_API_KEY}拼写是否正确。如果用的是settings.json,确认 JSON 没有尾逗号。

第三个是local proxy failed或连接超时,这多半是 Base URL 写错了。记住 TaoToken 的 Base URL 是https://taotoken.net/api,不要加/v1,也不要加尾部斜杠。如果你在config.toml里写了baseURL = "https://taotoken.net/api/v1",SDK 会拼成/v1/v1/...,直接失败。

第四个是reading choices相关错误,这通常出现在流式响应解析阶段,说明返回的 JSON 结构不符合 OpenAI 兼容格式。先确认你选的模型在 TaoToken 控制台是启用的,再确认npm字段用的是@ai-sdk/openai-compatible而不是别的适配器。

第五个是 OAuth 相关报错,如果你用的是需要 OAuth 的 provider,但配置里混了 API Key 模式,会报OAuth token missing之类。TaoToken 走的是 API Key 模式,不需要 OAuth,所以config.toml里不要配auth相关字段。

排查顺序建议:先opencode models确认 provider 加载成功,再发一条最简单的对话确认通道通,最后才测 Skill 调用。这样能把"通道问题"和"Skill 问题"分开定位。

6. 跑通自定义 Skill 并接入 TaoToken 的完整验证

最后给你一个端到端的验证流程。先建目录和文件:

mkdir -p .opencode/skills/taotoken-api-helper

写入SKILL.md:

--- name: taotoken-api-helper description: 当用户需要查询 TaoToken 可用模型、生成 API 调用示例或排查 401 错误时使用此技能 --- # TaoToken API 助手 你是一个专门帮助用户接入 TaoToken 的技能。 ## 可用模型列表 - claude-sonnet-4-5 - gpt-4o - deepseek-v3 ## 调用示例 Base URL 使用 https://taotoken.net/api,不要追加 /v1。 ## 常见错误处理 如果遇到 401,检查 API Key 是否以 sk- 开头,以及环境变量 TAOTOKEN_API_KEY 是否已导出。

确认config.toml里的 provider 配置正确,环境变量已导出:

echo $TAOTOKEN_API_KEY

启动 OpenCode,发一条会触发 Skill 的消息,比如"帮我查一下 TaoToken 有哪些可用模型"。模型应该会调用skill工具,传入name: taotoken-api-helper,然后按正文指令回答。

如果模型没调用 Skill,先检查系统提示里有没有<available_skills>片段。可以在 OpenCode 的调试日志里找,或者临时把description写得更明确,比如加上"当用户提到 TaoToken 时必须使用此技能"。

验证成功后,你可以把这个 Skill 目录提交到项目仓库,团队成员拉下来就能用。如果想让全局生效,把目录移到~/.config/opencode/skills/或~/.claude/skills/。想统一管理多个项目的 Skill,可以在opencode.json里声明自定义路径,或者用Discovery.pull从团队仓库拉取。

一个实用技巧:把 Skill 的description当成"触发条件"来写,而不是"功能描述"。模型是靠description做匹配的,写清楚"什么时候用"比写"能做什么"更有效。比如"当用户需要查询 TaoToken 可用模型、生成 API 调用示例或排查 401 错误时使用此技能"就比"TaoToken 助手技能"匹配率高得多。

如果你想把 Skill 和 Coding Plan 结合,做长期的编码 Agent 工作流,可以在 TaoToken 控制台配置 Coding Plan,把常用模型和额度固定下来,再配合 Skill 封装团队的代码规范、审查清单、部署流程。这样每次新项目初始化,只要把.opencode/skills/目录复制过去,Agent 就自动具备团队约定的能力集。API Key 管理在控制台的 API Keys 页面,接入细节可以对照官方文档,模型能力验证可以直接在模型对话页面测。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询