Agent Skills:TypeScript + Nx 构建可复用智能体能力体系
2026/9/16 7:56:54 网站建设 项目流程

1. 项目概述:一个被严重低估的“技能中枢”设计

“agent-skills”这四个字乍看像某个开源库的包名,或是某篇技术文档里的小节标题,但如果你在Nx monorepo里翻过十几个微前端项目、维护过三套TypeScript驱动的CLI工具、给NestJS服务写过五版任务调度器,你就会立刻意识到——这不是一个功能模块,而是一套可复用、可组合、可验证的智能体行为能力抽象体系。它解决的不是“怎么调API”,而是“一个智能体该具备哪些基础能力、这些能力如何被标准化定义、如何被安全注入、如何被运行时动态发现与调用”。我第一次在团队内部落地这个设计,是在重构一个需要对接17个异构系统(从SAP到自研IoT平台)的自动化运维Agent时。当时我们卡在“每个新接入系统都要重写一遍认证+重试+日志+错误分类逻辑”,光是重复代码就占了整个Agent体积的63%。直到把登录、HTTP请求、文件读写、定时触发、条件判断、JSON Schema校验这些动作,全部抽离成独立、带类型契约、带执行上下文约束的Skill类,才真正把开发效率拉回正轨。

核心关键词“agent-skills”背后,实际承载着三个层次的工程诉求:第一层是能力解耦——把智能体的行为拆成原子化、无状态、可测试的单元;第二层是类型即契约——用TypeScript的interface和泛型强制约束输入/输出结构,让IDE能自动提示、编译器能提前报错;第三层是运行时可插拔——借助Nx的project graph和semantic-release的版本策略,实现技能包的独立发布、灰度更新与依赖追溯。它不依赖任何AI框架,却为后续集成LLM调用、RAG检索、工具调用等高级能力打下坚实底座。适合正在用Node.js构建自动化服务、工作流引擎、运维机器人或低代码后端的开发者,尤其适合团队已有Nx monorepo基础、正面临“功能越加越多,代码越来越难维护”困境的中大型项目。

2. 整体架构设计与选型逻辑

2.1 为什么必须用Nx而非Lerna或pnpm workspaces?

很多人看到monorepo第一反应是“用pnpm workspace就够了”,但“agent-skills”的核心诉求是跨项目能力复用与版本隔离,这恰恰是pnpm workspace的短板。举个真实例子:我们有一个@acme/skill-http包,v2.1.0版本修复了JWT token自动刷新的竞态问题,但财务系统的Agent还在用v1.8.0(因为它的CI流程不允许升级),而新上线的客服机器人必须用v2.1.0才能对接新版CRM。如果用pnpm workspace,所有包共享同一份node_modules,根本无法实现这种细粒度的版本共存。Nx则通过project graph明确声明了每个skill包的implicitDependenciestargets,配合nx run-many --target=build --projects=@acme/skill-http,@acme/skill-file命令,能精准控制构建范围;更重要的是,Nx的affected命令能自动识别出“修改@acme/skill-http后,哪些Agent项目需要重新测试”,这是Lerna的changed命令完全做不到的——Lerna只看git diff,而Nx看的是真实的依赖拓扑。

提示:Nx的project.jsontargets.build.outputs字段必须显式声明["{workspaceRoot}/dist/libs/skill-http"],否则nx affected --target=test会漏掉依赖该skill的Agent测试用例。

2.2 TypeScript为何是不可替代的基石?

这里不是“用不用TypeScript”的选择题,而是“不用TypeScript就无法实现技能契约”的硬性要求。以最基础的FileReadSkill为例,它的接口定义必须同时约束三件事:输入参数的结构(路径、编码)、返回值的类型(Buffer或string)、以及可能抛出的错误类型(FileNotFoundErrorPermissionDeniedError)。如果用JavaScript,你只能靠文档约定,而实际调用时传入{path: 123}这种数字路径,运行时才会报错。TypeScript的泛型能力则让契约更进一步:Skill<TInput, TOutput, TError>基类强制所有技能继承时指定三元组,IDE在agent.execute('file-read', {path: '/tmp/data.json'})时就能实时提示path必须是string,且返回值自动推导为Promise<string>。更关键的是,TypeScript的declare module机制让我们能为Node.js内置模块(如fs.promises)添加精确的类型补丁——比如为fs.promises.readFileencoding参数添加'utf8' | 'base64' | 'hex'字面量联合类型,避免用户传入非法字符串导致运行时崩溃。

2.3 semantic-release如何解决技能包的可信交付?

