最近在折腾 agent-skills 这个方向时,我发现一个很有意思的现象:很多做 Agent 应用的人,把大量精力花在 prompt 调优上,却忽略了真正决定 Agent 能不能落地的关键——它到底能调用哪些技能,以及这些技能被封装得是否足够可靠。agent-skills 这个名字看上去只是一个开源项目代号,但它背后代表的是 Agent 从“会聊天”走向“会干活”的那条必经之路。这篇文章我打算用实际踩坑换来的经验,聊聊怎么为 Agent 设计和沉淀一套可复用的技能库,包括技能的定义方式、目录组织、测试机制和常见故障排查。无论你是刚接触函数调用的小白,还是已经在做工具使用 Agent 的工程师,这轮梳理应该都能给你一些直接能用的参考。
1. 项目核心思路:Agent 为什么需要“技能”
1.1 从“会聊天”到“会干活”的跨越
几乎所有做 LLM 应用的人都会遇到同一个瓶颈:模型很聪明,但你让它做一件具体的事,比如从一批日志里统计错误码分布、把 Markdown 表格转成 Excel、调用某个内部 API 拉取数据,它就变得不那么可靠了。原因不复杂——语言模型擅长生成文本,但生成文本和完成操作是两码事。操作背后需要真实地执行函数、处理异常、校验结果,这些都不是“多写几行 prompt 就能解决”的。
agent-skills 想解决的就是这个“最后一公里”问题。它主张把可以被执行、被验证、被复用的操作封装成“技能(skill)”,每个技能都有清晰的输入输出约定、实现代码和测试用例。Agent 在运行时通过语义匹配来自动发现这些技能,然后像人类查工具书一样,找到合适的函数并正确调用。你可以把它理解成给 Agent 安装了一套“插件库”,每当你希望它掌握一项新能力,就新增一个技能文件,而不需要反复改系统 prompt。
这个思路和传统的 ReAct 模式、纯 prompt 工程最大的区别在于:技能是有边界的、可测试的、可版本化的。prompt 写得再好,模型也可能在长对话中逐渐跑偏,但如果它调用的是一个封装良好的技能函数,那执行结果就是确定的,模型只需要负责“决定调哪个”和“传递正确参数”这两件事,剩下的交给代码即可。
1.2 agent-skills 到底解决了什么问题
先列几个我实际经历过的场景,你应该会感同身受:
- 想让 Agent 查询数据库,结果它总是凭空编出 SQL 列名,或者把字符串参数拼进 SQL 导致语法错误。
- 想让 Agent 处理 Excel 文件,它在 prompt 里“说”得很好,但根本没有真正执行任何操作,用户拿到的是一个空的回答。
- 多轮对话中,Agent 第一轮正确调用了工具,第二轮却因为上下文覆盖而换了另一种非法方式硬来。
- 团队里不同人给 Agent 封装了功能相近的工具,参数风格不一致,模型在调用时经常混淆。
这些问题如果只靠 prompt 去压制,效果非常有限。而 agent-skills 的思路是把操作抽象成“技能对象”,每个对象自带描述、参数 schema、实现和测试。模型面对的不再是散落的函数,而是一套结构化的能力清单。这种结构化本身就是对模型的一种“约束”,它传递的信息比自然语言描述要确定得多。
我自己的体会是,引入技能库之后,Agent 的错误模式从“自由发挥”变成了“选错技能”或“参数略偏”,这两类问题的修复成本低得多——前者只要扩充技能描述,后者只要调整 schema 示例。整个系统的稳定性会有一个质的提升。
1.3 与传统 prompt 工程和 function calling 的边界
很多人会问:OpenAI 的 function calling 不是已经做了这事吗?为什么还要单独搞一个 agent-skills 这样的项目?这里我想说清楚一个容易被混淆的点。
function calling 定义的是“模型如何输出一次工具调用请求”,它是一种协议层面的能力。而 agent-skills 定义的是“一个工具调用请求如何被组织、校验、执行和迭代”,它是工程层面的能力沉淀。前者解决“怎么喊”,后者解决“喊完之后怎么保证不出事”。实际项目中,两层需要配合使用,但很多团队只关注了第一层,写了一大堆 function 描述,却没有一套机制去管理这些 function 的测试、优先级、冲突和版本。
拿真实项目打比方,function calling 相当于给汽车装了方向盘,而 agent-skills 相当于建立了一套驾驶规范、道路标识和保养手册。没有后者,车也能开,但大概率会在路上出各种小状况。所以我的建议是:如果你正在做一个严肃的 Agent 应用,一定不要把工具调用只停留在 function calling 的配置层,而是尽早建立起自己的技能库体系。
2. 技能的定义与目录设计
2.1 一个技能应该由哪些部分组成
在 agent-skills 的思路里,一个技能不是简单一个函数,而是围绕这个函数的完整封装。我建议至少包含五个部分,缺一个都会在后续使用中埋隐患。
第一是技能元信息,包括技能名称和版本号。名称要短、要独特,方便模型在语义匹配时快速定位。比如 get_weather 就比 fetch_data_from_weather_api_v2 好得多。版本号则用来支持后续的重构和回滚。
第二是自然语言描述。这是给模型看的核心索引,描述的内容决定了模型在什么场景下会想到调用它。描述要覆盖三类信息:这个技能解决什么问题、什么情况下不要用它、有没有关联的替代技能。比如“获取指定城市当前天气,仅支持中国城市,不支持空气质量查询;空气质量请用 get_air_quality”。
第三是参数 schema。每个参数都要明确类型、取值范围、默认值和示例值。这里有个关键技巧:在描述里写清楚参数之间的依赖关系,比如“当 mode 为 forecast 时,days 必须在 1 到 7 之间”,否则模型很容易传出不合理的组合。
第四是实现代码。实现要尽量独立,不要依赖全局状态,最好把网络超时、异常处理都在函数内部消化掉,对外只暴露成功或抛出标准异常,这样技能的执行就是一个可预期的操作,而不是一团不可控的过程。
第五是测试用例。一个技能至少有 2 到 3 个正向用例和 1 到 2 个反向用例。正向用例验证“正确输入产生正确输出”,反向用例验证“错误输入得到清晰报错”。没有测试的技能,本质上只是一段不确定的代码,你根本无法知道 Agent 在某个场景下调用它会产生什么行为。
2.2 技能库的标准目录结构
参考 agent-skills 社区里比较成熟的实践,我推荐下面这个目录组织方式:
skills/ ├── meta.yaml # 技能库全局配置与加载开关 ├── common/ # 共享工具与公共依赖 │ ├── http_client.py │ └── validators.py ├── data_processing/ │ ├── excel_to_json/ │ │ ├── skill.py │ │ ├── test_skill.py │ │ └── README.md │ └── csv_cleaner/ │ ├── skill.py │ └── test_skill.py ├── api_integration/ │ ├── weather_query/ │ │ ├── skill.py │ │ └── test_skill.py │ └── stock_price/ │ ├── skill.py │ └── test_skill.py └── formatters/ └── markdown_table/ ├── skill.py └── test_skill.py每个技能独立成目录,目录名就是技能归属的领域。这样做的直接好处是:Agent 在加载时可以通过目录前缀做初步过滤,减少语义匹配的搜索空间。比如用户问“帮我把这份表格整理一下”,系统可以先锁定 data_processing 和 formatters 两个目录,而不是把全部技能都拉出来比较一遍,既能降低模型混淆的概率,也能减少 token 消耗。
2.3 命名与描述的实操心得
命名这件事,我踩过不少坑。早期我给技能起名喜欢带很长的业务前缀,比如 get_data_from_bi_server_by_project_id,结果模型经常识别不完整。后来我总结了几条规则,基本上可以避免这类问题。
规则一:名称用小写加下划线,控制在 3 个单词以内。规则二:名称要反映“动作+对象”,比如 send_email、parse_resume,不要用抽象名词如 helper、utils。规则三:和业务强相关的技能保留业务关键词,纯粹通用的技能不要带公司名或项目代号,提升复用性。
描述部分的措辞也值得琢磨。不要写“此函数可以用于获取数据”这种没有区分度的句子,而要写“当用户需要查询订单物流状态时使用,支持快递单号和订单号两种查询方式”。描述其实就是给模型的一份“使用说明书”,越具体,模型的调用准确率越高。我对比过同一批技能在描述改写前后的调用准确率,从 61% 提升到了 88%,幅度相当可观。
3. 实操:从零搭建一个可用的技能库
3.1 先确定第一批技能从哪里来
动手之前,先别急着写代码。我建议把产品需求里最高频的 10 个操作列一张清单,然后逐个判断:哪些适合做成技能?判断标准是三条——是不是重复发生、是不是有确定性的输入输出、是不是需要真实执行操作。满足这三条的,优先做。
比如“查天气”“算运费”“转格式”都是很好的候选。而“写一段优美的文案”这种高度开放、没有确定性输出的任务,就不适合封装成技能,它应该还是走模型直接生成的路子。把适合模型做的留给模型,把适合代码做的交给技能,这个边界越早划清,后面返工越少。
3.2 编写第一个技能:完整代码示例
为了让你有直观感受,我拿一个实际用过的技能来拆解。假设我们要做一个“根据人名和工作日天数计算应发工资”的技能,这个技能需要调用一个本地薪资计算函数,并包含假期忽略逻辑。完整实现如下。
# pay_calculator.py from skill_lib import Skill, Parameter class CalculateSalarySkill(Skill): name = "calculate_salary" version = "1.0.0" description = ( "根据员工姓名和当月实际出勤天数计算应发工资。" "适用于计算固定月薪员工的应发金额,不包含绩效、补贴和扣款。" "若涉及绩效请调用另一个技能 calculate_performance_allowance。" ) parameters = [ Parameter("employee_name", str, description="员工姓名,必填"), Parameter("work_days", int, description="实际出勤天数,范围 1-31", ge=1, le=31), Parameter("monthly_salary", float, description="月薪标准,单位元", gt=0), ] def run(self, employee_name: str, work_days: int, monthly_salary: float) -> dict: if work_days < 1 or work_days > 31: raise ValueError(f"work_days 必须在 1-31 之间,收到: {work_days}") daily_rate = monthly_salary / 21.75 # 法定月平均计薪天数 gross = daily_rate * work_days return { "employee_name": employee_name, "gross_salary": round(gross, 2), "daily_rate": round(daily_rate, 2), "message": f"{employee_name} 的应发工资为 {round(gross, 2)} 元" }这段代码里有几个设计细节值得说明。第一,description里明确写清了技能的边界——不算绩效、不算扣款,同时给出了替代技能的指引,模型在遇到绩效需求时就不会错误调用这个技能。第二,parameters里的约束条件ge、le、gt是校验层强制执行的,不依赖模型自觉,这可以在参数传入run之前就把非法值挡回去。第三,run方法内部也有防御性校验,即使外层校验被绕过,函数自身也不会吐出荒谬结果。
3.3 给技能配测试:没有测试不叫技能
技能一旦要被模型调用,就相当于进入了生产环境,它的行为必须是可回归验证的。我给技能写的测试分两层接触面:一层针对纯逻辑,一层模拟真实调用。
# test_pay_calculator.py import pytest from pay_calculator import CalculateSalarySkill def test_normal_case(): skill = CalculateSalarySkill() result = skill.run("张三", 21, 21000) assert result["gross_salary"] == 21000 def test_min_work_days(): skill = CalculateSalarySkill() result = skill.run("李四", 1, 21000) assert result["gross_salary"] == pytest.approx(965.52, rel=1e-2) def test_invalid_work_days_raises(): skill = CalculateSalarySkill() with pytest.raises(ValueError): skill.run("王五", 32, 21000)这套测试的价值在于:当技能后续升级,比如把 21.75 改成按当月实际计薪天数,回归测试就会直接把行为变化暴露出来,迫使你审视改动是否合理。而如果没有测试,这类参数调整往往要等线上用户投诉才能发现。
3.4 Agent 如何发现并加载技能
有了技能文件和测试,下一步就是在 Agent 运行时把技能自动加载进来。我实现过一个轻量级的技能发现器,核心逻辑不复杂,就是扫描技能目录、解析元信息、注册到调用路由表里。
import importlib.util import os from typing import List def discover_skills(skill_dir: str) -> List[object]: skills = [] for root, _, files in os.walk(skill_dir): for file in files: if not file.endswith(".py") or file.startswith("test_"): continue path = os.path.join(root, file) spec = importlib.util.spec_from_file_location(file[:-3], path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) for attr in vars(module).values(): if isinstance(attr, type) and hasattr(attr, "name") and hasattr(attr, "run"): skills.append(attr()) return skills这段代码有几个刻意处理的点。第一,跳过test_开头的文件,避免把测试文件当技能加载。第二,通过hasattr(attr, "name")和hasattr(attr, "run")双重判断来识别技能类,而不是靠继承固定基类,这样兼容性更好。第三,使用importlib动态导入,保证新增技能不需要重启整个 Agent 服务,开发体验好很多。
加载之后,我一般还会生成一份“技能清单”缓存下来,包含每个技能的 name、description、parameters 摘要,在每次对话时随系统 prompt 一起发给模型。清单不宜太长,否则会挤占上下文窗口,所以我的做法是:每次先根据用户输入的关键词做一次粗粒度过滤,只把可能相关的 10 到 15 个技能描述放入 prompt,这样既准确又省 token。
4. 常见问题与排查实录
4.1 模型总是调用错误的技能
这个问题出现频率最高,几乎每个初用 agent-skills 的团队都会碰到。典型表现是:用户想查天气,模型却去调用了“日期计算”技能,因为两者描述里都有“今天”“明天”等词。
排查思路要看描述重叠度。如果两个技能在语义上容易被混淆,就在其中一个的描述里明确加上“不适用”的负面条件。比如天气查询可以写“此技能不具备日期计算能力,如需计算两个日期之间的天数请使用 date_diff”。这类负面约束通常一两句就够,不需要长篇大论。
还有一种情况是技能数量太多,模型在选择时直接迷失。这时候不要继续往 prompt 堆描述,而是要把技能做分组或合并。我见过一个项目把 60 多个技能一口气全塞给模型,准确率只有 34%。后来按领域分组、每组抽一个代表技能先行路由,准确率拉到了 79%。所以,技能库不是越大越好,而是越清晰越好。
4.2 参数 schema 与实际实现对不上
这种情况通常发生在多人协作或快速迭代时。有人在元信息里写着参数timeout类型是 integer,但实现代码里实际当成字符串用,模型传了 30 进来,代码拼接 URL 时直接报 TypeError。
我的建议是给技能配置加一层“契约校验”,在run方法执行前自动比对传入参数和 schema。这里可以用一个轻量级装饰器实现。
from functools import wraps from typing import Callable def validate_params(schema: dict): def decorator(func: Callable): @wraps(func) def wrapper(*args, **kwargs): for pname, pconf in schema.items(): if pname in kwargs: ptype = pconf.get("type") if ptype == "int" and not isinstance(kwargs[pname], int): raise TypeError(f"参数 {pname} 需要 int,实际收到 {type(kwargs[pname])}") return func(*args, **kwargs) return wrapper return decorator这只是一个极简版本,生产环境建议直接用 pydantic 之类的库来做完整校验。接上之后,至少能把类型不匹配的问题从“运行时爆炸”提前到“调用即报错”,配合测试,回归周期会短很多。
4.3 技能文件出现冲突与优先级问题
当技能库发展一段时间后,很可能出现两个技能功能重叠的情况。比如新人看到 get_location_from_ip 这个技能,没意识到已经有一个 get_location_from_phone 也能间接拿到位置信息,于是都注册进了路由表。模型在调用时就可能随机选一个,导致结果不稳定。
处理手段有两种。第一种是硬规则:在技能路由表里,允许为每个领域设置唯一的“默认技能”,同领域其他技能只有在该默认技能被显式声明不可用时才进入候选列表。第二种是软规则:在技能描述里互相引用,比如旧技能写明“如果你需要 IP 定位,请优先使用 get_location_from_ip”。我实际用下来,软规则对模型更友好,因为它让模型自己“理解”优先级,而不是被规则硬卡死。
另外,每季度最好做一次技能库的“冗余扫描”,把重复或高度相似的技能合并。合并前看一眼各自的历史调用日志,尽量保留调用量高的那个,另一个作为别名兼容,避免破坏已有流程。
4.4 技能在真实调用场景中行为不稳
技能测试通过了,但放到生产环境还是偶发失败。这类问题十有八九出在外部依赖上,常见的有:第三方 API 限流没做重试、网络超时设置太短、外部返回格式变化没做兼容。
我给每个技能整理了一张“外部依赖清单”,写清楚它依赖哪些网络服务、密钥存放位置、超时和重试策略。然后统一封装一个 http_client,内置指数退避重试和超时配置。与其在单个技能里复制粘贴重试逻辑,不如在 common 层做一件共享的“雨衣”,这样每个技能的内部代码会清爽很多。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查顺序 |
|---|---|---|
| 模型调了无关技能 | 描述之间存在语义重叠 | 先看描述中是否有负面约束,再检查技能分组 |
| 参数经常传错类型 | schema 与实现不一致 | 先校验 schema 类型,再看示例值是否清晰 |
| 多个同类技能随机命中 | 缺少优先级标注 | 先加描述互引,再考虑路由表硬规则 |
| 技能测试通过但线上失败 | 外部环境差异 | 先看依赖服务和密钥,再看超时重试策略 |
| 技能加载后没生效 | 目录扫描路径配置错误 | 先打印加载日志,再确认目录结构 |
这个速查表是我在维护技能库时反复翻看的一份资料,每次排障都能按图索骥,节省大量时间。
5. 技能的复用、演进与团队协作
5.1 让技能库变成团队共享资产
技能库做出来之后,不应该只属于 Agent 应用的代码仓库,它还应该成为团队的共享资产。我的做法是单独建一个 skills 仓库,代码审查流程和业务代码一样严格,但文档要求更高。每个技能除了代码,必须有 README,写明适用场景、不适用场景、依赖清单、变更历史。
这样做的好处是,后续新人在给 Agent 加能力时,先到技能库里搜一轮,大概率能发现已有技能可以直接复用,而不是从零写一个新的。我统计过,合理的技能库复用机制能让团队新增功能的时间平均缩短三分之一。
还有一个容易被忽略的点:技能库的 API 设计要相对稳定。业务代码可以频繁重构,但技能库一旦被多个 Agent 流程引用,改动就必须走兼容升级,不要轻易删除技能或改变参数含义。
5.2 技能版本的平滑演进
技能也是有生命的,它会随着业务变化而迭代。我的演进策略分三步:先加新参数并标记为可选,老调用不受影响;跑一段时间的影子模式,让新逻辑和老逻辑并行执行并对比结果;确认稳定后,把新参数设为必选,移除废弃分支。
在 agent-skills 的语境下,版本管理其实不复杂,核心就是“不破坏已有调用约定”。如果非破坏不可,比如参数名改动,那就必须保留一层兼容适配器,把旧参数名映射到新参数名,同时打印警告日志,提醒调用方尽快升级。这样至少能保证接入方不会被突然打断。
5.3 最后一个实用经验:从调用日志里反向迭代技能
在技能库上线稳定之后,我建议大家养成定期翻调用日志的习惯。日志里有两类信息价值极高:一类是模型纠结后放弃调用的记录,说明技能描述还不够清晰;另一类是多次调用后才成功的路径,说明存在候选技能排序不合理的问题。
我每月会做一次这样的分析,选出调用准确率最低的 5 个技能,针对性地优化描述和示例。坚持下来,技能库的准确率是能持续爬升的。不要只看功能做得好不好,更要看模型“用起来顺不顺”,这决定了 Agent 整体的智能感。
根据我个人经验,把 agent-skills 这类技能库做扎实,远比在 prompt 上做表面功夫更值得投入。每次给 Agent 新增一个技能,就好比为它多配备了一件称手的工具,日积月累,它能独立完成的任务边界会明显扩大。希望这篇实践记录能帮你在做 Agent 技能沉淀时少走一些弯路。