TypeScript智能体技能模块化设计:agent-skills工程实践
2026/9/16 6:25:33 网站建设 项目流程

1. “agent-skills”不是项目名,而是一套可复用的智能体能力模块设计范式

你第一次在 GitHub 上看到agent-skills这个词,大概率是在某个 TypeScript + Nx 构建的 AI 工程化仓库里——它不带.js后缀,没写main.ts入口,甚至没有README.md,但它的目录结构异常干净:/libs/skills/web-search/libs/skills/file-read/libs/skills/llm-call。它不叫“插件”,不叫“工具函数”,更不是一堆utils/下的散装代码。它是被显式建模为“技能”(Skill)的、具备契约边界、可独立测试、可组合编排、可版本化发布的最小语义单元

这正是agent-skills的本质:它不是某个具体项目的产物,而是当前 TypeScript 生态中,面向 LLM 智能体(Agent)工程化落地时,逐渐收敛出的一套能力抽象层标准实践。关键词TypeScript决定了它的类型安全基底;node提供了运行时与系统交互能力;Nx承担了多技能模块的依赖管理、构建隔离与增量缓存;而semantic-release则确保每次git push都能自动发布带语义化版本号的@org/skill-web-search@1.2.0包——这才是真正支撑“技能即服务”(Skill-as-a-Service)的底层基建。

为什么这个命名如此关键?因为skills不是tools,也不是actionstool暗示外部调用、黑盒执行;action倾向于流程节点、状态变更;而skill天然携带三层隐含契约:

  • 输入有明确定义(如WebSearchSkillInput接口规定query: string, maxResults?: number);
  • 输出有结构化承诺(如返回WebSearchResult[],而非anystring);
  • 副作用受控且可声明(如requires: ['network', 'env:GOOGLE_API_KEY'],用于运行时权限校验)。

我去年在重构一个金融合规 Agent 时踩过最深的坑,就是把getStockPrice直接写成一个裸fetch()调用函数。结果上线后,风控团队要求所有网络请求必须打日志并审计来源,我们不得不翻遍 37 个文件去加console.log和 try-catch——而如果当初就按agent-skills范式定义StockPriceSkill,只需在libs/skills/stock-price/src/lib/stock-price.skill.ts里统一注入日志中间件,所有调用自动生效。这就是契约的力量:它让“改需求”从文本搜索变成接口修改,把“修 Bug”从人肉 grep 变成类型检查报错。

提示:agent-skills的核心价值不在“能做什么”,而在“怎么被别人安全地用”。它解决的从来不是单点功能实现问题,而是跨团队、跨项目、跨时间维度的能力复用信任问题。当你看到nx g @nrwl/node:library --name=web-search --directory=skills --importPath=@myorg/skill-web-search这条命令时,你看到的不是一个库生成器,而是一份能力交付协议的签署仪式。

2. 技能模块的物理结构:为什么必须用 Nx 管理,而不是单个 npm 包?

很多人第一反应是:“不就是写几个函数吗?建个skills/文件夹,npm publish就完事了。” 实际上,这是把agent-skills降维理解为“工具函数集合”,直接跳过了它作为工程化能力单元的核心挑战。真正的痛点在于:当你的 Agent 需要同时调用 12 个技能(天气、股票、邮件、数据库、PDF 解析、OCR、翻译、日历、代码执行、知识库检索、网页抓取、语音合成),这些技能之间存在复杂的依赖关系、版本兼容性、测试策略和发布节奏差异——而单包模式会让所有这些复杂度坍缩到一个package.json里,最终演变成无法维护的“巨石技能包”。

Nx 的不可替代性,就体现在它对这种复杂性的分治能力上。我们以真实项目中的技能拓扑为例:

技能模块依赖项测试方式发布频率关键约束
file-readfs-extra,iconv-lite单元测试 + 端到端文件读取低(月级)必须支持 Windows/Linux/macOS 文件路径规范
llm-call@anthropic-ai/sdk,openai模拟 HTTP 响应 + token 计费验证中(周级)必须兼容 streaming 与 non-streaming 两种响应模式
web-searchgoogleapis,serpapi真实 API Key 调用 + 结果结构断言高(天级)必须处理 SERP API 返回的organic_resultsanswer_box差异
code-execvm2,tmp沙箱环境隔离测试 + 超时强制终止极高(每次 PR)必须禁止process.exitrequire('child_process')等危险操作

如果把这些全塞进一个@myorg/agent-tools包里,会发生什么?

  • 每次web-search更新 API,都要触发整个包的重新构建、测试、发布,哪怕file-read完全没动;
  • code-exec因为沙箱安全策略升级,需要 Node.js 18+,但file-read仍需兼容 Node.js 16(客户旧系统),版本冲突直接卡死;
  • llm-call的测试需要真实 API Key,而 CI 环境不能泄露密钥,只能 mock,但web-search的 mock 又和llm-call的 mock 冲突,导致测试套件互相污染。

Nx 用三重机制破局:
第一重:项目级依赖图谱(Project Graph)
执行nx graph,你会看到一张清晰的有向图:web-searchllm-call(调用 LLM 解析搜索结果摘要),code-execfile-read(执行代码前先读取上下文文件)。Nx 不仅识别import语句,还能解析require.resolve()动态加载、甚至child_process.spawn('node', [...])这类隐式依赖。这意味着当你修改llm-call的返回类型时,Nx 能精准定位出哪些技能会因此编译失败——不是靠grep,而是靠 AST 分析。

第二重:任务调度与缓存(Task Runner & Cache)
nx affected --target=test不是简单跑所有测试,而是:

  1. 计算本次 Git 提交影响了哪些技能模块(比如只改了web-search/src/lib/serpapi.service.ts);
  2. 自动推导出受影响的测试目标(web-search:test)及其依赖的测试(llm-call:test,因为web-search依赖llm-call);
  3. 检查缓存中是否存在相同输入(源码哈希 + 依赖哈希 + Node 版本)的测试结果,命中则跳过执行。
    我们在一个拥有 42 个技能的项目中实测:全量测试耗时 28 分钟,而affected模式平均仅需 92 秒——因为 93% 的测试直接从缓存加载。

第三重:构建输出隔离(Build Output Isolation)
每个技能模块的dist/输出目录完全独立:dist/libs/skills/web-search/dist/libs/skills/file-read/。这使得semantic-release可以为每个技能单独发版——web-search1.5.0file-read同时发2.1.0,互不干扰。更重要的是,消费者可以按需安装:yarn add @myorg/skill-web-search@1.5.0,而不必拉下整个 80MB 的@myorg/agent-tools包。

注意:Nx 的--directory=skills参数绝非装饰。它强制将所有技能模块置于同一逻辑域下,使nx dep-graph --focus=skills能生成专属依赖视图。如果你把web-searchapps/下,它会被视为“应用”,失去技能模块的复用语义——这是新手最容易犯的结构性错误。

3. 技能的契约定义:从anySkillContract<TInput, TOutput>的类型进化

agent-skills的 TypeScript 实践,本质是一场从“运行时信任”到“编译时契约”的迁移。早期我们写技能函数,典型代码是这样的:

// ❌ 反模式:无契约,无防御,无文档 export function webSearch(query) { return fetch(`https://api.serpapi.com/search?q=${query}&api_key=${process.env.SERPAPI_KEY}`) .then(r => r.json()) .then(data => data.organic_results.map(r => ({ title: r.title, link: r.link }))); }

问题在哪?

  • 输入query类型是any,调用方传入undefined或对象也不会报错;
  • 输出是Promise<any>,消费者必须自己as WebSearchResult[]断言,一旦 API 响应结构变更(比如organic_results改名results),编译器完全沉默;
  • 错误处理缺失,网络超时、API Key 无效、配额超限等场景全部抛出未捕获异常;
  • 更致命的是:这个函数无法被静态分析工具识别为“技能”——它没有Skill标识,没有inputSchema描述,没有outputSchema声明,AI 编排器(如 LangChain 的 Tool Router)根本无法将其纳入决策流程。

