开源编程 Agent pi 的技能机制:怎么被发现、被筛选、装进上下文

2026-07-29

本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。

在 pi 里,技能不是插件,它是一段带 namedescription 的 markdown;常驻上下文的只有名字、描述和路径三行,正文要等模型自己调 read 才进来。 这句话决定了你调优的着力点:你写得再详尽的技能正文都不花钱,真正决定命中率的是那条不超过 1024 字符的描述,以及模型愿不愿意为它多走一次读文件。

站内的 Claude Code 技能机制 讲的是这一类能力包该怎么组织,Agent 记忆分层 讲的是长期知识该放在哪一层,两篇都是方法论。这篇不重复方法论,只做一件事:把 pi 这个具体项目的加载链路从磁盘一路跟到系统提示,看它每一步的判断依据是什么。pi 采用 MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026-07 在 GitHub 上约 8 万 star,代码你随时能自己打开对照。

一、技能在 pi 里的最小形态

一个技能就是一个目录,里面必须有 SKILL.md,其余文件随你安排。官方文档给的骨架是 scripts/ 放脚本、references/ 放按需加载的详细文档、assets/ 放模板,这三个目录名没有任何约束力,加载器不认识它们,只有 SKILL.md 是硬要求。

SKILL.md 的 frontmatter 里,namedescription 必填。文档列出的可选字段有 licensecompatibilitymetadataallowed-tools(标注为实验性)、disable-model-invocation。名字的规则很死:1 到 64 个字符,只允许小写字母、数字和连字符,不能以连字符开头或结尾,不能出现连续两个连字符。描述上限 1024 字符。

有意思的是这些规则被违反时会发生什么。看 packages/coding-agent/src/core/skills.ts 里的 loadSkillFromFile:名字太长、含大写、有连续连字符,全部只产出 warning 级别的诊断,技能照样加载。唯一的硬失败是描述缺失或只有空白——这时函数直接返回 { skill: null },这个技能不存在。文档里那句 “Exception: Skills with missing description are not loaded” 对应的就是这一行。

还有一层更松的处理:frontmatter 里干脆不写 name,也不构成问题。代码取名字的写法是 frontmatter.name || parentDirName,没写就拿 SKILL.md 所在目录的名字顶上,然后才把顶上来的这个名字丢进校验。也就是说”缺名字”这件事本身不产生任何诊断,只有当目录名本身带大写、带下划线、带连续连字符时,你才会看到一条 warning——而那条 warning 说的是名字不合规,不是名字缺失。你如果按”缺必填字段应该报错”的直觉去排查,会在诊断输出里扑空。

这个取舍很务实:名字不规范只影响可读性和命令名,描述缺失则意味着模型完全没有依据判断何时该用它,留着也是纯粹的上下文浪费。

pi 声明实现 Agent Skills 标准,但明确留了一处偏离:标准要求 name 与父目录名一致,pi 不强制。文档给的理由是共享技能目录会被多个 harness 使用,这条规则在那种场景下不合适。仓库里两份实现对此的态度还不一样——packages/agent/src/harness/skills.tsvalidateName 收了 parentDirName 参数,名字对不上会产出一条 warning;packages/coding-agent/src/core/skills.ts 的同名函数根本没有这个参数。文档描述的是后者的行为。

二、发现:pi 到底扫哪些目录

文档把技能来源分成五类:全局的 ~/.pi/agent/skills/~/.agents/skills/;项目级的 .pi/skills/.agents/skills/;包提供的 skills/ 目录或 package.json 里的 pi.skills 条目;设置文件里的 skills 数组;以及命令行的 --skill <path>

有三条容易被忽略的细节。

其一,项目级来源带信任门槛。packages/coding-agent/src/core/trust-manager.ts 里的 hasTrustRequiringProjectResources 会从 cwd 一路往上找 .agents/skills,找到就要求项目被信任;而 $HOME/.agents/skills 被显式排除在外,始终按用户级资源对待。在 package-manager.ts 里,项目侧的技能目录只有在 isProjectTrusted() 为真时才会被收集。你 clone 一个陌生仓库直接跑 pi,仓库里带的技能不会悄悄生效。

