一、引言:什么是 skill
前五篇文章讲了 pi agent 的核心骨架:调 LLM、流式事件、循环、工具、hook。这些机制让 agent 能干活、能扩展。但还有一种扩展方式不需要写代码、不需要注册工具、不需要实现接口------只需要写一个 Markdown 文件。
这就是 skill。
skill 是"按需加载的指令包"。它是一个目录,里面放着一个 SKILL.md 文件和可选的参考文档、脚本。agent 启动时只加载 skill 的名字和描述(约 100 token),写进 system prompt。当用户的任务匹配某个 skill 的描述时,LLM 自己用 read 工具读取 SKILL.md 全文,获取详细指令。如果指令里引用了更多文件,LLM 再按需读取。
这解决了一个核心问题:agent 的能力越多,system prompt 越长。如果所有领域知识都塞进 system prompt------代码审查流程、Git 工作流、部署规范、安全检查清单------token 浪费严重,LLM 注意力被分散。skill 把这些知识拆成独立包,只在需要时加载,不占常驻 context。
和前几种扩展方式的区别:
| 扩展方式 | 需要写代码? | 加载时机 | 改 agent loop? |
|---|---|---|---|
| 工具(AgentTool) | 是(实现 execute) | 始终传给 LLM | 不改 |
| hook(beforeToolCall 等) | 是(实现回调) | 固定点调用 | 不改 |
| skill | 否(只写 Markdown) | 按需 | 不改 |
skill 是最轻量的扩展方式------不改源码、不写代码、不注册工具,仅通过 Markdown 文件就能扩展 agent 的能力边界。
二、skill 文件格式
一个 skill 是一个目录,核心是 SKILL.md,可选附带参考文档和脚本:
bash
.pi/skills/
└── my-skill/ # 目录名 = skill name(必须和 frontmatter 的 name 一致)
├── SKILL.md # 必需------frontmatter + 指令正文
├── references/ # 可选------详细参考文档
│ ├── api-reference.md # LLM 按需读取
│ └── examples.md
└── scripts/ # 可选------可执行脚本
└── validate.py # LLM 通过 bash 执行,只有输出进 context
SKILL.md 结构
SKILL.md 由两部分组成------YAML frontmatter 和 Markdown 正文:
markdown
---
name: my-skill-name
description: Brief description of what this skill does and when to use it
---
# My Skill Name
## Instructions
[Clear, step-by-step guidance for the agent to follow]
## Examples
[Concrete examples of using this skill]
必填字段
name:
- 最多 64 个字符
- 只能包含小写字母、数字和连字符
- 不能以连字符开头或结尾,不能有连续连字符
- 必须和
SKILL.md所在目录名一致(pi 的loadSkillFromFile会校验)
description:
- 不能为空
- 最多 1024 个字符
- 必须同时说明"做什么"和"什么时候用"------这是 LLM 判断是否触发该 skill 的唯一依据
description 的写法是 skill 能否被正确触发的关键。好的 description 应该像这样:
makefile
description: 当用户要求代码审查时,按此 skill 的流程检查命名规范、错误处理、测试覆盖率。适用于 PR review、代码走查、质量检查。
而不是这样:
makefile
description: 代码审查工具
前者同时说明了"做什么"(检查命名/错误处理/测试覆盖率)和"什么时候用"(PR review / 代码走查 / 质量检查),LLM 能精准匹配。后者太模糊,LLM 可能在不该触发时触发,或该触发时没触发。
可选字段
disable-model-invocation :设为 true 时,该 skill 的描述不注入 system prompt------LLM 不会自动发现它。只能通过代码显式调用。适用于内部 skill 或实验性 skill。
正文写法
正文是 LLM 触发 skill 后读到的指令。写法建议:
- 步骤化:用有序列表写明工作流程,LLM 按步骤执行
- 引用外部文件 :复杂内容拆到
references/下,正文里写"For advanced usage, see REFERENCE.md" - 控制长度:正文建议 <5k token(约 3000 字中文)。超长内容拆到 references/ 里按需加载
完整的 skill 编写规范(字段约束、description 写法、正文结构、最佳实践),请参考 Claude 官方 Skill 编写指南。
三、加载与注册
skill 的加载分两个阶段:发现(遍历目录找 SKILL.md)和注册(存入 resources 供后续注入 system prompt)。
1. 发现:loadSkills
pi 的 loadSkills(agent/src/harness/skills.ts:49)负责从目录树中发现所有 skill:
typescript
// skills.ts:49-75
export async function loadSkills(
env: ExecutionEnv,
dirs: string | string[],
): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> {
const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = [];
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
const rootInfoResult = await env.fileInfo(dir);
if (!rootInfoResult.ok) {
if (rootInfoResult.error.code !== "not_found") {
diagnostics.push({ type: "warning", code: "file_info_failed", ... });
}
continue; // 目录不存在就跳过------不报错
}
const result = await loadSkillsFromDirInternal(env, rootInfoResult.value.path, true, ignore(), rootInfoResult.value.path);
skills.push(...result.skills);
diagnostics.push(...result.diagnostics);
}
return { skills, diagnostics };
}
核心逻辑在 loadSkillsFromDirInternal(skills.ts:103-175)------递归遍历目录:
typescript
// skills.ts:103-175(简化)
async function loadSkillsFromDirInternal(env, dir, includeRootFiles, ignoreMatcher, rootDir) {
// 1. 先找当前目录的 SKILL.md------找到了就加载,不递归子目录
const entries = await env.listDir(dir);
for (const entry of entries) {
if (entry.name !== "SKILL.md") continue;
const result = await loadSkillFromFile(env, entry.path);
if (result.skill) skills.push(result.skill);
return { skills, diagnostics }; // 有 SKILL.md 的目录就是一个 skill,不再往下找
}
// 2. 没有 SKILL.md------递归子目录
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
// 检查 ignore 文件(.gitignore / .ignore / .fdignore)
if (ignoreMatcher.ignores(entry.path)) continue;
if (entry.kind === "directory") {
const result = await loadSkillsFromDirInternal(env, entry.path, false, ignoreMatcher, rootDir);
skills.push(...result.skills);
}
}
return { skills, diagnostics };
}
两个关键设计:
"有 SKILL.md 就停" :遍历到一个目录时,先检查它有没有 SKILL.md。有就加载它、不再递归子目录。没有才继续往下找。这保证一个 skill 目录的子目录不会被误当成独立 skill。
ignore 文件支持 :遍历时读取 .gitignore / .ignore / .fdignore,匹配的路径跳过。这让用户能用 .gitignore 控制哪些 skill 被加载。
2. 解析:loadSkillFromFile
找到 SKILL.md 后,loadSkillFromFile(skills.ts:233-279)解析内容:
typescript
// skills.ts:233-279(简化)
async function loadSkillFromFile(env, filePath): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
const rawContent = await env.readTextFile(filePath);
// 解析 frontmatter(YAML)+ body(Markdown)
const { frontmatter, body } = parseFrontmatter(rawContent);
// name:优先用 frontmatter 的,没有就用目录名
const skillDir = dirname(filePath);
const parentDirName = basename(skillDir);
const name = frontmatter.name || parentDirName;
// 校验 name
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
}
// 校验 description
const description = frontmatter.description;
for (const error of validateDescription(description)) {
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
}
// description 为空 → 不加载这个 skill(但不报错,只记 warning)
if (!description || description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name, // skill 名
description, // 注入 system prompt 的描述
content: body, // SKILL.md 正文(触发后 LLM 读到的指令)
filePath, // 文件路径(LLM 用 read 工具读全文时用)
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
}
校验规则(skills.ts:281-301):
- name 校验:必须和目录名一致、≤64 字符、只能小写字母+数字+连字符、不能开头/结尾连字符、不能连续连字符
- description 校验:不能为空、≤1024 字符
校验失败不抛异常------只记 SkillDiagnostic warning,跳过该 skill。其他 skill 继续加载。这是容错设计------一个坏 skill 不影响整个 agent 启动。
3. 注册:ResourceLoader
pi coding-agent 的 ResourceLoader(coding-agent/src/core/resource-loader.ts:505-526)调 loadSkills 并存储结果:
typescript
// resource-loader.ts:505-526(简化)
private updateSkillsFromPaths(skillPaths: string[]) {
const skillsResult = loadSkills({
cwd: this.cwd,
agentDir: this.agentDir,
skillPaths,
});
this.skills = skillsResult.skills.map((skill) => ({
...skill,
sourceInfo: this.findSourceInfoForPath(skill.filePath, ...),
}));
this.skillDiagnostics = skillsResult.diagnostics;
}
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {
return { skills: this.skills, diagnostics: this.skillDiagnostics };
}
ResourceLoader 是 pi coding-agent 的资源管理器------启动时加载 skills、prompts、themes、extensions。加载完的 skills 存在 this.skills 里,通过 getSkills() 暴露。
AgentHarness(或 AgentSession)在构造 createTurnState 时从 resources.skills 读取 skills 列表,传给 formatSkillsForSystemPrompt(第 4 章讲)。没有独立的"注册"步骤------加载完就存着,构造 system prompt 时直接用。
四、注入 system prompt
skill 加载完后,它的 name + description 被格式化成 XML 块注入 system prompt。这是 LLM"发现"skill 的唯一途径。
1. formatSkillsForPrompt
pi coding-agent 的 formatSkillsForPrompt(coding-agent/src/core/skills.ts:335-361)负责把 skills 列表格式化成 system prompt 片段:
typescript
// skills.ts:335-361
export function formatSkillsForPrompt(skills: Skill[]): string {
const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
if (visibleSkills.length === 0) return "";
const lines = [
"\n\nThe following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its description.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
"",
"<available_skills>",
];
for (const skill of visibleSkills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
}
lines.push("</available_skills>");
return lines.join("\n");
}
输出长这样:
xml
The following skills provide specialized instructions for specific tasks.
Use the read tool to load a skill's file when the task matches its description.
When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.
<available_skills>
<skill>
<name>code-review</name>
<description>当用户要求代码审查时,按此 skill 的流程检查命名规范、错误处理、测试覆盖率。</description>
<location>/path/to/.pi/skills/code-review/SKILL.md</location>
</skill>
<skill>
<name>deploy-guide</name>
<description>部署到生产环境时,按此 skill 的检查清单验证配置、数据库迁移、回滚方案。</description>
<location>/path/to/.pi/skills/deploy-guide/SKILL.md</location>
</skill>
</available_skills>
2. 三个关键字段
每个 skill 在 system prompt 里有三个字段:
name:skill 名,LLM 用它引用 skilldescription:触发依据------LLM 拿用户的任务和这段描述做语义匹配,决定要不要读这个 skilllocation:SKILL.md的文件路径------LLM 决定读取时用read工具打开这个路径
注意:正文(content)不在 system prompt 里 。formatSkillsForPrompt 只输出 name + description + location,不输出 body。这就是"按需加载"------正文要等 LLM 主动 read 才进 context。
3. 两行关键指令
注入的 XML 块开头有两行指令:
vbnet
The following skills provide specialized instructions for specific tasks.
Use the read tool to load a skill's file when the task matches its description.
第一行告诉 LLM "下面这些是 skill"。 第二行告诉 LLM "任务匹配 description 时,用 read 工具读 SKILL.md 全文"------这是触发机制的关键。LLM 看到这段指令后,知道"如果用户任务匹配某个 skill 的 description,我应该 read 它的 SKILL.md"。
第三行处理相对路径------skill 正文里引用 references/xxx.md 时,LLM 要把它解析成相对于 SKILL.md 所在目录的绝对路径再 read。
4. disableModelInvocation 的作用
typescript
const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
disableModelInvocation: true 的 skill 被过滤掉 ------不注入 system prompt,LLM 看不到它。这种 skill 只能通过代码显式调用(AgentHarness 有 API 可以手动触发 skill),适用于:
- 内部 skill:只在特定流程里用,不希望 LLM 随意触发
- 实验性 skill:还没写好 description,不想让 LLM 误触发
- 安全敏感 skill:如部署 skill,只允许特定条件触发
5. 注入时机
formatSkillsForPrompt 在 buildSystemPrompt(coding-agent/src/core/system-prompt.ts:73, 165)里被调用:
typescript
// system-prompt.ts:70-73
if (hasRead && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}
两个条件:
hasRead:agent 有read工具。没有 read 工具的 agent 不能触发 skill(因为触发要 read SKILL.md),注入了也没用。skills.length > 0:有至少一个可见 skill。
注入发生在 buildSystemPrompt 构造 system prompt 时------文章 5 讲过,prepareNextTurn 每轮都会调 createTurnState,如果 systemPrompt 是函数(pi coding-agent 就是),每轮都重新构造。这意味着运行中新增的 skill 会在下一轮被注入------动态加载 skill 不需要重启 agent。
6. system prompt 对比
触发前(agent 启动时,skill 只有元数据在 system prompt 里):
xml
You are an expert coding assistant operating inside pi...
Available tools:
- read: Read file contents
- bash: Execute commands
- edit: Edit files
- write: Write files
Guidelines:
- Be concise in your responses
- Show file paths clearly when working with files
The following skills provide specialized instructions for specific tasks.
Use the read tool to load a skill's file when the task matches its description.
When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.
<available_skills>
<skill>
<name>code-review</name>
<description>当用户要求代码审查时,按此 skill 的流程检查命名规范、错误处理、测试覆盖率。</description>
<location>/project/.pi/skills/code-review/SKILL.md</location>
</skill>
</available_skills>
Current date: 2026-08-27
Current working directory: /project
此时 LLM 只知道有 code-review 这个 skill,但不知道里面的指令。token 成本:约 100 token。
用户说"帮我 review 这个 PR" → LLM 判断任务匹配 code-review 的 description → 调 read 工具读 SKILL.md:
markdown
[tool_call] read({ path: "/project/.pi/skills/code-review/SKILL.md" })
[tool_result] # 代码审查流程
## 步骤
1. 先用 bash 执行 `git diff main...HEAD` 获取完整 diff
2. 检查命名规范:
- 变量名使用 camelCase
- 常量使用 UPPER_SNAKE_CASE
- 文件名使用 kebab-case
3. 检查错误处理:
- async 函数有没有 try-catch
- 外部调用有没有超时处理
4. 检查测试覆盖率:
- 新增函数有没有对应测试
- 边界条件有没有覆盖
5. 输出审查报告,按严重程度分级:blocker / warning / suggestion
For detailed checklist, see [references/checklist.md](references/checklist.md).
SKILL.md 正文进入 context(约 500 token)。LLM 按正文里的步骤执行------先 git diff,再逐项检查。
如果 LLM 需要更详细的检查清单,再 read references/checklist.md:
css
[tool_call] read({ path: "/project/.pi/skills/code-review/references/checklist.md" })
token 成本对比:
| 阶段 | 进 context 的内容 | token |
|---|---|---|
| 启动 | name + description + location | ~100 |
| 触发 | + SKILL.md 正文 | ~600 |
| 深入 | + references/checklist.md | ~800 |
| 总计 | ~1500 |
如果不用 skill,把全部内容塞进 system prompt:每次调用都带 ~1500 token,即使任务和代码审查无关。10 个 skill 就是 15000 token 的浪费。skill 的按需加载让这 1500 token 只在真正需要时消耗。
五、三级加载机制
前四章分别讲了 skill 的格式、加载、注入。本章把它们串起来,看 skill 的内容如何分三级逐步进入 context------这就是 Claude 文档所说的"progressive disclosure"(渐进式披露)。
流程图
三级加载
| 级别 | 加载时机 | 内容 | token 成本 | 谁触发 |
|---|---|---|---|---|
| 1. 元数据 | agent 启动时 | name + description + location,注入 system prompt |
~100 token/skill | 框架(loadSkills + formatSkillsForPrompt) |
| 2. 指令 | LLM 判断任务匹配后 | SKILL.md 正文,LLM 用 read 工具读取 | <5k token | LLM(主动调 read) |
| 3. 资源 | 指令引用时 | references/ 下的文档 / scripts/ 下的脚本 |
按需 | LLM(按正文引用调 read / bash) |
第 1 级:元数据(始终加载)
agent 启动时 loadSkills 遍历 .pi/skills/,解析每个 SKILL.md 的 frontmatter,提取 name + description。formatSkillsForPrompt 把它们格式化成 <available_skills> XML 块注入 system prompt。
这一级始终加载------不管用户任务是什么,每个可见 skill 的 name + description 都在 system prompt 里。成本约 100 token/skill,10 个 skill 只占 1000 token。
这一级的作用是"让 LLM 知道有哪些 skill 存在"------但只看到名字和描述,不知道具体指令。
第 2 级:指令(触发时加载)
当用户任务匹配某个 skill 的 description 时,LLM 自己判断"我应该读这个 skill"。它调 read 工具读 SKILL.md 全文,正文进入 context。
这一级只在触发时加载------没有匹配的 skill 永远不读,不占 token。成本约 <5k token/skill,只在需要时消耗。
这一级的作用是"让 LLM 获取具体指令"------正文里有步骤、工作流、最佳实践、对外部文件的引用。
第 3 级:资源(按需加载)
正文里可能引用 references/xxx.md 或 scripts/validate.py。LLM 按需读取:
- 参考文档 :LLM 调
read工具读references/下的文件,内容进 context - 脚本 :LLM 调
bash工具执行scripts/下的脚本,只有输出进 context,脚本代码本身不进 context
这一级只在被引用时加载------没被正文引用的文件永远不读。一个 skill 可以带几十个参考文件,但只用到的才进 context。
这一级的作用是"让 LLM 获取详细信息"------API 文档、检查清单、示例数据,或通过脚本执行确定性操作。
为什么分三级
核心问题是context window 是稀缺资源。假设 agent 有 10 个 skill,每个 skill 的正文 + 参考文件共 5000 token:
| 方式 | 常驻 token | 触发后 token |
|---|---|---|
| 全塞 system prompt | 10 × 5000 = 50000 | 50000 |
| 三级加载 | 10 × 100 = 1000 | 1000 + 5000(触发的 1 个)= 6000 |
三级加载让常驻成本从 50000 降到 1000,触发后只多用 5000。LLM 的注意力不被无关 skill 干扰,token 预算留给真正的对话和工具结果。
和工具的对比
skill 的三级加载和工具调用有相似之处------都是 LLM 主动调 read 工具读文件。但机制不同:
| skill | 工具 | |
|---|---|---|
| 谁决定读什么 | LLM 看 system prompt 里的 description 判断 | LLM 看 system prompt 里的 tool description 判断 |
| 读什么 | 固定路径(SKILL.md 的 location) |
LLM 自己生成参数(如 read({ path: "..." })) |
| 读完后做什么 | 按正文指令执行工作流 | 用读到的内容回复用户 |
| 目的 | 注入领域知识 / 工作流 | 获取实时信息 |
skill 是"框架引导 LLM 去读特定文件",工具是"LLM 自主决定读什么"。skill 在 system prompt 里告诉 LLM"有这些指令包,匹配就读",工具在 system prompt 里告诉 LLM"你可以读任意文件"。
六、Q&A
Q1:在一个 skill 被加载后,是否可以继续加载其他 skill?
可以。skill 之间没有互斥。
LLM 在执行一个 skill 的指令过程中,可以同时触发另一个 skill。假设 agent 有 code-review 和 test-coverage 两个 skill,用户说"review 这个 PR,顺便看看测试覆盖率够不够"。LLM 可以:
- 先 read
code-review/SKILL.md→ 按流程执行 - 执行到"检查测试覆盖率"步骤时,发现
test-coverageskill 匹配 - 再 read
test-coverage/SKILL.md→ 按它的流程生成覆盖率报告 - 两个 skill 的指令同时在 context 里,LLM 综合两者执行
没有限制------不限制同时加载几个 skill、不限制加载顺序、不限制 skill 之间引用。唯一的约束是 context window------每个 skill 的正文 + 参考文件都占 token。但这是 LLM 自己的判断------如果它觉得不需要某个 skill 了,后续轮次不会重新 read。
和工具调用一样------LLM 可以在一轮里调多个工具,也可以在多轮里调不同工具。skill 的"加载"本质就是一次 read 工具调用,没有特殊的加载状态管理。
Q2:触发多个 skill 后,加载顺序是什么样的?
没有框架控制的顺序------完全由 LLM 自己决定。
skill 的加载本质是 LLM 调 read 工具。LLM 在一轮回复里可以产出多个 toolCall,顺序就是 LLM 生成的顺序。框架不干预。几种情况:
串行:LLM 先 read skill A → 拿到正文 → 下一轮按 A 的指令执行 → 执行过程中发现需要 skill B → read skill B。这是最常见的------LLM 读完一个 skill 后先理解内容,再决定要不要读下一个。
一轮同时 read 多个 :LLM 在一轮里同时产出两个 toolCall,executeToolCalls 按顺序执行,两个 skill 的正文在同一轮回填。但这种情况少见------LLM 通常不会一次赌多个 skill 都相关。
跨轮 read :turn 1 read skill A,turn 3 read skill B。skill 正文在 read 的那一轮进 context,之后一直在 context.messages 历史里,不需要重新 read。
关键点:顺序是 LLM 的语义判断,不是框架的调度。框架只负责"LLM 说 read,就执行 read"。
Q3:<available_skills> 始终存在 system prompt 里吗?会被更新吗?
是的,<available_skills> 始终在 system prompt 里,并且每轮更新。
pi 的 systemPrompt 可以是函数,prepareNextTurn 每轮都调 createTurnState → buildSystemPrompt() → formatSkillsForPrompt(skills)。所以每轮的 system prompt 都是重新构造的,包含最新的 skill 列表。
什么时候会变:
| 场景 | system prompt 里的 skills 变化 |
|---|---|
| 运行中新增 skill 文件 | 下一轮 <available_skills> 多一条 |
| 运行中删除 skill 文件 | 下一轮 <available_skills> 少一条 |
| skill 的 description 被修改 | 下一轮 description 文本更新 |
setTools 切换到 read-only 模式(无 read 工具) |
下一轮 <available_skills> 整块消失 |
skill 的 disableModelInvocation 被改 |
下一轮该 skill 出现/消失 |
但已读的 skill 正文不会消失------<available_skills> 是元数据,在 system prompt 里每轮重建。而 LLM 之前 read 进来的 SKILL.md 正文在 context.messages 历史里,不会因为 system prompt 重建而丢失。
Q4:哪些 skill 会出现在 <available_skills> 里?
一个字段决定------disableModelInvocation:
typescript
const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
展示的条件(缺一不可):skill 文件合法、description 非空、disableModelInvocation 不是 true、agent 有 read 工具。
不展示的情况:
| 情况 | 原因 |
|---|---|
disableModelInvocation: true |
被代码显式隐藏,只能通过 API 手动触发 |
| description 为空 | loadSkillFromFile 返回 skill: null,根本没进列表 |
| name 校验失败 | 同上------只记 warning,不加载 |
| 目录被 .gitignore 匹配 | 遍历时跳过 |
| 没有 read 工具 | buildSystemPrompt 里 hasRead 为 false,整块不注入 |
Q5:如果 skill 太多,会产生什么负面影响?
三个层面的问题,但 pi 的设计已经把影响压到最低。
token 成本------线性增长:每个可见 skill 占约 100 token。100 个 skill = 10000 token 常驻。和"全塞正文"比(100 × 5000 = 50 万 token,直接爆 context),10000 token 可控。
LLM 注意力分散------真正的风险:system prompt 里 100 个 description,LLM 每轮扫一遍判断匹配。描述越多,匹配精度越低------可能误触发(读了不相关的 skill,浪费 token)、漏触发(真正相关的被淹没)、决策延迟。这个问题没有框架层面的解决------pi 不做 skill 匹配过滤,完全交给 LLM 语义判断。
加载性能------影响小 :loadSkills 启动时遍历目录、读文件、解析 YAML。100 个 skill = 100 次文件读取,但这是一次性成本。
实际建议:
| skill 数量 | 影响 | 建议 |
|---|---|---|
| <20 | 无感 | 随便加 |
| 20-50 | token 可感知,注意力略有分散 | 定期清理不用的 skill |
| 50-100 | token 占用明显,误触发风险上升 | 用 disableModelInvocation 隐藏不常用的 |
| >100 | 严重影响 | 拆分到不同项目 / 按场景动态切换 |
一句话:token 成本是线性可预测的,注意力分散才是真正的风险。
七、下一章预告
下一篇文章将进入 pi 的 Extension API------.pi/extensions/ 目录下的扩展如何被加载、扩展如何注册自定义工具和 hook handler、扩展的生命周期管理,以及 Extension API 如何让第三方在不 fork pi 源码的前提下,完整地改变 agent 的工具集、行为和 UI 渲染。这是 pi "扩展无需 fork" 理念的最终落地机制------skill 注入知识,hook 拦截行为,extension 定义一切。