☰
Agent-Skills实战:构建智能体技能编排体系的完整指南
2026/10/12 3:47:41 网站建设 项目流程

开篇我先不绕弯子。agent-skills,说白了就是把智能体(Agent)能干的活儿,从“一堆零散的工具函数”升级成“一套有标准接口、有自描述、能被模型按需调用的技能组合”。我做了几年大模型应用,项目从简单的问答机器人做到复杂的多步骤任务系统,中间踩过的最大坑,就是模型不知道什么时候该调什么、参数到底该填什么、多个工具之间怎么配合。后来我把所有能力全部重构为“技能”抽象,整个系统的稳定性和可维护性上了一个大台阶。这篇博文就围绕agent-skills这个标题,把我实际搭建技能体系时的设计思路、格式规范、编排细节和排坑实录都拆开讲,适合正在做智能体功能开发、或者想把工具调用做得更工程化的朋友参考。

1. 为什么需要“技能层”,而不是继续堆工具函数

1.1 裸工具调用的问题,只有上过生产环境才懂

很多初期的智能体项目,建模思路非常简单:定义一堆函数,每个函数给个名字和描述,然后让大模型根据用户输入去“选函数、填参数”。Demo阶段跑得挺欢,可一旦进入生产环境,马上会暴露三个致命问题。

第一,函数描述写不够。一个函数就一两句话,模型能理解“这个函数是干嘛的”,但很难把握“什么时候该优先用它”“它的输出和其他模块有什么关系”。比如一个“查询库存”的函数,描述里写着“查询商品库存数量”,模型看到“这件衣服还有货吗”,也许会调用,可看到“我想要红色的那款”,它可能就不知道需要先调“查询商品规格”再调“查询库存”。功能之间缺乏语义衔接,模型就变成无头苍蝇。

第二,参数校验几乎为零。裸函数用JSON Schema简单约束一下类型还好,但复杂业务里“把用户说的‘最近’转成日期范围”“把‘附近’转成经纬度范围”这类语义解析,根本没法在Schema里表达。结果就是模型天马行空填参数,后端接口被塞进一堆非法请求。

第三,执行逻辑薄。单个工具只是“一个动作”,但任务往往需要“多个动作按顺序配合”。比如“帮我把某个报表发给项目群并@管理员”,拆开是“生成报表”“找到群聊”“发送消息”三个动作,裸工具场景下需要模型一次生成完整的多步调用序列,生成错一个环节,整个任务就断掉。

1.2 技能的本质:把“动作”提升为“能力封装”

技能(Skill)和工具(Tool)最大的区别在于:工具描述的是“一个可执行函数”,技能描述的是“一项完整的能力边界”。一份合格的技能定义,至少要包含四样东西:

一是技能的触发条件。什么时候该用这个技能?你希望模型在什么场景下优先考虑它?这比工具描述更强调“意图匹配”。

二是明确的输入输出协议。输入输出统一用结构化JSON,而不是自然语言来回传。比如“查询天气”技能,输入是城市名和日期,输出是一个结构化对象,包含温度、湿度、风力。这样后续其他技能可以直接消费,减少模型二次理解。

三是内部的执行剧本。技能封装好后,模型不需要关心内部是调了两个API还是查了三次数据库。对模型来说,它会看到“技能A已经完成,结果是……”。这大大降低了模型编排长任务的认知负担。

四是安全边界。哪些技能允许无监督执行,哪些技能必须人工审批,哪些技能需要二次确认。这些规则不应该散落在业务代码里,应该和技能定义绑在一起。

我在实战里把技能比作“岗位JD”。你招一个人,不会只给他一张纸写着“会Excel”,而是告诉他“你是数据分析助理,负责月报输出,输入是原始表,输出是指标卡”。技能定义也是在给模型“写JD”,写清楚职责、输入、输出、协作边界,模型才不是一个漫无目的的按钮机。

