agent-skills:AI智能体可复用能力的工程化设计范式
2026/9/16 8:11:47 网站建设 项目流程

1. “agent-skills”不是库名,而是一套可复用AI智能体能力模块的设计范式

你点开 GitHub 搜索“agent-skills”,大概率会看到几个空仓库、几份未完成的 README,或者某个 Nx 工作区里被标记为@myorg/agent-skills的私有包——它几乎从不作为独立开源项目存在,却在至少 17 个已上线的 AI 应用后台代码库里反复出现。这不是一个 npm 上能npm install agent-skills的标准包,而是一种被团队自发沉淀下来的、面向生产环境的智能体能力组织方式。我参与过 4 个不同行业的 AI 产品交付(金融风控对话引擎、工业设备远程诊断助手、法律文书生成中台、教育个性化推荐 Agent),发现只要团队开始用 Nx 管理 TypeScript 项目,且核心逻辑涉及“让 AI 做事”而非“让 AI 回答”,就一定会在libs/目录下诞生一个叫agent-skills的子目录。它里面没有 flashy 的 UI,没有炫酷的 LLM 调用封装,只有一堆.ts文件:executeShellCommand.tsreadFileFromS3.tsvalidateJSONSchema.tsqueryPostgresWithTimeout.ts……每个文件导出一个函数,签名高度统一:输入是结构化参数对象,输出是 Promise<SuccessResult | FailureResult>,失败时一定携带可分类的 error code 和 human-readable message。

为什么必须用 Nx?因为这些技能函数绝不能写成散落在各处的工具函数。它们需要被多个 Agent(比如“文档解析 Agent”、“数据校验 Agent”、“运维执行 Agent”)复用;需要独立测试(nx test agent-skills);需要版本控制(语义化发布@myorg/agent-skills@2.3.0);需要依赖隔离(agent-skills依赖@myorg/utils,但绝不允许反向依赖)。TypeScript 在这里不是锦上添花,而是生存必需——没有类型守门,executeShellCommandtimeoutMs: number参数一旦被误传为字符串,整个 Agent 流程就会静默卡死,日志里只有一行Error: Command timed out,根本看不出是哪个调用方传错了。而semantic-release则是这套范式的“呼吸阀”:每次nx release后自动生成 changelog,自动打 tag,自动发布到私有 registry,让下游 Agent 开发者清楚知道@myorg/agent-skills@2.3.0新增了uploadToAzureBlob,修复了parseCsvWithHeader在空行时的内存泄漏。这背后不是技术选型,而是对“AI 行为可追溯、可审计、可回滚”的硬性要求。当你看到“agent-skills”这个词,它真正指向的是一套把 AI 的“动手能力”变成像 API 接口一样可管理、可测试、可演进的工程实践,而不是某个具体的技术栈。

1.1 为什么“技能”必须与“Agent”解耦?一次线上事故的教训

去年 Q3,我们上线了一个客户支持 Agent,它能根据用户上传的截图自动识别故障类型并触发工单。上线第三天凌晨,客服系统告警:工单创建成功率从 99.8% 骤降至 62%。排查链路如下:

  • 首先检查 LLM 调用:OpenAI API 延迟正常,token 使用量无异常;
  • 再查 Agent 编排层:所有状态流转日志显示“进入 createTicket 步骤”,但后续无记录;
  • 最后翻看createTicket函数实现——它直接内联了 HTTP 请求逻辑、JWT token 刷新、重试策略、错误码映射……整整 237 行。

问题就出在这里。当时为了赶进度,开发把“创建工单”这个技能和 Agent 的决策逻辑揉在一起。而那天恰好是客户 CRM 系统升级,返回了新的 403 错误码FORBIDDEN_ACCESS_SCOPE_CHANGED。旧逻辑只处理401403,新错误码被当作未知错误吞掉,createTicket函数静默 resolve 了undefined,Agent 认为“成功”,流程继续,但实际工单根本没建。

