返回课程首页

21

Skill 的测试、分发与治理

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

源码基线:Pi v0.82.0 · commit 518855d

前四章已经完成 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. 创建内存 SessionManagerSettingsManagerAuthStorage
  4. 创建真正的 AgentAgentSession
  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?