其二,.agents/skills 是沿祖先目录向上找的。collectAncestorAgentsSkillDirs 从起始目录逐级往上推,遇到 git 仓库根就停;不在仓库里则一路推到文件系统根。这意味着 monorepo 里放在仓库根的技能,在任何子包目录下工作都能被扫到。

其三,根层的散装 .md 文件待遇不同。文档写得很清楚:在 ~/.pi/agent/skills/.pi/skills/ 里,根目录下的单个 .md 文件会各自被当成一个技能;而在 ~/.agents/skills/ 和项目 .agents/skills/ 里,根层 .md 会被忽略。代码里对应的是 loadSkillsFromDirInternalincludeRootFiles 参数。

遍历规则本身也值得说。函数先扫一遍目录项找 SKILL.md,一旦找到就加载并直接 return——不再递归子目录。也就是说技能不能嵌套:父目录一旦成了技能根,它下面的子技能就永远看不见。没找到 SKILL.md 时才进第二轮,跳过点开头的目录和 node_modules,对子目录递归下去。

遍历过程还会读 ignore 文件。IGNORE_FILE_NAMES.gitignore.ignore.fdignore 三个,规则会按相对目录加上前缀再喂给 ignore 匹配器,! 开头的取反规则也做了保留。所以被 gitignore 掉的技能目录不会被加载,这一点在你把技能放进临时目录调试时经常撞到。

三、筛选:重名、软链和不想被自动调用的技能

loadSkillspackages/coding-agent/src/core/skills.ts 里做汇总,它维护两个结构:一个 Map<string, Skill> 按名字存,一个 Set<string>canonicalizePath 后的真实路径。

真实路径这个集合处理的是软链场景:同一个文件通过不同软链被扫到两次,第二次直接静默跳过,不产生任何诊断。这是”同一份东西”,没什么可报告的。

名字碰撞则不同。不同文件用了同一个 name,会推一条 type: "collision" 的诊断,里面明确记了 winnerPathloserPath,然后保留先加载的那一个。文档里”warn and keep the first skill found”就是这个行为。谁算”先”取决于收集顺序——在 package-manager.ts 汇总自动发现的资源时,项目侧的 .pi/skills.agents/skills 排在用户侧的 ~/.pi/agent/skills~/.agents/skills 之前。项目里的同名技能会盖住你全局那份。

还有一类筛选发生在更后面。frontmatter 里写 disable-model-invocation: true 的技能仍然会被加载、仍然出现在技能列表里,但 formatSkillsForPrompt 第一行就把它们过滤掉了。它们不进系统提示,只能由人通过 /skill:name 显式触发。这是个很实用的开关:那些”用错了代价很大”或者”描述天然含糊、容易误触发”的技能,可以留在库里但不让模型自主决定。

组成部分它负责什么对应仓库位置你什么时候会碰到它
harness 版加载器遍历目录、解析 frontmatter、产出技能与诊断,技能对象里带正文packages/agent/src/harness/skills.ts基于 agent 包自己搭 harness 时
coding-agent 版加载器多来源汇总、重名裁决、软链去重,技能对象只留元数据packages/coding-agent/src/core/skills.ts用 pi 命令行日常干活时
系统提示拼装决定技能段要不要追加、追加哪几个字段packages/coding-agent/src/core/system-prompt.ts排查”技能压根没被看见”
来源路径汇总决定哪些目录进扫描范围、按什么顺序进packages/coding-agent/src/core/package-manager.ts全局与项目技能打架时
项目信任判定项目级技能生效的前置条件packages/coding-agent/src/core/trust-manager.ts项目技能”莫名其妙不生效”
斜杠命令展开/skill:name 换成完整的技能块packages/coding-agent/src/core/agent-session.ts模型不肯自己去读 SKILL.md
命令行开关--skill--no-skills 的解析packages/coding-agent/src/cli/args.ts临时挂一个技能或整体关掉

四、装进上下文:只有三个字段是常驻的

