☰
Agent Skills设计指南:从技能封装到动态编排的完整实践
2026/10/13 13:54:10 网站建设 项目流程

几个星期前,我在整理手头一个多智能体项目时,翻出了三个月前写的技能定义文件。里面居然有七八个功能重叠的技能,名字起得随心所欲,参数格式五花八门,甚至有两个技能的用途完全一样,只是分别在两个不同的实验分支里演化出来的。那一刻我意识到,agent-skills这个看起来很简单的问题,正在成为智能体应用从“能跑通”走向“能维护、可扩展”的关键瓶颈。

所谓 agent skills,通俗地说就是给智能体准备的一本“能力菜单”。你不需要把每个动作都写死在主流程里,而是把可复用的能力封装成一个个独立、有明确输入输出、带描述和参数的模块,让智能体在推理时根据用户请求动态选择和调用。这样做的好处非常直接:逻辑可以按领域拆分,模型不需要在一大段上下文中硬找答案,团队也能并行走不同的技能开发。这篇文章我会从技能体系的设计思路讲起,然后带你把注册机制、目录结构、调用编排和排错实践完整过一遍。无论你是刚接触智能体开发,还是已经被技能库乱成一团的工程搞得头疼,这里面都应该有你能直接拿去用的东西。

1. 先想清楚:Agent技能到底在解决什么问题

1.1 从“写死逻辑”到“按需调用”的转变

早期做智能体应用,最常见的写法是把所有能力塞进一个巨大的包裹函数里。用户说“帮我查一下明天的天气”,你就在代码里 if 判断意图,然后调用天气接口;用户说“帮我算个复利”,你又写一个 if 分支去调计算器。问题随着场景增多立刻暴露:意图判断越来越臃肿,新功能必须改动主流程,一旦某个接口挂了,整个对话就卡死在那里。

我在做的模拟项目 X 里,最初就是这种“面条式”结构。后来我们把能力拆成了独立技能,主流程只负责意图路由和上下文管理,具体动作全部交给技能模块。比如“查天气”就是get_weather技能,“查汇率”就是get_exchange_rate技能。每个技能内部自行处理参数解析、接口调用和结果格式化。这样主流程变得很薄,新增一个能力只需要新增一个技能目录,再注册一下就行,完全不用碰对话系统的核心代码。

这个转变本质上是在做“控制反转”:原来由开发者预判所有用户可能性,现在由智能体根据用户指令实时去匹配能力。它的前提是技能必须被准确描述,模型才能知道什么情况下用哪个。这也是为什么我会把技能描述和参数定义看得和实现代码一样重要。

1.2 技能与工具、插件、工作流的边界

很多初学者会被这些概念绕晕。我自己的理解是:工具是技能的“最小原子能力”,通常一个函数对应一个外部操作,例如“发送HTTP请求”“执行SQL查询”;技能则是面向任务的组合单元,它可以只用单个工具,也可以把多个工具按固定顺序串起来,比如“获取商品详情并计算优惠价”这个技能就内部串联了商品查询和价格计算两个步骤。

插件更偏重“打包分发”的概念。你可以把一组相关技能打包成一个插件,共享配置和鉴权信息,方便在不同项目之间复用。而工作流则是偏重状态流转的东西,它管理的是整个对话的流程,例如先收集参数,再调用技能,再根据结果决定下一步。技能可以嵌入工作流中,但技能本身不关心上游对话状态,它只负责完成自己那一份任务。

我建议在团队内部把它们明确区分:技能负责“完成一件事”,工具负责“执行一个动作”,插件负责“打包一组技能”,工作流负责“驱动一个会话”。这个边界一开始定了,后面写文档、做代码 review 都会省很多力气。

1.3 技能设计的三层结构:意图、参数、输出

一个合格的技能,至少要包含三个层面的设计。最上层是技能的能力声明,也就是自然语言描述:这个技能是做什么的,适合解决哪类问题,有哪些限制。中间层是参数契约:每个参数的名称、类型、是否必填、取值范围、默认值。最底层才是实现逻辑:怎么调用、怎么容错、怎么把结果转成统一结构。

