1. “agent-skills”不是功能模块,而是智能体能力的最小可交付单元
你第一次在 GitHub 仓库 README 里看到agent-skills这个词,大概率会下意识把它当成一个 npm 包名、CLI 工具名,或者某个框架里的插件目录。但实际翻开源码后你会发现:它既不导出 class,也不提供 CLI 入口,甚至没有 package.json 的 main 字段——它只是一组带严格契约的 TypeScript 文件集合,每个文件对应一个可被 LLM 调度、可被用户声明式调用、可独立测试验证的能力单元。
这正是当前智能体(Agent)工程实践中最常被误解的起点:我们总在讨论“如何让 Agent 更聪明”,却极少追问“聪明到底由什么构成”。答案不是模型参数量,也不是 prompt 工程技巧,而是skills 的颗粒度、契约清晰度与调度可靠性。agent-skills这个命名本身,就是在强调:技能(skill)是 Agent 架构中真正可复用、可替换、可灰度发布的原子单位,而非附属功能或工具封装。
我去年参与过三个不同规模的 Agent 项目,从内部提效工具到面向 C 端的 AI 助手,最终都收敛到同一个结论:90% 的 Agent 稳定性问题,根源不在 LLM 推理层,而在 skills 层的契约断裂。比如一个叫search-web的 skill,文档写的是“返回最多 5 条结果”,但实际实现中当网络超时就抛出未捕获异常;又比如send-emailskill 声称支持 HTML 内容,却在解析<img src="data:image/png;base64,...">时因 base64 解码失败而中断整个 workflow。这些都不是模型的问题,而是 skills 作为独立单元缺乏生产级契约保障。
所以当你看到热搜里反复出现的zcode cli、codex cli、reasonix 如何安装新 skills,本质是在问同一个问题:如何把一个具体能力(比如“查天气”“读 PDF”“生成周报”)变成可插拔、可验证、可版本管理的标准化构件?agent-skills就是这个标准化过程的落地载体——它不绑定任何特定框架(LangChain、LlamaIndex、AutoGen 都能接入),不强制依赖某家 API(OpenAI、DeepSeek、智谱、Minimax 只需适配 provider 层),甚至不规定必须用 TypeScript(Python/Go 实现同样遵循同一契约)。它的核心价值,是把“让 Agent 做事”这件事,从黑盒 prompt 调用,变成白盒函数调用。
提示:不要把
agent-skills当成一个要 npm install 的包。它更像一套接口规范(Interface Specification),就像 RESTful API 的 OpenAPI Spec,或 gRPC 的 .proto 文件。你 clone 下来的不是代码库,而是能力契约模板。
关键词里反复出现的CLI和slash commands,恰恰印证了这一范式的落地路径:用户通过/weather beijing这样的 slash command 触发 skill,CLI 工具则负责将命令解析为 skill 调用参数,并注入 runtime context(如用户身份、会话 ID、权限 scope)。而API这个词高频出现,则说明所有 skill 最终都必须暴露为可被 HTTP 或 WebSocket 调用的端点——不是为了对外服务,而是为了在 Agent 内部形成统一的、可观测的、可熔断的调用链路。
我见过太多团队在早期直接用fetch()调用第三方 API,结果当天气服务不可用时,整个 Agent 卡死在await fetch(...)上。而基于agent-skills范式,你会先定义weather.ts的输入输出 schema:
// skills/weather.ts export interface WeatherInput { city: string; unit?: 'c' | 'f'; } export interface WeatherOutput { temperature: number; condition: string; humidity: number; windSpeed: number; timestamp: string; } export async function execute(input: WeatherInput): Promise<WeatherOutput> { // 实际实现,含重试、降级、缓存逻辑 }这个文件本身不包含任何框架代码,但它定义了:
- 输入必须有
city字段(类型安全) - 输出必须包含
temperature等 5 个字段(结构确定) - 执行函数必须返回 Promise(异步契约)
- 错误必须被显式 throw(而非静默失败)
这才是agent-skills的真实面目:它用 TypeScript Interface + 显式函数签名,替代了模糊的 prompt 指令,把 AI 的“理解能力”锚定在可验证的代码契约上。后续所有 CLI 工具、slash command 解析器、API 网关,都只是这个契约的消费者。
2. 为什么所有热门 CLI 工具(zcode/codex/reasonix)都在围绕 skills 做封装?
如果你打开zcode cli的源码,会发现它的核心逻辑极其简单:读取本地skills/目录下的.ts文件,用 esbuild 编译成 ESM 模块,再根据文件名生成 slash command 映射表。比如skills/github-pr-stats.ts→/github-pr-stats。它不做任何 LLM 调度,不参与 prompt 构建,甚至不碰 token 计数——它只做一件事:把 skills 目录变成一个可执行的命令空间(Command Namespace)。
同理,codex cli的/compact命令,本质是调用skills/compact.ts的execute()函数,传入当前编辑器选中的文本;/model命令则触发skills/model-switch.ts,切换当前会话绑定的 LLM provider。它们不是在“控制 Agent”,而是在“调用 skills”。
这种设计背后,是工程实践倒逼出的分层共识:
- LLM 层:负责推理、规划、反思(Reasoning)
- Orchestration 层:负责 workflow 编排、memory 管理、tool calling 路由(如 LangGraph)
- Skills 层:负责具体动作执行(Action Execution)
而 CLI 工具,天然落在 Skills 层和 Orchestration 层之间——它既是 skills 的运行时容器,也是用户与 skills 的第一触点。所以zcode cli不需要知道你用的是 Claude 还是 DeepSeek,它只关心skills/search-web.ts是否导出了execute函数;reasonix之所以能“安装新 skills”,是因为它把 skills 目录当作插件市场,下载的 zip 包解压后只需满足export async function execute(...)这一契约即可生效。
我们来拆解一个真实场景:用户输入/resume optimize --job-title "Senior Frontend Engineer"。
这个命令的流转路径是:
- CLI 解析命令 → 提取 skill 名
resume、子命令optimize、flag--job-title - 加载
skills/resume.ts,调用其execute({ jobTitle: "Senior Frontend Engineer" }) resume.ts内部可能调用多个下游 API(ATS 评分接口、JD 分析服务、文案润色 LLM)- 所有调用结果被结构化为
ResumeOutput返回给 CLI - CLI 将输出格式化为 Markdown,插入聊天窗口
注意:整个过程中,LLM 只在第 3 步的“文案润色”环节参与,且是以 skills 的形式被另一个 skill 调用。也就是说,LLM 本身也可以是一个 skill——skills/llm-call.ts接收 prompt 和 model name,返回 completion,它和其他 skills 在调度层面完全平等。
这解释了为什么热搜里会出现claude agent skills: a first principles deep dive这样的长尾词:真正的深度,不在于怎么调用 Claude,而在于怎么把 Claude 的调用,封装成一个符合agent-skills契约的、可测试、可监控、可降级的 skill。比如:
// skills/llm-call.ts export interface LlmCallInput { model: string; // 'claude-3-haiku', 'deepseek-chat' messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>; temperature?: number; } export interface LlmCallOutput { content: string; usage: { inputTokens: number; outputTokens: number }; model: string; } export async function execute(input: LlmCallInput): Promise<LlmCallOutput> { const provider = getProvider(input.model); // 根据 model 名路由到不同 API try { const res = await provider.chat(input); return { content: res.content, usage: res.usage, model: input.model, }; } catch (err) { // 统一错误处理:记录 metrics、触发告警、返回 fallback 响应 throw new SkillError('LLM_CALL_FAILED', err.message); } }这个 skill 的价值,在于它把模型调用的复杂性(API key 管理、rate limit 处理、token 截断、fallback 机制)全部封装在内部,对外只暴露干净的execute()接口。当 DeepSeek 官方 API 出现no api key for provider route "deepseek-official"错误时,你只需修改getProvider()的实现,无需改动任何调用方代码。
注意:所有 CLI 工具的
/model参数,本质上都是在动态切换llm-call.ts中的provider实例。这就是为什么codex cli /model deepseek能立刻生效——它不是在改全局配置,而是在 runtime 重新绑定 skill 的依赖。
这也揭示了skills 开发与传统 API 开发的根本差异:
- 传统 API 是面向外部系统设计的,关注兼容性、版本号、HTTP 状态码
- skills 是面向 LLM 和 CLI 设计的,关注输入 schema 的可推理性、输出结构的可解析性、错误类型的可分类性
比如llm-call.ts的SkillError类型,必须包含code字段(如'LLM_CALL_FAILED'),因为 LLM 在 planning 阶段需要根据 error code 决策是否重试、降级或终止 workflow。如果只 thrownew Error("timeout"),LLM 无法区分这是网络超时还是模型拒绝响应。
3. skills 的契约设计:为什么 80% 的失败源于输入输出 schema 的模糊性?
我在三个项目中做过 A/B 测试:同一组 skills,一组用自由格式 JSON 描述输入输出(如{ "query": "string" }),另一组用 TypeScript Interface 严格定义。结果后者在 LLM tool calling 的准确率高出 37%,workflow 中断率下降 62%。这不是因为 LLM 更懂 TypeScript,而是因为严格的 schema 强制开发者思考边界条件,而模糊描述纵容了“差不多就行”的侥幸心理。
以热搜词find skills为例——这看似是个简单需求,但实际落地时,find到底指什么?
- 是全文本搜索(
/find "error 400")? - 是按标签过滤(
/find --tag "api")? - 是语义相似度匹配(
/find "how to handle rate limit")? - 还是跨 skills 调用链路追踪(
/find --trace "weather -> email")?
如果skills/find.ts的输入 interface 只写input: any,LLM 就可能把/find --tag api解析成{ tag: "api" },也可能解析成{ query: "--tag api" },甚至{ flags: ["--tag", "api"] }。而一旦解析错误,skill 内部的 if-else 判断就会失效,导致静默返回空结果或抛出难以定位的错误。
正确的做法,是用 TypeScript Interface 显式声明所有合法输入形态:
// skills/find.ts export interface FindInput { // 必填:搜索目标类型 target: 'skill' | 'command' | 'log' | 'error'; // 条件组合:支持 AND/OR 逻辑 conditions: Array<{ field: 'name' | 'tag' | 'status' | 'timestamp'; operator: 'eq' | 'contains' | 'gt' | 'lt'; value: string | number | boolean; }>; // 分页与排序 limit?: number; offset?: number; orderBy?: { field: string; direction: 'asc' | 'desc' }; } export interface FindOutput { results: Array<{ id: string; type: 'skill' | 'command' | 'log'; metadata: Record<string, any>; relevanceScore?: number; }>; total: number; page: number; }这个 interface 的设计逻辑是:
target强制用户明确搜索意图(避免 LLM 自作主张)conditions用数组而非 object,支持任意数量的 AND 条件([{field:'tag',op:'eq',val:'api'}, {field:'status',op:'eq',val:'active'}])orderBy允许指定排序字段,但禁止传入 SQL 注入式字符串(field限定为白名单)relevanceScore是可选字段,便于后续扩展语义搜索
当 LLM 收到/find --tag "api" --status "active"时,它必须生成符合此 interface 的 JSON,否则 tool calling 会失败并触发 re-plan。这看似增加了 LLM 的负担,实则大幅降低了系统不确定性——因为失败是立即可见的(JSON Schema validation error),而不是隐藏在业务逻辑深处。
再看另一个高频问题:api error: 400 this model's maximum context length is 1048576 tokens。这个错误出现在deepseek api调用中,根源是 skills 层没有对输入做长度预检。正确做法是在llm-call.ts的execute()函数开头加入 token 预估:
export async function execute(input: LlmCallInput): Promise<LlmCallOutput> { // 预估 token 数量(使用 tiktoken 或类似库) const estimatedTokens = estimateTokens(input.messages); const modelMaxContext = getModelMaxContext(input.model); // 从配置读取 if (estimatedTokens > modelMaxContext * 0.9) { // 主动截断,而非等待 API 返回 400 const truncatedMessages = truncateMessages(input.messages, modelMaxContext * 0.8); console.warn(`Truncated messages from ${estimatedTokens} to ${estimateTokens(truncatedMessages)} tokens`); input.messages = truncatedMessages; } // 后续正常调用... }这里的关键洞察是:skills 必须承担输入净化责任,不能假设上游(LLM 或 CLI)已做好准备。因为 LLM 的 token 计数是估算值,且不同 tokenizer 结果不同;而 skills 作为执行单元,必须对自身行为的资源消耗有确定性认知。
同样的逻辑适用于permission denied while trying to connect to the docker api这类错误。它通常发生在skills/docker-run.ts中,当 skill 尝试连接本地 Docker daemon 时失败。如果 skill 的输入 interface 不包含dockerSocketPath?: string字段,那么它就只能硬编码/var/run/docker.sock,导致在非 Linux 环境(如 macOS 的 Docker Desktop)下必然失败。而加上该字段后,CLI 可以根据平台自动注入正确路径,skill 本身保持跨平台兼容。
提示:所有 skills 的输入 interface,必须回答三个问题:
- 哪些字段是必填的?(用
field: type表示)- 哪些字段有默认值?(用
field?: type = defaultValue表示)- 哪些字段需要运行时注入?(用
context: { userId: string; sessionId: string }表示)
忽略其中任一问题,都会在规模化后引发连锁故障。
4. 从零构建一个 production-ready skill:以github-pr-stats为例
现在我们动手实现一个真实可用的 skill:github-pr-stats.ts,它接收一个 GitHub 仓库 URL,返回该仓库最近 30 天的 PR 统计数据(平均合并时间、作者分布、评论数等)。这个 skill 会覆盖从开发、测试到部署的完整生命周期,展示agent-skills范式如何解决实际问题。
4.1 输入输出契约定义:先写 interface,再写实现
// skills/github-pr-stats.ts import { z } from 'zod'; // 使用 Zod 进行运行时校验(TypeScript interface 仅用于编译时) export const GithubPrStatsInputSchema = z.object({ repoUrl: z.string().url().describe('GitHub repository URL, e.g., https://github.com/microsoft/vscode'), days: z.number().int().min(1).max(365).default(30).describe('Number of days to look back'), includeDrafts: z.boolean().default(false).describe('Include draft PRs in statistics'), }); export type GithubPrStatsInput = z.infer<typeof GithubPrStatsInputSchema>; export const GithubPrStatsOutputSchema = z.object({ repo: z.object({ owner: z.string(), name: z.string(), }), stats: z.object({ totalPrs: z.number().int(), mergedPrs: z.number().int(), closedPrs: z.number().int(), avgMergeTimeHours: z.number().optional(), // 可能为 null(无足够数据) topAuthors: z.array( z.object({ login: z.string(), prCount: z.number().int(), mergeRate: z.number().min(0).max(1), }) ).max(10), avgCommentsPerPr: z.number(), }), timeframe: z.object({ start: z.string().datetime(), end: z.string().datetime(), }), }); export type GithubPrStatsOutput = z.infer<typeof GithubPrStatsOutputSchema>;为什么用 Zod 而不是纯 TypeScript interface?因为 interface 只在编译时生效,而 skills 运行时可能接收 JSON 字符串(来自 HTTP API 或 CLI 参数解析)。Zod 提供:
- 运行时 schema 校验(防止恶意输入)
- 清晰的错误信息(
"repoUrl must be a valid URL") - 默认值自动填充(
days未传时设为 30) - 字段描述用于 LLM 的 prompt 生成(
describe()的内容会被注入 system prompt)
4.2 核心实现:分离关注点,专注单一职责
// skills/github-pr-stats.ts import { Octokit } from '@octokit/rest'; import { GithubPrStatsInputSchema, GithubPrStatsOutputSchema } from './schema'; // 1. 依赖注入:避免硬编码 token 和 rate limit 策略 interface GithubPrStatsDeps { octokit: Octokit; cache: { get: (key: string) => Promise<any>; set: (key: string, value: any, ttlSeconds?: number) => Promise<void>; }; } // 2. 主执行函数 export async function execute( input: GithubPrStatsInput, deps: GithubPrStatsDeps ): Promise<GithubPrStatsOutput> { // Step 1: 输入校验(运行时) const parsedInput = GithubPrStatsInputSchema.parse(input); // Step 2: 解析仓库信息 const repoInfo = parseRepoUrl(parsedInput.repoUrl); if (!repoInfo) { throw new Error('Invalid GitHub repo URL'); } // Step 3: 从缓存获取或计算统计数据 const cacheKey = `pr-stats:${repoInfo.owner}/${repoInfo.name}:${parsedInput.days}`; const cached = await deps.cache.get(cacheKey); if (cached) { return GithubPrStatsOutputSchema.parse(cached); } // Step 4: 调用 GitHub API 获取 PR 列表 const prs = await fetchPrs(deps.octokit, repoInfo, parsedInput); // Step 5: 计算统计指标 const stats = calculateStats(prs, parsedInput); // Step 6: 缓存结果(TTL 1 小时) await deps.cache.set(cacheKey, stats, 3600); return GithubPrStatsOutputSchema.parse(stats); } // 辅助函数:解析 URL(提取 owner/name) function parseRepoUrl(url: string): { owner: string; name: string } | null { const match = url.match(/github\.com\/([^/]+)\/([^/]+)/i); if (!match) return null; return { owner: match[1], name: match[2] }; } // 辅助函数:获取 PR 列表(含分页、错误重试) async function fetchPrs( octokit: Octokit, repoInfo: { owner: string; name: string }, input: GithubPrStatsInput ): Promise<Array<any>> { const now = new Date(); const since = new Date(now.getTime() - input.days * 24 * 60 * 60 * 1000); let allPrs: Array<any> = []; let page = 1; const perPage = 100; while (true) { try { const { data } = await octokit.paginate( octokit.pulls.list, { owner: repoInfo.owner, repo: repoInfo.name, state: 'all', sort: 'updated', direction: 'desc', per_page: perPage, page, since: since.toISOString(), }, (response) => response.data ); allPrs = allPrs.concat(data); if (data.length < perPage) break; page++; } catch (err) { // GitHub API 限流时,等待后重试 if (err.status === 403 && err.message.includes('rate limit')) { const resetTime = parseInt(err.headers?.['x-ratelimit-reset'] || '0', 10) * 1000; const waitMs = Math.max(resetTime - Date.now() + 1000, 1000); await new Promise(resolve => setTimeout(resolve, waitMs)); continue; } throw err; } } return allPrs.filter(pr => !input.includeDrafts || !pr.draft ); } // 辅助函数:计算统计指标(纯函数,无副作用) function calculateStats(prs: Array<any>, input: GithubPrStatsInput): GithubPrStatsOutput { const now = new Date(); const start = new Date(now.getTime() - input.days * 24 * 60 * 60 * 1000); const mergedPrs = prs.filter(pr => pr.merged_at && new Date(pr.merged_at) >= start); const closedPrs = prs.filter(pr => pr.closed_at && !pr.merged_at && new Date(pr.closed_at) >= start); // 计算平均合并时间(小时) let totalMergeTimeHours = 0; let mergeCount = 0; mergedPrs.forEach(pr => { const createdAt = new Date(pr.created_at); const mergedAt = new Date(pr.merged_at); if (mergedAt >= start && mergedAt >= createdAt) { const diffHours = (mergedAt.getTime() - createdAt.getTime()) / (1000 * 60 * 60); totalMergeTimeHours += diffHours; mergeCount++; } }); // 作者分布 const authorMap: Record<string, { count: number; merged: number }> = {}; prs.forEach(pr => { const login = pr.user?.login || 'unknown'; if (!authorMap[login]) { authorMap[login] = { count: 0, merged: 0 }; } authorMap[login].count++; if (pr.merged_at) authorMap[login].merged++; }); const topAuthors = Object.entries(authorMap) .map(([login, { count, merged }]) => ({ login, prCount: count, mergeRate: count > 0 ? merged / count : 0, })) .sort((a, b) => b.prCount - a.prCount) .slice(0, 10); return { repo: { owner: prs[0]?.base?.repo?.owner?.login || '', name: prs[0]?.base?.repo?.name || '' }, stats: { totalPrs: prs.length, mergedPrs: mergedPrs.length, closedPrs: closedPrs.length, avgMergeTimeHours: mergeCount > 0 ? totalMergeTimeHours / mergeCount : undefined, topAuthors, avgCommentsPerPr: prs.reduce((sum, pr) => sum + (pr.comments || 0), 0) / (prs.length || 1), }, timeframe: { start: start.toISOString(), end: now.toISOString(), }, }; }这个实现的关键设计点:
- 依赖注入:
octokit和cache作为参数传入,便于单元测试 mock - 纯函数式计算:
calculateStats不依赖外部状态,输入相同则输出确定 - 错误防御:GitHub API 限流时主动 sleep,避免请求风暴
- 缓存策略:TTL 1 小时,平衡数据新鲜度与 API 成本
4.3 CLI 集成:让/github-pr-stats命令真正可用
zcode cli的 skills 加载逻辑如下(简化版):
// cli/skills-loader.ts import { glob } from 'glob'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; export async function loadSkills() { const skillsDir = join(dirname(fileURLToPath(import.meta.url)), '../skills'); const skillFiles = await glob(join(skillsDir, '**/*.ts'), { ignore: ['**/node_modules/**', '**/schema.ts'] }); const skills = new Map<string, { execute: Function }>(); for (const file of skillFiles) { // 动态导入(ESM 环境) const mod = await import(file); if (typeof mod.execute === 'function') { const skillName = file .replace(skillsDir, '') .replace(/\.ts$/, '') .replace(/^\//, '') .replace(/\//g, '-'); // skills/github-pr-stats.ts → github-pr-stats skills.set(skillName, mod); } } return skills; }当用户输入/github-pr-stats https://github.com/torvalds/linux --days 7时,CLI:
- 解析命令 →
skillName = 'github-pr-stats',args = ['https://github.com/torvalds/linux', '--days', '7'] - 调用
skills.get('github-pr-stats')?.execute() - 将
args转换为GithubPrStatsInput对象(需 CLI 自带参数解析器) - 注入依赖(
octokit实例、Redis 缓存客户端) - 执行并返回结果
4.4 测试与验证:用真实数据驱动开发
一个 production-ready skill 必须有三类测试:
- 单元测试:验证
calculateStats纯函数逻辑 - 集成测试:mock Octokit 调用,验证整个
execute()流程 - E2E 测试:用真实 GitHub token 调用,验证端到端正确性(每周运行一次)
单元测试示例(Vitest):
// skills/github-pr-stats.test.ts import { calculateStats } from './github-pr-stats'; test('calculateStats returns correct top authors', () => { const prs = [ { user: { login: 'alice' }, merged_at: '2024-01-01T10:00:00Z', comments: 5 }, { user: { login: 'bob' }, merged_at: '2024-01-01T11:00:00Z', comments: 3 }, { user: { login: 'alice' }, merged_at: '2024-01-01T12:00:00Z', comments: 2 }, ]; const result = calculateStats(prs, { days: 30, includeDrafts: false }); expect(result.stats.topAuthors).toHaveLength(2); expect(result.stats.topAuthors[0].login).toBe('alice'); expect(result.stats.topAuthors[0].prCount).toBe(2); expect(result.stats.avgCommentsPerPr).toBeCloseTo(3.33); });集成测试 mock Octokit:
import { Octokit } from '@octokit/rest'; import { execute } from './github-pr-stats'; vi.mock('@octokit/rest'); test('execute handles GitHub API pagination', async () => { const mockOctokit = new Octokit({ auth: 'fake-token' }); // Mock paginate to return 200 PRs across 2 pages vi.mocked(mockOctokit.paginate).mockImplementation(async (fn, options, mapFn) => { const data = Array.from({ length: 200 }, (_, i) => ({ number: i + 1, user: { login: i % 2 === 0 ? 'alice' : 'bob' }, merged_at: i % 2 === 0 ? '2024-01-01T00:00:00Z' : null, comments: 3, created_at: '2024-01-01T00:00:00Z', closed_at: null, draft: false, base: { repo: { owner: { login: 'test' }, name: 'repo' } } })); return data.slice(0, 100).concat(data.slice(100, 200)); // Simulate two pages }); const result = await execute( { repoUrl: 'https://github.com/test/repo', days: 30 }, { octokit: mockOctokit, cache: { get: vi.fn(), set: vi.fn() } } ); expect(result.stats.totalPrs).toBe(200); expect(result.stats.mergedPrs).toBe(100); });实操心得:我最初在
github-pr-stats中没加缓存,结果一个/github-pr-stats命令触发 5 次 GitHub API 请求(分页),在高并发时直接触发 rate limit。后来加上 Redis 缓存,QPS 提升 8 倍。这印证了 skills 的关键原则:每个 skill 必须对自己的资源消耗负责,不能假设调用方会做节流。
5. skills 的运维与演进:如何避免“技能沼泽”?
当团队积累超过 50 个 skills 时,一个新的问题浮现:skills/目录变成难以维护的“技能沼泽”——没人记得weather.ts和weather-forecast.ts的区别,send-email.ts的 SMTP 配置被硬编码在文件里,pdf-extract.ts因依赖旧版 pdf-lib 而无法升级 Node.js 版本。这并非技术问题,而是缺乏 skills 生命周期管理机制的必然结果。
我们为此建立了三项铁律:
5.1 技能版本化:每个 skill 必须有明确的 semver 版本
agent-skills不允许存在skills/weather.ts这样的裸文件。正确结构是:
skills/ ├── weather/ │ ├── v1.0.0.ts # 主版本,稳定 API │ ├── v1.1.0.ts # 向后兼容的增强(新增 unit 参数) │ └── v2.0.0.ts # 不兼容变更(输出结构重构) ├── github-pr-stats/ │ └── v1.2.3.ts └── schema.ts # 全局类型定义CLI 工具加载时,会优先使用最新 patch 版本(v1.1.3>v1.1.0),但允许用户显式指定版本:/weather@v1.0.0 beijing。这样,当v2.0.0引入 breaking change 时,老 workflow 仍可指定@v1.1.3继续运行。
版本化带来的直接好处是:可以对 skills 做灰度发布。比如先让 10% 的流量走weather@v2.0.0,监控错误率和延迟,达标后再全量。这在传统 monolith 架构中几乎不可能实现。
5.2 技能健康度仪表盘:用 metrics 驱动决策
每个 skill 必须上报四类 metrics:
skill_duration_ms:执行耗时(P50/P95/P99)skill_errors_total:按 error code 统计(如GITHUB_RATE_LIMIT,CACHE_MISS)skill_invocations_total:调用次数(区分 CLI / API / LLM 自动调用)skill_output_size_bytes:序列化后输出大小(防 OOM)
我们用 Prometheus + Grafana 搭建仪表盘,重点关注:
- 错误率突增:某 skill 的
GITHUB_RATE_LIMIT错误在 5 分钟内上升 10 倍 → 检查是否被恶意刷量 - P95 延迟飙升:
pdf-extract.tsP95 从 200ms 升至 2s → 检查依赖库内存泄漏 - 输出 size 异常:
search-web.ts输出从 5KB 涨到 5MB → 检查是否未截断 HTML 内容
这个仪表盘让我们在用户投诉前就发现deepseek api调用异常:llm-call.ts的DEEPSEEK_TIMEOUT错误率在凌晨 2 点集中爆发,排查发现是 DeepSeek 官方服务在该时段进行滚动更新,而我们的 skill 没有配置合理的 timeout(原为 60s,应设为 15s 并加 retry)。
5.3 技能弃用流程:优雅退出比强行删除更重要
当一个 skill 需要下线(如send-sms.ts因短信服务商停服),我们绝不直接删文件。标准流程是:
- 将 skill 文件重命名为 `send-sms