如果当时createTicket是一个独立的agent-skills模块,情况会完全不同:

  • 它会有自己的单元测试,覆盖所有可能的 CRM 返回码;
  • 它的package.json会声明peerDependencies: { "@myorg/crm-client": "^1.5.0" },强制绑定 SDK 版本;
  • 当 CRM 升级时,nx test agent-skills会立刻失败,CI 拦截发布;
  • 修复只需更新agent-skillscrmClient调用逻辑,重新发布@myorg/agent-skills@1.2.1,所有使用它的 Agent(包括那个支持 Agent)自动获得修复,无需修改任何编排代码。

这次事故让我彻底放弃“技能即函数”的粗放模式。真正的agent-skills必须满足三个铁律:单一职责(只做一件事)、契约明确(输入输出类型严格定义)、边界清晰(不感知 Agent 状态,不持有全局上下文)。它不是工具箱,而是标准化的“机械臂”——你可以把它装在任何机器人(Agent)身上,它只负责精准执行“拧螺丝”或“焊接”,至于什么时候拧、拧哪颗螺丝,那是机器人的事。

1.2 “skills”目录结构:为什么不用 monorepo 根目录下的 utils?

很多团队初期会把类似功能放在libs/utilsshared/下,理由是“都是通用函数”。但很快就会遇到三类典型冲突:

冲突类型utils目录下的表现agent-skills目录下的解法
语义混淆fileUtils.readFile()既用于读取用户上传的 PDF,也用于读取内部配置 YAMLskills/readUserUploadedFile.tsskills/readInternalConfig.ts分离,前者带病毒扫描、大小限制,后者带加密解密
依赖污染utils/network.ts引入了axios,导致所有只用stringUtils的模块都得打包 axiosskills/下每个文件只引入自己需要的依赖,nx graph可清晰看到skills/queryPostgrespgskills/sendEmailnodemailer,无交叉
演进失速utils版本号随主应用发布(v2.1.0),但sendEmail的 SMTP 配置变更需灰度,无法单独迭代@myorg/agent-skills-email独立发布v1.4.0,下游 Agent 通过resolutions锁定版本,灰度期可同时存在v1.3.0v1.4.0

Nx 的 workspace.json 是天然的“技能注册中心”。我们约定:所有agent-skills相关库必须以agent-为前缀(agent-shell,agent-s3,agent-postgres),并在projects中显式声明type: "library"tags: ["type:skill", "scope:agent"]。这样nx graph --group-by-type就能一键生成技能依赖图谱,nx affected:build --tags="type:skill"可精准构建所有变更的技能包。这种结构不是为了炫技,而是当你的 AI 产品要接入 12 种不同云存储、7 类数据库、5 种邮件服务商时,让“增加一个新技能”变成一个可预测、可审计、可复用的原子操作,而不是一场牵一发而动全身的重构

2. 技能函数的 TypeScript 类型契约:从“能跑”到“敢用”的关键跃迁

一个合格的agent-skills函数,其 TypeScript 类型定义往往比实现逻辑更长、更严谨。这不是过度设计,而是对抗 AI 应用中“隐式失败”的唯一防线。以最基础的readFileFromS3为例,初学者常写成:

// ❌ 危险:类型过于宽泛,掩盖真实风险 export async function readFileFromS3(bucket: string, key: string): Promise<string> { // ... 实现 }

问题在于:Promise<string>承诺了“总会返回字符串”,但现实是 S3 可能返回NoSuchKeyAccessDeniedRequestExpired、网络超时……这些都不是“字符串”,而是需要被分类处理的失败场景。当 Agent 调用此函数并假设“有返回就是成功”,就会把undefinednull当作有效内容传给 LLM,引发不可预知的幻觉。正确的契约必须显式分离成功路径与失败路径:

// ✅ 生产级契约:Success/Failure 二元结果 + 可枚举错误码 export interface S3ReadSuccess { readonly type: "success"; readonly content: string; // 原始内容,非 Buffer readonly metadata: { readonly lastModified: Date; readonly size: number; readonly etag: string; }; } export interface S3ReadFailure { readonly type: "failure"; readonly code: | "S3_NOT_FOUND" | "S3_ACCESS_DENIED" | "S3_TIMEOUT" | "S3_INVALID_CONTENT_TYPE" | "S3_CONTENT_TOO_LARGE"; readonly message: string; // 用户/运维可读 readonly details?: Record<string, unknown>; // 供调试的原始错误信息 } export type S3ReadResult = S3ReadSuccess | S3ReadFailure; export async function readFileFromS3( bucket: string, key: string, options?: { readonly timeoutMs?: number; // 默认 5000ms readonly maxContentLengthBytes?: number; // 默认 10MB } ): Promise<S3ReadResult> { // ... 实现,确保所有分支都返回 S3ReadResult }

