返回课程首页

第 21 章

Skill 的测试、分发与治理

覆盖测试、包分发、冲突处理和安全治理。

源码基线:Pi v0.83.0 · commit 845d6ff

前四章已经完成 error-log-analysis Skill。现在准备把它交给团队使用。

这时真正的问题不再是“我的电脑上能不能跑”,而是:

  • 修改 Description 后,会不会不再触发?
  • 模型是否按顺序读取日志、运行脚本、输出报告?
  • 脚本报错或用户中断时,会不会继续做危险操作?
  • 团队成员拿到的是不是同一个版本?
  • 如果市场里的 Skill 不可信,桌面 Agent 应该怎样保护用户?

本章建立一条从测试到发布、再到治理的完整链路。

1. 先把“Skill 测试”拆成四层

flowchart LR
    A["加载测试<br/>文件能否成为 Skill"] --> B["展开测试<br/>正文是否进入消息"]
    B --> C["流程测试<br/>Tool 顺序与失败"]
    C --> D["模型评测<br/>语义触发准确率"]

四层回答不同问题:

层级 要验证什么 是否需要真实模型
加载 Frontmatter、路径、冲突、可见性 否
显式展开 AgentSession.prompt("/skill:name …") 或底层 AgentHarness.skill() 生成的消息 否
Agent 流程 Tool Call、Tool Result、错误和中断 否,Faux Provider 即可
自动匹配质量 真实模型是否读了正确 Skill 是,属于模型评测

不要用一个端到端用例包办全部层级。失败时你会不知道是加载错、Prompt 错、Tool 错,还是 模型选择不稳定。

2. Faux Provider 是什么

Pi 仓库的测试 Harness 调用 registerFauxProvider() 注册一个假的模型提供方,再用 setResponses() 放入预定响应:

const harness = await createHarness({ resourceLoader, tools });

harness.setResponses([
  fauxAssistantMessage(
    fauxToolCall("read", { path: skillPath }),
    { stopReason: "toolUse" },
  ),
  fauxAssistantMessage(
    fauxToolCall("analyze_log", { path: logPath }),
    { stopReason: "toolUse" },
  ),
  fauxAssistantMessage("发现 timeout 签名重复出现 12 次。"),
]);

await harness.session.prompt("分析这份日志");

它不会联网,也不消耗模型 Token。真正的 Agent Loop、Message、Tool 执行和 Event 仍然运行, 只有“模型下一步说什么”由测试预先决定。

sequenceDiagram
    participant T as 测试
    participant S as AgentSession
    participant F as Faux Provider
    participant X as Tool

    T->>F: setResponses(预定步骤)
    T->>S: prompt()
    S->>F: 模型请求
    F-->>S: 预定 Tool Call
    S->>X: execute()
    X-->>S: Tool Result
    S->>F: 下一次模型请求
    F-->>S: 预定回答

3. Pi 源码里的测试 Harness 做了什么

packages/coding-agent/test/suite/harness.ts 不是公开产品 API,而是 Pi 仓库自己的测试 基础设施。createHarness() 会:

  1. 创建临时目录;
  2. 注册 Faux Provider 和 Faux Model;
  3. 创建内存 SessionManager、SettingsManager、AuthStorage;
  4. 创建真正的 Agent 与 AgentSession;
  5. 注入测试 Resource Loader、Tool 和 Extension;
  6. 订阅全部 AgentSessionEvent;
  7. 在 cleanup() 中释放 Session、注销 Provider、删除临时目录。

因此下面的代码用于解释 Pi 自身怎样测试。若桌面 Agent 自己封装了 Runtime,应建立等价 Harness,而不是从已发布包中假定能导入这个仓库内部文件。这里的 suite Harness 只有 session、Faux Response 和 Event 等测试能力;它不是 packages/agent 中公开的 AgentHarness,也没有 skill() Method。

4. 第一组测试:Skill 能否正确加载

这一层直接测试 loadSkillsFromDir():

const result = loadSkillsFromDir({
  dir: skillDir,
  source: "test",
});

expect(result.skills).toHaveLength(1);
expect(result.skills[0].name).toBe("error-log-analysis");
expect(result.diagnostics).toEqual([]);

至少覆盖:

  • 合法 SKILL.md;
  • 缺失 Description;
  • 非法 YAML;
  • Name 与目录名不一致;
  • disable-model-invocation;
  • 嵌套目录;
  • 两个相同 Name 的 Skill。

