手撕 Agent Skills:用 TypeScript 从零实现一个最小 Skill 运行时
📖 摘要 :2026 年 9 月,Agent Skills 取代 MCP 成为 GitHub Trending 的新霸主------Matt Pocock 的
skills仓库单日涨星 24.7 万,Anthropic 也放出了官方规范。但绝大多数文章还停留在「SKILL.md 怎么写」。本文换一个工程视角:用 TypeScript 从零实现一个最小 Skills 运行时,覆盖「发现 → 解析 → 触发匹配 → 执行 → 校验」完整链路,并附带可直接npx tsx跑起来的 demo,帮你真正看懂 Skill 是怎么被「加载」和「调用」的。🏷️ 关键词:Agent Skills,LLM Agent,TypeScript,SKILL.md,AI 工程化
目录
- 一、背景与痛点
- 二、核心原理
- [2.1 什么是 Agent Skill](#2.1 什么是 Agent Skill)
- [2.2 Skill 运行时到底要干哪些活](#2.2 Skill 运行时到底要干哪些活)
- [2.3 一次 Skill 调用发生了什么](#2.3 一次 Skill 调用发生了什么)
- 三、实战:从零实现最小运行时
- 四、踩坑与优化
- 五、总结
一、背景与痛点
如果你用过 Claude Code、Codex CLI 这类编码 Agent,大概率见过 SKILL.md 这个文件。2025 年 Anthropic 把 Agent Skills 标准化为一套基于 Markdown 的规范,到 2026 年 9 月它彻底爆发:个人开发者把自己私有的 .agents 目录开源出来,官方也下场定标准,社区开始抢生态位。
为什么大家都在卷 Skills?因为两个真实痛点:
- 上下文窗口会被耗尽。长任务里,Agent 把历史对话全部塞回 prompt,token 飞速上涨、成本飙升、还容易「忘了之前定过什么」。把流程沉淀成 Skill,Agent 只在需要时加载对应那一份短文档,而不是每次都背一整本操作手册。
- Agent 容易「提前宣布胜利」。没有明确的成功标准,模型经常「我觉得改好了」就结束。Skill 里写死执行步骤 + 校验项,等于给 Agent 套上一个 checklist,逼它逐项对账。
所以 Skill 的本质不是「知识库」,而是把专家的「过程性知识」(该怎么做、做到什么程度算完)编码成 Agent 可随时按需加载的协议。它和 MCP 是互补的两层:MCP 解决「Agent 能调用什么工具接口」,Skills 解决「Agent 该按什么流程用这些工具」。
二、核心原理
2.1 什么是 Agent Skill
一个 Skill 通常就是一个 SKILL.md,由三段组成:
- 声明区(frontmatter) :
name/description/triggers(触发条件)。运行时靠它做匹配。 - 步骤区:把「怎么做」写成有序步骤,可绑定到真实代码 handler。
- 校验区(可选):成功标准,让 Agent 知道「做到什么程度算完」。
注意一个关键区别:frontmatter 里的是声明式知识(要做什么) ,步骤里的是过程式知识(怎么做),这两者要拆开,这是高质量 Skill 的共识写法。
2.2 Skill 运行时到底要干哪些活
所谓「运行时」,就是这段代码要做的事:
| 职责 | 说明 |
|---|---|
| 发现(Discovery) | 扫描某个目录,找到所有 *.md |
| 解析(Parse) | 把 Markdown 的 frontmatter 读成结构化 Skill 对象 |
| 匹配(Match) | 用户一句自然语言进来,判断该用哪个 Skill |
| 执行(Execute) | 回放步骤,或调用绑定的真实函数 |
| 校验(Verify) | 对照成功标准判断是否完成(demo 中简化为打印) |
2.3 一次 Skill 调用发生了什么
用户 query
│
▼
[Match] 遍历所有 Skill 的 triggers,做关键词打分
│ 命中 score 最高的那个
▼
[Execute] 打印 steps,若存在绑定 handler 则调用真实代码
│
▼
[Verify] 对照成功标准确认(demo 中打印结果即视为完成)
三、实战:从零实现最小运行时
3.1 项目结构
agent-skills-demo/
├── skill-runtime.ts # 最小运行时(解析+匹配+执行)
├── skills/
│ ├── date-formatter.md
│ ├── code-review.md
│ └── security-check.md
└── package.json
3.2 SKILL.md 格式约定
我们用一个极简子集,只依赖 frontmatter,无需任何第三方 YAML 库:
markdown
---
name: date-formatter
description: 将用户输入格式化为 ISO8601 标准时间字符串
triggers:
- 格式化
- 格式化时间
- 当前时间
- format date
steps:
- 解析用户输入的时间表达式,缺省用当前时间
- 校验解析结果,无效则明确报错
- 输出 ISO8601 字符串
---
3.3 解析器:把 Markdown 读成 Skill 对象
typescript
import * as fs from 'fs';
import * as path from 'path';
interface Skill {
name: string;
description: string;
triggers: string[];
steps: string[];
filePath: string;
}
// 解析 SKILL.md 的 frontmatter,返回 Skill 对象
function parseSkillMarkdown(raw: string): Skill | null {
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return null;
const fm = fmMatch[1];
const get = (key: string): string | undefined => {
const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : undefined;
};
// 解析 YAML 列表(triggers / steps),只认 "- xxx" 形式
const parseList = (key: string): string[] => {
const out: string[] = [];
let capture = false;
for (const line of fm.split('\n')) {
if (new RegExp(`^${key}:\\s*$`).test(line)) { capture = true; continue; }
if (capture) {
const item = line.match(/^\s*-\s+(.+)$/);
if (item) out.push(item[1].trim());
else if (/^[\w]+:/.test(line)) capture = false; // 遇到下一个 key 停止
}
}
return out;
};
const name = get('name');
if (!name) return null;
return {
name,
description: get('description') ?? '',
triggers: parseList('triggers'),
steps: parseList('steps'),
filePath: '',
};
}
// 扫描目录,收集所有 Skill
function loadSkills(dir: string): Skill[] {
if (!fs.existsSync(dir)) return [];
const skills: Skill[] = [];
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.md')) continue;
const fp = path.join(dir, file);
const skill = parseSkillMarkdown(fs.readFileSync(fp, 'utf-8'));
if (skill) { skill.filePath = fp; skills.push(skill); }
}
return skills;
}
3.4 触发器匹配:怎么知道该用哪个 Skill
匹配不用大模型,用最朴素的关键词打分就能跑通,而且快、零成本:
typescript
function matchSkill(skills: Skill[], query: string): Skill | null {
const q = query.toLowerCase();
let best: Skill | null = null;
let bestScore = 0;
for (const s of skills) {
let score = 0;
for (const t of s.triggers) {
const tl = t.toLowerCase();
if (q.includes(tl)) score += 2; // 整句命中,权重高
for (const w of tl.split(/[\s,,、]+/).filter(Boolean)) {
if (w.length > 1 && q.includes(w)) score += 1; // 词级重叠,权重低
}
}
if (score > bestScore) { bestScore = score; best = s; }
}
return best;
}
工程实践里,复杂场景会把这一步换成 embedding 向量检索(语义匹配),但「关键词打分兜底」永远值得保留------它是零依赖、可解释的保底逻辑。
3.5 执行器:把步骤真正跑起来
步骤可以是纯文本说明,也可以绑定真实代码。我们用一个 handlers 注册表把 Skill 名映射到函数,实现「过程性知识 → 真实执行」的闭环:
typescript
type Handler = (input: string) => string;
const handlers: Record<string, Handler> = {
'date-formatter': (input) => {
// 从自然语言里抽出日期片段,抽不到则默认当前时间
const m = input.match(/\d{4}[-/]\d{1,2}[-/]\d{1,2}([ T]\d{1,2}:\d{2}(:\d{2})?)?/);
const d = m ? new Date(m[0]) : new Date();
if (isNaN(d.getTime())) return '⚠️ 无法解析日期: ' + input;
return d.toISOString();
},
};
function executeSkill(skill: Skill, input: string): string {
console.log(`\n▶ 命中 Skill: ${skill.name}`);
console.log(` 描述: ${skill.description}`);
console.log(' 执行步骤:');
skill.steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
const handler = handlers[skill.name];
if (handler) {
const result = handler(input);
console.log(` 执行结果: ${result}`);
return result;
}
return '(该 Skill 仅含步骤说明,无绑定代码)';
}
3.6 跑通 demo
typescript
function main() {
const skillsDir = path.join(__dirname, 'skills');
const skills = loadSkills(skillsDir);
console.log(`已加载 ${skills.length} 个 Skill`);
const queries = [
'帮我格式化一下 2026-09-07 08:30:00',
'review 一下这段代码有没有问题',
'做一次安全检查,看看有没有注入风险',
];
for (const q of queries) {
const s = matchSkill(skills, q);
if (s) executeSkill(s, q);
else console.log(`\n· 未匹配到 Skill: ${q}`);
}
}
main();
package.json 只需:
json
{ "name": "agent-skills-demo", "type": "commonjs" }
运行:
bash
cd agent-skills-demo
npx tsx skill-runtime.ts
你会看到 3 句 query 分别命中 date-formatter、code-review、security-check 三个 Skill,其中 date-formatter 还真的输出了 ISO8601 时间字符串。从零到可运行,不到 120 行代码。
四、踩坑与优化
4.1 前端 matter 解析的坑
--- 在 Markdown 正文里也常见(如分割线),正则务必用 ^---\n 锚定文件头,否则会把正文误判成 frontmatter。生产环境建议直接用 js-yaml + 成熟的 frontmatter 库,不要自己造轮子解析复杂 YAML。
4.2 触发器怎么写才不误召回
- 写「意图」不写「关键词堆砌」 :
帮我格式化时间比时间, date, format更容易被真实 query 命中。 - 用同义词兜底 :中文场景加拼音/英文别名(
格式化时间+format date)。 - 避免过于宽泛的词 :
检查这种词会让一半 Skill 都被误召回。
4.3 安全红线:千万别 eval 外部 SKILL.md
Skill 文件很可能是动态加载甚至远程拉取的。永远不要把 SKILL.md 内容直接 eval / new Function 执行 。正确做法是:Skill 只描述「意图和步骤」,真实能力由你代码里白名单注册的 handlers 提供。这样即使有人塞进恶意 Skill,最多只是多打印几行步骤,动不了你的系统。
4.4 上下文压缩与成功标准
前面提到的「上下文压缩」是高阶玩法:Skill 执行完把长过程压成一份结构化状态文件(如 state.json),下次会话只加载这份摘要,而不是重读全部历史。再给每个 Skill 配一个 success_criteria 字段,执行器末尾对照检查------这是抑制「提前宣布胜利」最有效的一招。
五、总结
Agent Skills 之所以在 2026 年 9 月屠榜,是因为它把「模型能力趋同之后,真正的护城河在过程性知识」这件事变成了一套可复用、可分发、可标准化的协议。会写 Skill 的人,本质是在把自己的工程经验「封装成可增值的资产」。
今天我们手撕的最小运行时,已经跑通了「发现 → 解析 → 匹配 → 执行」四步。要上生产,你只需要补三件事:
- 匹配升级为 embedding 向量检索 + 关键词兜底;
- 加载支持远程/动态 Skill 仓库,并加签名校验;
- 执行接入真实工具(也就是和 MCP 打通:Skills 决定流程,MCP 提供工具)。
当你把团队里那些「只有老人懂的踩坑流程」都写成 Skill,新人接入、Agent 调用、跨项目复用,全都一次性解决。这,就是 Skills 经济的入口。