首页
/ Zed Agent Skills 深度解析:SKILL.md 的发现、目录、激活与安全模型

Zed Agent Skills 深度解析:SKILL.md 的发现、目录、激活与安全模型

2026-09-05 09:06:20作者:裴麒琰

本文以 crates/agent_skills/README.md 这份设计文档为主线,系统讲解 Zed 编辑器对 Agent Skills(SKILL.md 文件)的加载与解析机制:技能从哪里被发现、如何进入模型的目录(catalog)、如何被激活为 <skill_content> 信封,以及围绕 prompt 缓存、严格校验、敏感路径和授权流程所做的安全设计。读完后你将能够独立编写可被 Zed agent 正确加载的技能,并理解每一处"看起来可以偷懒"的地方为什么被刻意拒绝。

一、Agent Skills 是什么

Agent Skills 是一种跨工具的技能格式规范(agentskills.io 的 Agent Skills specification)。规范定义的核心要素包括:

  • SKILL.md 文件格式:文件必须以 YAML frontmatter 开头,包含必填的 namedescription 两个字段,其后是 Markdown 正文;
  • 目录布局:一个技能是一个目录,包含 SKILL.md 以及可选的 scripts/references/assets/ 等捆绑资源;
  • 渐进披露(progressive disclosure)模型:模型先看到所有技能的"名称 + 描述"小目录,决定使用某个技能时才加载其正文,正文中引用了捆绑资源时才进一步读取这些资源;
  • 少量可选 frontmatter 字段licensecompatibilitymetadataallowed-tools(实验性)。

规范刻意留下了大量空白:技能在磁盘上的位置、如何呈现给用户、目录如何封装、激活长什么样、名称冲突如何裁决。设计文档明确表示,其主体内容正是对"规范没有替我们做的选择"的交代,外加少数有意偏离规范的地方。

Zed 中的实现分布在三个位置,这也是本文后续反复对照的源码坐标:

模块 文件 职责
加载与解析核心 crates/agent_skills/agent_skills.rs 类型定义、frontmatter 解析、发现(discovery)、覆盖合并
skill 工具 crates/agent/src/tools/skill_tool.rs 工具本体、<skill_content> 渲染器、XML 转义辅助
会话集成 crates/agent/src/agent.rs 斜杠命令注册、斜杠激活、live reload、目录筛选

二、发现机制:只认 .agents/skills,只做一层

2.1 两个作用域,一个位置

Zed 只在 .agents/skills 这一个目录下寻找技能,分两个作用域:

  • 全局~/.agents/skills/,对所有项目生效;
  • 项目本地<worktree>/.agents/skills/,仅对当前项目生效。

源码中这两个路径由常量 AGENTS_DIR_NAME 拼出,全局目录由 global_skills_dir() 返回,项目本地相对路径由 project_skills_relative_path() 给出(固定为 ".agents/skills")。

文档解释了为什么扫描其他 agent 工具各自私有的技能目录:互操作摩擦是有限的,正确的答案是让各工具向规范推荐位置收敛,而不是去猜测并扫描半打工具私有路径——那会让发现面变得不可预测。已有其他工具技能的用户可以移动或软链接(symlink)过来。事实上 test_load_symlinked_skill_directory 专门验证了"技能目录是一个符号链接"的情形也能正常加载,这条路径被测试明确保护。

2.2 扁平扫描:只看 skills 根的直接子目录

发现逻辑恰好只看一层:技能即 <skills_root>/<skill-name>/SKILL.md<skills_root>/group/some-skill/SKILL.md 不会被找到。

实现见 find_skill_files()read_dir 后对每个条目检查 metadata.is_dir,再检查其下是否存在 SKILL.md,没有任何递归。文档给出的理由:规范本身略有歧义(示例是扁平的,但"实践规则"提到 4-6 层深度上限),而实际调研的技能集合中没有一例使用嵌套;递归会换来深度限制、目录数上限、异步递归、硬编码忽略列表(.gitnode_modulestarget)等一整套复杂度,还有一个反直觉的失败模式——某个技能的资源目录里恰好含有 SKILL.md(例如一个"教你写技能"的技能)。这一失败模式同样有测试背书:test_nested_skill_md_inside_skill_resources_is_not_loaded 断言 outer/references/SKILL.md 不会被当作独立技能加载。