formatSkillsForPrompt 的输出结构一眼能看完:几行说明文字,然后一个 <available_skills> 块,每个技能里只有 <name><description><location> 三项,全部走 XML 转义。说明文字里有一条特别关键——它告诉模型,技能文件里的相对路径要相对技能目录(也就是 SKILL.md 的父目录)解析,然后在工具命令里用绝对路径。技能里写相对路径能跑,靠的就是这句提示,不是什么路径重写机制。

技能正文不在系统提示里。文档把这个叫渐进披露:常驻的只有描述,完整指令按需加载。加载动作由模型自己发起,用的是普通的 read 工具——没有专门的”加载技能”工具。

这里有个容易漏的前提条件。在 system-prompt.ts 里,自定义系统提示的分支会先判断 read 工具是否可用,不可用就完全不追加技能段。逻辑上讲得通:连读文件的能力都没有,把技能列表放进去只是让模型看得见摸不着。你要是裁剪了工具集,技能会连带着一起消失。

文档对这套机制的短板说得很直白——模型不是每次都会去读。它给的兜底是提示词施压,或者直接用 /skill:name 命令强制加载。

强制加载这条路走的是 agent-session.ts 里的 _expandSkillCommand:按名字找到技能,读文件,剥掉 frontmatter,包成一个 <skill name="..." location="..."> 块,里面同样带一句”引用路径相对某个目录”。packages/agent/src/harness/skills.ts 导出的 formatSkillInvocation 是同一套格式,仓库测试里有它的完整期望字符串。这个命令默认开着,设置项是 enableSkillCommandssettings-manager.ts 里的读取处写的是 ?? true

还有个小细节能省你不少滚屏:packages/coding-agent/src/core/tools/read.ts 对文件名是 SKILL.md 的读取做了特判,在界面上折叠成一行 [skill] 目录名。你在会话记录里看到这行,就知道模型确实去加载技能正文了。

五、成本落在哪一步

把链路捋完,成本的分布就清楚了,而且它跟直觉不太一样。

技能数量的成本是线性且常驻的。每多一个可见技能,系统提示里就多一条名字加描述加路径。描述上限 1024 字符,你要是把它当摘要写满,几十个技能就能堆出可观的固定开销——而且这部分每一轮都在,谁也删不掉。这跟 上下文预算怎么定 里说的固定开销是一回事:先扣掉不可压缩的部分,剩下的才是你能腾挪的空间。

技能正文的成本是一次性且条件触发的。写多长都不进初始上下文,只在模型决定读的那一刻按实际长度进来。所以”技能正文写详细点”这件事的代价,远比”多加一个技能”要低。

真正贵的是判断失误。描述写模糊了,模型该用不用,你白白付了常驻成本还得手动补救;描述写得过于宽泛,模型见谁都读一遍,每次都多一轮工具调用和一整份正文。这跟 工具描述怎么写 面对的是同一个问题——描述是路由信号,不是文档摘要。文档给的正面例子是把能力和适用场景都写进去(提取 PDF 文本和表格、填表单、合并文件,用于处理 PDF 文档),反面例子是一句”Helps with PDFs”。

还有一笔隐性开销:技能列表变了,系统提示就变了。你在项目里加一个技能,前缀就跟之前的会话对不上。各家模型服务商对提示缓存的规则不同且会调整,以官方最新说明为准;另外,海外主流服务商官方对中国大陆存在区域限制、不支持直连,市面上存在第三方中转,本文不做背书也不给具体渠道。机制层面你只需要记住一点:技能列表属于系统提示的稳定段,别在会话中途反复折腾它。

六、边界与代价:这套设计明确不管什么

不管技能之间的依赖和顺序。 技能是扁平的一层,加载器扫到 SKILL.md 就停止递归,没有嵌套、没有分组、没有”先跑 A 再跑 B”的编排。要串起来,只能在技能正文里用自然语言写清楚。

不管技能内容的安全性。 文档在 Locations 一节顶部就挂了安全提示:技能可以指示模型执行任何动作,也可能包含模型会调用的可执行代码,使用前请审阅内容。项目信任机制拦的是”这个项目的技能要不要生效”,不是”这个技能干不干净”。allowed-tools 字段在文档里标着实验性。

不保证技能会被用上。 加载器只负责把描述放进上下文,之后是模型的自由裁量。文档自己承认模型不总会去读。要确定性,就得用 /skill:name,那已经是人在做决策了。

