1. 项目概述:一个被严重低估的“技能容器”设计范式
“agent-skills”这四个字乍看像某个开源库的包名,或是某篇技术文档里的小节标题,但如果你在Nx monorepo里反复看到它出现在libs/agent-skills路径下,又在TypeScript类型定义里发现SkillDefinition<T>、SkillExecutor、SkillRegistry这一整套接口与实现,那你大概率已经踩进了当前工程化实践里最务实、也最容易被忽视的一层抽象——不是AI Agent的调度逻辑,而是Agent能力本身的可插拔、可测试、可复用的建模方式。我从2021年接手第一个Nx+TS微前端项目开始,就一直在和这类“技能模块”打交道;后来做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 ): Promise<void> { 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): Promise<string> { // 实现细节省略 } }问题出在哪?
第一,耦合不可拆: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.0,file-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.6,edge-inference用1.4,互不干扰。错误透传不吞没:executor的
execute方法必须原样抛出SkillError类型错误,绝不做console.error后返回null这种事。因为错误处理策略(重试?降级?告警?)应该由调用方决定,executor只负责“如实报告”。同步/异步统一接口:无论底层是同步计算(如JSON Schema校验)还是异步IO(如HTTP请求),executor对外暴露的
execute方法签名必须是Promise<Output>。这样调用方不用区分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 ): Promise<WeComSendTextSkillOutput> { 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 { register<S extends SkillDefinition>(skill: S, executor: SkillExecutor<S>): void; getExecutor<S extends SkillDefinition>(skillId: S['id']): SkillExecutor<S>; execute<S extends SkillDefinition>( skillId: S['id'], input: S['input'] ): Promise<S['output']>; } export class InMemorySkillRegistry implements SkillRegistry { private executors = new Map<string, any>(); register<S extends SkillDefinition>(skill: S, executor: SkillExecutor<S>): void { this.executors.set(skill.id, executor); } getExecutor<S extends SkillDefinition>(skillId: S['id']): SkillExecutor<S> { const executor = this.executors.get(skillId); if (!executor) throw new Error(`Skill ${skillId} not registered`); return executor; } async execute<S extends SkillDefinition>( skillId: S['id'], input: S['input'] ): Promise<S['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-workspace@latest my-agent-project --preset=empty --cli=nx --nxCloud=false cd my-agent-project # 添加TypeScript支持 pnpm add -D @nrwl/node @nrwl/workspace # 创建skills库 nx g @nrwl/node:library agent-skills --directory=libs --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 创建第一个可发布skill:we-com-send-text
在libs/agent-skills下新建子库:
nx g @nrwl/node:library we-com-send-text --directory=libs/agent-skills --importPath=@my-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.ts:
export * 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.0,semantic-release会在发布时自动覆盖。peerDependencies声明axios,告诉使用者“你得自己装”。
4.4 编写测试并验证本地开发流
测试不是可选项,而是契约的守护者。在libs/agent-skills/we-com-send-text/src/lib/executor.spec.ts:
import { 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-resolution=node,第三次直接切回Node 16——对稳定性要求高的项目,别追新。
5.2 Nx构建失败:Cannot find module 'xxx',但明明已安装
典型场景:你在we-com-send-text里用了execa,pnpm install execa后nx build仍报错。
排查顺序:
检查
libs/agent-skills/we-com-send-text/package.json是否有"execa": "^7.0.0"在dependencies里?如果没有,pnpm install execa --filter=@my-org/agent-skills-we-com-send-text。检查
pnpm-lock.yaml里execa的解析路径是否正确。有时pnpm会解析到workspace根目录的node_modules,而不是子库自己的。运行pnpm why execa确认。最狠一招:删掉整个
node_modules和pnpm-lock.yaml,pnpm install重来。Nx的缓存机制有时会卡住旧解析。
注意:
agent-skills的子库绝不允许把axios、execa等放dependencies,必须是peerDependencies。否则下游项目安装时会重复打包,体积爆炸。
5.3 semantic-release发布失败:No commits found
CI里跑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/checkout@v3 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 --target=test,结果没触发任何测试。
原因: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 --focus=we-com-send-text看依赖图,再nx affected --target=build验证。
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 execute<S extends SkillDefinition>( skillId: S['id'], input: S['input'] ): Promise<S['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; } }对接Prometheus+Grafana,看板上实时显示: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-text | 1.3.0 | ✅ 生产 | 2024-05-20 | 张三 | docs |
s3-upload-file | 2.1.0 | ⚠️ 灰度 | 2024-05-18 | 李四 | docs |
每天晨会花2分钟同步这个表,比开1小时架构会更有效。毕竟,agent-skills的本质,不是炫技,而是让“能力”真正成为团队的公共资产——可查、可测、可替、可担责。