做Agent开发有一段时间了,我越来越觉得“技能”这个抽象词被低估了。很多人一上来就追模型、追框架,结果把Agent做成了“会聊天但干不了活”的玩具。我自己带过的项目里,同样一个底座模型,配上一套设计合理的agent-skills,和一套随手拼凑的工具集,交付效果完全是两个档次。今天这篇不聊虚的,就把我对技能库的理解、拆解思路、落地实现和踩坑记录一次性说清楚。
先说清楚一个最基本的问题:什么是agent-skills?简单说,它就是智能体能够调用的一组可复用、有明确边界、有输入输出规范的能力单元。你可以把它理解成给Agent配的工具箱,但工具箱里装的不只是“锤子”“扳手”这种实物工具,还包括你如何描述这个锤子、什么时候用锤子、用完之后怎么收场。这一整套东西合在一起,我把它叫作技能体系。
这篇内容适合正在做Agent应用落地的人,也适合想在个人项目里把智能体做得更扎实的技术爱好者。我会从设计思路到代码实现,再到问题排查,完整地把一条可复用的路径讲透。
1. 智能体技能库是什么,为什么Agent离不开它
1.1 技能与工具、插件的边界
很多项目里会把“技能”“工具”“插件”这三个词混着用,实际上它们的定位差异非常大。工具(tool)是最小粒度的操作单元,它回答的是“能做什么”,比如“读取文件”“发送HTTP请求”“执行Python代码”,这是一个动作级别的能力描述。
插件(plugin)是工具集合的分发包,它回答的是“怎么组织”,比如某个数据源插件里包含了连接、查询、写入三个工具,打包在一起方便复用。
技能(skill)则完全不同。它回答的是“怎么做才正确”,是一个包含了触发条件、执行流程、输入约束、输出规范、失败处理在内的一组逻辑封装。同一个工具,在不同的技能定义下,使用方式和效果可能差很多。举个例子,“读取订单表”和“读取订单表并按异常标记分类”是两个完全不同的技能,但底层调用的可能是同一个数据查询函数。
我在设计技能库时,一个反复被验证的结论是:Agent能力不取决于你塞了多少工具,而取决于你的技能层把工具组织得多好。工具是肌肉,技能是神经回路。肌肉再多,没有神经回路去协调,也只能原地抖动。
1.2 技能库能解决什么核心问题
第一,解决模型自主决策时的“胡乱发挥”。裸调用工具列表,模型经常凭直觉选错工具,或者在多步任务中途遗忘之前的上下文。技能层把“一步一步怎么做”固定下来,模型的自由度被限制在一个安全轨道里,出错的概率会大幅下降。
第二,解决多场景复用的效率问题。我见过不少团队的代码里散落着几十个函数,每个场景单独调,换一个业务流程就得重新写胶水代码。做成技能库之后,同样的技能可以被不同Agent复用,或者被同一Agent在不同任务阶段复用,维护点也集中了。
第三,解决调试和回溯的困难。没有技能层,Agent每步干了什么事,全在一大段对话历史里翻,完全没法复盘。有了技能层,每一步调用了哪个技能、传了什么参数、返回了什么结果,都能结构化成日志,问题定位效率提升非常明显。
用生活里的例子来说,技能库就像餐厅的标准化菜谱。后厨有灶台、烤箱、炒锅(工具),但光有这些厨师也做不出稳定的菜。菜谱规定了主料多少克、火力多大、翻炒多久、出锅标准是什么(技能),才能保证换个厨师、换家分店,出品依然在线。大模型就是那个记性很好但经验不足的学徒厨师,好菜谱是让它稳定出品的唯一途径。
2. 技能设计的核心思路与选型逻辑
2.1 先给智能体拆任务清单,而不是先列功能清单
这是我踩过最大的坑之一。早期我做技能库,第一反应是“先看我们有什么能力”,于是把公司现有API全部罗列出来,每个包装成一个技能。结果技能数量膨胀到两百多个,Agent根本选不过来,经常抓着一个不太匹配的技能硬套。后来我彻底换了个思路:先不问“有什么”,先问“用户交给我这件事,要完成需要哪几步”。
拆解任务清单的正确姿势是这样的:
- 把目标用户最常见的三类任务各挑一两个,拿纸笔画一遍完整的流程。
- 把流程里的每个环节标出“需要外部信息吗”“需要改动数据吗”“需要人工确认吗”。
- 只有那些“需要外部信息”和“需要改动数据”的环节,才可能成为技能候选。
举个例子,做一个客户服务Agent,任务清单可能是“查订单状态”“改收货地址”“申请退款”“推荐商品”。每个任务继续往下拆:查订单状态需要订单号获取、订单查询、结果格式化;改收货地址需要身份校验、地址变更、结果确认。这样拆下来,你得到的不是一张功能清单,而是一张任务流程网,每个节点对应一个明确边界的技能单元。
2.2 技能的粒度定多大才合适
这是技能库设计里争议最大、也最考验经验的问题。技能太粗,比如一个叫“处理订单”的技能,内部逻辑几十个分支,模型完全黑盒,出了问题没法定位,也没法在其他场景复用。技能太细,比如“把字符串转为小写”也做成一个技能,模型每走一步都要跳来跳去,决策成本高,对话历史也容易被无关调用刷爆。
我个人的经验是,技能粒度以“模型一次决策后,能连续执行完并拿到明确结果”为标准。具体来说,可以从三个维度来判断:
- 独立性:这个技能能不能单独完成一个有意义的业务动作?它不需要依赖另一个技能先做完前置步骤。
- 可验证性:执行完后,Agent是否能从返回结果中判断“做对了”还是“做错了”?
- 可复用性:这个技能除了当前流程,能不能在其他任务里也被用到?
我用一个例子解释。比如“查订单状态”和“更新订单状态”是两个技能,独立、可验证、可复用,粒度合适。但如果把“查询订单表里的最近三个月的全部订单,筛选出退款单,统计退款金额,再和上个月对比”做成一个技能,那就太粗了,因为“统计”“对比”这些动作其实是分析逻辑,不是执行逻辑。模型应该通过多次调用基础技能来完成这个分析,而不是靠一个封装好的黑盒。
2.3 技能之间的调用关系与冲突规避
技能不是孤立的,它们之间要定义清楚调用关系。这里我建议做一张技能依赖表,明确三个信息:这个技能执行前必须满足什么前置条件,执行中会调用哪些低层技能,执行后会对什么状态产生副作用。
前置条件地说,像“发送营销邮件”这个技能,前置条件通常是“用户已订阅”“用户邮箱已验证”。如果Agent在未检查订阅状态时直接调用发送技能,后果就是投诉率飙升。为了避免这种情况,我通常会在技能描述里写清楚“此技能将检查用户订阅状态并校验邮箱合法性,不合规则直接返回错误”,让模型在决策时提前知道风险。
副作用也很关键。比如“写入订单数据”这个技能,它不只是insert一行的动作,还会触发库存扣减、通知推送、日志记录等连锁反应。如果技能描述里不写明副作用,Agent可能在一个需要“预检查但不真正提交”的场景里误用了写入技能,造成脏数据。我在技能schema里专门加了一个sideEffects字段,用自然语言描述副作用,模型决策时会把这份信息纳入考量。
3. 从零搭建一套可复用的技能体系
3.1 技能描述怎么写才不会被模型误解
技能描述是整个技能库里最容易被忽视、又最决定成败的地方。同样的函数,描述写得好不好,模型调用准确率能差出两成以上。我总结了一套实用的写法原则。
原则一:动作打头,对象跟随,条件补充。一个好的技能描述是“做什么+对什么做+在什么条件下做”,比如“查询订单表中指定商户在指定时间范围内的支付成功订单,并按支付时间倒序返回”,而不是“订单查询功能”。
原则二:讲清楚成功与失败的标准。模型不理解你的代码,它只能从描述和返回结果里判断这次调用算不算成功。我在描述里一定会说清楚“返回为空代表无符合条件的记录,不等于执行失败”,以及“网络超时抛出的异常消息中会包含request_id,可用于日志追踪”。
原则三:写清楚何时不要用这个技能。负面提示比正面提示更能降低误调用率。例如“此技能不适用于查询退款订单,退款订单请调用query_refund_orders技能”。模型在接线时看到这些边界描述,误选概率会明显下降。
技能描述还有内容格式的要求。我一般会分成四块:功能概述(一句话说清楚做什么)、适用场景(什么时候应该调用)、不适场景(什么时候禁止调用)、输入输出说明(参数的语义和返回值的结构)。这四块我会用一个固定模板固下来,因为描述质量直接影响模型决策稳定性。
3.2 技能的输入输出规范设计
技能描述决定了模型“会不会用”,输入输出规范决定了“用得好不好”。我在输入输出设计上反复吃过亏,现在固定了一套规则。
输入方面,参数尽量扁平化。尽量避免嵌套JSON结构,模型生成复杂嵌套对象的出错率远高于扁平结构。比如,与其传“customer”: {“name”: “张三”, “age”: 30},不如传customer_name和customer_age两个独立参数。扁平结构既方便模型生成,也方便校验和日志记录。
参数要提供示例值。模型在不确定时往往会照抄示例格式,比如日期格式,你写“2025-06-01”,它就不会生成“2025/6/1”。这个技巧看着不起眼,实测能减少一大半参数格式错误。
输出方面,要把关键状态码和业务语义剥离。系统返回的code是“0”,业务上可能是“查询成功但无数据”,也可能“查询失败”,这两者的处理方式完全不同。我会在返回结构里单独设计一个status字段,用“SUCCESS”“EMPTY_RESULT”“BIZ_ERROR”“SYS_ERROR”四类枚举区分,Agent拿到后不需要再做二次猜测。
3.3 技能注册与动态加载机制
技能体系的底层是个注册中心。我实践下来,一个轻量的技能注册表至少包含以下字段:
| 字段 | 说明 |
|---|---|
| skill_name | 技能唯一标识,用于模型调用时的函数名 |
| description | 给模型看的技能说明,遵循3.1的写法模板 |
| input_schema | JSON Schema格式的参数定义,包含类型、是否必填、示例值 |
| output_schema | 返回值结构说明,包含状态枚举说明 |
| handler | 实际执行的回调函数引用 |
| dependencies | 技能依赖的其他技能ID列表 |
| enabled | 是否在Agent当前会话中启用 |
注册中心实现了以后,动态加载自然就来了。根据用户的任务意图预判当前会话可能需要哪些技能,只把这些技能注入模型的可用列表。这有两个好处:一是减少模型在大量无关技能里的选择负担,提升准确率;二是减少提示词里塞太多技能描述造成的token浪费。
动态加载的实现思路是:先用一个轻量意图识别模块给任务打标签,每个标签对应一组技能白名单。比如识别到“售后处理”意图,就加载查询订单、提交退款、查看物流这三个技能,其余技能全部不注入。实测在意图明确的任务上,准确率能提升不少,响应时间和token费用也降下来了。
技能的执行阶段我建议统一走一个调度中间件。每个技能调用都经过这个中间件,做三件事:参数校验、执行计时、日志采集。校验失败的请求直接拦截,不进入真实执行逻辑;执行计时用来监控异常耗时的技能;日志采集把调用链、输入输出、异常信息结构化存储。这一步看着繁琐,但它决定了你后期的排查效率。
3.4 一个技能定义的实际代码示例
上面讲了原理,这里给一个实际可参考的技能定义样例。这个技能的功能是“查询订单状态”,我以Python为例演示注册和调用的核心路径。
# skill_definition.py skill_order_status = { "skill_name": "query_order_status", "description": ( "查询用户指定订单的当前状态,状态包含待支付、已支付、已发货、已完成、已取消。" "此技能返回订单的基本信息和状态标签。" "不适合用于查询退款进度,退款进度请调用 query_refund_progress 技能。" "订单号为空或格式非法时返回 INVALID_PARAM。" ), "input_schema": { "type": "object", "properties": { "order_no": { "type": "string", "description": "订单号,格式为纯数字,长度不超过20位", "example": "202506010001", }, "customer_id": { "type": "string", "description": "用户唯一标识,用于校验当前用户是否拥有该订单", "example": "cust_100234", }, }, "required": ["order_no", "customer_id"], }, "output_schema": { "type": "object", "properties": { "status": { "type": "string", "enum": ["SUCCESS", "EMPTY_RESULT", "BIZ_ERROR", "SYS_ERROR"], }, "order_info": { "type": "object", "description": "订单详细信息,status为SUCCESS时返回", }, "message": {"type": "string", "description": "错误或补充说明信息"}, }, }, "handler": "handlers.query_order_handler", "dependencies": [], "enabled": True, } # registry.py skill_registry = {} def register_skill(skill): skill_registry[skill["skill_name"]] = skill register_skill(skill_order_status) def get_skill(skill_name): return skill_registry.get(skill_name)关键注意点有两个。第一个是handler字段,这里放的是字符串,而不是函数引用本身。这么设计是为了支持技能定义和实际逻辑分离,以后做技能的动态下发和热更新时,字符串引用比硬编码函数引用灵活得多。第二个是input_schema里的example字段,别小看它,模型在生成参数时对示例值的依赖程度远超你的想象。没有示例值的参数,模型经常在日期格式和枚举值上犯迷糊。
执行调度中间件的一个精简实现大约长这样:
# dispatcher.py import time import json def execute_skill(skill_name, params, context): skill = get_skill(skill_name) if not skill: return {"status": "BIZ_ERROR", "message": f"skill {skill_name} not found"} if not skill["enabled"]: return {"status": "BIZ_ERROR", "message": f"skill {skill_name} is disabled"} # 参数校验 validation_error = validate_params(skill["input_schema"], params) if validation_error: return {"status": "INVALID_PARAM", "message": validation_error} # 执行并计时 start_time = time.time() try: handler = load_handler(skill["handler"]) result = handler(params, context) except Exception as e: log_skill_execution(skill_name, params, error=str(e)) return {"status": "SYS_ERROR", "message": f"handler exception: {str(e)}"} finally: elapsed = time.time() - start_time log_skill_execution(skill_name, params, elapsed_ms=int(elapsed * 1000)) return result这个中间件把校验、日志、异常处理集中到了一个地方,避免在每个技能handler里重复写这些样板代码。日志采集这里特别重要,之后排查问题全靠它。
4. 实际操作中踩过的坑与排查技巧
4.1 技能描述写得太“泛”导致误调用
这是出现频率最高的问题。早期我写技能描述爱用“用于处理用户的订单相关问题”这种大而全的表述。结果模型在遇到售后问题时,经常选择这个“处理订单”技能而不是具体的“查询物流”技能,因为它觉得“订单相关问题”包含物流问题。
后来我把所有技能描述里的业务动词全部精确化:“查询”就是“查询”,“更新”就是“更新”,“提交”就是“提交”,绝不出现“处理”“管理”“相关”这类模糊动词。这个改动看起来微小,但对模型决策准确率的提升非常可观。你自己读一遍技能描述,如果读完仍然不知道这个技能在什么时候不该用,那模型大概率更不知道。
4.2 技能数量膨胀导致选择困难
技能库一旦超过六十个,模型的选择准确率就会显著下降。我发现的问题不是模型不认识这些技能,而是它把所有技能描述都看完之后,注意力的分配被稀释了。解决办法有两个。
一是启用意图预筛机制。先用一个轻量分类器判断用户意图,只加载相关技能。我的一个项目里总技能数一百二十个,但单次会话注入的通常不超过八个。准确率上去了,token消耗也少了,一石二鸟。
二是把低频技能移到“手动触发”区。有些技能因为业务特殊,每个月用不了几次,但它们的存在会影响模型的决策空间。我把这类技能的enabled字段默认设为False,只在特定业务场景的会话里按需打开。这样模型在大多数会话里面对的始终是一份精简的技能列表。
4.3 错误信息设计不足导致模型“硬处理”
模型拿到一个执行失败的结果之后,会尝试继续调用其他技能或者给用户编一个解释。如果错误信息本身就含糊不清,模型处理起来就会开始胡说八道。
我见过一个真实案例:订单查询接口返回了错误,错误消息是“系统繁忙,请稍后重试”。Agent拿到这个消息后,非但没有停下来,反而自作主张地又调了三次重试,最后直接告诉用户“订单不存在”。用户的体验极差,客诉炸了。
问题出在错误消息没有给模型提供足够的决策依据。现在我的错误信息设计遵循一个铁律:必须说明失败原因的种类,并给出明确的下一步建议。比如“查询超时(SYS_ERROR),建议间隔30秒后重试”,或者“订单号不存在(EMPTY_RESULT),建议向用户确认订单号是否正确”。模型有了这个信息,处理起来就有据可依,不会自己编原因瞎解释。
4.4 技能调用排查的日志规范
排查技能调用问题,最怕的就是数据不结构化,全在一大堆非格式文本里翻。我在每个技能执行中间件里固定记录以下字段:
| 字段 | 示例 |
|---|---|
| session_id | 会话级唯一ID,用于串联多轮对话 |
| trace_id | 单次技能调用的链路ID |
| skill_name | 被调用的技能名 |
| params | 模型生成的入参快照 |
| result_status | SUCCESS/EMPTY_RESULT/BIZ_ERROR/SYS_ERROR等 |
| elapsed_ms | 执行耗时 |
| error_message | 异常信息摘要 |
| model_id | 发起调用的模型版本 |
| timestamp | 调用发起时间 |
有了这些,当我收到一个“用户说订单查询失败”的反馈时,我只需要按session_id筛出该会话的所有调用记录,就能立刻定位到是模型入参传错了、接口本身报错了,还是用户输入的信息有误。排查效率比靠聊天记录去复盘高了几个量级。
4.5 模型把技能输出直接当最终答复的问题
这是我反复和团队强调的一个问题。技能执行的返回结果是供Agent内部使用的“中间状态”,它不是给用户的“最终回复”。比如查询订单状态,技能返回中包含订单状态码、支付金额、物流单号等结构化数据,如果Agent把这些原封不动地丢给用户,体验会非常生硬。
正确做法是让Agent在收到技能结果后,根据上下文的自然语气组织一段回复。我一般在技能描述末尾加上一句话:“此技能返回结构化数据,请根据会话上下文将结果转化为自然语言回复,不要直接输出原始JSON。”这个提示词虽然简单,但对回复质量的提升非常明显。另外,涉及金额、时间等关键数据时,建议在system提示里明确“必须将金额转换为用户所在时区和币种格式”,避免Agent把服务端时区的日期直接当用户本地日期用。
4.6 常见问题速查表
我把在实际项目中遇到的典型问题、可能原因和排查方法整理成一个速查表,方便直接对照使用。
| 问题现象 | 常见原因 | 排查步骤 |
|---|---|---|
| 技能选错,常选一个不匹配的技能 | 技能描述动词太模糊、或意图预筛机制失效 | 检查描述中的动作词是否足够精确,检查预筛标签是否覆盖了当前场景 |
| 参数格式总是错,日期或枚举值不对 | input_schema缺少example字段,或参数结构嵌套太深 | 为每个参数补example值,把嵌套结构改为扁平参数 |
| 技能执行成功但模型回复内容不对 | 模型直接输出结构化结果而未做自然语言转换 | 在技能描述末尾增加“转为自然语言回复”的约束 |
| 调用超时或者卡死 | 技能内部逻辑没有设置超时上限 | 在每个子调用中设置HTTP/函数超时时间,统一走中间件超时拦截 |
| 模型在失败后反复重试同一技能 | 错误信息没有给出“下一步建议” | 优化错误信息,补充失败类型和重试或终止的建议 |
| 技能数量太多,决策变慢 | 单次会话注入技能数量过大 | 启用意图预筛机制,把技能限制在8个以内 |
4.7 技能回滚与灰度发布
技能是有版本的生命体,不是静态的。我维护技能版本号,改动某个技能的描述、入参schema或handler逻辑时,版本号都要提升。灰度发布的做法是:新版本技能只对一小部分测试会话启用,稳定后再逐步扩大流量比例。一旦发现模型在新版本下的决策准确率下降,立刻切回旧版本,把影响面控制在最小。
这个策略在我自己维护的Agent项目里帮了大忙。有一次我优化了一个技能的描述,自我感觉写得更好,但灰度观察发现,模型对新描述的理解反而变差了,调用准确率掉了将近十个点。幸亏有版本回滚机制,一小时内就恢复了线上一版。如果没有这套机制,我只能让所有用户陪着踩坑。
5. 技能库的持续迭代思路
技能库上线那天不是结束,而是开始。我在实践中总结了一个双循环迭代法。
内循环是效果监控。每次技能调用,都按“调用次数、成功率、参数合法率、返回到达站率”四个核心指标持续收数。任何一个指标出现异常,自动触发告警,并且把关联的调用日志快照拉出来归因。
外循环是用户反馈。用户说“这个回答不对”或者“帮我换一种方式”,都要倒收到对应会话和对应的技能调用链。我维护了一张映射表:用户反馈关键词(比如“不对”“错了”“换个方式”)映射到对应的技能调用快照,每周集中分析一次,找出高频问题技能。
通过这个双循环,技能库就从一个静态代码库变成了一个可以持续进化的系统。我见过太多团队把技能库做完一版就放着不管了,三个月后模型升级了、业务调整了,技能还停在原地,最后项目整个烂尾。持续迭代的能力,才是Agent项目能不能长期跑下去的关键。
6. 最后分享一个我自己的心得
做到今天,我对agent-skills最深的感受是:技能的本质是给模型划定一个安全且高效的行动空间。你给它画多大、边界画得多清晰,直接决定了它在真实业务里能走多远。它解决的核心问题是模型的“无限可能性”和业务的“确定性要求”之间的矛盾——模型想象力再丰富,终归要落地到执行层,执行层的秩序感,就来自一套设计良好的技能体系。
如果你的团队正在做Agent项目,我建议你把至少一半的精力放在技能体系的设计上,而不是整天换更大的模型。模型是发动机,技能库是变速箱和方向盘。发动机动力再强,方向盘不稳、换挡逻辑混乱,这车也开不远。先把技能库的骨架搭稳,再回头审视模型选型,你的Agent项目成功率会大不一样。