ARTICLE DETAIL

资讯详情

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

Agent技能管理实战:从函数到可复用技能库的设计与踩坑

Agent技能管理实战:从函数到可复用技能库的设计与踩坑 做 Agent 开发绕不开的一个坎技能管理。等你的 Agent 开始接真实任务比如联网搜索、操作数据库、调用内部 API你会发现 prompt 里写满工具说明根本没法维护。早期做 demo 无所谓函数一多模型就开始上下文混乱、参数填错、选错工具甚至对着空气调一个不存在的接口。我最近把整个工具体系重构成一个可复用的技能库也就是这次要聊的 agent-skills跑了两个月的真实业务场景整体效果稳定很多。这篇就把我的设计思路、编码实现、踩坑记录都摊开来讲想给自己的 Agent 建技能库的可以直接照着抄。这个项目最核心的一件事把能被 Agent 调用的能力从函数升级成技能。函数只是 API 的薄封装技能则是一套完整的调用协议——包含能力描述、输入输出 Schema、执行逻辑、失败兜底、以及给模型返回的观察文本。Agent 本身是一个推理循环它的可靠性上限取决于你给它的工具像不像一个可以被正确使用的东西。这次整理出的 agent-skills本质上是建立了一套运行时协议和一组生产级技能实现让模型在复杂任务里能更快、更准地找到并调用正确的能力。如果你也在被这几个问题折磨模型总把参数类型传错、工具描述写太长反而干扰推理、技能报错后 Agent 直接放弃任务、或者新加一个工具要改动调用主流程好几处——那这篇文章正好对症。1. 技能体系的设计先拆层级再定协议1.1 为什么不能继续堆函数大多数项目刚开始都是把工具写成普通的异步函数然后拼一个 JSON 数组塞进 prompt。这种方式有三个工程隐患第一描述和实现耦合太深。工具的注释、参数说明、业务规则全塞在代码里想给某个工具换一种 prompt 视角的描述得改函数本身风险很大。第二缺少统一的错误反馈结构。函数 throw 一个字符串模型拿到后无法判断是参数问题、超时问题还是业务规则拦截只能瞎猜然后可能反复重试同一个错误调用。第三没有可观测性。每个工具被调用了几次、耗时多长、模型因为哪个工具产生了幻觉完全无迹可寻出问题只能靠猜没法系统性地排查和优化。我重构 agent-skills 的第一件事就是给所有技能一个统一外壳。核心约定是一个技能 元信息 输入 Schema 执行函数 观察结果。元信息和执行函数分离描述文本可以单独维护和测试一个技能就是一个自包含的模块。1.2 技能拆解的粒度怎么定技能拆太细模型需要在几十个工具之间反复跳转上下文很快就爆了拆太粗一个函数里塞了太多业务分支参数描述复杂到模型看不懂。我的实操原则是按一次独立调用能闭环的子任务来切。拿发邮件这个场景举例。我不会拆成 create_email_draft、get_recipient_info、smtp_send 这种粒度而是直接做成一个 send_email 技能输入里包含收件人、主题、正文、附件路径。模型一次调用就把整件事办完返回一个邮件 ID 就算结束。但搜索场景就不一样搜索是一次性查询不需要状态直接做 search_web 技能凡是涉及多步操作、需要持续追踪状态的任务才会考虑拆成多个子技能按状态机推进。还有一个原则技能之间不互相调用。两个技能要组合由 Agent 的推理循环来做编排而不是在技能内部硬编码调用另一个技能。否则技能之间的依赖会让整个系统变成意大利面条而且没法单独测试。1.3 给技能配一个注册表我建了一个技能注册表本质上就是一个按名字索引的 Map存着所有技能的元数据。Agent 启动时遍历注册表把所有技能的描述和参数 Schema 打包成一个数组再注入系统提示词。注册表带来的最大好处是可插拔。新增技能只要写好技能类在注册表里 register 一下prompt 部分自动生成调用路由也自动生效完全不用改主流程代码。移除技能也是一样的注销即可。注册表里还记录了一些运行时统计调用次数、平均耗时、失败率、最近错误信息。这个设计帮我解决了很大的排查效率问题后面第四部分细说。2. 技能描述的艺术让模型一眼选对同一套技能描述方式不同模型的工具选择准确率能差出 20%。我一开始也觉得反正是自然语言写得详细点总没错结果被现实教育了不是写得越全越好而是要让模型在最短时间内建立清晰的匹配关系。2.1 描述里该写什么不该写什么一个好的技能描述应该包含这个技能做什么、在什么场景下用、哪些情况不该用、调用前需要满足什么条件、典型的返回内容长什么样。不需要包含的是底层技术细节、代码实现逻辑、冗长的背景故事。模型不需要知道这个技能背后是 http 请求还是数据库查询它只需要知道这个动作会产生什么效果。我自己的模板是这样技能名称: search_web 能力描述: 通过搜索引擎检索公开网页信息适合查询实时新闻、技术资料、热点事件、人物背景。返回结果为标题、URL、摘要列表。 使用限制: 不能用于需要登录才能访问的站内信息不能替代精确的站点搜索。 注意事项: 查询结果时效性受搜索引擎索引影响重要信息建议用多个关键词交叉验证。这段描述控制在 100 字以内但包含了模型决策所需的全部信息。模型判断当前任务是否需要外部实时信息时一眼就能定位到这个技能。2.2 参数 Schema 的粒度控制参数 Schema 其实是模型最容易出错的地方。我见过太多设计把参数全写成 required然后模型在缺信息时就开始编造或者把字符串传给数字字段。我的经验是三个原则第一required 字段越少越好。只有执行逻辑真正离不开的字段才标 required能靠默认值兜底的都设成可选。比如搜索技能query 必须有其余像 region、lang、limit 统统 optional 并给默认值。第二每个字段必须写中英文双注释。我理解是模型对不同语言的敏感度不同中英双语注释能显著减少参数类型的误判。尤其是在枚举类字段上把每个可选值用中文解释一遍。第三类型尽量用 string 和 number 搞定少用 object 嵌套。模型在深层嵌套对象上的 fill 表现糟糕容易把对象当字符串传。设计技能入参时宁可拆成多个平铺字段也别嵌套两层以上的结构。这在后面实际编码时给我少了很多麻烦。3. 手写一个生产级技能从零到一3.1 技能类的统一结构我定义了一个 BaseSkill 基类所有技能继承它。技术栈选的是 Node.js TypeScript因为 Agent 运行时这块生态最顺测试框架也成熟。类型定义是核心直接约束住每个技能的形状。export interface SkillMeta { name: string; description: string; tags: string[]; version: string; } export interface SkillContext { userId: string; traceId: string; signal: AbortSignal; getSecret: (key: string) Promisestring; } export interface SkillResult { status: success | error | aborted; data?: unknown; error?: { code: string; message: string; recoverable: boolean; }; observation: string; durationMs: number; } export abstract class BaseSkill { abstract meta: SkillMeta; abstract inputSchema: ZodSchema; abstract execute(input: unknown, ctx: SkillContext): PromiseSkillResult; }这里最关键的字段是observation。它是一段拼好的文字模型在下一轮推理时会读它。我不想让模型直接看原始 JSON 返回值而是把结果变成一个经过提炼的自然语言事实。举个例子搜索技能返回的原始结构里有 15 条结果我观察文本只保留 top 3 条摘要和总结果数。模型拿到精简后的信息做下一步决策反而更准。3.2 用 Zod 做入参校验Zod 是我测试下来对 TypeScript 类型推导最友好的 Schema 库。技能实现里第一个动作永远是校验参数任何非法入参都在进入执行层之前被拦住。import { z } from zod; const searchWebSchema z.object({ query: z.string().min(1).max(200).describe(搜索关键词需要具体、准确), region: z.enum([us, jp, cn, eu]).optional().describe(指定搜索结果地区默认通用), limit: z.number().int().min(1).max(10).optional().default(5).describe(返回结果数量默认5条), recency: z.enum([day, week, month]).optional().describe(限定信息时效性适合新闻类查询), }); type SearchWebInput z.infertypeof searchWebSchema;校验失败时我会把 Zod 的错误信息转换成带 error code 的结构告诉模型是哪些字段哪种类别出错了。这一点对模型自纠错特别重要它能在下一轮直接修正参数而不用看原始堆栈。3.3 执行层的兜底策略真实环境里外部接口总会超时、限流、返回垃圾数据。技能执行层最怕的无非三种异常网络异常、下游接口变更、超时。针对这三种我在每个技能里都做了分类异常映射。try { const response await fetchWithTimeout(url, { timeoutMs: 8000, signal: ctx.signal }); ... } catch (err) { if (err.name AbortError) { return { status: aborted, observation: 用户已取消本次搜索任务, durationMs: ctx.signal.aborted ? elapsed : 0, }; } if (err.code ETIMEDOUT) { return { status: error, error: { code: SEARCH_TIMEOUT, message: 搜索服务响应超时请稍后重试或简化关键词, recoverable: true, }, observation: 搜索引擎超时没有拿到任何结果。建议用户等一下再试。, durationMs: elapsed, }; } ... }注意这里recoverable字段它告诉模型这个错误是值不值得换个参数重试还是压根别折腾了。我在多个 Agent 上验证过加上这个字段后模型在遇到可恢复错误时的自动重试率明显提升遇到不可恢复错误时也不会反复撞墙。每个技能的durationMs会被记录进 trace 模块长时间不达标的技能就能被量化识别而不是靠看日志猜。3.4 完整实现一个 search_web 技能把上面三块拼起来一个完整的技能长这样export class SearchWebSkill extends BaseSkill { meta { name: search_web, description: 通过搜索引擎检索公开网页信息支持按地区和时效性过滤, tags: [search, web], version: 1.2.0, }; inputSchema searchWebSchema; async execute(input: SearchWebInput, ctx: SkillContext) { const parsed this.inputSchema.parse(input); // 这里通常调用搜索引擎API或内部代理服务 const results await searchEngine.query(parsed, { signal: ctx.signal }); ... return { status: success, data: results.slice(0, parsed.limit), observation: this.buildObservation(results, parsed.limit), durationMs: elapsed, }; } private buildObservation(results: SearchResult[], limit: number) { if (results.length 0) { return 没有搜索到相关内容建议换一组更短的关键词或调整地区设置。; } const top results.slice(0, limit) .map((r, i) [${i1}] ${r.title}\n${r.url}\n${r.snippet}) .join(\n\n); return 搜索到约 ${results.totalCount} 条结果以下是最相关的 ${limit} 条\n${top}; } }这段代码我个人用下来最满意的一点是buildObservation它把原始结果转化成一种模型友好的信息流。模型读完这段文本后对后续步骤的判断准确率高很多因为决策依据提前被过滤好了。4. 实战接入让 Agent 真正跑起来4.1 系统提示词里的技能清单格式技能注册表生成系统提示词时我会控制每个技能的描述在 80~150 字之间。格式上不用严格的 JSON而是用固定模板的缩进文本实测模型对这种人类可读的格式感知更强。可用技能清单 1. search_web 用途搜索公开网页信息适合实时新闻、背景调查、技术查询 参数query(string, 必填), region(string:us/jp/cn/eu, 可选), limit(number:1-10, 默认5), recency(string:day/week/month, 可选) 注意重要信息请多个关键词交叉验证 2. run_sql 用途对业务数据库执行只读SQL查询支持SELECT和EXPLAIN 参数sql(string, 必填), database(string, 默认etl_warehouse) 注意只允许只读操作DDL/DML会被拒绝生成 prompt 的代码也有讲究我会把同一个技能放在重复区域不要生成成 JSON 对象数组否则有些推理模型对 JSON 格式信息提取能力会下降。4.2 运行时调用循环技能怎么被模型调用需要一个非常清晰的运行时循环构建消息数组系统提示词 历史消息 当前任务调用模型得到输出检查输出里有没有 tool_calls没有 → 返回回复给用户结束有 → 进入下一步遍历所有 tool_calls在注册表里查技能技能不存在 → 返回错误 observation标记不可恢复技能存在 → 校验参数 → 执行 → 拿到 SkillResult把每个技能执行结果 append 到消息数组回到步骤2继续下一轮这个循环看起来简单但有几个细节直接影响稳定性第一工具调用超时。每个技能的执行都受 AbortController 控制主循环给到 15 秒上限。超时就算技能内部没崩溃也主动中断避免模型长时间卡在一个失败动作上。第二同一轮里多个工具调用的响应要按顺序拼接。有些模型会一次性请求两个工具我把多个结果按工具调用的 id 严格映射回消息。顺序一旦错乱模型会彻底懵掉后续推理直接崩。第三必须限制最大循环轮数。我设置成 8 轮一旦超过就强制终止 Agent 进程让上层返回任务太复杂请分步提问。这个限制了死循环也保护了 token 预算。有一个实际例子用户问帮我搜索一下某家公司最近的融资消息然后用英文总结。Agent 第一轮调 search_web拿到搜索结果第二轮调自定义的 translate_text把摘要翻译成英文。第三轮直接回复总共不过三轮调用。技能编排简洁的话这种多技能组合任务跑得很稳。4.3 技能组按任务类型动态注入注册表里的技能如果全量注入二三十个技能的描述会占掉几千 token而且干扰模型判断。我加了技能组机制本质上按任务模版做技能子集。系统先通过意图识别选出一组再把对应的技能描述注入 prompt。目前生产环境分成四个技能组技能组适用场景包含技能示例通用信息组日常问答、知识查询search_web, extraction, summarize业务数据组内部数据查询、报表run_sql, get_metrics, export_csv内容生产组文案、翻译、SEOgenerate_text, translate_text, tone_adjust系统操作组内部系统操作send_email, create_ticket, manage_webhook分组之后模型一次最多只需要看 6~8 个技能上下文更轻选错率也明显下降。分组判断本身就是一个简单的分类函数或一次小模型调用成本很低。5. 常见坑与排查清单这一部分是我损失最多时间换来的。总结成一张速查表拿去直接用。症状可能原因快速排查方法模型反复调用同一个失败技能错误信息未说明可恢复性检查技能返回的 error.recoverable 是否设置正确参数总是传成字符串数字字段类型描述不清晰去掉字段描述里的歧义词加默认真实示例技能调用率极低描述里的触发条件写得太模糊在描述中加明确当用户提到××场景时应该使用模型自己编造参数必填参数太多将尽可能的字段设为 optional 并提供默认值同批多个工具调用顺序混乱返回值没有按 tool_call id 拼接引入严格的 id 映射逻辑写单测覆盖技能卡住不回话未设置超时或者 timeout 太长全局统一 15 秒技能内层 8 秒提前中断输出结果幻觉率高observation 太长信息过载精简 observation 字段只保留对下一步决策有用的核心信息5.1 一次典型的参数幻觉问题有个阶段我的邮件技能总出问题模型会把收件人字段填成the users email address或者把 rake 字段传成布尔值。后来我把参数 Schema 的每个字段都替换成了带示例的写法recipients: z.array(z.string().email()) .describe(收件人地址列表必须为有效邮箱格式示例[aexample.com])加了示例之后该问题的出现率降了很多。经验就是模型有模仿示例的倾向一个够清晰的示例远胜于十行抽象的字段解释。5.2 上下文污染的坑另一个坑是技能返回的 observation 太长导致上下文里全是垃圾数据。早期的 search_web 会把 15 条结果的完整正文全部返回模型陷入各种无关细节回答跑偏。我把 observation 写成提炼事实 关键来源 下一步建议三段式整个信息密度立刻上来了。每个技能写完都要检查一遍 observation 是否超过 400 字超过就得砍。Agent 的上下文窗口是战略资源不能被技能输出白白佔用。5.3 关于可测试性最后说一个工程上的重点每个技能都要有独立的单测别只靠集成测试。我现在每个技能至少有一个成功路径测试、一个参数校验失败测试、一个下游超时测试。mock 掉外部服务把执行结果锁定后面调整 prompt 或重构时特别有安全感。测试代码结构describe(SearchWebSkill, () { it(should return structured observation on success, async () { const skill new SearchWebSkill(); const result await skill.execute( { query: typescript 5.0, limit: 3 }, mockContext() ); expect(result.status).toBe(success); expect(result.observation).toContain(最相关的 3 条); }); });6. 后续还能怎么扩展agent-skills 这个方向越做越觉得空间大。现在跑通的基础版本只是一个起点有几个方向正在折腾多 Agent 间技能共享。A 团队写好的技能B 团队可以直接通过注册表协议引入技能描述和 Schema 本身就是标准化的打包成一个 npm 包即可复用效率很高。技能的自学习优化。日志里记录着模型对每个技能的调用过程和结果评价我准备做一个后处理脚本定期把调用成功但用户反馈不满意的样本挖出来反向优化技能描述形成自动化迭代闭环。基于技能成本的路由。每个技能的耗时和 token 成本差异很大后期可以在注册表里做一次预算估计某些低价值的动作直接跳过模型调用用规则引擎顶替省时省 token。Agent 能力的上限很大程度上就是技能工程的天花板。把技能当成产品来做把描述当作文案来打磨把调用当作协议来测试整个系统的稳定性和智能感会同时上升。现在的 agent-skills 已经很能打了但离我理想中的让技能像乐高积木一样自由组合还有距离。如果你也在搭 Agent 技能层欢迎把这篇文章当作一个起步参考设计上有更好的思路随时可以一起聊。
返回列表