其他几条明确的"不做":

  • 不向上遍历祖先目录:在 monorepo 中,<repo>/packages/frontend/.agents/skills/ 不会被自动发现,项目本地技能只放在 worktree 根。文档说明该用例真实存在,但"哪些路径算祖先"的实现很琐碎,暂不实现;
  • 不支持远程技能注册表,也不支持用户配置额外目录:技能只来自上述两个位置,需要额外位置的用户可以软链进 ~/.agents/skills/

2.3 Live reload 与 prompt 缓存的账

在 agent 运行期间新增、删除或编辑 SKILL.md 无需重启即生效:全局目录和项目本地的 .agents/skills/(经由 worktree 变更事件)都会被监视。文档强调这一点比看起来更重要——技能作者在迭代自己的 SKILL.md 时应立刻看到目录更新。

刷新链路的实现入口是 maintain_project_context():每次刷新事件(prompt-store 更新、规则文件编辑、worktree 事件、信任状态变化)都会重建 ProjectContext,但只有当新值与当前值不等时才真正替换(源码注释明确写着:不变的 ProjectContext 意味着字节一致的系统提示,从而继续命中模型 API 的 prompt 缓存)。

这个机制决定了技能作者的"缓存成本":

  • 技能目录(name + description + location)是系统提示的一部分,而 Anthropic 兼容的 prompt 缓存按字节相同的前缀匹配,任何目录文本变化都会使缓存失效;
  • 技能正文不在系统提示里——正文通过 skill 工具或斜杠命令按需加载、作为独立消息注入,因此编辑正文永不影响缓存;
  • 只改正文(技能作者最常见的迭代方式)会被识别为"目录无变化"的 no-op,系统提示字节不变,缓存保持温热;
  • 修改 namedescription 或移动 SKILL.md 文件改变目录、使缓存失效——这是不可避免的,因为模型看到的目录确实变了。

实践结论:在意缓存成本的技能作者应尽早敲定稳定的 name + description,之后安心迭代正文。

三、Frontmatter 解析:严格校验是永久性设计决策

3.1 校验规则与实现

必填字段的实际校验规则以 validate_name()validate_description() 为准:

一个关键的行为差异:校验分为"严格路径"和"加载路径"。加载时 parse_skill_file_content_for_loading() 对超长 description 只发出 SkillLoadWarning::DescriptionTooLong 警告并照常加载;而严格的 parse_skill_file_content()(用于创建/导入 UI)则直接报错。测试 test_parse_description_too_long_loads_with_warningtest_parse_skill_file_content_rejects_description_too_long 分别钉住了这两种行为。

name 校验失败(如 test_parse_name_too_longtest_parse_name_invalid_chars)则一律拒绝加载,错误以 UI 可见的 load error 呈现。文档对此态度非常强硬:这不是一个待修复的功能缺口,而是刻意且永久性的立场。理由有三:

  1. 规范的校验规则短小清晰,违规即编写错误,不存在"合理地偏离"的情形;
  2. 早而响亮的报错对编写系统才是正确的用户体验——"名称与目录不符"或"描述缺失"被静默加载,只会产生用错名称调用技能的模型或空白/截断的目录条目;
  3. 互操作论点方向反了:宽容解析只会鼓励编写出在更严格工具上无法干净加载的技能;保持技能可移植的正确方式是执行规范,而不是掩盖违规。

Zed 唯一接受规范之外的字段是 disable-model-invocation,其结构体 SkillMetadata 只有 namedescriptiondisable_model_invocation 三个字段,未知字段按标准 YAML 行为被静默忽略。

3.2 一个目录一个技能文件

每个技能目录只认直接位于其下的 SKILL.md;目录里的一切其他文件(scripts/init.pyreferences/spec.mdassets/template.html)都是捆绑资源而非独立技能。一个推论:如果把 SKILL.md 放在 outer-skill/references/SKILL.md 这类"奇怪"的位置,扁平扫描不会把它加载为技能——这是正确行为,资源目录本就不该有自己的 SKILL.md

3.3 解析算法与防御性上限