真正的agent-skills类型契约,长这样:

// ✅ 正确:显式 SkillContract 接口 export interface SkillContract<TInput, TOutput> { id: string; // 技能唯一标识,用于编排器路由 name: string; // 用户可读名称 description: string; // 供 LLM 理解的自然语言描述 inputSchema: ZodSchema<TInput>; // Zod 验证 schema,运行时校验 + JSON Schema 生成 outputSchema: ZodSchema<TOutput>; // 同上 execute(input: TInput, context: SkillContext): Promise<TOutput>; // 执行入口 requires?: string[]; // 声明运行时依赖('network', 'env:SERPAPI_KEY') } // 具体技能实现 export const WebSearchSkill: SkillContract<WebSearchInput, WebSearchResult[]> = { id: 'web-search', name: 'Web Search', description: 'Search the web for up-to-date information using SERP API.', inputSchema: z.object({ query: z.string().min(1, 'Query cannot be empty'), maxResults: z.number().int().min(1).max(10).default(5), }), outputSchema: z.array(z.object({ title: z.string(), link: z.string().url(), snippet: z.string().optional(), })), requires: ['network', 'env:SERPAPI_KEY'], async execute(input, context) { const { query, maxResults } = input; const apiKey = context.env.SERPAPI_KEY; // 运行时校验:Zod 自动抛出结构化错误 const response = await fetch( `https://api.serpapi.com/search?q=${encodeURIComponent(query)}&num=${maxResults}&api_key=${apiKey}` ); if (!response.ok) { throw new SkillError( `SERP API failed: ${response.status} ${response.statusText}`, { statusCode: response.status, query } ); } const data = await response.json(); // Zod 自动校验并转换结果,失败则抛出详细错误 return this.outputSchema.parse(data.organic_results || []); }, };

这个SkillContract接口带来了四重确定性:
1. 编译时类型安全:调用方必须传入符合inputSchema的对象,IDE 自动补全字段,z.infer<typeof WebSearchSkill.inputSchema>直接生成 TypeScript 类型。
2. 运行时结构防护inputSchema.parse(input)在入口处拦截非法输入(如query: null),outputSchema.parse(result)在出口处保证输出结构稳定——即使 SERP API 某天返回空数组,也不会让下游map()Cannot read property 'title' of undefined
3. LLM 可理解性description字段是给大模型看的“技能说明书”,inputSchemaoutputSchema可自动生成 JSON Schema,供 LangChain 的StructuredTool或 LlamaIndex 的FunctionTool直接消费,实现“LLM 自动选择技能”。
4. 运维可观测性SkillError继承自Error,但额外携带metadata对象(如{ query, statusCode }),所有技能错误统一由SkillErrorHandler捕获,自动上报 Sentry 并关联skillIdinputHash,排查时直接搜索web-search error 429即可定位所有被限流的查询。

我们曾在线上遇到一个诡异问题:web-search技能在某些查询下返回空数组,导致后续llm-call解析失败。传统调试要翻日志、找 traceId、比对输入。而用了SkillContract后,我们只需在SkillErrorHandler里加一行:

if (error instanceof SkillError && error.metadata?.statusCode === 429) { console.warn(`[THROTTLED] ${skill.id} hit rate limit for query: ${error.metadata.query}`); }

