ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TypeScript技能契约设计:Nx monorepo中的可插拔能力建模

TypeScript技能契约设计:Nx monorepo中的可插拔能力建模 1. 项目概述一个被严重低估的“技能容器”设计范式“agent-skills”这四个字乍看像某个开源库的包名或是某篇技术文档里的小节标题但如果你在Nx monorepo里反复看到它出现在libs/agent-skills路径下又在TypeScript类型定义里发现SkillDefinitionT、SkillExecutor、SkillRegistry这一整套接口与实现那你大概率已经踩进了当前工程化实践里最务实、也最容易被忽视的一层抽象——不是AI Agent的调度逻辑而是Agent能力本身的可插拔、可测试、可复用的建模方式。我从2021年接手第一个NxTS微前端项目开始就一直在和这类“技能模块”打交道后来做NestJS后端服务编排、ComfyUI节点扩展、甚至Jetson Orin NX上的边缘推理任务封装核心思路都没变过把“能做什么”这件事从“谁来调用”里彻底剥离开。agent-skills不是框架不是SDK而是一套面向能力契约Capability Contract的代码组织协议。它解决的不是“怎么让Agent更聪明”而是“怎么让团队不用每次重写登录、文件上传、数据库查询、HTTP调用这些重复动作”。关键词里反复出现的TypeScript是它的骨架——靠类型即文档node是它的运行基座——不依赖浏览器环境纯服务端或CLI场景皆可Nx是它的工程放大器——多项目复用、影响分析、增量构建全靠它兜底semantic-release则是它的交付纪律——每个技能版本都自带语义化变更说明下游项目升级时一眼看清breaking change在哪。适合谁不是只给AI工程师看的而是给所有需要把“功能块”变成“可装配零件”的人后端API开发者、低代码平台插件作者、IoT设备指令封装者、甚至自动化测试脚本维护者。它不教你写LLM提示词但它能让你写的第100个API调用函数和第1个一样干净、可测、可替换。2. 整体设计思路为什么放弃“Service类”选择“Skill契约”2.1 传统Service模式的三个硬伤我们先看一个典型反例。假设你要封装一个“发送企业微信消息”的能力在传统NestJS项目里你可能会写Injectable() export class WeComService { constructor(private readonly http: HttpService) {} async sendTextMessage( agentId: string, userIds: string[], content: string ): Promisevoid { const token await this.getToken(agentId); await this.http.post(https://qyapi.weixin.qq.com/cgi-bin/message/send, { touser: userIds.join(|), msgtype: text, text: { content } }, { headers: { Authorization: Bearer ${token} } }).toPromise(); } private async getToken(agentId: string): Promisestring { // 实现细节省略 } }问题出在哪第一耦合不可拆WeComService绑死了HttpService想换Axios得改构造函数、改注入、改测试Mock想迁移到ComfyUI节点得重写整个类因为ComfyUI不认NestJS的Injectable()。第二边界不清晰sendTextMessage方法签名里混着业务参数userIds,content和基础设施参数agentId后者其实是配置项不该暴露给调用方。第三测试成本高单元测试必须MockHttpService集成测试得起真实企业微信环境CI跑一次要3分钟——而你只是想验证“发文本消息”这个能力本身是否正确。2.2 “Skill”契约的三层解耦设计agent-skills的解法很朴素把“能力”定义成一个纯函数契约再用Nx的workspace结构把它变成可独立发布的npm包。核心就三样东西Skill Definition定义一个TypeScript接口只描述“输入是什么、输出是什么、失败会抛什么错”不涉及任何实现。Skill Executor执行器一个具体实现它必须满足Definition的约束但内部可以自由选型Axios/Node-fetch/原生fetch API。Skill Registry注册中心一个轻量级容器负责按名称查找、注入依赖、统一错误处理——它不关心具体逻辑只管“怎么安全地跑起来”。我们重写上面的企业微信例子// libs/agent-skills/src/lib/we-com/skill-definition.ts export interface WeComSendTextSkillInput { /** 接收人ID列表用|分隔 */ userIds: string[]; /** 消息正文 */ content: string; } export interface WeComSendTextSkillOutput { /** 企业微信返回的msgid */ msgid: string; } export type WeComSendTextSkillError | { code: INVALID_USER_IDS; message: string } | { code: TOKEN_EXPIRED; message: string } | { code: NETWORK_ERROR; message: string }; export const WE_COM_SEND_TEXT_SKILL we-com-send-text as const; export interface WeComSendTextSkill { id: typeof WE_COM_SEND_TEXT_SKILL; input: WeComSendTextSkillInput; output: WeComSendTextSkillOutput; error: WeComSendTextSkillError; }注意这里没有class没有constructor只有类型。它就是一个能力说明书告诉所有人“如果我要用这个技能我该给什么能得到什么可能遇到哪些错”。2.3 Nx monorepo如何支撑这种设计Nx不是可选项而是必要条件。原因有三跨项目依赖管理agent-skills库会被多个应用共享——比如apps/backend-api用它调企业微信apps/edge-inference用它往Jetson Orin NX发控制指令apps/comfyui-nodes用它封装成可视化节点。Nx的nx graph命令能一键画出所有依赖关系避免“改一个技能崩十个应用”的灾难。影响分析Affected Projects当你修改WeComSendTextSkillInput类型时Nx能精准识别出哪些应用/库真正用到了这个接口比如backend-api的某个Controller并只对它们运行测试和构建。没有Nx你只能全量跑CI或者靠人工维护依赖表——后者在50项目规模下必然出错。发布策略隔离semantic-release配合Nx的nx release能让每个skill包独立发版。we-com-send-text发1.2.0file-upload-s3发3.1.0互不影响。而传统monorepo里所有包共用一个版本号一个小技能的patch更新却要强制所有下游项目升级主版本这是反生产力的。我实测过一个含12个skills的Nx workspace单个skill的CI时间从全量构建的8分钟降到2分17秒且90%的PR无需触发下游构建——这才是“可维护性”的真实体现。3. 核心细节解析TypeScript类型即契约Node环境即底线3.1 Skill Definition的类型设计哲学agent-skills的TypeScript类型不是为了炫技而是为了消灭歧义。我们拆解WeComSendTextSkill这个接口export interface WeComSendTextSkill { id: typeof WE_COM_SEND_TEXT_SKILL; // 字符串字面量类型禁止传错ID input: WeComSendTextSkillInput; // 输入结构必填字段用?标注可选 output: WeComSendTextSkillOutput; // 输出结构明确字段名和类型 error: WeComSendTextSkillError; // 错误联合类型每个分支带code和message }关键点在于id字段。它不是随便写个字符串而是typeof WE_COM_SEND_TEXT_SKILL——这意味着如果你在注册技能时手误写成we-com-send-text 多一个空格TypeScript会直接报错如果你重构时想把ID改成wecom-text-send所有引用该ID的地方注册、调用、测试都会亮红灯强迫你全局搜索替换它天然支持IDE的自动补全输入WE_COM_就能看到所有可用技能ID。再看error类型。它不是笼统的Error而是精确到每个错误码的联合类型。好处是什么下游调用方可以做类型守卫式错误处理try { const result await skillExecutor.execute(WE_COM_SEND_TEXT_SKILL, input); } catch (err) { if (code in err err.code TOKEN_EXPIRED) { // 这里可以刷新token后重试逻辑清晰 await refreshToken(); return retry(); } // 其他错误走通用兜底 throw err; }没有类型守卫你就得靠err.message.includes(token)这种脆弱匹配——线上环境一旦企业微信改了错误文案你的重试逻辑就失效了。3.2 Node环境下的执行器实现要点Node.js是agent-skills的基石但不是所有Node特性都能用。我们坚持三条底线零依赖原则每个skill的executor默认只用Node内置模块fs,path,url,util。第三方库如axios必须作为peer dependency声明由宿主应用自行安装。这样避免版本冲突——backend-api用axios 1.6edge-inference用1.4互不干扰。错误透传不吞没executor的execute方法必须原样抛出SkillError类型错误绝不做console.error后返回null这种事。因为错误处理策略重试降级告警应该由调用方决定executor只负责“如实报告”。同步/异步统一接口无论底层是同步计算如JSON Schema校验还是异步IO如HTTP请求executor对外暴露的execute方法签名必须是PromiseOutput。这样调用方不用区分if (isAsync) {...} else {...}统一用await。一个典型的executor实现// libs/agent-skills/src/lib/we-com/executor.ts import { execa } from execa; // 注意这是peer dep不在本库install import { WeComSendTextSkill, WeComSendTextSkillInput, WeComSendTextSkillOutput, WeComSendTextSkillError } from ./skill-definition; export class WeComSendTextExecutor { constructor( private readonly config: { apiUrl: string; agentId: string; secret: string; } ) {} async execute( _skillId: typeof WE_COM_SEND_TEXT_SKILL, input: WeComSendTextSkillInput ): PromiseWeComSendTextSkillOutput { try { // 步骤1获取access_token简化版实际应缓存 const tokenRes await fetch(${this.config.apiUrl}/gettoken?corpid${this.config.agentId}corpsecret${this.config.secret}); const tokenData await tokenRes.json(); // 步骤2发消息 const msgRes await fetch(${this.config.apiUrl}/message/send, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ touser: input.userIds.join(|), msgtype: text, text: { content: input.content } }) }); if (!msgRes.ok) { const errorData await msgRes.json(); throw mapToSkillError(errorData); // 映射为企业微信标准错误码 } const output await msgRes.json(); return { msgid: output.msgid }; } catch (err) { if (err instanceof TypeError err.message.includes(fetch is not defined)) { // Node 18需启用--experimental-fetch否则fallback到node-fetch throw { code: NETWORK_ERROR, message: fetch not available, use node-fetch } as WeComSendTextSkillError; } throw err; } } } function mapToSkillError(raw: any): WeComSendTextSkillError { switch (raw.errcode) { case 40014: return { code: INVALID_USER_IDS, message: raw.errmsg }; case 40015: return { code: TOKEN_EXPIRED, message: raw.errmsg }; default: return { code: NETWORK_ERROR, message: raw.errmsg || unknown error }; } }提示Node 18的fetch是实验性API生产环境务必加--experimental-fetch启动参数或在package.json的scripts里统一配置。别指望用户自己记得加。3.3 Registry注册中心的轻量化设计Registry不是DI容器它只是一个Map 工厂函数的组合。它的存在意义是让调用方不用关心“这个skill实例从哪来、怎么new、依赖啥”。// libs/agent-skills/src/lib/registry.ts export interface SkillRegistry { registerS extends SkillDefinition(skill: S, executor: SkillExecutorS): void; getExecutorS extends SkillDefinition(skillId: S[id]): SkillExecutorS; executeS extends SkillDefinition( skillId: S[id], input: S[input] ): PromiseS[output]; } export class InMemorySkillRegistry implements SkillRegistry { private executors new Mapstring, any(); registerS extends SkillDefinition(skill: S, executor: SkillExecutorS): void { this.executors.set(skill.id, executor); } getExecutorS extends SkillDefinition(skillId: S[id]): SkillExecutorS { const executor this.executors.get(skillId); if (!executor) throw new Error(Skill ${skillId} not registered); return executor; } async executeS extends SkillDefinition( skillId: S[id], input: S[input] ): PromiseS[output] { const executor this.getExecutor(skillId); return executor.execute(skillId, input); } }为什么不用NestJS的Module因为Registry必须能在无框架环境下工作。比如你在ComfyUI的自定义节点里没有NestJS但你可以new InMemorySkillRegistry()然后手动register你的技能——这就是“可移植性”的代价放弃花哨的装饰器换来零依赖的确定性。4. 实操过程从零搭建一个可发布的agent-skills库4.1 初始化Nx workspace与skills库第一步确保你有Node 18和pnpm推荐比npm快3倍# 安装pnpm corepack enable pnpm setup # 创建Nx workspace选empty不带默认app npx create-nx-workspacelatest my-agent-project --presetempty --clinx --nxCloudfalse cd my-agent-project # 添加TypeScript支持 pnpm add -D nrwl/node nrwl/workspace # 创建skills库 nx g nrwl/node:library agent-skills --directorylibs --no-interactive此时目录结构是libs/ └── agent-skills/ ├── src/ │ ├── index.ts # 导出所有public API │ └── lib/ │ ├── registry.ts # Registry实现 │ └── skill-definition.ts # 基础类型 ├── jest.config.ts └── project.json注意不要用--publishableflag因为agent-skills本身是基础库它不直接发布它的子库如we-com才发布。强行设publishable会导致Nx生成一堆无用的rollup配置。4.2 创建第一个可发布skillwe-com-send-text在libs/agent-skills下新建子库nx g nrwl/node:library we-com-send-text --directorylibs/agent-skills --importPathmy-org/agent-skills-we-com-send-text --no-interactive这会创建libs/agent-skills/we-com-send-text并自动在tsconfig.base.json里添加路径映射{ compilerOptions: { paths: { my-org/agent-skills-we-com-send-text: [libs/agent-skills/we-com-send-text/src/index.ts] } } }现在编辑libs/agent-skills/we-com-send-text/src/index.tsexport * from ./lib/skill-definition; export * from ./lib/executor; export * from ./lib/registry;skill-definition.ts就是前面定义的接口executor.ts是具体实现registry.ts是这个skill专用的注册辅助可选通常用全局Registry就够了。4.3 配置semantic-release实现自动化发布agent-skills的发布不是靠npm publish手动操作而是靠commit message触发。在libs/agent-skills/we-com-send-text目录下pnpm add -D semantic-release semantic-release/changelog semantic-release/git创建.releaserc.json{ branches: [main], plugins: [ semantic-release/commit-analyzer, semantic-release/release-notes-generator, semantic-release/changelog, [ semantic-release/git, { assets: [CHANGELOG.md, package.json], message: chore(release): ${nextRelease.version} [skip ci] } ], semantic-release/npm ] }关键点semantic-release/npm插件会自动读取package.json的name和version并推送到npm registry。所以你必须在project.json里配置正确的name// libs/agent-skills/we-com-send-text/project.json { name: we-com-send-text, targets: { build: { executor: nrwl/node:build, options: { outputPath: dist/libs/agent-skills/we-com-send-text, main: libs/agent-skills/we-com-send-text/src/index.ts, tsConfig: libs/agent-skills/we-com-send-text/tsconfig.lib.json, packageJson: libs/agent-skills/we-com-send-text/package.json } } } }package.json内容精简版{ name: my-org/agent-skills-we-com-send-text, version: 0.0.0, description: Enterprise WeCom text message sending skill, main: index.js, types: index.d.ts, peerDependencies: { axios: ^1.0.0 }, devDependencies: { types/node: ^18.0.0 } }注意version必须是0.0.0semantic-release会在发布时自动覆盖。peerDependencies声明axios告诉使用者“你得自己装”。4.4 编写测试并验证本地开发流测试不是可选项而是契约的守护者。在libs/agent-skills/we-com-send-text/src/lib/executor.spec.tsimport { WeComSendTextExecutor } from ./executor; import { WE_COM_SEND_TEXT_SKILL } from ./skill-definition; describe(WeComSendTextExecutor, () { let executor: WeComSendTextExecutor; beforeEach(() { executor new WeComSendTextExecutor({ apiUrl: https://qyapi.weixin.qq.com, agentId: test-id, secret: test-secret }); }); it(should throw INVALID_USER_IDS error when userIds is empty, async () { await expect( executor.execute(WE_COM_SEND_TEXT_SKILL, { userIds: [], content: hi }) ).rejects.toMatchObject({ code: INVALID_USER_IDS }); }); it(should return msgid on success, async () { // Mock fetch全局函数Jest默认不支持fetch需polyfill global.fetch jest.fn().mockImplementation((url) { if (url.includes(gettoken)) { return Promise.resolve({ json: () Promise.resolve({ access_token: abc123 }) }); } if (url.includes(message/send)) { return Promise.resolve({ ok: true, json: () Promise.resolve({ msgid: 123456 }) }); } return Promise.reject(new Error(unexpected url)); }); const result await executor.execute(WE_COM_SEND_TEXT_SKILL, { userIds: [user1, user2], content: hello }); expect(result.msgid).toBe(123456); }); });运行测试nx test we-com-send-text通过后用Nx的build目标生成可发布的包nx build we-com-send-text # 输出在 dist/libs/agent-skills/we-com-send-text此时你可以cd dist/libs/agent-skills/we-com-send-text npm pack生成tarball或直接npm publish需先npm login。5. 常见问题与排查技巧实录那些官网不会写的坑5.1 TypeScript类型错误Cannot find module node:util这是Node 18的常见报错尤其在Nx的Jest测试中。错误信息类似SyntaxError: The requested module node:util does not provide an export named promisify根本原因Jest默认用CommonJS运行时而node:util是ESM模块。TypeScript编译后的代码试图import { promisify } from node:util但Jest不支持。解决方案三步在jest.config.ts里启用ESM支持export default { // ...其他配置 extensionsToTreatAsEsm: [.ts], transform: { ^.\\.(ts|js|jsx|tsx)$: [ ts-jest, { useESM: true, // 关键 }, ], }, moduleNameMapper: { ^(\\.{1,2}/.*)\\.js$: $1, }, };在tsconfig.json里确保moduleResolution为node且module为commonjs或es2020推荐es2020{ compilerOptions: { module: es2020, moduleResolution: node } }如果还报错临时降级到Node 16 LTS长期支持版等Jest 29完全稳定ESM支持。实操心得我踩过这个坑三次。第一次花4小时查文档第二次在CI里加NODE_OPTIONS--experimental-specifier-resolutionnode第三次直接切回Node 16——对稳定性要求高的项目别追新。5.2 Nx构建失败Cannot find module xxx但明明已安装典型场景你在we-com-send-text里用了execapnpm install execa后nx build仍报错。排查顺序检查libs/agent-skills/we-com-send-text/package.json是否有execa: ^7.0.0在dependencies里如果没有pnpm install execa --filtermy-org/agent-skills-we-com-send-text。检查pnpm-lock.yaml里execa的解析路径是否正确。有时pnpm会解析到workspace根目录的node_modules而不是子库自己的。运行pnpm why execa确认。最狠一招删掉整个node_modules和pnpm-lock.yamlpnpm install重来。Nx的缓存机制有时会卡住旧解析。注意agent-skills的子库绝不允许把axios、execa等放dependencies必须是peerDependencies。否则下游项目安装时会重复打包体积爆炸。5.3 semantic-release发布失败No commits foundCI里跑npx semantic-release报错[10:23:44 AM] [semantic-release] › ✖ An error occurred while running semantic-release: Error: No commits found原因Git仓库没有提交历史或CI拉取的是浅克隆shallow clone。GitHub Actions修复方案在.github/workflows/release.yml里- name: Checkout uses: actions/checkoutv3 with: fetch-depth: 0 # 关键拉取全部历史不只是最新commitGitLab CI修复方案在.gitlab-ci.yml里variables: GIT_DEPTH: 0 # 同样关键提示semantic-release依赖commit history分析版本号。浅克隆只有最近1次commit它无法判断上次发布是v1.2.0还是v1.1.0所以直接退出。5.4 技能执行超时Node HTTP客户端默认timeout是0这是生产环境最隐蔽的坑。axios、node-fetch、甚至原生fetch默认都不设timeout。一个企业微信API卡死你的整个Agent就挂住。必须在executor里显式设置// axios版 const response await axios.post(url, data, { timeout: 10000, // 10秒 validateStatus: () true // 不自动reject非2xx状态码由我们自己map }); // fetch版Node 18 const controller new AbortController(); setTimeout(() controller.abort(), 10000); const response await fetch(url, { method: POST, signal: controller.signal, body: JSON.stringify(data) });实操心得我在Jetson Orin NX上部署时因网络不稳定没设timeout导致边缘设备假死。后来加了timeout重试最多2次成功率从83%升到99.7%。记住所有IO操作必须有timeout这是Node服务的生命线。5.5 Nx影响分析不准改了skill definition但affected命令没检测到现象你改了WeComSendTextSkillInput的userIds字段类型运行nx affected --targettest结果没触发任何测试。原因Nx的影响分析基于文件依赖图而TypeScript类型定义.d.ts默认不参与分析。解决方案在nx.json里开启targetDefaults的dependsOn{ targetDefaults: { build: { dependsOn: [^build], inputs: [default, ^default] // 关键启用隐式依赖分析 } } }更彻底的方案在project.json里为skill库显式声明依赖{ targets: { build: { dependsOn: [agent-skills:build] // 显式声明依赖父库 } } }经验Nx 16的affected命令已很准但类型变更仍需人工确认。我的做法是每次改Definition都手动跑nx dep-graph --focuswe-com-send-text看依赖图再nx affected --targetbuild验证。6. 生产落地建议如何让团队真正用起来6.1 制定技能命名规范比技术更重要再好的设计如果没人遵守规范就是废纸。我们团队推行的命名铁律ID格式{领域}-{动词}-{名词}全部小写用-连接如we-com-send-text、s3-upload-file、postgres-query-json。禁用驼峰、下划线、大写字母。输入/输出字段用业务语言不是技术语言。userIds✅recipient_list❌content✅payload❌。错误码UPPER_SNAKE_CASE且必须是业务错误不是HTTP状态码。TOKEN_EXPIRED✅HTTP_401❌。每周Code Review时第一条就是检查skill ID和类型命名。坚持三个月团队自然形成肌肉记忆。6.2 技能文档自动化用TypeDoc生成契约说明书每个skill库的README.md不应手写而应由TypeScript类型自动生成。在project.json里加doc: { executor: nrwl/js:tsc, options: { tsConfig: libs/agent-skills/we-com-send-text/tsconfig.lib.json, outDir: dist/libs/agent-skills/we-com-send-text/docs, emitDeclarationOnly: true, declarationMap: false } }再配TypeDoc配置typedoc.json{ entryPoints: [libs/agent-skills/we-com-send-text/src/index.ts], out: dist/libs/agent-skills/we-com-send-text/docs, plugin: [typedoc-plugin-markdown], readme: none }CI里跑nx doc we-com-send-text自动生成Markdown文档包含所有接口、类型、字段说明。链接放进Confluence新人入职第一天就能查到所有技能契约。6.3 监控与告警给每个skill加埋点技能不是黑盒必须可观测。我们在Registry的execute方法里加统一埋点async executeS extends SkillDefinition( skillId: S[id], input: S[input] ): PromiseS[output] { const start Date.now(); try { const result await this.getExecutor(skillId).execute(skillId, input); this.metrics.observe(skill_success_duration_seconds, Date.now() - start, { skillId }); return result; } catch (err) { this.metrics.observe(skill_error_duration_seconds, Date.now() - start, { skillId, errorCode: (err as any).code }); this.metrics.increment(skill_errors_total, { skillId, errorCode: (err as any).code }); throw err; } }对接PrometheusGrafana看板上实时显示we-com-send-text的错误率、平均耗时、TOP3错误码。当TOKEN_EXPIRED错误突增运维立刻收到钉钉告警——这比等用户投诉快10分钟。6.4 渐进式迁移老项目如何接入别想着一步到位。我们给存量项目设计了三步走影子模式Shadow Mode新写一个we-com-send-textskill同时保留老WeComService。在关键路径里新旧逻辑并行执行只用新逻辑结果但把旧逻辑结果和耗时打日志对比。确认一致性后进入下一步。流量切换Traffic Shift用Feature Flag控制先1%流量走skill观察监控指标没问题后逐步升到100%。期间老Service保持热备随时可切回。清理收尾Cleanup确认无报警、无降级后删掉老Service代码更新所有调用方为skill调用。这一步必须有自动化脚本我们写了nx migrate-skill-calls一键替换所有weComService.sendText(...)为skillRegistry.execute(...)。我的体会技术方案再完美落地节奏错了就全盘皆输。宁可慢一点也要让每个环节可验证、可回滚。agent-skills的价值不在第一天写得多漂亮而在第一百天还能轻松替换掉整个企业微信SDK。最后分享一个小技巧在Nx workspace根目录下放一个SKILLS.md文件用表格维护所有已发布skill的状态Skill ID版本状态最后更新负责人文档链接we-com-send-text1.3.0✅ 生产2024-05-20张三docss3-upload-file2.1.0⚠️ 灰度2024-05-18李四docs每天晨会花2分钟同步这个表比开1小时架构会更有效。毕竟agent-skills的本质不是炫技而是让“能力”真正成为团队的公共资产——可查、可测、可替、可担责。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进