Agent Skills 工程化实践:TypeScript + Nx 构建可测试可治理的 AI 能力单元
2026/9/17 0:44:01 网站建设 项目流程

1. 项目概述:一个面向工程化落地的 Agent 能力抽象层

“agent-skills” 这个名字乍看像某个开源库的 npm 包名,但真正拆开来看,它不是一个玩具 demo,而是一套可复用、可测试、可组合、可演进的 Agent 行为能力抽象体系。我第一次在团队内部技术分享会上听到这个词时,它正被用在一套面向金融风控场景的自动化决策引擎里——不是写个 prompt 就完事,而是把“查征信”、“比对工商信息”、“调取历史交易流水”、“生成风险摘要”这些动作,全部封装成独立、带契约、有类型约束、能被统一调度的“技能单元”。这正是它和普通 LLM 工具调用(tool calling)的本质区别:它不只关注“能调什么”,更关注“怎么管、怎么验、怎么扩、怎么测”

核心关键词agent-skills在这里不是泛指任何 agent 的能力,而是特指一种工程化设计范式:将大模型驱动的智能体(Agent)所依赖的外部交互能力,从 prompt 工程中剥离出来,以标准接口 + 类型定义 + 生命周期管理的方式进行建模。它天然适配TypeScript的强类型系统,依赖Node.js提供的稳定运行时与丰富生态,借助Nx实现多包协同开发与增量构建,再通过semantic-release完成语义化版本发布与 changelog 自动化。这不是一个“学了就能吹”的概念,而是我在三个真实项目中反复打磨出来的落地路径:一个电商客服意图路由系统、一个企业级文档智能归档平台、一个嵌入式设备远程诊断助手。它们共同验证了一件事——当 Agent 从 PoC 走向生产环境,skills 不再是锦上添花的插件,而是整个系统可靠性的基石

适合谁来参考?如果你正在用 TypeScript 写 Node.js 后端,并且已经越过“让 LLM 回答问题”这个阶段,开始思考“如何让 LLM 稳定调用数据库”、“如何让多个技能按条件编排”、“如何给技能加超时和重试”、“如何在 CI/CD 中自动验证技能契约是否被破坏”,那么这套设计就是为你准备的。它不教你怎么写 prompt,也不讲大模型原理,只解决一个现实问题:怎么把 AI 能力,变成像 HTTP 接口、数据库连接池一样可维护、可监控、可替换的基础设施组件。下面我会从设计思路、核心细节、实操实现到排障经验,一层层剥开它的全貌。

2. 整体设计思路:为什么必须把 skills 单独抽象出来?

2.1 从“硬编码工具调用”到“契约化技能管理”的演进路径

最早我们做 Agent 时,所有外部操作都直接写在 LLM 的 system prompt 里:“你有以下工具:1. 查询用户订单(调用 /api/orders?uid={uid});2. 发送短信(调用 /api/sms/send)……”。这种写法在 MVP 阶段很爽,但上线两周后就暴露出致命问题:技能逻辑和 prompt 混在一起,改一个 API 地址要同时改代码和 prompt,测试无法覆盖,错误堆栈找不到源头,上线前不敢动。我亲眼见过一次线上事故:运维同学更新了短信网关域名,但忘了同步修改 prompt 里的 URL,结果所有“发送验证码”技能全部返回“404”,而日志里只有一行“LLM returned empty response”,排查花了 37 分钟。

后来我们尝试把工具函数抽成独立模块,比如smsService.send(),看起来干净了,但很快又卡在新瓶颈上:每个技能的输入输出结构五花八门,没有统一校验,前端传错字段、后端少返回字段,LLM 解析失败就静默降级,用户感知就是“AI 突然不会说话了”。有一次风控系统里,“查询企业股权结构”技能本该返回[{name: string, ratio: number}],但上游接口变更后返回了[{name: string, share_ratio: number}],TypeScript 编译器没报错(因为用了any),LLM 却因字段名不匹配无法提取数据,整条决策链路中断,而监控告警只显示“LLM 调用超时”。