Pi 的现有 skills.test.ts 就以 Fixture 测这些边界。发生同名冲突时,先进入 Map 的 Skill 保留,后来的被跳过并产生诊断。因此测试不仅要断言数量,还要断言赢家的 sourceInfo。

5. 第二组测试:显式调用一定展开正文

显式调用适合做稳定回归:

let modelSaw = "";

harness.setResponses([
  (context) => {
    const user = context.messages.find((message) => message.role === "user");
    modelSaw = user ? getMessageText(user) : "";
    return fauxAssistantMessage("ok");
  },
]);

await harness.session.prompt(
  "/skill:error-log-analysis 分析 server.log",
);

expect(modelSaw).toContain(
  '<skill name="error-log-analysis" location="',
);
expect(modelSaw).toContain("summarize-log.mjs");
expect(modelSaw).toContain("分析 server.log");

这个形状来自 Pi 自身的 agent-session-prompt.test.ts。它精确验证:

  • Skill 按 Name 找到;
  • Frontmatter 后的正文被包装;
  • 用户附加参数保留;
  • 展开发生在模型请求之前。

6. 怎样测试“正确触发”

这里必须先说清 Faux 的边界。

如果测试预先返回 read(SKILL.md),它证明的是:

当模型选择这个 Skill 时,Pi 能正确执行读取和后续流程。

它不能证明:

某个真实模型看到这句话时一定会选择这个 Skill。

所以“正确触发”分为两种测试:

确定性框架测试

捕获第一次模型请求,断言 System Prompt 中存在:

<name>error-log-analysis</name>
<description>...</description>
<location>...</location>

再让 Faux 返回 Read Tool Call,断言读取的绝对路径正是该 Skill。

真实模型评测

准备固定用例集:

[
  {
    "input": "分析 server.log 中反复超时的原因",
    "expectedSkill": "error-log-analysis"
  },
  {
    "input": "把这段英文翻译成中文",
    "expectedSkill": null
  }
]

在明确选择的模型和版本上重复运行,统计选择率。这个结果属于模型行为,不应写成 Pi Core 的保证。

7. 怎样测试“错误触发”

错误触发有两类:

  • False Positive:普通任务误读了日志 Skill;
  • Wrong Skill:日志任务读了另一个相似 Skill。

Faux 测试应先保证目录没有布线错误:

expect(systemPrompt).toContain("<name>error-log-analysis</name>");
expect(systemPrompt).not.toContain("<name>disabled-skill</name>");

真实模型评测再使用“相邻但不应触发”的输入:

  • “解释 HTTP 504 的一般含义”;
  • “生成一份空白事故报告模板”;
  • “把日志文件重命名”;
  • “分析一段没有日志的 TypeScript 报错描述”。

不要只测试正例。Description 越宽泛,False Positive 越容易被正例测试掩盖。

8. 测试 Tool 调用顺序

为 error-log-analysis 规定:

read SKILL.md
→ read 日志
→ 运行 summarize-log.mjs
→ read 报告格式
→ 输出结论

测试订阅 tool_execution_start:

const started: string[] = [];

harness.session.subscribe((event) => {
  if (event.type === "tool_execution_start") {
    started.push(event.toolName);
  }
});

await harness.session.prompt("分析日志");

expect(started).toEqual([
  "read",
  "read",
  "bash",
  "read",
]);

如果同一次模型响应发出多个 Tool Call,Pi Core 可以并行执行它们。需要严格顺序的步骤, Skill 应明确要求等待前一步结果,并让模型分多个 Turn 发调用;不能仅靠数组排列暗示依赖。

9. 不只检查 Tool Name

只断言 ["read", "bash"] 太弱,还要检查:

  • Argument 是否是预期绝对路径;
  • Script 是否使用固定入口;
  • Tool Result 是否被下一次模型请求看到;
  • 最终回答是否引用了 Result 中的证据;
  • 未授权目录有没有被访问。

桌面 Agent 可在测试 Tool 中记录:

const calls: Array<{ name: string; args: unknown }> = [];

然后对 Arguments 做结构化断言。安全测试尤其不能只看最终自然语言回答。

10. 测试脚本失败

让内置 Bash 以非零状态退出,或让自定义 Tool throw,再给 Faux 一个最终响应。断言:

  • tool_execution_end 标记错误;
  • Error Tool Result 进入后续 Context;
  • 后续没有执行会修改文件的 Tool;
  • 最终回答明确告诉用户失败,不能伪造报告。

