从 agent-skills 这个项目命名就能看出来,它要解决的是 Agent 技能沉淀的问题。我一开始做聊天机器人,总是往系统提示词里塞各种“你是个会查天气的助手”“你是个会算数的助手”这类话,结果上下文越写越长,功能越加越乱。后来我把能固化的能力全部抽成独立的技能包,让大模型自己按需“调用技能”,整个系统的可维护性和扩展性才真正打开。这篇文章就围绕 agent-skills 这个技能库框架,讲清楚技能到底是什么、该怎么设计、怎么落地,以及我在实际开发中踩过的坑,给正在做 Agent 应用、自动化工作流的同学一份可直接参考的手记。
1. 为什么 Agent 需要“技能”而不是“工具”
1.1 一个技能包含什么,和工具调用有什么区别
很多人刚接触 Agent 时,第一反应是“给大模型加函数”,于是写了十几个函数:get_weather()、calculate_expression()、send_email(),然后把这些函数塞给模型。这当然可行,但很快你会发现它只是解决了“单次调用”的问题,离“能力复用”还差得远。
工具调用的本质是“函数”:你给我参数,我返回结果。而技能的定义要宽得多:技能是一个“带使用说明、带输入校验、带异常处理、带后处理逻辑”的原子能力单元。打个比方,工具是工具箱里的一把扳手,而技能是老师傅的“拧螺丝手法”。拧螺丝根本不是单纯把扳手套上去,还要判断螺丝型号、用多大力度、遇到滑丝怎么处理、拧完要不要做标记。这些操作步骤本身就构成了一套流程。
所以在 agent-skills 里,每个技能包都包含四个部分:
- 技能说明:告诉大模型“这个技能是干嘛的、什么时候用、什么时候千万别用”。
- 输入定义:一个严格的 JSON Schema,规定需要哪些字段,字段类型和取值范围。
- 执行逻辑:收到参数后真正跑起来的代码,可能是一次 API 调用,可能是本地脚本,也可能是几步操作的组合。
- 结果处理:把原始返回包装成模型友好、用户可读的结果。
有了这四层结构,技能就不再是一次性的散装函数,而是一个能登记、能被发现、能被复用的独立模块。
1.2 从“提示词塞满”到“技能注册中心”
不做技能库的时候,项目会经历一个很典型的过程:产品经理提需求,说能不能让机器人帮忙转 PDF、能不能识别截图里的表格、能不能自动把长文分章节。传统做法是把每个需求变成一段新的系统指令。比如“当用户发来 PDF 链接时,请下载文件并调用库A解析”“当用户提到表格图片时,请调用库B识别”,这些指令最终成了一篇失控的说明书,模型既要理解语义,又要记住几十条规则,稍不留神就做出“张冠李戴”的响应。
agent-skills 的解法是引入“注册中心”。系统启动时,所有技能包把自己登记到一个全局 Registry 里,Registry 统一管理技能的元数据。当用户的消息进来,Agent 并不需要在提示词里逐个告诉模型“你可以用哪些技能”,而是由一个“技能选择器”先根据消息语义筛选出最相关的 3-5 个技能,再把它们的精简说明作为上下文注入。模型只需要在候选集合里做选择,这一步能大幅降低指令遵循的难度。
这也带来了两个直接的好处。第一,技能说明从“全文背诵”变成“按需查阅”,上下文长度得到控制;第二,技能可以动态热插拔,新增技能只需要写一个技能包然后注册,不需要改动 Agent 主逻辑。我维护的项目里,技能数量从 5 个涨到 40 个,提示词基本没动过,这在以前是难以想象的。
2. 技能库的核心结构与关键字段
2.1 技能描述文件的通用 Schema
一个技能包在 agent-skills 里通常由一个配置文件加一个或多个执行脚本组成。配置文件我用 JSON 或者 YAML 写,核心字段如下:
{ "id": "doc_summarize", "name": "文档摘要", "version": "1.2.0", "description": "当用户需要总结邮件、报告、网页文章或其他长文本时使用。支持生成摘要、提炼要点、控制摘要长度。不要用于代码解释或翻译任务。", "tags": ["summary", "text-processing"], "input_schema": { "type": "object", "properties": { "text": { "type": "string", "description": "需要被总结的原始文本" }, "max_length": { "type": "integer", "default": 200, "minimum": 50, "maximum": 2000 }, "language": { "type": "string", "enum": ["中文", "English"], "default": "中文" } }, "required": ["text"] }, "output_schema": { "type": "object", "properties": { "summary": {"type": "string"}, "keywords": {"type": "array", "items": {"type": "string"}} }, "required": ["summary", "keywords"] }, "handler": "handlers/summarize.py", "timeout_ms": 15000, "allow_parallel": false, "required_context": ["user_id", "timezone"] }这里我想重点解释几个容易忽略的字段:
- description 是给大模型看的,不是给人看的。所以必须写清“什么时候用”和“什么时候别用”。比如“不要用于代码解释”这个反例,能让误调用率肉眼可见地下降。
- input_schema 越严格越好。不要贪方便把所有参数都设为可选,一旦允许模型自由发挥,它就会传出一堆你没定义的字段。我习惯把必填字段设成 required,并给每个属性一个贴近自然语言的描述。
- required_context 表示这个技能在执行时需要访问哪些会话级信息。比如“工作计划”技能需要知道用户所在时区,那么这个字段就声明了上下文依赖。注册中心会确保这些信息在调用前就被填充好。
- allow_parallel 控制当前技能能否和其他技能并发执行。涉及修改共享状态的技能必须设为 false。
2.2 技能的生命周期:注册-发现-调用-回收
技能并不只是在配置里写一段描述就行了,它有一个完整的生命周期。我在 agent-skills 里把它分成四步:
第一步是注册。开发者写好配置和执行脚本后,调用 Registry 模块完成注册。注册时会校验配置合法性、检查 handler 文件是否存在、检查 import 是否成功、输入输出 schema 是否能被 JSON Schema 校验器正确解析。任何一步失败,技能都不会上线,避免“配置能写但跑不起来”的尴尬。
第二步是发现。系统启动时,会把所有技能的 description 文本做成向量索引。当用户消息到达,技能选择器计算消息向量与每个技能描述向量的相似度,再结合关键词匹配和人工设置的门控规则,得到候选技能列表。这一步的目标不是选出最正确的技能,而是把范围从几十个缩小到三五个,把最终决策权交给大模型。
第三步是调用。技能被选中后,执行器把模型生成的参数解析成标准 JSON,严格按 input_schema 做校验。校验通过后,如果技能声明了 required_context,就从会话状态中补全缺失字段。然后 executor 以子任务或子进程方式执行 handler,设置超时上限。
第四步是回收。无论调用成功还是失败,执行器都会写日志,释放临时资源,把结果格式化成统一的结构返回给 Agent。如果技能内部创建了临时文件,还要在 finally 块里清理掉,否则跑几天存储就会爆掉。
这四步把技能的整个生命周期都管住了,之后做权限控制、审计、限流也都有地方下手。
3. 手把手实现一个“文档摘要技能”
3.1 定义技能文件:先写给人看的说明
假设我现在要给 Agent 加一个“文档摘要”技能。按照 agent-skills 的约定,我先创建技能目录:
skills/ doc_summarize/ skill.json handlers/ summarize.py __init__.pyskill.json 我参考了前文的通用 Schema。这里最花心思的是 description 的措辞。我第一版写的是“用于总结文档”,结果发现模型经常把“翻译一段英文”也识别成总结任务。后来改成:
当用户需要对邮件、报告、网页文章、会议记录等连续文本生成摘要、提取要点、压缩篇幅时使用。该技能不负责翻译,不负责代码解释,不负责问答。加上反例之后,误选率从 34% 降到了 11%。这一步充分证明了:技能的“说明书”质量直接决定了 Agent 会不会乱用技能。
3.2 用 Python 实现 handler 并注册到 Agent
handler 是真正干活的代码。结合我经常用的模型服务,抽象一个 llm.chat() 接口,任何提供文本生成的模型都能接入。我把 handler 实现为:
import json import os from typing import Dict, Any # 假设你已经在项目初始化时创建了 llm 客户端,例如: # from your_llm_client import get_client # llm = get_client() def split_text(text: str, chunk_size: int = 1200) -> list: # 简单分块,避免超出模型上下文上限 return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] def summarize_chunk(chunk: str, language: str) -> str: prompt = f"请用{language}总结以下内容,要求保留核心信息,不超过150字:\n{chunk}" response = llm.chat(messages=[ {"role": "user", "content": prompt} ], temperature=0.3) return response.strip() def summarize_text(text: str, max_length: int, language: str) -> Dict[str, Any]: chunks = split_text(text) if len(chunks) == 1: summary = summarize_chunk(chunks[0], language) else: # 多分段先各自总结,再整体压缩 partial_summaries = [summarize_chunk(c, language) for c in chunks] joined = "\n".join(partial_summaries) summary = summarize_chunk(joined, language) return { "summary": summary[:max_length], "keywords": extract_keywords(summary) } def extract_keywords(text: str) -> list: # 简化实现:按分隔符切分并去重 parts = text.replace(",", " ").replace("。", " ").split() seen = set() result = [] for p in parts: if p not in seen: seen.add(p) if len(p) > 1: result.append(p) return result[:8] def handle(params: Dict[str, Any]) -> Dict[str, Any]: text = params["text"] max_length = params.get("max_length", 200) language = params.get("language", "中文") # 做一层防御性校验,即使外部校验漏了,内部也不能崩 if not isinstance(text, str) or len(text) == 0: raise ValueError("text 字段必须是非空字符串") return summarize_text(text, max_length, language)如果技能需要被当成独立服务调用,handler 的入口函数是 handle(),参数就是 input_schema 里声明的对象。执行器会把返回值按 output_schema 序列化。这里我用了非常简化的 extract_keywords,生产环境可以替换成更成熟的关键词抽取方案,但演示足够。
注册到 Agent 主循环的关键代码如下:
from skill_engine import Registry, AgentRuntime registry = Registry() registry.register_from_directory("skills/doc_summarize") runtime = AgentRuntime(llm=llm, registry=registry) user_message = "帮我总结这封会议邀请的要点:..." response = runtime.run(user_message) print(response)AgentRuntime 内部会做我刚才说的发现和调用两步。我写了一个极简的伪代码示意:
def run(self, user_message): candidate_skills = self.selector.select(user_message, top_k=5) prompt = self._build_prompt(user_message, candidate_skills) action = llm.chat(messages=[{"role": "user", "content": prompt}]) skill_id, args = self._parse_action(action) skill = self.registry.get(skill_id) result = skill.execute(args) return self._format_result(result)实际项目中 _parse_action 通常会走模型的原生函数调用能力,或者微软语境里的结构化输出,这里不展开细节。只要保证参数以 JSON 形式稳定返回,后面的流程就能通用。
3.3 技能组合示范:摘要 + 关键词提取 + 翻译
单个技能能解决单一任务,但真实需求往往是组合的。比如用户说“把这份英文报告总结成中文,并找出里面的三个重要数字”。一个摘要技能搞不定,因为还涉及翻译和数字抽取。agent-skills 里的做法是做一个“编排型技能”,它自己不写具体逻辑,而是把流程拆成多个子技能依次调用。
编排技能的执行逻辑大致是这样:
def handle(params): source_text = params["text"] summarize_result = call_skill("doc_summarize", { "text": source_text, "max_length": 500, "language": "中文" }) translate_result = call_skill("text_translate", { "text": summarize_result["summary"], "target_lang": "中文" }) numbers_result = call_skill("extract_numbers", { "text": summarize_result["summary"], "limit": 3 }) return { "translated_summary": translate_result["translated_text"], "key_numbers": numbers_result["numbers"] }组合技能的好处是每个子技能可以单独测试、单独升级。我只需要把“摘要”技能里的模型换掉或者把输出格式改一下,涉及摘要的所有编排任务都自动受益。
组合时最难的是上下文传递。我踩过一个大坑:子技能返回的结构里字段名改了,编排脚本没同步改,结果线上静默失败。所以我现在会在编排技能输出 Schema 里给每个字段加上 source 注释,同时写一个冒烟测试脚本,每次改动都跑一遍。
4. 踩过的坑与排查实录
4.1 常见问题速查表
我把项目里高频出现的问题整理成一个速查表,基本覆盖了新手能碰到的大部分状况:
| 问题现象 | 可能原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| 模型选了错误的技能 | 技能描述不够精确,正反例没写清楚 | 调出选择器的日志,看候选技能相似度 | 重写 description,加入“不要用于”的否定项 |
| 技能报参数校验错误 | input_schema 太宽松,模型传了多余字段 | 打印模型生成的原始参数 | 开启 strict 模式,拒绝未知字段 |
| 长文本经常超时 | handler 没有分块,或调用了多次模型服务 | 看超时日志中的 input 长度 | 增加分块逻辑,在技能内部做重试 |
| 两个技能同时修改同一个文件 | allow_parallel 没设 false | 检查技能并发标志 | 把 allow_parallel 设为 false,或引入任务锁 |
| 模型“一本正经”地编造技能返回结果 | Agent 没绑定真正的行为结果 | 核对 Agent 主循环是否经过执行器 | 强制把执行结果注入到最终回复,禁用模型直接编造 |
| 技能升级后行为不一致 | 没有做版本管理 | 查看当前 skill.json 的版本 | 在注册中心保留历史版本,并加 changelog |
这里我特别想强调最后一行。技能是会迭代的,绝不能在原文件上反复改。我给技能包的版本号定了规则:改动输入输出结构属于破坏性升级,大版本加 1;只优化 prompt 逻辑属于小版本,小版本加 1。这样出了问题可以快速回退到上一个可用版本。
4.2 一个典型的“技能冲突”调试过程
有段时间我的两个技能反复“打架”:一个是“日期计算”,负责回答“三天后是几号”;另一个是“日程安排”,负责帮用户“安排在三天后”。用户明明说的是“三天后帮我预约个会议”,模型却经常调用日期计算技能,返回一个日期文本就草草结束,根本没有创建日程。
一开始我以为是模型能力不够,后来打开了技能选择日志。日志显示“日期计算”和“日程安排”在用户问题上的向量相似度非常接近,因为两个技能的描述里都提到了“三天后”“时间”这些词。再往下看,模型在选择时把两个技能的 description 的尾部内容当成了关键信息,而我的日期计算描述里又写了“可以帮忙计算某个日期的偏移”,这就让模型误以为它有日程安排的权限。
修复方案有两步。第一步,在两个技能 description 的结尾都加上边界条件:
日期计算:只回答“X天后是几号”“某个日期是星期几”,不负责创建、删除、修改日程。 日程安排:只负责创建和修改日程,如果需要计算日期本身,请先调用日期计算技能。第二步,给技能选择器加了一个门控规则:当两个候选技能相似度差距在 0.03 以内时,优先选择带“创建/修改动作”语义的技能。这条规则一开始很粗暴,但确实把误选率压下来了。后来我改成了基于实体识别的判断:用户消息里出现“预约、安排、提醒”等动词时,日程安排技能的权重自动加 0.15。
这个案例让我意识到,技能调度不是纯 embedding 能解决的,需要结合领域规则和实际业务逻辑。技能库不仅仅是代码仓库,也是一个人机协作的知识库。
4.3 几个优化小技巧
第一,技能描述里一定要写“反例”。正面说明只能告诉我们“这是什么”,反例才能降低模型的心智负担。我给所有技能统一加了一句“不要用于……”,效果显著。
第二,handler 内部要防御性编程。即使 input_schema 校验过了,也保不齐出现极端情况,比如空字符串、超大数字。我在所有 handler 入口加 isinstance 和长度校验,成本低但能把线上崩溃概率降一半。
第三,给技能设置超时要区分“外部依赖”和“内部计算”。如果技能是调用第三方服务,超时容忍度可以放宽到 15 秒;如果是纯本地计算,5 秒还没完成基本就是死循环了。我把 timeout_ms 字段单独拎出来,方便运维同学看监控。
第四,技能启动时做预热。比如技能需要加载一个离线模型或向量索引,预热之后首次调用延迟能从 3 秒降到 300 毫秒。agent-skills 的 Registry 里加了一个 warmup() 钩子,框架会在服务启动完成后自动调用所有技能包的预热函数。
5. 从个人项目到可复用的平台:扩展建议
5.1 技能评估体系
当技能超过十个,靠肉眼判断质量已经不够了。我做了一个简单的评测集,专门测“模型能不能选对技能、选对了能不能执行成功”。评测集结构就是一个 JSON 列表:
[ { "input": "帮我总结一下这篇新闻文章", "expected_skill": "doc_summarize", "expected_status": "success" }, { "input": "今明两天气温变化是多少", "expected_skill": "fetch_weather", "expected_status": "success" } ]评测脚本批量跑用户输入,分别记录技能选择准确率、参数校验通过率、执行成功率、最终答案相关性四个指标。每次修改技能,我只需要跑一遍评测集就能知道有没有引入回归。四个指标里我最看重“选择准确率”,因为这一步错了后面全白搭。我会定期人工 review 评测集里失败样例,把错题原因归结到描述、门控规则还是 handler,再针对性地优化。
5.2 多 Agent 场景下的技能共享
agent-skills 目前是单进程内嵌的框架。如果要做成平台,我最建议先拆出一个“技能中心服务”,让不同 Agent 通过远程方式调用同一个技能库。技能中心提供三类接口:
- 注册接口:给开发者提交技能包。
- 发现接口:给 Agent 返回候选技能元数据。
- 执行接口:让 Agent 把技能 ID 和参数发过来,由中心统一执行并返回结果。
这个模式下,权限模型要提前设计。我把技能权限分为三种:只读(例如查询天气)、写操作(例如发送邮件)、高危操作(例如删除文件)。Agent 在发起调用时带上自身身份凭证,技能中心根据身份判断它是否有权执行对应等级的操作。
并发冲突在多 Agent 场景下会更严重。我在技能中心内部为每个技能维护一个互斥锁,如果 allow_parallel 为 false,同一时间只允许一个 Agent 调用,其他请求排队。一开始会有点性能损耗,但换来的状态一致性非常值得。
5.3 后续能玩的方向
技能市场是我觉得最有想象力的方向。开发者把技能包打包上传,其他 Agent 可以通过接口订阅。这里的关键是技能签名和校验,保证你调用的技能代码没有被篡改。
另一个方向是让技能本身具备版本升级之外的 A/B 测试能力。同一个技能可以发布两个版本,Agent 按用户分桶调用不同版本,指标好的版本自动扩容。这个思路在做推荐类的 Agent 时尤其有用。
还有一个偏产品层面的方向:以技能为中心构建自动化工作流。现在的 Agent 是“用户提出需求,Agent 调技能”,但更高效的模式是“某个事件触发,工作流自动执行多个技能”。比如收到新邮件后,先调用文档解析技能提取附件,再调用表格技能更新数据,最后调用通知技能在群里播报。这套东西放在 agent-skills 上并不复杂,因为你已经有了注册中心和编排能力,只需要加一个事件调度器。
最后分享一个我个人的体会。最早我把 Agent 当成一个“会聊天的模型”,后来才发现真正让 Agent 产生生产力的,不是模型的推理本身,而是它手边有大量高质量、可靠、可重用的技能。写技能包的过程,其实是在把组织里零散的“经验”固化下来。所以每当我新认识一个能提高效率的小能力,我都会问自己:它够不够格做成一个技能?如果够,就动手写一份 skill.json,把使用说明和反例写清楚。每次写反例的时候,我都觉得像是给未来的自己留了一盏警示灯。
再补一个特别实际的建议:写技能的时候先写“这个技能不适合做什么”,再写“适合做什么”。我测试下来,单凭这一条,模型误调用率能下降一半。这个技巧没在任何官方文档里见到过,但非常值得一试。