别让 Skill 成为系统里最脆的那一环
基于 SkillsBench 的深度评测指南 —— 不谈空泛概念,用科学方法让你的 Skills 经得起复用
最近在工作中,我经常会想去沉淀一些自己的 Skills —— 把常用的逻辑封装起来,后面可以复用,像搭积木一样构建 Agent。但是每次写完,脑子里隐隐就会冒出一个问题:这个 Skill 在别人用的时候,会不会教错人了?会不会跑不通呢?
我相信不止我一个人有这种焦虑。在构建 AI Agent 或复杂自动化流程时,Skills(技能/能力)是我们绕不开的概念。无论是调用内部 API、查询数据库,还是封装复杂的业务 Prompt,我们的初衷都很美好:沉淀可复用逻辑,像搭乐高一样构建智能体。
但现实往往很骨感。随着 Skills 越写越多,不少团队陷入了这样的困境:
我们不是不会写 Skills,而是不知道它什么时候会坏。
今天,我们不谈空泛的概念,而是结合斯坦福、CMU 等机构发布的重磅研究 SkillsBench,聊聊如何科学地评测 Skills,避免它们成为系统的"阿喀琉斯之踵"。
一、问题不是"不会写",而是"不知道它什么时候会坏"
在很多 AI 应用系统里,我们热衷于抽象 Skills。这样做的好处显而易见:解耦和复用。
但做着做着,你会发现一些诡异的现象:
这种状态的根源在于:我们只关注了 Skill 的"功能实现",却忽略了它的"安全性评估"。
graph LR
A[Skills 来源] --> B[精心策划的 Skills]
A --> C[模型自生成的 Skills]
B --> D[✅ 显著收益提升]
C --> E[❌ 性能下降 / 无效]
D --> F[可信赖的系统]
E --> G[隐性崩坏的系统]
图:Skills 来源对系统性能的影响(基于 SkillsBench 研究)
二、Skills 和普通代码最大的不同:它不是"纯"的
为什么传统的单元测试在 Skills 面前经常失效?因为 Skills 往往不是一个纯函数(Pure Function)。
普通函数的输入是明确的参数,而 Skills 的输入通常包含大量隐式上下文:
| 隐式输入 | 说明 | 影响程度 |
|---|---|---|
| Context(上下文) | 当前的对话历史、Agent 的内部状态 | 🔥🔥🔥 |
| Prompt(提示词) | 微小的变动可能导致截然不同的输出 | 🔥🔥🔥 |
| Memory(记忆) | 长期存储的状态信息 | 🔥🔥 |
| External System(外部系统) | 数据库、第三方 API 的实时响应 | 🔥🔥 |
这就导致了一个棘手的问题:
这也是为什么:
- Demo 跑通了 ≠ 生产环境没问题
- 单测通过了 ≠ 组合场景不翻车
graph TB
subgraph pure["纯函数"]
A1["输入 (x, y)"] --> B1["f(x, y) = z"]
B1 --> C1["输出 (z)"]
end
subgraph skill["Skill"]
A2["显式参数"] --> B2["Skill()"]
M2["上下文 Context"] --> B2
N2["提示词 Prompt"] --> B2
O2["记忆 Memory"] --> B2
P2["外部系统 API"] --> B2
B2 --> C2["输出 + 副作用"]
end
图:纯函数 vs Skill 的输入对比
三、最容易被忽视的三个"隐形炸弹"
在评测 Skills 时,以下三个坑出现的频率最高,杀伤力也最大。
1. "看起来是读操作,其实在写"
很多 Skills 披着"查询"的外衣,却在背地里做"写入"的事。比如查询用户信息的同时,顺手更新了 last_active_time。
问题在于:调用方对此一无所知。
结果就是:一个看似无害的 Skill 调用,悄悄污染了后续的整个行为链。这是最难排查的一类 Bug。
sequenceDiagram
participant Agent
participant Skill as getUserInfo
participant DB
Agent->>Skill: 查询用户信息(以为是只读)
Skill->>DB: SELECT * FROM users WHERE id = ?
DB-->>Skill: 返回用户数据
Skill->>DB: UPDATE users SET last_active = NOW() ❌
Note right of Agent: 调用方完全不知道
发生了写操作!
Skill-->>Agent: 返回用户数据
图:披着读操作外衣的隐式写入
2. 组合之后才出问题(1+1 < 0)
单个 Skill 的表现完美,但当它们串联起来却可能报错。这通常是因为:
- 上下文污染(Context Pollution):前一个 Skill 的输出干扰了后续 Skill 的判断
- 输出格式漂移:A Skill 返回 JSON,B Skill 期望纯文本
- 隐式依赖冲突:两个 Skill 都依赖同一个全局状态,但写入顺序不一致
3. 回归是"静默失败"的
最可怕的不是报错,而是无声的性能下降。你优化了一个 Skill:
- 没有报错 ✅
- 没有触发报警 ✅
- 只是效果变差了(比如多调用了一次 API,或响应延迟增加)❌
四、那到底应该怎么评测 Skills?
既然标准方法不管用,我们需要一套更贴近实战的评测策略。结合 OpenAI 和 LangChain 的最佳实践,以下是 5 个可落地的建议。
1. 建立"最小评测集"与 A/B 测试
不要只写 Demo,要为每一个核心 Skill 建立测试用例矩阵。更重要的是,引入 A/B 测试机制:对比 Agent 在"无 Skills"和"有 Skills"下的表现差异。
graph LR
subgraph ab["A/B 测试对比"]
A["Agent 无 Skills"] --> C["性能基准 Baseline"]
B["Agent 有 Skills"] --> D["性能增量 Delta"]
C --> E{Delta > 0 ?}
D --> E
E -->|是| F["✅ Skill 有效,保留"]
E -->|否| G["❌ Skill 无效,移除"]
end
图:通过 A/B 测试量化 Skill 的真实价值
2. 强制区分:纯逻辑 vs 有副作用
建议在代码层面或文档中给 Skills 打上标签:
| 类型 | 标签 | 特征 | 风险等级 |
|---|---|---|---|
| Pure Skill | pure |
无副作用,只读,可放心缓存和重试 | 🟢 低 |
| Side-effect Skill | side-effect |
会写 DB、写 Memory、调用支付接口等 | 🔴 高 |
并明确标注它会触碰哪些外部状态。这能让调用方在编排工作流时立刻意识到风险。
// ✅ 推荐:显式标注 Skill 类型
interface SkillDefinition {
name: string;
type: 'pure' | 'side-effect';
sideEffects?: string[]; // 声明会触碰哪些外部状态
dependencies?: string[]; // 声明依赖的其他 Skills
}
const getUserInfo: SkillDefinition = {
name: 'getUserInfo',
type: 'pure',
};
const updateUserActive: SkillDefinition = {
name: 'updateUserActive',
type: 'side-effect',
sideEffects: ['users.last_active_time'],
};
3. 做"前后状态对比"(State Diff)
这是排查隐式 Bug 的神器。在执行 Skill 前后,强制快照关键状态(Memory, Context, 全局配置),然后自动生成 Diff。
很多时候,你以为没改状态,Diff 会告诉你:"不,你改了"。
graph TD
A["执行 Skill 前"] --> B["快照 State_before"]
B --> C["执行 Skill"]
C --> D["快照 State_after"]
D --> E["Diff = State_after - State_before"]
E --> F{有变化?}
F -->|是| G["⚠️ 检查是否为预期变更"]
F -->|否| H["✅ 确认为 Pure 操作"]
图:State Diff 排查隐式副作用
4. 引入"轨迹级"回归测试
不要只检查最终结果,要检查 Trajectory(轨迹),即 Agent 的整个决策路径。
- Tool Selection:Agent 选择了哪些工具?
- Action Ordering:动作的执行顺序是否正确?
- Decision Points:关键决策节点的选择是否一致?
利用 Record/Replay(记录/回放)技术,捕获一次成功的执行轨迹,后续每次修改都进行回放对比。这能有效防止因 Prompt 微调导致的逻辑路径改变。
5. 日志一定要"可还原"
很多团队的日志只打了结果,这是不够的。一个合格的 Skill 日志应包含:
| 日志字段 | 说明 | 重要性 |
|---|---|---|
| 输入 | 含完整上下文(Context, Memory, Prompt) | 🔴 必需 |
| 调用链 | Tool 调用顺序、参数、响应 | 🔴 必需 |
| 输出 | 最终返回结果 + 状态变化 | 🔴 必需 |
| 状态变化 | Memory/DB/外部系统的变更记录 | 🟡 强烈建议 |
6. 给 Skill 写 Tests —— 像 Jest 一样定义输入输出
这是我在实践中摸索出来的一个想法:既然传统单测对 Skill 不完全管用,那我们能不能发明一种更适合 Skill 的"测试"方式?
灵感来自于前端开发中的 Jest 单元测试。我们写代码的时候,每个函数都会配上 describe 和 it,明确定义"什么输入 → 什么输出"。Skill 其实也可以这么做 —— 虽然不能 100% 覆盖所有场景,但至少能保证:在已知的场景下,这个 Skill 是可用的。
具体怎么做?每个 Skill 在定义时,强制要求附带一组 Examples(用例):
interface SkillExample {
description: string; // 用例描述:这个场景在做什么
input: { // 输入:包含上下文、参数、记忆
query: string;
context?: Record;
memory?: Record;
};
expectedOutput: { // 期望输出:明确、可校验
result: string;
sideEffects?: string[]; // 预期的副作用
};
}
interface SkillDefinition {
name: string;
type: 'pure' | 'side-effect';
sideEffects?: string[];
// ✨ 新增:必须附带至少一个 Example
examples: SkillExample[];
}
写出来的效果类似这样:
const searchKnowledgeBase: SkillDefinition = {
name: 'searchKnowledgeBase',
type: 'pure',
examples: [
{
description: '用户查询项目部署流程',
input: {
query: '项目怎么部署到生产环境?',
context: { projectName: 'my-app' },
},
expectedOutput: {
result: '从知识库检索到部署文档,返回步骤 1-5',
},
},
{
description: '查询不存在的内容',
input: {
query: '怎么发射火箭?',
context: { projectName: 'my-app' },
},
expectedOutput: {
result: '未检索到相关内容,返回兜底回复',
},
},
],
};
更进一步,这些 Examples 还能发挥三个作用:
- 文档即测试:Examples 既是使用文档,也是回归校验的基准,一举两得
- CI 守门员:每次修改 Skill 后,自动跑一遍 Examples,确保基础场景不被破坏
- 新手上车指南:其他开发者看到 Examples,立刻就能理解这个 Skill 能干什么、怎么用
五、一个简单但有效的起步方案
如果你现在的项目处于"裸奔"状态,不要试图一步到位搭建复杂的评测平台。建议你今晚就做这三件事:
这三步不需要任何额外的基础设施,但能立刻让你的系统稳定性上一个台阶。
graph LR
A["🔥 Top 5
核心 Skill"] --> B["📝 写至少 1 个
Example"]
B --> C["🔍 修改前后
跑 Examples 对比"]
C --> D["✅ 系统稳定性
显著提升"]
图:三步起步方案的执行路径
六、写在最后
Skills 的设计哲学很像城市建设:修路(写逻辑)很快,但维护下水道(副作用、状态管理)才是真正的挑战。
Skills 的问题,从来不是"写出来",而是"写出来之后还能不能被信任"。
而评测的本质,就是在回答这三个灵魂拷问:
如果这些问题没有答案,那么 Skills 写得越多,你的系统只会变得越脆弱。
如果这篇文章对你有启发,欢迎分享给你的团队。让更多人意识到:评测不是可选项,而是 Skills 工程化的必选项。