我见过太多人只写了实现,完全不做能力和契约声明。后果就是模型在面对相似请求时无所适从。例如你有两个技能,一个叫“bert_summary”,一个叫“text_enhance”,如果描述都是“处理文本”,模型根本分不清该用哪个。正确的做法是像下面这样明确边界:

bert_summary:对中文长文本生成摘要,输入正文,输出最多200字的要点概述,适合新闻、报告、论文摘要场景。
text_enhance:对已有文本进行润色,纠正语病,提升表达流畅度,输入原文,输出润色后的全文,不改变原意和长度。

这一层描述直接决定了技能能不能被正确命中。模型不会看函数名,它只读描述。所以我每次写技能,都会花至少十分钟打磨这一段话,反复问自己:如果你的同事只看这段描述,能准确知道什么时候该找你吗?参数契约则决定了模型能否正确生成调用参数。复杂参数如果不给示例,模型很容易填错。比如日期格式到底是YYYY-MM-DD还是DD/MM/YYYY,枚举值大小写是否敏感,都必须在参数定义里写死。输出结构同样重要。我统一用{ "status": "success|error", "data": ..., "message": ... }这种格式,这样上层编排逻辑只需要解析三种字段,不需要为每个技能单独写分支判断。

2. 技能仓库与注册机制:让Agent看得见、调得对

2.1 技能清单:从YAML Schema到自然语言描述

技能仓库的第一份资产就是技能清单。我推荐用 YAML 或 JSON Schema 来声明每个技能的元信息,因为这种结构化数据既能被代码读取,也能方便地转成给模型看的自然语言描述。下面是一个我实际使用过的简化示例:

name: get_weather description: 查询指定城市未来3天的天气预报,适合回答“天气”“气温”“降雨”等问题 version: 1.2.0 author: alice parameters: - name: city type: string required: true description: 城市中文名,例如“北京”“上海” - name: days type: integer required: false default: 3 description: 查询天数,取值1到7 output: type: object fields: - name: forecast type: array description: 每天的日期、天气现象、最高温、最低温 - name: alert type: string description: 极端天气预警,没有则为空 tags: [weather, forecast, public-api]

这份清单在启动时会加载进内存,然后转成一段段自然语言描述,注入到系统提示词里。注意这里有个细节:不能把所有技能的定义一次性全塞进去。当技能数量超过一定阈值,上下文窗口和模型注意力都会被稀释,反而降低命中率。你需要做动态检索,只把和当前用户请求最相关的技能描述注入提示词。这个我会在 2.3 节专门讲。

2.2 注册中心的实现思路

技能注册中心是连接技能仓库和智能体运行时之间的桥梁。它要做的事情主要有三件:第一,运行时启动时扫描指定目录,加载所有技能定义文件,校验格式;第二,维护一个技能索引,支持按名称、标签、描述内容进行搜索;第三,监听文件变化,实现热更新。

我自己习惯用装饰器来注册技能。在 Python 里大概是这样的:

skills_registry = {} def skill(name, description, parameters=None): def decorator(func): skills_registry[name] = { "function": func, "description": description, "parameters": parameters or [], "version": func.__version__ if hasattr(func, "__version__") else "0.1.0", } return func return decorator @skill( name="get_weather", description="查询指定城市未来3天天气预报", parameters=[{"name": "city", "type": "string", "required": True}], ) def get_weather(city: str, days: int = 3): # 实际调用天气服务 ...

这样的好处是,技能实现和声明写在一起,新增技能时不会忘记注册。加载一个技能模块,就自动向注册中心登记了。我还加了一层启动校验:如果技能定义里缺少description或者有参数没写type,就启动失败并明确报错。把问题挡在发布之前,而不是等到模型调用时才报错,这是我在几次线上教训之后学到的。

2.3 上下文窗口里的动态技能加载

当技能数量多了以后,必须做“按需注入”。常见的做法是基于向量检索或关键字匹配。我目前更倾向于先用 BM25 或简单的关键词打分做粗筛,把候选技能限制在十到二十个,再让模型从这些候选中挑选并调用。这样既能保证响应速度,又不会让系统提示词太长。