直到我们引入agent-skills的设计范式,才真正把这个问题根治。它的核心不是多写几行代码,而是建立三层契约

  • 接口契约(Interface Contract):每个 Skill 必须实现Skill<TInput, TOutput>接口,强制声明输入类型TInput和输出类型TOutput
  • 行为契约(Behavior Contract):每个 Skill 必须提供validateInput(input: TInput): Promise<void>方法,在执行前校验输入合法性(比如手机号格式、日期范围);
  • 元数据契约(Metadata Contract):每个 Skill 必须导出metadata: SkillMetadata对象,包含iddescriptionrequiredPermissionstimeoutMs等字段,供调度器统一管理。

这三层契约,让 skills 从“能跑就行”的脚本,变成了“可验证、可审计、可治理”的服务单元。Nx 的 workspace 架构天然支持这种分层——我们可以把@myorg/skills-core(定义契约)、@myorg/skills-finance(金融类技能实现)、@myorg/skills-utility(通用工具技能)拆成独立包,各自有自己的测试、CI、版本号,互不影响。

2.2 为什么选 TypeScript 而不是 JavaScript 或 Python?

有人会问:Python 不是更流行于 AI 领域吗?为什么坚持用 TypeScript?答案很实在:类型即文档,类型即测试,类型即协作边界。在 agent-skills 场景下,TypeScript 的价值远超语法糖。

举个真实例子:我们有个技能叫fetchUserCreditReport,它需要调用第三方征信接口。最初用 JS 写,接口返回结构复杂,包含嵌套数组、可选字段、时间戳字符串。前端同学传参时少传了一个reportType字段,后端函数没做校验,直接发请求,对方返回 400 错误,但错误信息模糊,日志里只有一行Error: Bad Request。换成 TypeScript 后,我们定义了严格的输入类型:

export interface CreditReportInput { userId: string; reportType: 'full' | 'summary' | 'risk'; includeHistory?: boolean; // 注意:这里明确标注了 requiredPermissions,后续权限校验直接读取 requiredPermissions: ['credit:read']; }

编译阶段就报错:“Property 'reportType' is missing in type '{ userId: string; }' but required in type 'CreditReportInput'.”。更重要的是,这个类型定义自动成为前端 SDK 的依据——我们用tsoa自动生成 OpenAPI spec,前端直接npx openapi-typescript生成 types,连字段名拼错都不会发生。

再看一个更关键的点:LLM 的 tool call 参数解析。OpenAI 的function_call返回的是纯 JSON,字段名大小写、空格、缺失字段全靠 runtime 判断。TypeScript 的zod库配合parse方法,能把原始 JSON 安全转成强类型对象:

import { z } from 'zod'; const creditReportSchema = z.object({ userId: z.string().min(1), reportType: z.enum(['full', 'summary', 'risk']), includeHistory: z.boolean().optional().default(false), }); // LLM 返回的 rawArgs 是 any 类型的 JSON const parsedInput = creditReportSchema.parse(rawArgs); // 如果 rawArgs 是 { userId: "u123" },这里会抛出 ZodError,提示缺少 reportType

这个parse调用,本质是运行时的类型守门员。它比任何单元测试都早一步拦截非法输入,而且错误信息精准到字段级别。Node.js 提供了稳定的 V8 引擎和成熟的异步 I/O 支持,而 TypeScript 编译后的 JS 代码在 Node.js 上零性能损耗——这两者结合,让 skills 的可靠性有了底层保障。

2.3 Nx:为什么不用 pnpm workspaces 或 Turborepo?

Nx 的优势不在“能做 monorepo”,而在于它对TypeScript 工程的深度理解。当我们把 skills 拆成多个包时,Nx 的project.json配置能精确控制每个包的构建、测试、lint 行为。比如@myorg/skills-finance包依赖@myorg/skills-core,Nx 能自动分析依赖图,确保 core 包变更时,只重新构建和测试 finance 包,而不是全量跑 CI。

更重要的是 Nx 的代码影响分析(affected commands)。假设我们修改了Skill<TInput, TOutput>接口的定义,Nx 能精准识别出哪些 skills 实现了这个接口,哪些测试用例引用了它,然后只运行受影响的测试——这在上百个 skills 的项目里,把 CI 时间从 22 分钟压到 4 分钟。而 pnpm workspaces 只能做依赖安装,Turborepo 的缓存策略对 TypeScript 类型检查的支持不如 Nx 原生。

还有一个常被忽略的点:Nx 的 generator 功能。我们自定义了一个nx g skill --name=sendEmail --domain=communication命令,它会自动创建:

  • libs/skills-communication/src/lib/send-email/send-email.skill.ts(带完整契约实现模板)
  • libs/skills-communication/src/lib/send-email/send-email.spec.ts(含输入校验、超时、mock 调用的测试骨架)
  • libs/skills-communication/src/lib/send-email/send-email.metadata.ts(预填 id、description、timeoutMs)

这个 generator 保证了所有 skills 的结构一致性,新同学第一天就能写出符合规范的代码,而不是靠看文档猜怎么写。Nx 不是银弹,但它把“写规范代码”的成本,降到了最低。

3. 核心细节解析:Skills 的契约定义与生命周期管理

3.1 Skill 接口的四个核心成员及其设计哲学

Skill<TInput, TOutput>接口看似简单,但每个成员都承载着明确的工程意图。我们不把它当作一个函数签名,而是一个最小可行服务契约

export interface Skill<TInput, TOutput> { // 1. id:全局唯一标识,用于调度器寻址和日志追踪 readonly id: string; // 2. execute:核心执行方法,返回 Promise<TOutput> // 设计要点:不接受 context 参数,所有依赖(如 logger、config)通过构造函数注入 execute(input: TInput): Promise<TOutput>; // 3. validateInput:输入前置校验,失败时抛出 ValidationError // 设计要点:必须是 async,允许做轻量级外部校验(如检查用户是否存在) validateInput(input: TInput): Promise<void>; // 4. metadata:描述性信息,供 UI 展示、权限控制、调度策略使用 readonly metadata: SkillMetadata; }

为什么execute不接受context参数?这是刻意为之的依赖倒置。早期我们把loggerconfighttpClient全部塞进execute的第二个参数,结果导致:

  • 测试时要 mock 一堆对象,setup 代码比业务逻辑还长;
  • 某个技能想换用不同的 http client,必须改所有调用方;
  • 日志打点位置不统一,有的在 execute 开头,有的在结尾。

现在改为构造函数注入

export class SendEmailSkill implements Skill<SendEmailInput, SendEmailOutput> { constructor( private readonly emailClient: EmailClient, private readonly logger: Logger, private readonly config: Config ) {} readonly id = 'send-email'; readonly metadata = { /* ... */ }; async execute(input: SendEmailInput): Promise<SendEmailOutput> { this.logger.debug(`Sending email to ${input.to}`); const result = await this.emailClient.send({ to: input.to, subject: input.subject, body: input.body, }); return { success: true, messageId: result.id }; } async validateInput(input: SendEmailInput): Promise<void> { if (!input.to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.to)) { throw new ValidationError('Invalid email format'); } } }

这样,测试时只需传入jest.mock()的 mock 实例,execute方法本身变得纯粹、无副作用、易测试。而validateInput的 async 设计,让我们能在校验阶段做真正的业务判断。比如“查询用户余额”技能,validateInput不仅检查userId是否为空,还会调用用户服务确认该用户是否处于激活状态——如果用户已注销,直接拒绝执行,避免无效的下游调用。

3.2 SkillMetadata:不只是描述,更是调度策略的输入源

SkillMetadata看似只是个配置对象,但它实际是 skills 被纳入“智能体操作系统”的通行证。我们定义如下:

export interface SkillMetadata { id: string; description: string; // 关键字段:所需权限,用于 RBAC 控制 requiredPermissions: string[]; // 关键字段:超时时间,单位毫秒,防止技能阻塞整个 Agent timeoutMs: number; // 关键字段:是否幂等,决定重试策略 isIdempotent: boolean; // 关键字段:稳定性等级,用于灰度发布 stability: 'alpha' | 'beta' | 'stable'; // 可选字段:支持的模型,避免调度器把不兼容技能派给小模型 compatibleModels?: string[]; }

这些字段直接驱动运行时行为。例如,调度器在执行前会:

  1. 检查当前用户 token 是否包含metadata.requiredPermissions中的所有权限;
  2. 启动一个Promise.race([skill.execute(input), timeoutPromise]),超时则中断并记录timeoutMs
  3. 如果执行失败且isIdempotent为 true,则自动重试 2 次;若为 false,则直接失败;
  4. 如果stability'alpha',则只对 5% 的流量开放,其余流量 fallback 到旧逻辑。

这个设计让 skills 不再是孤立的函数,而是融入整个系统治理框架。我们甚至用metadata.compatibleModels实现了模型路由:当 LLM 返回{"name": "fetchUserCreditReport", "arguments": {...}}时,调度器发现当前使用的是gpt-3.5-turbo,而该技能的compatibleModels包含['gpt-4', 'claude-3'],就会拒绝执行并触发 fallback——避免小模型调用高复杂度技能导致解析失败。

3.3 技能注册中心(Skill Registry):如何安全地管理数百个技能实例

有契约,就得有管理中心。我们不把 skills 实例存在全局变量或单例里,而是用一个SkillRegistry类集中管理:

export class SkillRegistry { private readonly skills = new Map<string, Skill<any, any>>(); // 注册技能:强制类型检查,避免重复注册 register<TInput, TOutput>(skill: Skill<TInput, TOutput>): void { if (this.skills.has(skill.id)) { throw new Error(`Skill with id "${skill.id}" already registered`); } this.skills.set(skill.id, skill); } // 获取技能:返回类型安全的 Skill 实例 get<TInput, TOutput>(id: string): Skill<TInput, TOutput> | undefined { return this.skills.get(id) as Skill<TInput, TOutput>; } // 批量获取:用于 Agent 的 tool call 解析 getBatch(ids: string[]): Array<Skill<any, any>> { return ids.map(id => this.skills.get(id)).filter(Boolean) as Array<Skill<any, any>>; } // 健康检查:遍历所有技能,调用 validateInput 的空输入,确认契约有效 async healthCheck(): Promise<{ id: string; status: 'ok' | 'error'; error?: string }[]> { const results: Array<{ id: string; status: 'ok' | 'error'; error?: string }> = []; for (const [id, skill] of this.skills.entries()) { try { // 传入空对象,触发 validateInput 的基础校验 await skill.validateInput({} as any); results.push({ id, status: 'ok' }); } catch (e) { results.push({ id, status: 'error', error: e.message }); } } return results; } }

这个 registry 的关键设计点在于类型擦除与安全恢复get<TInput, TOutput>(id)方法用as断言是危险的,但我们通过register方法的严格类型约束,保证了 map 中存储的 skill 实例与其 id 的类型是绑定的。更进一步,我们在 Nx 的 e2e 测试中,会启动一个完整的 registry 实例,加载所有 skills,然后调用healthCheck()—— 这个测试成了每日 CI 的第一道防线,任何技能的输入类型变更(比如删掉一个必填字段)都会在这里暴露。

提示:registry 实例不应是单例。在 NestJS 环境中,我们把它注册为SINGLETON,但在纯 Express 应用中,我们为每个请求创建独立 registry 实例(注入不同权限集的 skills 子集),避免权限泄露。

4. 实操过程:从零搭建一个可发布的 agent-skills 工作区

4.1 初始化 Nx Workspace 与包结构规划

我们不从npx create-nx-workspace@latest开始,而是用 Nx 的@nx/nodepreset,因为它内置了 Node.js 项目最佳实践:

npx create-nx-workspace@latest my-agent-skills \ --preset=node \ --appName=skills-core \ --style=css \ --linter=eslint \ --packageManager=pnpm \ --nxCloud=false

创建完成后,立即删除默认生成的apps/skills-core(这是个应用,我们要的是库),然后创建核心包:

nx g @nx/node:library skills-core --directory=libs --no-interactive nx g @nx/node:library skills-finance --directory=libs --no-interactive nx g @nx/node:library skills-utility --directory=libs --no-interactive

最终的包结构是:

libs/ ├── skills-core/ # 定义 Skill 接口、基础异常、registry ├── skills-finance/ # 实现金融类技能:征信查询、反洗钱扫描等 ├── skills-utility/ # 实现通用技能:发送邮件、生成 PDF、调用 REST API

为什么skills-core必须是最底层?因为skills-financeskills-utility都要 importSkill接口。Nx 的project.json会自动添加implicitDependencies,确保 core 包变更时,其他包重新构建。我们还在libs/skills-core/project.json中配置了严格的 lint 规则:

{ "targets": { "lint": { "executor": "@nx/eslint:lint", "options": { "lintFilePatterns": ["libs/skills-core/**/*.ts"], "fix": true } } }, "tags": ["type:core", "scope:shared"] }

tags字段用于 Nx 的影响分析——当其他包的project.json也标记scope:shared,Nx 就知道它们共享同一套类型定义,变更时需联动。

4.2 实现 Skills-Core:接口、异常与 Registry 的完整代码

libs/skills-core/src/index.ts是整个体系的入口,导出所有公共契约:

// libs/skills-core/src/index.ts export * from './lib/skill'; export * from './lib/skill-registry'; export * from './lib/exceptions'; export * from './lib/metadata';

skill.ts定义核心接口:

// libs/skills-core/src/lib/skill.ts export interface Skill<TInput, TOutput> { readonly id: string; execute(input: TInput): Promise<TOutput>; validateInput(input: TInput): Promise<void>; readonly metadata: SkillMetadata; } // 基础异常类,所有 skills 抛出的错误都应继承它 export abstract class SkillError extends Error { constructor( message: string, public readonly code: string, public readonly cause?: unknown ) { super(message); this.name = 'SkillError'; } } // 输入校验失败专用异常 export class ValidationError extends SkillError { constructor(message: string) { super(message, 'VALIDATION_ERROR'); } } // 执行超时异常 export class TimeoutError extends SkillError { constructor(skillId: string, timeoutMs: number) { super(`Skill "${skillId}" timed out after ${timeoutMs}ms`, 'TIMEOUT_ERROR'); } }

skill-registry.ts实现注册中心:

// libs/skills-core/src/lib/skill-registry.ts import { Skill, SkillError, ValidationError } from './skill'; export class SkillRegistry { private readonly skills = new Map<string, Skill<any, any>>(); register<TInput, TOutput>(skill: Skill<TInput, TOutput>): void { 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): Skill<TInput, TOutput> | undefined { return this.skills.get(id) as Skill<TInput, TOutput>; } getBatch(ids: string[]): Array<Skill<any, any>> { return ids.map(id => this.skills.get(id)).filter(Boolean) as Array<Skill<any, any>>; } async healthCheck(): Promise<{ id: string; status: 'ok' | 'error'; error?: string }[]> { const results: Array<{ id: string; status: 'ok' | 'error'; error?: string }> = []; for (const [id, skill] of this.skills.entries()) { try { // 使用 skill.metadata.id 作为 key,避免类型断言 await (skill as any).validateInput({} as any); results.push({ id, status: 'ok' }); } catch (e) { results.push({ id, status: 'error', error: e instanceof Error ? e.message : String(e) }); } } return results; } }

注意healthCheck中的as any是权宜之计,因为 TypeScript 无法在循环中推断泛型。我们接受这点小瑕疵,换取了运行时的安全性。这个文件的单元测试覆盖率必须 100%,我们用 Jest 模拟各种异常场景:

// libs/skills-core/src/lib/skill-registry.spec.ts describe('SkillRegistry', () => { it('should register and get skill', () => { const registry = new SkillRegistry(); const mockSkill = { id: 'test-skill', execute: jest.fn(), validateInput: jest.fn(), metadata: { id: 'test-skill', description: '', requiredPermissions: [], timeoutMs: 5000, isIdempotent: true, stability: 'stable' } } as any as Skill<any, any>; registry.register(mockSkill); expect(registry.get('test-skill')).toBe(mockSkill); }); it('should throw on duplicate registration', () => { const registry = new SkillRegistry(); const mockSkill = { id: 'test-skill', /* ... */ } as any as Skill<any, any>; registry.register(mockSkill); expect(() => registry.register(mockSkill)).toThrow(); }); });

4.3 实现一个真实技能:SendEmailSkill(带完整测试与发布配置)

libs/skills-utility中,我们实现SendEmailSkill。首先定义输入输出类型:

// libs/skills-utility/src/lib/send-email/send-email.types.ts export interface SendEmailInput { to: string; subject: string; body: string; cc?: string[]; attachments?: { filename: string; content: Buffer }[]; } export interface SendEmailOutput { success: true; messageId: string; }

然后实现技能类:

// libs/skills-utility/src/lib/send-email/send-email.skill.ts import { Skill, SkillError, ValidationError } from '@myorg/skills-core'; import { SendEmailInput, SendEmailOutput } from './send-email.types'; export class SendEmailSkill implements Skill<SendEmailInput, SendEmailOutput> { constructor( private readonly emailClient: EmailClient, private readonly logger: Logger, private readonly config: Config ) {} readonly id = 'send-email'; readonly metadata = { id: 'send-email', description: 'Send an email to specified recipients', requiredPermissions: ['email:send'], timeoutMs: 10000, isIdempotent: false, stability: 'stable' as const, }; async execute(input: SendEmailInput): Promise<SendEmailOutput> { this.logger.debug(`Sending email to ${input.to}`); try { const result = await this.emailClient.send({ to: input.to, subject: input.subject, body: input.body, cc: input.cc, attachments: input.attachments, }); return { success: true, messageId: result.id }; } catch (e) { throw new SkillError( `Failed to send email: ${e instanceof Error ? e.message : String(e)}`, 'EMAIL_SEND_FAILED', e ); } } async validateInput(input: SendEmailInput): Promise<void> { if (!input.to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.to)) { throw new ValidationError('Invalid "to" email address'); } if (!input.subject || input.subject.trim().length === 0) { throw new ValidationError('Subject cannot be empty'); } if (!input.body || input.body.trim().length === 0) { throw new ValidationError('Body cannot be empty'); } } }

最后,导出工厂函数,方便 DI 容器注入:

// libs/skills-utility/src/lib/send-email/index.ts import { SendEmailSkill } from './send-email.skill'; import { EmailClient } from '../email-client'; import { Logger } from '../logger'; import { Config } from '../config'; export function createSendEmailSkill( emailClient: EmailClient, logger: Logger, config: Config ): SendEmailSkill { return new SendEmailSkill(emailClient, logger, config); }

测试文件send-email.spec.ts必须覆盖三种场景:

// libs/skills-utility/src/lib/send-email/send-email.spec.ts describe('SendEmailSkill', () => { let skill: SendEmailSkill; const mockEmailClient = { send: jest.fn() } as any as EmailClient; const mockLogger = { debug: jest.fn() } as any as Logger; const mockConfig = {} as any as Config; beforeEach(() => { skill = new SendEmailSkill(mockEmailClient, mockLogger, mockConfig); }); it('should throw ValidationError for invalid email', async () => { await expect(skill.validateInput({ to: 'invalid', subject: 'test', body: 'body' })).rejects.toThrow('Invalid "to" email address'); }); it('should execute successfully with valid input', async () => { mockEmailClient.send.mockResolvedValue({ id: 'msg_123' }); const result = await skill.execute({ to: 'test@example.com', subject: 'hello', body: 'world' }); expect(result).toEqual({ success: true, messageId: 'msg_123' }); }); it('should throw SkillError on email client failure', async () => { mockEmailClient.send.mockRejectedValue(new Error('Network timeout')); await expect(skill.execute({ to: 'test@example.com', subject: 'hello', body: 'world' })).rejects.toThrow('Failed to send email'); }); });

4.4 配置 semantic-release:实现全自动语义化发布

semantic-release的价值在于:它把“什么时候发版”这个主观决策,变成了基于 commit message 的客观规则。我们不需要人工判断“这个 PR 是 feature 还是 fix”,只要 commit message 符合约定,release 就自动触发。

首先,在 workspace 根目录安装:

pnpm add -D semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/github

然后创建.releaserc.json

{ "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", [ "@semantic-release/github", { "assets": [ {"path": "dist/libs/skills-core/*.tgz", "label": "skills-core"}, {"path": "dist/libs/skills-finance/*.tgz", "label": "skills-finance"}, {"path": "dist/libs/skills-utility/*.tgz", "label": "skills-utility"} ] } ] ] }

关键在commit-analyzer的配置。我们要求所有提交必须用 Conventional Commits 格式:

feat(skills-core): add SkillRegistry.healthCheck method fix(skills-utility): handle empty attachments in send-email skill chore(deps): update zod to v3.22.4

Nx 的nx release命令会自动调用semantic-release,但我们需要定制它,让它为每个包单独发布。在nx.json中配置:

{ "namedInputs": { "production": ["default", "^production"] }, "tasksRunnerOptions": { "default": { "runner": "@nrwl/nx-cloud", "options": { "cacheableOperations": ["build", "test", "lint", "release"] } } }, "targetDefaults": { "release": { "dependsOn": ["build"], "inputs": ["production"], "cache": true } } }

然后为每个包的project.json添加 release target:

// libs/skills-core/project.json { "targets": { "release": { "executor": "nx:run-commands", "options": { "command": "npx semantic-release --branches main --ci --no-ci --pkgRoot dist/libs/skills-core" } } } }

CI 流程(GitHub Actions)中,我们监听pushmain分支,然后:

  1. 运行nx build构建所有包;
  2. 运行nx test运行所有测试;
  3. 运行nx release—— 这会触发semantic-release,它会:
    • 分析main分支上自上次 release 以来的所有 commits;
    • 如果有feat:提交,版本号升minor(如 1.2.0 → 1.3.0);
    • 如果有fix:提交,版本号升patch(如 1.2.0 → 1.2.1);
    • 如果有BREAKING CHANGE:,版本号升major(如 1.2.0 → 2.0.0);
    • 生成 changelog,打 tag,发布到 npm registry。

注意:--no-ci参数是必须的,因为 GitHub Actions 的 runner 默认设置CI=true,而semantic-release在 CI 环境下会跳过某些检查。我们显式禁用它,确保流程一致。

5. 常见问题与排查技巧实录:那些只有踩过坑才知道的事

5.1 “TypeScript 编译报错:Cannot find module ‘@myorg/skills-core’” —— 路径映射陷阱

这是 Nx monorepo 里最经典的坑。当你在libs/skills-finance中 import@myorg/skills-core,TypeScript 编译器却报错找不到模块,原因通常是tsconfig.base.json中的paths配置没生效。

正确做法是:确保libs/skills-finance/tsconfig.json继承了tsconfig.base.json。检查libs/skills-finance/tsconfig.jsonextends字段:

{ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "../../dist/out-tsc", "types": ["node"] }, "files": [], "include": [], "references": [ { "path": "./tsconfig.lib.json" } ] }

如果extends指向的是./tsconfig.lib.json,那就错了。tsconfig.lib.json是给构建用的,tsconfig.json才是给编辑器和 tsc 用的。Nx 的 generator 有时会出错,手动修正即可。

另一个常见原因是tsconfig.base.jsonpaths配置不完整:

{ "compilerOptions": { "baseUrl": ".", "paths": { "@myorg/skills-core": ["libs/skills-core/src/index.ts"], "@myorg/skills-finance": ["libs/skills-finance/src/index.ts"], "@myorg/skills-utility": ["libs/skills-utility/src/index.ts"] } } }

注意:paths的 value 必须是.ts文件,不是dist目录。TypeScript 的路径映射是在编译前解析的,它需要源码路径。

5.2 “技能执行超时,但日志里没看到 timeoutMs 生效” —— Promise.race 的隐蔽陷阱

我们给每个 skill 设置了metadata.timeoutMs,并在调度器里用Promise.race包裹skill.execute(input)。但上线后发现,有些技能明明设置了5000,却跑了 15 秒才超时。排查发现,问题出在Promise.race的使用方式上:

// ❌ 错误写法:timeoutPromise 在 race 外部创建,时间从 now 开始算 const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new TimeoutError(skill.id, skill.metadata.timeoutMs)), skill.metadata.timeoutMs) ); return Promise.race([skill.execute(input), timeoutPromise]);

问题在于:timeoutPromisesetTimeoutPromise.race调用前就启动了。如果调度器本身有 2 秒延迟(比如在做权限校验),那么timeoutPromise已经跑了 2 秒,留给skill.execute的时间只剩

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

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

立即咨询