技能包一旦被多个Agent项目引用,其版本稳定性就关乎整个系统的可靠性。“agent-skills”采用semantic-release的commit message规范(feat:、fix:、chore:)自动触发发布,但这只是表象。真正的价值在于它与Nx的深度集成:每次nx release命令执行时,semantic-release会扫描所有libs/skill-*目录下的package.json,根据commit历史计算出每个skill的语义化版本号(如@acme/skill-http@3.2.0),并生成对应的GitHub Release Notes。更重要的是,它强制要求所有技能包的peerDependencies必须显式声明对@acme/agent-core的版本范围(如^2.0.0),这样当agent-core发布v3.0.0时,semantic-release会拒绝发布任何未适配新core的skill包——这个检查是在CI流水线里由nx run skill-http:check-peer-deps脚本完成的,而不是靠人工review。我们曾因此拦截了7次因疏忽导致的不兼容发布。

3. 核心技能类型定义与实现细节

3.1 Skill基类的设计哲学:最小契约,最大自由

Skill基类只有12行代码,却定义了整个体系的骨架:

export abstract class Skill<TInput, TOutput, TError extends Error = Error> { abstract readonly id: string; abstract readonly description: string; abstract readonly inputSchema: JSONSchema7; abstract readonly outputSchema: JSONSchema7; abstract execute( input: TInput, context: SkillExecutionContext ): Promise<TOutput>; protected validateInput(input: unknown): TInput { // 使用ajv进行JSON Schema校验 const valid = this.inputValidator.validate(input); if (!valid) throw new ValidationError(this.inputValidator.errorsText()); return input as TInput; } }

注意三个关键设计点:第一,inputSchemaoutputSchema不是装饰器或注释,而是运行时可访问的JSONSchema7对象——这意味着技能可以被可视化编排工具(如低代码工作流设计器)直接读取并生成表单;第二,execute方法接收SkillExecutionContext,其中包含loggertimeoutMsabortSignal等统一上下文,避免每个技能重复实现超时控制;第三,validateInput是受保护方法,强制所有子类在execute前调用它,但校验逻辑由ajv实例统一管理,保证错误信息格式一致。我们刻意避免在基类中加入retrycircuitBreaker等高级特性,因为这些属于执行策略,应由Agent运行时根据场景动态注入,而非固化在技能内部。

3.2 HTTP技能的实战实现:从简单请求到企业级健壮性

HttpSkill是使用频率最高的技能,但它的实现远不止axios.get()。我们拆解出五个必须处理的企业级需求:

  1. 认证链式注入:支持Bearer Token、API Key、OAuth2 Client Credentials三种模式,且允许组合(如先用Client Credentials获取Token,再用Token调用API)。实现方式是定义AuthStrategy接口,每个策略返回Promise<AuthHeader>,技能执行时按顺序调用。
  2. 响应体智能解析:根据Content-Type头自动选择解析方式——application/jsonJSON.parse()text/*直接返回string,application/octet-stream返回Buffer,并缓存原始response.data供后续技能使用。
  3. 错误标准化映射:将HTTP状态码401/403映射为UnauthorizedError,404映射为NotFoundError,500+映射为ServiceUnavailableError,所有错误都携带originalResponse属性,方便调试。
  4. 请求ID透传:从context.requestId中提取唯一ID,注入到X-Request-ID头,并记录到日志中,实现全链路追踪。
  5. 敏感字段脱敏:在日志中自动过滤AuthorizationCookie等头字段,且支持自定义脱敏规则(如password字段值替换为[REDACTED])。
// libs/skill-http/src/lib/http.skill.ts export class HttpSkill extends Skill<HttpRequestInput, HttpResponseOutput> { readonly id = 'http'; readonly description = '发起HTTP请求并处理响应'; readonly inputSchema = { type: 'object', properties: { url: { type: 'string', format: 'uri' }, method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'] }, headers: { type: 'object', additionalProperties: { type: 'string' } }, body: { type: ['string', 'object', 'null'] } }, required: ['url', 'method'] }; async execute(input: HttpRequestInput, context: SkillExecutionContext) { const validated = this.validateInput(input); const config: AxiosRequestConfig = { url: validated.url, method: validated.method, headers: await this.resolveAuthHeaders(validated), data: validated.body, timeout: context.timeoutMs || 10000, signal: context.abortSignal }; try { const response = await axios(config); return { status: response.status, statusText: response.statusText, headers: response.headers, data: this.parseResponseData(response) }; } catch (error) { throw this.mapHttpError(error, validated.url); } } }

注意:parseResponseData方法中,对application/json响应会额外校验response.data是否符合outputSchema定义的JSON Schema,这步校验在技能层面完成,避免错误数据流入下游技能。

3.3 文件系统技能的边界控制:安全永远是第一位的

FileReadSkill看似简单,但生产环境必须解决三个致命问题:路径遍历攻击、大文件内存溢出、权限误判。我们的解决方案是:

  • 路径规范化:使用path.normalize()+path.resolve()双重校验,确保最终路径不超出预设根目录(如/var/agent-data)。具体实现是const resolvedPath = path.resolve(rootDir, input.path); if (!resolvedPath.startsWith(rootDir)) throw new SecurityError('Path traversal attempt');
  • 流式读取:对大于1MB的文件,自动切换为fs.createReadStream()并pipe到stream.Transform,避免Buffer占用过多内存。技能返回值类型根据input.stream布尔值动态变化:stream=true时返回Readable,否则返回Buffer|string
  • 权限预检:在execute开始前调用fs.access(resolvedPath, fs.constants.R_OK),失败时抛出PermissionDeniedError而非ENOENT,防止攻击者利用错误信息探测文件存在性。
// libs/skill-file/src/lib/file-read.skill.ts export class FileReadSkill extends Skill<FileReadInput, FileReadOutput> { readonly id = 'file-read'; readonly description = '安全地读取文件内容'; readonly inputSchema = { type: 'object', properties: { path: { type: 'string', minLength: 1 }, encoding: { type: 'string', enum: ['utf8', 'base64', 'hex'] }, stream: { type: 'boolean', default: false } }, required: ['path'] }; async execute(input: FileReadInput, context: SkillExecutionContext) { const resolvedPath = this.sanitizePath(input.path); await this.checkReadPermission(resolvedPath); if (input.stream) { return fs.createReadStream(resolvedPath, { encoding: input.encoding }); } const stat = await fs.stat(resolvedPath); if (stat.size > 1024 * 1024) { throw new ValidationError('File too large for buffer read. Use stream=true'); } return fs.readFile(resolvedPath, input.encoding || 'utf8'); } }

4. Agent运行时与技能注册机制

4.1 技能注册的两种模式:静态注入 vs 动态发现

Agent启动时加载技能有两种方式,选择取决于部署场景:

  • 静态注入(推荐用于Kubernetes Deployment):在Agent初始化时,通过new Agent({ skills: [new HttpSkill(), new FileReadSkill()] })显式传入技能实例列表。优点是启动快、依赖关系清晰、便于单元测试;缺点是每次新增技能都要修改Agent代码。
  • 动态发现(推荐用于Serverless Function):Agent启动时扫描node_modules/@acme/skill-*目录,通过require.resolve('@acme/skill-http/package.json')读取package.json中的main字段,动态require()并实例化。这种方式支持热插拔——只需部署新技能包,无需重启Agent。但必须解决两个问题:一是模块缓存,我们用delete require.cache[modulePath]强制重载;二是类型安全,动态require返回any,我们通过instanceof校验确保导出的是Skill子类。
// apps/agent-core/src/agent.ts export class Agent { private readonly skills = new Map<string, Skill<any, any>>(); constructor(options: AgentOptions = {}) { if (options.skills) { options.skills.forEach(skill => this.registerSkill(skill)); return; } // 动态发现模式 const skillPackages = this.findSkillPackages(); for (const pkg of skillPackages) { try { const module = require(pkg.main); const skill = module.default || module; if (skill.prototype instanceof Skill) { this.registerSkill(new skill()); } } catch (error) { console.warn(`Failed to load skill from ${pkg.name}:`, error); } } } private registerSkill(skill: Skill<any, any>) { if (this.skills.has(skill.id)) { throw new Error(`Duplicate skill ID: ${skill.id}`); } this.skills.set(skill.id, skill); } }

4.2 执行上下文(ExecutionContext)的设计要点

SkillExecutionContext不是简单的参数传递对象,而是Agent与技能之间的契约信封。它包含五个核心字段:

  1. requestId: string:全局唯一请求ID,用于日志关联和分布式追踪。
  2. timeoutMs: number:当前执行的超时时间,由Agent根据任务优先级设定(高优任务30s,低优任务300s)。
  3. abortSignal: AbortSignal:支持外部中断,比如用户取消任务时,Agent可调用abortController.abort()
  4. logger: Logger:结构化日志实例,自动注入{skillId, requestId, timestamp}等上下文字段。
  5. metadata: Record<string, unknown>:开放字段,供技能间传递临时数据(如HTTP技能将response.headers['X-RateLimit-Remaining']写入metadata,限流技能可读取)。

关键设计是logger的实现:我们不直接传递console,而是封装为Pino实例,并设置level: 'info',但允许技能通过context.logger.level = 'debug'临时提升日志级别——这比在每个技能里写if (process.env.DEBUG) console.debug()优雅得多。

4.3 技能执行的生命周期钩子

为了支持审计、监控和调试,我们在Agent.execute()中嵌入了四个可选钩子:

  • beforeExecute: 在技能execute方法调用前触发,可用于记录输入参数、检查权限。
  • afterExecute: 在技能成功返回后触发,可用于记录输出、更新指标。
  • onError: 在技能抛出错误时触发,可用于错误分类、告警通知。
  • onTimeout: 在技能执行超时时触发,可用于清理资源、发送超时告警。

钩子函数通过AgentOptions.hooks传入,类型为Partial<AgentHooks>。特别注意onTimeout的实现:它必须在AbortSignal触发后立即执行,但我们不能在catch块中处理,因为超时错误可能被技能内部捕获。正确做法是监听abortSignal.onabort事件,并在Agent内部维护一个Map<string, AbortController>来管理每个执行的控制器。

// apps/agent-core/src/agent.ts async execute<TInput, TOutput>( skillId: string, input: TInput, options: ExecuteOptions = {} ): Promise<TOutput> { const skill = this.skills.get(skillId); if (!skill) throw new SkillNotFoundError(skillId); const abortController = new AbortController(); const context: SkillExecutionContext = { requestId: options.requestId || uuidv4(), timeoutMs: options.timeoutMs || 30000, abortSignal: abortController.signal, logger: this.logger.child({ skillId }), metadata: {} }; // 设置超时钩子 if (options.timeoutMs && options.hooks?.onTimeout) { setTimeout(() => { if (!abortController.signal.aborted) { abortController.abort(); options.hooks.onTimeout({ skillId, input, context }); } }, options.timeoutMs); } try { if (options.hooks?.beforeExecute) { options.hooks.beforeExecute({ skillId, input, context }); } const result = await skill.execute(input, context); if (options.hooks?.afterExecute) { options.hooks.afterExecute({ skillId, input, result, context }); } return result; } catch (error) { if (options.hooks?.onError) { options.hooks.onError({ skillId, input, error, context }); } throw error; } }

5. 工程化实践与避坑指南

5.1 Nx workspace配置的黄金三原则

我们在12个Agent项目中沉淀出Nx配置的三条铁律:

  1. Project Graph必须显式声明隐式依赖:在libs/skill-http/project.json中,"implicitDependencies": ["@acme/agent-core"]不能省略。否则nx affected --target=build会漏掉需要重建的Agent项目,导致运行时Cannot find module '@acme/agent-core'
  2. 所有技能包必须启用"buildable": true:即使技能本身不产出bundle,也要在project.json中设置"targets.build.options.outputPath": "dist/libs/skill-http"。这是semantic-release识别发布包的前提。
  3. 禁止在libs/下创建index.ts导出所有技能:看似方便,实则破坏了tree-shaking。正确的做法是每个技能包有独立入口(如@acme/skill-http导出HttpSkill类),Agent项目按需导入import { HttpSkill } from '@acme/skill-http'

5.2 TypeScript配置的致命陷阱

tsconfig.json中两个常被忽略的配置会引发灾难性后果:

  • "skipLibCheck": true:必须关闭!开启后TypeScript会跳过node_modules.d.ts文件的类型检查,导致@acme/skill-http依赖的axios类型定义失效,HttpResponseOutputdata字段类型推导错误。
  • "noUncheckedIndexedAccess": true:建议开启。它会让obj[key]的类型变为T | undefined,强制你处理undefined分支,避免Cannot read property 'xxx' of undefined运行时错误。我们在SkillExecutionContextmetadata字段上因此提前发现了3个未处理的空值bug。

5.3 semantic-release的CI配置要点

GitHub Actions中semantic-release的配置有三个关键点:

  1. Token权限必须最小化GITHUB_TOKEN只需packages: writecontents: write权限,禁用secrets: read——技能包不涉及密钥管理。
  2. 发布前必须运行nx affected --target=build:在.releaserc中配置"verifyConditions": ["@semantic-release/github", "@semantic-release/exec"],其中@semantic-release/exec执行nx affected --target=build --base=$BASE_COMMIT --head=$HEAD_COMMIT,确保所有变更的技能都被构建。
  3. 版本号必须与Nx workspace版本对齐:在nx.json中设置"release": {"version": "2.0.0"},然后在semantic-releaseplugins中配置"@semantic-release/exec"执行nx version --specifier minor,保证workspace版本与技能包版本同步。

5.4 真实踩过的五个坑与解决方案

  1. 坑:技能包发布后Agent无法解析新版本
    原因:package-lock.json锁定了旧版本,而npm install默认不更新lockfile。
    解决:在CI中执行npm install --no-package-lock && npm ci,强制使用最新lockfile。

  2. 坑:HTTP技能在Node.js v18+报ReferenceError: TextEncoder is not defined
    原因:某些精简版Node.js发行版(如Alpine镜像)未内置TextEncoder
    解决:在libs/skill-http/src/polyfills.ts中添加globalThis.TextEncoder = require('util').TextEncoder,并在project.jsontargets.build.options.main中前置引入。

  3. 坑:文件读取技能在Windows路径下path.normalize()失效
    原因:path.normalize('C:\\..\\etc\\passwd')返回C:\\etc\\passwd,未检测到遍历。
    解决:改用path.win32.resolve()并对比path.win32.dirname(resolvedPath)是否等于预设根目录。

  4. 坑:动态发现模式下技能实例的this指向丢失
    原因:const skill = new (require(pkg.main).default)()中,default可能是命名导出而非默认导出。
    解决:统一技能包导出规范,在package.json中声明"exports": {".": "./src/index.ts"},并强制使用export default class HttpSkill extends Skill {...}

  5. 坑:Agent执行超时后,HTTP技能的axios请求未真正终止
    原因:axioscancelToken已被废弃,AbortSignal需通过config.signal传递。
    解决:升级axios到v1.2.0+,并在HttpSkill.execute()中显式传递signal: context.abortSignal

6. 可扩展性设计与未来演进方向

6.1 技能组合(Skill Composition)的实践模式

单一技能解决原子问题,但真实业务需要组合。我们定义了两种组合模式:

  • 管道式组合(Pipeline):技能A的输出直接作为技能B的输入,形成线性链。实现上,Agent提供pipe()方法:agent.pipe(['http', 'json-parse', 'validate-schema']),自动将前一个技能的result作为下一个技能的input。关键点是类型推导——pipe方法的泛型参数<T1, T2, T3>由第一个技能的TOutput和最后一个技能的TOutput决定,中间类型自动推导。
  • 并行式组合(Parallel):多个技能并发执行,结果合并。例如同时读取三个配置文件:agent.parallel(['file-read', 'file-read', 'file-read'], [{path: 'a.json'}, {path: 'b.json'}, {path: 'c.json'}])。难点在于错误聚合——我们设计ParallelExecutionError类,包含errors: Array<{skillId: string, error: Error}>,便于上层统一处理。

6.2 与LLM集成的预留接口

虽然“agent-skills”本身不依赖AI,但为LLM调用预留了标准接口:

  • Skill基类新增isToolCallReady: boolean属性,标识该技能是否可被LLM作为tool调用。
  • 定义ToolCallInput接口,包含functionName: string(技能ID)、arguments: string(JSON字符串),由Agent负责反序列化后调用对应技能。
  • 所有技能的description字段必须遵循OpenAI tool description格式:“Read a file from disk. Input: {path: string, encoding?: 'utf8' | 'base64'}”。

这样,当需要接入LLM时,只需编写一个LLMToolExecutor类,遍历agent.skills筛选出isToolCallReady === true的技能,即可无缝对接。

6.3 监控与可观测性的内置支持

每个技能执行都自动上报三个核心指标:

  • skill_execution_duration_seconds{skill_id, status}:直方图,记录执行耗时(status为successerror)。
  • skill_execution_total{skill_id, status}:计数器,累计执行次数。
  • skill_error_total{skill_id, error_type}:按错误类型细分的计数器(如ValidationErrorNetworkError)。

这些指标通过prom-client暴露为/metrics端点,Agent启动时自动注册。我们特意将指标收集逻辑放在Agent.execute()内部,而非技能内部,确保所有技能(包括第三方贡献的技能)都能被统一监控。

我在实际项目中发现,最有效的优化不是增加新技能,而是分析skill_execution_duration_seconds的p95分位数——当http技能的p95超过2s时,我们定位到DNS解析慢的问题,通过在Agent中预热dns.resolve()缓存,将p95降至300ms。这种基于真实指标的迭代,比凭经验猜测高效得多。

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

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

立即咨询