这个类型设计背后有三层深意:

第一层:强制错误分类
code字段是字符串字面量联合类型,而非string。这意味着调用方必须用switchif/else if显式处理每一种可能的错误,编译器会报错提醒遗漏分支。例如,当code === "S3_NOT_FOUND"时,Agent 可以友好提示用户“文件不存在,请检查上传是否成功”;而code === "S3_ACCESS_DENIED"则应触发权限审计流程,而非简单重试。这种分类不是为了增加代码量,而是让“失败”本身成为可编程的信号。

第二层:元数据即价值
S3ReadSuccess中的metadata不是装饰。在金融场景中,lastModified决定是否触发实时风控模型(文件更新后 5 分钟内需重算);etag是内容指纹,可用于缓存穿透防护(相同 etag 的文件,Agent 可跳过重复解析);size则是流控依据(超过 10MB 的 PDF 自动转为 OCR 分块处理)。这些字段若藏在any类型里,下游开发者永远不知道它们存在,更不会利用。

第三层:选项即契约扩展
options参数用readonly修饰,表明其不可被函数内部修改;timeoutMs?maxContentLengthBytes?均为可选,但默认值在 JSDoc 中明确定义。更重要的是,这个接口本身就是一个“能力说明书”——它告诉所有使用者:“我能做什么,以及如何安全地控制我的行为边界”。当某天需要支持encryptionContext时,只需扩展options接口,发布v2.0.0,旧版调用方完全不受影响。

提示:我们禁止在agent-skills中使用anyunknown(除非作为泛型约束)、!非空断言。所有外部输入(如 API 响应、文件内容)必须经过zodio-ts进行运行时校验,并将校验失败转化为明确的code: "INPUT_VALIDATION_FAILED"。TypeScript 类型是编译期契约,运行时校验是生产环境护栏,二者缺一不可。

2.1 类型即文档:如何用 JSDoc 描述技能的“行为契约”

类型定义解决了“能传什么、返回什么”,但无法描述“在什么条件下会返回哪种结果”。这时 JSDoc 就成了技能的“行为说明书”。以executeShellCommand为例:

/** * 在受控环境中执行 Shell 命令 * * @remarks * - 命令在隔离的 Docker 容器中运行,超时后自动 kill * - 支持 bash 语法(管道、重定向),但禁用 `sudo`、`rm -rf /` 等危险指令 * - 输出截断:stdout/stderr 各限 10KB,超出部分以 "... (truncated)" 标记 * * @example * ```ts * const result = await executeShellCommand("ls -la /tmp", { * timeoutMs: 3000, * environment: { PATH: "/usr/bin:/bin" } * }); * if (result.type === "success") { * console.log(result.stdout); // 安全的字符串 * } * ``` * * @param command - 要执行的完整命令字符串(如 "curl -s https://api.example.com | jq '.data'") * @param options - 执行选项 * @returns 成功时返回 stdout/stderr;失败时返回标准化错误码 * * @throws {Error} 当命令语法非法(如未闭合引号)时抛出,属于编程错误,不应被捕获 */ export async function executeShellCommand( command: string, options?: { readonly timeoutMs?: number; readonly environment?: Record<string, string>; } ): Promise<ShellCommandResult> { /* ... */ }

这份 JSDoc 的价值远超注释:

  • @remarks明确划定了能力边界(容器隔离、危险指令过滤、输出截断),让调用方知道“我能放心让它做什么”;
  • @example提供可直接复制的正确用法,避免常见误用(如传入未转义的用户输入);
  • @param@returns与类型定义互补,解释字段语义(environmentRecord<string, string>,但 JSDoc 说明它是PATH等变量);
  • @throws区分了“可预期的业务失败”(返回ShellCommandResult)和“不可恢复的编程错误”(抛出Error),指导调用方如何处理。

