1. 项目概述:一个被严重低估的“AI能力原子库”
“agent-skills”这四个字,乍看像某个开源项目的代号,或是某家AI创业公司的内部术语。但如果你在GitHub上搜过它,会发现它既不是热门库,也没有明星团队背书;如果你在技术社区里问起,多数人会愣一下,反问:“是那个Nx插件?还是TypeScript写的技能注册器?”——恰恰是这种模糊性,暴露了它的真实价值:它不是一个成品应用,而是一套面向AI Agent系统的能力抽象与组织范式。
我第一次接触这个概念,是在给一家做智能客服中台的客户做架构评审时。他们当时面临一个典型困境:后端有20多个独立服务(查订单、改地址、查物流、开电子票、对接CRM、调用OCR识别图片、调用TTS生成语音……),前端Agent每次要执行复合任务(比如“帮我把昨天买的那件衬衫换成L码,并把新单号发到微信”),就得硬编码拼接6个API调用顺序、处理5种错误分支、还要手动管理上下文传递。代码越写越像意大利面,测试用例爆炸式增长,新人上手一周都理不清调用链。直到我们把所有后端能力,按“agent-skills”的方式重新建模——不是封装成SDK,而是定义成带元数据、输入契约、输出契约、执行策略、失败回退逻辑的“可插拔技能单元”,整个系统的可维护性和扩展性才真正打开。
核心关键词“agent-skills”背后,其实藏着三个层次的工程实践共识:第一层是语义化能力切分——把业务功能从“接口”升维成“技能”,强调意图(intent)、上下文(context)、副作用(side effect);第二层是类型驱动的契约治理——用TypeScript的interface和泛型,强制约束每个技能的输入/输出结构、错误类型、重试策略,让IDE能实时校验、让编译器提前报错;第三层是规模化协作基础设施——用Nx做单体仓库下的模块化治理,用semantic-release实现技能包的自动化版本发布与Changelog生成,让10人团队能像维护Linux内核子系统一样,安全、并行地迭代上百个技能。
它不解决“怎么训练大模型”这种问题,但它直击AI落地最痛的软肋:当模型能力稳定后,如何让成百上千个具体业务动作,像乐高积木一样被快速组装、验证、替换、监控?这就是“agent-skills”的真实定位——不是AI的“大脑”,而是AI的“手指”和“关节”。它适合三类人深度参考:正在设计Agent架构的后端/全栈工程师、需要将AI能力产品化的技术负责人、以及想系统性理解“AI工程化”而非“AI调用”的TypeScript进阶学习者。你不需要懂大模型原理,但必须习惯用类型思维去建模世界。
2. 核心设计思路:为什么是TypeScript + Nx + semantic-release的铁三角组合?
2.1 技能即契约:TypeScript不是选型,而是必要前提
很多人看到“agent-skills”第一反应是:“用Python写不更简单?毕竟AI生态都在那边。” 这是个典型的认知偏差。Python擅长快速原型,但“skills”要解决的从来不是“能不能跑”,而是“能不能被安全、可靠、可追溯地集成”。这里的关键矛盾在于:AI调用天然具有不确定性(网络抖动、模型幻觉、服务降级),而业务系统要求确定性(订单状态必须准确、资金操作必须幂等)。TypeScript的静态类型系统,正是横跨在这两个世界之间的唯一可信桥梁。
举个真实案例:我们有个“查询用户历史订单”的技能,最初定义为async function getOrders(userId: string): Promise<any>。上线后,前端调用方传入了null,后端服务返回了{data: []},但某次上游服务变更,突然返回了{result: [], code: 200}。TypeScript的any类型对此完全失明,错误在运行时才暴露——前端页面白屏,日志里只有一行Cannot read property 'map' of undefined。当我们把它重构为:
interface OrderItem { id: string; status: 'pending' | 'shipped' | 'delivered'; createdAt: Date; } interface GetOrdersInput { userId: string; limit?: number; offset?: number; } interface GetOrdersOutput { items: OrderItem[]; total: number; hasNext: boolean; } interface GetOrdersError extends Error { code: 'USER_NOT_FOUND' | 'SERVICE_UNAVAILABLE' | 'RATE_LIMIT_EXCEEDED'; } async function getOrders(input: GetOrdersInput): Promise<GetOrdersOutput | GetOrdersError> { // 实现细节... }变化立竿见影:IDE立刻标红所有未处理GetOrdersError的调用点;CI流水线在npm run build阶段就捕获了input.userId可能为undefined的潜在空指针;更重要的是,当后端API响应结构变更时,TypeScript编译器直接报错Type '{ result: any[]; code: number; }' is not assignable to type 'GetOrdersOutput',把问题锁死在开发阶段。这不是语法糖,这是用编译器构建的“防御性编程护城河”。
提示:TypeScript的
declare global和命名空间并非炫技。比如我们定义全局SkillRegistry类型,让所有技能自动注册到统一类型空间,这样getSkill('order-query')的返回值类型就能被精确推导为typeof getOrders,实现真正的“类型即文档”。
2.2 模块即边界:Nx不是工具,而是协作协议
“agent-skills”项目往往起步于一个skills/目录,里面放着十几个.ts文件。但当技能数突破30个,团队成员超过5人,问题就来了:张三改了payment.ts,李四的refund.ts因为依赖同一个工具函数而意外崩溃;王五提交了一个新技能inventory-check.ts,却忘了更新README.md里的技能清单;赵六修复了一个OCR识别的bug,但测试用例只覆盖了ocr.ts,没跑document-processing.ts这个更高层的组合技能……这些都不是代码问题,而是缺乏显式模块边界的协作熵增。
Nx的威力,在于它把“模块”从概念变成了可执行、可审计、可强制的工程实体。我们不会让开发者手动维护tsconfig.json的路径映射,而是用Nx的@nx/workspace插件,为每个技能创建独立的lib项目:
npx nx g @nx/workspace:lib --name=skill-order-query --directory=skills --tags="type:skill,area:commerce" npx nx g @nx/workspace:lib --name=skill-ocr-recognize --directory=skills --tags="type:skill,area:document" npx nx g @nx/workspace:lib --name=skill-crm-sync --directory=skills --tags="type:skill,area:customer"每条命令生成的不只是文件夹,更是一份契约:projects/skills/skill-order-query/project.json里明确声明了它的implicitDependencies(隐式依赖)、targets(构建/测试/发布目标)、tags(用于影响图分析)。当张三修改skill-order-query时,Nx的affected命令能精准计算出:哪些其他技能(如skill-order-composite)会受影响,哪些e2e测试必须重跑,哪些文档需要更新。这不再是“靠人肉记忆和会议同步”,而是由工具强制保障的可预测的变更影响范围。
注意:Nx的
project.json中tags字段是灵魂。我们约定type:skill标识原子技能,type:composite-skill标识组合技能,area:xxx标识业务域。这样,npx nx dep-graph --focus=skills就能生成清晰的依赖图谱,npx nx affected --target=test --tag="type:skill"就能只测试所有原子技能——这才是规模化协作的底层基础设施。
2.3 发布即信任:semantic-release不是自动化,而是可信度声明
在传统Node.js项目里,npm publish是一个充满仪式感的操作:开发者手动改package.json的version,写CHANGELOG.md,然后敲下命令。但在“agent-skills”场景下,这种模式是灾难性的。想象一下:skill-payment发布了v1.2.3,但skill-refund还在用v1.1.0,而v1.2.3里悄悄改了支付回调的签名格式……没有自动化版本管理,技能间的兼容性就是一场豪赌。
semantic-release的精妙之处,在于它把“版本号”从一个随意的字符串,变成了对代码变更语义的机器可读声明。我们约定:所有提交信息必须遵循Conventional Commits规范(feat:,fix:,chore:,docs:等前缀)。当一个PR合并到main分支,CI流水线触发npx semantic-release,它会:
- 解析所有新提交:统计
feat:的数量(决定是否升minor)、fix:的数量(决定是否升patch)、是否有BREAKING CHANGE(决定是否升major); - 生成智能Changelog:自动提取每个
feat:/fix:的描述,按模块分组,附上关联的PR链接; - 执行原子化发布:为每个技能库(如
@myorg/skill-order-query)单独打Git tag、更新package.json、发布到私有NPM registry。
结果是什么?当你看到@myorg/skill-order-query@2.4.1这个版本号,你不需要翻代码、不需要问同事,就能100%确信:它包含至少一个新功能(minor),且没有破坏性变更(无major)。这就是“可信度声明”——版本号本身成了最简明的API契约。对于下游Agent系统,npm install @myorg/skill-order-query@^2.4.0意味着“我接受所有向后兼容的增强”,而@myorg/skill-order-query@~2.4.0则意味着“我只接受Bug修复”。这种基于语义的依赖管理,是支撑数百个技能协同演进的基石。
3. 核心技能结构解析:一个可复用、可测试、可监控的最小单元
3.1 技能的“五脏六腑”:标准化结构拆解
一个符合“agent-skills”范式的TypeScript技能,绝非一个简单的函数。它是一个自包含的、具备完整生命周期的软件单元。我们以skill-weather-forecast.ts为例,拆解其标准结构:
// 1. 【元数据】技能身份标识(用于注册、发现、监控) export const WEATHER_FORECAST_SKILL_ID = 'weather-forecast'; // 2. 【输入契约】严格定义调用者能传什么 export interface WeatherForecastInput { /** 城市名称,必填 */ city: string; /** 预报天数,1-7,默认3 */ days?: number; /** 是否返回详细气象数据(湿度、风速等) */ detailed?: boolean; } // 3. 【输出契约】严格定义技能返回什么 export interface WeatherForecastOutput { /** 城市名称 */ city: string; /** 预报日期列表 */ forecasts: Array<{ date: string; // YYYY-MM-DD temperature: { min: number; max: number }; condition: 'sunny' | 'cloudy' | 'rainy' | 'snowy'; humidity?: number; windSpeed?: number; }>; /** 数据来源标识 */ source: 'openweathermap' | 'accuweather'; } // 4. 【错误契约】明确声明所有可能失败场景 export class WeatherForecastError extends Error { constructor( public readonly code: 'CITY_NOT_FOUND' | 'API_RATE_LIMITED' | 'SERVICE_UNAVAILABLE', message: string, ) { super(message); this.name = 'WeatherForecastError'; } } // 5. 【执行逻辑】核心业务实现(可注入依赖) export async function execute( input: WeatherForecastInput, dependencies: { weatherApi: WeatherApiClient; // 依赖注入,便于Mock测试 }, ): Promise<WeatherForecastOutput | WeatherForecastError> { try { // 输入校验(防御性编程) if (!input.city || input.city.trim().length === 0) { return new WeatherForecastError('CITY_NOT_FOUND', 'City name is required'); } if (input.days && (input.days < 1 || input.days > 7)) { return new WeatherForecastError('INVALID_INPUT', 'Days must be between 1 and 7'); } // 调用外部服务 const rawResponse = await dependencies.weatherApi.getForecast({ city: input.city, days: input.days ?? 3, }); // 输出转换(适配契约) return { city: input.city, forecasts: rawResponse.data.map((item) => ({ date: item.date, temperature: { min: item.temp_min, max: item.temp_max }, condition: mapCondition(item.weather_main), humidity: item.humidity, windSpeed: item.wind_speed, })), source: 'openweathermap', }; } catch (error) { // 统一错误分类 if (error instanceof NetworkError) { return new WeatherForecastError('SERVICE_UNAVAILABLE', 'Weather API is down'); } if (error.response?.status === 429) { return new WeatherForecastError('API_RATE_LIMITED', 'Rate limit exceeded'); } return new WeatherForecastError('UNKNOWN_ERROR', 'Unexpected error occurred'); } } // 6. 【技能注册器】供Nx工作区统一加载 export const weatherForecastSkill = { id: WEATHER_FORECAST_SKILL_ID, inputSchema: WeatherForecastInput, outputSchema: WeatherForecastOutput, errorType: WeatherForecastError, execute, };这个结构看似繁琐,但每一部分都解决一个关键问题:元数据让技能可被发现和路由;输入/输出契约让调用者无需阅读文档就能理解接口;错误契约让错误处理不再靠instanceof或字符串匹配;执行逻辑的依赖注入让单元测试成为可能;注册器则是Nx模块化加载的入口。它不是一个“最佳实践”,而是“最低生存标准”。
3.2 技能的“呼吸系统”:执行策略与上下文管理
真实的AI Agent调用,远比execute(input)复杂。一个技能可能需要:
- 重试:网络请求失败时,指数退避重试3次;
- 超时:防止某个技能卡死整个Agent流程;
- 熔断:当错误率超过50%,自动跳过该技能10分钟;
- 上下文透传:将用户ID、会话ID、设备信息等元数据,自动注入到所有技能调用中;
- 可观测性:记录耗时、成功率、输入摘要(脱敏后)。
这些不是每个技能自己实现,而是通过一个统一的SkillExecutor来注入。我们用TypeScript的装饰器模式实现:
// 定义执行策略接口 interface SkillExecutionStrategy { timeoutMs?: number; maxRetries?: number; backoffBaseMs?: number; circuitBreaker?: { failureThreshold: number; resetTimeoutMs: number; }; } // 装饰器工厂 export function withStrategy(strategy: SkillExecutionStrategy) { return function <T extends (...args: any[]) => any>(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function (this: any, ...args: any[]) { const startTime = Date.now(); let lastError: unknown; for (let attempt = 0; attempt <= (strategy.maxRetries ?? 0); attempt++) { try { // 熔断检查 if (await isCircuitOpen(propertyKey)) { throw new CircuitOpenError(`Circuit open for skill ${propertyKey}`); } // 执行带超时 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), strategy.timeoutMs ?? 10000); const result = await Promise.race([ originalMethod.apply(this, args), new Promise<never>((_, reject) => controller.signal.addEventListener('abort', () => reject(new TimeoutError())) ) ]); clearTimeout(timeoutId); return result; } catch (error) { lastError = error; if (attempt < strategy.maxRetries) { await new Promise(resolve => setTimeout(resolve, strategy.backoffBaseMs ? Math.pow(2, attempt) * strategy.backoffBaseMs : 1000) ); } } } throw lastError; }; }; } // 在技能上使用 export const weatherForecastSkill = { id: WEATHER_FORECAST_SKILL_ID, // ... 其他属性 execute: withStrategy({ timeoutMs: 8000, maxRetries: 2, backoffBaseMs: 500, circuitBreaker: { failureThreshold: 5, resetTimeoutMs: 600000 } })(execute) };实操心得:我们曾因未设置
timeoutMs,导致一个天气技能在API故障时阻塞了整个Agent的响应,用户等待超30秒。后来强制规定:所有技能默认timeoutMs=5000,高延迟技能(如OCR)必须显式声明timeoutMs=30000并附上业务理由。这已成为代码审查的硬性红线。
3.3 技能的“神经系统”:类型安全的注册与发现
有了标准化的技能,下一步是如何让Agent系统“知道”它们的存在。我们摒弃了动态import()或require(),坚持编译期可分析的静态注册。核心是Nx的libs结构和TypeScript的const assertions:
// projects/skills/index.ts - 所有技能的统一入口 import { weatherForecastSkill } from './weather-forecast'; import { orderQuerySkill } from './order-query'; import { ocrRecognizeSkill } from './ocr-recognize'; // ... 导入所有技能 // 使用const断言,确保类型是精确的字面量联合类型 export const ALL_SKILLS = [ weatherForecastSkill, orderQuerySkill, ocrRecognizeSkill, // ... 更多 ] as const; // 类型推导出:typeof ALL_SKILLS[number]['id'] 是 'weather-forecast' | 'order-query' | 'ocr-recognize' export type SkillId = typeof ALL_SKILLS[number]['id']; // 注册函数,返回一个类型安全的技能映射 export function registerAllSkills(): Record<SkillId, SkillDefinition> { return ALL_SKILLS.reduce((acc, skill) => { acc[skill.id] = skill; return acc; }, {} as Record<SkillId, SkillDefinition>); }Agent系统启动时,只需调用registerAllSkills(),就能获得一个Record<SkillId, SkillDefinition>,其中SkillId是精确的字符串字面量联合类型。这意味着:
getSkill('non-existent-id')在编译期就会报错;switch(skillId) { case 'weather-forecast': ... }的case分支可以被IDE自动补全;getAllSkills().filter(s => s.id.startsWith('order-'))的过滤结果类型是精确的SkillDefinition[]。
这彻底消灭了“字符串魔法”(Stringly-typed)带来的运行时风险。类型系统不再是摆设,而是贯穿开发、测试、部署全流程的“活文档”。
4. 实操全流程:从零搭建一个可运行的skills工作区
4.1 初始化Nx工作区与技能骨架
第一步,创建一个全新的Nx工作区。我们选择apps+libs的经典结构,libs存放所有skills,apps存放演示用的Agent CLI和Web UI:
# 创建Nx工作区(使用pnpm作为包管理器,更快) npx create-nx-workspace@latest agent-skills-demo \ --preset=apps \ --cli=nx \ --nx-cloud=false \ --package-manager=pnpm cd agent-skills-demo # 生成第一个技能库(使用Nx的lib generator) npx nx g @nx/workspace:lib --name=skill-hello-world --directory=skills --tags="type:skill,area:demo" # 查看生成的结构 tree libs/skills/skill-hello-world # 输出: # libs/skills/skill-hello-world/ # ├── src/ # │ ├── index.ts # 默认导出 # │ └── lib.spec.ts # Jest测试 # ├── jest.config.ts # ├── project.json # Nx项目配置 # └── tsconfig.json # TypeScript配置关键点在于project.json。我们需要手动编辑它,添加build和publish目标,使其符合semantic-release的要求:
// libs/skills/skill-hello-world/project.json { "name": "skill-hello-world", "root": "libs/skills/skill-hello-world", "sourceRoot": "libs/skills/skill-hello-world/src", "projectType": "library", "targets": { "build": { "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"], "options": { "outputPath": "dist/libs/skills/skill-hello-world", "main": "libs/skills/skill-hello-world/src/index.ts", "tsConfig": "libs/skills/skill-hello-world/tsconfig.lib.json", "assets": ["libs/skills/skill-hello-world/*.md"] } }, "publish": { "executor": "@nx/js:publish", "dependsOn": ["build"], "options": { "registryUrl": "https://npm.pkg.github.com", // 替换为你的私有registry "access": "public" } } }, "tags": ["type:skill", "area:demo"] }注意:
@nx/js:publishexecutor会自动读取package.json中的name和version。因此,我们必须在libs/skills/skill-hello-world/package.json中正确设置:{ "name": "@agent-skills-demo/skill-hello-world", "version": "0.0.0-development", // 初始版本,semantic-release会覆盖 "main": "src/index.ts", "types": "src/index.ts" }
4.2 编写第一个技能:Hello World with Type Safety
现在,让我们编写一个真正可用的技能。编辑libs/skills/skill-hello-world/src/index.ts:
// 1. 元数据 export const HELLO_WORLD_SKILL_ID = 'hello-world'; // 2. 输入契约 export interface HelloWorldInput { /** 用户姓名,必填 */ name: string; /** 问候语前缀,默认"Hello" */ prefix?: string; } // 3. 输出契约 export interface HelloWorldOutput { /** 生成的问候语 */ greeting: string; /** 生成时间戳 */ timestamp: Date; } // 4. 错误契约 export class HelloWorldError extends Error { constructor(public readonly code: 'NAME_EMPTY', message: string) { super(message); this.name = 'HelloWorldError'; } } // 5. 执行逻辑 export async function execute(input: HelloWorldInput): Promise<HelloWorldOutput | HelloWorldError> { if (!input.name || input.name.trim().length === 0) { return new HelloWorldError('NAME_EMPTY', 'Name cannot be empty'); } return { greeting: `${input.prefix ?? 'Hello'}, ${input.name}!`, timestamp: new Date(), }; } // 6. 技能注册器 export const helloWorldSkill = { id: HELLO_WORLD_SKILL_ID, inputSchema: HelloWorldInput, outputSchema: HelloWorldOutput, errorType: HelloWorldError, execute, }; // 7. 默认导出,供其他模块导入 export default helloWorldSkill;同时,编写一个简单的Jest测试,验证输入校验:
// libs/skills/skill-hello-world/src/lib.spec.ts import { execute, HelloWorldError } from './index'; describe('helloWorldSkill', () => { it('should return greeting with valid input', async () => { const result = await execute({ name: 'Alice' }); expect(result).toEqual({ greeting: 'Hello, Alice!', timestamp: expect.any(Date), }); }); it('should return error when name is empty', async () => { const result = await execute({ name: '' }); expect(result).toBeInstanceOf(HelloWorldError); expect((result as HelloWorldError).code).toBe('NAME_EMPTY'); }); });运行测试:npx nx test skill-hello-world。如果一切顺利,你会看到绿色的PASS。
4.3 集成semantic-release:自动化发布流水线
这是最关键的一步。我们需要配置semantic-release,让它能自动为我们的技能库发布版本。首先,安装依赖:
pnpm add -D semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/npm @semantic-release/github然后,在工作区根目录创建.releaserc.json:
{ "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", [ "@semantic-release/npm", { "npmPublish": true, "pkgRoot": "dist/libs/skills/skill-hello-world" } ], [ "@semantic-release/github", { "assets": ["dist/libs/skills/skill-hello-world/**/*"] } ] ] }接着,配置CI(以GitHub Actions为例),在.github/workflows/release.yml中:
name: Release Skills on: push: branches: [main] paths: - 'libs/skills/skill-hello-world/**' jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: '18' registry-url: 'https://npm.pkg.github.com' - run: pnpm install - name: Build skill-hello-world run: npx nx build skill-hello-world - name: Semantic Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx semantic-release最后,进行一次符合规范的提交并推送:
git add . git commit -m "feat(skill-hello-world): add initial implementation" git push origin mainCI流水线会自动触发,semantic-release会:
- 解析提交信息,识别为
feat,决定发布0.1.0; - 运行
nx build skill-hello-world; - 将
dist/libs/skills/skill-hello-world下的文件发布到GitHub Packages; - 创建Git tag
v0.1.0; - 生成并发布
CHANGELOG.md。
实操心得:第一次发布常失败,原因通常是
GITHUB_TOKEN权限不足或package.json中的name格式错误(必须是@scope/name)。建议先在本地用npx semantic-release --dry-run调试。
4.4 构建Agent CLI:消费技能的最小可行产品
为了验证技能真的“活”了,我们创建一个简单的CLI应用来调用它。生成一个app:
npx nx g @nx/workspace:app --name=agent-cli --directory=apps --tags="type:app,area:cli"然后,在apps/agent-cli/src/main.ts中,消费我们刚发布的技能:
import { helloWorldSkill } from '@agent-skills-demo/skill-hello-world'; async function main() { try { const result = await helloWorldSkill.execute({ name: 'World' }); console.log('✅ Success:', result.greeting); } catch (error) { console.error('❌ Error:', error); } } main();注意,这里我们直接import了@agent-skills-demo/skill-hello-world。Nx会自动解析这个包名,并在pnpm install时从GitHub Packages拉取最新版本。运行npx nx serve agent-cli,你应该能看到✅ Success: Hello, World!。
这标志着整个闭环完成:技能开发 → 自动化测试 → 类型安全构建 → 语义化版本发布 → 应用消费。每一个环节都由工具链保障,而非人工记忆。
5. 常见问题与实战排错指南:那些文档里不会写的坑
5.1 “TypeScript编译报错:Cannot find module ‘@agent-skills-demo/skill-hello-world’”
现象:在CLI应用中import技能包时,VS Code报红,tsc编译失败,提示找不到模块。
根本原因:TypeScript的模块解析(Module Resolution)机制。@agent-skills-demo/skill-hello-world是一个发布到NPM registry的包,TypeScript默认只在node_modules中查找,但pnpm的node_modules结构是扁平化的,且@agent-skills-demo可能不在node_modules/@agent-skills-demo下。
解决方案:在工作区根目录的tsconfig.base.json中,添加paths别名映射:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@agent-skills-demo/*": ["libs/skills/*/src/index.ts"] } } }这样,TypeScript就能在源码中直接解析@agent-skills-demo/skill-hello-world,而无需等待pnpm install。同时,在project.json的build目标中,确保tsConfig指向正确的配置。
提示:这是Nx工作区的常见陷阱。永远优先用
paths别名进行本地开发,发布后才切换到NPM包引用。
5.2 “semantic-release发布失败:No commits found since last release”
现象:CI流水线中semantic-release报错,说找不到上次发布的提交。
根本原因:semantic-release依赖Git标签(tag)来确定“上次发布”的位置。如果你的工作区是全新初始化的,或者main分支上还没有任何tag,它就无法计算增量。
解决方案:手动创建一个初始tag。在本地执行:
git tag v0.0.0 git push origin v0.0.0然后,再提交一个feat:提交并推送,semantic-release就能正常工作了。后续所有发布都由它自动管理。
5.3 “技能执行时,依赖注入的client无法被Mock”
现象:在Jest测试中,我们想MockWeatherApiClient,但execute函数是直接调用的,没有提供注入点。
根本原因:技能函数被定义为async function execute(...),其依赖是硬编码在函数体内的,违反了“依赖倒置原则”。
解决方案:重构技能,采用“函数工厂”模式。将execute定义为一个返回函数的工厂:
// libs/skills/skill-weather-forecast/src/index.ts export function createWeatherForecastSkill( dependencies: { weatherApi: WeatherApiClient; } ) { return async function execute(input: WeatherForecastInput): Promise<...> { // 这里可以自由使用 dependencies.weatherApi }; } // 在项目中使用 const weatherForecastSkill = { id: 'weather-forecast', // ... 其他元数据 execute: createWeatherForecastSkill({ weatherApi: new RealWeatherApiClient() }) };在测试中,就可以轻松注入Mock:
const mockWeatherApi = { getForecast: jest.fn().mockResolvedValue({ data: [...] }) }; const execute = createWeatherForecastSkill({ weatherApi: mockWeatherApi }); await execute({ city: 'Beijing' }); expect(mockWeatherApi.getForecast).toHaveBeenCalledWith({ city: 'Beijing' });实操心得:我们曾因未采用此模式,在一个OCR技能的测试中,不得不启动一个真实的MinIO服务来存取图片,导致单个测试耗时45秒。重构后,测试降到0.2秒。
5.4 “Nx dep-graph显示技能间有意外依赖”
现象:运行npx nx dep-graph,发现skill-order-query竟然依赖了skill-crm-sync,但代码里明明没有import。
根本原因:TypeScript的import type或/// <reference types="..." />声明,虽然不产生运行时代码,但会被Nx的依赖分析器(基于AST)识别为依赖。更隐蔽的是,tsconfig.json中的paths别名如果配置不当,也可能引入隐式依赖。
解决方案:使用Nx的--exclude参数过滤掉类型依赖,或在project.json中显式声明implicitDependencies为[],然后手动添加真正需要的依赖。更推荐的做法是,利用Nx的project.json中的tags和implicitDependencies进行精细化控制:
{ "name": "skill-order-query", "implicitDependencies": ["@nx/js"], // 只依赖Nx核心 "tags": ["type:skill", "area:commerce"] }然后,运行npx nx graph --group-by-directory,观察依赖图是否符合预期。
5.5 “技能在生产环境抛出TypeError: Cannot read property 'map' of undefined”
现象:技能在本地测试完美,但上线后随机崩溃,日志显示某个数组操作失败。
根本原因:TypeScript的类型检查只在编译期,而API响应是运行时的。如果后端服务返回了不符合契约的JSON(比如items字段是null而不是[]),TypeScript无法阻止。
解决方案:在execute函数内部,添加运行时类型守卫(Runtime Type Guard)。我们使用zod库:
import { z } from 'zod'; const WeatherForecastOutputSchema = z.object({ city: z.string(), forecasts: z.array(z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), temperature: z.object({ min: z.number(), max: z.number() }), condition: z.enum(['sunny', 'cloudy', 'rainy', 'snowy']), })), source: z.enum(['openweathermap', 'accuweather']), }); export async function execute(...) { // ... 调用API获取rawResponse try { const validatedOutput = WeatherForecastOutputSchema.parse(raw