最近在给团队搭建智能体(Agent)应用时,把大量精力花在了一个容易被忽视的环节上:agent-skills,也就是智能体的技能系统设计。之前我们做 Agent 的方式很“原始”,把所有能力和指令塞进一个超长 Prompt 里,结果模型经常答非所问,工具调用乱成一团,后面吸取教训,把能力拆成一个一个结构化的 Skill(技能),才真正跑通了。这篇就把我在这个过程中的设计思路、踩坑记录和一些可直接复用的方案整理出来。
这个项目解决的问题很具体:当你的 Agent 需要处理文件、查询数据、操作外部系统时,怎样让大模型准确、高效地调用这些能力,而不是靠“猜”。它适合正在做 Agent 开发、想给智能体重构能力层、或者想沉淀一个可复用技能库的开发者和技术负责人。无论你用的是 Claude、GPT、开源模型还是自研的编排框架,这套设计思路都适用。我们当时落地了一个“技能目录 + 描述卡片 + 可执行脚本 + 注册表”四层结构,跑通之后稳定性提升非常明显。
1. 内容整体设计与思路拆解
1.1 先搞清楚:Agent Skill 到底是什么
Agent Skill 本质上是一种将“能力”结构化、标准化、可复用化的封装单元。它不能简单等同于一个 Python 函数,也不只是一段 Prompt 提示词。我的理解是,一个 Skill 应该包含三样东西:它知道“自己在什么场景下该被使用”(描述),它定义了“调用它需要哪些输入”(参数 Schema),它还有真正干活的“执行体”(脚本或服务)。
很多人会问:既然我们已经有各种工具调用了,为什么还要多此一举搞 Skill 层?这个问题的答案我是踩了坑才明白的。之前我们用 Function Calling 裸调工具,每个工具都是独立的,维护起来没太大问题,但当工具数量增加到几十个时,大模型经常“乱点鸳鸯谱”。把相近的能力聚合为一个 Skill,配合清晰的语义描述和边界约束,模型“误用工具”的概率会大幅下降。
我当时给团队画的简化公式是:
工具(Tool) = 一个函数
技能(Skill) = 一个完整的能力域(包含场景判断、入参校验、执行流程、结果规整)
举个例子,“发送邮件”是一个工具;而“处理邮件相关事务”是一个 Skill,它可以内部调度发邮件、按联系人分组、自动生成主题这多个子动作。
1.2 为什么不建议把所有能力都塞进 Prompt
有一种“偷懒”方案是:把 Agent 会用到的事项全部写进系统提示词里,比如“如果你收到文件相关的任务,就执行文件整理脚本”。初期效果看似还行,但一旦业务复杂,问题就暴露得特别明显:
- 上下文被污染:大量的能力说明把上下文撑成大杂烩,关键指令反而被稀释,模型决策质量直线下降。
- 无法复用:一套流程写死在 A 场景里,B 场景想调用完全相同的逻辑,只能复制再改一遍。
- 没法测试:能力行为的边界不清晰,回归测试都不知道该测什么。
把“能力的定义”从“对话提示层”下沉到“可编程的技能层”,是我这次重构的核心决策。技能层是可编程的,就意味着可以用 Git 做版本管理、可以用单元测试做回归验证、可以给不同环境(开发/预发/生产)部署不同的技能集。这下运维模型就从“调 Prompt”变成了“管理代码”。
1.3 整体架构的四个层次
我们最终落地的方案分成了四层:
- 技能定义层:每个 Skill 目录下的描述文档(SKILL.md)和参数 Schema 文件。
- 执行实现层:真正干活的脚本、二进制、API 封装或者工作流定义。
- 注册与发现层:所有 Skill 的索引注册表,负责加载、去重、权限控制。
- 调度与编排层:Agent 内核读取注册表,结合任务语义选择 Skill,并进行多步编排。
这四个层次相互独立的好处是:你可以只替换执行实现层而不影响定义,比如想从本地执行换成一个远程服务,改动非常小。这个设计对后期迭代太关键了,不然每次需求变更都要动到调度器。
2. 核心细节解析与实操要点
2.1 一个 Skill 的“身份证”:名称与描述怎么写
Skill 的描述是决定大模型能不能正确调用它的第一因素。我见过太多人写描述写得很随意,比如“tools for sending emails”,结果大模型根本不知道什么时候该用它。一个合格的描述应该包含四个部分:
- 核心职责:这个技能主要负责做什么。
- 适用场景:什么类型的任务应该触发它。
- 边界与禁忌:什么情况下不要使用。
- 输入输出概览:大致需要什么参数,会返回什么结果。
我自己常用的模板是:
处理【XX领域】任务的技能。当用户请求涉及【场景A】、【场景B】时使用。不接受【场景C】的请求。需要提供【参数X】和【参数Y】,返回【结果描述】。
举个例子,“会议纪要辅助 Skill”的描述:
将会议录音或转写文本整理为结构化会议纪要。适用于周会、项目复盘、客户访谈等场景。可提取待办事项、风险项和决策项。不适用于实时字幕翻译。输入为转写文本内容,输出为 Markdown 格式纪要文件。
这段描述很长吗?不长,但信息密度非常高。实测下来,描述里写清楚“不适用场景”非常有效,能挡住很多误触发。
2.2 参数 Schema 设计的三个关键原则
- 少而精:能用 3 个参数解决的绝不用 5 个,参数越多,模型猜测和编造的概率越大。
- 类型要严格:参数类型必须明确,宁可要严格字符串枚举,也不要让模型“自由发挥”格式。比如时间参数,要求必须是 ISO8601 格式字符串,而不是“大概下午三点”。
- 默认值兜底:给参数设合理的默认值,避免模型在缺参数时直接报错。
我当时做一个文件归档技能,初始参数设计了 7 个:来源路径、目标路径、归档方式、是否递归、是否保留结构、是否压缩、删除原文件。后来在实际测试中发现,模型经常会漏传“目标路径”,而且“保留结构”和“删除原文件”之间还有隐含冲突。后来我把参数砍成 3 个:
source_dir: string, required, 待归档目录 target_dir: string, optional, 默认 /archive, 归档目标目录 mode: enum[string], optional, 默认 move, 可选值 move/copy/compress砍完之后,调用成功率肉眼可见地提升了。
2.3 技能实现形态:脚本、函数还是服务
技能内部实现我试过三种路径,各有适合的场景:
- 单文件脚本(Python/Node/Bash):最快落地,适合逻辑简单的技能,日志和错误处理自己写。
- 平台函数(如云函数):适合需要弹性伸缩或跟云资源打交道的场景,但调试麻烦一点。
- 独立微服务:适合重计算、需要会话状态、或对外部系统有复杂调用链的技能,但引入的运维负担最重。
我们的原则是:先脚本跑通,再按需服务化。不要一上来就整微服务,Agent Skill 的痛点往往不是性能,而是逻辑准确性。脚本能表达清楚的逻辑,微服务不一定更快。
2.4 目录结构是技能的骨架
我建议每个 Skill 独立目录,不要杂糅。一个标准目录结构长这样(这个是我们内部的模板):
skills/ pdf_extract/ SKILL.md schema.yaml implement.py requirements.txt tests/ test_implement.py email_handle/ SKILL.md schema.yaml implement.py目录名就是技能名,必须简短、无空格,便于注册表索引。所有说明归 SKILL.md,所有参数定义归 schema.yaml,实现归 implement.py,测试归 tests。不要搞“放一个 doc 里顺便把脚本也塞进去”的那种结构,后期维护会非常痛苦。
3. 实操过程与核心环节实现
3.1 从零实现一个「文件归档技能」
我们用一个真实的技能来演示完整流程。假设我们要做一个可以自动把项目文件按分类归档、并生成索引清单的技能。第一步,先写 SKILL.md:
# archive_files 将散落的项目文件按类型归档到指定目录,并生成一份 index.md 索引清单。 适用于“帮我整理一下文件夹”、“把下载目录的文件归档”这类请求。 不接受需要重命名内容主体或批量修改文件内部内容的请求。 参数: - source_dir: 要整理的源目录(必填) - target_dir: 归档目标目录(选填,默认 ./archive) - file_type: 需要归档的文件类型(选填,默认 all) 输出:一份包含归档位置和文件数量的 Markdown 索引文件。这里“不接受重命名内容主体”这种边界描述,是为了防止模型拿这个技能去干它不该干的事。接下来写 schema.yaml:
name: archive_files description: 整理并归档项目文件,生成索引清单 parameters: source_dir: type: string required: true description: 待整理的源目录绝对路径 target_dir: type: string required: false default: "./archive" description: 归档目标目录 file_type: type: string required: false default: "all" enum: [all, doc, image, code] description: 按类型筛选归档3.2 实现脚本的编写与要点
写 implement.py 时,我特别注意了一点:脚本要在没有 Agent 的时候也能独立运行。这样语法检查、单测、手动验证都很方便。脚本头部做一个命令行参数解析,Agent 调用时传 JSON 序列化参数,手动调试时直接传命令行参数。
import argparse import json import os import shutil from pathlib import Path TYPE_MAP = { "doc": [".pdf", ".docx", ".txt", ".md"], "image": [".png", ".jpg", ".jpeg", ".gif"], "code": [".py", ".js", ".ts", ".go", ".java"], } def archive_files(source_dir, target_dir="./archive", file_type="all"): source_path = Path(source_dir).resolve() target_path = Path(target_dir).resolve() if not source_path.exists(): raise FileNotFoundError(f"源目录不存在: {source_path}") extensions = None if file_type != "all" and file_type in TYPE_MAP: extensions = TYPE_MAP[file_type] moved_count = 0 index_entries = [] for item in source_path.iterdir(): if item.is_file(): if extensions and item.suffix.lower() not in extensions: continue relative_target = target_path / (item.suffix.lstrip(".") or "misc") relative_target.mkdir(parents=True, exist_ok=True) dest = relative_target / item.name shutil.move(str(item), str(dest)) moved_count += 1 index_entries.append(f"- {item.name} -> {dest}") index_path = target_path / "index.md" with index_path.open("w", encoding="utf-8") as f: f.write("# 归档索引\n\n") f.write(f"共归档 {moved_count} 个文件。\n\n") f.write("\n".join(index_entries)) return {"moved_count": moved_count, "index_path": str(index_path)} if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--source_dir", required=True) parser.add_argument("--target_dir", default="./archive") parser.add_argument("--file_type", default="all") args = parser.parse_args() result = archive_files(args.source_dir, args.target_dir, args.file_type) print(json.dumps(result, ensure_ascii=False))这个脚本看起来简单,里面有一个关键设计:按扩展名分目录归档,而不是直接全部倾倒在一个目录。这样索引文件和分类逻辑清晰,用户拿到结果后能直接看到“哪些文件去了哪里”。返回值统一用 JSON 序列化,方便 Agent 解析成结构化结果。
提示:实际生产中不要直接 shutil.move,最好先复制到临时目录,确认无误后再切换。我当时为了赶进度直接 move,结果归档到一半磁盘报错,文件处于“两边都不在”的状态,教训很深刻。
3.3 技能注册与加载流程
写好了技能文件,下一步就是注册。我设计了一个 registry.json,集中登记可用技能:
{ "version": "1.0", "skills": [ { "name": "archive_files", "path": "skills/archive_files", "description_summary": "归档并整理项目文件", "enabled": true } ] }加载器启动时扫描 registry.json,逐个读取 SKILL.md 和 schema.yaml,把它们组装成一个“技能描述包”发给模型。这时候有个性能优化点:给模型看的描述是摘要版,不是完整文档。我知道很多框架会把整个文档塞给模型,但那样上下文开销太大。摘要版包含技能名、一句话职责、参数名列表,就足够模型做选择了。真正的完整文档在模型选定技能之后,由执行器按需读取。
3.4 Agent 调用技能的执行流程
执行链路是这样走的:
- 用户提出“帮我整理下载文件夹”。
- 调度器把任务语义向量化,与会话中的技能摘要做匹配。
- 模型返回“调用 archive_files”的意图,附带参数 {source_dir: "/Users/xx/Downloads"}。
- 执行器校验参数,填充默认值,调用 implement.py。
- 脚本执行完毕,返回 JSON 结果。
- Agent 把结果整理成自然语言回复用户。
这一步里最容易被忽略的是第 4 步的“执行器校验”。很多情况下模型给出的 source_dir 根本不存在,或者路径是相对路径但基准目录不对。我的做法是:在执行器里统一做“路径规范化 + 存在性检查”,不通过就直接返回建议信息,而不是把异常抛给大模型。
4. 常见问题与排查技巧实录
4.1 模型选错技能怎么办
这个问题几乎所有人都会遇到。我们遇到过:用户问“帮我总结一下这个 PDF”,模型却调用了“pdf_extract”而不是“pdf_summarize”。后来排查发现,两个技能的描述里都出现了“PDF”关键词,模型很难区分边界。解决方案是:在描述里增加“使用规则”段落,明确说“pdf_extract 只负责提取文本/图片内容,不负责理解或总结;若用户需要总结,应调用 pdf_summarize”。描述里少写修饰性词语,多写“什么情况下用我”和“什么情况下别用我”,能有效降低混淆率。
4.2 参数传错类型或缺失
模型传错参数类型,常见的处理方法是让 Schema 解析严格抛错,但我建议框架层做一层“软修复”:如果缺的是可选参数,直接填默认值;如果类型不匹配,尝试做一次保守转换;只有必填参数缺失或者转换失败,才向用户确认。这样用户体验会平滑很多,不打断对话流程。但这层“软修复”逻辑要写清楚日志,否则错误会隐藏得很深。
4.3 执行超时与结果截断
Agent 技能的耗时天差地别,快的几十毫秒,慢的几分钟。如果调度器没有明确的超时控制,会出现“模型任务还没等到结果,对话会话已经超时”。我的经验是:给每个技能设定预期耗时和硬超时两个值。预期耗时用来告诉用户“稍等一下”,硬超时到了就主动返回部分结果,比如“文件已归档 20 个,剩余还在处理中,索引会稍后生成”,好过一直卡死。
4.4 并发冲突与资源占用
多个技能同时执行时最容易出问题的是资源竞争,比如两个任务同时写同一个索引文件、同时操作同一个临时目录。我的建议是给每个执行会话分配独立的工作目录,所有输出先写到工作目录,结束时再按规则 merge 到目标位置。这个方案能挡住一大半并发问题,而且排查起来非常直观。另一个经验是:不要在技能内部依赖全局环境变量,技能应该是自包含的,参数来源必须显式声明。
5. 后续还能怎么扩展
做完这个技能库之后,我最大的感受是:整个项目最花时间的不是写代码,而是打磨技能的描述边界和参数规范。代码好不好用一目了然,但“模型能不能准确理解技能是干嘛的”这件事,需要反复测试和迭代。后来我养成了一个习惯:每次新增技能,先不看它的功能实现,而是只看描述和参数 Schema,想象自己是一个不了解业务的人,能否仅凭文档就正确使用它。
下一阶段我准备做两件事:一是给技能加“效果反馈”机制,记录每次调用的结果好坏,反过来修正描述;二是把技能注册表改造成动态加载,支持从远端仓库热更新,不用每次改动都重启整个 Agent 服务。这些方向目前还在验证中,欢迎有同样在搞 Agent 技能层的朋友多交流。
最后再分享一个小技巧:调试 Skill 描述时,不要只看成功率,要分类汇总“模型误用场景”。我用一个简单的 JSONL 日志记录每次调用请求里的技能名称和实际任务需求,每周复盘一次,很快就能定位到描述里的歧义词。这个过程比调代码本身更能提升整体系统的稳定性。