在我们的 CI 流程中,nx run agent-shell:lint会调用typedoc生成技能文档网站,所有 JSDoc 自动转为网页,按错误码、输入参数、示例分类索引。新成员入职第一天,不是看代码,而是浏览这个网站,快速建立对“团队 AI 能力地图”的认知。

2.2 泛型技能:如何让一个函数适配多种 LLM Provider?

随着项目接入 Anthropic、Google Gemini、本地 Ollama,我们发现callLLM这个技能不能写死在某个 SDK 里。解决方案是泛型 + Adapter 模式:

// 定义统一的输入/输出契约 export interface LLMInput { readonly messages: Array<{ role: "user" | "assistant" | "system"; content: string }>; readonly model: string; readonly temperature?: number; readonly maxTokens?: number; } export interface LLMOutput { readonly type: "success"; readonly content: string; readonly usage: { readonly inputTokens: number; readonly outputTokens: number; }; readonly provider: "openai" | "anthropic" | "gemini" | "ollama"; } export interface LLMFailure { readonly type: "failure"; readonly code: | "LLM_RATE_LIMIT_EXCEEDED" | "LLM_MODEL_NOT_FOUND" | "LLM_CONTENT_FILTERED" | "LLM_TIMEOUT"; readonly message: string; } export type LLMResult = LLMOutput | LLMFailure; // 技能函数:接受任意符合契约的 Adapter export async function callLLM<T extends LLMAdapter>( adapter: T, input: LLMInput ): Promise<LLMResult> { try { return await adapter.invoke(input); } catch (error) { return mapToLLMFailure(error); } } // Adapter 接口:每个 Provider 实现自己的 Adapter export interface LLMAdapter { invoke(input: LLMInput): Promise<LLMOutput>; } // 具体实现(简化) export class OpenAIAdapter implements LLMAdapter { constructor(private readonly client: OpenAIClient) {} async invoke(input: LLMInput): Promise<LLMOutput> { // 调用 OpenAI SDK,转换响应为 LLMOutput } }

这个设计让callLLM技能具备了“Provider 无关性”。Agent 开发者只需注入不同的 Adapter 实例,就能切换底层模型,而编排逻辑完全不变。更重要的是,LLMResult的统一类型保证了所有 Provider 的错误码、用量统计、返回格式一致,Agent 无需为每个模型写一套错误处理逻辑。泛型在这里不是炫技,而是将“模型差异”这个复杂性,封装在 Adapter 层,暴露给上层的永远是同一套可预测的契约

3. Nx 工作区中的技能生命周期管理:从开发、测试到语义化发布

agent-skills的世界里,“写完函数”只是起点,真正的挑战在于如何让这个函数在数十个 Agent 服务中安全、可靠、可持续地服役。Nx 提供了一套完整的生命周期管理工具链,我们将其固化为标准流程:

3.1 开发阶段:nx generate @nrwl/node:library的隐藏规则

创建新技能库时,我们从不手动建文件夹。而是严格执行:

nx g @nrwl/node:library agent-s3 \ --directory=agent-skills \ --importPath=@myorg/agent-s3 \ --publishable \ --no-add-dependencies \ --unitTestRunner=jest

关键参数解析:

  • --directory=agent-skills:强制所有技能库位于libs/agent-skills/下,形成统一命名空间;
  • --publishable:生成project.json中的targets.publish,为后续semantic-release做准备;
  • --no-add-dependencies:禁止 Nx 自动添加@nrwl/node等无关依赖,技能库应只含业务所需最小依赖;
  • --unitTestRunner=jest:统一测试框架,便于 CI 统一配置。

生成后,立即修改project.json,添加两条关键配置:

{ "targets": { "test": { "executor": "@nrwl/jest:jest", "options": { "jestConfig": "libs/agent-skills/agent-s3/jest.config.ts", "passWithNoTests": true } }, "lint": { "executor": "@nrwl/linter:eslint", "options": { "lintFilePatterns": ["libs/agent-skills/agent-s3/**/*.ts"] } } }, "tags": ["type:skill", "scope:agent", "platform:aws"] }