再配合nx affected --target=build --base=main --head=HEAD,立刻锁定是web-searchinputSchema缺少对特殊字符(如#)的编码处理——问题在 15 分钟内闭环。

提示:zod不是可选依赖,而是agent-skills的基石。它比joi更轻量(Tree-shakable),比class-validator更适合函数式技能,且其z.describe()方法可直接生成 OpenAPI 兼容的description字段。不要试图用interface替代ZodSchema——前者只在编译期有效,后者贯穿开发、测试、生产全生命周期。

4. 技能的自动化发布:semantic-release 如何让每次git push都成为可信交付

agent-skills体系中,“发布”不是运维同学深夜手动npm publish的仪式,而是git push origin main后,CI 流水线自动完成的原子化交付动作。semantic-release是这套自动化的引擎,但它绝非开箱即用的黑盒——它的威力,取决于你如何将 Git 提交信息、技能模块结构、Nx 项目配置三者深度耦合。

先说结论:semantic-release的核心价值,不是“自动发版”,而是“用提交历史作为唯一可信的版本事实源”。这意味着:

  • 版本号(1.2.0)不再由人脑决定,而是由feat:fix:BREAKING CHANGE:等约定式提交前缀自动计算;
  • 每个技能模块的发布版本,严格对应其CHANGELOG.md中记录的变更范围,杜绝“这个包为什么升了 2.0.0?没人记得改了啥”;
  • 消费者看到@myorg/skill-web-search@1.5.0,就能 100% 确信:它包含所有feat(web-search): add serpapi fallbackfix(web-search): handle empty organic_results的提交,且不包含任何feat(file-read): support zip archives的代码。

要实现这一点,必须完成三个关键配置层的对齐:

4.1 提交规范层:Conventional Commits 是唯一入口

semantic-release默认只识别conventional-commits格式的提交。在agent-skills项目中,我们强制使用cz-conventional-changelog(Commitizen)作为提交工具,并定制commitlint规则:

// commitlint.config.js module.exports = { extends: ['@commitlint/config-conventional'], rules: { // 限定 scope 必须是技能模块名,且小写连字符 'scope-enum': [2, 'always', [ 'web-search', 'file-read', 'llm-call', 'code-exec', 'email-send', 'calendar-sync', 'pdf-parse' ]], // feat/fix 必须带 scope,避免全局变更 'scope-empty': [2, 'never'], } };

这样,合法的提交必须是:
feat(web-search): add serpapi fallback
fix(file-read): handle windows path separator
feat: add new skill(缺少 scope)
chore: update dependencies(chore 不触发发布)

注意:scope必须与 Nx 项目名完全一致(nx list查看),且全部小写。我们曾因webSearch(驼峰)和web-search(连字符)不一致,导致semantic-release无法匹配技能模块,所有提交都被忽略——这是配置中最隐蔽的坑。

4.2 Nx 项目层:project.json中的release配置

每个技能模块的project.json必须显式声明发布配置,这是semantic-release识别“谁该发什么版”的依据:

// libs/skills/web-search/project.json { "name": "web-search", "type": "library", "targets": { "build": { /* ... */ }, "test": { /* ... */ }, "release": { "executor": "@semantic-release/exec:exec", "options": { "cmd": "npx semantic-release --branches main --no-ci --dry-run" } } }, "tags": ["type:skill", "scope:web-search"], "implicitDependencies": ["@myorg/shared"] }

关键点在于:

  • targets.release是 Nx 任务,可通过nx run web-search:release手动触发(调试用);
  • tags中的scope:web-searchcommitlintscope-enum对齐,形成闭环;
  • implicitDependencies声明了该技能依赖的共享库(如@myorg/shared),确保shared更新时,web-search也会被affected检测到并重新发布。

4.3 CI 流水线层:GitHub Actions 的精准触发

.github/workflows/release.yml不是简单跑npx semantic-release,而是利用 Nx 的affected能力,只发布真正变更的技能:

name: Release Skills on: push: branches: [main] paths: - 'libs/skills/**' - 'package.json' - 'nx.json' jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # 必须获取完整 git history - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18.x' registry-url: 'https://registry.npmjs.org/' - name: Install dependencies run: yarn install - name: Determine affected skills id: affected run: | # 获取本次提交影响的技能模块名 echo "SKILLS=$(npx nx print-affected --select=projects --base=origin/main --head=HEAD --plain | tr '\n' ',' | sed 's/,$//')" echo "SKILLS=$SKILLS" >> $GITHUB_ENV - name: Release affected skills if: env.SKILLS != '' run: | # 为每个受影响的技能单独执行 release for skill in $(echo ${{ env.SKILLS }} | tr ',' '\n'); do echo "Releasing skill: $skill" npx nx run $skill:release done

这个流水线的关键智慧在于:

  • npx nx print-affected精准计算出本次git push影响了哪些技能(比如只影响web-searchllm-call);
  • for skill in ...循环为每个技能单独执行nx run $skill:release,确保web-search1.5.0llm-call2.1.0,互不干扰;
  • --base=origin/main确保比较基准是远程主干,避免本地分支污染发布判断。

实测效果:在一个包含 35 个技能的仓库中,过去每周手动发布需 2 小时核对变更、打包、上传、写 Changelog;现在git push后 8 分钟,所有变更技能的dist/包已上传 npm,CHANGELOG.md自动生成,Slack 通知推送至#agent-release频道——工程师的时间,终于从“发布协调员”回归为“技能开发者”。

提示:semantic-release--dry-run模式是调试神器。在本地执行npx semantic-release --dry-run --branches main,它会模拟整个发布流程,告诉你“将发布web-search@1.5.0,因为检测到 2 个feat和 1 个fix提交”,避免线上误操作。务必在首次配置时全程使用--dry-run

5. 技能的实战集成:在 NestJS Agent 中动态加载与安全执行

定义好技能、管理好发布,最终要落地到 Agent 的运行时。我们以一个基于 NestJS 构建的金融分析 Agent 为例,展示agent-skills如何从“静态代码”变为“动态能力”。

5.1 技能注册中心:SkillRegistry的单例管理

NestJS 的模块化特性天然适配技能管理。我们创建SkillModule,在onApplicationBootstrap阶段自动扫描所有@myorg/skill-*包:

// modules/skill/skill.module.ts @Injectable() export class SkillRegistry { private skills = new Map<string, SkillContract<any, any>>(); constructor( @Inject(SKILL_MODULES) private readonly skillModules: SkillContract<any, any>[], ) {} register(skill: SkillContract<any, any>) { if (this.skills.has(skill.id)) { throw new Error(`Skill with id '${skill.id}' already registered`); } this.skills.set(skill.id, skill); } get<TInput, TOutput>(id: string): SkillContract<TInput, TOutput> | undefined { return this.skills.get(id) as SkillContract<TInput, TOutput>; } getAll(): SkillContract<any, any>[] { return Array.from(this.skills.values()); } } @Module({ providers: [ SkillRegistry, { provide: SKILL_MODULES, useFactory: () => [ // 动态导入,避免循环依赖 import('@myorg/skill-web-search').then(m => m.WebSearchSkill), import('@myorg/skill-file-read').then(m => m.FileReadSkill), import('@myorg/skill-llm-call').then(m => m.LlmCallSkill), ], }, ], exports: [SkillRegistry], }) export class SkillModule {}

关键设计点:

  • SKILL_MODULES是一个useFactory提供的异步数组,确保技能模块在 NestJS 初始化时才加载,避免启动时require()失败;
  • SkillRegistry是单例,所有 Controller、Service 都可注入它,通过skillRegistry.get('web-search')获取技能实例;
  • register()方法在构造时批量注册,保证技能 ID 全局唯一,防止同名覆盖。

5.2 安全执行沙箱:SkillExecutor的权限控制

技能执行不是简单await skill.execute(input),必须叠加运行时防护:

// services/skill-executor.service.ts @Injectable() export class SkillExecutor { constructor( private readonly skillRegistry: SkillRegistry, private readonly logger: Logger, ) {} async execute<TInput, TOutput>( skillId: string, input: TInput, context: SkillContext, ): Promise<TOutput> { const skill = this.skillRegistry.get<TInput, TOutput>(skillId); if (!skill) { throw new NotFoundException(`Skill not found: ${skillId}`); } // 1. 权限校验:检查 skill.requires 是否满足 const missingRequirements = skill.requires?.filter(req => { if (req.startsWith('env:')) { const envKey = req.slice(4); return !context.env[envKey]; } if (req === 'network') { return !context.networkEnabled; } return false; }); if (missingRequirements?.length) { throw new ForbiddenException( `Missing requirements for skill ${skillId}: ${missingRequirements.join(', ')}` ); } // 2. 输入校验:Zod 自动解析并抛出结构化错误 try { const validatedInput = skill.inputSchema.parse(input); // 3. 执行并捕获 SkillError return await skill.execute(validatedInput, context); } catch (error) { if (error instanceof z.ZodError) { throw new BadRequestException( `Invalid input for ${skillId}: ${error.errors.map(e => e.message).join('; ')}` ); } if (error instanceof SkillError) { this.logger.error(`Skill execution failed: ${skillId}`, { error: error.message, metadata: error.metadata, }); throw error; } throw error; } } }

这个SkillExecutor提供了三层防护:

  • 权限闸门requires字段在运行时强制校验,env:SERPAPI_KEY缺失则直接403 Forbidden,不浪费一次 API 调用;
  • 输入守卫:Zod 校验失败返回400 Bad Request,错误信息精确到字段(如"Query cannot be empty"),前端可直接展示;
  • 错误归一化:所有技能错误统一为SkillError,日志中自动打标skillIdinputHash,便于追踪。

5.3 Agent 控制器:动态技能路由

最后,在AgentController中,我们实现“根据用户指令,自动选择并执行技能”:

// controllers/agent.controller.ts @Controller('agent') export class AgentController { constructor( private readonly skillExecutor: SkillExecutor, ) {} @Post('execute') async executeSkill( @Body() body: { skillId: string; input: any }, ): Promise<any> { // 1. 从预定义技能列表中验证 skillId 合法性(防任意代码执行) const allowedSkills = ['web-search', 'file-read', 'llm-call']; if (!allowedSkills.includes(body.skillId)) { throw new BadRequestException(`Skill not allowed: ${body.skillId}`); } // 2. 构建执行上下文 const context: SkillContext = { env: { SERPAPI_KEY: process.env.SERPAPI_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY, }, networkEnabled: true, logger: this.logger, }; // 3. 执行 return this.skillExecutor.execute( body.skillId, body.input, context, ); } }

这个控制器看似简单,却蕴含深意:

  • allowedSkills白名单是最后一道防线,防止攻击者传入skillId: '../../../etc/passwd'这类路径遍历;
  • context对象集中管理所有技能共享的依赖(环境变量、网络开关、日志器),避免每个技能重复process.env.XXX
  • 整个流程无硬编码,web-search升级到1.5.0后,只要@myorg/skill-web-search包更新,SkillRegistry重启时自动加载新版本——零代码修改,能力平滑升级。

我们曾用此架构支撑一个实时财报分析 Agent:用户输入“对比苹果和微软最近一季度的营收增长率”,Agent 自动拆解为:

  1. web-search技能获取两家公司最新财报链接;
  2. file-read技能下载 PDF 并提取文本;
  3. llm-call技能解析文本,定位“Revenue”段落;
  4. llm-call技能执行数学计算并生成对比报告。
    整个链路耗时 12.3 秒,错误率低于 0.7%,而所有技能模块均由不同团队独立开发、测试、发布——agent-skills范式,让智能体真正成为“可组装的乐高”。

最后分享一个血泪经验:永远在SkillExecutor.execute()中添加console.time(${skillId}-execute)console.timeEnd()。我们曾发现code-exec技能在特定输入下耗时飙升至 45 秒,远超 5 秒超时阈值。通过时间戳定位,发现是vm2沙箱对正则表达式.*的回溯爆炸。解决方案不是优化代码,而是为code-exec技能增加timeoutMs: 3000配置项,并在SkillContract接口中声明——把性能契约,也变成类型系统的一部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询