1. 先搞清楚agent-skills到底在解决什么问题
这两年做大模型应用,尤其是做Agent相关项目的人,应该都有一个很强烈的体感:模型越来越聪明,但Agent干活的边界越来越模糊。我问过身边好几个做AI产品的朋友,大家吐槽最多的不是模型能力不够,而是“模型什么都能聊,但真让它干一件具体的事,经常掉链子”。这背后的核心矛盾,就是我一直想聊的agent-skills——给智能体沉淀一套可复用、可组合、可观测的“技能库”。
先说清楚我理解的agent-skills是什么。它不是一段Prompt,也不是简单挂一个Function Calling的接口列表,而是一整套让Agent具备“稳定执行某类任务”能力的工程化方案。你可以把它理解成给Agent装了一套“工具箱”,每个技能对应一个标准化的操作流程,包含触发条件、输入输出协议、执行步骤、依赖资源、失败兜底策略。Agent接到用户需求后,先做意图识别,再从技能库里调度合适的技能,像流水线工人一样把活干完。
这套东西解决了什么问题?最直接的是三个:
- 降低模型自由发挥带来的不确定性。没有技能约束时,模型可能每次生成不同的调用方式,有了技能后,行为路径是可预期的。
- 让复杂任务可以被拆解和复用。一个“查天气并提醒带伞”的需求,底层是两个技能:天气查询、消息推送,拆开之后任意组合。
- 让Agent的行为可观测、可调试。技能有明确的输入输出和日志,出了问题能定位到具体环节,而不是对着黑盒猜。
适合谁来参考?说实话,只要你在做AI Agent、AI工作流、自动化助手这类方向,哪怕还处于原型阶段,这篇文章都值得看完。我会把技能库的设计思路、落地代码、踩过的坑一次讲透。
2. 技能体系怎么设计:我的分层方案
2.1 顶层设计:一个Agent技能仓库的目录结构长什么样
我最早做Agent项目时,代码里直接塞了一堆if-else判断,然后在这些分支里调用不同的API。跑通Demo没问题,可一旦技能超过五六个,代码就乱成一锅粥。后来我参考了语言服务领域的插件化思想,把技能库设计成了独立的“技能文件包”,每个技能都拥有自己独立的知识、资源和执行逻辑。
一个结构清晰的技能仓库,我建议长这样:
skills/ ├── meta.yaml # 技能总索引,声明所有已注册技能 ├── weather_query/ # 技能1:天气查询 │ ├── skill.yaml # 技能定义:名称、描述、参数、触发条件 │ ├── main.py # 技能执行主体 │ ├── requirements.txt # 技能专属依赖 │ └── assets/ # 技能需要的静态资源(如城市编码表) ├── logistics_track/ # 技能2:物流查询 │ ├── skill.yaml │ ├── main.py │ └── requirements.txt └── reminder_push/ # 技能3:提醒推送 ├── skill.yaml ├── main.py └── requirements.txt这个结构最大的好处是“高内聚、低耦合”。每个技能的内部实现细节被完全封装,Agent调度层只需要读取skill.yaml里的元信息,就能决定什么时候调用、传什么参数、拿什么结果。新加一个技能就是新建一个目录,老技能出问题也不影响其他技能运行。
为什么不用微服务?说到底,技能是Agent的“手和脚”,不是独立的业务系统。微服务太重了,而且技能之间往往需要共享上下文,独立部署反而增加了通信成本。把它做成进程内的插件包,既能灵活插拔,又不会拖慢Agent的响应速度。
2.2 核心元数据:让Agent自己知道“什么时候该用哪个技能”
技能定义文件是整个agent-skills体系里最容易被低估的部分。很多人写技能时只写一个名字和一个描述,结果模型根本不知道这个技能是干嘛的,或者干脆在错误的场景下调用了这个技能。
我先展示一份我实际在项目中使用的skill.yaml模板:
name: weather_query display_name: 天气查询 version: 1.2.0 description: 查询指定城市当前天气和未来几天的预报。当用户询问天气、气温、 是否下雨、是否需要带伞、台风影响等问题时使用。 注意:仅支持国内主要城市,海外城市请使用 international_weather_query 技能。 author: your_name tags: - weather - 工具调用 - 实时数据 parameters: type: object required: - city properties: city: type: string description: 城市名称,如"北京"、"上海";若用户只说"我这",需根据IP定位后的城市名传入。 days: type: integer default: 1 description: 预报天数,默认1天。 trigger_keywords: - 天气 - 气温 - 下雨 - 带伞 - 台风 execution: timeout: 5s isolation: process retry_policy: max_retries: 3 backoff: exponential fallback: - skill: general_chat description: 若天气数据源异常,则切到通用对话技能,告知用户稍后重试。你注意到几个关键的细节吗?
description这里千万不要只写“查询天气”。模型判断是否调用技能,主要靠语义匹配,描述里必须包含“触发场景”和“边界条件”。“仅支持国内城市”这句话,能省下大量无效调用。
trigger_keywords是一个辅助手段,它不是为了硬匹配,而是帮助模型在意图不明确时快速锁定候选集。如果用户说“今晚会不会冷”,这个句子没有直接出现“天气”关键词,但技能描述里的“是否下雨、是否需要带伞”会帮助模型推理出应该调用它。
参数定义里给每一个参数写明默认值和传入规则,能有效避免模型瞎传参。比如用户说“我这”,如果模型不解析IP,就会把“我这”当成城市名传给API,结果自然是报错。
2.3 技能描述怎么写才不会被模型忽略
写技能描述,本质上是在跟模型的注意力机制做博弈。描述写太短,模型抓不住重点;写太长,关键信息会被淹没。我总结了三条实测有效的经验,直接分享出来。
第一,开头第一句话必须是“做什么 + 什么时候用”。模型对描述开头的内容注意力权重最高,所以不要把背景介绍放在前面。错误示范是“本技能用于对接和风天气API,该API由xxx公司提供……”;正确示范是“查询国内城市当前天气及未来预报,用户询问天气、气温、带伞、台风时使用”。
第二,主动声明负面场景。就像刚才weather_query里写的“不支持海外城市”,这种负向边界能防止模型在错误场景下调用。我发现不少初学者不敢写“不能做什么”,担心限制了模型能力。实际恰恰相反,你写得越清楚,模型越敢在确定场景下果断调用。
第三,给模型留“台阶”——也就是fallback字段。模型也是有“心理负担”的,如果它觉得某个请求可能超出技能范围,又找不到备用方案,它宁可自己瞎编也不去调用技能。所以我在每个技能里都配置了一个兜底技能,告诉模型“数据源挂了就切到闲聊”。这个设计在实测里大幅提高了技能调用率,因为模型知道即便出错也有退路。
我自己一般还会维护一份meta.yaml总索引,里面登记所有技能的名称、版本、状态、负责人。这样不仅便于人工管理,还能让Agent在启动时快速加载技能清单,而不必每次扫描整个目录。技能多了以后,这个索引文件就是Agent的“黄页”。
3. 实操:从零搭一个带技能库的Agent(示例)
3.1 环境准备与依赖选择
理论说了那么多,下面进入实操环节。我用一个“物流查询助手”的案例,完整演示怎么把一个技能从定义到接入Agent跑通。这个案例虽然规模不大,但足以覆盖agent-skills的关键环节。
先说环境。整套系统我建议用Python 3.10以上,因为后续要利用functools.singledispatch这类特性做技能分派,新版本语法更舒服。核心依赖只有两个:pydantic做参数校验,PyYAML解析技能定义文件。至于LLM SDK,用OpenAI兼容协议就行,国内外的模型服务基本都兼容这个协议。
安装命令很简单:
pip install pydantic PyYAML openai为了演示方便,我定义了一个最小化的技能基类,所有技能都继承它:
from abc import ABC, abstractmethod from pydantic import BaseModel from typing import Any class Skill(ABC): name: str = "" version: str = "" @abstractmethod def execute(self, params: dict[str, Any]) -> dict[str, Any]: """执行技能,返回结构化结果""" pass基类只做了一个约束:技能必须实现execute方法,并且参数和返回值都必须是JSON可序列化的。这个约束是刻意的,因为Agent的调度层不需要关心技能内部怎么实现,它只负责传递参数和接收结果。如果某个技能内部要实现复杂的业务逻辑,那也应该是技能自己的事,不能影响其他技能。
3.2 定义一个“物流查询”技能的全过程
物流查询技能的核心逻辑并不复杂:根据快递单号和快递公司编码,调用第三方物流API,解析返回结果,把物流轨迹整理成结构化JSON。但为了让这个技能“长在Agent里”,我需要做三件额外的事。
第一步,写技能定义文件skill.yaml:
name: logistics_track display_name: 物流查询 version: 1.0.0 description: 查询快递物流轨迹。用户询问快递到哪了、物流进度、包裹位置、 预计送达时间等场景时使用。支持申通、圆通、中通、韵达、顺丰、 邮政EMS等常见快递。需要用户提供快递单号,如果没有单号, 先引导用户提供。如果单号无法识别快递公司,默认使用智能识别接口。 parameters: type: object required: - tracking_number properties: tracking_number: type: string description: 快递单号,用户直接提供的原始数字串。 carrier_code: type: string description: 快递公司编码,如果用户说了公司名称则映射为编码,否则留空。注意一个细节:carrier_code是可选的,非必填。为什么?因为很多用户只知道单号,不知道是哪家快递。如果把它设为必填,Agent就会在用户没给全信息时反问用户,体验很差。我把这个字段设为可选,并且约定“没有就留空,由接口自动识别”,这样Agent可以更主动。
第二步,实现main.py:
import json from typing import Any from skill_base import Skill import httpx class LogisticsTrackSkill(Skill): name = "logistics_track" version = "1.0.0" async def execute(self, params: dict[str, Any]) -> dict[str, Any]: tracking_number = params.get("tracking_number", "").strip() carrier_code = params.get("carrier_code", "") if not tracking_number: return {"code": 400, "message": "缺少快递单号", "data": None} # 如果没传快递公司,尝试从单号格式识别 if not carrier_code: carrier_code = self._guess_carrier(tracking_number) # 调用第三方物流API(这里省略具体请求细节) result = await self._query_logistics(tracking_number, carrier_code) if result["status"] == "error": throw RuntimeError("物流接口返回异常:" + result["message"]) return { "code": 200, "message": "success", "data": { "tracking_number": tracking_number, "carrier": result["carrier"], "status": result["status"], "trail": result["trail"], # 轨迹列表 "estimated_delivery": result["eta"], } } def _guess_carrier(self, tracking_number: str) -> str: # 简单规则:顺丰单号通常15位纯数字,邮政EMS以字母开头 if tracking_number.isdigit() and len(tracking_number) == 15: return "SF" # 其他规则略 return "AUTO"第三步,把这个技能注册到技能仓库。我这里用了一个装饰器注册机制,替代之前那种手工维护列表的方式:
from typing import Type from skill_base import Skill SKILL_REGISTRY: dict[str, Type[Skill]] = {} def register_skill(cls): # 实例化一次只是为了获取名称,真正的调用时会重新创建实例 instance = cls() SKILL_REGISTRY[instance.name] = cls return cls @register_skill class LogisticsTrackSkill(Skill): name = "logistics_track" ...这里有个容易踩的坑:如果你在模块顶层直接实例化技能类,而这个技能类有I/O初始化操作(比如建立数据库连接),那么模块导入速度会变得非常慢,甚至报错。所以我只实例化一次拿名称,然后存类对象,真正运行时才创建实例。技能类本身是无状态的,实例很轻,每次调用创建实例成本极低,还天然避免了状态污染。
3.3 技能注册与动态加载机制
如果只能加载写死的技能类,那这套体系就谈不上“库”。为了做到动态加载,我用importlib按需导入技能目录下的模块。
核心代码如下:
import importlib import pkgutil import skills def load_all_skills(): for module_info in pkgutil.iter_modules(skills.__path__): if module_info.name.startswith("skill_"): importlib.import_module(f"skills.{module_info.name}")在技能仓库根目录下,我用skill_前缀标记哪些模块是技能模块,这样避免把辅助工具类也算进技能里。这个约定虽然土,但很有效,至少我在项目里没再改过。
动态加载机制带来的直接收益是:上线新技能不需要重启整个Agent服务。我只要把技能目录打包上传,Agent在下一轮调度时就能感知到新技能。这个能力在跨团队协作时太重要了——算法团队和业务团队可以各自维护自己的技能包,互不阻塞。
不过,动态加载也带来一个隐患:如果某个技能模块在导入时抛异常,会导致整个Agent启动失败。所以我在load_all_skills里加了异常隔离:
def load_all_skills(): for module_info in pkgutil.iter_modules(skills.__path__): if not module_info.name.startswith("skill_"): continue try: importlib.import_module(f"skills.{module_info.name}") except Exception as e: logger.error(f"加载技能模块 {module_info.name} 失败: {e}") continue这样单个技能坏了,只是这个技能不可用,其他技能照常加载,Agent服务不至于整体挂掉。
4. 技能执行链路里最容易被忽略的几个细节
4.1 上下文窗口的占用和清理
在我做过的Agent项目里,上下文管理是翻车率最高的地方,没有之一。技能的输出如果直接塞进对话上下文里,几轮对话后模型就会开始“遗忘”早期指令。尤其是物流查询这类技能,返回的轨迹列表可能很长,一次查询就把上下文塞爆了。
我的方案是两层设计。
第一层,技能返回的数据进上下文前做摘要。比如物流轨迹有20条,我不会把20条全部塞给模型,而是只保留首条、末条和状态关键词,像下面这样:
{ "status": "运输中", "summary": "快件已从杭州发出,正在发往广州,最新节点:已到达广州转运中心", "trail_count": 20, "estimated_delivery": "2025-07-20" }完整的20条轨迹放在另一块非上下文区域,只有用户主动要求“逐条展示”时,Agent才会去读取并渲染。这就把一次技能调用的token占用从上千压缩到一百以内。
第二层,设计好上下文清理策略。我采用“滑动窗口 + 摘要叠加”的方式:超过指定轮数后,早期对话的原始内容被替换成摘要,再放进上下文。像“用户之前在查哪个快递”这类信息,用一个全局变量记住,永远不会因为滑动窗口被冲掉。
4.2 技能失败后的兜底策略
模型调用技能、技能执行报错,这在生产环境里是必然发生的事情。什么接口超时、数据源返回异常、参数格式不对,你能想到的故障都会遇到。不能让这些错误直接暴露给用户,得有一套兜底策略。
我在每个技能定义里都配置了fallback字段,逻辑是这样的:
- 第一层兜底:技能自身捕获异常,重试最多3次,每次间隔指数退避(1秒、2秒、4秒)。很多第三方接口偶发超时,退避重试能解决80%的问题。
- 第二层兜底:如果重试仍失败,技能返回一个
skill_error结构化错误块,而不是抛出未捕获异常。例如:
{ "code": 503, "error_type": "data_source_timeout", "message": "物流接口响应超时", "advice_to_model": "建议告知用户网络稍有繁忙,请稍后再试,不要编造任何物流节点信息。" }这里最狠的一条是我加了个advice_to_model字段——直接告诉模型该怎么跟用户解释。我见过太多次模型在技能报错后自作主张瞎编物流节点,用户一听就知道是假的,彻底失去信任。
- 第三层兜底:Agent调度层检查技能返回的
code,如果非200,就切到fallback里指定的备用技能。比如物流查询挂了,可以切到“人工客服留言”技能,收集用户单号和联系方式,后续补查。
这套三层兜底跑下来,我项目的技能执行成功率从85%左右提到了96%以上。剩下那4%基本是数据源彻底故障,该认就得认,但至少不会让Agent说胡话。
4.3 权限隔离与安全边界
Agent能调用技能,就意味着它能接触外部系统。技能库越丰富,攻击面越大。有一段时间我几乎每天检查一次日志,就是怕某个技能被恶意利用。
安全这块,我给的方案分成三部分。
第一,技能运行环境隔离。我用sandboxed执行方式跑第三方编写的技能——核心是用subprocess或者容器运行技能代码,而不是直接在当前进程里eval。这样即使技能代码里混入恶意文件删除命令,也只影响沙箱环境,不会波及宿主。
第二,技能权限声明。每个技能在skill.yaml里声明自己需要哪些资源和权限,比如network: true表示需要联网,file_write: false表示禁止写文件。技能在沙箱里执行时,沙箱系统根据声明动态下发权限。没有声明的操作一概禁止。我踩过的亏是早期有个技能为了缓存数据偷偷写了本地文件,后来把所有写操作全部禁止,技能改成写内存缓存,问题才根治。
第三,对技能输入做严格的参数校验,防止提示注入。你永远无法控制用户会往参数里塞什么,所以技能正式执行前,一律用Pydantic做类型校验和长度限制。
class LogisticsTrackParam(BaseModel): tracking_number: str = Field(..., min_length=6, max_length=32) carrier_code: str = Field("", max_length=10)这样即使参数里混入命令指令,也会被校验挡住。安全这件事不能全交给模型,工程层面必须兜底。
5. 常见问题与排查技巧实录
5.1 模型总是选错技能怎么办
我碰到最多的现象是:用户明明想查天气,模型却去调用了“穿衣建议”技能。原因通常不是技能本身的问题,而是技能描述之间产生了语义重叠。解决思路是给技能划清边界。
具体操作上,我会检查所有技能的description,找出语义相近的技能,然后在描述里手动声明差异。比如“天气查询”和“穿衣建议”都涉及气温,我会在“穿衣建议”里加一句“本技能用于根据天气情况给出搭配建议;如果需要获取实时天气数据,请使用weather_query技能”。模型只要读到了这个交叉引用,就不会在天气查询场景下选错。
还有一个排查技巧:给模型加reasoning trace,让它输出调用技能前的一句话思考过程。比如模型说“用户想知道需不需要带伞,这需要实时天气信息,所以调用weather_query”。这样你一眼就能看到模型的决策链路,哪一步出了问题一目了然。
5.2 技能之间互相干扰怎么实例
技能多了以后,你可能遇到一个诡异的问题:明明只改了一个技能的代码,另一个技能的行为却变了。别急着怀疑灵异事件,最可能的原因是技能间共享了可变状态。
举个例子:我在技能里定义了一个模块级变量CACHE = {},A技能往里面写数据,B技能读到了A写的数据,这在真实用户场景里是灾难。像用户A查询的物流单号,被用户B的会话读到了,数据隐私就泄露了。
我后来把所有共享状态全部改成“按会话隔离”。每个技能实例在创建时绑定一个sesssion_id,缓存键都加上会话前缀。如果是无状态技能,干脆禁止写任何缓存。这里有一条血泪教训:技能类里不要定义模块级变量,所有可变数据都要存放在显式的上下文对象里。
5.3 技能越加越多,召回变慢怎么优化
技能库膨胀到50个以上之后,即便有模型语义匹配,每次调用前把所有技能描述都塞给模型也不现实——token开销大,模型决策也容易出错。这时候需要对技能做两层筛选。
第一层,粗筛。我用trigger_keywords和标签做一个候选人过滤。比如用户说“快递”,所有技能里只有logistics_track和logistics_issue两个技能的标签包含物流,那就只把这两个技能的描述交给模型。其余48个技能根本不会出现在模型视野里。
第二层,精排。在粗筛出的候选中,模型根据用户请求和技能描述,选择最合适的一个。如果候选仍然模糊,就多给一个“综合对比”环节,让模型先解释候选技能各自适合什么场景,再选一个。
这套“候选集 + 语义决策”的两段式召回,让技能平均决策时间从800毫秒降到了不到200毫秒,准确率还升了不少。核心原理是:先做规则过滤缩小范围,再做语义理解,不给模型太多美学上的选择余地。
5.4 日志和排查:技能出问题了怎么定位
最后说说日志。Agent的问题定位比传统后端要难得多——因为它中间夹了一层模型调度。同一个用户问题,可能这次走了A技能,下次B技能。如果不记录调用决策链,出了问题根本无从查起。
我建议给Agent每个请求生成一个trace_id,然后贯穿以下日志节点:
intent_recognition:模型识别出的用户意图skill_selection:选中的技能、候选技能列表、模型注释skill_execution_start:技能开始执行、传入参数skill_execution_end:技能执行结果、耗时、返回的数据摘要response_generation:模型生成的最终回复
排查时按trace_id拉出这一条链路,就能看到是意图识别错了、技能选错了、还是技能执行报错。我把这个日志方案做成了一个小工具,所有技能在基类里统一记录,不需要每个技能单独写日志代码。省心很多。
6. 最后分享一点我的私房经验
跑完这套agent-skills体系,我最大的体会是:技能库的建设不是纯技术问题,更像是产品设计问题。你要不断地琢磨:用户真实意图里哪些是高频的、哪些是低频的;技能边界划到哪里最合适;参数怎么设计才能让模型少犯错。技术只是骨架,对场景的理解才是血肉。
如果你刚起步,我建议别一上来就搞50个技能。先挑3到5个最高频的场景,每个技能用心打磨,跑通完整链路,再逐步扩展。“少而精”的初期策略能让你更快摸清楚模型的调用习惯,也能积累扎实的排查经验。
最后再分享一个小技巧。技能上线后别忘了一个步骤——小流量灰度。先在内部环境或者测试用户上跑一周,重点观察三个指标:技能调用率、执行成功率、用户满意度。我自己曾经有一个技能,技术指标全部正常,但真实用户调用率不到10%,后来加上用户访谈才发现,是技能描述里的术语太专业,模型和用户都理解偏了。灰度期先于全量发现问题,远比用户投诉后再补救省力。