不做描述层面的校验。 名字有一整套正则和长度检查,描述只查是否为空和是否超长。“这条描述能不能让模型正确判断”没有任何自动化手段兜底,只能靠你自己回归测试。

版本与分发不在这一层。 加载器面对的是文件系统上的目录,谁把技能放进去、放的是哪个版本,是包管理和团队约定的事。

适用场景也因此清晰:它适合”数量可控、边界清楚、正文可以写厚”的能力包。如果你需要几百个技能按情境动态开关,扁平列表加常驻描述这套结构会先撑不住——那是检索问题,不是加载问题。

七、上手与避坑清单

描述缺失的技能会从列表里整个消失。 会踩是因为 loadSkillFromFile 对缺描述先推一条 description is required 的 warning 诊断、然后返回 null,加载流程不中断、不抛错,只是这个技能再也不会出现在后面的任何地方;而缺名字连诊断都没有,直接回落成父目录名。两者待遇完全不同,你按”必填字段缺了应该报错中断”的直觉去找往往找不到。避法是新增技能后先看诊断输出,确认它出现在技能列表里,再去调内容。

gitignore 会把技能一起 ignore 掉。 会踩是因为加载器主动读 .gitignore.ignore.fdignore 三种文件并应用规则,而你放技能的临时目录很可能正好被某条通配规则覆盖。避法是技能不生效时先确认它没被忽略规则命中,别一上来就怀疑 frontmatter。

父目录成了技能根,子技能就全废了。 会踩是因为扫描逻辑找到 SKILL.md 立刻返回、不再递归。避法是把技能目录放成平级的兄弟目录,不要在一个技能目录里再塞技能。

同名技能悄悄互相顶替。 会踩是因为碰撞只产生诊断、不中断加载,而保留的是先加载的那份,项目侧又排在用户侧前面——现象是”我改了全局技能怎么没反应”。避法是给技能名加上来源前缀之类的区分,以及看到碰撞诊断时去核对 winnerPath

克隆下来的仓库里的技能不会自动生效。 会踩是因为项目级来源受信任门槛约束,而你在自己长期用的仓库里从没见过这个环节。避法是新仓库第一次跑时留意信任提示,别把”技能没出现”归因到路径配置。

--no-skills 不会关掉 --skill 显式指定的路径。 会踩是因为名字看着像总开关,实际在 resource-loader.ts 里只有”关闭且没有显式路径”时才返回空集。避法是想彻底断干净就两个都别带显式路径,想留一个白名单就正好用这个组合。

别把 /skill:name 后面跟参数的拼接方式当接口用。 文档描述的追加形式,和 agent-session.ts_expandSkillCommand 实际拼出来的字符串并不完全对得上。避法是如果你的技能正文依赖参数出现在某个特定位置,自己打印一次实际内容确认,别照文档写死。

改工具集会连带干掉技能段。 会踩是因为技能段的追加条件里绑了 read 工具的可用性,这个耦合不在你调工具集时的思考路径上。避法是裁剪工具时把 read 留着,或者接受技能整体失效。

收束:接下来看哪个文件

技能这套东西的复杂度不在加载器里——加载器一共几百行,逻辑直白到可以一次读完。复杂度在你写的那条描述上:它是唯一常驻的路由信号,也是唯一没有自动化校验兜底的部分。

给你一份自检清单:技能出现在列表里了吗(描述别空);描述里同时写清了”做什么”和”什么时候用”吗;名字在全部来源里唯一吗;正文里的相对路径是相对 SKILL.md 所在目录写的吗;不该让模型自主触发的,有没有加 disable-model-invocation

要继续往下挖,按这个顺序读:先 packages/coding-agent/src/core/skills.ts 看完整链路,再 packages/coding-agent/src/core/system-prompt.ts 确认技能段的追加条件,最后 packages/coding-agent/src/core/package-manager.ts 看来源顺序——重名裁决的所有疑问都在那里能得到答案。想理解这套加载器如何被更上层的会话调度使用,可以对照 Agent 上下文管理 一起看。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的系统提示词是怎么拼出来的开源编程 Agent pi 的扩展加载器

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。