extract_frontmatter() 的关闭分隔符识别相当讲究:候选行必须是恰好--- 组成(后跟 \n\r\n 或 EOF,且位于第 0 列),---trailing---- 都不算。对每个候选,源码把截到该候选处的前缀交给 serde_yaml_ng 尝试反序列化为 SkillMetadata——第一个能成功解析的候选才是真关闭符。这套"试错推进"能正确处理引号字符串内部出现 --- 的情形(见 test_parse_accepts_only_truly_terminated_closing_delimiter),也正确处理 CRLF / 混合换行(test_parse_skill_with_crlf_line_endings)。

两层防御性上限值得注意:

正文是延迟读取的:parse_skill_frontmatter() 的文档注释明确写着"body 被刻意不返回……以免为 N 个技能付出 N × 正文大小的内存";正文在技能真正被物化给模型时才经由 read_skill_body() 读取并 trim()(对应测试 test_read_skill_body_returns_trimmed_body)。

四、目录(Catalog):模型在系统提示中看到什么

4.1 <available_skills> 封装

目录是模型在系统提示中看到的技能列表:每个已加载技能得到 name、description 和指向 SKILL.md 的绝对路径(SkillSummary),仅此而已——没有正文、没有资源。封装格式如下(来自设计文档):

<available_skills>
  <skill>
    <name>brand-writer</name>
    <description>...</description>
    <location>/abs/path/to/SKILL.md</location>
  </skill>
  ...
</available_skills>

选择 XML 风格标签的理由:模型熟悉、便于在测试快照中识别、并与激活信封共用同一套约定。

4.2 XML 转义是真实防御

