agent-skills 是我最近整理的一套智能体技能管理方案。起因很朴素:我受够了每次把十几个工具定义硬塞进Prompt的做法,工具一多,模型就开始选择困难,要么反复试错,要么干脆用错工具。如果你也在做Agent应用,大概率遇到过跟我类似的尴尬。这篇文章就是我对 agent-skills 这次重构的完整复盘,从技能目录怎么设计、调用链路怎么打通,到多技能协作、安全边界和几个真实踩坑记录,希望能给正在自己搭Agent的同学一份可以直接参考的路线图。
1. 为什么Agent需要一套"技能"体系
1.1 我给Agent塞工具的血泪史
刚开始做Agent的时候,我的做法和大多数入门教程一样:把每个工具函数的名字、描述、参数JSON Schema全部写进system prompt,告诉模型"你有这些工具可以用,需要时按格式返回调用"。前五个工具的时候一切正常,模型基本能按要求选对。等我把搜索、网页提取、邮件、数据库查询、待办管理这些能力陆续接进去,Prompt很快就膨胀到了两万多字符,模型的表现却开始变得不稳定。
最典型的一次,用户问"帮我找一下上周提到的那篇关于LLM推理成本的文章",模型连续两次在搜索工具和文档工具之间来回切换,第一次参数填了纯关键词,第二次把文档ID当成搜索词,最后干脆编了一个并不存在的链接。我意识到问题不在模型本身,而在于提示词里信息过载:当十几个工具定义排在一起,模型需要同时完成"理解意图、筛选工具、填对参数"三个任务,出错率必然上升。更难受的是,业务代码里到处散落着工具调用逻辑,换个项目就得把相似代码重写一遍。
后来我参考了Agent Skills的思路,把所有可复用的能力重新设计成一个个"技能单元"。所谓Agent Skills,简单说就是把领域知识、操作步骤、参数校验和调用约束打包在一起,让模型看到的不再是赤裸裸的函数定义,而是"在什么情况下使用、怎么用、期望得到什么结果"的完整说明。它比function calling多了一层封装,这层封装恰恰是工程稳定性的关键。
1.2 从function calling到Agent Skills,变化在哪里
很多人会把function calling和Agent Skills混为一谈。function calling是模型平台定义的一种接口协议,明确规定模型可以在生成文本时输出一个结构化的函数调用请求。Agent Skills更像应用层的一次抽象:它不关心底层是哪个模型平台的具体格式,它关心的是"这个能力怎么描述才能被模型准确选择""执行入口在哪里""需要哪些权限"。你可以把function calling理解成一把螺丝刀,而Agent Skills是给这把螺丝刀写的作业指导书,上面写着"拧M4螺丝时用、拧塑料件时别用、扭矩不要超过2N·m"。
这个差异决定了使用方式完全不同。以前我在Prompt里堆函数定义,模型看到的是一张扁平的清单;现在我把技能描述放进路由Prompt,模型选择的是一个能力完整、边界清晰的候选集。技能的描述里可以带着领域提示和反例,这是函数定义很难表达的。举例来说,"网页正文提取"这个技能,描述里除了写"提取网页正文",我还会写"仅适用于在线网页,不适用于本地PDF、图片或需要登录的站点"。模型看到这样清晰的边界,选错概率会低很多。
1.3 agent-skills 想解决的三件事
我做agent-skills的初衷并不是要做一个大而全的Agent框架,而是想把工程中最容易乱的几个环节固定下来。第一是目录化管理,每个技能一个文件夹,自带描述文件、实现代码、依赖清单和测试,版本清晰,可以单独review。第二是动态路由,不把全部技能塞进同一个Prompt,而是先通过轻量检索缩小候选范围,再让大模型在候选技能里做最终决策,这样既省token,也减少干扰。第三是统一执行护栏,权限声明、超时控制、日志采集都在Runner层做掉,业务代码不需要关心这些细节。
这套方案也可以和现有的Agent框架配合使用。我自己一开始是直接嵌在业务代码里,后来发现把它独立成一个"技能中间层"反而更通用——上层是任意规划器,下层是技能实现,中间通过标准化的技能描述和执行接口解耦。语言模型可以换,技能库可以继续用。
2. 技能长什么样——agent-skills的目录结构与描述规范
2.1 一个技能就是一个文件夹
在agent-skills里,每个技能独占一个目录,目录名就是技能名。一个标准技能目录长这样:
skills/ web_search/ SKILL.md search.py requirements.txt tests/ test_search.py content_extract/ SKILL.md extractor.py requirements.txt tests/ test_extractor.pySKILL.md是技能的身份证,实现代码是技能的大脑,requirements.txt记录依赖,tests目录里放着冒烟测试。我选择用文件夹而不是单个JSON,原因很实际:技能可能包含几十行甚至上百行执行逻辑,还要挂私有依赖,如果都塞进一个JSON文件里,维护起来会非常痛苦。文件夹结构让每个技能可以独立测试、独立发版,用版本管理工具跟踪改动时,影响范围也一目了然。
还有一个容易被忽略的好处:依赖隔离。假设一个技能需要处理表格数据,另一个技能只做HTTP请求,如果把它们放在同一个依赖清单里,环境中总是堆积着大量根本用不到的包。agent-skills允许每个技能声明自己的依赖,安装时由Runner统一收集去重,虽然做不到完全隔离,但至少能避免大部分版本冲突。
2.2 SKILL.md 才是灵魂
技能目录里最值得花时间写的文件就是SKILL.md。它不只是给开发者看的文档,更是给模型看的"使用手册"。我用的SKILL.md是YAML加一段自由文本的结构,核心字段包括name、description、when_to_use、when_not_to_use、parameters、entrypoint、permissions和examples。下面这个例子是我常用的简化模板:
--- name: web_search description: 通过搜索引擎检索公开网页信息,适合用来获取实时动态、最新资讯、特定关键词的相关结果。 when_to_use: - 用户需要查找最新资料 - 用户问题涉及当前时间敏感的事实 when_not_to_use: - 用户上传本地文件并要求解析文件内容 - 问题源自内部知识库且不需要网络 parameters: type: object properties: query: type: string description: 检索关键词,尽量简洁 max_results: type: integer description: 最多返回多少条结果 default: 5 required: - query entrypoint: search.py::main permissions: network: true files: false shell: false examples: - user: 帮我查一下2025年大模型推理优化有什么新进展 call: query: 大模型推理优化 2025 进展 max_results: 5 ---这里面最值得强调的是when_not_to_use字段。刚开始写技能描述时我完全忽略了它,导致好几个技能的描述都只是重复"提取内容"这类宽泛能力描述,模型遇到具体任务时根本不知道选谁。后来我在每个技能里都强制加入"什么时候不要用"的说明,模型选错技能的概率才降下来。examples字段同样重要,它给模型提供了最直观的对齐方式,尤其在参数填法容易产生歧义时,"一个示例胜过三行参数描述"。
parameters字段我推荐遵循JSON Schema的常用子集,但不要写得太死。URL字段不要加format约束,文本字段不要设置超短的长度限制,required里只保留真正调用时必有的字段。原因是模型生成参数时会受到Schema约束,约束越严格,模型越容易被"卡"住,宁可让技能内部做宽松处理,也不要让前置校验成为失败源头。
2.3 技能注册表与依赖管理
光有文件夹还不够,Runner需要知道系统里到底有哪些技能。我保留了一个顶层注册表skills.yaml,记录每个技能的名称、版本、启用状态和入口。Runner启动时扫描skills目录,读取每个技能目录下的SKILL.md,构建一个技能索引,后续所有路由决策都基于这份索引。
def build_skill_index(skills_dir): index = [] for skill_path in skills_dir.glob("*/SKILL.md"): skill = parse_skill_meta(skill_path) index.append(skill) return index这个函数非常简单,但背后有一个设计选择:我不在代码里硬编码技能列表,而是让技能库自我描述。这样新增技能时不需要改Runner代码,只要按规范放一个文件夹进去,注册表就会自动发现它。依赖的安装交给Runner在部署阶段统一处理,先扫描所有技能目录下的依赖清单,收集后安装,遇到同包不同版本时以注册表里声明的版本为准,并把冲突记录到日志。
3. 从用户请求到技能执行的完整链路
3.1 先粗筛,再精选
技能路由我采用了两阶段设计:先粗筛、再精选。所谓粗筛,就是用关键词或向量召回的方式从全部技能中挑出5个左右候选;所谓精选,是把这些候选技能的描述交给大模型,让模型从候选里选择真正该用的技能,并填充参数。这样做的原因是控制信息量:如果系统里有50个技能,把50份SKILL.md全部塞给模型,不仅浪费token,还会让模型在相似技能之间晕头转向。而先缩小到5个候选,模型的决策压力小很多。
粗筛我一开始用的是最简单的关键词权重匹配,后来技能多了才换成向量检索。这里我想表达一个观点:不要为了技术炫技而提前上向量库。二三十个技能的量级,关键词匹配完全够用,而且可解释性强。下面是一个简化示例:
def retrieve_candidates(request, skill_index, top_k=5): scores = {} for skill in skill_index: score = 0 for token in request.split(): if token in skill["keywords"]: score += 1 scores[skill["name"]] = score ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [name for name, s in ranked if s > 0][:top_k]实际项目中,我会把SKILL.md里的description、when_to_use和examples里出现的专业词汇提取出来作为keywords,再按命中数量打分。这个方法很基础,但稳定。
3.2 让模型输出结构化的调用意图
粗筛得到候选技能后,下一步就是把候选技能的描述和用户请求一起交给模型,要求它输出一个结构化调用指令。我用的prompt模板大概是这样的:
你是Agent的技能路由器。用户请求如下: {user_request} 可用技能如下: {candidate_skills} 请从候选技能中选择一个最合适的技能,并填入参数。 只允许选择候选中出现的技能名称,不允许自行发明。 以JSON格式返回: {"skill": "技能名", "arguments": {"参数名": "参数值"}, "confidence": 0.0-1.0} 如果无法决定,返回{"skill": null, "arguments": {}, "confidence": 0.0}这个模板里有个容易忽略的细节:我要求模型返回confidence字段。表面上它没什么用,但实际调试时非常方便。当模型给某个技能的confidence低于0.5,而用户任务又比较开放,我会让Agent先向用户确认一句"我理解你想做的是XX,对吗",而不是直接执行。这能避免很多因为理解偏差导致的无用调用。
参数填充和技能选择放在同一个模型调用里完成,前提是候选技能的SKILL.md已经把parameters写得足够清楚。我之前试过把选技能和填参数分成两次调用,准确率确实高一点,但延迟和成本都上去了,后来发现只要技能描述规范,一次调用完全够用。
3.3 Runner执行与结果回填
路由完成后,执行阶段交给Runner。Runner负责加载技能入口、检查权限、设置超时、捕获异常,并把执行结果规范化成统一结构。核心执行函数大致长这样:
async def run_skill(skill_name, args): skill = get_skill(skill_name) check_permissions(skill, args) async with timeout(skill.timeout): result = await skill.invoke(args) return normalize_result(result)normalize_result会把所有技能返回的内容转换成固定的结构,包括status、content、meta三部分。status只有ok或error两种取值,content是给模型回填的正文,meta里可以放来源、耗时、置信度等信息。这个统一结构让上层的规划器不用关心每个技能返回格式的差异,多技能协作时才走得通。
还有一个很多人会忽略的点:技能返回的内容可能很长,如果直接把几万字原文回填给大模型,上下文窗口很容易爆掉。我一般会在技能内部先做一次自摘要,或者在Runner里对大content做截断,比如默认保留前8000字符,并让技能在meta里给出"是否截断"的标记。回填时保留原文,不做二次改写,因为任何改写都可能引入幻觉。
4. 多技能协作——任务拆解、上下文流动与冲突处理
4.1 编排模式:把复杂任务拆成技能序列
单个技能能解决的问题有限,真实场景里用户往往一句话背后藏着好几个步骤。就拿"帮我整理近一周AI Agent领域的重要动态,并输出一份带要点的摘要"来说,这个任务至少需要三步:先用搜索技能找到相关文章,再对文章做正文提取,最后把提取到的内容交给摘要生成技能。要支持这种链路,Agent得先具备任务拆解能力。
我采用的方式是先规划后执行:模型先根据用户请求生成一个步骤列表,每个步骤绑定一个技能和参数,参数里可以引用前面步骤的结果。规划结果用JSON表示,类似这样:
[ { "step": "search", "skill": "web_search", "arguments": {"query": "AI Agent 最新动态 周报", "max_results": 10} }, { "step": "extract", "skill": "content_extract", "arguments": {"urls": ["${step.search.results[0].url}"]} }, { "step": "summarize", "skill": "summary_generator", "arguments": {"documents": "${step.extract.content}"} } ]参数里的${step.search.results[0].url}是模板变量,Runner在执行前会把它解析成上下文里真实的值。这里有一个设计原则:技能和技能之间不直接传内部对象,所有中间结果都放在统一的上下文缓存里,通过模板变量引用。这样每个技能保持纯函数式输入输出,调试时只要看上下文缓存就能知道数据流向。
4.2 自主规划的一个折中方案
完全让模型自由规划再自由执行,在复杂任务里很容易失控。我采用的折中方案是两步走:第一步让模型生成一份完整计划,第二步按计划逐项执行。每执行完一步,把结果追加到上下文,并让模型判断这一步结果是否符合预期,符合才进入下一步,不符合就修正计划。执行循环的伪代码如下:
for step in plan.steps: args = resolve(step.arguments, context) result = await run_skill(step.skill, args) if not validate_step_result(step, result): corrected_plan = await planner_correct(plan, result, user_request) break context.update(step.name, result)validate_step_result是我后来加的一个重要环节。之前没有它时,一旦某个技能返回空内容,Agent还是会硬着头皮往下走,最后生成一份空洞的摘要。现在我在验证阶段会检查content是否为空、meta里有没有error标记,只要有问题就暂停执行并通知用户,有时候直接让用户补充信息比Agent自己瞎猜更高效。
同时我设置了最大步骤数,默认6步,防止模型规划出一个几十步的长链条然后中途死循环。到达上限时,Agent会带着已经完成的部分结果给用户一个阶段性输出,并明确告知哪些步骤没执行完。这个"可中断"的设计,比追求全自动更符合实际工程需要。
4.3 冲突与异常处理
多个技能返回的信息不一致时,不能假装没看见。比如用户问"某产品的开源协议是什么",搜索技能返回的是MIT,数据库技能里记录的是另一个许可证版本,这种冲突如果直接取前面的结果,可能让答案出现事实性错误。我的做法是:让技能返回时带上meta中的来源置信度和时效性信息,然后由模型在生成最终回复时综合判断;如果冲突无法调和,就如实告诉用户"不同来源存在矛盾,建议进一步核实"。
技能执行失败也需要准备降级链。下面这张表是我常用的降级策略:
| 失败情况 | 降级动作 |
|---|---|
| web_search超时 | 尝试备用搜索源,再失败则返回"暂时无法获取最新数据" |
| content_extract解析失败 | 尝试用备用正文提取算法重试一次,失败则返回原始URL让用户自查 |
| summary_generator返回空 | 直接返回提取的正文,不强行生成摘要 |
| 权限校验拒绝 | 明确告知Agent和用户"该操作需要人工授权" |
降级的核心思路是:宁可输出完整的过程性结果,也不要让流程因为一个环节失败而全部静默。把失败暴露出来,用户才知道发生了什么,Agent也不会在错误的道路上越走越远。
5. 一次调用失败引发的排查——技能描述、参数校验与热更新的坑
5.1 模型总是选错相似技能
排查案例可能是这套体系里最有价值的部分。当时我同时接入了两个技能:web_extract负责在线网页正文提取,pdf_extract负责本地PDF内容解析。问题表现是,用户连续几次拿PDF文件要求提取内容,模型都调用了web_extract,导致文件信息为空。看路由日志后发现,两个技能的描述在语义上几乎重叠,都写着"提取内容并返回正文",未说明输入类型差异,模型只能靠猜。
修复方法是重写两个SKILL.md的描述。我在web_extract的when_not_to_use里明确写上"不接受本地PDF、图片、离线文件",在pdf_extract的description里加上"用于解析用户上传的PDF文件,适合合同、论文、报告等PDF格式材料",并各自补了一个示例。改完后同场景下模型选错技能的概率从大约30%降到了5%以内。这个案例给我一个判断标准:如果两个技能经常被模型选混,先不要急着加更复杂的路由逻辑,先看它们的描述是不是真的能从语义上区分开。
5.2 参数校验太严格,真实请求直接卡死
另一个坑出在JSON Schema参数校验上。我给content_extract技能的url参数定义了format规范,认为这样可以保证参数合法。结果用户经常给出一串不带协议的文章ID,比如"articles/2025/agent-skills",模型按规则填这个值时,前置校验直接拒绝执行。我一开始以为是模型生成参数不够规范,后来发现是校验规则设计得不合理:前置Schema只能做类型级别的粗筛,不该承载业务层的语义校验。
修复方案是把format字段全部去掉,只保留type和required,然后在技能内部增加一个参数归一化函数,遇到不带协议的字符串自动补上协议头,补不了就返回明确错误。同时把必填字段降到最少。现在我的参数设计原则是:前置校验宽松,业务约束后置,默认值能兜底。这样模型不容易因为一点点参数格式问题就触发整段流程失败。
5.3 热更新后执行的还是旧代码
开发阶段最诡异的问题之一:改了技能实现文件,重新请求时Runner执行的还是旧逻辑。我第一次遇到时怀疑是模型缓存,于是直接打印入口函数的地址,发现函数对象没变,才意识到技能模块在加载时被缓存了。检查代码后发现,加载器用了运行时的模块缓存,技能文件更新后时间戳确实变了,但模块已经在缓存表里,不重新加载就永远走旧实现。
修复方案是在SKILL.md里增加version字段,并在技能索引中记录加载时的version和入口签名。Runner每次执行前对比当前磁盘上的SKILL.md和缓存索引的version与入口签名,只要不一致就重新加载模块。核心判断逻辑很简单:
def should_reload(skill, cached_meta): return skill.version != cached_meta.version or \ skill.entry_signature != cached_meta.entry_signature这个坑很多人在开发环境下会碰到,但很少有人会想到去查模块缓存。加了版本对比之后,技能热更新从"偶尔生效"变成了"必然生效"。
6. 把技能库做成稳健的小系统——质量度量、安全边界与维护节奏
6.1 用数据衡量技能好不好用
技能库要越用越稳,不能只靠感觉。我在Runner层给每一次技能调用都写结构化日志,字段包括技能名称、参数摘要、耗时、是否成功、错误类型、用户是否在结果后继续追问。这些日志汇总后就能算出每个技能的使用频率、成功率、平均耗时和"用户追问率"。追问率高往往意味着技能返回的结果不够符合预期,即使它技术上执行成功了。
我有两个常用指标:误用率和空回率。误用率是模型调用了该技能但用户反馈"不对"的占比,空回率是技能执行成功但content内容为空的占比。每周我会扫一眼这两个指标,把连续两周空回率高或者调用次数极低的技能找出来,要么补测试重写,要么直接下线。这套数据驱动的维护方式很朴素,但比看代码直觉可靠得多。
6.2 安全边界不能靠自觉
Agent技能的安全边界是最不能省事的部分。每个技能在SKILL.md里声明需要的权限,Runner在加载时读取这些声明,并且在执行前做强制检查,而不是信任技能代码自身。需要网络访问的技能只能走配置好的超时网络出口,需要文件读写的技能只能访问指定的工作目录,默认情况下shell权限关闭。下表是我常用的权限策略:
| 权限 | 默认策略 | 需要特别注意的情况 |
|---|---|---|
| network | 允许出站,但限制超时 | 访问内部服务地址必须拒绝 |
| file | 仅允许工作目录内访问 | 需要检测路径穿越 |
| shell | 默认关闭 | 仅白名单命令可以执行 |
| sensitive_operator | 需要二次确认 | 发送邮件、删除文件、修改数据 |
我还会把敏感操作单独拎出来,不允许任何技能静默执行。比如一个"发送邮件"的技能,Runner在执行前一定会输出待确认的操作摘要,并等待人工授权。这个等待函数在自动化流程里看似麻烦,但它是防止Agent在错误理解用户意图后造成不可逆操作的最后一道闸门。
6.3 新技能加入流程与维护节奏
agent-skills运行一段时间后,我总结出一套新技能的加入流程:先写SKILL.md,再写实现代码,再补测试,最后注册。这个顺序很重要,因为写描述的过程就是思考技能边界的过程。如果一上来就写代码,很容易出现一个功能上没问题、但描述模棱两可的技能,最终沦为路由阶段的高频误用项。SKILL.md写不清楚时,我宁愿不开发这个技能。
维护节奏方面,我会在每个迭代周期末尾做一次技能review:把成功率过低的技能修一轮,把一个月内调用为零的技能下线,把用户高频追问的场景沉淀成新技能。未来如果技能库继续扩大,可以把整个skills目录打包成共享依赖,在多个项目里直接复用;甚至可以用积累下来的技能描述和示例生成一批微调数据,让模型对自家技能的选择更精准。
最后再分享一点个人感受。技能库不是建一次就能一劳永逸的,它更像一个需要持续修剪的花园:定期砍掉没用的技能,给保留的技能补充更清晰的描述和更丰富的示例,它才会越用越顺手。agent-skills这套方案帮我把散落在业务代码里的工具逻辑收拢成了一个可管理、可度量、可演进的小系统,虽然它还有很多粗糙的地方,但至少在"让Agent更可靠"这件事上,它给了我足够的抓手。