如果你做过一段时间的 AI Agent,大概率遇到过这个尴尬:模型本身很聪明,但翻来覆去就是记不住那些放不进一句话指令里的操作细节。我最初也把各种能力写成提示词片段,塞进系统提示词里,结果 prompt 从几百行膨胀到几千行,改一个动作就像在雷区里布线,牵一发动全身。后来被逼着做了个项目,就叫agent-skills。它不是什么新框架,而是一套把“技能”从 Agent 主体里拆出来、按结构化文件管理、由模型按需加载的轻量方案。
这个项目能解决的问题很具体:技能不再散落在代码分支和 prompt 字符串里,而是收敛到独立目录;模型不再靠“碰运气”去调用能力,而是通过统一格式感知到“有哪些技能、什么时候该用、参数怎么传”。适合想自己搭建 Agent 轮子、又不想被重型框架绑架的开发者参考,也适合已经在用 Agent 但经常被“漏调用”“乱调用”困扰的团队。下面我会从设计思路、技能文件结构、加载器实现、常见问题几个方面,完整拆一遍这次实操的沉淀。
1. 项目整体设计与思路拆解
1.1 核心问题:技能散落导致 Agent 不可控
先说为什么要单独做一个 agent-skills。我早期做 Agent 时,技能基本以三种形态存在:第一,直接写在系统提示词里,比如“当你需要查天气时,调用 weather 函数”;第二,以大量 if-else 分支硬编码在调度代码里;第三,散落成若干个外部脚本,靠模型偶尔想起来拼出完整命令。
这三种形态各有各的问题。提示词里的技能描述多了以后,模型注意力会被稀释,经常出现该调用的时候不调用、不该调用的时候乱调用;硬编码分支虽然稳定,但每加一个新能力都要改主流程,代码被各种业务条件塞满;外部脚本则完全没有索引,模型不知道它们的存在,自然也不会主动用。更麻烦的是,技能一多,测试和排查只能靠翻日志猜,改了 A 技能还可能影响 B 技能的触发条件。
agent-skills 的核心设计就是把技能从这些地方“抠”出来,变成一个目录 + 约定文件的结构:每个技能有自己的目录,目录里包含说明文件、实现脚本、参数定义、示例。Agent 启动时扫描目录,生成一份能力清单,后续模型就是拿着这份清单去决策。这样做的好处是,技能之间天然隔离,新增技能只需要新建目录,不需要动主循环。
1.2 方案选型:为什么用结构化文件而不是纯函数
设计之初我犹豫过:既然最终都要执行代码,为什么不直接写成 Python 函数,再用装饰器注册?这样不是更简单吗?试过之后发现,纯函数方案有一个致命短板:模型感知不到函数内部实现,它只能通过“函数名 + 一句话签名”判断要不要调用。当函数数量超过几十个时,光靠装饰器里的 docstring,模型很难稳定区分两个功能相近的函数,触发冲突率会显著上升。
后来我参考了“工具描述 + 参数 schema”的思路,把技能拆成三个部分:人读的描述文档(SKILL.md)、机器读的参数定义(schema.json)、实际执行逻辑(main.py)。模型决策时主要看描述文档和 schema,执行时才加载 main.py。这和“菜单式点菜”很像:菜单要把每道菜的原料、口味、适合场景写清楚,后厨才根据订单做菜。如果菜单信息模糊,客人就会点错菜。
我把纯函数、prompt 内嵌、结构化文件三种方案做过一次对比,结论很直观:
| 方案 | 模型感知成本 | 新增技能成本 | 运行稳定性 | 调试友好度 |
|---|---|---|---|---|
| 纯函数注册 | 低,但易混淆 | 中 | 高 | 中 |
| 提示词内嵌 | 极低,随篇幅下降 | 低 | 低 | 差 |
| 结构化文件 | 高 | 低 | 高 | 好 |
最终选定结构化文件,核心原因是它把“模型的注意力”当成稀缺资源来对待。我们做的每一次技能描述,都是在帮模型降低决策时的认知负担。描述写得好不好,直接决定模型选得准不准,这一点光靠代码注册很难做到。
1.3 技能与工具、工作流的边界划分
做 agent-skills 之前,我还经常搞混“技能”“工具”“工作流”三个词。如果不把边界划清楚,目录结构就会变成一锅粥。
我现在的定义比较朴素:工具是最小的原子操作,比如“发送邮件”“读取文件”“调用某个接口”,它只做一件事,不关心上下文;技能是为了完成某个子目标而组合出来的能力,比如“整理日报”,它可能要读数据、渲染模板、然后发送邮件;工作流则是多个技能加上业务规则、状态流转的编排,比如“每天早上自动生成日报并发送给指定人”,这已经不是单个技能能覆盖的范畴了。
用生活里的例子类比:工具箱里的扳手、螺丝刀是工具,会修水龙头算是一项“技能”,而“根据业主电话诊断问题、带工具上门、修完做测试”就是工作流。agent-skills 主要管中间那一层,也就是技能。它向上承接工作流的调度,向下封装工具的调用。
边界划清以后,技能目录的粒度反而好定了:如果一个技能需要用户反复确认“数据的起始时间和截止时间”,那它可能更接近工作流,应该再拆细一点;如果一个技能小到只是读取一个文件,那它应该归属于工具层,不应该单独立项。我在实际迭代中一直守着这个粒度,少走了很多弯路。
2. 技能库的核心细节与实操要点
2.1 技能文件的目录与字段设计
agent-skills 的技能目录长这样:
skills/ 日报生成/ SKILL.md main.py schema.json examples/ case1.json case2.md 数据汇总/ SKILL.md main.py schema.json 文件归档/ SKILL.md main.py schema.json其中 SKILL.md 是给模型看的,也是整个系统里最关键的“人机接口”。它的编写规范我经过了好几轮调优才稳定下来。初始版本我写得很随意,模型老是选错,后来慢慢沉淀出一套固定字段。
SKILL.md 的字段表格如下:
| 字段 | 是否必填 | 作用 | 说明 |
|---|---|---|---|
| name | 是 | 技能名称 | 尽量是动宾结构,如“生成日报” |
| description | 是 | 模型决策依据 | 说明什么时候用、什么时候不用 |
| arguments | 是 | 参数定义 | 以 JSON Schema 格式写在 schema.json 里 |
| returns | 否 | 返回说明 | 描述返回给模型的内容格式 |
| env | 否 | 运行依赖 | 所需环境变量或外部服务 |
| tags | 否 | 分类标签 | 便于按场景索引 |
| version | 否 | 版本号 | 技能变更追踪用 |
main.py 是实际执行入口,我统一约定一个run(params)函数,params 是一个字典,执行完返回一个字符串或可序列化的结构。schema.json 则直接定义参数格式。这两层分离的一个额外好处是,模型只接触 SKILL.md 和 schema.json,不会看到实现代码,某些包含密钥或内部逻辑的技能也能更安全地暴露给 Agent。
2.2 描述文本怎么写才能被模型稳定命中
这部分属于“看着简单,做起来全是坑”的核心环节。大语言模型不是靠代码编译去理解技能的,它靠的是对自然语言描述的语义匹配。也就是说,描述文本里出现的动词、场景词、边界词,直接决定了模型能不能在正确时机命中技能。
我总结了一套描述公式:
在[什么场景/任务]下,当[关键条件]时,使用本技能[做什么]。如果[什么情况],请不要使用本技能。比如一个“生成日报”的技能,我会写成:
name: 生成日报 description: | 在用户需要查看项目进展、汇总当日工作、生成日终报告时使用。 当用户提供日期或提到“日报”“日终总结”时,优先使用本技能。 如果用户只是询问数据走势,没有要求生成报告,请不要使用本技能。注意最后一句边界描述,它非常重要。我早期没有写负向约束,模型经常在用户说“给我看下今天的数据”时,直接触发日报生成,最后生成了一堆客户根本没想看的总结。后来我统一补齐了“不要使用”的描述,误触发率下降了一大截。
此外,我还会在 SKILL.md 里加一到两个典型示例。示例不需要长,两句就够,比如:
示例: - 用户说“帮我写一下今天的日报”,输出结构化 Markdown 报告。 - 用户说“把最近三天的数据整理成日报发我邮箱”,先生成报告再发送邮件。这个细节能让模型更快建立“文本到技能”的映射,尤其当两个技能描述相似时,示例是区分它们的最有效手段。
2.3 参数定义与输入校验
参数定义我直接采用的是 JSON Schema 的一段子集,没有引入额外依赖。schema.json 的结构如下:
{ "type": "object", "properties": { "date": { "type": "string", "description": "日报对应的日期,格式 YYYY-MM-DD,默认值为当天", "enum": ["2024-01-01", "2024-01-02"] }, "include_metrics": { "type": "boolean", "description": "是否包含核心指标模块", "default": true } }, "required": ["date"] }这里踩过一个大坑:如果只给 type,不给 description,模型就很容易传错值。比如 date 字段,模型可能传成“今天”“昨天”这种相对表达,或者传成时间戳。所以我在每个 property 的定义里都写清“格式是什么、默认值是什么、从用户哪句话里提取”,这对提升参数命中率帮助很大。
参数校验我放在执行器里,不放在 main.py 里。原因是 main.py 更关注业务逻辑,校验逻辑集中起来才能统一处理错误。校验失败时,返回给模型的错误信息必须明确,不能只是一个“bad params”的笼统报错,我会拼出具体是哪个字段不合法、期望什么格式、实际收到了什么值。这样模型在下一轮对话里还能自纠。
3. 实操过程:从零搭建一个技能加载器
3.1 准备目录与装载逻辑
有了技能目录和约定格式,接下来要写一个轻量加载器。我用 Python 实现,核心逻辑就是递归扫描skills/目录,读取每个技能的子文件,然后构建技能索引。
import json from pathlib import Path from typing import Dict def load_skills(base_dir: str = "skills") -> Dict[str, dict]: skills = {} base = Path(base_dir) for skill_dir in base.iterdir(): if not skill_dir.is_dir(): continue skill_md = skill_dir / "SKILL.md" schema_file = skill_dir / "schema.json" main_file = skill_dir / "main.py" if not (skill_md.exists() and schema_file.exists() and main_file.exists()): continue skills[skill_dir.name] = { "name": skill_dir.name, "description": skill_md.read_text(encoding="utf-8"), "schema": json.loads(schema_file.read_text(encoding="utf-8")), "main": skill_dir / "main.py", "dir": skill_dir, } return skills这段代码不长,但有一个细节很重要:skills的 key 用的是目录名,而不是 SKILL.md 里的 name 字段。目录名是唯一标识,name 字段更像给人或模型展示的名字。两者不一致时,以目录名为准,避免重名冲突。
目录装载完成后,我会把技能列表压缩成一段“总清单”,交给模型。清单里不包含 main.py 实现,只包含每个技能的名称和 SKILL.md 的描述前几十个字符。模型决策只看清单,真正执行时才 import 对应脚本。
3.2 技能注册与动态加载
加载器写完后,要解决的是“何时重新扫描”的问题。Agent 进程如果常驻,技能目录很可能在运行中新增或修改。最省事的办法是每次调用前全量扫描,但技能多了以后会浪费不必要的磁盘 I/O。我折中了一下:给每个技能目录记录文件哈希值,调用时先看哈希有没有变化,有变化才重新加载。
import hashlib def _dir_hash(path: Path) -> str: h = hashlib.md5() for f in sorted(path.iterdir()): if f.is_file(): h.update(f.read_bytes()) return h.hexdigest()注册逻辑我用一个简单字典维护,技能对象包括meta(元信息)、schema、runner三个部分。runner 是动态 import main.py 后拿到的run函数。注意 import 之后要用importlib.reload处理修改场景,否则同一个模块只会被加载一次,后续改代码不生效。
import importlib.util def _load_runner(main_path: Path): spec = importlib.util.spec_from_file_location( main_path.stem, main_path ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module.run这一层看起来简单,但它是“动态加载”的根基。很多 Agent 框架把技能写死在代码里,本质是放弃了热更新能力。而我这边改一个技能实现,只需要替换对应目录下的 main.py,下次调用自动生效,这在实际迭代中非常省事。
3.3 与 Agent 主循环的对接方式
有了技能索引,接下来要把它接入 Agent 主循环。我没有用复杂的消息总线,而是走一个非常轻的协议:
- Agent 拿到用户消息;
- Agent 读取技能清单;
- Agent 决策:可能直接回复,也可能请求调用某个技能;
- Agent 请求调用时,会带上“技能名 + 参数”;
- 执行器校验技能名和参数,调用
run,得到结果; - 执行器把结果回传给 Agent,Agent 根据结果继续生成回复。
伪代码大致是这样:
def handle_turn(user_message, skill_registry, llm): skill_list = [{ "name": s["name"], "description": s["description"], "arguments": s["schema"], } for s in skill_registry.values()] prompt = build_prompt(user_message, skill_list) response = llm.complete(prompt) if response.tool_call: skill_name = response.tool_call.name params = response.tool_call.arguments if skill_name not in skill_registry: return "错误:技能不存在" result = skill_registry[skill_name].run(params) final_prompt = build_final_prompt(user_message, result) return llm.complete(final_prompt) return response.text这里的核心思想是“模型只输出意图,不执行代码”。技能列表只是决策上下文,真正的执行由可信的执行器完成。这样做一方面防止模型生成的不安全代码被直接执行,另一方面也方便在调用前后加日志、限流、审计。
3.4 一次完整的调用链路示例
为了更直观地展示效果,我模拟一个请求链路。用户输入是:“帮我生成昨天的日报,只看核心指标。”执行链路如下:
第一步,Agent 读取技能清单,发现“生成日报”技能,描述里明确说明“用户提供日期或提到日报时优先使用”。模型判断符合条件,生成技能调用请求。
第二步,执行器收到请求,技能名是“生成日报”,参数是{"date": "2024-06-10", "include_metrics": true}。
第三步,执行器校验 schema,date 格式合法,include_metrics 是布尔值,进入执行。
第四步,main.py 里的run函数读取数据源、计算指标、渲染模板,返回一段 Markdown 报告字符串。
第五步,执行器把返回值交给模型,模型基于报告生成一段自然语言摘要,补上“昨天核心指标整体向上,其中新增用户较前日提升 12%”这类解读。
最终呈现给用户的内容是“报告 + 解读”,而不是冷冰冰的数据。这个过程里,技能只负责“生成”,模型负责“理解与表达”,分工非常清晰。结构化的调用链让每一步都可观测、可回放、可测试。
4. 常见问题与排查技巧实录
4.1 模型总是选错技能怎么办
这是我在使用 agent-skills 过程中最常被问的问题。选错技能通常表现为:用户明明问数据汇总,模型却调了日报生成;同一个功能有两个相似技能,模型每次都用错。
排查顺序我固定为三步。第一步,看两个技能的 description 语义是否重叠;第二步,看名称是否容易混淆;第三步,看示例是否写清了区分边界。一旦找到重叠点,就给其中一个加上更严苛的边界描述,比如“仅当用户明确提到‘终稿’时使用,导出草稿请使用另一个技能”。
还有一种情况是模型在犹豫时倾向于调用“默认技能”。解决方法是把更通用的技能排在描述清单前面,或者把容易混淆的技能名改成表意更明确的长名称。比如“文件归档”和“数据快照”容易混淆,我就把后者改成“按时间点保存数据快照”,模型识别度立刻提高。
4.2 技能之间的命名冲突
随着技能数量增长,目录名重复是迟早的事。比如团队里两个人分别做了“导出报表”,但导出格式完全不一样。我在加载器里做了一个“命名空间隔离”机制:技能目录名允许重名,但会把父级分类目录作为前缀。
skills/ 财务/ 导出报表/ 运营/ 导出报表/加载时生成技能 key 为财务/导出报表、运营/导出报表。模型看到的是带分类前缀的技能名,自然能区分。如果确实需要全局唯一的短名字,我支持在 SKILL.md 里加一个alias字段,给模型提供一个更友好的别名。但运维排错时,永远用完整路径。
4.3 参数类型不匹配与隐式转换
技能执行时报错,常见原因就是模型传参类型不对。比如 schema 里要求date是字符串,模型却给了一个数字时间戳。做严格校验当然可以,但会导致假失败增多。我采用的策略是“显式转换 + 失败兜底”。
执行器会维护一张转换表:字符串、整数、浮点数、布尔值之间的常见写法都做一次隐式转换尝试。比如布尔值,模型传true或True都能接受;日期字段,模型传时间戳时会尝试转成YYYY-MM-DD。如果转换失败,执行器不会直接崩溃,而是把错误信息拼好回传给模型,让模型自行修正后重新调用。
这里有个容易忽略的小细节:隐式转换不能“闷头改”。我要求每条转换逻辑都必须输出日志,例如“date 参数由 1718000000 转换为 2024-06-10”,这样排错时能分清是谁的锅,是模型传错,还是转换逻辑设错了。
4.4 技能调试的“黑盒”问题
最常遇到的调试场景是“模型没调用技能,但用户觉得它应该调用”。为了还原现场,我在执行器里加了三层日志:调用前打印“即将调用技能 X,参数为 Y”,执行后打印“返回值为 Z,耗时 T”,另外对所有旁路分支也打印决策原因。
比如模型因描述不足没有调用技能,日志里会清晰看到模型只生成了文本回复,执行器根本没有收到调用请求。你要做的事情就聚焦到“为什么描述没被命中”上,而不是在代码里海捞。
另外一个实战技巧是,调试时把模型温度固定为 0,并固定随机种子。否则同一个 prompt 跑五次,模型行为都不一样,根本没法对比改动效果。我通常在技能库的调试配置里直接写上固定参数,上线前再放开,这个小小习惯帮我省了大量反复验证的时间。
5. 写在最后:几个让我少踩坑的习惯
抛开技术细节,agent-skills 这个项目给我最大的收获,不是代码写得多漂亮,而是建立了一套“技能也是一种产品”的思维。每一个技能都要有明确的使用边界、参数说明、示例场景,这和给用户写功能文档没有本质区别。技能数量少的时候可能感觉多余,但一旦超过二十个,你就能体会到结构化的力量。
我再分享两个一直在坚持的习惯。第一个是每次新增技能,强制自己先写 SKILL.md 再写 main.py,描述都写不清晰的能力,不配被模型调用;第二个是每个技能都配一个最小可运行的 example 文件,它既是模型少样本示例,又是回归测试的基线。后面我做技能重构时,就是靠这些 example 文件保证行为不一致时能第一时间被发现。
如果你也在被 Agent 技能管理折磨,不妨从一个小目录开始,把最常用的三五个能力先拆出来,跑通加载和调用链路,再慢慢扩展。不必一开始就追求完美,让模型先稳定命中一个技能,再复制这套模式去覆盖更多场景,这条路是走得通的。