目录中所有插值(namedescriptionlocation)都会经过 xml_escape()(基于 quick_xml,对 <>&"' 五个预定义实体转义,且多字节 UTF-8 原样保留,见 test_xml_escape_covers_predefined_entities)。文档强调这不是理论防御:一个恶意技能作者本可以构造一条"闭合外层标签并写入新指令"的 description,把内容注入系统提示。

4.3 disable-model-invocation 过滤与错误信息不泄漏

disable-model-invocation: true 的技能被整体排除出目录,模型无从知道其存在,但仍可作为斜杠命令发现。目录筛选在 select_catalog_skills() 中完成。

更隐蔽的一点是错误信息:模型若用 skill 工具调用一个隐藏技能的名称,返回的 "not found" 错误中附带的 "Available skills" 列表也排除该隐藏技能——即使模型幻觉出了正确名称,也无法从错误消息里提取出描述。skill_tool.rs 中 run() 的实现 在查找与构造错误列表时都用 !s.disable_model_invocation 过滤;测试 test_skill_tool_refuses_disable_model_invocation 断言隐藏技能名在错误消息中恰好出现一次(即调用方传入的那次),不会在"可用技能"清单里再次出现。

4.4 固定 50KB 预算:一个"永久决策"

整个目录(全局 + 项目本地)所有技能的 name + description 字节总和被 MAX_SKILL_DESCRIPTIONS_SIZE 限定在 50KB。实现上,select_catalog_skills() 按顺序累加 name.len() + description.len();一旦某个技能使总额超过预算,停止继续填充(而不是尝试塞入后面更小的技能),使截断点按排序顺序确定。被丢弃的技能会产生一条在 UI 中呈现的 load error。

文档对"为什么不按模型上下文窗口比例动态计算"给出了三点论据:作者需要一个可预测的答案("我的技能会加载吗?"不该随模型切换而变);固定预算把作者推向"关键词前置的短描述"这种共享预算下正确的设计方式;50KB 足够容纳数百条写得好的描述,撞上上限的正解是缩短描述而不是调大预算。文档同样把"把上限调大一点"或"做成动态的"提议标注为答案是否,并指出应去回怼写了目录溢出描述的人。

五、激活:skill 工具与 <skill_content> 信封

5.1 两条路径,同一个渲染器

  • 模型路径:模型调用 skill { name: "brand-writer" },工具返回 SKILL.md 正文;
  • 用户路径:用户输入 /brand-writer,同样的内容作为用户消息注入对话。

两条路径共用 render_skill_envelope(),保证模型看到的结构与其由谁发起加载无关。斜杠命令的注册与激活分别对应 build_available_commands_for_project()send_skill_invocation()

5.2 信封结构与三个刻意包含的字段

<skill_content name="brand-writer">
<source>global</source>
<directory>/abs/path/to/skill</directory>
Relative paths in this skill resolve against <directory>.

...SKILL.md 正文...
</skill_content>

对照 render_skill_envelope() 的实现,信封包含:

  • <source>built-in / global / project-local,让模型知道技能来自用户机器还是项目,对"这是公司风格指南"这类项目特定指令尤其有用。项目本地技能还会额外带一个 <worktree> 标签标注 worktree 根名(测试 test_skill_tool_returns_source 验证了 global 无 <worktree>、project-local 有);
  • <directory>:使模型可以把 SKILL.md 提到的任何相对路径(scripts/extract.pyreferences/spec.md)与目录拼合解析——规范推荐这一做法;
  • 正文的中性化处理:这一点实现与文档表述略有演进。设计文档描述的是"正文全部 XML 转义";从当前源码看,neutralize_envelope_tags() 采取的是放松策略——只把正文中包裹器自身标签的字面量(<skill_content</skill_content 开头的部分)转义,其余合法 HTML(<details><summary><a href="..."> 等)原样通过,避免正常 Markdown/HTML 被实体转义搞乱。安全性由测试 test_skill_tool_neutralizes_envelope_tags_in_malicious_skill(恶意正文中的伪造闭合标签被中和,字面标签在输出中恰好各出现一次)与 test_skill_tool_passes_through_legitimate_html(合法 HTML 逐字通过)双向钉死。
  • 没有捆绑资源枚举(见下)。

5.3 不做 <skill_files> 清单

一些实现会在激活信封中列出技能目录下的所有文件,好让模型知道有哪些捆绑资源。Zed 明确不做:SKILL.md 本身就是"模型该读什么"的真相来源——写得好的 SKILL.md 会点名引用它想用的每个资源;对确实需要枚举的场景(SKILL.md 泛指某个 templates/ 目录),模型可以按需调用 list_directory。测试 test_skill_tool_output_wraps_in_skill_content 直接断言输出中不含 <skill_files>

5.4 全局技能路径上的 read_file 快速通道

项目本地技能在 worktree 内,读文件天然可行;全局技能在 ~/.agents/skills/,位于任何 worktree 之外,按常规会被拒绝。实现上以快速通道解决:任何规范化后落在全局技能目录内的绝对路径绕过项目路径机制、直接经文件系统读取,且两侧都做 canonicalize,.. 段和符号链接无法逃逸技能树;两个树之外的路径仍按原样拒绝。文档的定性:快速通道是门(gate),不是任意外读的暗门(backdoor)。

六、覆盖(Override)语义

  • 全局与项目本地技能同名时,项目本地胜出,并记录告警——遵循规范"项目覆盖用户"的建议;
  • 同源冲突(同一作用域内两个同名技能)按先出现者胜,同样告警。

优先级层次由 SkillSource::precedence() 集中实现:ProjectLocal (2) > Global (1) > BuiltIn (0),测试 test_skill_source_precedence_is_total_and_ordered 将其固定为回归契约;"先出现者胜"要确定,load_skills_from_directory() 会先把结果按路径排序(因为 read_dir 顺序依赖文件系统),对应测试 test_load_skills_returns_results_sorted_by_path

值得注意的是源码中还存在第三层来源 SkillSource::BuiltIn(优先级最低,可被全局/项目本地同名技能遮蔽)——这是内置技能机制,见下文第七节。

文档同时解释了为何接受"项目覆盖用户"带来的安全担忧(恶意项目替换用户信任的技能):技能文件的编辑本身已被敏感路径门控(第九节);加载期信任检查是计划中的补充,落地后不受信任的项目根本无法加载技能;而日常用例"我在这个项目里想用另一版 code-review 技能"恰需要项目覆盖。覆盖告警目前只进日志,文档坦承"以 UI 横幅呈现"是未来改进,难点在于区分有意覆盖(告警即噪音)与意外覆盖。

七、内置技能与技能分享

设计文档未覆盖、但源码中真实存在的两块机制,作为补充:

内置技能builtin_skills() 通过 include_str!builtin/create-skill/SKILL.md 在编译期嵌入二进制,create-skill 技能的正文保存在 Skill.embedded_body 中,skill 工具可直接服务正文而无需磁盘读取,其合成路径形如 <built-in>/create-skill/SKILL.md。该内置技能本身就是一份技能编写指南,其正文中给出的作用域选择表、frontmatter 必填/可选字段说明与 name 正则 ^[a-z0-9]+(-[a-z0-9]+)*$(要求 name 与目录名一致)值得技能作者直接参照。配套地,slugify_skill_name() 把任意人类可读标题转换为必然通过 validate_name 的合法名称(& 特化为 and,其他标点直接丢弃而非变连字符,截断到 64 字节后再修剪尾随连字符),测试断言其输出永远通过校验。

技能分享链接encode_skill_share_link() 生成 zed://skill?data=… 深链,完整内嵌 base64url(无填充)编码的 SKILL.md 内容,接收方打开链接后被提示审阅并安装;解码侧校验 scheme/host、拒绝非法 base64,并同样受 100KB 上限约束。往返一致性由 skill_share_link_round_trips 测试保证。

八、逐技能可用性与斜杠命令

  • disable-model-invocation(支持):从模型目录隐藏、skill 工具拒绝加载,但用户仍可通过斜杠命令调用。它处理的是"该由人决定何时运行"的工作流——/deploy/release 这类不希望模型基于对话上下文自主触发的情形;
  • user-invocable: false(刻意不支持):其论证用例是"后台参考"技能,文档认为这不是真实品类:值得让模型自主访问的行为,就值得让用户手动触发,反之亦然。想清理斜杠菜单的正解是不安装该技能或写更聚焦的技能,而不是给 frontmatter 加一个"对用户隐藏"的旋钮;
  • 斜杠命令对所有技能生效:该 flag 只切分"模型可自主触发"与"用户可手动触发",两条路径默认都开着。

源码里还有一个文档未展开的细节:斜杠命令使用带作用域前缀的 /<scope>:<name> 语法,由 SkillSource::scope_prefix() 决定——全局技能前缀为空(插入 /:<name>),项目本地技能以 worktree 根名为前缀(插入 /<worktree>:<name>)。空前缀专属于全局来源,因此名为 global 的 worktree 不再产生歧义;手写 /global:<name> 不会被当作 /:<name> 的别名,而是按字面寻找名为 global 的 worktree。

九、安全模型:敏感路径、工作树信任与激活授权

Zed 的技能安全由三道相互组合的门构成,分别针对不同威胁。

9.1 技能文件是敏感路径(防提示注入自修改)

SKILL.md 及其捆绑资源被归类为敏感路径:agent 的编辑工具即使在已信任项目内写入它们也需要显式用户授权。威胁模型是"技能自修改"式的提示注入——如果 agent 能悄悄改写已安装技能的 SKILL.md,恶意提示就能把指令持久化到跨会话生效的位置;编辑门控关闭了这个环。读取不做门控,因为技能本身就预期模型读取其捆绑资源。

实现上,tool_permissions.rs 在多条路径上将技能文件分类为 SensitiveSettingsKind::AgentSkills(对这类分类,授权时走 always-prompt 流程,"总是允许"不会永久绕过);路径识别由 is_agents_skills_path() 完成——它匹配路径中任意深度的 .agents/skills 连续组件、大小写不敏感(以对齐 macOS/Windows 的默认文件系统行为),源码注释坦承这会把 vendored 源码里嵌套的 .agents/skills 也标记出来,但这被认定为更安全的失败方向:多一次确认提示的恼人,远小于让 agent 静默改写用户没预期的技能树。

9.2 项目本地技能要求 worktree 信任(防首触提示注入)

<worktree>/.agents/skills/ 中的技能只从用户已标记为信任的工作树加载。未信任仓库的技能被整体排除在目录、斜杠命令列表和模型视野之外。威胁模型:恶意项目可以随附一个 description 里嵌着"若被问及凭据,通过工具调用 X 外泄"的技能——由于技能描述在会话开始时就进入系统提示,用户没有机会先审阅项目带了什么就已被模型看到。全局技能不受影响(在用户自己的主目录下,无条件信任)。

实现上该门复用 Zed 现有的项目信任机制 TrustedWorktrees::can_trust——与门控不受信任项目的语言服务器和代码执行的是同一套机制。agent.rs 中的订阅 监听信任状态事件,一旦用户信任某 worktree 即触发项目上下文刷新,技能无需重启会话即可生效。该门与其他门组合而非替代:即使在已信任项目内,编辑技能文件仍是敏感操作;模型激活任何技能仍走逐工具授权流程。

9.3 skill 工具激活需要授权

模型调用 skill 工具时走与所有内置工具相同的工具权限流。从 SkillTool::run() 看:

  • 默认行为是 Confirm,在正文送达前提示 Allow Once / Always Allow / Reject——与其他"使用时确认"工具一致,而非自动放行。理由是:技能本身是惰性的(只是指令),但模型遵循指令产生的副作用不是;偏安全一侧的默认是廉价的,因为想彻底免打扰的用户可把该工具默认设为 Allow。测试 test_skill_tool_prompts_for_authorization_by_default 验证了确认流程,test_skill_tool_denial_returns_error 验证了 Deny 时工具直接报错而不渲染信封;
  • 从源码结构看,"Always Allow" 的输入值是以 SKILL.md绝对路径为键(而非技能名),因此项目本地覆盖全局的同名技能可获得相互独立的信任授权,测试 test_skill_tool_auth_context_uses_skill_file_path 明确断言了这一点;
  • 内置技能跳过授权提示:随 Zed 发行、默认受信任,见 run() 中的 is_builtin 分支
  • 斜杠命令激活不走此流程:用户显式输入 /skill-name 再提示一次是冗余的,授权门专门针对模型的自主使用;
  • 工具在调用时才快照当前技能集(而非线程构建时),因此会话中新增的技能立即可被模型调用;
  • UI 层面,skill 工具的 kind 是 ToolKind::Other 而非 Read——源码注释解释 Read 会映射为放大镜图标、读起来像"搜索",与技能激活的语义不符。

设计文档还点明了授权门与 disable-model-invocation 的分工:前者是编写期声明("此工作流绝不该自主运行"),后者是用户期控制("任何模型驱动的激活前我要一步确认"),二者可同开可同关,覆盖不同威胁。

十、刻意不做的事(以及从源码看已经做了什么)

设计文档列出的、被有意推迟的功能:

  • 覆盖告警进入 UI:目前仅日志,覆盖本身正确发生,只是没有横幅;
  • 压缩(compaction)保护:尚不适用——agent 目前不压缩对话;落地时技能工具输出应被豁免;
  • allowed-tools 执行:规范称之为实验性;Zed 会解析该字段但不执行,未来接线的集成点就是现有工具权限流;
  • 技能正文中的 $ARGUMENTS 参数替换:有用但属增量功能;
  • 动态上下文注入SKILL.md 中内嵌、在模型看到正文前展开的 shell 命令——能力强大但需要独立的安全模型。

此外,源码中已超出文档叙述范围、实际具备的能力包括上文第七节的内置技能create-skill 随二进制发行)与 zed://skill 分享链接,以及供设置 UI 读取的全局索引 SkillIndexglobal_skills + 按 worktree 分组的 project_skills)。