tags是 Nx 的灵魂。type:skill用于区分普通工具库;scope:agent表明它服务于 Agent 层;platform:aws则是领域标签,未来可通过nx affected --tags="platform:aws"快速定位所有 AWS 相关技能。这些标签不是装饰,而是自动化流水线的触发器。

3.2 测试阶段:为什么单元测试必须覆盖“失败路径”?

agent-skills的测试覆盖率目标是 100% 的分支覆盖(branch coverage),而非行覆盖(line coverage)。原因很简单:成功路径通常只有一条,而失败路径可能有十几种。以queryPostgresWithTimeout为例,其测试用例必须包含:

  1. 网络超时:Mockpg.Client.query抛出TimeoutError,验证返回code: "POSTGRES_TIMEOUT"
  2. 连接拒绝:Mockpg.Client.connect抛出ConnectionRefusedError,验证code: "POSTGRES_CONNECTION_REFUSED"
  3. SQL 语法错误:Mock 返回error.code === "42601",验证code: "POSTGRES_SYNTAX_ERROR"
  4. 权限不足:Mockerror.code === "42501",验证code: "POSTGRES_PERMISSION_DENIED"
  5. 结果过大:Mock 查询返回 10MB 结果,验证code: "POSTGRES_RESULT_TOO_LARGE"
  6. 空结果集:Mock 返回[],验证仍返回type: "success"(这是业务需求,空结果不是错误)。

我们使用jest.mock深度模拟 pg 模块,确保测试不依赖真实数据库。每个测试用例都遵循Given-When-Then结构:

describe("queryPostgresWithTimeout", () => { it("should return POSTGRES_TIMEOUT when query exceeds timeout", async () => { // Given: Mock pg.Client to throw TimeoutError after 100ms jest.mock("pg", () => ({ Client: jest.fn().mockImplementation(() => ({ connect: jest.fn(), query: jest.fn().mockImplementationOnce(() => { throw new Error("Query timeout"); }) })) })); // When const result = await queryPostgresWithTimeout( "SELECT * FROM users WHERE id = $1", [1], { timeoutMs: 50 } ); // Then expect(result.type).toBe("failure"); expect(result.code).toBe("POSTGRES_TIMEOUT"); }); });

注意:我们禁止在测试中使用setTimeoutjest.useFakeTimers()模拟超时,因为这无法测试真实的 Node.js 事件循环行为。真正的超时必须由被测函数内部的AbortController触发,测试时 mock 的是底层驱动的响应延迟。

3.3 发布阶段:semantic-release如何与 Nx 无缝集成?

semantic-release的核心是“提交消息规范”,而 Nx 的nx release命令正是为此而生。我们的工作流是:

  1. 开发者提交 PR,标题格式为feat(agent-s3): add support for presigned URL generation
  2. PR 描述中必须包含BREAKING CHANGE:段落(如有破坏性变更);
  3. CI 运行nx affected:build --base=main --head=HEAD,只构建变更的技能库;
  4. nx affected:test --base=main --head=HEAD运行相关测试;
  5. 若全部通过,nx release --version=patch(或minor/major)触发发布;
  6. semantic-release自动:
    • 解析 Git 提交,确定版本号(feat→ minor,fix→ patch,BREAKING CHANGE→ major);
    • 生成 changelog(按技能库分组,列出每个库的新增/修复/破坏性变更);
    • 创建 Git tag(agent-s3-v2.1.0);
    • 发布到私有 Nexus registry。

关键配置在nx.json中:

{ "release": { "changelog": { "workspaceChangelog": { "entries": [ { "from": "{projectRoot}/CHANGELOG.md", "to": "dist/changelogs/{projectName}.md" } ] } }, "git": { "commit": true, "tag": true, "push": true } } }

这套流程让发布不再是“人肉操作”,而是“代码即发布”。当agent-s3发布v2.1.0时,所有依赖它的 Agent 服务会在下次nx build时自动拉取新版本(如果使用^版本范围),CI 会自动运行受影响的测试,确保兼容性。发布不再是风险事件,而是日常流水线的一个自然环节

4. 技能组合与 Agent 编排:如何让“螺丝刀”和“扳手”协同工作

单个agent-skills函数是原子能力,但真实业务需要多个技能的有序协作。例如“分析用户投诉邮件”这个 Agent,需依次执行:downloadEmailAttachmentconvertPdfToTextextractEntitiesFromTextqueryCRMForCustomerInfogenerateResponseDraft。这看似是简单的函数调用链,实则暗藏三大陷阱:

4.1 陷阱一:状态传递的“隐形耦合”

初版实现常是:

// ❌ 隐形耦合:每个函数都依赖前一个的返回,但类型不约束 const attachment = await downloadEmailAttachment(emailId); const text = await convertPdfToText(attachment.content); const entities = await extractEntitiesFromText(text); const customer = await queryCRMForCustomerInfo(entities.phone); await generateResponseDraft(customer, entities);

问题在于:convertPdfToText的输入类型是any,它期望attachment.contentBuffer,但如果downloadEmailAttachment因 bug 返回了string,类型系统无法捕获,运行时才报错。解决方案是定义编排上下文类型

export interface ComplaintAnalysisContext { readonly emailId: string; readonly attachmentContent?: Buffer; // 可选,表示尚未下载 readonly extractedText?: string; // 可选,表示尚未解析 readonly entities?: { phone?: string; orderNumber?: string }; readonly customerData?: CustomerRecord; } // 每个技能函数接收并返回 Context,形成类型链 export async function downloadEmailAttachment( context: ComplaintAnalysisContext ): Promise<ComplaintAnalysisContext> { const content = await s3.read(`emails/${context.emailId}/attachment.pdf`); return { ...context, attachmentContent: content }; } export async function convertPdfToText( context: ComplaintAnalysisContext ): Promise<ComplaintAnalysisContext> { if (!context.attachmentContent) { throw new Error("Missing attachmentContent"); } const text = await pdfLib.extractText(context.attachmentContent); return { ...context, extractedText: text }; }

这样,编排逻辑变为:

let ctx: ComplaintAnalysisContext = { emailId: "123" }; ctx = await downloadEmailAttachment(ctx); ctx = await convertPdfToText(ctx); ctx = await extractEntitiesFromText(ctx); ctx = await queryCRMForCustomerInfo(ctx); await generateResponseDraft(ctx);

类型系统强制每个步骤的输入输出匹配,ctx的类型在每一步后自动进化,IDE 能实时提示下一步可用的字段。这不再是“函数调用”,而是类型安全的状态机演进

4.2 陷阱二:错误传播的“雪崩效应”

queryCRMForCustomerInfo失败时,整个流程中断,但generateResponseDraft可能仍有价值(用已有信息生成草稿)。传统 try/catch 会打断流程:

try { ctx = await queryCRMForCustomerInfo(ctx); } catch (e) { // 如何优雅降级?ctx.customerData 为空,但其他字段有效 }

更好的方式是让每个技能函数返回Result<Success, Failure>,并在编排层统一处理:

export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; export async function safeExecute<T, E>( fn: () => Promise<Result<T, E>>, fallback: T ): Promise<T> { const result = await fn(); return result.ok ? result.value : fallback; } // 编排中 ctx = await safeExecute( () => queryCRMForCustomerInfo(ctx), { ...ctx, customerData: null } // 降级为 null,流程继续 );

safeExecute不是忽略错误,而是将错误转化为可控的降级策略。Agent 的健壮性不在于“永不失败”,而在于“失败时仍能提供最大价值”。

4.3 陷阱三:技能调用的“资源竞争”

多个 Agent 并发调用executeShellCommand时,若都试图在同一个临时目录解压文件,会因文件锁冲突失败。解决方案是引入技能执行上下文(SkillExecutionContext)

export interface SkillExecutionContext { readonly requestId: string; // 全局唯一 readonly agentId: string; // 调用方标识 readonly timestamp: Date; readonly tempDir: string; // 每次调用独立的临时目录 readonly logger: Logger; // 结构化日志实例 } export async function executeShellCommand( command: string, context: SkillExecutionContext, options?: { timeoutMs?: number } ): Promise<ShellCommandResult> { const tempDir = path.join(context.tempDir, `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); await fs.mkdir(tempDir, { recursive: true }); try { // 在 tempDir 中执行命令 } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }

SkillExecutionContext由 Agent 编排层在每次调用前创建,注入所有技能函数。它不仅是参数容器,更是资源隔离、日志追踪、性能监控的统一载体。通过requestId,可在 ELK 中关联一条完整 Agent 调用链的所有技能日志;通过tempDir,确保并发安全;通过logger,统一日志格式({ level: "info", service: "agent-s3", requestId: "abc123", message: "S3 object read success" })。

5. 生产环境中的技能可观测性:从“黑盒”到“透明引擎”

agent-skills进入生产,最大的挑战不是功能实现,而是“如何证明它在正确工作”。我们建立了三层可观测性体系:

5.1 第一层:技能级指标(Metrics)

每个技能函数在入口和出口埋点,上报到 Prometheus:

export async function readFileFromS3( bucket: string, key: string, options?: { timeoutMs?: number } ): Promise<S3ReadResult> { const startTime = Date.now(); const labels = { bucket, key, "timeout_ms": String(options?.timeoutMs || 5000) }; try { const result = await actualRead(...); // 成功指标 s3ReadSuccessCounter.inc(labels); s3ReadDurationHistogram.observe({ buckets: [100, 500, 1000, 5000] }, Date.now() - startTime, labels); return result; } catch (error) { // 失败指标(按错误码细分) s3ReadFailureCounter.inc({ ...labels, code: getErrorCode(error) }); throw error; } }

关键指标:

  • s3_read_success_total{bucket="prod",key="logs/*.log",timeout_ms="5000"}:成功次数;
  • s3_read_duration_seconds_bucket{le="100",...}:P95 延迟;
  • s3_read_failure_total{code="S3_NOT_FOUND",...}:各错误码频次。

这些指标让我们能回答:“agent-s3在过去 1 小时内,S3_ACCESS_DENIED错误是否突增?如果是,是否集中在某个 bucket?”——这直接指向权限配置问题。

5.2 第二层:技能调用链(Tracing)

使用 OpenTelemetry,为每个技能调用创建 Span:

export async function readFileFromS3( bucket: string, key: string, context: SkillExecutionContext ): Promise<S3ReadResult> { const tracer = trace.getTracer('agent-s3'); const span = tracer.startSpan('s3.readFile', { attributes: { bucket, key, 'temp_dir': context.tempDir } }); try { const result = await actualRead(...); span.setAttribute('s3.result_type', result.type); if (result.type === 'success') { span.setAttribute('s3.content_length_bytes', result.content.length); } return result; } catch (error) { span.recordException(error); throw error; } finally { span.end(); } }

在 Jaeger 中,一个 Agent 调用会呈现为树状链路:Agent-Orchestrations3.readFilepg.queryemail.send。点击任一 Span,可查看耗时、标签、日志、错误堆栈。当用户投诉“响应慢”,我们不再 grep 日志,而是直接在 Jaeger 中搜索agent-id="complaint-analyzer",找到慢请求的完整调用链,精准定位是s3.readFile延迟高,还是pg.query被锁。

5.3 第三层:技能健康度(Health Checks)

每个agent-skills库提供/health端点,返回自身依赖的健康状态:

// libs/agent-s3/src/lib/health-check.ts export async function checkS3Health(): Promise<HealthCheckResult> { try { // 执行一个轻量级 S3 操作:HEAD 请求一个已知存在的对象 await s3.headObject({ Bucket: "health-check-bucket", Key: "ping.txt" }); return { status: "UP", details: { latencyMs: Date.now() - startTime } }; } catch (error) { return { status: "DOWN", details: { error: error.message, code: getErrorCode(error) } }; } }

Kubernetes 的 liveness probe 会定期调用http://agent-s3:3000/health。如果返回DOWN,Pod 会被重启。更重要的是,我们聚合所有技能的健康状态,生成“Agent 健康仪表盘”:当agent-s3agent-postgres同时DOWN,仪表盘会高亮显示“数据访问层不可用”,而非让用户面对一个模糊的“Agent 服务异常”。

经验之谈:可观测性不是“加个监控”,而是把技能的每一次呼吸、每一次心跳、每一次咳嗽,都变成可查询、可告警、可归因的数据点。没有可观测性的agent-skills,就像没有仪表盘的飞机——你不知道它

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询