如果你最近在折腾 AI Agent 项目,大概率已经接触过 agent-skills 这个叫法。它并不是某个框架的专有名词,而是一类被反复验证过的解决方案:把 Agent 的能力沉淀成可复用、可组合、可进化的技能包。简单说,就是在传统工具调用之上,再加一层“说明书 + 脚本 + 示例 + 边界条件”的完整封装,让 Agent 在遇到某一类场景时,能像人一样翻出对应的操作手册来干活。这篇文章里,我会结合自己实际维护一套技能库的经验,把 agent-skills 的结构设计、实操接入、踩坑记录和迭代方法完整拆开讲一遍。适合正在做 Agent 应用、搞过几个 Prompt 但发现越来越难维护的开发者和研究者参考。
我在写这套东西之前,也走过很长一段弯路:一开始把所有行为逻辑都塞进系统提示词里,结果上下文越堆越长,改一个细节就要重新调一整段描述;后来又把所有能力都包装成函数,但函数只解决了“能调”的问题,没有解决“什么时候调、怎么调、调完怎么校验”的完整链路。直到我把一组能力整理成 agent-skills 这种带元信息、带脚本、带自检清单的技能包,才算真正找到了可维护的形态。
1. agent-skills 是什么?为什么它会被逐渐认可
1.1 技能(skill)和工具(tool)不是一回事
先把概念盘清楚。很多人一听“给 Agent 加技能”,第一反应是“这不就是工具调用吗?”,但这两者的设计粒度差别很大。工具(tool)通常是一个可执行的函数或接口,比如“查询天气”“计算文件大小”“发送邮件”。它解决的是“做什么”的问题,模型只需要判断该不该调用,然后把参数填对就行。
而技能(skill)解决的是“怎么做”的问题。一个完整的技能包至少要包含四部分:
- 一段给人看的描述,说明这个技能在什么场景下适用、什么时候不该用;
- 一个可执行的脚本或工具入口;
- 一份操作步骤或检查清单,指导模型如何一步步完成任务;
- 一组典型输入输出示例,以及边界条件说明。
我用一个生活化的类比来解释:工具是一把菜刀,技能是“做红烧肉的完整流程”。菜刀能切菜,但它不知道肉要焯几遍水、糖色炒到什么程度算好。Agent 也一样,它能调用一个函数,但函数不会自动告诉它“这个函数输出的 JSON 里,如果 status 字段是 failed,下一步应该重试还是直接报错”。这些过程性知识,必须靠技能包来承载。
1.2 为什么过了这么久,大家才意识到技能需要“包”起来
其实不是最近才出现这个概念,而是早年大家把知识都写在 Prompt 里,靠模型的能力去理解长篇大论。那时 Agent 任务单一,Prompt 长一点也扛得住。后来任务变复杂了,一个 Agent 要处理文件、查数据库、写报告、发消息,如果所有规则都堆在一个系统提示词里,上下文很快就会被撑爆,而且改一处描述,可能引发另一处行为的连锁变化。
技能包这种形态,本质上是把“上下文”从模型内部搬到了外部。模型的系统提示词里只留一句话:“遇到以下场景,请查阅对应技能包的使用说明。”这样每次任务进来,只有相关技能的描述和脚本会被加载,无关内容不占上下文。这个思路和程序员把公共代码抽成函数、把配置抽成文件是一样的,只是过去这套方法用在代码工程上,现在用在 Agent 行为编排上。
我自己的实践经验是:一旦技能数量超过 5 个,运维成本就开始直线下降。因为每次改动只影响一个技能包,不用重新过一遍全局 Prompt。到 20 个以上,你就会明显感觉到“技能库”本身已经成了一个小型知识管理系统,需要认真做版本管理和评审。
2. 手把手设计一个高质量技能包:结构、字段与目录规范
2.1 SKILL.md 是整个体系的地基
每个技能包的核心都应该是 SKILL.md,它负责向 Agent 解释“我是谁、什么时候用我、用我的时候要注意什么”。别小看这个文件,它的质量直接决定技能会不会被错误触发,以及触发后执行得顺不顺。
我习惯用规范化的 frontmatter 作为元信息,然后在正文里用固定章节表达细节。一个典型的结构如下:
--- name: json_recover description: 用于修复损坏的 JSON 文本,处理截断、多余逗号、单引号混用等常见问题。 when_to_use: 当输入是一段解析失败的 JSON 字符串,或用户明确要求“修复 JSON”时。 version: 1.2.0 --- # json_recover ## 适用场景 - 模型输出被截断导致的 JSON 括号不闭合 - 字符串里混用了单引号和双引号 - 数组或对象末尾多了逗号 ## 不适用场景 - 输入是流式 JSON(streaming JSON),应改用 json_stream_parser 技能 - 输入根本不是一个 JSON,而是普通的文本 ## 操作步骤 1. 尝试用标准 JSON 解析器解析,记录第一个异常位置 2. 根据异常位置判断错误类型 3. 调用 scripts/repair_json.py 处理 4. 校验修复结果,如果仍失败,将原始文本完整保留并报错,不强行“脑补”内容 ## 输入输出格式 - 输入:原始 JSON 字符串 - 输出:修复后的 JSON 字符串,或错误信息 ## 示例 ### 正确示例 输入:{"name": "张三", "age": 30,} 输出:{"name": "张三", "age": 30} ### 错误示例 输入:{"name": "张三", "age": "三十"} 输出:应报错,不应将“三十”改成 30这些字段不是随便写的。模型在调用技能前,通常会先根据 description 做一次语义匹配,所以 description 里必须包含“触发词 + 业务场景 + 输入类型”。而 when_to_use 字段,对路由模块来说就像一道门槛,能有效防止模型在相似但不合适的场景里误用技能。
2.2 完整技能包的目录规范:谁能在 30 秒内接手
单个 SKILL.md 还不够,一个能长期维护的技能包,还要配套一套规整的目录。我维护技能库时的目录结构大致是这样的:
agent-skills/ ├── skills/ │ ├── json_recover/ │ │ ├── SKILL.md │ │ ├── scripts/ │ │ │ └── repair_json.py │ │ ├── docs/ │ │ │ └── design.md │ │ └── tests/ │ │ └── test_repair_json.py │ ├── sql_guard/ │ │ ├── SKILL.md │ │ ├── scripts/ │ │ │ └── check_sql.py │ │ └── examples/ │ │ └── sample_queries.sql │ └── ... ├── shared/ │ └── logging_config.py ├── tests/ └── README.md这个结构有几个设计原则:
- 层级不超过三层。skills 目录下面只有 skill 名一层,skill 内部再分 scripts、docs、tests。太深的结构会让自动加载器变复杂。
- 命名全部使用 kebab-case。像 json_recover、sql_guard 这种小写加下划线的风格,在跨平台文件系统里最稳。
- 每个技能包自带测试。没有测试的技能,我不允许合并进主分支。因为技能最终会驱动 Agent 做一些真实操作,如果测试不通过,线上故障往往是肉眼可见的。
- 共享代码放在 shared 目录,技能之间不相互调用对方的私有脚本。否则技能之间一旦产生依赖,改一个包就要牵一发动全身,和当初把逻辑全塞进 Prompt 没有本质区别。
2.3 设计技能的边界:一次只做好一件事
技能设计有一个非常容易被忽视的原则:单一职责。一个技能包只解决一类问题,哪怕这类问题下的细节很多,也不要贪多把相邻问题一并收了。
举个例子,你可能经常需要处理 CSV 文件。如果直接把“读取 CSV、清洗数据、生成统计图、导出 Markdown 表格”全写进一个 skill,看起来功能很全,实际使用时会发现:Agent 只是想导出一个表格,却要把整个技能包的所有检查步骤都过一遍,不仅浪费 token,还容易在无关步骤上报错。
正确的拆法是拆成三个独立技能:
- csv_profiler:分析 CSV 结构、字段类型和缺失值,输出数据画像;
- csv_cleaner:按规则清洗脏数据,如去重、修复格式、处理空值;
- csv_to_markdown:把 CSV 转换成指定格式的 Markdown 表格。
每个技能都足够聚焦,模型在对应场景能迅速做出正确选择。而且这三个技能可以自由组合,比如“先画像再清洗”,或者“不画像直接清洗”,灵活性远高于一个巨型技能。
我在实际设计中还会在 SKILL.md 里特意写“不适用场景”,这个章节的作用比“适用场景”更大。模型是概率推理的,如果只告诉它什么时候能用,它倾向把所有相似问题都往这个技能上套;明确写清楚“哪些情况不该用这个技能”,等于给路由逻辑上了一道保险。
3. 实操环节:从零封装一个日志分析技能并接入 Agent 主循环
3.1 需求拆解:把模糊任务变成清晰的技能清单
抽象的设计说多了容易飘,用一个具体例子走一遍完整流程。假设我们要让 Agent 每天自动分析服务日志,提取异常信息,并且生成一份 Markdown 格式的日报。如果直接写一个“日志分析”技能,大概率会失败,因为“分析”这个词太模糊。
我先拆需求,把这一条任务拆成三个技能包:
- log_exception_extractor:扫描日志文件,定位 error、exception、timeout 等关键词,输出包含时间戳、服务名、摘要的结构化异常列表;
- log_summarizer:对异常列表做归类和统计,按类型、频率、影响范围排序,输出一段总结文字;
- markdown_reporter:把总结和列表渲染成带标题、表格、要点的 Markdown 日报。
拆完之后,每个技能都能独立维护、独立测试、独立复用到其他场景。比如今天的日报只需要列表不想看总结,那就只调前两个技能。
3.2 编写 SKILL.md 时的实操细节
以 log_exception_extractor 为例,我在写它的 SKILL.md 时,会刻意控制正文长度。经验表明,SKILL.md 正文控制在 200 行以内最合适。超过这个量级,模型在查询时容易丢失重点。关键信息必须放在“操作步骤”里,而且步骤里不要教模型做事,而是告诉它检查的顺序。
这份 SKILL.md 的核心部分大致长这样:
--- name: log_exception_extractor description: 从应用日志中提取异常事件,生成结构化列表。输入是日志文件路径或日志文本,输出为 JSON 数组。 when_to_use: 用户要求分析日志、排查错误、统计异常,或明确说“找出日志里的报错”时。 version: 1.0.0 --- ## 操作步骤 1. 先读取日志文件末尾 2000 行,如果文件很大,优先使用 scripts/tail_bytes.py 分段读取 2. 按行匹配异常模式关键词:ERROR、Exception、Timeout、Failed、Fatal 3. 对每一条匹配行,提取时间戳(ISO 8601 格式)、服务名(括号内)、错误摘要 4. 按时间倒序排序输出 JSON 数组 5. 如果文件不存在或没有匹配项,返回空数组,不生成错误 ## 输出格式 [{"timestamp": "2025-06-01T10:00:00Z", "service": "auth", "message": "token expired"}] ## 注意事项 - 不要把 warning 级别的内容混入异常列表 - 不去猜测错误原因,只做提取 - 被截断的半行匹配,直接丢弃并记录一条 warning写完描述后,我建议你用至少 5 条真实日志样本来回验证描述,确保模型能稳定命中这个技能而不是误选其他技能。
3.3 接入 Agent 主循环的参考实现
技能包本身只是一堆文件和规范,它要生效,还需要一套加载和路由机制。我自己实现过一个非常轻量的加载器,它做的事情只有三步:扫描目录、解析 frontmatter、注册到一个技能列表里。
from pathlib import Path import yaml SKILLS_ROOT = Path("agent-skills/skills") def load_skills(root: Path = SKILLS_ROOT) -> list[dict]: skills = [] for skill_dir in root.iterdir(): if not skill_dir.is_dir(): continue index_file = skill_dir / "SKILL.md" if not index_file.exists(): continue with index_file.open(encoding="utf-8") as f: text = f.read() parts = text.split("---", 2) if len(parts) < 3: continue meta = yaml.safe_load(parts[1]) skills.append({ "name": meta.get("name", skill_dir.name), "description": meta.get("description", ""), "when_to_use": meta.get("when_to_use", ""), "path": skill_dir, }) return skills加载之后,还要有路由逻辑。我的方案很朴素:先把用户请求和所有技能的 description 拼接,做一个 Embedding 相似度排序,选出 Top 3;再用 when_to_use 字段做一次规则过滤,去掉明显不满足触发条件的;最后把这 1 到 3 个技能的 SKILL.md 全文注入上下文,让 Agent 决定最终调用哪个或哪几个。
def route_to_skills(user_input: str, skills: list[dict], top_n: int = 3) -> list[dict]: # 简化示例:这里用关键词命中做粗筛,实际可以替换成向量检索 hits = [] for skill in skills: score = 0 for token in skill["description"]: if token in user_input: score += 1 for negative in ["不适用", "不要", "不能"]: if negative in user_input: score -= 1 if score > 0: hits.append((score, skill)) hits.sort(key=lambda x: x[0], reverse=True) return [skill for _, skill in hits[:top_n]]这段代码非常简单,但它演示了一个关键点:路由和推理相互配合。路由负责缩小范围,真正的判断权仍然在模型手里。即使我的路由召回错了,只要 SKILL.md 里的“不适用场景”写清楚了,模型也能在最终决策时把它过滤掉。
3.4 验证与测试:技能包也要单元测试
我一直强调测试,这里给出一个具体的参考。每个技能包都至少包含三类测试用例:
- 正常用例:输入标准格式的数据,预期得到标准输出;
- 边界用例:输入空字符串、极大文件、特殊字符,预期不崩溃且输出合理;
- 失败用例:输入完全不符合要求的数据,预期输出明确的错误信息。
我拿 json_recover 技能举例,一个测试用例长这样:
import subprocess import json def test_repair_trailing_comma(): result = subprocess.run( ["python3", "scripts/repair_json.py", '{"a": 1,}'], capture_output=True, text=True ) assert result.returncode == 0 parsed = json.loads(result.stdout) assert parsed == {"a": 1}这类测试不一定要用多大框架,一个 pytest 文件足够。关键是一定要纳入 CI 流程,因为技能库会不断更新,老技能改坏了不能被及时发现,等到线上 Agent 出错时再排查,成本要高得多。
4. 避坑指南:常见的技能调用失败现场与排查方法
4.1 技能包常见“死活不生效”现场
我在维护技能库的过程中,遇到过很多次“技能文件明明写好了,模型却不调用,或者调用了但执行结果不对”的情况。下面这个表格基本覆盖了我见过最多的五类问题:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 技能从未被触发 | description 中的触发词和用户表达差异太大 | 用真实用户话术回测,把高频表达补充进 description |
| 触发后执行结果不稳定 | 操作步骤只写了目标,没有写检查顺序 | 把步骤改成“先…再…最后…”的顺序化描述 |
| 技能内的脚本报错 | 脚本依赖的外部命令行工具没安装 | 在 SKILL.md 里增加“前置依赖”章节 |
| 输出内容又长又乱 | 没有规定输出格式 | 在 SKILL.md 中强制指定 JSON 或 Markdown 结构 |
| 多个技能同时触发,行为冲突 | 技能边界重叠,路由无法区分 | 在 where_to_use 里互相写明“不适用场景” |
我自己翻车最多的是第一种。有次我写了一个处理 CSV 编码问题的技能,描述里用了“GBK”“乱码”这些词,但线上用户实际的表达是“打开文件看着是花的”“Excel 里全是问号”,模型匹配概率自然就低。后来我把用户真实的话术录进来做回测,再把这些口语化表述加入描述,命中率才上去。
4.2 排查方法论:从“现象”到“根因”的三板斧
如果你也遇到了技能不生效,我建议按下面的顺序排查,而不是直接改 Prompt:
第一步,先看日志,确认路由是否命中。任何 Agent 框架都会记录模型调用了哪些技能。如果连命中日志里都没有这个技能,说明问题出在路由或描述上,跟脚本没有关系。
第二步,手动执行技能脚本。直接用命令行调用 scripts 目录下的脚本,输入一条典型样例,看输出是否符合预期。这一步能快速把问题分成两类:“模型没选对技能”和“技能本身有 bug”。
第三步,把模型上下文里实际注入的内容完整打出来检查。很多时候,问题既不在路由,也不在脚本,而是 SKILL.md 被模型读取时格式错乱了。比如 frontmatter 解析失败、某些 Markdown 语法被转义成了纯文本,模型看到的是一堆乱码,自然没法正确执行。
4.3 我踩过最深的坑:SKILL.md 越长越没用
这件事很反直觉,但我实测过很多次:SKILL.md 写得越长,模型越抓不住重点。原因很朴素,模型的注意力是有限的,当一份说明写了几百行,前面写得再清楚,后面也容易被忽略。
后来我做了一个改动:把 SKILL.md 压到最精简,只保留“触发条件、操作顺序、输出格式、注意事项”四块。凡是和工作流强相关、但不需要模型“思考”的内容,一律下沉到 scripts 里,由代码负责。模型只需要判断“该不该用、什么时候用、用完怎么校验”,至于过程中具体怎么处理字符、怎么解析日志,那是代码的事。
还有一个细节:不要在 SKILL.md 里写“如果……那么……否则……”的复杂条件分支。模型对多分支指令的执行一致性很差。遇到这种场景,我的做法是拆成两个独立技能,在触发条件上做区分。把复杂度交给路由,而不是交给推理。
5. 从技能包到技能体系:迭代、评估与团队复用
5.1 度量一个技能包好不好用
技能包建完之后,如果不做度量,就永远不知道它在真实环境里表现如何。我目前会用三个指标来评估一个技能:
- 命中率:用户请求到来时,该技能被正确选中/正确忽略的比例。命中率低说明描述写得不清晰。
- 完成率:技能被选中后,成功完成任务的概率。完成率低说明操作步骤或脚本有问题。
- 修复率:Agent 第一次失败后,根据反馈自我修正并成功完成的概率。修复率低说明技能的报错信息不够友好。
这三个指标不一定非要做到实时监控,哪怕每周从日志里统计一次,也能帮助你发现哪些技能在退化。我见过太多技能库,建的时候很认真,上线后一年不改,最后模型升级、数据格式变了,技能还在用老规则处理新问题,结果自然越来越差。
5.2 技能的版本管理
从技能包升级到技能体系,一个绕不开的话题就是版本管理。我现在用 Git 管理整个技能库,并且给每个技能的 frontmatter 里加版本号。规则很简单:
- 修复 bug 但不影响行为特征,递增 patch 版本,比如 1.2.1;
- 增加新的适用场景或调整操作步骤,递增 minor 版本,比如 1.3.0;
- 重写技能或变更输入输出格式,递增 major 版本,比如 2.0.0。
版本号不只是给别人看的,它在运行时还有实际用途。Agent 在调用技能后,会把技能版本记录到执行日志里。线上出了问题,你能快速确认是“技能版本旧导致的”还是“数据格式变化导致的”,而不是一头雾水地去翻代码历史。
5.3 团队协作与技能复用
如果你的团队超过三个人都在写 Agent,技能库就应该像代码库一样被管理起来。我建议至少有两条团队协作规则:
第一条,新增或修改技能必须过评审。评审的人不需要深挖每个脚本实现,但要看 SKILL.md 是否简洁、边界是否清晰、测试是否覆盖了三种用例(正常、边界、失败)。很多技能废掉的直接原因,就是有人在迭代时为了应付一个 case,往技能描述里硬塞了一条特例,把整个语义搅浑了。
第二条,定期做技能库的“重组”。每过一两个月,我习惯把技能列表重新过一遍,把那些命中率低的、描述有歧义的、职责重叠太多的技能拿出来讨论,该拆分拆分、该合并合并。技能库本质上是一棵活在代码里的知识树,不定期修剪,它就会长歪。
最后说点个人体会
我自己目前维护的技能库大概有二十多个技能包,最大的感受是:技能包不是设计出来的,是逼出来的。每次 Agent 在线上犯错,我都会把这次教训沉淀成一次技能修改——要么修描述,要么加测试,要么新增一个技能。这个过程听起来琐碎,但积累几个月之后,你会发现自己解决同类问题的速度越来越快,因为知识不再只存在于模型参数里,而是变成了团队可以随时查阅、测试、迭代的显性资产。
如果你正准备开始,我的建议是从最让你头疼的三个问题着手,先写三个技能包跑通整个链路。不用贪多,第一批技能即使设计得不够完美也没关系,跑几次真实任务之后,你会比任何教程都更清楚哪里需要调整。技能库的价值不在于数量,而在于每一次改动是否真的让 Agent 的行为变得更可预测。