粗筛的 key 来源包括:用户当前消息、对话历史最后两轮摘要、以及路由模型输出的意图标签。举个例子,用户说“能不能帮我把这篇英文报告翻译成中文?”粗筛会从描述中匹配“翻译”“语言转换”“文本处理”等词,优先召回translate_text、language_detect,而不会召回get_weather。

此外我还会把已经用过的技能记录加入短期缓存。在同一个会话里,如果用户接着问“刚才那个翻译结果帮我润色一下”,模型应该优先考虑当前会话已用过的技能,而不是重新做全局检索。这种基于会话上下文的技能记忆,能显著减少重复检索和误调用,体验提升很明显。

3. 实操:搭建一个可复用的技能调用体系

3.1 目录组织与命名规范

建议按业务域把技能分成几类,不要全堆在一个文件夹里。我目前采用这样的结构:

skills/ communication/ translate_text/ text_enhance/ summary_generate/ data/ sql_query/ csv_analyze/ workflow/ document_ocr/ report_generate/ utils/ string_format/ time_convert/

每个技能子目录里至少包含三块内容:SKILL.md(人类可读的说明文档)、skill.yaml(机器可读的定义)、main.py(实现代码)。如果技能里有依赖的外部资源、测试脚本,也都放在对应目录下。命名上我统一用小写字母和下划线,行为动词开头,比如fetch_stock_price而不是stockPriceFetcher。这样在代码和提示词里都很容易辨认,特别是模型输出函数名时不容易出错。

3.2 一个典型技能的实现细节

我拿一个简单的“单位换算”技能来说,它看起来简单,但完整实现包含了所有要点。首先是定义文件:

name: convert_units description: 完成长度、重量、温度、货币等常见单位的换算,适合“3公里等于多少米”“100华氏度等于多少摄氏度”等问题 parameters: - name: value type: number required: true - name: from_unit type: string required: true - name: to_unit type: string required: true

然后是实现。这里要注意,参数来自模型生成,所以必须做严格的类型和枚举校验。模型有时候会把单位缩写填错,比如把“英镑(重量)”和“英镑(货币)”混淆,你这边的处理应该是返回明确错误信息,让上层智能体有机会重新生成参数,而不是直接崩溃。

unit_aliases = { "km": "kilometer", "公里": "kilometer", ... } def convert_units(value, from_unit, to_unit): # 1. 归一化单位名 from_norm = unit_aliases.get(from_unit.lower(), from_unit) to_norm = unit_aliases.get(to_unit.lower(), to_unit) # 2. 检查是否支持 if from_norm not in conversion_table or to_norm not in conversion_table[from_norm]: return { "status": "error", "message": f"不支持从 {from_unit} 换算到 {to_unit}", } # 3. 执行换算 result = conversion_table[from_norm][to_norm](value) return { "status": "success", "data": {"value": result, "from": from_unit, "to": to_unit} }

我会额外做一步:把错误信息设计成“可纠正”的格式。比如当模型把yard写成ydr时,错误信息会提示是否指 yard?,而不是干巴巴地报“单位不存在”。这样多轮对话中模型能自动修正,用户几乎感受不到内部出错。

3.3 多技能编排的输入输出约束

复杂任务很少由一个技能单独完成,比如“对这份 PDF 做摘要并翻译成英文,然后发送到指定邮箱”。这需要文档解析、摘要、翻译、邮件发送四个技能按顺序执行。编排时最关键的是共享数据的格式。

我通常定义运行时上下文对象,每个技能从上下文中读取自己需要的字段,执行完再写回新的字段。例如:

  • document_ocr读入file_path,输出text_content
  • summary_generate读入text_content,输出summary
  • translate_text读入summary,输出translated_text
  • send_email读入translated_text和recipient,输出email_id

这样技能之间没有硬编码的互相调用,而是通过统一的上下文协作。好处是每个技能都能单独测试,也可以替换实现。比如把summary_generate从抽取式改成生成式,完全不影响到前后两个技能。如果你的编排逻辑一旦复杂,我建议画一张数据流表格,写明每个技能的输入输出字段,否则时间一长没人搞得清楚顺序。

