1. “agent-skills”不是库名,而是一套可复用AI智能体能力模块的设计范式
你点开 GitHub 搜索agent-skills,大概率会失望——它既不是 npm 上下载量破百万的明星包,也不是官方文档里明确定义的标准术语。它没有 README.md,没有版本号,甚至没有独立仓库。但恰恰是这种“不存在感”,让它在真实工程现场高频出现:在 Nx 工作区的libs/agent-skills目录下,在 TypeScript 类型定义文件agent-skills/src/types.ts里,在语义化发布(semantic-release)生成的 changelog 中反复被提及。它不是一个产品,而是一种被团队自发沉淀下来的、面向 AI 智能体(Agent)的能力抽象层实践模式。
我第一次见到这个词,是在一个为专利审查员开发的辅助系统中。当时团队要让 AI 模型不仅能回答“这个专利是否新颖”,还要能“自动提取权利要求书中的技术特征”、“比对 IPC 分类号”、“定位相似专利的附图标记”。这些动作明显超出了 prompt engineering 的范畴,它们需要调用外部工具、处理结构化数据、执行带状态的多步操作。我们没去造轮子,而是把每个原子能力封装成独立函数:extractClaimsFromPdf()、classifyIPCByTitle()、fetchSimilarPatents()……然后统一放进agent-skills这个逻辑命名空间里。后来发现,隔壁做工业 PLC 代码生成的团队,目录结构一模一样;再后来,Jetson Orin NX 边缘推理项目组也建了同名 lib——他们封装的是captureCameraStream()、quantizeModelOnDevice()、triggerHardwareReset()这类物理世界交互技能。
这说明什么?说明agent-skills已经脱离了字面意义,成为一种隐性行业共识:当 AI 系统从“问答机器”进化为“执行主体”,就必须把“能做什么”这件事,从模型权重里剥离出来,变成可测试、可组合、可版本化的 TypeScript 模块。它解决的核心问题非常朴素:不让 AI 的“手”和“脑”长在同一棵树上。模型负责决策(调用哪个 skill),skill 负责执行(怎么调用 API / 怎么读写文件 / 怎么控制硬件)。这种分离,直接决定了整个系统的可维护性上限。
为什么必须用 TypeScript?因为 Skill 不是黑盒函数。它有明确的输入 Schema(比如ExtractClaimsInput必须包含pdfBuffer: Uint8Array和pageRange?: [number, number]),有严格的输出类型(ExtractClaimsOutput包含claims: Array<{number: string; text: string}>),还有可能抛出的特定错误(PdfParseError、PageOutOfRangeError)。JavaScript 无法在编译期捕获这些契约,而 TypeScript 的 interface + generic + discriminated union 能把契约刻进代码基因里。我见过太多项目,因为一个skill返回的result字段类型模糊,导致下游 Agent 在 runtime 突然崩溃——而这类错误,在agent-skills的类型定义里,早就在tsc --noEmit阶段被拦住了。
Nx 在这里扮演的是“能力工厂”的基建角色。它不是为了管理一堆微服务,而是为了管理这一堆高度内聚、低耦合的技能模块。每个agent-skills下的子模块(如agent-skills/pdf-extractor、agent-skills/ipc-classifier)都是一个独立的 Nx library,拥有自己的tsconfig.json、自己的jest.config.ts、自己的nx.json构建配置。更重要的是,Nx 的 project graph 能清晰告诉你:agent-skills/ipc-classifier依赖了agent-skills/shared-types,但绝不允许反向依赖。这种强制的依赖拓扑,让技能模块天然具备“可插拔”属性——今天用 GPT-4 做决策引擎,明天换成本地部署的 Qwen2,只要 Skill 接口不变,Agent 逻辑完全不用动。
提示:不要把
agent-skills当成一个要发布的开源库去 npm install。它的价值在于目录结构、类型契约和构建约束。真正要复用的,是那个libs/agent-skills文件夹本身,以及里面每一行经过严格类型校验的export function xxx(): Promise<Yyy>。
2. 从零搭建 agent-skills 工作区:Nx 初始化与核心约束设计
搭建agent-skills并非简单运行npx create-nx-workspace就完事。关键在于初始化时就植入三个不可妥协的约束:单根类型定义、技能隔离边界、语义化发布流水线。这三者共同构成了agent-skills可靠性的基石。我见过太多团队在初期放任自流,结果半年后skills目录变成类型地狱——某个pdf-parser模块偷偷引入了axios,而hardware-controller模块又依赖了pdf-parser,最终导致边缘设备部署时打包体积爆炸。下面是我经过五次生产项目验证的初始化流程。
2.1 创建工作区并锁定 TypeScript 版本
首先,放弃create-nx-workspace的交互式引导。它默认创建的 monorepo 结构过于宽泛,容易让agent-skills混入其他无关领域。直接使用命令行精准控制:
npx create-nx-workspace@latest my-agent-project \ --preset=apps \ --appName=agent-core \ --style=css \ --linter=eslint \ --packageManager=pnpm \ --nxCloud=false \ --interactive=false进入项目后,立即执行关键一步:将 TypeScript 降级到 5.3.3。别被热搜词里“typescript 7.0 弃用 baseurl”干扰——那是未来的事。当前(2024年中)最稳定的组合是 TypeScript 5.3.x + Nx 18.x。原因很实际:TypeScript 5.4+ 引入的moduleResolution: bundler默认行为,会破坏 Nx 对paths别名的解析逻辑,导致import { extractClaims } from '@my-org/agent-skills/pdf-extractor'在 Jest 测试中报错Cannot find module。执行:
pnpm add -D typescript@5.3.3 pnpm exec tsc --version # 确认输出为 Version 5.3.3接着,修改根目录tsconfig.base.json,强制启用strict模式并禁用危险选项:
{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, "skipLibCheck": false, "forceConsistentCasingInFileNames": true, "moduleResolution": "node", "resolveJsonModule": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "isolatedModules": true, "incremental": true, "composite": true, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist" } }注意:
"skipLibCheck": false是硬性要求。很多团队为求快设为true,结果在agent-skills中引用@types/node时,因类型冲突导致tsc编译失败却无提示,直到 CI 流水线卡住才暴露问题。
2.2 创建 agent-skills 核心库及其类型枢纽
运行以下命令创建主技能库:
pnpm nx g @nx/workspace:library agent-skills --directory=libs --publishable --importPath=@my-org/agent-skills --unitTestRunner=jest --bundler=swc这会在libs/agent-skills下生成标准结构。但关键改造在libs/agent-skills/src/index.ts:
// libs/agent-skills/src/index.ts export * as pdf from './lib/pdf'; export * as ipc from './lib/ipc'; export * as hardware from './lib/hardware'; export * as patent from './lib/patent'; // 🔑 核心:所有公共类型必须从此处导出,形成单一入口 export type { PdfExtractResult, IpcClassificationResult, HardwareCommandResponse, PatentSearchResult, } from './lib/types';同时,在libs/agent-skills/src/lib/types.ts中定义全局类型枢纽:
// libs/agent-skills/src/lib/types.ts export interface SkillError extends Error { code: string; cause?: unknown; } export type SkillResult<T> = { success: true; data: T; } | { success: false; error: SkillError; }; // 所有 Skill 函数必须返回 SkillResult<T> export type SkillFn<Input, Output> = (input: Input) => Promise<SkillResult<Output>>; // 全局错误码枚举(避免字符串散落各处) export enum SkillErrorCode { PDF_PARSE_FAILED = 'PDF_PARSE_FAILED', IPC_CLASSIFICATION_TIMEOUT = 'IPC_CLASSIFICATION_TIMEOUT', HARDWARE_UNREACHABLE = 'HARDWARE_UNREACHABLE', PATENT_API_RATE_LIMIT = 'PATENT_API_RATE_LIMIT', }这个设计看似简单,实则解决了三个致命问题:第一,SkillResult<T>统一了所有 Skill 的返回形态,Agent 层无需为每个 Skill 写不同的错误处理逻辑;第二,SkillErrorCode枚举让错误分类可被 IDE 自动补全,杜绝了"pdf-parse-error"和"pdf_parse_error"这类拼写不一致引发的监控告警失效;第三,SkillFn<Input, Output>泛型约束,确保任何新添加的 Skill 都必须符合契约——如果你试图写一个不返回Promise<SkillResult<...>>的函数,TypeScript 会立刻报错。
2.3 用 Nx Project Graph 强制技能隔离
Nx 的真正威力,在于用project.json的implicitDependencies和targets.dependencies实现“物理隔离”。编辑libs/agent-skills/project.json:
{ "name": "agent-skills", "targets": { "build": { "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"], "options": { "outputPath": "dist/libs/agent-skills", "main": "libs/agent-skills/src/index.ts", "tsConfig": "libs/agent-skills/tsconfig.lib.json", "assets": ["libs/agent-skills/*.md"] }, "configurations": { "production": { "optimization": true, "extractLicenses": true, "inspect": false } } } }, "implicitDependencies": ["@my-org/agent-skills-shared-types"], // ⚠️ 关键:只允许依赖共享类型 "tags": ["type:library", "scope:agent"] }然后创建一个专门的shared-types库(注意不是agent-skills的子模块,而是同级独立库):
pnpm nx g @nx/workspace:library agent-skills-shared-types --directory=libs --publishable --importPath=@my-org/agent-skills-shared-types将libs/agent-skills/src/lib/types.ts中的SkillError、SkillResult、SkillErrorCode等基础类型全部移入agent-skills-shared-types。这样,agent-skills的任何子模块(如pdf-extractor)都只能依赖@my-org/agent-skills-shared-types,而绝不能直接 import 其他技能模块。Nx 的nx graph命令会清晰显示:pdf-extractor → agent-skills-shared-types,ipc-classifier → agent-skills-shared-types,但pdf-extractor ↛ ipc-classifier。这种拓扑,是agent-skills可组合性的物理保障。
2.4 配置 semantic-release 实现技能版本自治
agent-skills的每个子模块(如pdf-extractor)必须拥有独立的版本号和发布历史,而非整个agent-skills库共用一个版本。这要求 semantic-release 的配置粒度下沉到 library 级别。在libs/agent-skills/pdf-extractor/project.json中添加:
{ "targets": { "release": { "executor": "nx-plugin-semantic-release:release", "options": { "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", [ "@semantic-release/exec", { "prepareCmd": "pnpm exec tsc --build libs/agent-skills/pdf-extractor/tsconfig.lib.json && pnpm exec jest --config libs/agent-skills/pdf-extractor/jest.config.ts" } ], [ "@semantic-release/npm", { "npmPublish": true, "pkgRoot": "dist/libs/agent-skills/pdf-extractor" } ], "@semantic-release/github" ] } } } }关键点在于prepareCmd:它强制在发布前执行tsc编译和jest单元测试。这意味着,任何未通过类型检查或单元测试的 Skill 修改,都无法进入发布流水线。我曾在一个项目中看到,hardware-controller模块因忘记更新SkillResult类型,导致其triggerReset()函数返回了原始Promise<void>,而非Promise<SkillResult<void>>。这个错误在pnpm nx build agent-skills时就被tsc拦截,根本不会走到pnpm nx release这一步。
3. 技能模块的原子化设计:以 pdf-extractor 为例的完整实现链
agent-skills的灵魂不在顶层设计,而在每一个具体 Skill 的实现细节。一个合格的 Skill 模块,必须同时满足可测试性、可观测性、可调试性、可降级性四大特性。下面以pdf-extractor为例,展示从需求分析到上线的完整闭环。这不是一个简单的“读取 PDF 提取文本”的 demo,而是面向专利审查场景的真实工程实现。
3.1 需求拆解:为什么不能用 pdfjs-dist?
表面看,pdf-extractor的需求很简单:“从 PDF 中提取权利要求书文本”。但深入业务场景,你会发现一堆隐藏约束:
- 页码精度:权利要求书总在 PDF 的最后几页,但具体起始页码未知,需动态识别;
- 格式鲁棒性:用户上传的 PDF 可能是扫描件(OCR 后文本)、纯文字 PDF、混合 PDF(部分页面扫描,部分文字);
- 法律效力要求:提取的文本必须与原始 PDF 的字符位置严格对应,以便后续做“文本高亮定位”;
- 性能边界:单个 PDF 最大 200MB,需在 30 秒内完成,否则触发 Agent 超时熔断。
如果直接用pdfjs-dist,会立刻撞墙:它擅长渲染,但不提供页面内容的结构化 DOM 树;它无法区分扫描页和文字页;它没有内置 OCR 能力。因此,pdf-extractor的设计必须分层:
| 层级 | 技术选型 | 职责 | 是否可替换 |
|---|---|---|---|
| 解析层 | pdf-lib+pdfjs-dist | 解析 PDF 结构,识别页面类型(文字/扫描) | ✅ 可替换为popplerCLI |
| OCR 层 | tesseract.js(WebWorker) | 对扫描页执行 OCR,输出带坐标信息的文本 | ✅ 可替换为云端 OCR API |
| 结构层 | 自研ClaimLocator | 基于字体大小、缩进、编号模式(“1.”、“2.”)定位权利要求区块 | ❌ 核心业务逻辑,不可替换 |
| 输出层 | PdfTextBlock类型 | 封装文本、坐标、置信度,供下游高亮渲染 | ✅ 可扩展字段 |
这个分层,就是agent-skills的典型架构:基础设施层(infra)可替换,业务逻辑层(domain)不可替换,契约层(contract)绝对稳定。
3.2 类型定义:用 TypeScript 刻画业务契约
在libs/agent-skills/pdf-extractor/src/lib/types.ts中,定义精确的输入输出:
export interface PdfExtractInput { /** * PDF 文件二进制数据 * @example new Uint8Array([0x25, 0x50, 0x44, 0x46]) */ pdfBuffer: Uint8Array; /** * 可选:指定处理页码范围,提升大文件性能 * @default [0, -1] 处理全部页面 */ pageRange?: [number, number]; /** * OCR 置信度阈值,低于此值的文本块将被过滤 * @default 0.7 */ ocrConfidenceThreshold?: number; } export interface PdfTextBlock { /** * 文本内容(已去除首尾空白) */ text: string; /** * 在 PDF 页面中的绝对坐标(单位:PDF 点,1/72 英寸) */ bbox: { x1: number; y1: number; x2: number; y2: number; }; /** * 所属页面索引(从 0 开始) */ pageNumber: number; /** * OCR 置信度(仅扫描页有效,文字页为 1.0) */ confidence: number; /** * 文本块类型,用于下游样式渲染 */ type: 'claim' | 'title' | 'description' | 'other'; } export interface PdfExtractResult { /** * 成功提取的文本块列表 */ blocks: PdfTextBlock[]; /** * 检测到的页面类型统计 */ pageStats: { total: number; textPages: number; scannedPages: number; }; /** * 处理耗时(毫秒) */ durationMs: number; }注意bbox字段的注释:“单位:PDF 点,1/72 英寸”。这是法律文书处理的关键——下游的高亮渲染组件,必须能将bbox坐标精确映射到 Canvas 上。如果这里只写coordinates: [number, number, number, number],就会导致前端工程师反复追问“这个坐标系原点在哪?是左上角还是左下角?单位是什么?”,拖慢整个迭代节奏。TypeScript 的 JSDoc 注释,就是最高效的跨职能沟通协议。
3.3 实现:用 async/await + Promise.race 实现超时熔断
pdf-extractor的核心函数extractClaims()必须内置超时保护。不能依赖 Agent 层的全局 timeout,因为 Skill 本身可能因网络(OCR API)、CPU(Tesseract)、I/O(大文件读取)等不同原因卡死。实现如下:
// libs/agent-skills/pdf-extractor/src/lib/extractor.ts import { SkillFn, SkillResult, SkillErrorCode } from '@my-org/agent-skills-shared-types'; import { PdfExtractInput, PdfExtractResult, PdfTextBlock } from './types'; import { parsePdfStructure } from './parsers/pdf-structure'; import { performOcr } from './ocr/tesseract-worker'; import { locateClaims } from './locators/claim-locator'; export const extractClaims: SkillFn<PdfExtractInput, PdfExtractResult> = async ( input: PdfExtractInput ): Promise<SkillResult<PdfExtractResult>> => { const startTime = Date.now(); try { // Step 1: 解析 PDF 结构,识别页面类型 const structure = await parsePdfStructure(input.pdfBuffer, input.pageRange); // Step 2: 并行处理文字页和扫描页 const [textPagesResult, scannedPagesResult] = await Promise.all([ processTextPages(structure.textPages), processScannedPages(structure.scannedPages, input.ocrConfidenceThreshold ?? 0.7) ]); // Step 3: 合并结果并定位权利要求 const allBlocks = [...textPagesResult, ...scannedPagesResult]; const claimBlocks = locateClaims(allBlocks); const durationMs = Date.now() - startTime; return { success: true, data: { blocks: claimBlocks, pageStats: { total: structure.totalPages, textPages: structure.textPages.length, scannedPages: structure.scannedPages.length, }, durationMs, }, }; } catch (error) { const durationMs = Date.now() - startTime; // 🔑 关键:所有错误必须包装为 SkillError,并携带 ErrorCode const skillError: SkillError = { name: 'PdfExtractError', message: error instanceof Error ? error.message : 'Unknown extraction error', stack: error instanceof Error ? error.stack : undefined, code: getErrorCode(error), // 根据 error 类型映射 ErrorCode cause: error, }; return { success: false, error: skillError, }; } }; // 熔断实现:任何子步骤超过 15 秒即中断 const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> => { return Promise.race([ promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms) ), ]); }; const processTextPages = async (pages: TextPage[]): Promise<PdfTextBlock[]> => { return withTimeout( Promise.all(pages.map(page => extractTextFromPage(page))), 15_000 // 15秒熔断 ); };这个实现体现了agent-skills的工程哲学:错误不是异常,而是可分类、可监控、可重试的状态。getErrorCode(error)函数会根据error.name或error.code映射到SkillErrorCode.PDF_PARSE_FAILED或SkillErrorCode.OCR_TIMEOUT,这样 Prometheus 监控就能按error_code标签聚合,快速定位是 PDF 解析层还是 OCR 层出了问题。
3.4 测试:用 Jest 模拟真实世界不确定性
agent-skills的测试不是为了“覆盖所有分支”,而是为了验证 Skill 在边界条件下的行为是否符合契约。pdf-extractor的测试用例必须覆盖:
- 空 PDF 缓冲区:
new Uint8Array(0),应返回success: false且code: 'PDF_PARSE_FAILED' - 超大 PDF(模拟内存不足):用
jest.mock('pdf-lib')模拟parseDocument抛出OutOfMemoryError,验证是否正确映射为SkillErrorCode.PDF_PARSE_FAILED - OCR 低置信度文本:手动构造一个
confidence: 0.3的PdfTextBlock,验证ocrConfidenceThreshold: 0.7是否将其过滤 - 权利要求编号识别:提供一段包含
1. A device...和2. The device of claim 1...的文本,验证locateClaims()是否返回两个type: 'claim'的块
关键技巧:用jest.mock替换底层依赖,但保留类型签名。例如:
// libs/agent-skills/pdf-extractor/src/lib/__mocks__/tesseract-worker.ts import type { OcrResult } from '../ocr/tesseract-worker'; // 模拟 OCR 返回固定结果,但保持类型一致 export const performOcr = jest.fn().mockImplementation( async (): Promise<OcrResult> => ({ blocks: [ { text: '1. A method for...', bbox: { x1: 100, y1: 200, x2: 500, y2: 230 }, confidence: 0.95, }, ], }) );这样,测试代码能享受 TypeScript 的类型安全,而不会因 mock 失去类型检查。我见过太多项目,mock 返回any类型,导致测试通过但线上崩溃——因为blocks字段名写成了textBlocks而不是blocks。
4. Agent 与 Skills 的协同机制:基于 TypeScript 类型的运行时契约验证
agent-skills的终极价值,不在于 Skill 本身有多强大,而在于它如何被 Agent 安全、高效地调用。一个典型的 Agent(如专利审查 Agent)会维护一个SkillRegistry,它不是简单的 Map<string, Function>,而是一个类型安全的技能注册中心。这个设计,让 Agent 的决策逻辑从“字符串匹配”升级为“类型推导”。
4.1 技能注册表:用 TypeScript 的 keyof 和 typeof 构建类型反射
在apps/agent-core/src/lib/skill-registry.ts中,定义注册表:
import type { SkillFn, SkillResult } from '@my-org/agent-skills-shared-types'; import type * as Skills from '@my-org/agent-skills'; // 🔑 核心:从导入的 Skills 模块中,自动推导所有可用 Skill 名称和类型 type SkillName = keyof typeof Skills; type SkillDefinition<T extends SkillName> = ReturnType<(typeof Skills)[T]>; // 注册表接口:key 是 Skill 名称,value 是 Skill 函数 interface SkillRegistry { [key in SkillName]: SkillDefinition<key>; } // 实际注册表(运行时) const registry: SkillRegistry = { pdf: Skills.pdf.extractClaims, ipc: Skills.ipc.classifyByTitle, patent: Skills.patent.searchSimilar, hardware: Skills.hardware.triggerReset, }; // 类型安全的调用函数 export const invokeSkill = async <T extends SkillName>( skillName: T, input: Parameters<SkillDefinition<T>>[0] ): Promise<SkillResult<ReturnType<SkillDefinition<T>>['data']>> => { const skill = registry[skillName]; if (!skill) { throw new Error(`Skill '${skillName}' not found in registry`); } return skill(input) as Promise<SkillResult<ReturnType<SkillDefinition<T>>['data']>>; };这个设计的精妙之处在于SkillName = keyof typeof Skills。它不是硬编码字符串'pdf' | 'ipc',而是从@my-org/agent-skills的实际导出中自动获取。如果某天你删除了Skills.pdf,TypeScript 会立刻报错:Type 'pdf' is not assignable to type 'keyof typeof Skills'。同样,invokeSkill('pdf', {...})的第二个参数input,其类型会自动推导为PdfExtractInput,因为Skills.pdf.extractClaims的第一个参数类型就是PdfExtractInput。
4.2 运行时类型验证:防止“类型正确但值错误”的陷阱
TypeScript 只能保证编译期类型,但无法阻止运行时传入错误的值。例如,PdfExtractInput要求pdfBuffer是Uint8Array,但如果传入一个ArrayBuffer,tsc不会报错,但pdf-lib会抛出TypeError: Expected Uint8Array。为此,agent-skills在每个 Skill 入口添加轻量级运行时验证:
// libs/agent-skills/pdf-extractor/src/lib/extractor.ts import { validatePdfBuffer } from './validators/pdf-buffer-validator'; export const extractClaims: SkillFn<PdfExtractInput, PdfExtractResult> = async ( input: PdfExtractInput ): Promise<SkillResult<PdfExtractResult>> => { // 🔑 运行时验证:确保 pdfBuffer 是有效的 Uint8Array const validation = validatePdfBuffer(input.pdfBuffer); if (!validation.valid) { return { success: false, error: { name: 'PdfBufferValidationError', message: validation.error, code: SkillErrorCode.PDF_BUFFER_INVALID, }, }; } // ... 后续逻辑 };validatePdfBuffer的实现极其简单,但至关重要:
// libs/agent-skills/pdf-extractor/src/lib/validators/pdf-buffer-validator.ts export const validatePdfBuffer = (buffer: unknown): { valid: boolean; error?: string } => { if (!(buffer instanceof Uint8Array)) { return { valid: false, error: 'pdfBuffer must be an instance of Uint8Array' }; } if (buffer.length === 0) { return { valid: false, error: 'pdfBuffer cannot be empty' }; } if (buffer[0] !== 0x25 || buffer[1] !== 0x50 || buffer[2] !== 0x44 || buffer[3] !== 0x46) { return { valid: false, error: 'pdfBuffer does not start with %PDF signature' }; } return { valid: true }; };这个验证只做三件事:检查类型、检查长度、检查 PDF 签名。它不解析整个 PDF,所以开销可以忽略不计(< 0.1ms),但能拦截 90% 的上游调用错误。我曾在一个项目中,因前端传入base64字符串而非Uint8Array,导致pdf-lib在解析时崩溃,错误堆栈长达 200 行,根本无法定位问题源头。加入这个验证后,错误直接变成清晰的PDF_BUFFER_INVALID,前端工程师 5 秒内就能修复。
4.3 技能组合:用 Promise.allSettled 实现容错编排
真实的 Agent 决策很少只调用一个 Skill。例如,专利审查 Agent 的一个典型工作流是:
pdf.extractClaims()提取权利要求文本ipc.classifyByTitle()获取 IPC 分类号建议patent.searchSimilar()查找相似专利hardware.triggerReset()(如果检测到硬件异常)
这四个 Skill 的执行顺序并非严格串行,而是部分并行。但Promise.all()一旦某个 Skill 失败,整个 Promise 就 reject,导致其他成功的结果丢失。agent-skills的标准做法是Promise.allSettled():
// apps/agent-core/src/lib/workflows/patent-review.ts import { invokeSkill } from '../skill-registry'; import type { PdfExtractResult, IpcClassificationResult, PatentSearchResult } from '@my-org/agent-skills'; export const runPatentReview = async ( pdfBuffer: Uint8Array ): Promise<{ claims: PdfExtractResult | null; ipc: IpcClassificationResult | null; similarPatents: PatentSearchResult | null; hardwareReset: boolean; }> => { const results = await Promise.allSettled([ invokeSkill('pdf', { pdfBuffer }), invokeSkill('ipc', { title: 'Method for wireless charging' }), invokeSkill('patent', { keywords: ['wireless', 'charging', 'coil'] }), invokeSkill('hardware', { command: 'reset' }), ]); // 🔑 关键:即使某个 Skill 失败,也不影响其他结果 return { claims: results[0].status === 'fulfilled' ? results[0].value.data : null, ipc: results[1].status === 'fulfilled' ? results[1].value.data : null, similarPatents: results[2].status === 'fulfilled' ? results[2].value.data : null, hardwareReset: results[3].status === 'fulfilled' && results[3].value.success, }; };allSettled的返回值是一个数组,每个元素都有status: 'fulfilled' | 'rejected'字段。Agent 层可以根据status做差异化处理:对fulfilled的结果直接使用;对rejected的结果,记录error.code并决定是否重试(如PATENT_API_RATE_LIMIT可重试,PDF_PARSE_FAILED则直接失败)。这种容错能力,让agent-skills构建的系统在真实复杂环境中具备极强的韧性。
5. 生产环境落地:CI/CD 流水线、监控告警与降级策略
agent-skills从开发完成到稳定运行,中间隔着一条深沟:生产环境的不确定性。一个在本地 Jest 测试中 100% 通过的 Skill,可能在 CI 环境因 Node.js 版本差异失败,在生产环境因内存限制崩溃。下面是我在线上系统中验证过的、针对agent-skills的完整运维方案。
5.1 CI 流水线:用 Nx Affected 检测最小变更集
agent-skills的 monorepo 往往包含数十个 Skill 模块。每次 PR 都全量构建所有模块,既浪费资源又拖慢反馈。Nx 的affected命令是解药。在.github/workflows/ci.yml中:
name: CI on: [pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 - name: Install dependencies run: pnpm install - name: Detect affected projects id: affected run: echo "AFFECTED=$(pnpm nx print-affected --select=projects --base=origin/main --head=HEAD)" >> $GITHUB_ENV - name: Build affected projects if: env.AFFECTED != '[]' run: pnpm nx run-many --target=build --projects=${{ env.AFFECTED }} --parallel=3 - name: Test affected projects if: env.AFFECTED != '[]' run: pnpm nx run-many --target=test --projects=${{ env.AFFECTED }} --parallel=3 - name: Release affected libraries if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: pnpm nx run-many --target=release --projects=${{ env.AFFECTED }}pnpm nx print-affected会分析 Git diff,精确找出哪些 Skill 模块的源码被修改。例如,PR 只改了libs/agent-skills/pdf-extractor/src/lib/extractor.ts,那么env.AFFECTED就是["agent-skills-pdf-extractor"],流水线只会构建、测试、发布这一个模块。这将平均 CI 时间从 12 分钟缩短到 2.3 分钟,且避免了“改了一个 PDF Skill,却意外发布了硬件控制 Skill”这类事故。