1. “agent-skills”不是项目名,而是一套可复用的智能体能力模块设计范式
你点开 GitHub 搜索agent-skills,大概率会看到零星几个冷门仓库,star 数个位数,README 简短得像草稿——这恰恰是它最真实的生存状态:它不是某个明星开源项目的官方子模块,也不是 npm 上打包好的@agent-skills/core,而是在 Nx + TypeScript + Node 生态中,一批有经验的工程团队在构建多智能体系统(multi-agent system)时,自发沉淀下来的一套能力组织契约。
我第一次见到这个词,是在给一家做工业设备预测性维护的客户做架构评审时。他们的 Nx 工作区里有个libs/agent-skills目录,下面没有index.ts,只有十几个按语义分组的子目录:file-access、http-client、llm-router、tool-calling、state-persistence、error-recovery……每个目录里是skill.spec.ts+skill.impl.ts+skill.schema.json的三件套。当时我就问:“这个agent-skills是你们自己定义的规范?”对方工程师笑了笑:“对,我们没把它发到 npm,因为一旦发出去,就变成别人要适配我们;现在它只是我们工作区里的一个‘内部协议’——只要新来的智能体模块遵守这个目录结构、接口签名和错误处理约定,就能被主调度器自动发现、加载、熔断、重试。”
这就是agent-skills的本质:它是一份轻量级、可执行的智能体能力接口说明书,而非一个运行时库。它解决的不是“如何让 AI 说话”,而是“如何让一百个不同来源、不同语言、不同部署方式的 AI 能力,在同一个 Node 进程里安全、可观测、可编排地共存”。
为什么必须用 TypeScript?因为技能模块之间不靠字符串通信,而靠类型契约。比如http-client技能导出的不是一个makeRequest()函数,而是一个HttpClientSkill类型,它强制包含inputSchema: z.object({ url: z.string().url(), method: z.enum(['GET','POST']) })和outputSchema: z.object({ status: z.number(), body: z.any() })。调度器在编译期就能校验:你传给它的参数是否满足inputSchema?你返回的结果是否符合outputSchema?这种静态保障,在动辄几十个技能协同的复杂工作流中,比任何运行时校验都可靠。
为什么绑定 Nx?因为agent-skills天然需要“模块自治 + 全局可见”。Nx 的 project graph 能自动识别libs/agent-skills/*下每个子模块的依赖关系;nx build agent-skills-http-client会生成独立的 ESM 包,供其他团队以import { HttpClientSkill } from '@myorg/agent-skills-http-client'方式消费;而nx affected --target=test又能精准只跑被修改技能的测试用例——这种开发体验,是单纯用npm link或 monorepo 手动管理永远达不到的。
提示:别急着
npm install agent-skills。目前全网没有权威的agent-skills官方包。所有高热度搜索词(如typescript ai、nx二次开发、typescript + nestjs)指向的,其实是开发者在落地这套范式时遇到的真实技术栈组合。你真正要学的,不是某个库的 API,而是如何用 TypeScript 的类型系统 + Nx 的工程化能力 + Node 的进程模型,把“AI 能力”从黑盒函数,变成可版本化、可测试、可替换的软件构件。
2. 从零搭建agent-skills工作区:Nx 初始化与技能目录骨架的深层逻辑
很多团队卡在第一步:不知道agent-skills目录该放在 Nx 工作区的什么位置,以及为什么不能直接nx g @nrwl/node:library agent-skills。答案藏在技能模块的生命周期里——它既不是纯工具库(不需要被外部项目直接 import),也不是应用(不启动 HTTP 服务),而是一种可插拔的运行时能力单元。这就决定了它的工程结构必须满足三个硬约束:
- 物理隔离性:每个技能必须有独立的
package.json,声明自己的peerDependencies(如zod版本、axios版本),避免因主调度器升级导致技能崩溃; - 类型可聚合性:所有技能的输入/输出 Schema 必须能被一个统一的类型注册中心收集,用于生成 OpenAPI 文档或 LLM 的 function calling 描述;
- 构建可并行性:当新增一个
database-query技能时,不应触发http-client的重新构建,否则 CI 时间会指数级增长。
因此,标准做法是:用 Nx 的@nrwl/js:library生成基础骨架,再手动注入技能专属结构。以下是我在三个不同客户项目中验证过的最小可行初始化流程:
2.1 创建 Nx 工作区并配置 TypeScript 严格模式
# 使用最新稳定版 Nx CLI(截至 2024 年中为 18.x) npx create-nx-workspace@latest my-agent-system \ --preset=apps-and-libs \ --cli=nx \ --nx-cloud=false \ --package-manager=pnpm cd my-agent-system # 强制启用 TypeScript 严格检查(关键!) npx nx g @nrwl/js:library agent-skills-base \ --directory=libs/agent-skills \ --buildable=true \ --publishable=false \ --importPath=@myorg/agent-skills-base此时libs/agent-skills/agent-skills-base目录下会生成标准库结构。但立刻删除src/lib/agent-skills-base.ts和src/index.ts—— 因为我们不需要导出一个“基类”,而需要一个“技能容器”。
2.2 构建技能目录树:为什么必须用skill.impl.ts而非index.ts
进入libs/agent-skills/agent-skills-base/src,创建如下结构:
libs/agent-skills/agent-skills-base/src/ ├── skills/ # 所有能力模块的根目录(物理隔离层) │ ├── http-client/ # 每个技能一个子目录 │ │ ├── skill.impl.ts # 技能主逻辑(必须导出 SkillImpl 类型) │ │ ├── skill.spec.ts # 技能元数据(名称、描述、版本、schema) │ │ └── skill.schema.json # JSON Schema 文件(供非 TS 环境消费) │ ├── file-access/ │ │ ├── skill.impl.ts │ │ ├── skill.spec.ts │ │ └── skill.schema.json │ └── ... └── types/ # 全局类型定义(非技能特有) └── skill.ts # SkillImpl, SkillSpec 等核心接口重点看skill.impl.ts的写法:
// libs/agent-skills/agent-skills-base/src/skills/http-client/skill.impl.ts import { z } from 'zod'; import { SkillImpl } from '../../../types/skill'; export const HttpClientSkill: SkillImpl = { // 运行时唯一标识,必须全局唯一且稳定(不能用中文或空格) id: 'http-client-v1', // 核心执行函数:输入必须是 Zod 解析后的对象,输出必须是 Promise<unknown> execute: async (input: z.infer<typeof inputSchema>) => { const response = await fetch(input.url, { method: input.method }); return { status: response.status, body: await response.json() }; }, // 输入 Schema:必须是 Zod 对象,且字段名需与 execute 参数名一致 inputSchema: z.object({ url: z.string().url(), method: z.enum(['GET', 'POST', 'PUT', 'DELETE']) }), // 输出 Schema:同理,必须精确描述返回结构 outputSchema: z.object({ status: z.number(), body: z.unknown() }) };为什么不用export default?因为default导出会破坏类型推导。当调度器扫描*.impl.ts文件时,它通过import * as skillModule from './skill.impl'获取模块对象,再通过skillModule.HttpClientSkill访问实例——这样 TypeScript 才能准确推导出SkillImpl类型,而不是any。
2.3skill.spec.ts:技能的“身份证”,不是可选配置
// libs/agent-skills/agent-skills-base/src/skills/http-client/skill.spec.ts import { SkillSpec } from '../../../types/skill'; export const HttpClientSkillSpec: SkillSpec = { // 名称:面向开发者的可读标识(可含空格、中文) name: 'HTTP 客户端', // 描述:一句话说明用途,用于生成文档或 LLM 提示词 description: '向任意 URL 发起 HTTP 请求,支持 GET/POST/PUT/DELETE 方法', // 版本:遵循语义化版本,影响调度器的兼容性策略 version: '1.2.0', // 分类标签:用于技能市场或 UI 筛选 tags: ['network', 'api'], // 依赖声明:明确列出此技能运行所需的 Node 内置模块或第三方包 dependencies: { 'node:fs': '>=18.0.0', 'axios': '^1.6.0' } };这个文件的存在,让调度器能在加载技能前做两件事:
- 检查当前 Node 版本是否满足
dependencies['node:fs']要求; - 验证
axios是否已安装且版本匹配(通过require.resolve('axios/package.json')读取版本号)。
注意:
skill.spec.ts中的version字段,必须与skill.impl.ts中id的后缀保持一致(如id: 'http-client-v1'对应version: '1.0.0')。这是人为约定,但极其重要——当调度器发现id为http-client-v1的技能其version升级到1.2.0时,它会自动触发缓存失效,避免旧版本技能被误用。
3. 技能调度器的核心实现:如何让agent-skills模块真正“活”起来
有了技能目录,下一步是让它们被发现、加载、执行。很多人以为调度器是个复杂框架,其实它的核心逻辑只有 87 行 TypeScript 代码(我删减了日志和错误处理后的精简版)。关键在于理解它的设计哲学:调度器不管理技能生命周期,只做三件事——发现、校验、调用。
3.1 技能发现:基于文件系统扫描,而非动态 import()
调度器绝不会写import('./skills/http-client/skill.impl'),因为:
- 动态 import 返回 Promise,增加异步复杂度;
- Webpack/Vite 等打包器会将所有
import()路径打包进 bundle,失去“按需加载”意义; - 更重要的是,它破坏了技能的物理隔离性——如果
http-client技能依赖zod,而调度器没装zod,import()就会失败。
正确做法是:用 Node 的fs.readdirSync同步扫描skills/**/skill.impl.ts,再用ts-node或swc编译后require()。以下是生产环境实测稳定的实现:
// apps/scheduler/src/lib/skill-discovery.ts import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // 1. 同步扫描所有 skill.impl.ts 文件路径 export function discoverSkills(skillRoot: string): string[] { const implFiles: string[] = []; function walk(dir: string) { const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { walk(fullPath); } else if (file === 'skill.impl.ts') { implFiles.push(fullPath); } } } walk(path.join(__dirname, '..', '..', '..', skillRoot)); return implFiles; } // 2. 安全 require:捕获所有可能的加载错误 export function loadSkillModule(implPath: string) { try { // 使用 require 加载已编译的 JS(不是 TS) // 前提:构建脚本已将 .ts 编译到 dist 目录 const jsPath = implPath.replace(/\.ts$/, '.js'); return require(jsPath); } catch (e) { console.error(`Failed to load skill from ${implPath}:`, e); return null; } }这个设计带来两个关键优势:
- 启动快:扫描 100 个技能目录耗时 < 5ms(Node 的
readdirSync极快); - 失败隔离:某个技能
require()失败,不影响其他技能加载。
3.2 技能校验:用 TypeScript 类型系统做运行时守门员
发现技能后,调度器必须确认它符合agent-skills协议。这里有个反直觉但至关重要的细节:校验不是在loadSkillModule()之后做,而是在require()之前,通过读取skill.spec.ts的 AST 完成。
为什么?因为skill.spec.ts是纯声明式 JSON-like 结构,没有副作用。而skill.impl.ts可能包含require('some-heavy-lib'),如果先加载再校验,就浪费了资源。
我们用@swc/core的parseSyncAPI 提前解析skill.spec.ts:
// apps/scheduler/src/lib/skill-validation.ts import { parseSync } from '@swc/core'; export interface SkillSpec { name: string; description: string; version: string; tags: string[]; dependencies: Record<string, string>; } export function validateSkillSpec(specPath: string): SkillSpec | null { try { const content = fs.readFileSync(specPath, 'utf8'); // 解析为 AST,提取 export const XXX: SkillSpec = {...} 中的字面量值 const ast = parseSync(content, { syntax: 'typescript', tsx: false }); // 此处省略 AST 遍历逻辑(实际使用 swc 的 visitor 模式) // 最终返回 { name, description, version, ... } 对象 return extractedSpec; } catch (e) { console.warn(`Invalid skill.spec.ts at ${specPath}:`, e); return null; } }只有validateSkillSpec()成功返回,调度器才调用loadSkillModule()加载对应的skill.impl.ts。这种“先验后载”策略,让调度器能在毫秒级内拒绝掉 90% 的非法技能(如 schema 缺失、version 格式错误)。
3.3 技能调用:带上下文的执行沙箱与熔断机制
最后是执行环节。agent-skills的execute函数签名是async (input: any) => Promise<any>,但真实调度器会给它注入三个隐式参数:
export interface SkillExecutionContext { // 当前请求的唯一 trace ID,用于全链路日志追踪 traceId: string; // 技能超时时间(毫秒),由调度器统一配置,非技能自身决定 timeoutMs: number; // 临时工作目录,技能可安全读写(如下载文件、缓存结果) tempDir: string; } // 调度器实际调用方式: await Promise.race([ skillImpl.execute(input, { traceId, timeoutMs, tempDir }), new Promise((_, reject) => setTimeout(() => reject(new Error('Skill timeout')), timeoutMs) ) ]);这个设计解决了三个高频痛点:
- 超时控制:避免某个技能卡死整个调度器(如
http-client遇到 DNS 故障); - 临时存储:技能无需关心
/tmp路径,调度器自动分配隔离目录(如/tmp/agent-skills-abc123/); - 可观测性:所有日志自动带上
traceId,可与前端请求、数据库操作日志关联。
实操心得:我在 Jetson Orin NX 边缘设备上部署时,发现
tempDir必须指向 NVMe SSD 而非 eMMC 存储。因为某些file-access技能会解压 500MB 的固件包,eMMC 的随机写入速度会导致超时。解决方案是在调度器启动时检测存储性能,并动态设置tempDir—— 这种硬件感知能力,是通用框架无法提供的,但agent-skills的开放架构让它成为可能。
4. 从开发到发布:semantic-release 如何为agent-skills模块注入可信度
当你的agent-skills目录里积累了 20+ 个技能,团队开始面临一个现实问题:如何让下游团队信任这些模块?npm publish一次发一个包太慢,git tag手动管理又容易出错。这时semantic-release不是锦上添花,而是维持协作信任的生命线。
但直接npx semantic-release会失败——因为agent-skills不是单个包,而是一组相互独立的库。我们必须改造它的行为:让 semantic-release 为每个技能子目录生成独立的 git tag 和 npm version,同时保证主工作区的package.json版本不变。
4.1 Nx 插件化配置:为每个技能定义 release 规则
在nx.json中添加自定义任务:
{ "tasksRunnerOptions": { "default": { "runner": "@nrwl/workspace/tasks-runners/default", "options": { "cacheableOperations": ["build", "test", "lint", "release"] } } }, "projects": { "agent-skills-base": { "targets": { "release": { "executor": "@nrwl/workspace:run-commands", "options": { "command": "npx semantic-release -d --branches main --ci false --no-ci --tag-format 'agent-skills-{name}-v{version}' --plugins '@semantic-release/commit-analyzer,@semantic-release/release-notes-generator,@semantic-release/npm,@semantic-release/github'", "cwd": "libs/agent-skills/agent-skills-base" } } } } } }关键参数解读:
--tag-format 'agent-skills-{name}-v{version}':生成类似agent-skills-http-client-v1.2.0的 tag,而非默认的v1.2.0;--no-ci:禁用 CI 检查,因为我们将在本地或专用发布机上运行;@semantic-release/npm插件会自动读取每个技能目录下的package.json,并只发布publishable: true的技能。
4.2 技能package.json的特殊写法:声明式发布控制
每个技能子目录(如libs/agent-skills/agent-skills-base/src/skills/http-client/)必须有一个package.json:
{ "name": "@myorg/agent-skills-http-client", "version": "0.0.0-semantically-released", "description": "HTTP 客户端技能模块", "main": "dist/skills/http-client/skill.impl.js", "types": "dist/skills/http-client/skill.impl.d.ts", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }, "peerDependencies": { "zod": "^3.22.0" } }注意version字段必须是"0.0.0-semantically-released"—— 这是 semantic-release 的约定,表示“此版本号将由 release 流程自动覆盖”。如果写成"1.0.0",semantic-release 会跳过发布。
4.3 提交信息规范:让机器读懂你的意图
agent-skills的发布完全依赖 commit message。我们强制使用 Conventional Commits 规范,并为技能模块定制关键词:
| 前缀 | 含义 | 示例 |
|---|---|---|
feat(http-client) | 新增 HTTP 客户端技能或重大功能 | feat(http-client): add support for multipart form data |
fix(file-access) | 修复文件访问技能的 bug | fix(file-access): handle permission denied on Windows |
chore(nx) | Nx 工作区配置变更 | chore(nx): upgrade to Nx 18.3.0 |
docs(skills) | 技能文档更新 | docs(skills): add Chinese README for database-query |
当提交feat(http-client): add retry logic时,semantic-release 会:
- 检测到
feat前缀,将http-client技能的版本号从1.2.0升到1.3.0; - 生成 tag
agent-skills-http-client-v1.3.0; - 执行
npm publish,发布新版本。
踩坑实录:某次发布失败,日志显示
Cannot find module 'zod'。排查发现http-client的package.json中peerDependencies写成了"zod": "^3.0.0",而实际构建环境装的是zod@3.22.4。但^3.0.0允许3.22.4,理论上不应报错。最终定位到是@swc/core的parseSync在解析skill.spec.ts时,错误地将zod当作运行时依赖加载。解决方案:在skill.spec.ts中避免任何import语句,只用字面量对象。这个教训让我明白:agent-skills的spec文件必须是“纯数据”,任何导入都会破坏它的可移植性。
5. 在真实场景中演进:从typescript + nestjs到jetson orin nx的技能迁移实践
agent-skills的最大价值,不是它多酷炫,而是它如何让同一套能力,在完全不同的硬件和软件环境中无缝迁移。我参与过三个典型迁移案例,它们揭示了这套范式的真正韧性。
5.1 场景一:从 NestJS 后端迁移到边缘设备(Jetson Orin NX)
原始需求:客户需要在工厂现场的 Jetson Orin NX 设备上,运行一个能调用 PLC 接口、分析振动传感器数据、并生成 PDF 报告的智能体。原系统是基于 NestJS 的云服务,技能模块(如plc-connector、vibration-analyzer)全部用 TypeScript 编写,依赖@nestjs/common。
迁移挑战:
- Jetson Orin NX 的 ARM64 架构不支持某些 x86 优化的 Node 原生模块;
- 设备内存仅 16GB,无法运行完整的 NestJS 框架;
- 网络不稳定,需要离线缓存技能依赖。
解决方案:剥离框架依赖,保留技能内核。
- 将
plc-connector技能的skill.impl.ts中所有@Inject()、@OnModuleInit()移除,改用纯函数式写法; vibration-analyzer技能不再 import@nestjs/config,而是通过process.env.VIBRATION_MODEL_PATH读取模型路径;- 构建脚本改为
pnpm build --filter=agent-skills-plc-connector --configuration=orin-nx,其中orin-nx配置指定target: 'es2020'和module: 'commonjs',确保兼容 Node 18。
效果:迁移后,plc-connector技能在 Orin NX 上启动时间从 3.2s 降至 0.4s,内存占用从 420MB 降至 89MB。最关键的是,skill.spec.ts中的dependencies字段帮我们提前发现了问题——plc-connector声明依赖node-modbus: ^4.0.0,而该包的 ARM64 构建脚本有 bug。我们在发布前就替换成更轻量的modbus-serial,避免了现场故障。
5.2 场景二:从 TypeScript 技能到 Python 技能的混合编排
客户需求:新接入一个用 PyTorch 训练的视觉缺陷检测模型,但调度器是 Node.js。他们不想重写整个调度器,希望 Python 技能能像 TypeScript 技能一样被发现和调用。
agent-skills的开放性再次体现:我们创建skills/vision-detector/目录,但内容是 Python:
skills/vision-detector/ ├── skill.impl.py # Python 实现的 execute 函数 ├── skill.spec.ts # 依然是 TypeScript,描述输入输出 ├── model/ # 模型权重文件 └── requirements.txtskill.spec.ts写法不变:
export const VisionDetectorSkillSpec: SkillSpec = { name: '视觉缺陷检测', description: '上传图片,返回缺陷位置和类型', version: '1.0.0', tags: ['vision', 'ai'], dependencies: { 'python': '>=3.9.0' } };调度器检测到skill.impl.py后,自动调用python3 skill.impl.py --input '{"image_path":"/tmp/abc.jpg"}',并通过标准输出接收 JSON 结果。整个过程对调度器透明——它只认skill.spec.ts的契约,不关心skill.impl是 TS、JS、Python 还是 Rust。
5.3 场景三:nx二次开发中的技能热重载
在为客户定制 Nx 插件时,他们要求“修改一个技能后,无需重启整个调度器,技能立即生效”。这看似违背 Node.js 的模块缓存机制,但agent-skills的目录结构让它成为可能。
实现原理:调度器维护一个skillCache: Map<string, SkillImpl>,每次执行前检查fs.statSync(implPath).mtimeMs是否变化。若变化,则delete require.cache[require.resolve(implPath)],再重新require()。
关键代码:
// apps/scheduler/src/lib/hot-reload.ts export function shouldReloadSkill(implPath: string, lastModified: number): boolean { try { const currentStat = fs.statSync(implPath); return currentStat.mtimeMs > lastModified; } catch { return false; } } export function reloadSkill(implPath: string): SkillImpl | null { const cacheKey = require.resolve(implPath); delete require.cache[cacheKey]; try { const module = require(implPath); // 假设 Python 技能导出的是 { execute: Function } return 'execute' in module ? module : null; } catch (e) { console.error(`Hot reload failed for ${implPath}:`, e); return null; } }这个功能让前端工程师能实时调试file-access技能——他们修改skill.impl.ts,保存,几秒后新逻辑就生效了。没有 webpack,没有 HMR,只有对 Node.js 模块系统本质的理解。
最后分享一个小技巧:在
nx serve scheduler启动时,加一个-w参数监听libs/agent-skills/**/*变化,触发自动nx build agent-skills-base。这样 TypeScript 技能的修改,也能被热重载捕获。这个组合拳,让agent-skills开发体验接近前端开发的丝滑感——而这,正是它能在typescript面试、nx二次开发等热词中持续被提及的根本原因:它把 AI 工程,拉回了软件工程的正轨。