3.4 测试与回归

技能测试分为单技能测试和链路测试。单技能测试比较好写,直接对函数发起调用,验证输入输出和错误分支。链路测试则要模拟真实对话,让模型自由选择技能,检查选择是否正确、参数填充是否合理、最终结果是否满足用户需求。

我团队的经验是维护一套“黄金问题集”,每个技能至少配三个正向问题和两个负向问题。负向问题比如“今天是星期几?”不应该触发get_weather。我们每次改动技能描述或实现,都跑一遍这套问题集,用准确率做回归。长期跑下来,能避免改了一个技能把另一个技能的命中率带崩的情况。

4. 避坑实录与我的经验总结

4.1 技能名描述不清导致幻觉调用

这是我在模拟项目 X 中踩过最大的坑。项目里有一个技能叫calendar_query,我原本期望它用来查询日程和节假日。但因为我只写了“查询日历相关信息”,模型经常在用户问“帮我安排一个会议”时调用它,导致它试图返回一个并不存在的会议安排接口。后来我把描述改成“查询指定日期的节假日和公历农历信息,不涉及日程安排或会议创建”,同时还加了负向描述“不要在用户要求创建会议时调用本技能”。这之后命中率才恢复正常。

所以请记住:描述里除了说明自己做什么,还要明确说明不做什么。对模型来说,负向提示是很好的约束,尤其在技能边界模糊的时候。

4.2 参数校验和错误恢复的容错设计

模型生成参数难免有错,尤其是复杂 JSON 结构容易漏字段或填错类型。我在运行时统一用 JSON Schema 校验,不通过就返回带原因的校验错误。更重要的一点是,智能体的调度循环需要具备“重试”能力:当技能返回错误时,调度层需要把错误信息回传给模型,让模型重新生成调用。有些框架会把技能错误直接当作最终答案返回给用户,这会让用户看到莫名其妙的“参数 city 不能为空”。正确做法是在内部进行一次重试,如果连续两三次仍然失败,才放弃并转交给兜底话术。

4.3 技能之间的依赖与版本管理

技能之间不推荐产生直接依赖,但如果确实有共享工具函数,我把它抽成common/目录,并锁定接口版本。当 A 技能的某个行为依赖 B 技能的输出格式时,我会在 A 的测试用例中固定一个 B 的模拟输出,避免 B 改动导致 A 挂掉。版本号也是硬性的,每次改行为必须升版本,并在SKILL.md里记录变更原因。上线后保留旧版本目录,至少要保留最近两个版本,方便快速回滚。

这里还要提醒一点:不要用 git 分支替代版本管理。分支只能解决代码层面的并行开发,解决不了运行时“当前对话该用哪个版本”的问题。技能注册中心需要支持按版本加载,至少要在配置里能指定默认版本。

4.4 我当前落地后沉淀的几件事

经历了多个项目的折腾,我逐渐形成了一套轻量级的技能治理流程。写技能的时候,先写 SKILL.md 里的描述和示例,再画参数契约,最后才写代码。代码写好后跑一个最小回归,确保描述里写的示例能通过。接着做一次“盲测”:找不了解这个技能的同事,不看代码,只看描述,问他这个问题该不该由这个技能处理。如果他能明确判断,说明描述合格;如果他犹豫了,就继续打磨措辞。

运行阶段,我会每天记录技能调用日志,重点关注三类数据:被调用次数、调用失败率、模型主动放弃调用的次数。如果一个技能上线一周调用量为零,大概率是描述检索不到它,需要检查召回逻辑;如果失败率高,优先看参数校验和外部服务稳定性;如果模型经常调错技能,那主要问题通常在描述边界不清楚。

最后,整个 agent-skills 体系的核心不是技术工具选得多花哨,而是能不能让团队的所有技能像一套组织良好的 API 一样,被发现、被理解、被稳定调用。我在实际维护中发现,花在技能描述和参数契约上的时间,最终会以十倍的方式回报给你。所以我的建议很简单:不要急着加新技能,先把已有技能的“说明书”写明白。

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

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

立即咨询