ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent Skills 工程化实践:TypeScript + Nx 构建可测试可治理的 AI 能力单元

Agent Skills 工程化实践:TypeScript + Nx 构建可测试可治理的 AI 能力单元 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 编译器没报错因为用了anyLLM 却因字段名不匹配无法提取数据整条决策链路中断而监控告警只显示“LLM 调用超时”。直到我们引入agent-skills的设计范式才真正把这个问题根治。它的核心不是多写几行代码而是建立三层契约接口契约Interface Contract每个 Skill 必须实现SkillTInput, TOutput接口强制声明输入类型TInput和输出类型TOutput行为契约Behavior Contract每个 Skill 必须提供validateInput(input: TInput): Promisevoid方法在执行前校验输入合法性比如手机号格式、日期范围元数据契约Metadata Contract每个 Skill 必须导出metadata: SkillMetadata对象包含id、description、requiredPermissions、timeoutMs等字段供调度器统一管理。这三层契约让 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 或 TurborepoNx 的优势不在“能做 monorepo”而在于它对TypeScript 工程的深度理解。当我们把 skills 拆成多个包时Nx 的project.json配置能精确控制每个包的构建、测试、lint 行为。比如myorg/skills-finance包依赖myorg/skills-coreNx 能自动分析依赖图确保 core 包变更时只重新构建和测试 finance 包而不是全量跑 CI。更重要的是 Nx 的代码影响分析affected commands。假设我们修改了SkillTInput, TOutput接口的定义Nx 能精准识别出哪些 skills 实现了这个接口哪些测试用例引用了它然后只运行受影响的测试——这在上百个 skills 的项目里把 CI 时间从 22 分钟压到 4 分钟。而 pnpm workspaces 只能做依赖安装Turborepo 的缓存策略对 TypeScript 类型检查的支持不如 Nx 原生。还有一个常被忽略的点Nx 的 generator 功能。我们自定义了一个nx g skill --namesendEmail --domaincommunication命令它会自动创建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 接口的四个核心成员及其设计哲学SkillTInput, TOutput接口看似简单但每个成员都承载着明确的工程意图。我们不把它当作一个函数签名而是一个最小可行服务契约export interface SkillTInput, TOutput { // 1. id全局唯一标识用于调度器寻址和日志追踪 readonly id: string; // 2. execute核心执行方法返回 PromiseTOutput // 设计要点不接受 context 参数所有依赖如 logger、config通过构造函数注入 execute(input: TInput): PromiseTOutput; // 3. validateInput输入前置校验失败时抛出 ValidationError // 设计要点必须是 async允许做轻量级外部校验如检查用户是否存在 validateInput(input: TInput): Promisevoid; // 4. metadata描述性信息供 UI 展示、权限控制、调度策略使用 readonly metadata: SkillMetadata; }为什么execute不接受context参数这是刻意为之的依赖倒置。早期我们把logger、config、httpClient全部塞进execute的第二个参数结果导致测试时要 mock 一堆对象setup 代码比业务逻辑还长某个技能想换用不同的 http client必须改所有调用方日志打点位置不统一有的在 execute 开头有的在结尾。现在改为构造函数注入export class SendEmailSkill implements SkillSendEmailInput, SendEmailOutput { constructor( private readonly emailClient: EmailClient, private readonly logger: Logger, private readonly config: Config ) {} readonly id send-email; readonly metadata { /* ... */ }; async execute(input: SendEmailInput): PromiseSendEmailOutput { 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): Promisevoid { 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[]; }这些字段直接驱动运行时行为。例如调度器在执行前会检查当前用户 token 是否包含metadata.requiredPermissions中的所有权限启动一个Promise.race([skill.execute(input), timeoutPromise])超时则中断并记录timeoutMs如果执行失败且isIdempotent为 true则自动重试 2 次若为 false则直接失败如果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 Mapstring, Skillany, any(); // 注册技能强制类型检查避免重复注册 registerTInput, TOutput(skill: SkillTInput, 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 实例 getTInput, TOutput(id: string): SkillTInput, TOutput | undefined { return this.skills.get(id) as SkillTInput, TOutput; } // 批量获取用于 Agent 的 tool call 解析 getBatch(ids: string[]): ArraySkillany, any { return ids.map(id this.skills.get(id)).filter(Boolean) as ArraySkillany, 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 的关键设计点在于类型擦除与安全恢复。getTInput, 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-workspacelatest开始而是用 Nx 的nx/nodepreset因为它内置了 Node.js 项目最佳实践npx create-nx-workspacelatest my-agent-skills \ --presetnode \ --appNameskills-core \ --stylecss \ --lintereslint \ --packageManagerpnpm \ --nxCloudfalse创建完成后立即删除默认生成的apps/skills-core这是个应用我们要的是库然后创建核心包nx g nx/node:library skills-core --directorylibs --no-interactive nx g nx/node:library skills-finance --directorylibs --no-interactive nx g nx/node:library skills-utility --directorylibs --no-interactive最终的包结构是libs/ ├── skills-core/ # 定义 Skill 接口、基础异常、registry ├── skills-finance/ # 实现金融类技能征信查询、反洗钱扫描等 ├── skills-utility/ # 实现通用技能发送邮件、生成 PDF、调用 REST API为什么skills-core必须是最底层因为skills-finance和skills-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:sharedNx 就知道它们共享同一套类型定义变更时需联动。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 SkillTInput, TOutput { readonly id: string; execute(input: TInput): PromiseTOutput; validateInput(input: TInput): Promisevoid; 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 Mapstring, Skillany, any(); registerTInput, TOutput(skill: SkillTInput, TOutput): void { if (this.skills.has(skill.id)) { throw new Error(Skill with id ${skill.id} already registered); } this.skills.set(skill.id, skill); } getTInput, TOutput(id: string): SkillTInput, TOutput | undefined { return this.skills.get(id) as SkillTInput, TOutput; } getBatch(ids: string[]): ArraySkillany, any { return ids.map(id this.skills.get(id)).filter(Boolean) as ArraySkillany, 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 Skillany, 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 Skillany, 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 SkillSendEmailInput, 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): PromiseSendEmailOutput { 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): Promisevoid { 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: testexample.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: testexample.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.4Nx 的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中我们监听push到main分支然后运行nx build构建所有包运行nx test运行所有测试运行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 默认设置CItrue而semantic-release在 CI 环境下会跳过某些检查。我们显式禁用它确保流程一致。5. 常见问题与排查技巧实录那些只有踩过坑才知道的事5.1 “TypeScript 编译报错Cannot find module ‘myorg/skills-core’” —— 路径映射陷阱这是 Nx monorepo 里最经典的坑。当你在libs/skills-finance中 importmyorg/skills-coreTypeScript 编译器却报错找不到模块原因通常是tsconfig.base.json中的paths配置没生效。正确做法是确保libs/skills-finance/tsconfig.json继承了tsconfig.base.json。检查libs/skills-finance/tsconfig.json的extends字段{ 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.json的paths配置不完整{ 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]);问题在于timeoutPromise的setTimeout在Promise.race调用前就启动了。如果调度器本身有 2 秒延迟比如在做权限校验那么timeoutPromise已经跑了 2 秒留给skill.execute的时间只剩
返回列表