如果自定义 Tool 只是成功返回一个带 error 字段的普通对象,Core 仍把它当成功 Result; 那是在测试“错误数据”,不是 isError=true 的 Tool Error。要形成 Tool Error,需要执行 抛错、参数校验/Tool 查找失败,或由相应 Hook 明确改写错误状态。

Skill 没有统一的 skill_error 事件。脚本失败仍是普通 Tool Error,所以测试要监听 Tool 事件和消息,而不是等待不存在的 Skill 生命周期。

11. 测试文件读取失败

需要分别测试两条路径:

路径 失败发生处 应观察什么
自动发现后读取 Read Tool Tool Error / Result
/skill:name 展开 AgentSession 同步读取 skill_expansion Error,原文本继续

这两个错误通道不同。若桌面 UI 把它们都展示为“Skill 启动失败”,可以统一用户体验,但测试 仍应保留底层原因。

12. 测试用户中断

构造一个等待 AbortSignal 的慢 Tool:

execute: async (_id, _params, signal) => {
  await waitUntilAborted(signal);
  throw new Error("aborted");
}

当收到 tool_execution_start 后调用 Session 的中断能力,并断言:

  • Slow Tool 收到 Abort Signal;
  • Agent Run 结束;
  • 下一 Turn 才会产生的后续危险 Tool 没有启动;
  • UI 不再显示“仍在运行”;
  • 临时文件得到清理。

不要只在 Tool 尚未开始时取消;最容易泄漏资源的是执行中断。默认 Parallel 的同一批 Tool Call 可能已经同时启动,Abort 不会追溯撤销已发生的副作用。若要断言“同批后续 Tool 未 启动”,测试必须明确使用 Sequential Execution;更简单的做法是把危险 Tool 放在下一次 Faux Response。

13. 一张最小测试矩阵

用例 加载 Prompt/展开 Tool 最终结果
正确显式调用 1 个 Skill 有 Skill Block 按顺序 有证据
正确自动路径 目录可见 有 Metadata Read 正确位置 完成
不相关任务 目录可见 有 Metadata 不读 Skill 普通回答
隐藏 Skill 已加载 目录无 Metadata 无自动读取 可显式调用
脚本失败 已加载 正常 Error 停止并说明
用户中断 已加载 正常 收到 Abort 无后续调用
同名冲突 2 个来源 只见赢家 不读输家 有诊断

这张表也适合成为桌面 Agent 的发布门禁。

14. 项目级与用户级 Skill

coding-agent 的默认发现入口包括:

项目:<cwd>/.pi/skills
项目:cwd 及祖先的 .agents/skills(到 Git Root 或 Filesystem Root,受 Trust 约束)
用户:~/.pi/agent/skills
用户:~/.agents/skills

.pi/skills 与 .agents/skills 的扫描细节和显式路径例外已在第 18 章展开;这里按治理范围 把它们都归为项目级或用户级。

项目目录适合:

  • 与仓库代码、日志格式和团队流程绑定;
  • 随代码审查;
  • 在 CI 中测试;
  • 团队共享同一版本。

用户目录适合:

  • 个人工作习惯;
  • 跨项目通用能力;
  • 不应提交到业务仓库的个人配置。

项目设置中的 Package 会在项目被 Trust 后补装。Trust 不是 Skill 内容审计的替代品;它只 是宿主决定是否接受这个项目环境的一道边界。

15. 把 Skill 放进 Pi Package

最小包:

my-log-skills/
├── package.json
└── skills/
    └── error-log-analysis/
        ├── SKILL.md
        ├── scripts/
        ├── references/
        └── assets/

显式 Manifest:

{
  "name": "@team/pi-log-skills",
  "version": "1.2.0",
  "keywords": ["pi-package"],
  "pi": {
    "skills": ["./skills"]
  }
}

没有 pi Manifest 时,Package Manager 也会按约定扫描顶层 skills/。Manifest 更适合 公开包,因为资源边界更明确,还能使用 Glob 和排除规则。

16. 三种 Package 来源

npm:@team/pi-log-skills@1.2.0
git:github.com/team/pi-log-skills@v1.2.0
./local/pi-log-skills
  • npm 带版本的 Spec 被 Pin,更新 Package 时跳过;
  • Git 的 Tag 或 Commit 是固定 Ref;Update 会校准到该 Ref,不会自动漂到更新 Ref;
  • Local Path 不复制文件,Settings 中的相对路径以该 Settings File 为基准解析。

