先聊点实在的。过去大半年我一直在折腾智能体(Agent)相关的东西,从最早只会调大模型接口写死 Prompt,到现在尝试让模型自己按需调用“技能”,走了不少弯路。今天这篇就是想把我最近搭的一套agent-skills体系完整拆开来讲,包括为什么做、怎么做、踩过哪些坑。如果你也在做智能体开发、工具调用编排、或者准备把 AI 能力接入真实业务系统,这篇文章里的思路和代码可以直接拿去参考。
1. 智能体技能管理到底在解决什么问题
先说一个普遍现象:很多人做智能体,第一个版本确实跑通了,但用一段时间就发现模型越来越“笨”。不是模型本身退化了,而是你往系统里塞的东西太乱——几十个函数堆在 tools 列表里、系统 Prompto 里写满了流程描述、每次新增功能都要改核心代码,慢慢就变成一坨谁也动不了的“屎山”。
我用的智能体框架是最主流的那个大模型对话框架,它本身支持“工具调用”和“插件式扩展”,但这些机制有两个明显短板:
- 工具定义僵化。每加一个函数就要重新定义一个 JSON Schema,还得手写参数校验和错误处理。
- 逻辑与代码强耦合。想让智能体做完 A 再做 B、失败时换 C 方案,这些“编排逻辑”要么写死在控制器里,要么塞进 Prompt 里让模型自己“猜”,结果就是行为不可控。
agent-skills这个思路就是为了解决这两个问题。它的核心主张是:把每一项能力封装成一个“技能单元”,技能之间相互独立、可插拔、可编排。智能体只需要通过一个统一的入口请求技能,剩下的参数校验、重试、降级、记录都由技能管理层来完成。
一套成熟的技能体系,至少要满足这几个条件:
- 每个技能有标准的输入输出协议,模型和人类都能看懂;
- 技能支持动态注册与卸载,不用频繁改框架源码;
- 技能可以组合调用,比如“查天气”之后自动“添加日程提醒”;
- 技能执行过程有完整的日志追踪,出了问题能定位到具体节点。
我拆了几个实际业务场景,发现凡是智能体效果不稳定的项目,90%都能归类为技能管理混乱:有的技能又大又全,一个函数里塞五六个功能;有的技能边界模糊,查天气和查空气质量混在一起。而把能力拆成独立技能之后,效果立竿见影——模型误调用的频率明显下降,调试效率也高了很多。
2. 先理清三个容易混淆的概念
在动手写代码之前,有个概念必须掰清楚:技能(Skill)、工具(Tool)和函数调用(Function Calling)到底有什么本质区别。我在项目评审时被问过无数次,很多人直接把它们划等号,结果设计出来的东西四不像。
| 概念 | 本质 | 例子 | 谁来定义 |
|---|---|---|---|
| 函数调用 | 模型输出结构化的调用请求 | {"name":"get_weather","args":{"city":"北京"}} | 模型协议层 |
| 工具 | 可被调用的具体能力单元 | 一个封装好的 HTTP 接口或本地函数 | 开发者 |
| 技能 | 工具 + 前置条件 + 后置动作 + 失败策略 | “出门前准备”:查天气→建议穿衣→加日历 | 开发者 + 业务规则 |
可以这样理解:函数调用是大模型回答问题的“输出格式约定”,工具是让大模型“够到外部世界的手”,而技能则是“一整套完整的行为规则”。比如单纯的工具可能只负责查数据库,但技能可以定义为“查库存→低于阈值→自动发起补货申请单→通知仓库负责人”。这个链条中每一步都可能用到不同工具,但组合起来的整体行为才是技能。
我在文档里给团队的规范是:凡是只做一次外部请求、无状态、无副作用的功能,定义为工具;凡是涉及多步骤、有状态流转、需要容错策略的功能,一律封装为技能。这个约定执行两个月下来,代码仓库结构清晰了很多,新增需求时基本不会出现“不知道该改哪里”的情况。
另外还要区分“技能”和“插件”。插件更偏向代码层面的一组扩展包,而技能偏重运行时行为。一个技能可以依赖多个插件,一个插件也能支撑多个技能,二者不是同一维度的概念。
3. 技能描述文件的结构化设计
我在实现 agent-skills 时,第一件事不是急着写代码,而是先设计“技能描述文件”的格式。我参考了主流的 OpenAPI 规范和几个开源框架的插件清单格式,最终用 YAML 作为载体。为什么用 YAML?上手成本低,注释友好,关键是能直接渲染成用户可读的手册。
一个完整的技能描述文件长这样(命名上覆盖了实际项目 X 的场景):
name: logistics_order_query version: 1.0.0 description: 根据运单号查询物流轨迹,支持多家物流公司。常用于订单物流跟踪与异常件排查场景。 author: dev_team visible: true tags: - logistics - order - trace triggers: - 查物流 - 快递到哪了 - 包裹跟踪 - 运单查询 parameters: - name: tracking_number type: string required: true description: 物流运单号,支持数字与字母。 - name: carrier_code type: string required: false enum: [SF, ZTO, YTO, STO] description: 物流公司编码,不传则自动识别。 steps: - name: validate_input action: builtin_validator inputs: - tracking_number on_failure: terminate_with_message - name: auto_carrier action: detect_carrier inputs: - tracking_number outputs: - carrier_code on_failure: use_default - name: request_trace action: call_api endpoint: "https://api.example.com/trace" params: waybill: $tracking_number carrier: $carrier_code vendor: logistic_provider retry: 3 timeout: 10 on_failure: terminate_with_message outputs: - name: current_status type: string description: 当前物流状态,如运输中、已签收。 - name: trace_points type: array description: 物流轨迹点列表。这个文件有几个关键设计。
3.1 触发词与意图识别
triggers字段不是给模型看的,是给上层调度器用的。当用户没说“帮我查下快递”而是直接丢一个运单号过来时,调度器可以根据正则或向量相似度匹配到logistics_order_query技能。这么做能大幅减少模型乱选技能的情况,我实测下来误触发率从 18% 降到了 3% 以内。
当然触发词不需要写全,只写最典型、最高频的表达即可。判断标准是:团队成员看到这个词,能自然联想到这个功能。宁可漏几个次常用法,也不要写一堆模棱两可的词,否则调度器反而容易混淆。
3.2 参数声明与校验
参数这一块,我刻意没有完全依赖模型的 JSON Schema,而是加了一层自己的校验配置。原因是:模型偶尔会给出格式正确但业务上无效的参数(比如负数运单号),这类错误在 Schema 层面是发现不了的。
处理策略是:每个技能参数支持required、enum、pattern和validator四类约束。我一般建议能不用自定义validator就不用,因为每引入一个自定义校验函数,就会多一层维护成本。用pattern正则解决 80% 场景足够了。
3.3 步骤编排与失败策略
steps是技能的灵魂。我见过一些同类方案,把步骤定义成列表让模型自己“理解”,但实际执行时,模型对步骤顺序的遵循率并不理想。所以我的设计中,步骤本身就是可执行的“小脚本”,每个 step 有对应的 action 处理器,调度器按顺序执行,模型不需要“理解”步骤逻辑。
这里要特别强调on_failure策略。我常用的四种:
terminate_with_message:失败后终止,并把错误信息返回给用户。retry:按配置重试 N 次,每次间隔可配。use_default:失败后使用默认值继续执行后续步骤。fallback_skill:失败后跳转到另一个技能处理。
这个设计参考了工业界很多工作流引擎的做法。实际效果很直接:以前智能体遇错就“胡编答案”,现在是“明确说做不到并给出原因”,极大提升了用户体验。
3.4 输出协议
输出字段也是显式声明的。我的习惯是把输出分为core_data和display_text两部分:前者给系统流程继续使用,后者是给用户看的格式化文案。这种区分看起来很微小,但实际场景中非常有用——下游步骤拿core_data做判断,展示层只消费display_text,两边互不污染。
4. 技能注册中心与动态加载
设计好描述文件,下一步是把它加载进系统。我实现了一个轻量的“技能注册中心”,本质上是一个带索引和缓存的技能库。它承担三个任务:收集技能文件、做语法和依赖校验、向调度器提供查询接口。
目录结构是这样组织的(我习惯按技能类型分目录,但部分团队也喜欢扁平化管理,都可以):
skills/ ├── logistics/ │ ├── logistics_order_query.yaml │ └── carrier_detect.yaml ├── schedule/ │ ├── calendar_query.yaml │ └── meeting_booking.yaml └── common/ ├── time_convert.yaml └── address_parse.yaml注册中心的代码部分,我用了主流的 Python 类库来简化路径遍历。核心逻辑是扫描目录、加载 YAML、解析成内部的SkillDescriptor类,然后做三件事:
- 语法解析:用 Python 的 YAML 解析器读取文件,保证格式合法。
- 引用校验:检查当前技能引用的动作处理器是否都存在。这一步非常关键,能避免运行到一半才发现“处理器没注册”的尴尬。
- 冲突检测:检查技能名是否重复,以及触发词是否与已注册技能冲突。
加载完成后,所有技能会放进一个字典,以技能名为 key,同时建立触发词索引。调度器来查询时,先精确匹配技能名,再模糊匹配触发词。整个过程我控制在一秒以内完成,不影响服务启动速度。
需要说明的是,动态加载不等于“热更新”。我目前的设计里,技能文件变化后需要调用一次refresh()接口才能重新加载。之所以不做全自动监听,是为了避免运行中技能突然变脸导致状态不一致。如果你要的是“改完配置立即生效且测试稳定”,可以先保留手动刷新机制,等有完善的灰度验证时再做热更新。
5. 技能执行引擎的实现要点
既然技能本质是一组带约束的步骤序列,执行引擎就要像一个微型流程解释器。我实现的执行器核心是一个循环,逐条取出步骤,分发到对应的 action 执行器,并把上游输出灌入下游输入。
5.1 参数传递与上下文管理
技能执行时,所有参数放在一个共享的SkillContext字典里。每个步骤从该字典读取输入、执行、然后把输出写回。这个设计的好处是:步骤之间不必显式声明依赖关系,只要保证上游输出的字段名和下游输入的字段名一致,数据自然流转。代价是需要保证字段命名统一,否则查错时很崩溃。
我的命名约定是:输出字段全部用 step 名做前缀。比如request_trace步骤输出的current_status,实际存储为request_trace.current_status。这样即使两个步骤输出同名变量,也不会互相覆盖。
5.2 重试与超时控制
得益于描述文件里的retry和timeout字段,执行引擎本身不需要硬编码重试逻辑。每次调用外部 API 时,考虑到网络不稳定,我会设一个统一的默认超时(比如 10 秒),再按技能配置覆盖。
重试策略我采用的是指数退避加抖动:第一次失败后等 1 秒,第二次 2 秒,第三次 4 秒,上限是技能配置的次数。加入随机抖动是为了避免多个实例同时重试造成外部服务的负载峰值。
5.3 日志与观测性
执行日志是调试智能体最有用的工具,但也是最容易做废的。我之前从“日志全打到一个文件”升级到“日志全部结构化”的核心技巧是:每次技能执行都生成一个全局唯一的 trace_id,从调度器发起到最终输出,所有日志都带这个 ID。
日志级别我分成四个:
- INFO:记录技能开始、结束、参数摘要。
- DEBUG:记录每一步的输入输出、耗时。
- WARN:记录偶发失败但通过重试恢复的步骤。
- ERROR:记录重试后依旧失败的情况。
线下排查时,我用一个简单命令即可按技能名和 trace_id 过滤日志,效率比之前提高了非常多。
5.4 与既有项目 X 的集成实践
我最初是在一个跨平台项目里引入这套技能的。那个项目支持桌面端和移动端,之前所有工具调用逻辑都集中在一个功能入口函数中,后续加了十几个功能后根本维护不动。引入 agent-skills 之后,我做的改造是:
- 原有工具函数按能力拆分为独立技能;
- 原入口函数改为通用调度器,不再感知业务细节;
- 多端统一调用同一套执行引擎,只让展示层各自适配。
这样改完之后,新增一个功能只是加一个 YAML 文件和一个 action 处理器,不改动调度器逻辑。后续在移动端demo上验证,整体执行时延并没有明显增加。
6. 技能扩展:从 0 到 1 写一个新技能
这部分实操性最强。我以“查节日安排”为例子,梳理一个完整的技能开发过程。
6.1 明确边界与输入
第一步是想清楚技能做一件事就够了。比如“查节日安排”这个技能,只做两件事:查询指定日期或节日名称的安排详情和调整上班日提醒。不负责创建日程、不负责统计节假日天数,那些是其他技能的事。
6.2 新建 YAML 描述文件
节日安排可能需要联网数据源,也可能用本地节假日表。考虑到这个技能要快速上线且可用率高,我选了本地节假日表加远程规则兜底的策略。描述文件填好 name、description、parameters、steps,步骤里先查本地表,成功则直接输出,失败则远程获取。
6.3 实现 action 处理器
我新建了一个 action 处理器文件,定义了一个类。类里实现execute(context, step_config)方法,从 context 取参数、调用查询、把结果写回。
class HolidayQueryHandler(BaseActionHandler): action_type = "query_holiday" def execute(self, context, step_config): date = context.get("date") source = step_config.get("source", "local") if source == "local": result = self._query_local(date) else: result = self._query_remote(date) context.set("query_holiday.result", result) return result这里我没有贴完整实现,但有两件事值得说明:
- 每个 action 处理器只依赖 context 和 step_config,不依赖任何外部全局状态,保证可测试性。
- 每个 handler 在类级别声明
action_type,注册中心启动时自动收集所有 handler 并建立映射表。
6.4 注册并验证
把 YAML 文件放到技能目录,调用刷新接口,再用一个测试脚本调用调度器,传入“今天”的日期,打印输出结果。验证通过后,我测试了异常输入(如非法日期)和本地表缺失场景,确保失败策略生效。
整个流程我统计过,一个熟练的开发者从拿到需求到可测试运行,大约 30 分钟。前提是 handler 基类设计得够顺手,不用每次重写公共逻辑。
6.5 技能的组合编排
单个技能写多了之后就会有组合需求。比如这个例子:用户问“五一期间哪天该补班”,就会同时触发“查节日安排”和“日历查询”两个技能。我的调度器目前的做法比较务实:先映射主技能,执行过程中如果需要另一个技能的数据,就显式声明一个required_skills字段,运行时由执行引擎自动拉起,而不是让模型自己“意图重定向”。
组合的代价也很明显:无论嵌套多少层,每一个技能执行时的参数校验都跑一遍,日志追踪的 trace_id 必须一路传下去,否则排查起来非常痛苦。
7. 技能调优:让智能体更会“选技能”
技能体系搭起来后,下一步是让智能体在正确的时候调用正确的技能。这里有两条路线:基于规则的路由和基于模型的意图识别。两条路线我都测过,结论是混合方式效果最稳。
我目前使用的混合策略是:
- 先把所有技能的触发词做规范化,建一套倒排索引。
- 用户输入进来,先做精确匹配和正则匹配,命中就直接路由。
- 没有命中时,才把候选技能列表和用户输入一起交给模型去判断。
这里有个细节:交给模型时,不是把所有技能描述一股脑全塞进去,因为模型上下文窗口有限且小模型效果会严重下降。我的做法是先用向量检索粗筛 Top 5 候选,再把这 5 个技能的描述和参数说明交给模型精排。这样准确率大幅提升,而且即使大模型偶尔返回一个不存在的技能名,兜底逻辑也会给出友好提示。
此外还需要定期回归测试。我把 300 条典型对话样本做成了回归集,每次调整技能后跑一遍,统计正确路由率。低于 90% 就回滚。这套机制看起来死板,但能防止“改 A 技能弄坏 B 技能”的典型回归问题。
8. 常见问题与排查技巧实录
这部分都是实打实踩过的坑,我整理成速查表,方便直接对照。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 技能加载时报语法错误 | YAML 缩进或引号有问题 | 先用解析器单独解析该文件,再校验参数格式是否符合规范。 |
| 技能加载成功但调度不命中 | 触发词与用户表达差异过大 | 在测试环境中打印关键词匹配过程,确认触发词表是否覆盖目标语义;必要时补充同义词。 |
| 技能步骤执行到一半失败 | 上游输出字段名与下游输入不一致 | 打开 DEBUG 日志,重点对照每一步的 context 写入与读取的字段名。 |
| 技能执行耗时超出预期 | 外部 API 响应慢、重试次数过多 | 检查平均耗时分布,确认超时配置是否合理;适当调低重试次数。 |
| 多个技能同时命中候选 | 触发词过于泛化 | 给技能补充更精准的触发词,或提升向量检索阶段的相似度阈值。 |
| 模型返回不存在的技能名 | 候选列表过大或引导不充分 | 限制候选技能数量,优化向量检索召回逻辑;在 Prompt 中明确列出可调用范围。 |
| 日志打印混乱无法定位 | 缺少统一 trace_id | 为每次技能执行生成唯一 ID,并贯穿所有执行日志、外部调用日志。 |
| 技能测试通过但线上偶发失败 | 外部依赖不稳定或配置不一致 | 检查线上配置是否正确,用重放日志的方式逐个断言线上执行过程,定位差异点。 |
还有几个容易忽略但很典型的坑,多说几句。
8.1 并发执行导致共享状态被污染
如果多个用户同时触发同一个技能,而技能实现中误用了类级别的变量保存临时状态,那一定会串数据。我的解决方案是:写 handler 时只操作 context,禁止在类属性上保存任何请求相关的临时数据。
这类问题排查起来比较隐蔽,我分享一个技巧:在 DEBUG 日志中打印 context 的 id,并在每次读写时记录调用栈。一旦发现异常值但找不到来源,就能通过 context id 比对是否是并发串扰导致。
8.2 重试导致外部副作用重复执行
像“发通知”“创建订单”这种操作,重试成功后客户端倒是一直收不到确认消息,实际数据已经重复创建。我引入了“幂等键”机制:每个需要幂等的步骤,在第一次执行前生成一个idempotency_key并发送给外部系统;重试时带着同一个 key。如果外部系统不支持幂等键,就退而求其次,在执行前先查重再执行。
8.3 模型 Prompt 中混入无关技能
做得早的时候,我把所有技能的描述拼进系统 Prompt,结果上下文被无关内容占据。后来改成候选技能动态注入后,这个问题解决了。判断标准是:每个技能描述控制在 100 字以内,只保留“何时用”和“怎么用”两句关键信息,其余细节全部留在执行参数里。
9. 从技能到知识:进一步的扩展方向
技能体系跑稳定后,我自然想到一个更大的问题:技能解决了“能做”的问题,但很多场景还依赖“该怎么做”的经验知识。比如同一套疾病问诊技能,不同医生团队会给出不同的问诊顺序和追问方向,这些差异不适合写死在代码里,也不适合完全交给大模型发挥。
我的应对是在技能描述文件里额外挂一个knowledge_refs字段,指向一组只读知识文档。执行引擎在技能跑完后,把文档的关键内容注入上下文,供模型组织最终回复。这样做的效果是:技能负责外部动作,知识库负责表达规则,两者分开维护,互不影响。
这个方向的探索还比较初步,但已经能看出价值:那些需要行业经验积累的领域(譬如医疗问诊、法律咨询、设备售后),把老专家头脑里的决策树固化到技能关联知识库中,新员工只需调用对应技能即可继承经验。后续计划是把知识引用做成可版本化的独立单元,实现“一套技能 + 多重知识策略”。
10. 一些总结性的个人经验
最后分享几段个人体会,不写空话。
做 agent-skills 这类系统,最忌讳一上来就贪大求全。我最初设计过包含循环、条件分支等特性的复杂执行引擎,后来发现实际需求根本用不到那么重的东西——90% 的场景就是按顺序执行几个步骤,失败时换个姿势重试。把核心做简单,才能让扩展点真正被人用起来。
另一个体会是:技能命名和边界设计一定要有制度。我建议每个技能描述文件顶部写清楚“本技能不做什么”。这比写“能做什么”更能阻止模型误调用。我见过太多技能描述写得文采飞扬,结果调用错了才去看,才发现“原来它不负责这个”。
还有,做技能体系最好的策略不是从零写框架,而是先盘点现有代码库里的函数和接口,按能力维度拆出来,再决定哪些包装成技能。拆的时候只做“搬运与声明”,不要顺手改功能逻辑——降低回归风险,上线后再逐步优化。
如果你正准备在项目中落地类似的技能管理机制,我建议你先挑一个最简单的功能跑通全流程,哪怕只是一个“查询当前时间并格式化输出”的技能。这个流程跑通之后,再逐渐把核心业务接口接进来,验证调度准确率和端到端耗时。这套体系不是银弹,但当一个项目的智能体功能多到让你改不动的时候,你一定会觉得当初花时间搭技能层是值的。