1. 项目概述:一个被严重低估的“技能容器”设计范式
“agent-skills”这个名称乍看平平无奇,像某个开源库的包名,又像某次内部技术分享的临时标题。但如果你最近刷过 TypeScript 社区、Nx 工具链讨论组,或者参与过任何基于智能体(Agent)架构的中后台系统开发,你大概率已经在多个项目里见过它——只是没意识到它背后藏着一套高度可复用、可测试、可组合的能力抽象协议。它不是框架,不是 SDK,更不是 AI 模型封装;它是一套用 TypeScript 严格定义的、运行在 Node.js 环境中的技能契约(Skill Contract)标准接口体系。核心关键词“agent-skills”本身就是一个信号:它指向的不是“如何造一个 Agent”,而是“如何让任意 Agent 安全、可控、可审计地调用外部能力”。这直接切中了当前工程化落地 AI 应用的最大痛点——能力边界模糊、调用链路不可控、错误处理碎片化、权限与审计缺失。
我第一次在真实生产环境里撞见它,是在一个金融风控决策平台的重构项目中。当时团队正把几十个 Python 编写的规则引擎、第三方 API 封装、数据库校验脚本,逐步迁移到由 Nx 管理的 TypeScript 单体工作区。最初大家各自写callExternalApi()、validateUserInput()、sendSlackAlert()这类函数,结果不到三个月,就出现了三套不同的重试逻辑、四套不兼容的错误码映射、五种风格迥异的输入校验方式。日志里全是Error: unknown error from payment gateway这种无效信息。直到一位资深后端同事甩出一个叫@myorg/agent-skills的私有包,里面只有两个文件:Skill.ts和SkillRunner.ts。他只改了不到 20 行代码,就把所有散装能力收编进统一调度器,错误日志立刻能精准定位到是“支付网关超时(code=PAY_TIMEOUT)”,重试策略自动降级为指数退避,审计日志里清晰记录着“用户A在14:23:05调用了payment-validate技能,输入脱敏后为{card_last4: '4242', amount: 199.00}”。那一刻我才真正理解,“agent-skills”不是功能模块,而是一种面向能力治理的基础设施语言。它适合所有正在从“单点 AI 调用”迈向“可编排智能体工作流”的团队,尤其适合那些已采用 Nx 进行大型 TypeScript 项目管理、并依赖 semantic-release 实现自动化版本发布的工程团队。它不解决模型推理问题,但它决定了你的模型能力能否被安全、稳定、可维护地交付到业务前线。
2. 核心设计思路:为什么是 TypeScript + Nx + semantic-release 的铁三角组合?
2.1 技能契约的本质:从“函数调用”到“能力协商”
“agent-skills”的底层设计哲学,源于对传统函数调用范式的彻底反思。在普通 Node.js 项目中,我们写一个sendEmail(to, subject, body)函数,调用方必须精确知道参数名、类型、顺序、甚至副作用(比如是否自动重试、是否记录日志)。一旦需求变更——比如要支持附件、要区分事务性邮件和通知类邮件、要对接新邮件服务商——函数签名就得改,所有调用点都得同步更新,极易引发连锁故障。而“agent-skills”将这个过程升维为一次能力协商(Capability Negotiation)。它强制定义了三个不可绕过的契约要素:
- 能力标识(Skill ID):一个全局唯一、语义化的字符串,如
email.send-transactional或db.query-user-profile。它不依赖函数名,而是业务域概念,便于监控、审计、权限控制。 - 输入契约(Input Schema):使用 TypeScript Interface 严格声明,但关键在于它必须通过
zod或@sinclair/typebox进行运行时验证。例如email.send-transactional的输入必须包含to: string & EmailFormat、templateId: string & UuidV4、context: Record<string, unknown>,且context中的userId字段若存在,必须是数字类型。这杜绝了“传错参数导致下游静默失败”的经典陷阱。 - 执行契约(Execution Contract):规定技能必须返回一个标准化的
SkillResult<T>类型,包含success: boolean、output?: T、error?: SkillError、metadata: { durationMs: number, retries: number, traceId: string }。更重要的是,它要求所有技能实现必须遵循统一的错误分类体系——SkillError不是Error的简单继承,而是包含code: string(如EMAIL_RATE_LIMIT_EXCEEDED)、level: 'fatal' | 'warning' | 'info'、suggestion: string(如“请检查 SMTP 配置或联系运维提升配额”)等字段。这意味着调用方无需关心内部实现,只需根据code做决策:EMAIL_RATE_LIMIT_EXCEEDED触发降级策略,DB_CONNECTION_LOST启动熔断,INPUT_VALIDATION_FAILED直接返回用户友好的提示。
这种设计直接解决了我在上一家公司踩过的大坑:一个电商促销系统里,applyCoupon技能在不同环境返回格式混乱——开发环境抛原生Error,测试环境返回{ ok: false, msg: "xxx" },生产环境又变成{ success: false, data: null, error: { code: 500, message: "internal error" } }。前端不得不写三套解析逻辑,每次发布都提心吊胆。而“agent-skills”的契约强制所有技能输出同构,前端只需监听result.success和result.error.code,稳定性提升了一个数量级。
2.2 为什么必须是 TypeScript?类型即文档,类型即契约
选择 TypeScript 绝非跟风。在“agent-skills”体系中,TypeScript 的类型系统承担着三重不可替代的角色:设计文档、编译期守门员、IDE 智能助手。我们来看一个真实案例。某次为物流系统添加trackPackage技能时,后端同学定义了输入接口:
interface TrackPackageInput { trackingNumber: string; carrierCode: 'SF' | 'ZTO' | 'YD'; timeoutMs?: number; }仅仅这一行carrierCode: 'SF' | 'ZTO' | 'YD',就让前端在调用前就明确知道只支持这三家快递,避免了传入'EMS'导致的 400 错误;让测试同学自动生成覆盖全部枚举值的用例;让 CI 流程在 PR 提交时就拦截掉任何非法 carrierCode 的调用代码。如果用 JavaScript,这些约束只能靠注释或运行时校验,而注释会过期,运行时校验则意味着错误被推到了集成测试甚至线上阶段。
更关键的是,TypeScript 的泛型和条件类型让“技能契约”具备了强大的表达力。比如我们定义了一个通用的retryableSkill高阶函数,它接收任意技能并返回一个带重试逻辑的包装版:
function withRetry<TInput, TOutput>( skill: Skill<TInput, TOutput>, options: { maxRetries: number; baseDelayMs: number } ): Skill<TInput, TOutput> { // 实现细节... }这里TInput和TOutput的泛型约束确保了包装后的技能,其输入输出类型与原始技能完全一致。调用方拿到withRetry(sendEmail),IDE 依然能精准提示sendEmail的所有参数和返回值类型,零学习成本。这种“类型穿透”能力,在 JavaScript 中根本无法实现。我曾用 Babel 插件尝试给 JS 项目加类似能力,结果是类型定义与实际运行时行为严重脱节,最终放弃。TypeScript 不是“加了类型检查的 JS”,它是为构建高可靠性契约系统而生的语言。
2.3 为什么必须是 Nx?单体工作区是技能生态的温床
“agent-skills”绝不是一个孤立的 npm 包。它的生命力,完全依赖于 Nx 构建的单体工作区(Monorepo)。原因很简单:技能不是一次性函数,它们是一个需要持续演进、相互依赖、共同治理的生态系统。想象一下,你有auth.login、auth.refreshToken、auth.logout三个技能,它们共享同一套 JWT 解析逻辑和密钥管理配置。如果每个技能都作为独立包发布,那么:
- 修改 JWT 解析逻辑,需要同时发布
@myorg/auth-core、@myorg/auth-login、@myorg/auth-refresh三个包,并确保所有下游服务升级到兼容版本; auth.login的单元测试想复用auth-core的 mock 数据工厂,却要跨包引用,导致测试启动变慢、依赖关系混乱;- 想给所有
auth.*技能统一添加请求追踪头(X-Trace-ID),得在三个包里分别改代码、分别提交 PR、分别走 CI。
而 Nx 的工作区结构,让这一切变得自然:
/libs /skills /auth # auth.login, auth.refreshToken 等技能实现 /email # email.send-transactional 等 /db # db.query-user-profile 等 /core /types # Skill, SkillResult, SkillError 等核心类型定义 /utils # retry, timeout, logger 等通用工具 /shared /schemas # zod schema 定义,如 EmailAddressSchemaNx 的project.json文件精确描述了每个技能包的依赖关系。/skills/auth明确依赖/core/types和/shared/schemas,Nx 的影响分析(nx affected)能瞬间告诉你,修改/core/types/SkillError.ts会影响哪些技能包,从而精准触发相关 CI 流程。更重要的是,Nx 的build和test命令天然支持增量构建——如果只改了/skills/email,CI 只会重新构建和测试它及其直接依赖,而不是整个工作区。我们在一个拥有 87 个技能的项目中实测,全量测试耗时 22 分钟,而 Nx 的增量测试平均仅需 47 秒。这种效率,是任何基于独立 npm 包的方案都无法企及的。它让“技能”从一个个孤岛,变成了一个有机生长的森林。
2.4 为什么必须是 semantic-release?自动化版本是契约演进的生命线
当“agent-skills”成为一个被多个业务线、数十个微服务共同依赖的基础设施时,版本管理就成了生死线。手动维护package.json中的version字段,然后npm publish,这种做法在“agent-skills”场景下是灾难性的。因为每一次版本发布,都意味着一次契约变更。而契约变更必须满足严格的语义化版本规则(SemVer):
- 补丁版本(x.y.Z):仅修复 bug,不改变输入/输出契约,不新增能力。例如修复
email.send-transactional中一个导致 HTML 标签未转义的安全漏洞。 - 次要版本(x.Y.z):新增向后兼容的能力(如增加
email.send-broadcast技能),或为现有技能增加可选参数(如为db.query-user-profile新增includeArchived?: boolean参数),但绝不移除或修改现有必填参数。 - 主版本(X.y.z):破坏性变更,如移除
auth.login技能,或修改其输入接口(将password字段改为passwordHash)。这要求所有调用方必须主动适配。
semantic-release 的价值,在于它将这套规则自动化、不可篡改地嵌入到 CI 流程中。我们配置它监听main分支的合并,根据提交消息的前缀(fix:、feat:、BREAKING CHANGE:)自动判断应发布的版本号,并生成符合规范的 CHANGELOG。这意味着:
- 开发者无需记住“这次该发 1.2.3 还是 1.3.0”,只需按规范写提交信息;
- 所有发布都是原子操作:
git tag、npm publish、CHANGELOG更新、GitHub Release 创建,一步到位,杜绝人为失误; - 下游服务通过
^1.2.0这样的范围依赖,能安全地自动获取所有补丁和次要版本更新,享受 bug 修复和新能力,而不会意外引入破坏性变更。
我亲眼见过一个团队因手动发布失误,将一个包含BREAKING CHANGE的提交打上了1.2.3补丁版本号,导致所有依赖它的服务在部署后大面积报错。而 semantic-release 的自动化流程,从源头上杜绝了这种可能性。它让“agent-skills”的契约演进,成为一条清晰、可追溯、可信赖的河流,而不是一场充满不确定性的豪赌。
3. 核心细节解析:从零搭建一个可运行的 agent-skills 工作区
3.1 初始化 Nx 工作区与基础结构
我们从零开始,搭建一个最小可行的agent-skills工作区。注意,这不是一个“Hello World”教程,而是基于真实项目经验提炼的、经过生产环境验证的初始化路径。首先,确保你已安装npm和nvm(Node Version Manager),这是管理 Node.js 版本的基石。我们推荐使用 Node.js 18.x LTS,因为它对node:util等内置模块的 ESM 支持最成熟,能避免网络热词中频繁出现的SyntaxError: The requested module 'node:util' does not provide an export named这类问题。
# 使用 nvm 切换到 Node 18 nvm install 18 nvm use 18 # 全局安装 Nx CLI(推荐,避免本地 node_modules 冗余) npm install -g nx # 创建新的 Nx 工作区,命名为 'agent-skills-workspace' npx create-nx-workspace@latest agent-skills-workspace --preset=apps --nx-cloud=false --package-manager=npm # 进入工作区目录 cd agent-skills-workspace这一步创建了一个基础工作区。接下来,我们需要按照“agent-skills”的领域划分,创建核心库。关键原则是:先定义契约,再实现能力。因此,第一步永远是创建@agent-skills/core库,它只包含类型定义,不包含任何运行时逻辑。
# 创建 core 库,存放所有核心类型 nx g @nrwl/node:library core --directory=libs --importPath=@agent-skills/core --publishable --no-interactive # 创建 skills 库,作为所有具体技能的父容器 nx g @nrwl/node:library skills --directory=libs --importPath=@agent-skills/skills --publishable --no-interactive # 创建 shared 库,存放共享的 schema 和工具 nx g @nrwl/node:library shared --directory=libs --importPath=@agent-skills/shared --publishable --no-interactive此时,工作区结构如下:
/libs /core /src /index.ts # 导出所有核心类型 /lib /skill.ts # Skill 接口定义 /result.ts # SkillResult, SkillError 定义 /skills /src /index.ts # 空,后续按需导出具体技能 /shared /src /index.ts # 导出共享 schema 和工具 /lib /schemas.ts # zod schema 定义 /utils.ts # 通用工具函数提示:
--publishable参数至关重要,它告诉 Nx 这个库需要被构建为一个可发布的 npm 包。--importPath则定义了该库在代码中的导入路径,如import { Skill } from '@agent-skills/core'。这为后续的自动化发布奠定了基础。
3.2 定义核心契约:Skill、SkillResult 与 SkillError
现在,我们深入libs/core/src/lib/skill.ts,编写“agent-skills”的心脏。这里的每一行代码,都在定义能力交互的宪法。
// libs/core/src/lib/skill.ts import { z } from 'zod'; /** * 技能执行的元数据,用于可观测性 */ export interface SkillMetadata { /** 技能执行耗时(毫秒) */ durationMs: number; /** 重试次数 */ retries: number; /** 分布式追踪 ID */ traceId: string; } /** * 技能错误的标准结构 * code 是机器可读的唯一标识符,用于自动化决策 * level 表示错误严重程度,影响告警级别和用户提示 * suggestion 是给调用方的明确行动指南 */ export interface SkillError { code: string; level: 'fatal' | 'warning' | 'info'; message: string; suggestion: string; /** 可选的原始错误对象,用于调试 */ originalError?: unknown; } /** * 技能执行结果的统一结构 * output 是泛型,由具体技能定义 * error 是 SkillError 的可选实例 * metadata 提供可观测性数据 */ export interface SkillResult<TOutput = unknown> { success: boolean; output?: TOutput; error?: SkillError; metadata: SkillMetadata; } /** * 技能的执行上下文,提供运行时环境信息 * 这是技能与外部世界交互的唯一通道,避免全局变量污染 */ export interface SkillContext { /** 日志记录器,预置了 traceId */ logger: { info: (msg: string, ...args: any[]) => void; warn: (msg: string, ...args: any[]) => void; error: (msg: string, ...args: any[]) => void; }; /** 配置项,来自工作区的 config 文件 */ config: Record<string, unknown>; /** 用于发起 HTTP 请求的客户端 */ httpClient: { get: <T>(url: string) => Promise<T>; post: <T>(url: string, body: unknown) => Promise<T>; }; } /** * 技能的核心接口定义 * TInput 是输入契约,必须是 zod schema 的 infer 类型 * TOutput 是输出契约 * context 是运行时上下文 */ export interface Skill<TInput, TOutput> { /** * 技能的唯一标识符,格式为 domain.action * 例如: 'email.send-transactional', 'db.query-user-profile' */ id: string; /** * 输入契约的 zod schema * 这是运行时验证的唯一依据,也是 TypeScript 类型的来源 */ inputSchema: z.ZodSchema<TInput>; /** * 技能的执行函数 * 必须返回 SkillResult<TOutput> * 必须能处理所有由 inputSchema 定义的输入 */ execute: ( input: TInput, context: SkillContext ) => Promise<SkillResult<TOutput>>; }这段代码看似简单,却蕴含了深刻的设计思想。Skill接口强制要求inputSchema,这确保了所有技能都必须通过zod进行运行时验证,堵死了“类型正确但数据非法”的漏洞。SkillContext的设计,则彻底隔离了技能的实现细节——它不知道自己运行在 Express 还是 NestJS 上,也不知道日志是输出到控制台还是发送到 ELK,它只通过context.logger和context.httpClient这两个契约与外界通信。这使得技能的单元测试变得极其简单:你只需 mock 一个SkillContext对象,就能 100% 覆盖所有执行路径,无需启动任何服务器或数据库。
3.3 创建第一个技能:email.send-transactional
现在,我们来实现第一个具体的技能:email.send-transactional。它将被放在libs/skills/src/lib/email/send-transactional.ts。
// libs/skills/src/lib/email/send-transactional.ts import { z } from 'zod'; import { Skill, SkillResult, SkillError, SkillContext } from '@agent-skills/core'; import { EmailAddressSchema, TemplateIdSchema } from '@agent-skills/shared'; /** * 定义输入契约的 zod schema * 这里展示了如何组合共享 schema 并添加业务特定约束 */ const SendTransactionalEmailInputSchema = z.object({ to: EmailAddressSchema.describe('收件人邮箱地址'), templateId: TemplateIdSchema.describe('邮件模板 ID'), context: z.record(z.unknown()).describe('模板渲染上下文'), // 添加一个业务特定的可选字段 priority: z.enum(['low', 'normal', 'high']).default('normal').describe('发送优先级'), }); // TypeScript 类型由 schema 自动推导,保证绝对一致 type SendTransactionalEmailInput = z.infer<typeof SendTransactionalEmailInputSchema>; /** * 定义输出契约 * 这里是一个简单的成功标识,实际项目中可能是 { messageId: string } */ type SendTransactionalEmailOutput = { messageId: string; }; /** * 实现 Skill 接口 * 注意:id 字段是硬编码的字符串,这是契约的一部分,不能动态生成 */ export const sendTransactionalEmail: Skill< SendTransactionalEmailInput, SendTransactionalEmailOutput > = { id: 'email.send-transactional', inputSchema: SendTransactionalEmailInputSchema, async execute(input, context) { try { // 1. 记录开始日志 context.logger.info(`Starting email.send-transactional for ${input.to}`); // 2. 构建请求体,调用内部邮件服务 API const payload = { to: input.to, templateId: input.templateId, context: input.context, priority: input.priority, }; // 3. 使用 SkillContext 提供的 httpClient 发起请求 // 这里假设内部邮件服务 API 地址存储在 context.config 中 const response = await context.httpClient.post<{ id: string }>( `${context.config.emailServiceUrl}/send`, payload ); // 4. 构建成功结果 const result: SkillResult<SendTransactionalEmailOutput> = { success: true, output: { messageId: response.id }, error: undefined, metadata: { durationMs: Date.now() - performance.now(), // 简化版耗时计算 retries: 0, traceId: context.logger.traceId || 'unknown', }, }; context.logger.info(`email.send-transactional succeeded for ${input.to}`, { messageId: response.id, }); return result; } catch (err) { // 5. 统一错误处理,转换为 SkillError let skillError: SkillError; if (err instanceof Error && err.message.includes('rate limit')) { skillError = { code: 'EMAIL_RATE_LIMIT_EXCEEDED', level: 'warning', message: '邮件发送频率超出限制', suggestion: '请检查配置或联系运维提升配额', }; } else if (err instanceof Error && err.message.includes('invalid template')) { skillError = { code: 'EMAIL_TEMPLATE_NOT_FOUND', level: 'fatal', message: '指定的邮件模板不存在', suggestion: '请确认 templateId 是否正确,或检查模板服务状态', }; } else { // 未知错误,归为通用错误 skillError = { code: 'EMAIL_INTERNAL_ERROR', level: 'fatal', message: '邮件服务内部错误', suggestion: '请稍后重试,或联系技术支持', originalError: err, }; } const result: SkillResult<SendTransactionalEmailOutput> = { success: false, output: undefined, error: skillError, metadata: { durationMs: Date.now() - performance.now(), retries: 0, traceId: context.logger.traceId || 'unknown', }, }; context.logger.error( `email.send-transactional failed for ${input.to}`, { error: skillError, originalError: err } ); return result; } }, };这个实现展示了“agent-skills”的精髓:契约驱动、错误分类、可观测性内建。inputSchema确保了输入的合法性;execute函数内部的try/catch块,将所有可能的异常都捕获并转化为预定义的SkillError;context.logger的调用,让每一步操作都有迹可循。最关键的是,它没有引入任何外部依赖(如nodemailer),所有 I/O 操作都通过context.httpClient进行,这使得它可以在任何 Node.js 环境中运行,无论是本地开发、CI 测试,还是生产 Kubernetes 集群。
3.4 构建与发布:配置 semantic-release 实现自动化
为了让@agent-skills/core和@agent-skills/skills能够被其他项目消费,我们必须配置 semantic-release。这需要在工作区根目录下进行一系列配置。
首先,安装必要的依赖:
# 在工作区根目录安装 semantic-release 及其插件 npm install --save-dev semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/npm @semantic-release/github然后,为每个可发布的库创建.releaserc配置文件。以libs/core为例,在libs/core/.releaserc中写入:
{ "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", [ "@semantic-release/npm", { "npmPublish": true, "pkgRoot": "dist/libs/core" } ], [ "@semantic-release/github", { "assets": ["dist/libs/core/*.tgz"] } ] ] }这个配置告诉 semantic-release:当main分支有新提交时,分析提交信息(@semantic-release/commit-analyzer),生成发布说明(@semantic-release/release-notes-generator),然后将构建产物(位于dist/libs/core)发布到 npm(@semantic-release/npm),并上传 tarball 到 GitHub Release(@semantic-release/github)。
接下来,配置 CI 流程。我们以 GitHub Actions 为例,在.github/workflows/release.yml中定义:
name: Release on: push: branches: [main] # 只在 libs 目录下的文件变更时触发,避免无关提交触发发布 paths: - 'libs/**' jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' registry-url: 'https://registry.npmjs.org/' - name: Install dependencies run: npm ci - name: Build all publishable libraries run: npx nx build --all --with-deps - name: Semantic Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release这个 CI 流程的关键点在于npx nx build --all --with-deps。它会自动分析依赖图,只构建那些被修改的库及其所有上游依赖。例如,如果只修改了libs/skills/src/lib/email/send-transactional.ts,它会构建libs/skills和它所依赖的libs/core,而不会浪费时间去构建libs/db。这极大地提升了 CI 效率。
最后,别忘了在package.json的scripts中添加一个本地发布的快捷命令,方便开发调试:
// package.json { "scripts": { "release:local": "npx semantic-release --dry-run --ci false --debug" } }运行npm run release:local,semantic-release 会模拟整个发布流程,打印出它将要发布的版本号和 CHANGELOG,而不会真正发布到 npm。这是验证配置是否正确的最佳方式。
4. 实操过程与核心环节实现:一个完整的技能调用链路
4.1 技能注册中心:统一管理与发现
在“agent-skills”体系中,技能不是被零散调用的,而是被一个中央注册中心(Registry)统一管理和发现。这并非一个复杂的运行时服务,而是一个轻量级的、基于 TypeScript Map 的内存注册表。它的核心价值在于:解耦调用方与实现方,支持运行时动态加载与替换。
我们在libs/core/src/lib/registry.ts中实现它:
// libs/core/src/lib/registry.ts import { Skill } from './skill'; /** * 技能注册中心 * 单例模式,确保全局唯一 */ class SkillRegistry { private static instance: SkillRegistry; private skills: Map<string, Skill<unknown, unknown>>; private constructor() { this.skills = new Map(); } static getInstance(): SkillRegistry { if (!SkillRegistry.instance) { SkillRegistry.instance = new SkillRegistry(); } return SkillRegistry.instance; } /** * 注册一个技能 * 如果同 ID 技能已存在,则抛出错误,防止意外覆盖 */ register<TInput, TOutput>(skill: Skill<TInput, TOutput>): void { if (this.skills.has(skill.id)) { throw new Error(`Skill with id '${skill.id}' is already registered`); } this.skills.set(skill.id, skill); } /** * 根据 ID 获取技能 * 返回一个类型安全的 Skill 实例 */ get<TInput, TOutput>(id: string): Skill<TInput, TOutput> | undefined { const skill = this.skills.get(id) as Skill<TInput, TOutput> | undefined; return skill; } /** * 获取所有已注册技能的 ID 列表 */ getAllIds(): string[] { return Array.from(this.skills.keys()); } } export const skillRegistry = SkillRegistry.getInstance();这个注册中心的设计非常克制。它没有网络层、没有持久化、没有复杂的路由,就是一个纯粹的内存 Map。这正是它的优势所在:极简、可靠、无额外依赖。在应用启动时,我们只需将所有技能导入并注册:
// apps/api/src/main.ts import { skillRegistry } from '@agent-skills/core'; import { sendTransactionalEmail } from '@agent-skills/skills/email/send-transactional'; // 在应用启动时注册所有技能 skillRegistry.register(sendTransactionalEmail); // 后续,任何地方都可以通过 skillRegistry.get('email.send-transactional') 获取它这种设计带来了巨大的灵活性。例如,在测试环境中,我们可以注册一个模拟技能(Mock Skill)来替代真实的邮件发送:
// libs/skills/src/lib/email/mock-send-transactional.ts import { z } from 'zod'; import { Skill, SkillResult } from '@agent-skills/core'; export const mockSendTransactionalEmail: Skill<unknown, { messageId: string }> = { id: 'email.send-transactional', inputSchema: z.any(), // 模拟技能不验证输入 async execute(input, context) { context.logger.info('MOCK: email.send-transactional called', { input }); return { success: true, output: { messageId: `mock-${Date.now()}` }, error: undefined, metadata: { durationMs: 1, retries: 0, traceId: 'mock-trace-id' }, }; }, };然后在测试启动时,用mockSendTransactionalEmail替换掉真实的sendTransactionalEmail。整个测试过程无需启动任何外部服务,速度极快,且 100% 可控。
4.2 技能执行器:注入上下文与执行策略
有了注册中心,下一步就是创建一个统一的技能执行器(Skill Runner)。它负责从注册中心获取技能、注入运行时上下文、执行技能,并处理通用的横切关注点(Cross-Cutting Concerns),如重试、超时、日志、指标上报。
我们在libs/core/src/lib/runner.ts中实现:
// libs/core/src/lib/runner.ts import { v4 as uuidv4 } from 'uuid'; import { Skill, SkillResult, SkillContext, SkillError } from './skill'; import { skillRegistry } from './registry'; /** * 技能执行器的配置选项 */ export interface SkillRunnerOptions { /** 默认超时时间(毫秒) */ defaultTimeoutMs?: number; /** 默认重试次数 */ defaultMaxRetries?: number; } /** * 技能执行器 * 它是调用方与技能实现之间的唯一桥梁 */ export class SkillRunner { private readonly options: SkillRunnerOptions; constructor(options: SkillRunnerOptions = {}) { this.options = { defaultTimeoutMs: 30000, defaultMaxRetries: 3, ...options, }; } /** * 执行一个技能 * @param id 技能 ID * @param input 技能输入 * @param context 运行时上下文 * @returns 技能执行结果 */ async run<TInput, TOutput>( id: string, input: TInput, context: Omit<SkillContext, 'logger' | 'httpClient'> & { logger?: SkillContext['logger']; httpClient?: SkillContext['httpClient']; } ): Promise<SkillResult<TOutput>> { // 1. 从注册中心获取技能 const skill = skillRegistry.get<TInput, TOutput>(id); if (!skill) { const error: SkillError = { code: 'SKILL_NOT_FOUND', level: 'fatal', message: `Skill with id '${id}' is not registered`, suggestion: `Please check if the skill is imported and registered in your application`, }; return { success: false, output: undefined, error, metadata: { durationMs: 0, retries: 0, traceId: 'unknown' }, }; } // 2. 创建带有 traceId 的完整 SkillContext const traceId = context.logger?.traceId || uuidv4(); const fullContext: SkillContext = { logger: { info: (msg, ...args) => context.logger?.info?.(`[SKILL:${id}] ${msg}`, ...args) ?? console.log(msg, ...args), warn: (msg, ...args) => context.logger?.warn?.(`[SKILL:${id}] ${msg}`, ...args) ?? console.warn(msg, ...args), error: (msg, ...args) => context.logger?.error?.(`[SKILL:${id}] ${msg}`, ...args)