想让团队复现,优先使用精确 npm 版本或 Git Commit/Tag。开发中的本地路径适合快速迭代, 不等于可复现发布。

17. 用户范围与项目范围安装

默认 pi install 写用户 Settings;-l 写项目 Settings:

用户:~/.pi/agent/settings.json
项目:.pi/settings.json

相应安装位置也不同:

来源 用户范围 项目范围
npm ~/.pi/agent/npm/ .pi/npm/
Git ~/.pi/agent/git/ .pi/git/

同一个 Package 同时出现在两个范围时,项目条目通常胜出;如果项目条目 autoload: false,它会作为对用户条目的差量配置。身份依据是 npm Package Name、去掉 Ref 的 Git Repository URL,或解析后的 Local Absolute Path。

18. 更新策略

发布前至少保存:

  • Package Version;
  • Git Commit;
  • 每个 Skill 的 Name 与内容 Hash;
  • 测试结果;
  • 权限清单;
  • Changelog。

更新流程建议:

flowchart LR
    A["发现新版本"] --> B["下载到隔离区"]
    B --> C["Diff Skill/脚本/依赖"]
    C --> D["自动测试"]
    D --> E{"权限扩大?"}
    E -->|是| F["重新请求用户批准"]
    E -->|否| G["灰度启用"]
    F --> G
    G --> H["保留可回退版本"]

流程图是桌面 Marketplace 的产品治理建议,不是 Pi Core 已内置的完整更新工作流。

19. 同名冲突怎样治理

Pi 的 Skill 合并语义是“先加载者保留”,并发出 Name Collision Diagnostic。不要把加载 顺序当作隐式版本选择器。

治理规则应当是:

  1. Name 带清楚的 Domain;
  2. 启动时把 Collision Diagnostic 提升到可见警告;
  3. 测试断言实际赢家的 SourceInfo;
  4. Marketplace 安装前显示将被覆盖或被跳过的资源;
  5. 用户明确选择后再调整启用范围或 Package Filter。

Pi Package Filter 可以用 skills: [] 禁用某 Package 的全部 Skill,或通过 Pattern、 +path、-path 缩小资源集合。

20. 不可信 Skill 的真实风险

Skill 是 Markdown,但不代表它只是文档。它可以指示模型:

  • 读取私密文件;
  • 把内容发送到外部服务;
  • 执行随包携带的二进制或脚本;
  • 修改、删除项目文件;
  • 忽略宿主安全规则;
  • 读取外部内容后继续接受 Prompt Injection。

Pi 文档明确警告:Package 拥有完整系统访问能力;Extension 能执行任意代码,Skill 能指示 模型执行包括启动程序在内的操作。安装前必须审查源码。

21. 脚本和外部资源

审查 Skill 时沿引用链继续:

SKILL.md
├── scripts/*.mjs
├── references/*.md
├── assets/*
├── package.json dependencies
└── 外部 URL / 下载内容

重点检查:

  • Script 是否读取 Environment Secret;
  • 是否访问网络、上传文件;
  • 是否使用未固定 Dependency;
  • 是否拼接未经处理的 Shell Argument;
  • 是否写入 Skill Directory 以外;
  • 外部文档是否会被当成可信指令;
  • 下载结果是否验证来源与 Hash。

“SKILL.md 看起来安全”不能推出它引用的脚本和依赖安全。

22. 桌面 Agent 的执行保护

Marketplace 不能只靠审核页面。Runtime 还应提供:

  • 最小 Tool 集;
  • 文件系统 Allowlist;
  • 网络 Domain Allowlist;
  • 敏感 Tool 的逐次审批;
  • 参数预览;
  • Timeout 与 Abort;
  • Output Size 限制;
  • Tool Call 审计日志;
  • Secret Redaction;
  • 临时工作目录。

这些保护主要由桌面宿主的 Tool Policy 与 Extension 实现。Skill 自己写“请先询问用户”只是 软约束,不能代替宿主强制审批。

23. Marketplace 最小数据模型

Pi 源码提供 Package 安装、来源与资源加载机制;下面是桌面 Marketplace 在其上需要增加的 产品层:

interface MarketplaceSkillRecord {
  packageSource: string;
  resolvedVersion: string;
  contentHash: string;
  publisher: string;
  requestedCapabilities: string[];
  reviewStatus: "unreviewed" | "reviewed" | "blocked";
  testReportId: string;
}

还应记录:

  • 发布者身份和签名;
  • Skill、Script、Dependency 的 Diff;
  • 支持的 Pi Version;
  • 自动与显式调用策略;
  • 已知风险和撤回状态;
  • 用户安装时批准的能力。

这些字段不是当前 Pi Package Manifest 的内置 Schema,不能把产品建议伪装成 Core API。

24. 安装与更新时的用户体验

安装前展示:

来源:npm:@team/pi-log-skills@1.2.0
包含:2 Skills、1 Script、0 Extensions
请求:读取项目日志;执行 node 脚本
网络:不需要
冲突:error-log-analysis 与用户级 Skill 同名

更新时展示“新增能力”,而不是只展示 Version Number。若新版本从“只读日志”变成“执行 Shell 并访问网络”,必须重新批准。

Pi Core 不会替桌面产品推导完整 Capability Manifest;Marketplace 需要静态扫描、人工声明 与运行时监测共同完成。

25. 从开发到发布的完整路径

flowchart TD
    A["编写 SKILL.md"] --> B["加载与 Frontmatter 测试"]
    B --> C["显式展开测试"]
    C --> D["Faux Tool 流程测试"]
    D --> E["真实模型触发评测"]
    E --> F["安全审查脚本与依赖"]
    F --> G["打包并固定版本"]
    G --> H["隔离安装与冲突检查"]
    H --> I["用户批准能力"]
    I --> J["灰度发布与审计"]
    J --> K["更新或回退"]

前四步找功能问题,中间三步建立供应链边界,最后三步处理真实使用中的风险。

26. 常见误区

“Faux 测试通过,说明模型一定会触发”

错误。Faux 的选择由测试预定。真实模型触发要另做评测。

“Skill 是 Markdown,所以安装没有风险”

错误。模型可以依据 Markdown 调用高权限 Tool,Skill 还可以携带脚本和外部引用。

“有项目 Trust 就不用审查 Package”

错误。Trust 决定是否接受项目环境,不证明每个 Package 内容可信。

“同名 Skill 会自动选最新版”

错误。Skill 合并是先到先得并产生诊断,不按 SemVer 选择。

“Git Tag 会被 Update 自动推进”

错误。固定 Ref 只会被校准到配置的 Ref;要换 Ref,需要更新安装来源。

“Marketplace 审核过就不需要 Runtime Policy”

错误。静态审核不能覆盖运行时 Argument、外部内容和用户数据。

27. 本章小结

  • Skill 测试应拆成加载、展开、流程和真实模型评测四层;
  • Faux Provider 让真正的 Agent Loop 在无网络条件下执行预定模型响应;
  • Faux 能验证目录、Tool 和错误布线,不能证明语义触发准确率;
  • Tool 顺序、Argument、Result、错误和 Abort 都要单独断言;
  • 项目级 Skill 适合团队流程,用户级 Skill 适合个人跨项目能力;
  • Pi Package 可从 npm、Git 或 Local Path 分发,并支持用户与项目范围;
  • 固定 npm Version 或 Git Ref 才能建立可复现基线;
  • 同名 Skill 先到先得,必须展示 Diagnostic,不能暗中依赖顺序;
  • 第三方 Skill、Script、Dependency 和外部内容都属于供应链风险;
  • Marketplace 治理是桌面宿主在 Pi Package 机制上增加的产品层;
  • 安全最终依赖宿主 Tool Policy、审批、隔离、审计和可回退更新。

28. 自测

  1. Skill 的四层测试分别回答什么问题?
  2. 为什么 Faux Provider 不能证明真实模型会正确触发?
  3. 怎样验证 /skill:name 在模型请求前已经展开?
  4. Tool 顺序测试为什么还要检查 Argument 和 Result?
  5. 自动读取失败与显式展开失败分别走什么通道?
  6. 中断测试至少要断言哪四件事?
  7. 项目级与用户级 Skill 各适合什么场景?
  8. npm Pin、Git Ref 和 Local Path 的更新语义有什么不同?
  9. 同名 Skill 冲突时 Pi 怎样选,桌面产品又该怎样展示?
  10. 为什么 Marketplace 审核不能代替 Runtime Tool Policy?