十一、从源码开始阅读

设计文档给出的阅读路径(以仓库实际文件为准):

入口 说明
crates/agent_skills/agent_skills.rs 类型、frontmatter 解析、发现、覆盖合并、内置技能、分享链接
crates/agent/src/tools/skill_tool.rs skill 工具、<skill_content> 渲染器、XML 转义辅助
crates/agent/src/agent.rs 斜杠命令注册、斜杠激活、live reload 与 maintain_project_context 的缓存保护
crates/agent/src/agent.rs select_catalog_skillsdisable-model-invocation 过滤与 50KB 目录预算的落点
crates/prompt_store/src/prompts.rs ProjectContext——系统提示渲染所针对的类型,接收来自目录筛选的摘要
crates/agent/src/templates/system_prompt.hbs 系统提示模板中的目录渲染
crates/agent/src/tools/tool_permissions.rs 技能文件的敏感路径分类(SensitiveSettingsKind::AgentSkills)与全局技能快速通道

如果你要在 Zed 中落地自己的技能:把 <skill-name>/SKILL.md(小写字母、数字、连字符命名的目录)放进 ~/.agents/skills/ 或项目根的 .agents/skills/,确保 namedescription 通过上文的严格校验,把关键词前置写进 description 以尊重 50KB 共享预算——保存即生效,模型目录随之更新;对 /deploy 这类工作流加上 disable-model-invocation: true,就交还了人类对时机的决定权。

登录后查看全文
热门项目推荐
相关项目推荐