先说结论:现在做大模型应用,真正拉开差距的早就不是模型本身,而是能不能让模型稳定、可复用、低成本地完成真实任务。这个叫agent-skills的项目,就是围绕这个问题做的一套技能库。
我最早做 agent 的时候走了不少弯路。最典型的就是把几十条指令全部塞进系统提示词里,结果上下文越来越长,模型越聊越“犹豫”,改一个环节要连带影响另外三个环节。后来发现社区里已经有人在用“技能化”的思路拆解 agent 能力:每个技能是一个独立目录,里面有一份给模型看的说明文档,加上若干可执行的脚本,再加一份元信息。agent 在接到任务后先“选技能”,再加载对应说明,然后执行脚本,最后把结果整理给用户。
这套思路就是agent-skills项目的核心。它解决的不是“让模型能调用工具”这种基础设施问题,而是“让模型知道自己该用哪个工具、怎么用、用完怎么收场”的问题。如果你正在做 agent、自动化流程,或者只是手里有一堆重复性工作想交给大模型,这篇内容值得往下看。
1. 整体设计与思路拆解:技能不只是插件,而是“说明书 + 工具箱”
我第一次看到 agent-skills 这类项目时,第一反应是“这不就是插件吗”。真正用起来才发现,它和传统插件、函数调用有本质区别。插件是给程序用的,函数调用也是给程序用的;技能首先是给模型看的,它是一份人会写、模型能读的说明书。
1.1 从单一大提示词到技能库,到底在解决什么问题
单一大提示词有几个痛点,做过 agent 的人应该都懂:
- 上下文被无脑塞满。不管当前任务用不用得上,所有规则都在系统提示词里,token 开销大,模型注意力被稀释。
- 维护成本高。你改一个技能的行为,得从上万字的提示词里找到对应段落,删改时还可能影响其它指令。
- 无法测试。提示词是纯文本,没有接口边界,没有返回值,没法做自动化回归。
- 复用困难。同一个“清理 CSV”的需求,在 A 项目里写了一套,换到 B 项目又要重写一遍。
技能化的做法是把“某个能力”单独抽出来,放进一个目录里。目录里有文档、有脚本、有测试,它既是给模型看的知识,也是可以被程序执行的工具。模型根据用户需求从技能库里选一个加载,而不是把所有技能常驻在上下文中。
1.2 agent-skills 的目录结构:人和模型共用一套约定
我实际维护的 agent-skills 仓库,目录结构长这样:
agent-skills/ ├── skills/ │ ├── csv_cleaner/ │ │ ├── SKILL.md │ │ ├── scripts/ │ │ │ └── clean_csv.py │ │ └── tests/ │ │ └── cases.json │ ├── date_utils/ │ │ ├── SKILL.md │ │ └── scripts/ │ │ └── to_iso.py │ └── log_analyzer/ │ ├── SKILL.md │ └── scripts/ │ └── analyze.py ├── registry.json └── README.md每个技能一个文件夹,强制包含一个SKILL.md。这个文件是全仓库的核心,里面是“给模型看的操作说明”。可选目录是scripts/和tests/,放可执行脚本和测试数据。
选这个结构的原因很简单:只有文档没有脚本的技能是不完整的,只有脚本没有文档的技能是没法被模型正确调用的。把两者放在同一个目录,才能做到“一个技能就是一个小型交付单元”。换到任何 agent 框架里,都能原样搬运。
1.3 为什么不把所有能力都做成 function calling
很多人会问:现在主流框架都有 function calling,为什么还要搞 SKILL.md?
我的理解是这样:function calling 解决的是“程序接口声明”问题,它告诉模型“你能调这个函数,参数 schema 是这样的”。但一个稍微复杂的任务,比如“清理一个 CSV 文件”,不是一步函数调用能搞定的。它可能要处理编码、空行、重复行、列数不一致,还可能遇到各种边缘情况。这些信息很难塞进 JSON Schema,却非常适合写进一篇自然语言说明里。
| 对比项 | function calling | agent-skills 技能 |
|---|---|---|
| 表达对象 | 给程序看的接口 | 给模型看的说明书 |
| 适用任务 | 单步、可参数化 | 多步骤、有规则、需要判断 |
| 上下文开销 | 每个函数声明都会占用 token | 按需加载,选中才读 SKILL.md |
| 维护难度 | 改接口要同步改调用方 | 每个技能独立,改一个不影响其它 |
所以我的做法不是二选一,而是两者配合:技能文档描述流程,脚本内部再暴露少量函数接口。模型先读说明,再按说明调脚本,脚本把结果交给模型格式化输出。
2. 核心细节解析与实操要点:写技能、包脚本、做检索
确认了技能库的思路之后,接下来最值得抠的是三件事:怎么写 SKILL.md、怎么封装脚本、怎么让 agent 准确选中技能。这三个环节任何一个做得糙,整个库都会出现“模型知道有这个技能但不会用”的尴尬情况。
2.1 SKILL.md 的写作范式:不是写文档,是在写“行动指令”
我见过最失败的技能文档,是把这个技能的所有背景知识写了一遍,但模型看完不知道第一步该干嘛。SKILL.md 不是百科,不是代码注释,而是一份行动手册。它的核心结构应该是:什么时候用、怎么用、禁止做什么、异常怎么处理。
我习惯把 SKILL.md 写成这样:
--- name: csv_cleaner description: 清洗 CSV 文件,去除空行和重复行,修复列数不一致。当用户给出 CSV 文件路径,或要求“清洗、去重、补列”时使用;当用户只是讨论 CSV 规则时不要使用。 version: 1.2.0 input: CSV 文件路径或 CSV 文本 output: 清洗后的 CSV 文件路径 dependencies: python3, pandas --- # csv_cleaner ## 什么时候做 - 用户要求清洗 CSV 数据、去重、去除空行、修正错列。 - 数据格式不统一需要规整。 ## 怎么做 1. 确认输入是文件路径还是原文本。如果是原文本,先写入临时文件。 2. 永远不要覆盖原文件。生成新文件,输出路径默认在原文件名后加 _cleaned。 3. 执行脚本:python3 scripts/clean_csv.py --input <输入文件> --output <输出文件> --dedup 4. 如果脚本返回失败,把 stderr 原样展示给用户,不要尝试解释。 ## 禁止 - 不要把完整 CSV 内容塞进 Markdown 表格里,除非用户明确要求预览。 - 不要修改原始数据文件。每个要点我都尽量写成“能做还是不能做”的指令,而不是描述性的文字。模型读“如果输入列数不一致,就补空字符串”,比读“系统需要处理不一致的列数”要可靠得多。
2.2 脚本封装原则:让脚本成为一个可控的黑盒
技能里的脚本不是给人手动运行的,是给 agent 运行时调用的。所以它的接口必须像命令行工具一样清晰:输入参数、输出结果、错误信息。我在封装脚本时坚持几条原则:
- 只通过命令行参数和标准输入输出交互,不要搞交互式提问。Agent 运行环境里没有人坐在终端前回答
y/n。 - 返回码必须准确。0 是成功,非 0 是失败。失败时把具体错误写到 stderr,不要让 agent 猜。
- 不修改源文件。所有输出写到新路径,避免不可逆操作。
- 依赖要少而明确。最好的技能脚本只用 Python 标准库,实在不行再引 pandas,并在元信息里写清楚依赖。
拿 CSV 清洗举例,脚本核心逻辑可以这样写:
#!/usr/bin/env python3 """CSV 清理脚本 用法: python3 clean_csv.py --input <path> --output <path> [--dedup] """ import argparse import csv import sys from pathlib import Path def main(): parser = argparse.ArgumentParser() parser.add_argument("--input", required=True) parser.add_argument("--output", required=True) parser.add_argument("--dedup", action="store_true") args = parser.parse_args() in_path = Path(args.input) if not in_path.exists(): print(f"file not found: {in_path}", file=sys.stderr) return 1 seen = set() rows = [] with open(in_path, newline="", encoding="utf-8-sig") as f: reader = csv.reader(f) for row in reader: # 跳过空行 if not any(field.strip() for field in row): continue key = tuple(field.strip() for field in row) # 去重 if args.dedup and key in seen: continue seen.add(key) rows.append(row) # 列数补齐 max_len = max(len(row) for row in rows) for row in rows: row += [""] * (max_len - len(row)) with open(args.output, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerows(rows) print(f"cleaned rows: {len(rows)}") return 0 if __name__ == "__main__": sys.exit(main())这个脚本不复杂,但足够说明问题:它能被命令行稳定调用,错误信息明确,输出文件不会覆盖原文件。真正干活时,脚本里往往还会加编码探测、大文件分块处理等逻辑,但这些都可以在技能版本迭代时逐步补上。
2.3 按需加载与检索:别把整个技能库塞进上下文
技能库一旦有了十几个技能,就不能每次对话都把全部 SKILL.md 塞进去。这里的关键是“检索优先级”。
我的做法是先用元信息做粗筛。每个 SKILL.md 开头的description字段就是检索索引,它必须写清触发条件。然后让 agent 根据当前任务,对比技能列表里的描述,选一个最匹配的。这一步可以交给模型判断,也可以先做关键词过滤缩小范围。
一个最简实现大概是这样的:
import json from pathlib import Path def load_registry(path="registry.json"): return json.loads(Path(path).read_text(encoding="utf-8")) def pick_skill(task, registry, llm): prompt = "任务:{task}\n技能列表:\n" for i, skill in enumerate(registry): prompt += f"{i}. {skill['name']}: {skill['description']}\n" prompt += "请只输出一个最合适的编号,不要解释。" result = llm(prompt) return registry[int(result.strip())]实际项目里我不会让模型直接输出一个编号就完事,而是会加一层校验:如果模型选出来的技能描述和任务完全对不上,就返回“没有合适技能”,而不是硬选。宁可不干活,也不要错误地干。
3. 实操过程与核心环节实现:从零搭一个可用的技能库
光讲设计太虚,下面走一遍从初始化到接入 agent 的完整流程。这套流程我在新项目里反复用,基本可以照着抄。
3.1 初始化仓库和技能模板
首先建目录,并生成一个标准化模板:
mkdir -p agent-skills/skills cd agent-skills然后写一个非常简单的new_skill.sh,用来生成新的技能骨架。模板脚本不复杂,但能保证每个技能目录结构统一,不会出现“两个技能结构都不一样”的维护灾难。
#!/bin/bash SKILL_NAME=$1 SKILL_DIR="skills/$SKILL_NAME" mkdir -p "$SKILL_DIR/scripts" "$SKILL_DIR/tests" cat > "$SKILL_DIR/SKILL.md" <<EOF --- name: $SKILL_NAME description: 一句话说明触发条件。 version: 0.1.0 input: 输入说明 output: 输出说明 dependencies: --- # $SKILL_NAME EOF echo "created $SKILL_DIR"每次新增技能,先跑./new_skill.sh log_analyzer,再往里填内容。这个习惯帮我避免了“文档没头部”的问题。SKILL.md如果没有元信息段,后面的 registry 生成、检索过滤全部会失效。
3.2 写第一个真实技能并跑通脚本
我用一个“日志分析”技能举例。用户可能给一个日志文件,要求统计错误级别、出现次数最多的错误、以及最近 10 条 ERROR。这类任务特点是规则明确,非常适合做成技能。
先在skills/log_analyzer/SKILL.md里写清楚触发条件和步骤:
--- name: log_analyzer description: 分析服务日志文件,统计 error/warn/info 级别数量、提取高频错误、输出最近错误列表。当用户给出日志文件内容或路径并要求“分析日志、错误统计、看报错”时使用。 version: 1.0.0 input: 日志文件路径或日志文本 output: 统计摘要与关键错误列表 dependencies: python3 --- # log_analyzer ## 怎么做 1. 判断输入是文件路径还是文本。文本写入临时文件。 2. 运行 python3 scripts/analyze_log.py --input <文件> --limit 10 3. 把脚本输出的 JSON 结果转成可读摘要,不要把原始 JSON 丢给用户。再写对应的script/analyze_log.py。脚本输出 JSON,方便 agent 解析:
#!/usr/bin/env python3 import argparse, json, re, sys from collections import Counter from pathlib import Path def main(): parser = argparse.ArgumentParser() parser.add_argument("--input", required=True) parser.add_argument("--limit", type=int, default=10) args = parser.parse_args() path = Path(args.input) if not path.exists(): print(json.dumps({"error": "file not found"}), file=sys.stderr) return 1 level_counter = Counter() error_samples = [] for line in path.read_text(errors="ignore").splitlines(): match = re.search(r"\b(ERROR|WARN|INFO|DEBUG)\b", line) if match: level_counter[match.group(1)] += 1 if "ERROR" in line: error_samples.append(line.strip()) result = { "levels": dict(level_counter), "total_lines": len(path.read_text(errors="ignore").splitlines()), "recent_errors": error_samples[-args.limit:], } print(json.dumps(result, ensure_ascii=False)) return 0 if __name__ == "__main__": sys.exit(main())跑一次看看效果:
python3 scripts/analyze_log.py --input app.log --limit 3 {"levels": {"ERROR": 4, "INFO": 20, "WARN": 2}, "total_lines": 50, "recent_errors": ["timeout error", "connection reset", "disk full"]}输出格式稳定后,agent 只需要读这段 JSON,再组织成自然语言。脚本与模型之间的边界就清楚了:脚本负责确定性计算,模型负责表达和判断。
3.3 生成 registry 并接入 agent 主循环
技能多了以后,不能每次手动去目录里找。我写了build_registry.py扫描技能目录,解析 YAML 头,生成一个registry.json:
python3 build_registry.py > registry.jsonregistry.json内容大概是这样:
[ { "name": "csv_cleaner", "description": "清洗 CSV 文件...", "path": "skills/csv_cleaner" }, { "name": "log_analyzer", "description": "分析服务日志文件...", "path": "skills/log_analyzer" } ]接入 agent 主循环时,我只做三件事:加载 registry、根据任务选技能、执行技能并把结果交给模型总结。这个循环很小,但它是整个 agent-skills 项目的发动机。有了它,技能库才不是一堆静态文件,而是真正可以在对话里被调用的能力。
3.4 技能质量验收:至少过三关
我给每个技能定的验收标准,不通过不往上合代码。这一部分也是我后来才补上的,早期全靠手工试,改一次技能就担心破坏另一个场景。
第一关是脚本级测试。每个tests/目录里放至少一组输入输出样例,跑起来看返回码和 stdout 是否符合预期。
第二关是模型调用测试。用一组统一 Prompt 直接问 agent,比如“请帮我清洗 /tmp/dirty.csv”,看它会不会选错技能、会不会自己乱编参数。如果模型没选中该技能,多半是 description 写得太像功能描述,不够像触发条件。
第三关是回归测试。技能升级后,把历史测试 Prompt 全部重跑一遍,对比输出。只要有一项行为改变不符合预期,就要检查 SKILL.md 或脚本哪里动了。
4. 常见问题与排查技巧实录
再好的设计,落地时都会踩坑。下面这些是我维护 agent-skills 时真实遇到的问题,每个都对应一个可操作的排查方法。
4.1 模型就是不调用技能,怎么办
这是最常遇到的现象。用户明明说“帮我整理一下这份日志”,agent 却开始凭空总结,完全不走log_analyzer。排查时我首先看技能 description,十有八九是描述写得过于功能化。
比如“分析日志文件”这种描述,等于没说。模型不知道什么情况下该用。正确写法是“当用户给出日志文件路径或日志内容并要求做错误统计、级别统计、日志摘要时使用”。要把触发条件完整写进去,而不是只写功能名。
4.2 脚本报错,agent 却开始自行脑补结果
有些模型特别“聪明”,脚本明明返回非 0,它还能继续编一个漂亮的答案回给用户。这种情况非常危险,尤其是对数据处理类技能。
我的处理办法是在 SKILL.md 里写一条硬性规则:脚本返回非 0 时,原样展示 stderr,不猜测、不修饰、不尝试绕过去。同时在主循环里检查返回码,如果非 0,禁止把后续结果交给模型总结。
4.3 技能文档太长,上下文又爆了
技能文档不是越详细越好。太长的话,即使只加载一个技能,也会把上下文撑爆。
我定的经验值是:单个 SKILL.md 尽量控制在 60 行以内;能写进脚本的不要写进文档;需要大量背景知识的,只写“参考 scripts/README.md”而不必把所有细节铺开。给模型的说明只保留决策规则和步骤,其余让脚本解决。
4.4 技能升级后旧任务行为变差,怎么定位
技能库一旦开始多人协作,版本管理就很重要。我给每个 SKILL.md 头部加版本号,并约定升级规则:改脚本行为必须升 minor 版本;只改文案可以不升;破坏性变更必须升 major 且写 changelog。
排查行为变差时,第一步不是改代码,而是看该技能版本有没有变过。用 git diff 对比两个版本之间的 SKILL.md,通常问题出在“把原来明确禁止的事改成了允许”。我踩过最深的坑就是这类隐式行为变化,加了三行“补充说明”,反而让模型开始做多余的事。
4.5 技能冲突:两个技能描述都命中怎么办
当库里同时有json_formatter和csv_cleaner时,用户一句“把数据整理一下”可能让两个技能都中。这个问题靠 description 的边界措辞优化,同时也要接受现实:模型确实可能犹豫。
我的做法是在选择阶段加入“负向描述”。比如在这个技能 description 后补一句“当用户只是需要格式化展示,而不是清洗数据时,不要使用”。这比正向描述更能帮助模型排除错误选项。
4.6 常见错误与修复速查
| 现象 | 可能原因 | 修复方向 |
|---|---|---|
| 技能没被选中 | description 太泛 | 写清触发条件和负向条件 |
| 脚本找不文件 | 路径写死相对路径 | 用技能目录作为基准路径 |
| 输出被模型改写失真 | 缺少“原样返回”规则 | SKILL.md 加禁止改写指令 |
| 上下文爆掉 | 全量加载技能 | 改用按需加载 + 精简文档 |
| 改完不生效 | 缓存旧 registry | 重新生成 registry.json |
5. 一些个人体会与后续思路
我在实际维护 agent-skills 的过程中,最明显的感觉是:这个项目逼着你把模糊的工作流变成清晰的文本。以前靠聊天随手完成的“整理一下”、“统计一下”,现在必须写成步骤、写成参数、写成边界条件。这个过程很麻烦,但做完之后整个系统变得非常可控,也更容易和团队协作。
还有一个很实际的好处:技能可以跨项目搬运。我在一个数据清洗项目里打磨好的csv_cleaner,换到另一个新 agent 应用里,只需要复制文件夹,重新生成 registry 就能用。它不绑定任何特定模型厂商,也不是某个框架的专有产物。
最后给个小技巧:别一上来就追求技能多,先把你手上最常做的三到五件重复工作做成技能。跑通循环之后,再慢慢扩充。技能库最怕的不是技能少,而是技能质量参差不齐。一个测试不充分、描述模糊的技能,比没有这个技能更影响 agent 的整体可靠性。先把每个技能当一个小产品去维护,agent 的能力才会稳定地长出来。