2. 技能格式设计与注册机制

2.1 从一段TypeScript接口说起

我的技能模块落地时,参考了当时团队里“函数式插件”的写法,最后沉淀出一套接口,每一份技能就是一个满足下面结构的对象:

interface AgentSkill { // 技能唯一标识,全系统不能重复,建议用“域.动作”格式 name: string; // 例如 "inventory.queryStock" // 一行话:这个技能是做什么的,给模型看的,必须包含触发场景 description: string; // 标签:用于技能路由和管理,不属于模型上下文 tags: string[]; // 例如 ["inventory", "readonly"] // 输入协议:JSON Schema,描述模型要填的参数 inputSchema: Record<string, any>; // 输出协议:JSON Schema,描述返回给模型的结构 outputSchema: Record<string, any>; // 执行函数:内部实现,参数就是模型填好并经过校验的输入 execute(input: Record<string, any>, context: ExecutionContext): Promise<Record<string, any>>; // 技能描述元信息:版本、作者、依赖技能列表、安全级别等 meta: { version: string; author?: string; requires?: string[]; // 依赖的其他技能 name securityLevel: "auto" | "auth" | "manual_approve"; }; }

这套接口初看平平无奇,但真正写起来会发现几个非常关键的细节。

name字段我用“域.动作”的命名方式,比如 project.createReport、project.sendReport、inventory.queryStock。这么做有两个直接好处:一是技能多了以后,模型更容易通过名称结构理解“这一类动作大概是干什么的”;二是后期做权限控制时,可以直接按域前缀匹配,比如“project.*”技能组某个角色可用,效率高。

description字段的位置非常微妙。我专门花时间做过对比实验:一份只有三十个字的description,和一个经过反复打磨、包含“适用场景”和“不适用场景”的description,在意图识别准确率上的差距可能超过两成。后来我们沉淀出一个公式化的描述模板:

  • 第一句写“此技能用于……”补全动词
  • 第二句写“典型的调用场景是……”,举例说明
  • 第三句写“当……时,不要调用此技能,而应该使用……”

这个模板简洁有效,能让模型少犯“病急乱投医”的错。

2.2 注册中心:不是放个Map就完事

技能注册不是把所有技能往一个数组里一放,然后靠遍历拼进Prompt。我经历过的阶段是:一开始几个技能直接写死在代码里,后面技能涨到二十几个,每个技能的description拼起来几千字,模型上下文被撑爆,触发准确率反而往下掉。这才意识到要做一个真正的“技能注册中心”,核心解决的问题有三个。

第一个是技能动态发现。所有技能在系统启动时自动扫描注册,而不是在业务代码里写死引用。我给技能模块定了一个约定:每个技能放在独立文件里,导出默认对象,注册中心读取目录后自动汇总。这样做的好处非常实际——新加一个技能不需要改注册表,也不会因为忘了把技能加进去导致线上功能缺失。

第二个是描述按需注入。拼上下文绝不是把所有技能描述全塞进去,而是通过一层“技能路由”先粗筛一遍。系统会根据对话主题、用户历史行为、以及模型初步提取的意图标签,把可能用到的技能子集取出来。子集大小我一般控制在十五到二十个技能以内,每个描述控制在两百字以内,这样上下文可控,意图识别精度也高。

第三个是技能依赖图的维护。注册中心会扫描每个技能的meta.requires,构建一张有向无环图。执行一个技能前,检查依赖是否满足,不满足则先执行依赖技能。比如“发送周报”技能依赖“生成周报数据”和“查询目标群聊”,两个依赖执行完,主技能才会被允许执行。依赖关系写死在技能定义里,比让模型自由发挥要可靠得多。

3. 技能编排的核心环节

3.1 决策与路由:模型只做选择题,不要做填空题

好的技能编排体系,一定是“模型做主流程判断,系统做主流程执行”。我常跟团队说的一句话是:不要指望大模型精确理解每一个业务细节,它需要的是在合理的选项里做选择,而不是在高维空间里自由发挥。

因此路由层被设计成两跳。第一跳是粗粒度意图分类。系统预先定义若干意图域,比如“查询域”“写入域”“管理域”“闲聊域”。通过一个轻量级分类器(可以是一个小模型,也可以是一套规则加关键词)判断用户请求属于哪个域,然后只把该域相关的技能描述注入给主模型。第二跳才是让主模型在候选技能集合里选出具体的技能并填好参数。

这层设计有一个很值得说的点:永远不要让模型在没有候选时自行“发明”行为。如果所有技能描述都已注入,但模型判断任何技能都不适合,它唯一能做的是回复“无法处理并说明原因”,而不是自己去组装步骤。早期版本允许模型“自由尝试”,结果就是它频繁用错技能,让人觉得智能体既笨又不可控。

路由和候选技能的选择,还可以配合一个非常简单的排序规则:根据用户请求中的名词实体与技能tag的匹配度、技能历史调用频率、技能安全级别等差值的权重分排序。这个排序逻辑不需要多么复杂的算法,但能显著提高最终技能选择的命中率。我实际测试下来,加了简单排序之后,由路由带来的触发准确率提升大概在十个百分点上下。

3.2 输入输出校验与延迟摘要机制

技能的参数,模型填的是一份JSON。但模型不像程序,它对JSON的字段类型、值域、日期格式、时间表达的理解并不稳定。比如用户说“帮我查一下昨天的数据”,模型可能输出“昨天”这种自然语言,也可能输出一个具体日期,甚至可能输出一个相对时间表达式。这些都需要一个专门的“参数解析与归一化层”来处理。

我在项目里用JSON Schema做基础校验,但Schema表达能力有限。真正发挥作用的是一套“参数正则化规则”。比如日期类参数统一由一个工具函数处理,识别“昨天”“上周一”“最近三天”并转换为绝对日期;枚举类参数如果模型填了不在列表里的值,系统自动尝试做同义词映射,比如“红色”映射到“红”,映射不上就明确报错并回传给模型,而不是静默丢弃。

这里还有一个我踩过坑后的强烈建议:不要盲目相信“输出永远是JSON”。模型以为自己在输出JSON,实际上可能会夹带解释性的文本。所以解析环节必须使用容错JSON解析器,去掉代码块包装、提取大括号区间、修复裸URL和末尾逗号。没有这层兜底,接口偶发报错会让整个流程中断,而复现问题又极难。

输入校验通过后,执行得到输出,但输出往往也很大。比如查库存返回几百条记录,直接全部塞给模型,模型上下文很快就会被冲爆。我在技能层引入了“延迟摘要机制”:技能的execute返回原始结果,但系统只把经过摘要压缩后的结果传给模型。摘要规则写在技能内部,比如“库存列表只返回总记录数和前五条样本”“详细内容需要模型主动要求时才展开”。这个设计和工具层最大的区别就在这:技能知道自己什么样的输出对后续决策有价值。

主动展开的策略也结合起来用。如果模型需要更多数据,它会再发起一次技能调用,传入“从第几条开始取”的分页参数。相当于把技能输出做成懒加载模式。这套机制上线后,长会话的上下文膨胀率降低了将近一半,模型在高频任务中的专注度也稳定了很多。

4. 实操:从零搭一套最小可用的技能框架

4.1 技能定义的标准样板

我直接给出一份可照抄的最小技能实现。用“查询某地天气”作为例子,不引入任何特定第三方API,换成任何HTTP接口即可:

// skills/weather.query.ts import type { AgentSkill } from "../skill-types"; export const weatherQuerySkill: AgentSkill = { name: "weather.query", description: "此技能用于查询指定城市在未来三天内的天气情况。典型调用场景:用户询问某地会不会下雨、适不适合出门、温度多少。当用户询问历史天气、气候统计时不要调用本技能,应该使用 weather.history。", tags: ["weather", "readonly"], inputSchema: { type: "object", properties: { city: { type: "string", description: "城市名称,例如:深圳" }, date: { type: "string", enum: ["today", "tomorrow", "day_after_tomorrow"] } }, required: ["city", "date"] }, outputSchema: { type: "object", properties: { city: { type: "string" }, date: { type: "string" }, temperature: { type: "number" }, condition: { type: "string" }, humidity: { type: "number" } } }, meta: { version: "1.0.0", securityLevel: "auto", requires: [] }, async execute(input) { // 这里是实际调用天气服务的地方 const data = await fetchWeatherAPI(input.city, input.date); return { city: data.city, date: data.date, temperature: data.temp, condition: data.weather, humidity: data.humidity }; } };

这份样板虽然简单,但每个字段都有它存在的理由。inputSchema里的description不是给后端看的,而是给模型填参数时的“辅导提示”,写清楚“城市名称”和“例如:深圳”会显著降低模型乱填的几率。meta里的securityLevel“auto”表示允许模型直接执行,不需要二次确认。安全级别字段在后端执行前和控制层挂钩,不是摆设。

4.2 让技能“会调用其他技能”:递归组合示例

技能真正的威力在学习互相配合。我用一个“自动生成销售简报并发送到群聊”的复合技能来演示,这个技能不直接实现业务逻辑,而是编排两个子技能:

export const report.sendWeekly: AgentSkill = { name: "report.sendWeekly", description: "此技能用于自动生成本周销售简报并发送到指定工作群。典型调用场景:用户说“发一下这周销售汇报”。当用户只是要求查看文字摘要,不要求发群时,使用 report.summary,不要使用本技能。", tags: ["report", "write"], inputSchema: { type: "object", properties: { groupName: { type: "string", description: "目标群聊名称" } }, required: ["groupName"] }, meta: { version: "1.2.0", securityLevel: "manual_approve", requires: ["report.generateData", "im.findGroup"] }, async execute(input, ctx) { // 1. 生成数据 const data = await ctx.executeSkill("report.generateData", { range: "this_week" }); // 2. 寻找群聊 const group = await ctx.executeSkill("im.findGroup", { name: input.groupName }); // 3. 发送消息 await ctx.executeSkill("im.sendMessage", { groupId: group.id, content: formatReport(data.result) }); return { status: "sent", groupId: group.id }; } };

这段代码里的核心是ExecutionContext提供的executeSkill方法,它让技能可以像调用函数一样嵌套调用其他技能,从而组合出复杂能力。

但也正因为有了组合,必须提前定死“循环调用”的限制。我规定了两条红线:一条是A技能不能直接或者间接调用自身,注册中心用依赖图检测循环依赖,启动时报错而非运行时报错;另一条是每个技能最多只能嵌套调用三层子技能,超过即拒绝执行。这个限制的灵感来自一次线上事故:一个技能A调用技能B,B又调用C,C内部又回调A,差一步就栈溢出,当时是服务崩溃才发现。如果从设计上就不允许深递归,很多AI应用常见的“无限循环”问题根本不会发生。

4.3 注册中心的最小实现

注册中心的实现策略是“约定大于配置”,靠目录扫描来收集技能。核心代码逻辑不复杂:

// registry.ts import fs from "node:fs"; import path from "node:path"; const skills = new Map<string, AgentSkill>(); export async function loadSkills(skillsDir: string) { const files = fs.readdirSync(skillsDir).filter(f => f.endsWith(".skill.ts")); for (const file of files) { const mod = await import(path.join(skillsDir, file)); const skill = mod.default as AgentSkill; validateSkill(skill); skills.set(skill.name, skill); } // 构建依赖图并检查循环 buildDependencyGraph(skills); } export function getSkill(name: string): AgentSkill | undefined { return skills.get(name); }

这里有个实用的约定:技能文件名和技能name的末段保持一致,比如文件weather.query.ts对应技能weather.query。这样排查问题的时候,日志里看到技能名就能立刻定位文件,不需要在Map里翻来覆去。

5. 版本管理与灰度回滚

5.1 技能不是“写完就完”,是要持续迭代的

技能跑在业务线上,就意味着它随时可能因为外部接口变动、模型行为变化、用户新需求而需要调整。一个成熟的技能体系必须支持版本管理。

我采用语义化版本约定。版本号三段式:主版本号、次版本号、修订号。主版本升级意味着技能输入输出协议变化,会影响所有依赖该技能的调用方;次版本升级意味着新增参数、扩展能力,兼容旧调用;修订号只处理bug修复和描述优化。

每次版本升级都会在技能定义里保留一个“变更日志”字段,记录这版改了什么、为什么改。这个字段绝不写入模型上下文,只存在技能包元数据里,方便开发人员复盘。实际运营中,我们积累了大几十个技能,有些技能一年没动过,有些技能一个月改了八版,没有版本字段根本维护不下去。

5.2 用的最多的“模型维度灰度”

技能迭代最大的风险是“模型行为不可控”。你改了技能的description,这版模型可能理解得更好,但换个版本的模型可能反而触发率下降。因此灰度策略必须能控制“某个技能的新版本只对某一类流量生效”。

我在生产环境里将流量按几个维度切分:按比例随机灰度、按业务线灰度、按用户白名单灰度、按对话会话ID哈希灰度。最简单的投产方案就是“按模型版本灰度”:如果你在同时接入新旧两个大模型,可以让新模型走新技能描述,旧模型继续用旧版本描述。这样两边互不影响,一旦发现新技能表现不佳,秒级回滚到旧版本,不会造成线上事故扩大。

这里有个细节可能很多人忽略:技能版本的切换,作用于“注入到模型上下文的description”,而不是“系统里注册的技能代码”。这意味着即使新版description效果不好,你只需要切换“技能描述版本”和“技能执行函数版本”两个维度中的一个。某些时候你只是改了描述,并不是改了逻辑,这两者必须能独立灰度。我在项目里把“技能视图”拆成三个版本维度——描述版本、参数Schema版本、执行函数版本,三者分开控制,灵活度非常高。

灰度期间的观测指标也有讲究。单一指标容易骗人,我一般看四个核心指标:触发准确率(该用的时候用没用到)、参数校验通过率(模型填参的合法程度)、任务完整执行率(是不是经常中途失败)、用户反馈不满意率(兜底兜没兜住)。四个指标对照着看,基本上技能小改有没有问题心里有数。

灰度期间还要专门监控“连坐效应”。有些技能是复合技能,内部依赖好几个子技能,其中一个子技能的版本更新,可能影响所有上层技能。我在灰度方案里独立设置了一条告警:如果某个技能在灰度版本下的调用失败率上涨,自动暂停该技能灰度,并通知运维检查依赖技能是否被改动。

6. 常见问题与排查技巧实录

6.1 模型死活不触发某个技能

这种情况遇到过很多次。排查时先把“该用的技能”和“实际被用的技能”拉到一个日志表里做对比,然后依次检查四件事:

第一,看技能描述是不是跟用户高频表达“对不上”。比如用户说“多少钱”,你的技能描述里写“查询价格”,也许没问题,但如果写“获取商品费用信息”,模型可能就不认识了。尽量用用户会说的口语做描述关键词,不要端着书面语。

第二,看技能是否被路由层筛掉了。如果候选技能集合里根本没有这个技能,模型当然触发不到。我早期在路由层犯过“过度过滤”的错,一个技能因为标签分类不匹配被过滤,导致线上反复出现“明明有这个功能却从不触发”。后来我把路由层的过滤逻辑定为“宁多勿漏”,即使保留低概率技能,也要让模型在候选里自己判断。

第三,看是不是同时存在两个语义相近的技能,模型选项摇摆。比如“查询订单”和“查询订单详情”两个技能描述高度重叠,模型的触发概率就会忽高忽低。解决方法是合并技能,或者在description里写明边界差异:“当只需要订单列表时用前者,需要订单内商品明细时用后者”。

第四,看输入Schema是否太严格。如果模型认为技能可用,但想想参数凑不齐,它也可能放弃触发。此时应适当放宽非关键参数的required标注,或者增加可选参数的默认值生成逻辑。

6.2 模型填出一堆“幻觉参数”

模型填参数时的“幻觉”也是让人头疼的重灾区。有次用户问“去杭州出差该穿什么”,模型竟然把“杭州”解析成参数并调用一个城市天气技能,这不算离谱;离谱的是模型把“出差”解析成一个日期参数,直接传了个非法日期。这个问题最有效的一招就是“参数白名单加别名库”。

我在每个技能的inputSchema之外维护一个“别名映射表”,把用户口语里的表述映射到标准枚举值。比如“天气技能”的“date”参数,别名库里有“今天”“明天”“后天”“周末”“下周”。模型输出原始表述后,解析层要么直接命中别名库,要么通过日期解析工具转换,转换失败就从可选值里选默认值。一次都不转、直接塞给后端,就是在给自己埋雷。

再说一个和“幻觉”相关的隐蔽情况:模型调用技能时可能往参数里带一堆它自己脑补的字段,这些字段并不在Schema里。严格模式下会直接丢弃这些多余字段,避免污染业务逻辑。我从某次线上事故中吸取的教训是:千万不要让“宽容的模型输出”直接进入业务层。每个技能参数在传入execute之前都必须经过一层“schema净化”,只保留schema里声明的字段,其他一律过滤掉。

6.3 技能执行成功但用户觉得没干活

这种“技术成功、体验失败”的情况,最容易被忽视。你查日志看到技能调用了、接口返回200了,但用户觉得智能体什么也没给他。后来定位到:技能确实执行了,但返回的是核心数据摘要,太简洁,用户感知不到完成度。

解决方法是技能里加一条“执行反馈文本”字段。execute返回的结果除了结构化数据,还应该包含一段面向用户或面向模型说的“动作描述”。比如“查询库存”技能返回时,不光给数字列表,还要附带一句“当前共有12件库存,其中3件正在促销”。 模型拿到这句,再做润色转发给用户,用户才会有“AI真的动手帮我查了”的感知。用大白话说,技能不该是“闷头干事”,还得给后续发言人递话头。

7. 关于设计边界的一些最后建议

我做了这么久的技能体系,最大的心得是“技能层承担的是一个翻译和封装的工作”。它要把业务世界里的动作翻译成模型能理解的语言,同时把模型模棱两可的意图翻译成系统可以严格执行的指令。这种双向翻译,链路长、细节多,不能指望靠好用的模型一步到位。关键要把系统边界划清楚:模型负责选择和表达意图,系统负责校验和执行,技能层负责封装和组合。

最近我在尝试把技能继续拆细,增加更细粒度的“原子技能”和“复合技能”分层,让复合技能在编排时完全基于原子技能,业务逻辑更清晰。另外一个还在验证的方向是技能执行完毕后的“结果自我复盘”,让技能自己检查输出是否满足需求,不满足就重试一次,减少无用输出进入模型上下文。

这套模式离完美还有距离,但方向上我个人很确信:大模型应用的稳定落地,靠的不是“更聪明的模型”,而是“更清晰的能力切片”。把技能做好,就是把这种清晰切片的工程化基础打好。如果你也在做智能体,强烈建议不要跳过技能设计这一步。先定义清楚技能的描述、输入、输出、校验和版本,再谈功能上线,后面会省下无数个维护期的深夜。

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

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

立即咨询