ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TypeScript智能体技能模块化设计:agent-skills工程实践

TypeScript智能体技能模块化设计:agent-skills工程实践 1. “agent-skills”不是项目名而是一套可复用的智能体能力模块设计范式你第一次在 GitHub 上看到agent-skills这个词大概率是在某个 TypeScript Nx 构建的 AI 工程化仓库里——它不带.js后缀没写main.ts入口甚至没有README.md但它的目录结构异常干净/libs/skills/web-search、/libs/skills/file-read、/libs/skills/llm-call。它不叫“插件”不叫“工具函数”更不是一堆utils/下的散装代码。它是被显式建模为“技能”Skill的、具备契约边界、可独立测试、可组合编排、可版本化发布的最小语义单元。这正是agent-skills的本质它不是某个具体项目的产物而是当前 TypeScript 生态中面向 LLM 智能体Agent工程化落地时逐渐收敛出的一套能力抽象层标准实践。关键词TypeScript决定了它的类型安全基底node提供了运行时与系统交互能力Nx承担了多技能模块的依赖管理、构建隔离与增量缓存而semantic-release则确保每次git push都能自动发布带语义化版本号的org/skill-web-search1.2.0包——这才是真正支撑“技能即服务”Skill-as-a-Service的底层基建。为什么这个命名如此关键因为skills不是tools也不是actions。tool暗示外部调用、黑盒执行action倾向于流程节点、状态变更而skill天然携带三层隐含契约输入有明确定义如WebSearchSkillInput接口规定query: string, maxResults?: number输出有结构化承诺如返回WebSearchResult[]而非any或string副作用受控且可声明如requires: [network, env:GOOGLE_API_KEY]用于运行时权限校验。我去年在重构一个金融合规 Agent 时踩过最深的坑就是把getStockPrice直接写成一个裸fetch()调用函数。结果上线后风控团队要求所有网络请求必须打日志并审计来源我们不得不翻遍 37 个文件去加console.log和 try-catch——而如果当初就按agent-skills范式定义StockPriceSkill只需在libs/skills/stock-price/src/lib/stock-price.skill.ts里统一注入日志中间件所有调用自动生效。这就是契约的力量它让“改需求”从文本搜索变成接口修改把“修 Bug”从人肉 grep 变成类型检查报错。提示agent-skills的核心价值不在“能做什么”而在“怎么被别人安全地用”。它解决的从来不是单点功能实现问题而是跨团队、跨项目、跨时间维度的能力复用信任问题。当你看到nx g nrwl/node:library --nameweb-search --directoryskills --importPathmyorg/skill-web-search这条命令时你看到的不是一个库生成器而是一份能力交付协议的签署仪式。2. 技能模块的物理结构为什么必须用 Nx 管理而不是单个 npm 包很多人第一反应是“不就是写几个函数吗建个skills/文件夹npm publish就完事了。” 实际上这是把agent-skills降维理解为“工具函数集合”直接跳过了它作为工程化能力单元的核心挑战。真正的痛点在于当你的 Agent 需要同时调用 12 个技能天气、股票、邮件、数据库、PDF 解析、OCR、翻译、日历、代码执行、知识库检索、网页抓取、语音合成这些技能之间存在复杂的依赖关系、版本兼容性、测试策略和发布节奏差异——而单包模式会让所有这些复杂度坍缩到一个package.json里最终演变成无法维护的“巨石技能包”。Nx 的不可替代性就体现在它对这种复杂性的分治能力上。我们以真实项目中的技能拓扑为例技能模块依赖项测试方式发布频率关键约束file-readfs-extra,iconv-lite单元测试 端到端文件读取低月级必须支持 Windows/Linux/macOS 文件路径规范llm-callanthropic-ai/sdk,openai模拟 HTTP 响应 token 计费验证中周级必须兼容 streaming 与 non-streaming 两种响应模式web-searchgoogleapis,serpapi真实 API Key 调用 结果结构断言高天级必须处理 SERP API 返回的organic_results与answer_box差异code-execvm2,tmp沙箱环境隔离测试 超时强制终止极高每次 PR必须禁止process.exit、require(child_process)等危险操作如果把这些全塞进一个myorg/agent-tools包里会发生什么每次web-search更新 API都要触发整个包的重新构建、测试、发布哪怕file-read完全没动code-exec因为沙箱安全策略升级需要 Node.js 18但file-read仍需兼容 Node.js 16客户旧系统版本冲突直接卡死llm-call的测试需要真实 API Key而 CI 环境不能泄露密钥只能 mock但web-search的 mock 又和llm-call的 mock 冲突导致测试套件互相污染。Nx 用三重机制破局第一重项目级依赖图谱Project Graph执行nx graph你会看到一张清晰的有向图web-search→llm-call调用 LLM 解析搜索结果摘要code-exec→file-read执行代码前先读取上下文文件。Nx 不仅识别import语句还能解析require.resolve()动态加载、甚至child_process.spawn(node, [...])这类隐式依赖。这意味着当你修改llm-call的返回类型时Nx 能精准定位出哪些技能会因此编译失败——不是靠grep而是靠 AST 分析。第二重任务调度与缓存Task Runner Cachenx affected --targettest不是简单跑所有测试而是计算本次 Git 提交影响了哪些技能模块比如只改了web-search/src/lib/serpapi.service.ts自动推导出受影响的测试目标web-search:test及其依赖的测试llm-call:test因为web-search依赖llm-call检查缓存中是否存在相同输入源码哈希 依赖哈希 Node 版本的测试结果命中则跳过执行。我们在一个拥有 42 个技能的项目中实测全量测试耗时 28 分钟而affected模式平均仅需 92 秒——因为 93% 的测试直接从缓存加载。第三重构建输出隔离Build Output Isolation每个技能模块的dist/输出目录完全独立dist/libs/skills/web-search/、dist/libs/skills/file-read/。这使得semantic-release可以为每个技能单独发版——web-search发1.5.0file-read同时发2.1.0互不干扰。更重要的是消费者可以按需安装yarn add myorg/skill-web-search1.5.0而不必拉下整个 80MB 的myorg/agent-tools包。注意Nx 的--directoryskills参数绝非装饰。它强制将所有技能模块置于同一逻辑域下使nx dep-graph --focusskills能生成专属依赖视图。如果你把web-search放apps/下它会被视为“应用”失去技能模块的复用语义——这是新手最容易犯的结构性错误。3. 技能的契约定义从any到SkillContractTInput, TOutput的类型进化agent-skills的 TypeScript 实践本质是一场从“运行时信任”到“编译时契约”的迁移。早期我们写技能函数典型代码是这样的// ❌ 反模式无契约无防御无文档 export function webSearch(query) { return fetch(https://api.serpapi.com/search?q${query}api_key${process.env.SERPAPI_KEY}) .then(r r.json()) .then(data data.organic_results.map(r ({ title: r.title, link: r.link }))); }问题在哪输入query类型是any调用方传入undefined或对象也不会报错输出是Promiseany消费者必须自己as WebSearchResult[]断言一旦 API 响应结构变更比如organic_results改名results编译器完全沉默错误处理缺失网络超时、API Key 无效、配额超限等场景全部抛出未捕获异常更致命的是这个函数无法被静态分析工具识别为“技能”——它没有Skill标识没有inputSchema描述没有outputSchema声明AI 编排器如 LangChain 的 Tool Router根本无法将其纳入决策流程。真正的agent-skills类型契约长这样// ✅ 正确显式 SkillContract 接口 export interface SkillContractTInput, TOutput { id: string; // 技能唯一标识用于编排器路由 name: string; // 用户可读名称 description: string; // 供 LLM 理解的自然语言描述 inputSchema: ZodSchemaTInput; // Zod 验证 schema运行时校验 JSON Schema 生成 outputSchema: ZodSchemaTOutput; // 同上 execute(input: TInput, context: SkillContext): PromiseTOutput; // 执行入口 requires?: string[]; // 声明运行时依赖network, env:SERPAPI_KEY } // 具体技能实现 export const WebSearchSkill: SkillContractWebSearchInput, WebSearchResult[] { id: web-search, name: Web Search, description: Search the web for up-to-date information using SERP API., inputSchema: z.object({ query: z.string().min(1, Query cannot be empty), maxResults: z.number().int().min(1).max(10).default(5), }), outputSchema: z.array(z.object({ title: z.string(), link: z.string().url(), snippet: z.string().optional(), })), requires: [network, env:SERPAPI_KEY], async execute(input, context) { const { query, maxResults } input; const apiKey context.env.SERPAPI_KEY; // 运行时校验Zod 自动抛出结构化错误 const response await fetch( https://api.serpapi.com/search?q${encodeURIComponent(query)}num${maxResults}api_key${apiKey} ); if (!response.ok) { throw new SkillError( SERP API failed: ${response.status} ${response.statusText}, { statusCode: response.status, query } ); } const data await response.json(); // Zod 自动校验并转换结果失败则抛出详细错误 return this.outputSchema.parse(data.organic_results || []); }, };这个SkillContract接口带来了四重确定性1. 编译时类型安全调用方必须传入符合inputSchema的对象IDE 自动补全字段z.infertypeof WebSearchSkill.inputSchema直接生成 TypeScript 类型。2. 运行时结构防护inputSchema.parse(input)在入口处拦截非法输入如query: nulloutputSchema.parse(result)在出口处保证输出结构稳定——即使 SERP API 某天返回空数组也不会让下游map()报Cannot read property title of undefined。3. LLM 可理解性description字段是给大模型看的“技能说明书”inputSchema和outputSchema可自动生成 JSON Schema供 LangChain 的StructuredTool或 LlamaIndex 的FunctionTool直接消费实现“LLM 自动选择技能”。4. 运维可观测性SkillError继承自Error但额外携带metadata对象如{ query, statusCode }所有技能错误统一由SkillErrorHandler捕获自动上报 Sentry 并关联skillId和inputHash排查时直接搜索web-search error 429即可定位所有被限流的查询。我们曾在线上遇到一个诡异问题web-search技能在某些查询下返回空数组导致后续llm-call解析失败。传统调试要翻日志、找 traceId、比对输入。而用了SkillContract后我们只需在SkillErrorHandler里加一行if (error instanceof SkillError error.metadata?.statusCode 429) { console.warn([THROTTLED] ${skill.id} hit rate limit for query: ${error.metadata.query}); }再配合nx affected --targetbuild --basemain --headHEAD立刻锁定是web-search的inputSchema缺少对特殊字符如#的编码处理——问题在 15 分钟内闭环。提示zod不是可选依赖而是agent-skills的基石。它比joi更轻量Tree-shakable比class-validator更适合函数式技能且其z.describe()方法可直接生成 OpenAPI 兼容的description字段。不要试图用interface替代ZodSchema——前者只在编译期有效后者贯穿开发、测试、生产全生命周期。4. 技能的自动化发布semantic-release 如何让每次git push都成为可信交付在agent-skills体系中“发布”不是运维同学深夜手动npm publish的仪式而是git push origin main后CI 流水线自动完成的原子化交付动作。semantic-release是这套自动化的引擎但它绝非开箱即用的黑盒——它的威力取决于你如何将 Git 提交信息、技能模块结构、Nx 项目配置三者深度耦合。先说结论semantic-release的核心价值不是“自动发版”而是“用提交历史作为唯一可信的版本事实源”。这意味着版本号1.2.0不再由人脑决定而是由feat:、fix:、BREAKING CHANGE:等约定式提交前缀自动计算每个技能模块的发布版本严格对应其CHANGELOG.md中记录的变更范围杜绝“这个包为什么升了 2.0.0没人记得改了啥”消费者看到myorg/skill-web-search1.5.0就能 100% 确信它包含所有feat(web-search): add serpapi fallback和fix(web-search): handle empty organic_results的提交且不包含任何feat(file-read): support zip archives的代码。要实现这一点必须完成三个关键配置层的对齐4.1 提交规范层Conventional Commits 是唯一入口semantic-release默认只识别conventional-commits格式的提交。在agent-skills项目中我们强制使用cz-conventional-changelogCommitizen作为提交工具并定制commitlint规则// commitlint.config.js module.exports { extends: [commitlint/config-conventional], rules: { // 限定 scope 必须是技能模块名且小写连字符 scope-enum: [2, always, [ web-search, file-read, llm-call, code-exec, email-send, calendar-sync, pdf-parse ]], // feat/fix 必须带 scope避免全局变更 scope-empty: [2, never], } };这样合法的提交必须是✅feat(web-search): add serpapi fallback✅fix(file-read): handle windows path separator❌feat: add new skill缺少 scope❌chore: update dependencieschore 不触发发布注意scope必须与 Nx 项目名完全一致nx list查看且全部小写。我们曾因webSearch驼峰和web-search连字符不一致导致semantic-release无法匹配技能模块所有提交都被忽略——这是配置中最隐蔽的坑。4.2 Nx 项目层project.json中的release配置每个技能模块的project.json必须显式声明发布配置这是semantic-release识别“谁该发什么版”的依据// libs/skills/web-search/project.json { name: web-search, type: library, targets: { build: { /* ... */ }, test: { /* ... */ }, release: { executor: semantic-release/exec:exec, options: { cmd: npx semantic-release --branches main --no-ci --dry-run } } }, tags: [type:skill, scope:web-search], implicitDependencies: [myorg/shared] }关键点在于targets.release是 Nx 任务可通过nx run web-search:release手动触发调试用tags中的scope:web-search与commitlint的scope-enum对齐形成闭环implicitDependencies声明了该技能依赖的共享库如myorg/shared确保shared更新时web-search也会被affected检测到并重新发布。4.3 CI 流水线层GitHub Actions 的精准触发.github/workflows/release.yml不是简单跑npx semantic-release而是利用 Nx 的affected能力只发布真正变更的技能name: Release Skills on: push: branches: [main] paths: - libs/skills/** - package.json - nx.json jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: fetch-depth: 0 # 必须获取完整 git history - name: Setup Node.js uses: actions/setup-nodev4 with: node-version: 18.x registry-url: https://registry.npmjs.org/ - name: Install dependencies run: yarn install - name: Determine affected skills id: affected run: | # 获取本次提交影响的技能模块名 echo SKILLS$(npx nx print-affected --selectprojects --baseorigin/main --headHEAD --plain | tr \n , | sed s/,$//) echo SKILLS$SKILLS $GITHUB_ENV - name: Release affected skills if: env.SKILLS ! run: | # 为每个受影响的技能单独执行 release for skill in $(echo ${{ env.SKILLS }} | tr , \n); do echo Releasing skill: $skill npx nx run $skill:release done这个流水线的关键智慧在于npx nx print-affected精准计算出本次git push影响了哪些技能比如只影响web-search和llm-callfor skill in ...循环为每个技能单独执行nx run $skill:release确保web-search发1.5.0llm-call发2.1.0互不干扰--baseorigin/main确保比较基准是远程主干避免本地分支污染发布判断。实测效果在一个包含 35 个技能的仓库中过去每周手动发布需 2 小时核对变更、打包、上传、写 Changelog现在git push后 8 分钟所有变更技能的dist/包已上传 npmCHANGELOG.md自动生成Slack 通知推送至#agent-release频道——工程师的时间终于从“发布协调员”回归为“技能开发者”。提示semantic-release的--dry-run模式是调试神器。在本地执行npx semantic-release --dry-run --branches main它会模拟整个发布流程告诉你“将发布web-search1.5.0因为检测到 2 个feat和 1 个fix提交”避免线上误操作。务必在首次配置时全程使用--dry-run。5. 技能的实战集成在 NestJS Agent 中动态加载与安全执行定义好技能、管理好发布最终要落地到 Agent 的运行时。我们以一个基于 NestJS 构建的金融分析 Agent 为例展示agent-skills如何从“静态代码”变为“动态能力”。5.1 技能注册中心SkillRegistry的单例管理NestJS 的模块化特性天然适配技能管理。我们创建SkillModule在onApplicationBootstrap阶段自动扫描所有myorg/skill-*包// modules/skill/skill.module.ts Injectable() export class SkillRegistry { private skills new Mapstring, SkillContractany, any(); constructor( Inject(SKILL_MODULES) private readonly skillModules: SkillContractany, any[], ) {} register(skill: SkillContractany, any) { if (this.skills.has(skill.id)) { throw new Error(Skill with id ${skill.id} already registered); } this.skills.set(skill.id, skill); } getTInput, TOutput(id: string): SkillContractTInput, TOutput | undefined { return this.skills.get(id) as SkillContractTInput, TOutput; } getAll(): SkillContractany, any[] { return Array.from(this.skills.values()); } } Module({ providers: [ SkillRegistry, { provide: SKILL_MODULES, useFactory: () [ // 动态导入避免循环依赖 import(myorg/skill-web-search).then(m m.WebSearchSkill), import(myorg/skill-file-read).then(m m.FileReadSkill), import(myorg/skill-llm-call).then(m m.LlmCallSkill), ], }, ], exports: [SkillRegistry], }) export class SkillModule {}关键设计点SKILL_MODULES是一个useFactory提供的异步数组确保技能模块在 NestJS 初始化时才加载避免启动时require()失败SkillRegistry是单例所有 Controller、Service 都可注入它通过skillRegistry.get(web-search)获取技能实例register()方法在构造时批量注册保证技能 ID 全局唯一防止同名覆盖。5.2 安全执行沙箱SkillExecutor的权限控制技能执行不是简单await skill.execute(input)必须叠加运行时防护// services/skill-executor.service.ts Injectable() export class SkillExecutor { constructor( private readonly skillRegistry: SkillRegistry, private readonly logger: Logger, ) {} async executeTInput, TOutput( skillId: string, input: TInput, context: SkillContext, ): PromiseTOutput { const skill this.skillRegistry.getTInput, TOutput(skillId); if (!skill) { throw new NotFoundException(Skill not found: ${skillId}); } // 1. 权限校验检查 skill.requires 是否满足 const missingRequirements skill.requires?.filter(req { if (req.startsWith(env:)) { const envKey req.slice(4); return !context.env[envKey]; } if (req network) { return !context.networkEnabled; } return false; }); if (missingRequirements?.length) { throw new ForbiddenException( Missing requirements for skill ${skillId}: ${missingRequirements.join(, )} ); } // 2. 输入校验Zod 自动解析并抛出结构化错误 try { const validatedInput skill.inputSchema.parse(input); // 3. 执行并捕获 SkillError return await skill.execute(validatedInput, context); } catch (error) { if (error instanceof z.ZodError) { throw new BadRequestException( Invalid input for ${skillId}: ${error.errors.map(e e.message).join(; )} ); } if (error instanceof SkillError) { this.logger.error(Skill execution failed: ${skillId}, { error: error.message, metadata: error.metadata, }); throw error; } throw error; } } }这个SkillExecutor提供了三层防护权限闸门requires字段在运行时强制校验env:SERPAPI_KEY缺失则直接403 Forbidden不浪费一次 API 调用输入守卫Zod 校验失败返回400 Bad Request错误信息精确到字段如Query cannot be empty前端可直接展示错误归一化所有技能错误统一为SkillError日志中自动打标skillId和inputHash便于追踪。5.3 Agent 控制器动态技能路由最后在AgentController中我们实现“根据用户指令自动选择并执行技能”// controllers/agent.controller.ts Controller(agent) export class AgentController { constructor( private readonly skillExecutor: SkillExecutor, ) {} Post(execute) async executeSkill( Body() body: { skillId: string; input: any }, ): Promiseany { // 1. 从预定义技能列表中验证 skillId 合法性防任意代码执行 const allowedSkills [web-search, file-read, llm-call]; if (!allowedSkills.includes(body.skillId)) { throw new BadRequestException(Skill not allowed: ${body.skillId}); } // 2. 构建执行上下文 const context: SkillContext { env: { SERPAPI_KEY: process.env.SERPAPI_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY, }, networkEnabled: true, logger: this.logger, }; // 3. 执行 return this.skillExecutor.execute( body.skillId, body.input, context, ); } }这个控制器看似简单却蕴含深意allowedSkills白名单是最后一道防线防止攻击者传入skillId: ../../../etc/passwd这类路径遍历context对象集中管理所有技能共享的依赖环境变量、网络开关、日志器避免每个技能重复process.env.XXX整个流程无硬编码web-search升级到1.5.0后只要myorg/skill-web-search包更新SkillRegistry重启时自动加载新版本——零代码修改能力平滑升级。我们曾用此架构支撑一个实时财报分析 Agent用户输入“对比苹果和微软最近一季度的营收增长率”Agent 自动拆解为web-search技能获取两家公司最新财报链接file-read技能下载 PDF 并提取文本llm-call技能解析文本定位“Revenue”段落llm-call技能执行数学计算并生成对比报告。整个链路耗时 12.3 秒错误率低于 0.7%而所有技能模块均由不同团队独立开发、测试、发布——agent-skills范式让智能体真正成为“可组装的乐高”。最后分享一个血泪经验永远在SkillExecutor.execute()中添加console.time(${skillId}-execute)和console.timeEnd()。我们曾发现code-exec技能在特定输入下耗时飙升至 45 秒远超 5 秒超时阈值。通过时间戳定位发现是vm2沙箱对正则表达式.*的回溯爆炸。解决方案不是优化代码而是为code-exec技能增加timeoutMs: 3000配置项并在SkillContract接口中声明——把性能契约也变成类型系统的一部分。
返回列表