做智能体的时候,我发现一个很有意思的现象:同样一个大模型,有人只能让它写写诗、改改文案,有人却能让它替自己完成一整套业务动作——查数据、开工单、发通知。差别不在模型本身,而是在它周围那层“手”。这层手,就是我今天想聊的 agent-skills。所谓 agent-skills,可以简单理解成一组被大模型理解、调用、编排的技能包。它把散落在代码里的工具函数,升级成带描述、带参数约束、带错误处理的能力单元,然后通过一套统一协议暴露给模型。如果你正在做智能体应用,或者想把自己的某个工具能力封装给模型用,这篇文章应该能帮你少走很多弯路。我会从设计思路、协议约定、实操代码到踩坑排查,完整过一遍。
1. agent-skills 到底在解决什么问题
1.1 从“会聊天”到“会干活”的那道坎
先抛一个直观的场景:我希望一个智能体能帮我查一下某城市的天气,然后根据天气情况决定要不要给客户发一条提醒短信。如果只靠大模型的对话能力,模型能猜出“天气不错”这种模糊结论,但它拿不到实时天气数据,也触发不了短信接口。这就是单纯聊天和真正干活的边界。
为了解决这个边界问题,传统做法是给模型写一堆“函数”,告诉它有哪些参数,它按需调用。一开始这种方案够用,但随着工具数量增长,问题开始冒出来:函数命名不统一、参数含义含糊、返回结构混乱,模型经常不知道该选哪个工具。
agent-skills 的思路,是把这些“会干活的函数”当成一个个独立的技能来管理。技能不仅包含函数的执行逻辑,还包含一段机器可读的描述——什么时候该用、参数长什么样、返回值怎么解析、失败了怎么兜底。这样一来,模型不再是面对一堆随机函数,而是面对一张清晰的能力地图。这张地图既能让模型快速定位能力,也能让开发者方便地增加或下线能力,而不是改一版就牵一发动全身。
1.2 技能与函数调用有什么本质不同
有同学可能会问:这不就是 function calling 吗?确实,agent-skills 建立在函数调用之上,但它至少多做了三件事。
第一是语义化。函数名是给人看的,技能描述是给模型看的。同一个函数,你可以给它两套描述:一套给系统工程师看代码,一套给大模型看使用时机。技能层会把这些描述整理成模型容易理解的格式,比如“在用户询问某地天气时使用此技能,需要提供城市名称和日期”。
第二是标准化。函数调用往往各写各的,参数格式全凭个人习惯。技能层会强制统一输入输出协议、错误码、重试策略,给每个技能配上元信息,比如技能版本、权限等级、超时时间。依赖这些元信息,多个开发者可以并行新增技能,不用反复对齐命名。
第三是可编排。函数调用往往是单次调用,技能层则允许一个技能组合另一个技能,形成技能链。比如“查询天气”和“发送短信”是两个技能,“根据天气发送客户提醒”就是一条技能编排流程。用 Agent 系统的话说,技能是原子能力,编排是业务流程。这一层抽象,才是 agent-skills 更值得关注的地方。
2. 技能库的核心设计:先定规矩再动手
2.1 技能描述协议的最低要求
技能库本质上是“模型和你代码之间的合同”。如果合同写得含糊,模型执行起来就会跟着含糊。一个技能至少要包含六个字段:技能名、技能描述、输入参数、输出结构、错误处理和依赖信息。
名字用简短的小写加下划线,例如 query_weather。描述要写清楚两个问题:这个技能在什么场景下使用,以及有哪些使用限制。输入参数建议用 JSON Schema 描述,不仅要定义类型,还要写每个参数的说明和是否必填。我曾经见过技能描述里写“输入一个城市”,模型一直在猜,是城市名还是城市 ID,是传拼音还是中文?最终只好把描述改成“输入城市的中文名称,例如:某城市”。输出结构同样要明确,包括成功返回的字段和成功状态码。
错误处理建议参考 HTTP 状态码的思路,至少要有参数错误、服务不可用、业务失败三类。依赖信息则指明这个技能需要哪些其他技能或外部服务。这六个字段看似简单,但严格执行之后,技能库的可用性会明显提升。
2.2 从 Schema 到注册表:能力从无序变有序
光有单个技能的定义还不够,你需要一个统一的注册表来管理所有技能。注册表的核心职责是:收集技能、暴露查询接口、支持按需加载。
我常用的做法是维护一张技能元信息表,字段包括技能名、版本、入口、描述、参数 JSON Schema、输出 JSON Schema、超时时间、权限标识。运行时,Agent 先通过注册表拿到所有可用技能的描述,再把描述喂给大模型,模型从中选择技能并生成参数,最后调用注册表里的入口函数执行。这种模式的好处是新增技能只需要向注册表注册,不需要改 Agent 的主体代码。这个小扩展点,是 agent-skills 比较重要的工程收益之一。
注册表本身建议做两层:一层是静态索引,用于存放技能元信息;一层是动态实例,用于管理技能运行时状态。比如某个技能依赖外部接口,可以缓存连接池;某个技能是重量级任务,可以在实例层控制并发。静态和动态分离后,技能的稳定性会好很多。
2.3 权限、作用域与冲突处理
技能库变大后,冲突和权限问题就躲不掉了。
先说权限。不是所有技能都适合让大模型随意调用,比如删除用户、发送营销短信、修改价格这类高危操作,必须在技能层做白名单。我的方案是在技能元信息里加一个 access_level 字段,Agent 运行时根据当前会话的身份决定是否把技能暴露给模型。如果未授权,宁可让模型说“没有权限”,也不要尝试调用。
再说冲突。两个技能可能都匹配同一个用户意图,比如“获取订单详情”和“获取订单状态”,描述上高度相似。这种冲突靠模型随机选容易出错。我的做法是给技能加权重和优先级,描述里写上更具体的触发条件,同时在注册表的查询接口里实现排序逻辑。一个经验是:描述越短但触发条件越明确的技能,越不容易冲突。
3. 从零搭一套 agent-skills 系统
3.1 环境准备与最小骨架
这个部分比较实践。我会用一个最小可运行的例子来讲,语言用 Python,依赖库只用标准库,这样你不需要装额外的框架也能跑通思路。
先准备一个 SkillRegistry 类,里面用字典存放技能元信息和入口函数。技能注册用装饰器,代码大概长这样:
import json from typing import Callable, Any class SkillRegistry: def __init__(self): self._skills = {} def register(self, name, schema, access_level=1): def decorator(func: Callable): self._skills[name] = { "name": name, "schema": schema, "func": func, "access_level": access_level, } return func return decorator def describe(self, access_level=1): visible = [] for skill in self._skills.values(): if skill["access_level"] <= access_level: visible.append(skill["schema"]) return visible def run(self, name, **kwargs): skill = self._skills.get(name) if not skill: raise KeyError(f"skill not found: {name}") return skill["func"](**kwargs) registry = SkillRegistry()这段代码是一个最小骨架。run 方法先按名字找技能,找不到就直接抛异常。describe 方法只返回当前权限可见的技能 schema,目的是让模型不要看到它没权限用的技能。你会发现整个系统没有引入任何 AI 框架,因为 agent-skills 的核心不是某个框架,而是这套注册-发现-执行的机制。
3.2 注册一个真实技能:天气查询
下面注册一个天气查询技能。注意,这里不接真实外部服务,我用一个本地字典模拟第三方接口,重点看协议怎么定。
@registry.register("query_weather", { "name": "query_weather", "description": "根据城市中文名称查询当日的天气情况,适合在用户询问某地天气时使用", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市中文名称,例如:某城市"} }, "required": ["city"] }, "outputs": { "type": "object", "properties": { "temperature": {"type": "number", "description": "当日最高温度,摄氏度"}, "condition": {"type": "string", "description": "天气状况,如晴、多云"} } } }) def query_weather(city): mock_db = {"某城市": {"temperature": 28, "condition": "晴"}} if city not in mock_db: return {"error": "no_data", "message": f"未找到城市 {city} 的天气数据"} return mock_db[city]这里有一个关键点:技能入参的 JSON Schema 直接给到大模型,大模型会基于这个 schema 生成参数。如果 schema 里只写“city: string”,模型可能会生成一个不存在的 ID。我加了“城市中文名称,例如:某城市”,实测下来,参数正确率提高了很多。返回结构也要提前约定,成功返回温度字段,失败返回 error 字段。任何技能都要在返回里明确提出错误语义,否则模型会把错误文本当成正常结果来用。
3.3 Agent 调用链路的完整实现
接下来实现一个简单的 Agent 脚本。它的工作流程是:拿到用户问题,先从注册表取技能描述,把描述和用户问题一起发给大模型,大模型返回“技能名+参数”的 JSON,然后脚本执行技能并回传结果。
import json def call_llm(system_prompt: str, user_query: str) -> str: # 这里替换成你实际使用的大模型接口 # 返回一个 JSON 字符串,比如 {"skill": "query_weather", "arguments": {"city": "某城市"}} pass def run_agent(user_query: str, registry: SkillRegistry): visible_skills = registry.describe(access_level=1) system_prompt = ( "你是一个智能体助手。请分析用户需求,从可用技能中选择一个并生成参数。" "可用技能如下:\n" + json.dumps(visible_skills, ensure_ascii=False) ) raw_result = call_llm(system_prompt, user_query) parsed = json.loads(raw_result) skill_name = parsed["skill"] arguments = parsed.get("arguments", {}) return registry.run(skill_name, **arguments)这个脚本只做了最基本的事情。真正生产环境里,你还需要增加:循环机制——模型可能先查天气再决定发不发短信,所以一次工具调用之后要再次调用模型,让模型基于返回结果继续规划;最大迭代次数限制——防止模型陷入无限循环;时间预算——每个技能调用要有独立超时。这几项会在下一节详细展开。
4. 跑通之后,避坑与排查实录
4.1 最常踩的五个坑
技能库跑通只是第一步。我把自己做 agent-skills 过程中踩过的坑整理成一张表,你可以直接拿来做检查单。
| 坑 | 现象 | 原因 | 解法 |
|---|---|---|---|
| 参数幻觉 | 模型传了 schema 里不存在的字段 | 描述不严谨,模型猜测 | 在 description 里给示例值 |
| 技能冲突 | 同样的问题每次调用不同技能 | 技能描述相似 | 写清触发条件,添加唯一标识 |
| 错误返回不明确 | 模型把 error 文本当作业务结果 | 输出结构没有区分 success/error | 统一输出格式,加入 error 字段 |
| 超时后无响应 | 第三方接口慢,整个 Agent 卡死 | 没有按技能设置超时 | 设置超时并返回 timeout 错误 |
| 上下文膨胀 | 技能描述太多,超出模型上下文 | 技能数量多 | 按意图分类,动态暴露部分技能 |
第一行参数幻觉是目前最普遍的。模型在生成参数时会“自由发挥”,它看到参数名为 city,就可能会传一个 ID 而不是名字。解决方式不是骂模型,而是把示例值写死在描述里,比如“例如:某城市”。我用这个技巧把参数错误率降低了大概三成。
第二行技能冲突,常见于两个技能功能接近。比如“查询订单状态”和“查询物流状态”,模型经常选错。解法是在描述中写清楚边界:“订单状态是用户下单后的处理进度;物流状态是包裹发货后的运输信息。”边界越清晰,选择越稳定。
4.2 参数幻觉怎么治
参数幻觉值得单独拎出来说,因为它是 agent-skills 最容易出现且最影响体验的问题。
我总结了一套从外到内的治理方法。外层是描述修正,把每个参数的取值范围和示例写清楚;中层是校验器,在技能入口增加参数校验,不合法就直接返回参数错误,不要带病执行;内层是默认值策略,给非必填参数设置合理默认值,减少模型的选择压力。比如“查询天气”的日期参数,如果没有传日期,可以默认取当天。这三层都做了以后,模型传错参数的概率会明显下降。
还需要注意,校验错误要返回结构化的错误码,比如 param_error,而不是抛一个纯字符串异常,因为模型需要从错误码里判断下一步该做什么。如果它收到一句“参数错误”,下一步大概率是再次生成同样的错误参数。这一点在我自己的项目里反复出现,直到我把错误码和提示信息分开,模型才学会自我纠正。
4.3 技能冲突与优先级问题
技能冲突本质上是技能空间设计的问题,不是模型的问题。当两个技能的触发条件重叠,模型只能靠猜。
我建议在技能元信息表里增加 priority 字段,在描述中把触发条件写得尽量细。运行时,可以做一个两步选择机制:第一轮由模型选择技能;如果模型返回的技能在注册表中不存在或者参数校验失败,第二轮由规则系统根据关键词匹配推荐最相似的技能,再把推荐结果返回给模型确认。这个机制虽然多了一次交互,但在技能数量超过五十个以后,整体准确率会提升很多。
另外一个经验是:尽量把“窄技能”合并成“宽技能”。比如“查询今天天气”“查询明天天气”“查询一周天气”可以合成一个 query_weather 技能,用参数 date_range 区分。技能数量越少,冲突面越小,模型的选择负担也就越低。
5. 评估与迭代:让技能库越用越顺手
5.1 离线评估怎么设计
技能库不能只靠“感觉”。你需要一套离线评估集,把所有技能的代表性用户问题整理成测试用例,每个用例标注期望技能和期望参数。跑测试时,把用户问题发给 Agent,记录它选择了哪个技能、生成了什么参数,然后和标准答案对比。
指标可以看技能选择准确率和参数命中率。参数命中率指的是生成参数与标准答案完全一致或等价的比例。等价判断可以把参数规范化后比较,也可以允许一定容差。离线评估最大的好处是可以在改动技能描述后快速回归,避免改一个技能导致另一个技能选错。我通常把测试用例按技能分目录存放,每新增一个技能都要补二十条以上用例。
5.2 线上观测与日志
线上环境里,你需要回答几个问题:模型选技能选得准不准?技能执行成功率高不高?不同技能组合走下来的性能怎么样?我的做法是给技能层埋点,记录时间戳、入参、出参、耗时、错误码和会话 ID。这组日志要统一打到一个地方,方便追溯。
技能执行成功率的定义建议用“成功返回业务结果/总调用次数”,而不是“没有抛异常/总调用次数”。因为有些请求虽然没抛异常,但返回的是“未找到数据”,这种对用户来说仍然是失败的。我踩过的坑是只统计异常率,导致某个技能返回空结果的问题完全没暴露出来。后来把“业务成功”和“系统成功”分开统计,问题才浮现。
5.3 技能拆分的颗粒度
技能应该拆到什么程度?我可以给你一个判断标准:如果两个技能总是在同一个对话流程里被连续调用,那就值得合并,或者做成一个编排技能。如果某一个技能功能过多、参数超过七八个,说明它太胖了,需要拆成更小的技能。
一个技能的最佳状态是:描述不超过两句话,参数不超过五个,输出结构不超过三层。这个标准不是绝对的,但能让你在项目初期有一个尺子。等技能量大了,可以再引入组合技能或子流程概念,但前期不要为了设计过度设计。我见过有人把“发送邮件”拆成“读取收件人”“读取正文”“连接服务器”三个技能,结果模型每次调用都要三步走,既慢又容易出错。这种拆法就属于没有价值的细粒度。
6. 一些经验与后续可以怎么玩
6.1 我踩过的最痛的坑
最后分享一个我在 agent-skills 项目里踩得最深的坑:把接口文档直接当作技能描述用。当时我觉得某个外部接口的文档已经写得很清楚了,直接拿过来当描述喂给模型,结果模型频繁调用失败。原因是接口文档是给人看的,里面全是字段说明和接口路径,缺乏“什么时候该调用这个接口”这种使用场景描述。大模型最需要的是触发条件,而不是接口语法。
后来我强制自己用三句话重写每个技能描述:什么情况下使用、需要注意什么、返回什么结果。效果立竿见影,选择准确率上来了。这件事情让我意识到,agent-skills 的核心资产不是代码,而是那套经过打磨的文本描述。描述的质量直接决定模型表现。这也是为什么我一直强调,搭好注册表只是起点,持续维护技能描述才是长期工作。
6.2 可以扩展的方向
如果你已经跑通基础版技能库,有几个方向值得尝试。
第一个是技能编排。把常用流程固化成编排模板,比如“客户服务流程”:查询订单状态,如有延迟则发送通知。这样可以减少模型每一步都做决策的负担。第二个是技能热更新。技能描述或实现更新时,不需要重新部署 Agent,只需要更新注册表里的元信息。第三个是技能评估自动化。把离线测试集接进 CI 流程,任何技能描述改动都要过一遍回归测试。第四个是多模态技能。有些技能需要输入图片或输出语音,未来可以按同样的协议扩展数据类型。这些方向我都在逐步验证,等有更多结果再来分享。