1. 设计思路:为什么Agent需要一套“技能集”而不是一堆工具函数
先说个背景。我最近半年一直在做基于大模型的自动化代理项目,早期踩过一个特别典型的坑:把十几个工具函数一股脑塞进系统的工具列表,然后让Agent自己选。效果嘛,单任务还行,一旦场景复杂,模型就开始“选择困难”:明明该查数据库的,它偏去调搜索接口;该写文件的,它跟你耗在参数补全上。而且每次新增一个工具,旧任务的稳定性就可能被牵连。后来我把这套东西重构成了“Agent技能集”模式,也就是这次想聊的agent-skills。
所谓Agent技能集,说白了就是把“工具函数 + 使用说明 + 触发场景”打包成一个独立单元。每个技能就是一个文件夹,里面装着它专属的指令文档、脚本和依赖声明。模型不需要在每次请求里看到全部工具,而是先根据用户意图命中一两个技能,再把对应技能的说明加载进来使用。这个思路和传统function calling最大的区别在于:它不是给模型发一把螺丝刀、一个扳手、一台电钻,而是给模型一份“工种手册”,需要拧螺丝时说清楚用哪个工具、按什么顺序操作、有什么注意事项。
我之所以坚持这个设计,有几个实际考量。
第一个是上下文预算。大模型上下文窗口是很贵的资源。假设你有30个工具,每个工具描述平均400个token,光工具定义就吃掉12000个token,这还没算系统提示词和对话历史。用户一次普通的查询,上下文可能直接烧掉三分之一。而技能集模式下,主提示词里只有一份十几个技能的索引列表,每个技能一句话长描述,总共不超过1500个token,被命中后才加载单个技能的完整说明,整体开销小得多。
第二个是职责边界。传统工具列表里,工具之间是平级的,没有层次关系。Agent要自己判断“先做什么再做什么”。技能集天然带流程:一个技能里可以包含多步操作,顺序在SKILL.md中写明,模型只要按步骤走就行。比如“网页摘要”技能,内部要先抓取页面、清理正文、抽取标题、再调用摘要接口,这些步骤封装在技能内部,对外只暴露一层触发条件。
第三个是复用和分享。工具函数写得再好,换一个项目基本无法直接迁移,因为提示词、参数定义、错误处理都是为旧项目定制的。技能集把指令和代码绑在一起,拷贝整个目录到新项目就能用,团队里共享也非常方便。这就很像npm包或pip依赖,只是它捆绑的不只是代码,还有模型侧的行为约定。
这套设计的关键取舍在于:技能粒度怎么定。太粗,一个技能包含太多场景,命中后指令模糊,模型容易跑偏;太细,技能数量爆炸,索引列表又变长,命中准确率下降。我现在的经验是,以“一次性任务”为粒度:用户的一句话意图如果可以用不超过5步操作完成,就值得做成一个单独技能。超过5步的任务,建议拆成多个技能再串成工作流,而不是硬塞进一个技能里。
2. 目录结构、SKILL.md规范与触发机制的关键细节
技能集的物理形态并不神秘,就是一个约定好的目录结构。每个技能一个文件夹,内部必须有一个SKILL.md,这是模型的“说明书”;其余是工具脚本、资源文件和配置。下面是我目前比较稳定的一套结构:
skills/ 01_web-summarizer/ SKILL.md tools/ fetch_page.py clean_html.py summarize.py assets/ prompt_templates/ short_summary.txt detailed_summary.txt requirements.txt 02_database-query/ SKILL.md tools/ run_sql.py config/ db_connections.yamlSKILL.md是整个技能集的灵魂。它决定了模型什么情况下会想到这个技能、加载后知道怎么正确执行。我推荐用YAML frontmatter + Markdown正文的双段结构。frontmatter给调度器看,正文给模型看。
--- name: web-summarizer description: 当用户需要总结网页内容、提取文章要点、生成链接摘要时使用。适合处理博客、新闻、产品页面等公开网页。 version: 1.2.0 author: team-ai license: MIT triggers: - 总结网页 - 提取摘要 - 这篇文章讲了什么 readme: | 加载本技能后,按 tools/ 目录下的步骤执行。先抓取网页正文,再清洗为纯文本,最后调用摘要模板生成输出。整个过程不要向用户询问多余参数,URL 优先从对话上下文中提取。 --- # Web Summarizer 技能使用说明 ## 执行步骤 1. 从用户输入中提取目标URL。如果没有明确URL,但有链接,请解析为绝对地址。 2. 运行 `python tools/fetch_page.py <url>`,获取干净的正文文本。 3. 判断正文长度。超过2000字时,使用 detailed_summary.txt 模板;少于2000字,使用 short_summary.txt 模板。 4. 输出摘要时保留原文核心论点和关键数据,不要添加外部信息。 ## 注意事项 - 只抓取目标页面本身,不沿链接继续爬取。 - 如果页面返回404或超时,直接告知用户抓取失败,不尝试其他URL。 - 禁止将网页内容用于商业用途,所有结果仅供个人学习参考。这里最关键的是description字段。你可以把它理解为技能的“触发开关”。模型不会逐字阅读所有技能正文,它只根据用户请求和frontmatter里的description做快速匹配。所以description写得越精确,技能被正确调用的概率越高。
我的体会是,写description要遵循三条原则。
一是动词开头,明确动作。尽量写“当用户需要总结某个网页时”,而不是“包含网页摘要相关能力”。前者是任务导向,后者是能力导向。模型做意图匹配时,任务导向的描述命中率高得多。
二是写清楚不做的事。description里加一句“不要用于PDF文件”“不要处理需要登录的页面”这类边界说明,能把误调用率压下去。很多技能被乱用,不是模型笨,而是你没告诉它边界。
三是避免过多同义词堆砌。我见过有人把“总结、摘要、概括、提炼、要点、核心内容”全部塞进description,以为覆盖广,结果模型反而困惑。一两个代表性动词加一个场景短语就够了。
除了SKILL.md,另一种我正在尝试的做法是给技能附带一个“技能索引文件”,放在skills目录根部。这个文件不进入模型上下文,而是给调度模块用:一个轻量级模型或者基于embedding的检索器,根据用户输入选出3-5个候选技能,再把这几个候选的SKILL.md注入主模型。这样可以支撑上百个技能的大规模技能库,避免所有技能的内容都塞给主模型。目前我在20多个技能的小型库上跑,直接用单模型扫描frontmatter就够了;技能超过50个,建议上索引检索。
3. 实操:从零搭一个可用的“网页摘要”技能
讲了这么多设计,还是动手做一个完整技能最直观。以“网页摘要”为例,我来完整走一遍流程,包括目录创建、SKILL.md编写、脚本实现和联调验证。
第一步,确定技能边界。我给它定的范围是:公开网页的正文抽取与摘要生成,不处理需要登录的页面,不访问无HTML正文的资源,一次只处理一个URL。
第二步,创建目录和脚本。我的项目结构如下:
agent-skills/ app.py # Agent主程序,负责调度 skills/ web-summarizer/ SKILL.md tools/ fetch_page.py summarize.pyfetch_page.py负责抓取网页并清洗为纯文本,这是整个技能里最容易出问题的环节。我始终用双重清理策略:先用正则粗粒度剥离script和style标签,再用HTML解析库做精确恢复。实际代码如下:
#!/usr/bin/env python3 import sys import re import html import urllib.request from urllib.parse import urlparse UA = "Mozilla/5.0 (compatible; AgentSkillBot/1.0)" def fetch_text(url: str, max_chars: int = 8000) -> str: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise ValueError("仅支持 http/https 链接") req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=10) as resp: raw = resp.read().decode("utf-8", "ignore") # 先去掉脚本和样式,避免正文被噪声干扰 text = re.sub(r"<script[\s\S]*?</script>", "", raw, flags=re.I) text = re.sub(r"<style[\s\S]*?</style>", "", text, flags=re.I) # 去掉标签和实体 text = re.sub(r"<[^>]+>", " ", text) text = html.unescape(text) # 压缩空白 text = re.sub(r"\s+", " ", text).strip() return text[:max_chars] if __name__ == "__main__": try: print(fetch_text(sys.argv[1])) except Exception as e: print(f"FETCH_ERROR: {e}", file=sys.stderr) sys.exit(1)这个脚本的重点是异常处理。抓网页这种事,目标服务器随时可能拒绝、超时、返回乱码。我把所有异常统一捕获并输出带前缀的错误信息,这样Agent可以靠识别FETCH_ERROR前缀来给用户一个明确的失败反馈,而不是把一串traceback丢给模型,让模型猜发生了什么。
summarize.py就简单一些,接收抓取到的纯文本,调用摘要模型接口,按模板输出。我用的是一家通用模型服务的接口,但为了演示,这里用命令行参数传入文本的方式:
#!/usr/bin/env python3 import sys def summarize(text: str, max_len: int = 300) -> str: # 实际项目里这里会调用大模型接口 # 这里演示一个简单的提取式摘要逻辑 sentences = text.replace("。", ".\n").split("\n") kept = [] total = 0 for sent in sentences: if total + len(sent) > max_len: break if len(sent) > 20: kept.append(sent.strip()) total += len(sent) return " ".join(kept) if __name__ == "__main__": text = sys.stdin.read().strip() print(summarize(text))第三步,在SKILL.md中把这些脚本串成完整流程。之前已经给出了模板,这里补充一点:执行顺序和失败分支一定要写清楚。我见过很多技能文档,步骤写得含糊,模型自由发挥,结果工具用错、参数传错。命令执行要转化为显式的Shell命令,不要给模型选择空间。
第四步,写一个简单的调度器,让Agent能命中这个技能。调度逻辑完全不复杂:
#!/usr/bin/env python3 import os import yaml SKILLS_DIR = "skills" def load_skill_index(): index = [] for name in os.listdir(SKILLS_DIR): md_path = os.path.join(SKILLS_DIR, name, "SKILL.md") if not os.path.exists(md_path): continue with open(md_path, encoding="utf-8") as f: head = f.read(2000) meta = {} if head.startswith("---"): _, fm, _ = head.split("---", 2) meta = yaml.safe_load(fm) or {} index.append({ "name": meta.get("name", name), "description": meta.get("description", ""), "path": md_path, }) return index if __name__ == "__main__": for skill in load_skill_index(): print(f"{skill['name']}: {skill['description']}")这个脚本的目的有两点:一是供模型看到的技能索引列表;二是方便人工检查每个技能的description是否写到位。我会在每次新增技能后跑一遍,直接从输出结果判断描述质量,不用打开每个文件。
联调时我最常用的测试命令是这样的:
python app.py "帮我总结一下 https://example.com/chatgpt-usage 这篇博客"Agent输出里应当出现技能名web-summarizer,并且摘要内容取自目标页面正文。如果Agent没有命中技能而是直接瞎编,我优先检查description是否覆盖了这个表达。这是我说的“先测触发,再测执行”原则。很多团队在调试Agent技能时一上来就调试脚本本身,结果代码没问题,就是Agent不调用,白忙一场。
4. 常见问题与排查技巧实录:技能不触发、误触发、上下文污染
技能集做多了之后,问题就不仅仅是写代码这么简单了。我整理了一套排查问题的方法集,按出现频率排序,给新手做个参考。
| 现象 | 可能原因 | 排查方法 | 修复方向 |
|---|---|---|---|
| Agent明明看到网页URL,却不去调用web-summarizer | description里没有覆盖用户表达方式 | 查看技能索引输出,确认描述与测试语句的语义匹配度 | 重写description,直接用测试语句里的原词 |
| Agent经常在用户没提网页时也调用该技能 | description边界描述缺失 | 检查描述里有没有“不要用于XX场景” | 在description末尾加上明确排除项 |
| 技能被命中但执行结果为空 | 脚本异常被吞掉,Agent拿不到错误信息 | 单独运行脚本复现,检查stderr输出 | 确保所有异常都打印到stderr并带明确前缀 |
| 页面内容很多,上下文被撑爆 | 脚本输出未做长度限制 | 检查脚本返回的最大字符数 | 设置max_chars,优先截断正文而不是摘要 |
| 两个技能都在说自己负责“网页处理” | 技能粒度设计重叠 | 核对两个技能的description 触发词交集 | 合并或重新切割技能边界 |
先说技能不触发这个最常见的问题。我现在养成的习惯是,写完一个技能先不看代码,直接用十种不同的用户说法去测试触发。比如网页摘要技能,我会试“总结一下这个页面”“这篇文章的核心观点”“把链接内容提炼一下”“这个博客讲了什么”等表达。如果一半以上没触发,那说明description写得太窄。反过来,如果用户在聊代码问题,Agent却触发了网页技能,说明description的边界没写清。
排除问题时要区分两种上下文:一种是“技能索引在系统提示里”,另一种是“技能全文在上下文里”。前者是指模型能看到的技能目录,后者是技能被命中后加载的完整SKILL.md。很多调试者搞混这两者,以为把SKILL.md写到系统提示里就能解决问题,结果上下文越来越长,模型行为越来越飘。我的经验是:索引和正文必须分开放,索引保持短,正文按需加载。
再有一个极其隐蔽的坑:脚本的输出污染了Agent的后续推理。早期的fetch_page.py会把抓取到的整页文本都print出来,Agent收到后不仅包含了正文,还混入了导航菜单、页脚、广告文案,结果摘要质量一塌糊涂。后来我在脚本里只返回清洗后的正文,并且加了字数上限,问题立刻缓解。凡是工具脚本的输出,都要比照着“这就是最终要交付给模型的信息”这个标准去做清洗。
关于依赖环境,我也踩过坑。一台服务器上Python版本不同、系统库缺失,Skill里的脚本明明在本机跑得好好的,部署后就报错。后来我把所有技能脚本统一用venv隔离,requirements.txt声明依赖,主程序通过固定的解释器路径调用。另外不要依赖当前工作目录,脚本内部用绝对路径或者基于脚本所在目录的路径。技能集应该像一个可移植的盒子,而不是只能在你机器上跑的临时脚本集合。
说到误触发,我有一个非常典型的例子。团队里同事给“会议纪要整理”技能写description,写的是“当对话内容包含会议记录时使用”,结果Agent在用户随口说“昨天开会说的事你记得吗”时也触发了这个技能,把一篇正常聊天总结成了会议纪要。修复方法就是在description里加边界:“仅当用户明确提供一段会议原始记录文本或录音转写文本时使用,而不是根据对话历史推断会议内容。”这一条加完,误触发率直接降了八成。
我自己还维护了一个小型的触发回归集,就是二十条典型用户句子以及期望命中的技能名。每次修改description,跑一遍回归集,对比命中结果。这个方法虽然土,但在技能库规模不大时,比任何评估框架都直观高效。
5. 技能评估与迭代:怎么让技能越用越准
技能集不是写完就完了,它更像一个需要持续打磨的产品。我目前的做法是每两周做一次技能体检,重点看三个维度:命中准确率、执行成功率、上下文消耗效率。
命中准确率就是前面说的回归测试。我会根据线上日志抽取用户真实请求,人工标注期望技能,然后跑一遍当前技能索引,统计正确命中的比例。这个数值低于80%,说明技能描述体系需要调整,不是改一两个description就行的,可能要重新审视技能边界划分。
执行成功率看的是技能内部脚本的健壮性。我准备了一个自动化冒烟测试,每个技能至少有一个固定的测试用例,比如web-summarizer就固定抓取一个我控制的测试页面。每次改完代码,跑一遍所有技能的冒烟测试,确保没有因为改动影响了已有技能。
上下文消耗效率是我近来比较看重的一个指标。同样一个任务,技能索引设计得好,两轮对话就完成了;设计不好,模型需要向用户追问参数、反复试错,四五轮还没结束。我在日志里记录每轮对话的工具调用次数和上下文token消耗,异常波动说明技能的说明文档写得不够清楚,模型拿不到足够信息去做一次成功调用。
配合评估,我还做了一套版本控制:每个技能发布时,SKILL.md里记录版本号和变更说明。模型执行时如果发现行为不符合当前版本预期,我能在日志里快速定位是哪个版本的改动引起的。这一点在多人协作时特别重要——大家都改同一个技能,没有版本控制,出现问题根本扯不清。
再看看技能集后续可以怎么扩展。目前我的项目已经支持了技能依赖,就是一个技能可以声明依赖另一个技能,调度器会自动加载被依赖的技能说明。比如我搭了一个“资讯简报”技能,它内部依赖“网页摘要”“RSS抓取”“邮件发送”三个子技能,用户只需要说“生成一份今日AI资讯简报”,系统自动串联执行。这种多技能组合是我的主攻方向,因为单个技能能力有限,技能之间的编排才能释放Agent真正的潜力。
对于一个刚开始用技能集的人,我给一个最直接的建议:先做一个小而精的技能库,只放五六个真正高频使用的技能,把pet每个SKILL.md都写到“连一个实习生都能照着执行”的程度,再考虑扩大规模。技能集最大的陷阱不是做不出来,而是做完以后没人用、没测准、没法迭代。它本质上是一个需要持续运营的体系,而不是一次性开发的一组脚本。
我个人现在的习惯是,任何新的Agent项目,动手写第一行业务代码之前,先把skills目录建好,把一个最小技能的SKILL.md写出来。有了这个骨架,后续增加能力、排查问题、评估效果,都有了一个清晰的落点。这是我在多次踩坑之后沉淀下来的最核心的工作方式。