"agent-skills"这类项目,我最开始接触时也以为只是把一堆工具函数打包给Agent调用——毕竟官方文档里讲得轻描淡写。直到亲手搭过、跑过、也被模型蠢哭过之后才明白,技能库根本不是什么"API清单"。
这篇文章我想把"agent-skills"这个项目里真正值得抄的东西拆开讲:技能到底由哪几层组成、每个技能怎么从零封装、多个技能之间怎么编排、以及最容易被忽略的质量评估和回归测试。不管你是刚给Agent接工具,还是已经在养一整套技能库,这篇都能直接帮你避开我踩过的坑。
1. 为什么Agent会被"单一技能"卡死——问题的本质
1.1 一个看似简单却翻车的调用场景
先看一个最常见的场景。你给Agent接了一个天气查询API,功能很简单:传入城市名,返回温度和天气现象。模型也明白自己可以做这件事。但实际跑起来,问题一串接一串:
- 用户说"上海明天冷吗",模型直接调用了查询函数,但传参传成了"上海明天",API返回400。
- API限流了,返回429,模型看到HTTP错误代码,完全不知道怎么办,直接跟用户说"服务故障"。
- 好不容易拿到数据,模型又自己脑补了一个"体感温度-5℃",明明API返回字段里根本没有体感温度。
这还只是一个极简单的工具。真实业务里,工具涉及数据库、第三方服务、内部平台,参数校验、权限、重试、返回结构每个环节都会让模型出错。
问题出在哪?模型不缺工具,缺的是一个"会正确使用工具的完整能力包"。这个能力包就是我理解的agent-skills:不是工具函数本身,而是把工具的调用方式、参数约束、异常处理、输出规范,连同触发它的场景描述,一起封装成一个可以复用、可以组合的技能单元。
1.2 技能、工具与提示词三者的边界
很多人把"技能"和"工具"混着叫,概念一旦模糊,工程上很容易设计歪。我建议用下面这张表来定边界:
| 概念 | 粒度 | 包含内容 | 举例 |
|---|---|---|---|
| 工具 / Function | 单一操作 | 一个可执行的函数,入参出参明确 | get_weather(city) |
| 技能 / Skill | 任务级能力 | 触发描述 + 参数校验 + 工具调用 + 异常兜底 + 输出规范 | 天气查询技能:知道何时使用、怎么处理限流、输出统一结构 |
| 工作流 / Workflow | 多技能编排 | 按业务逻辑串联/并联多个技能,带状态流转 | 客服工单处理:先查用户、再查订单、再生成回复 |
工具是技能的"手",技能是工具的"大脑+手+反射神经"。模型本身是"决策中枢",它读了技能描述后,决定要不要调用、传什么参数;技能内部再保证"即使模型给了不完美的参数,也能尽量给出可用的结果"。
理解这一点后,"agent-skills"项目的价值就很清楚了:它围绕"技能"这一层做工程化——如何定义技能、如何注册、如何评估、如何让多个技能协作。比单纯堆function call多了一层,而这一层才是Agent稳定性的关键。
2. 技能拆解:一个技能到底由哪几层组成
2.1 技能描述层:决定模型"能不能找到你"
模型面对一个用户问题时,会在自己的Function列表或技能列表里做语义匹配。真正决定它选谁不选谁的,是技能的描述文本。描述写得烂,再好的工具也没人用。
我见过的最低效描述是这样的:
get_order_info,获取订单信息。问题很明显:里面没有任何"触发场景词",模型不知道什么时候该用它。比如用户说"我的快递到哪了",模型可能完全不联想到这个技能,因为"快递"这个词在描述里不存在。
一份合格的技能描述通常包含五块:
- 功能概述:这个技能做什么。
- 触发场景:哪些用户意图应该调用它,尽可能列出同义说法("查订单""物流进度""快递""发货了吗")。
- 输入说明:需要哪些参数,每个参数的含义。
- 输出说明:返回的是什么结构,模型拿到之后怎么用。
- 注意事项:比如"仅限本人查询""状态为已取消的订单不可发货"等。
写描述的原则是:替模型把问题想全。模型不会有耐心去猜你的字段含义,你要把"什么时候调用、传什么、拿到什么、别做什么"直接写清楚。
2.2 触发与参数校验层:拦住模型的"幻觉参数"
模型在生成函数参数时,经常会出现三类问题:
- 参数名拼错(
user_iD) - 参数类型错误(把
"2024-12-30"当字符串传入,但接口要时间戳) - 枚举值超出范围(订单状态传个
"pending_payment",但系统里只有"unpaid")
这三类问题用一层参数校验就能解决大半。基于JSON Schema做入参校验是最稳的做法,不用写一堆if-else,用校验器统一处理。
import jsonschema skill_param_schema = { "type": "object", "properties": { "user_id": {"type": "string", "pattern": "^U\\d{6}$"}, "order_status": { "type": "string", "enum": ["unpaid", "paid", "shipped", "completed", "cancelled"] }, "page": {"type": "integer", "minimum": 1, "default": 1} }, "required": ["user_id"] } def parse_skill_args(args: dict) -> dict: jsonschema.validate(args, skill_param_schema) return args校验规则如果出现"枚举外的值",通常有两种处理方式:直接报错,把允许值告诉模型让它重新生成;或者在代码里做一次归一化映射。我建议优先报错,让模型根据错误信息自我修正——实践中大多数模型都能在下一轮修正成功。
2.3 执行与兜底层:让"手"足够稳定
参数校验通过了,接下来是实际执行。这一层最容易忽视的是重试、超时和降级。
先说超时。Agent任务往往有严格的时间预算,一个技能执行超过10秒,整个对话体验就崩了。所以执行层的超时时间必须比主流程短,建议设置为"技能预算时间的三分之一",留出富余给模型分析和回答。
再说重试。外部服务调用失败是常态,但重试要区分错误类型:网络超时可以重试2-3次;鉴权失败和参数错误重试没有意义;限流则应该退避,比如等1秒、2秒、4秒。
最后是降级。拿天气查询来说,如果实时API挂了,可以先返回上次本地缓存的天气数据,同时在返回结果里注明"数据获取时间"。对用户来说,一个带时间标注的旧数据,比一句"服务故障"好得多。
2.4 输出规范层:让技能可以被"拼接"
这一点做的人最少,但恰恰是"技能组合"的地基。如果每个技能的返回结构都不一样,后续编排或模型解析的成本会呈指数上升。
我习惯统一用下面这个包裹结构:
{ "status": "success | error", "data": {}, "error": { "code": "RATE_LIMITED", "message": "请求过于频繁,请稍后重试" }, "meta": { "execution_time_ms": 123, "cache_hit": true, "source": "local_cache" } }status:模型只需判断成功还是失败。data:模型直接读取的业务数据。error:失败原因,尽力写得让模型"看懂后知道怎么改",比如把允许的枚举值列进去。meta:技术日志,模型可以忽略,但能辅助人类排查。
统一输出规范还有一个隐形的收益:多个技能串联时,后一个技能的入参可以直接从前一个技能的data里取,不需要再做一次字段映射。
3. 从零封装一个技能:以"用户订单查询"为例的完整过程
概念讲多了容易飘,我用一个真实场景把技能从定义到落地的全过程走一遍。假设你在做一个客服助手,需要让Agent具备"查询用户订单"的能力,数据存在MySQL里,线上跑的是Python FastAPI服务。
3.1 先把"模型要做什么"写成人话
写代码之前,先把技能说明书以纯文本形式写出来。这一步是给模型看的,也是给你自己理清脑子的。我的草稿差不多是这样:
技能名称:用户订单查询 功能:根据用户ID查询其历史订单列表,可按订单状态筛选。 何时使用: - 用户询问"我的订单"、"我买过什么"、"物流到哪里了" - 客服需要查看用户近期订单记录 - 用户要求查看特定状态的订单(待付款、已发货等) 何时不使用: - 用户未登录或未提供用户ID时,不直接调用,先引导登录 - 查询其他用户的订单(无权限),拒绝并说明原因 输入参数: - user_id:用户唯一标识,格式为U开头加6位数字 - order_status:可选,枚举为 unpaid/paid/shipped/completed/cancelled - page:页码,默认1 - page_size:每页数量,默认20,最大50 输出: - 订单列表,包含order_id、商品名称、金额、状态、创建时间、物流单号 注意: - 已取消的订单不展示物流单号 - 金额单位为元,保留两位小数这份说明书有几个作用:它是技能描述的底稿;它帮你在后面写校验规则和SQL时有了明确边界;更重要的是,它逼你提前想清楚技能的"职责边界"——这不是一个"万能查询器",它只处理订单查询。
3.2 定义入参与校验规则
基于说明书,入参自动就有了约束。这里有一个小技巧:参数约束不要太宽,也不要太死。
太宽会导致模型随便传值,把不该查的数据查了。比如user_id做成一个自由字符串,模型就可能在没拿到用户信息时编一个"abc123"传进去。加了正则^U\d{6}$,模型就明白必须是一个合规的用户ID。
太死则会在边界场景误伤。比如page_page这些参数其实不用在schema里限制死,设置minimum: 1即可,剩下的交给默认值。
ORDER_QUERY_SCHEMA = { "type": "object", "properties": { "user_id": {"type": "string", "pattern": "^U\\d{6}$"}, "order_status": { "type": "string", "enum": ["unpaid", "paid", "shipped", "completed", "cancelled"] }, "page": {"type": "integer", "minimum": 1}, "page_size": {"type": "integer", "minimum": 1, "maximum": 50} }, "required": ["user_id"] }注意schema里没写default。因为模型传参时,如果看到有default,有时会投机少传参数;如果明确没有默认值,它更可能在生成时补全。这是我试过多次后的经验,算是模型行为的小规律。
3.3 写执行层:查库加兜底
执行层是技能的心脏。直接查库时,最防的一点就是SQL注入。即使你认为"这是模型生成的参数,不是用户直接输入",也不能轻视——模型的输出本身就是用户输入的间接产物,任何外部输入都可能被间接注入。所以务必用参数化查询。
import mysql.connector from mysql.connector import Error def execute_order_query(args: dict) -> dict: user_id = args["user_id"] order_status = args.get("order_status") page = args.get("page", 1) page_size = min(args.get("page_size", 20), 50) offset = (page - 1) * page_size base_sql = """ SELECT order_id, item_name, amount, order_status, created_at, tracking_number FROM orders WHERE user_id = %s """ conditions = [user_id] params = [] if order_status: base_sql += " AND order_status = %s" conditions.append(order_status) base_sql += " ORDER BY created_at DESC LIMIT %s OFFSET %s" params.extend([page_size, offset]) try: conn = get_db_conn() cursor = conn.cursor(dictionary=True) cursor.execute(base_sql, tuple(conditions + params)) rows = cursor.fetchall() cursor.close() conn.close() except Error as e: return error_response("DB_ERROR", str(e)) for row in rows: row["amount"] = round(row["amount"], 2) if row["order_status"] == "cancelled": row.pop("tracking_number", None) return success_response({ "orders": rows, "page": page, "page_size": page_size, "total": len(rows) })这段代码有几个细节是真实踩坑经验:
LIMIT %s OFFSET %s这两个参数也必须走参数绑定,不能因为"都是数字"就直接格式化拼接。amount保留两位小数,否则MySQL的DECIMAL字段返回的可能是Decimal('99.90'),JSON序列化时直接报错。- 已取消的订单去掉物流单号,这是在说明书里就写好的业务约束,模型不需要知道原因,执行层替你处理了。
3.4 返回结构设计
按前面说的输出规范,把execute_order_query的返回值包成统一结构:
def skill_handle(args: dict) -> dict: try: parsed = parse_skill_args(args) except jsonschema.ValidationError as e: return error_response("INVALID_ARGS", str(e.message), tip="请检查参数后重新调用") result = execute_order_query(parsed) if result["status"] == "error": return result if not result["data"]["orders"]: return success_response(result["data"], note="该用户暂无符合条件的订单") return result这里加了一个tip字段,它在error里,作用是告诉模型"你可以怎么修正再试一次"。比如"请检查参数后重新调用",模型收到后大概率会调整参数重试;如果不加tip,模型可能只会把错误信息翻译一遍告诉用户——一个是"查了但报错",另一个是"没查到就拉倒",体验完全不一样。
3.5 注册进技能库
最后一步,把技能登记到库中,供Agent检索。我的注册记录长这样:
SKILL_REGISTRY = [ { "name": "query_user_orders", "description": "查询用户订单列表。当用户询问订单、物流、退款进度等与订单相关的问题时使用。", "tags": ["order", "订单", "物流", "售后"], "version": "1.0.0", "handler": skill_handle, "schema": ORDER_QUERY_SCHEMA, "author": "yourname", "last_updated": "2025-01-10" } ]description里的"订单、物流、退款"这些词是我特意加的场景词。模型做意图匹配时,不是严格做关键词匹配,而是语义相似度判断;但场景词越多,落在正确技能上的概率越高。经验是描述中至少出现3个与业务相关的"人话词",不要只写官方术语。
4. 技能组合与路由:让多个技能协同工作的编排思路
4.1 没有编排的技能库只是一堆API
单个技能解决了"模型调用工具"的稳定性,但真实业务往往需要模型连续调用多个技能。一个客服场景里,用户说"帮我查一下这个订单,顺便退了它",Agent至少需要:查订单技能 → 退款申请技能 → 通知工单技能。这三个技能串起来才是完整任务。
如果每个技能各自为战,Agent处理跨技能任务时,就像让一个新人同时管三台机器,手忙脚乱,还容易丢状态。
4.2 三种常用的组合模式
我在本项目里用到的组合模式有三种,按复杂度排序:
串行管道(Pipeline)
前一个技能的data直接作为后一个技能的入参来源。适合"订单 → 物流 → 退款"这种天然有先后顺序的流程。
用户意图 → query_user_orders → get_order_logistics → apply_refund → 生成回复串行管道的关键是把每个技能的输出规范统一。前一个技能输出order_id,后一个技能照着取就行。这也是为什么前面反复强调输出规范层,它直接决定了管道能否干净地接上。
平行扇出(Fan-out)
一个任务里同时需要多个独立信息时,让模型同时发出多个技能调用。比如用户问"我的订单和优惠券状态怎么样",可以同时调用订单查询和优惠券查询,各自拿回结果后合并生成回复。
实现平行扇出时,技能本身要做好并发安全。有些技能内部用了共享缓存或全局变量,并发调用时会互相污染。如果你的技能是无状态的(每次调用都是独立连接、独立变量),那可以放心并发。
动态选择(Router)
模型作为路由,根据用户意图决定走哪条技能链。这在客服场景里最常见:
用户问题 → 意图识别 ├─ 订单相关 → 订单技能组(查订单/退货/换货) ├─ 商品相关 → 商品技能组(查库存/查详情/比价) └─ 账号相关 → 账号技能组(改密码/查积分/绑手机)动态选择不一定要写复杂的路由代码,很多时候直接把所有技能描述放进上下文,让模型自己选即可。只有当技能数量非常多(超过20个)时,我才会考虑加一层"意图粗分类器"来减少候选技能,降低误调率。
4.3 把规则放在技能描述里还是路由层
这是个容易被忽略的设计抉择。我的原则是:
- 语义规则放技能描述。比如"电商客服场景下,用户说我要退了,应优先关联订单查询技能",这层语义关联让模型自己判断足够,不需要写死路由逻辑。
- 硬性业务规则放路由层。比如"未完成实名认证的用户不允许触发退款申请""金额超过5000元的订单需要额外审批",这类规则必须用代码强制约束,不能依赖模型自觉。
把硬规则交给模型是一个危险的开始。模型在"要不要审批"这类问题上可能被prompt里的其他信息干扰,出现漏判。技能编排中,该用代码锁死的地方就要锁死。
5. 技能质量评估:从"能跑"到"可靠"的迭代路径
5.1 单技能基准测试
每个技能上线前,我都建议做一张"技能测试卡",至少包含下面这些用例:
| 测试类型 | 输入示例 | 期望行为 |
|---|---|---|
| 正常路径 | user_id=U123456, order_status=shipped | 返回已发货订单列表 |
| 参数不合法 | user_id=abc | 校验失败,提示格式错误 |
| 空结果 | 合法ID但无订单 | 返回空列表,附带说明 |
| 外部服务异常 | 数据库断开 | 返回DB_ERROR,可重试 |
| 边界数值 | page=99999 | 返回空列表,不报错 |
| 恶意输入 | user_id="; DROP TABLE orders;--" | 被参数化查询拦截 |
测试卡有两个作用:自动化回归的种子用例,以及上线评审的检查项。一场值得开的技能评审会,不是讨论"代码风格",而是逐条过这张表。
5.2 回归测试防止"改坏"
技能库最大的风险不是单个技能开发时出问题,而是某个技能升级后,把另一个技能的调用场景"顶掉"了。技能描述之间发生冲突,是常见的暗坑。
我遇到过这样的真实案例:新增了一个"售后订单查询"技能,描述写了"查询售后、退款、维权订单"。结果上线后,"普通订单查询"技能的调用量骤降——因为模型看到两个技能描述时,优先卷走了语义更细的"售后订单查询"。用户明明问的是"我最近买的东西到哪了",模型也调了售后查询,导致返回空结果。
从那之后,我要求每次技能变更必须跑一遍全量回归集。回归集就是历史对话中已经正确路由的样本,比如明确标注"这条应走A技能,不应该走B技能"。自动化的方式是:用新技能库跑这批历史问题,检查路由结果有没有变化。只要有10%以上样本路由偏移,就要查是不是描述产生了语义竞争。
5.3 线上监控"隐性失败"
除了开发阶段的测试,上线后的隐性失败更值得盯。技能调用失败通常能通过日志看到,但有一类失败是致命的:模型该调用技能时没有调用。
比如用户问"帮我退掉这个订单",模型却给出了大段文字建议,说"您可以在订单页面申请退款"——这算是一次"违规成功"吗?从用户角度不算成功,但系统日志里完全看不出异常。
我的做法是给每次技能调用加一个caller_confidence字段,记录模型在调用技能时附带的信心分(如果没有,可以记录上下文里技能命中排序的得分差)。当这个值低于一个阈值时,进入人工抽样复核池。另一个更简单的指标是"用户引导率":如果大量用户在咨询后发出"怎么弄""我不懂""具体在哪点",大概率是模型没调用对应技能,而是给了嘴上建议。
这类监控指标比代码报错更有信号意义,建议在技能平台上从第一天就埋点。
6. 我在实操中踩过的坑与收尾建议
6.1 技能描述太长,反而拉低命中率
我一开始给技能写了极其详尽的描述,恨不得把数据库表结构都写进去。结果模型反而不调用了——信息过载干扰了它的匹配判断,它看到一个长文本,误以为是系统提示而不是工具说明。
现在我的经验是:技能描述控制在150到300字之间,把最有辨识度的场景词放开头,参数细节交给schema处理。模型只需要知道"什么时候用、关键输入、关键输出",其他细节在执行层或schema里维护。
6.2 参数校验太严,Agent直接放弃调用
参数校验是为了拦错误,不是为了让模型感到挫败。我试过对user_id做过于严格的前缀校验,结果模型连续两次生成都不合法,第三次它干脆不调用了,直接回复"我没有权限查询您的订单"。
后来我调整了策略:对于可以猜测或可以清晰说明的字段,给出错误提示并允许模型从用户的表述中提取;对于必须人工确认的字段(比如涉及用户隐私),校验失败时不只报错,而是附带一句引导说明"请向用户确认账号信息后再查询"。模型拿到这个提示,通常会转去追问用户而不是放弃或瞎编。
6.3 本地工具误伤线上服务
开发阶段我们经常在本地mock外部依赖,但上线时忘记切换连接配置,这种情况看起来荒谬,实际非常常见。建议在每个技能里打一个env标签,并在技能执行入口做一次环境校验:只有env与实际运行环境匹配时才允许执行。哪怕只是简单几行断言,也能挡住大量"本地调通一上线就挂"的事故。
6.4 技能版本管理:先有代码仓库,再谈技能平台
技能是代码,要进代码仓库,走评审、CI、灰度发布流程。我看到不少团队为了图方便,直接在配置后台改技能描述,改了也不留痕迹。结果出问题后根本说不清是哪个版本引入的。
我的做法是:技能目录就是一个Git仓库,每个技能一个文件夹,包含skill.yaml(描述与元信息)、schema.json(入参校验)、handler.py(执行逻辑)、test_cases.json(测试卡)。CI里跑一遍校验和测试卡,通过之后才允许合并。等技能数量真正上规模以后,再去接上层的技能管理平台也不迟。
最后分享一个我在项目推进中最大的体会:技能库的质量不是靠堆数量,而是靠每个技能的边界清晰、行为可预期。一个能力边界模糊但描述诱人的技能,会让整个Agent的路由质量雪崩。与其一次召募三十个半成品技能,不如先把五个核心技能打磨到"遇到相关意图就一定能正确命中、稳定执行",再一步步扩展。这个节奏,远比追新工具更值得投入。