去年我在一个内部知识库问答系统上第一次踩到“技能组织”的坑。当时功能其实都写好了:查库存、算价格、发邮件、建工单,一个个Python函数摆在那里。结果大模型经常选错工具,或者把参数传错,一场演示下来翻车了好几次。后面我把这些能力重新按“技能”而不是“函数”来组织,加上了描述、参数约束、测试样例,效果才明显稳下来。
这篇文章想聊的就是这套被我验证过多次的做法。所谓的agent-skills,说人话就是:让大模型能够稳定发现、理解、调用的一组可执行能力单元,以及围绕它们衍生出的设计、实现、评测和迭代方法。它解决的核心问题是:模型再聪明,如果没有一套能被正确调用的“手”,也做不成事;而工具一多,怎么让模型不选错、不卡死、不受损,就是技能工程需要回答的事情。
如果你是做Agent应用开发、想给自己的大模型接上工具,或者单纯想看看一套技能库应该长什么样,这篇文章应该能帮你节约不少试错时间。
1. 先把“技能”这件事想清楚
1.1 为什么不是简单函数调用
很多同学一开始都会想,Agent调用工具不就是“给模型一个函数列表,让它自己选”吗?理论上没错,但实操中这里有三层认知很容易被忽略。
第一,函数调用是编程视角,技能是模型视角。你在代码里写query_inventory(item_id: str),模型并不知道“item_id”究竟是SKU编码还是商品名称,也不知道这个函数适合在什么业务场景下用。技能需要额外提供一句人话说明,比如“当用户询问某商品是否有现货时调用,参数item_id为商品唯一编码,可从商品详情中提取”。
第二,单个函数往往不构成完整能力。比如“下单”这个动作,可能需要先查库存、再查价格、再校验地址、再创建订单。如果把这一整串都做成一个函数,内部会非常臃肿;但如果不封装,模型需要自己编排好几个工具,推理链路长了以后错误率会指数上升。技能允许你把这串动作封装成一个更高层的复合能力,让模型只做一次决策。
第三,函数调用是静态的,技能体系是动态的。新业务上线时,你不需要改动Agent的主流程,只需要注册一个新技能,描述清楚它是什么、能做什么、参数怎么传,模型下次就能用。这种“即插即用”的能力扩展方式,才是技能工程化最大的价值。
所以我的结论是:不要把技能理解成API网关上的接口列表,而要把技能理解成“给模型看的说明书+可执行逻辑”的组合体。设计时始终问自己:一个聪明但完全不了解我们业务的人,看到这份说明书能不能正确使用它?
1.2 技能的三要素
一个能被Agent稳定使用的技能,至少包含三个部分:身份描述、参数契约、执行逻辑。三者缺一个都会出问题。
身份描述回答“这个技能是什么、什么时候用”。我习惯给每个技能写下面几类信息:技能名称(要唯一且语义清晰)、一句话摘要(不超过50字)、使用场景(写清楚触发条件,包括“什么时候不要用”)、依赖关系(它内部调用了哪些下级能力)。
参数契约回答“模型应该传什么进来”。这部分建议直接用JSON Schema。好处是模型推理时结构明确,校验逻辑也统一。每个参数都要写清楚类型、是否必填、取值范围、示例值,特别是“示例值”非常有用——模型在不确定参数格式时,会参考示例来推断,这比在描述里写一大段规则有效得多。
执行逻辑回答“调用之后实际发生什么”。这是一个普通函数、一段工作流、还是一个外部服务请求,都不重要。重要的是它必须有明确的成功与失败返回结构。我会统一用{"success": true, "data": ...}和{"success": false, "error": {...}}两种结构返回,这样模型在观察阶段才能正确判断是否需要换工具或重试。
1.3 技能工程化解决的三类痛点
把技能当作一等公民来管理,而不是散落在代码里的工具函数,主要能解决三类痛点。
第一是选择准确率问题。技能带上了场景描述和禁用条件之后,模型选错的概率会明显下降。比如我有一个“查天气”的技能,描述里明确写了“仅用于未来3天天气预报,历史天气请使用查历史天气技能”,模型就不会拿它去回答昨天的天气。
第二是维护成本问题。没有技能库的时候,要用某个能力就得去翻代码、找函数、猜参数。有了统一的技能清单和版本管理,运营同学也能通过配置中心维护技能描述,不用每次改个说明都要走上线发布流程。
第三是扩展性问题。系统要接入一个新领域能力时,只需要新增技能并注册,Agent主流程和提示词都不用动。我在做跨部门系统集成时,就是靠这种方式把几个团队的工具统一汇入一个技能库,各自维护各自的技能,互不干涉。
2. 设计一套可复用的技能体系
2.1 技能分层:原子、复合、编排
把所有技能都平铺开来,数量一多就会乱。我一般分成三层。
原子技能是最小可执行单元,比如查询天气、发送邮件、创建工单。它们一般对应单次外部调用,没有内部子技能依赖。复合技能由多个原子技能或小复合技能编排而成,比如“下单购买”内部会依次调用查库存、算价格、创建订单。编排技能本身不执行具体业务逻辑,它的职责是判断当前场景需要哪些复合技能,并按顺序触发。
分层带来的最大好处是职责清晰。原子技能保持单一职责,容易测试;复合技能把常见多步操作固化下来,减少模型推理负担;编排技能则负责在更高维度上做路由。
我做智能客服时给技能分层的体会是:把“查询”和“操作”严格分开。查询类技能可并发执行,操作类技能需要用户确认。如果混在一起,模型可能在用户还没确认时就执行了下单操作,这是产品层面的严重事故。所以设计分层时不只是看技术复杂度,还要看业务风险等级。
2.2 技能描述怎么写才不让模型误解
技能描述是模型“看到”的唯一窗口,它的质量直接决定选择准确率。我踩过很多次坑之后,总结出几条写法经验。
描述开头第一句话直接说功能,不要铺垫。模型在推理时是逐词读取的,如果前20个字还在讲“本技能用于帮助用户解决……”,真正功能信息就被冲淡了。正确写法是:“列出用户指定时间段内的所有历史工单记录。”
写清“什么时候用”和“什么时候不用”。很多模型误选工具,就是因为没有禁用条件。我会在描述里专门加一节使用限制,说明这个技能不覆盖哪些场景;模型看到明确禁用信息后,通常会转去调用正确技能。
参数说明都用示例说话。不要只写“date: 日期”,要写“date: 2025-05-01,注意日期格式为YYYY-MM-DD,必须是过去时间”。示例比规则更直观,因为模型在大量训练数据中见过示例形式的表述。
描述长度也要控制。太长会占用上下文,太短又说不清楚。我一般控制在200字左右,重点信息前置。超过300字的描述,我会考虑拆成两级:把核心功能放前面,详细规则放参数的description里。
2.3 冲突消解与优先级策略
技能多了以后一定会遇到“多个技能都能完成类似任务”的情况。比如我做过一个系统里同时有“查快递”和“查物流”两个技能,功能高度重叠,模型经常选错。
我的处理方式是建立冲突消解表。在技能管理后台里维护一张关系表,注明哪些技能之间存在重叠、各自的优先场景是什么、当模型同时匹配到多个技能时默认推荐哪一个。然后在系统提示词里加一段通用规则:若多个技能描述指向同一目标,优先使用专名更具体的技能;若无法判断,优先使用最近上线的版本。
如果重叠实在无法避免,干脆合并。有一次我只保留了一个“统一搜索技能”,内部根据关键词自动路由到不同数据源,模型不再需要纠结选哪个。这样做的代价是这个技能内部会变复杂,但推理层的选择负担就减轻了。
2.4 技能边界划分的经验
给技能划边界有一个实用技巧:按“用户可以自然表达的意图”来切,而不是按“后端提供的接口”来切。比如后端可能拆了订单查询、订单详情、订单状态三个接口,但用户只会说“帮我看看我的订单到哪了”。这时应该把它们合并成一个“查询订单状态”技能,内部自己组合接口。
边界画得太细,模型选择困难;画得太粗,技能内部变成一个庞然大物,维护困难。我的经验是:一个技能对应的用户意图,尽量保持在“一句话能说清楚”的程度。否则就继续拆。
3. 实操:搭建一个最小可用的技能库
3.1 项目结构怎么组织
我习惯用一个独立目录来承载所有技能,而不是和主程序混在一起。目录结构大致如下:
agent_skills/ ├── registry.py # 技能注册器 ├── models.py # 技能数据模型 ├── loader.py # 动态加载技能 └── skills/ ├── __init__.py ├── order_query/ # 一个技能一个目录 │ ├── skill.yaml # 技能描述与参数契约 │ └── handler.py # 执行逻辑 ├── weather_query/ │ ├── skill.yaml │ └── handler.py └── ...每个技能独立目录的好处是:可以单独写测试、单独发布、单独维护负责人。skill.yaml里放的是给模型看的描述和参数契约,handler.py里放的是真正的执行逻辑。两者分离,意味着描述调整可以不用动代码,也就是运营同学能直接参与维护。
3.2 定义一个技能的数据结构
我先定义一个技能的数据模型,把技能名、描述、参数契约、执行函数都绑在一起。下面使用pydantic来做校验,实际你用什么库都行,关键是字段清晰。
from typing import Any, Callable, Optional from pydantic import BaseModel, Field class Skill(BaseModel): name: str = Field(description="技能唯一名称,使用小写加下划线") description: str = Field(description="给模型看的技能描述") parameters: dict = Field( default_factory=dict, description="JSON Schema格式的参数契约" ) handler: Optional[Callable[..., Any]] = None tags: list[str] = Field(default_factory=list) version: str = "1.0.0" disabled_scenarios: str = Field( default="", description="描述该技能不适用的场景" ) def invoke(self, **kwargs) -> dict: if self.handler is None: return {"success": False, "error": {"code": "NO_HANDLER"}} try: result = self.handler(**kwargs) return {"success": True, "data": result} except Exception as e: return { "success": False, "error": {"code": "EXEC_ERROR", "message": str(e)} }这个模型很像一个“给模型看的函数签名”。description和parameters是模型能看到的;handler是实际执行的,模型看不到。disabled_scenarios是我后来加的,用来告诉模型“别乱用”,效果很显著。
3.3 动态加载与注册机制
技能库不可能每次新增技能都改主程序代码,所以需要一个动态加载机制。我在loader.py里实现了一个简单扫描器:遍历skills/目录下所有子目录,读取每个子目录里的skill.yaml,再导入同目录下的handler.py。
import importlib.util import yaml from pathlib import Path from .models import Skill SKILL_ROOT = Path(__file__).parent / "skills" def load_skill_from_dir(skill_dir: Path) -> Skill: meta_path = skill_dir / "skill.yaml" handler_path = skill_dir / "handler.py" with open(meta_path, "r", encoding="utf-8") as f: meta = yaml.safe_load(f) spec = importlib.util.spec_from_file_location( skill_dir.name + "_handler", handler_path ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return Skill( name=meta["name"], description=meta["description"], parameters=meta.get("parameters", {}), handler=getattr(module, "handle"), tags=meta.get("tags", []), version=meta.get("version", "1.0.0"), disabled_scenarios=meta.get("disabled_scenarios", ""), ) def load_all_skills() -> list[Skill]: skills = [] for child in SKILL_ROOT.iterdir(): if not child.is_dir(): continue skill = load_skill_from_dir(child) skills.append(skill) return skills这种动态加载改造成本很低,却让技能库具备了很强的扩展性。后来我还加了基于文件修改时间的自动重载,开发调试时改完技能描述保存一下就能生效,不用重启服务,体验非常好。
3.4 技能测试与依赖管理
技能库的测试不能只测功能逻辑,还要测描述文本和参数契约是否符合预期。我会写两类测试。
第一类是单元测试,直接调用handler验证执行逻辑。比如模拟一个天气接口返回固定数据,断言技能返回结构正确、参数校验失败时能返回规定的错误格式。
第二类是契约测试,检查skill.yaml里的描述是否满足规范。我用一个脚本扫描所有技能,检查必填字段是否齐全、参数Schema是否符合JSON Schema规范、描述里是否包含禁用场景说明。这些自动化检查在CI里跑,能挡住不少低级错误。
依赖管理方面,一个领域内高度相似的技能共用底层客户端模块,不要让每个技能自己封装HTTP请求。我把共享客户端抽成common/clients.py,技能里的handler只负责业务逻辑和参数组装。这样接口升级时只需要改一处。
4. 接入Agent运行时的关键环节
4.1 把技能列表交给模型的结构
技能库建好了,下一步是让它真正进入Agent的推理循环。目前主流做法是直接把技能列表序列化成模型可读的JSON结构,拼进系统提示词。序列化时注意要保持原始JSON Schema,不要自己简化。
def build_skills_prompt(skills: list[Skill]) -> str: lines = [] for s in skills: schema = { "name": s.name, "description": s.description, "parameters": s.parameters, } if s.disabled_scenarios: schema["disabled_scenarios"] = s.disabled_scenarios lines.append(json.dumps(schema, ensure_ascii=False, indent=2)) return "\n\n".join(lines)我见过有些团队为了省tokens,把参数描述全部删掉只留参数名,结果模型乱传参数。省那一两百个token,换来的是百分之十几的工具调用失败率,完全不划算。参数契约是模型唯一能参考的格式规范,能保留就保留。
4.2 工具调用失败以后怎么办
技能执行不可能百分百成功。模型调用工具拿到失败结果后,表现高度依赖返回信息的质量。如果返回只是一句“Error”,模型根本不知道错在哪、要不要重试。
我设计的失败返回包含错误码、错误阶段、修正建议三块。举个例子:
{"success": false, "error": {"code": "MISSING_PARAM", "field": "user_id", "message": "缺少用户ID,请先获取用户信息"}}模型读到这个结果,知道自己缺了什么参数、该去哪里补,下一步就会去调用“获取用户信息”技能而不是反复重试同一个失败的调用。错误码要尽量稳定且枚举化,模型会更愿意依据已知错误码做分支判断。
还有一个关键点是重试限制。我会在Agent循环里记录每个技能的最大调用次数,比如默认3次。超过次数还没成功,就让模型更换策略或直接向用户说明暂时无法完成,避免无限循环烧token。
4.3 上下文膨胀与结果压缩
技能调用多了,Observation会非常长,尤其是查询类技能把一堆数据塞回上下文,很快就把窗口占满。我常用的三种压缩手段:
只返回摘要而不是原始结果。比如查库存技能返回的不是全部明细,而是“共找到132个SKU,其中缺货3个,详见附件”。模型需要的是结论和判断依据,细节放在执行结果文件里,需要时再让模型调用详情查询技能。
截断长列表,保留头部和尾部。模型通常最需要知道“有哪些”“总量多少”,中间大量重复项可以省略,用一句...共省略120条...示意。
用向量检索代替全量返回。我在一个文档问答技能里就是这么做的:先把文档切片向量化存入检索库,技能返回给模型的只是“已检索到相关段落,共5条”,而不是把整份文档塞回去。上下文压力瞬间小了很多。
4.4 多Agent场景下的技能共享与隔离
技能库在单一Agent里跑起来之后,迟早会遇到多Agent的场景:客服Agent、数据分析Agent、后台管理Agent都挂在同一个技能库上。这时必须考虑共享和隔离。
我采用的方案是给技能增加可见性标签,按Agent角色过滤。
def visible_skills(skills: list[Skill], roles: set[str]) -> list[Skill]: return [ s for s in skills if not s.tags or (s.tags & roles) ]比如下单类技能只有客服角色可见,数据批量导出技能只有管理员可见。这样模型即使看到了全部技能列表,也应只按自己的角色权限调用,避免跨权限操作。权限判断不能只靠提示词,执行端必须再做一次校验。真正安全的做法是:技能执行前根据调用来源角色再做一次白名单检查,防止模型被诱导执行未授权操作。
这里要特别强调,永远不要相信模型不会越权。技能库的权限边界必须在执行层硬性约束,提示词只是辅助手段。
5. 技能质量的评测与持续迭代
5.1 构造评测场景的方法
技能好不好,不能靠感觉。我的做法是准备一套Golden Set,每个用例记录用户的原始输入、期望调用技能名、期望参数值。数量不求多,但覆盖面要广。50个精心设计的用例比500个随意泛化的用例更有效。
用例设计有几个角度:常见意图(用户最常说的话)、边界意图(类似“帮我查一下”这种缺少关键信息的输入)、易混淆意图(两个技能都能沾边的场景)、禁用意图(描述里明确不支持的场景)。每一类都配10条以上,基本能把技能选型的问题暴露出来。
跑评测的方式很简单:把Agent系统提示词和技能列表固定住,只输入用户文本,记录模型实际调用结果,和期望值对比。我一般会统计准确率和错误类型分布。
5.2 关键指标怎么解读
我长期关注的指标有三个:技能命中率、任务完成率、平均调用轮次。
技能命中率指的是模型选对技能且参数基本正确的比例。低于80%说明技能描述有严重问题,需要重写描述而不是继续调提示词。任务完成率看的是整条链路的最终效果,包含执行成功和模型正确判断是否需要换技能。平均调用轮次则反映效率,一个简单查询如果模型绕了三五个工具才做对,说明技能编排有问题,多半是复合技能没有封装好。
表格可以这样记录对比:
| 指标 | 优化前 | 优化后 | 变化原因 |
|---|---|---|---|
| 技能命中率 | 68% | 91% | 参数示例补全、禁用场景写清 |
| 任务完成率 | 53% | 87% | 复合技能封装下单链路 |
| 平均调用轮次 | 6.2 | 3.1 | 统一搜索技能减少路由判断 |
5.3 回归测试与版本管理
技能库一旦被多个Agent共用,改动影响范围会很大。我经历过一次改技能描述把所有Agent都带崩的惨案,之后非常强调回归测试。每次有技能描述或逻辑变更,我会先在测试环境跑一遍完整Golden Set,对比指标差异,再决定是否上线。
版本管理上,skill.yaml里的version字段必须递增。技能发布系统会记录每个技能的历史版本,支持一键回滚。做法不复杂:发布时把整个skills/目录打一个快照,回滚时把快照内容覆盖回去即可,配上一个简单的版本号路由就能做到。
5.4 持续运营的节奏
技能库不是上线就完事的,需要持续运营。我给维护定了几个固定节奏:每周从线上日志里抽一次失败案例,看错误分布;每月用新需求驱动新增技能;每个季度做一次全量评测对比当季目标。
线上日志里的错误分析是最重要的来源。比如某个技能用户用得多但失败率高,可能不是描述问题,而是下游接口不稳定。这些经验积累下来,技能库会越来越像一个稳定运转的产品,而不是一堆临时脚本。
6. 我的实际体会
这套技能工程化的思路前前后后改进了三四轮,我最想分享的教训是:模型的能力边界决定了技能描述怎么写,而技能描述的质量决定了整个Agent的上限。与其花大把时间调prompt,不如把功夫下在技能设计上,效果更稳定,也更可持续。
如果你正准备从零搭一个Agent技能库,我的建议是不要一上来就把所有功能都封装上去。先挑两三个最核心、最容易被模型理解的技能跑通全流程,再逐步扩展。技能描述写不好的时候,多拿真实用户输入去测试,让模型告诉你哪里不够清楚——它在选错工具时给出的“思考过程”就是最直接的优化线索。
最后再分享一个小技巧:每个技能的skill.yaml里加一个test_cases字段,存放两三条典型的用户输入样例。调试Agent时直接在提示词里加入当前技能对应的样例,模型往往能快速对齐使用方式,比纯描述更管用。这个细节让我在项目后期的排查效率提升了不少。