最近一段时间,我一直在整理自己的agent-skills技能库。起因很简单:我发现自己做的多个 Agent 任务反复在同样的地方翻车——同一个字段格式错误、同样的表头识别不准确、同一种输出解析失败。三番五次之后,我意识到问题不在于模型不够聪明,而在于我从来没有把“做过一遍的正确做法”沉淀下来。今天这篇文章,就把agent-skills这套方法完整拆开讲一遍,包括技能库的目录设计、输入输出合约、运行时调用逻辑、回归测试方法,以及我踩过的一些坑。
这篇文章适合两类读者:一类是刚刚开始做 Agent 开发,想让自己的大模型应用不再“靠运气输出”的开发者;另一类是已经做过几个 Agent 项目,但觉得每次都在重复调 prompt、重复解析输出,想把工程经验沉淀下来的朋友。读完之后,你至少能拿到一套可以直接抄走的技能目录规范,以及一个从需求拆解到测试回归的完整示例。
1. 先看一次真实的翻车现场:没有技能层,Agent 永远在重复踩坑
1.1 那次表格转换任务的完整过程
前不久,我需要把一个跨部门汇总的预算表转成统一结构的 JSON,方便后续程序读取。表格本身并不复杂,但表头是中文,日期列里混着“2025/1/3”“1月3日”“2025-01-03”三种写法,金额列里还有“120.5元”这种带单位的值。
我当时图省事,直接把 Excel 文件路径丢给模型,后面跟了一句“帮我把数据提取成 JSON”。结果自然不理想:模型确实识别出了大部分内容,但日期格式它自己发挥了,金额单位也没统一,更麻烦的是它把两个表头含义相近的列合并到了一起。我花了差不多半小时去一条条校对数据,最后手动改完才敢交给下游使用。
这其实是 Agent 开发里非常典型的一类失败:任务描述给了,正确的做法没给。模型知道“提取数据”是什么意思,但不知道你对日期格式、单位、空值、列名映射这些事到底有哪些确定性要求。
1.2 问题根源:把语义理解和流程控制混在了一起
很多人在设计 Agent 时会有一个错觉:模型越强,越不需要约束。实际上,模型擅长的是“语义理解”——比如看到“1月3日”知道它是个日期,看到“市场部”知道它应该放进 department 字段。但模型非常不擅长“确定性流程控制”——比如“不管原始数据怎么乱,输出必须符合某个固定 JSON 结构”“日期必须统一成 YYYY-MM-DD”“空单元格必须返回 null,不能省略”。
一旦你把这两类事情混在一起交给模型自由处理,结果就是每次输出都不一样。这次能跑通,不代表下次也能跑通。而且问题往往不是模型变笨了,而是你没把该定的规则定死。
这也是agent-skills这套思路的起点:把能确定的规则用代码固定下来,把需要理解的部分用提示词约束住,最后再用输入输出合约把两边粘起来。
1.3 agent-skills 的定位:组合“确定性代码”和“模型推理”
我一直把agent-skills理解成一种技能层规范。它不是某个具体的大模型平台,也不是一个必须引入的重量级框架,而是一套组织 Agent 能力的方式:每个技能都是一个独立的目录,里面有元信息、提示词、输入输出 schema、执行代码和测试用例。
这样做的好处很直接:当一个新的 Agent 任务出现时,我不需要从零开始写 prompt,而是先看看技能库里有没有能复用的能力;当某个技能效果变差时,我可以单独修改它,并通过测试用例确认不会影响其他技能。换句话说,技能层像是给 Agent 项目装了一个“可插拔的熟练工种”,而不是每次都用一段即兴发挥的对话去碰运气。
2. 技能库的骨架:目录、元信息和版本约束
2.1 一套我用了很久的目录结构
先给出一套我实际在用的目录组织方式,你可以直接照着建:
agent-skills/ ├── skills/ │ ├── excel_to_json/ │ │ ├── skill.yaml │ │ ├── prompt.md │ │ ├── input_schema.json │ │ ├── output_schema.json │ │ ├── execute.py │ │ └── tests/ │ │ └── cases.json │ ├── weekly_report/ │ │ └── ... │ └── translate_doc/ │ └── ... ├── shared/ │ ├── validators.py │ └── logger.py ├── loader.py └── config.yaml每个技能独占一个目录,这样技能之间互不干扰。skill.yaml是技能的“身份证”,prompt.md是给模型看的操作说明,input_schema.json和output_schema.json分别定义输入参数和返回结果的结构,execute.py是运行时真正被调用的入口,tests/cases.json则是后续做回归测试用的用例集。
把测试用例放在技能目录内部,而不是集中放在一个总目录,是我后来才养成的习惯。原因很简单:技能做久了以后,每个技能的更新节奏不一样,让测试用例跟着技能走,修改时才能一眼看到影响范围。
2.2 元信息字段:给 Agent 的“使用说明书”
每一个技能的核心是skill.yaml。我把关键字段固定在这么几项:
name: excel_to_json version: 1.2.0 description: 将一张 Excel 工作表转换为结构化的 JSON 数组,支持自定义字段映射和日期清洗。 trigger: 当用户提供 Excel 文件路径并要求提取数据时 input_schema: input_schema.json output_schema: output_schema.json prompt: prompt.md entry: execute.py tags: - excel - data_processing - json这里的description和trigger不是给人看的备注,而是给“上层路由”用的。一些 Agent 框架会根据任务描述自动选择合适的技能,如果这两个字段写得模糊,技能库再大也调度不到正确技能上。所以我一般这样写:description说明这个技能做什么;trigger说明什么场景下应该触发它。
另外,entry字段指定了运行时入口文件。比起让 Agent 直接阅读一堆代码,我更喜欢让执行器根据entry加载函数,然后统一走同一个调用接口,这样所有技能对运行时来说都是等价的。
2.3 版本约束:为什么我说这是最容易忽略的一环
技能文件会随着需求变化而改动,典型情况包括:提示词调整、输入 schema 增加字段、输出结构升级。如果没有版本约束,改动一旦让下游数据格式变化,很多已经跑着的任务就会悄无声息地坏掉。
我采用的版本规则很简单:语义化版本号MAJOR.MINOR.PATCH。修改提示词但不改变输入输出结构,PATCH增加;增加可选输入字段或新增输出字段且不影响旧调用,MINOR增加;调整了输出主结构,或者旧参数不再兼容,MAJOR增加。
在技能被调用时,配置里会写清楚允许的版本范围,例如excel_to_json >=1.2.0, <2.0.0。这样既允许小的修复自动生效,又避免大的 Breaking Change 影响现有任务。
3. 技能合约:让 Agent 知道“该交什么、该拿什么”
3.1 输入合约:参数校验放在调用模型之前
任何一个技能都应该先声明它需要哪些输入参数,并且在使用者把参数传进来之后,先做一次真正的校验,而不是直接塞给模型。这既是为了省时间,也是为了省成本——如果参数本身是错的,模型再聪明也只会输出一个错误的“合理结果”。
下面是一个参数校验用的 JSON Schema 片段:
{ "type": "object", "required": ["excel_path"], "properties": { "excel_path": { "type": "string", "description": "需要转换的 Excel 文件路径" }, "sheet_name": { "type": "string", "description": "工作表名,默认取第一个工作表" }, "fields": { "type": "object", "description": "目标字段到源表头的映射,缺省时由模型识别", "additionalProperties": { "type": "string" } } } }如果调用方没有传excel_path,或者传进来的不是字符串,运行时应该在调用模型之前立刻抛错。这就像是你请一位老师傅干活,但连材料都没准备好,老师傅再厉害也没办法。规则是:能确定的事,绝不让模型承担不确定性。
3.2 输出合约:解析失败再重试,而不是让模型自由发挥
输入要校验,输出更要校验。我常用的输出结构大致这样:
{ "status": "success", "data": [ { "date": "2025-01-03", "department": "市场部", "amount": 120.5, "note": null } ], "errors": [], "warnings": [] }warnings是我后来特意加的。很多情况下模型返回的数据整体可读,但个别单元格可能识别得不自信,与其直接让它报错,不如把“可能有问题的行”写进warnings,交给人或者上游程序判断。这比一刀切的成功失败更符合实际业务。
运行时拿到模型返回的文本之后,要先做三步处理:去掉可能包裹着的 Markdown 代码块;用 JSON 解析器取到结构体;再根据output_schema.json校验字段和类型。如果解析失败,我会把错误信息重新拼回提示词,让模型自己修正一次,而不是无条件重试很多次。交互轮数一多,成本和时间都会失控。
3.3 合约的提示词表达:让模型“照着表格填”
光是定义 JSON Schema 还不够,模型看不到 Schema 它就不知道具体规矩。所以prompt.md里需要把输入和输出的关键要求用自然语言写清楚。我一般会在提示词开头放一段非常直白的话:
你是一名数据处理执行器。请严格按照下面的输入和输出合约执行任务。 输入参数: - excel_path: {{ excel_path }} - sheet_name: {{ sheet_name }} 运行时已经提供 Excel 前 5 行的预览内容,请基于以下规则工作: 1. 根据中文表头推断字段映射,必要时参考用户提供的字段映射。 2. 日期字段统一转换为 YYYY-MM-DD 格式。 3. 金额字段去掉货币符号和单位,统一转为数字。 4. 空单元格输出 null,不要省略字段。 5. 只输出一个 JSON 对象,不要输出任何解释文字。这里有一个很关键的设计:提示词不要求模型去读整个 Excel 文件,而是由运行时准备好预览内容再填进去。大模型不适合直接干这种底层解析工作,它更适合根据一小段预览做判断。预览数据越精炼,模型输出越稳定。
4. 手写一个技能:把非规范 Excel 转成结构化 JSON
4.1 需求拆解:哪些交给代码,哪些交给模型
还是用前面的 Excel 转 JSON 任务作为例子。在动手写技能之前,我会先做一个拆解:哪些环节应该用代码完成,哪些环节必须靠模型推理。
代码部分负责读取文件、生成预览、执行最终转换、类型校验。这些操作都有明确规则,不需要语义理解。模型部分负责识别中文表头、判断两个表头是否表示同一个语义字段、识别日期和金额的混合写法。这些工作没有固定公式,正好是模型擅长的地方。
这么一拆之后,技能的核心逻辑就变成了:**代码生成预览,模型做语义映射,代码再按映射执行转换。**模型不直接面对整个文件,代码也不去机械地猜测列名含义,两边各干各的活。
4.2 定义目录与元信息
我在agent-skills/skills/excel_to_json/下初始化了技能目录。skill.yaml写成这样:
name: excel_to_json version: 1.2.0 description: 将 Excel 工作表转换为 JSON 数组,支持列名映射、日期标准化和金额单位清洗。 trigger: 用户上传 Excel 或提供 Excel 路径并要求结构化提取数据 input_schema: input_schema.json output_schema: output_schema.json prompt: prompt.md entry: execute.py tags: - excel - json - data_cleaning配上前面已经写好的input_schema.json和稍微扩展过的output_schema.json,一个技能的基础结构就搭好了。这一步看起来简单,但关键时刻能救命:过一个月你回来看这个技能,不需要重新读代码,光看 YAML 就能知道它能干什么、怎么调。
4.3 提示词正文的细节
prompt.md里需要写得足够具体,但不能把具体数据也写死。我的做法是把它做成一个模板,运行时渲染后再发给模型。下面是一个精简版本:
你是一名数据提取专家。请分析运行时提供的 Excel 预览,完成字段映射和格式判断。 预览表头: {{ preview_header }} 预览数据: {{ preview_rows }} 已知输入参数: excel_path: {{ excel_path }} sheet_name: {{ sheet_name }} 用户指定映射:{{ fields }} 需要你输出的内容: 1. 一个字段映射对象,表示“目标字段 -> 源表头”。 2. 每个字段的类型判断结果,是 string、number 还是 date。 3. 日期格式约定,如果源数据不是标准格式,给出转换规则。 4. 金额字段的单位信息,方便运行时统一转换。 请使用 JSON 输出,结构如下: { "field_mapping": {}, "field_types": {}, "date_formats": {}, "currency_units": {} }这样模型返回的其实是“转换指令”,而不是直接返回最终数据。拿到指令后,我用代码去读取原始 Excel,按指令完成表格转换。这样比让模型直接生成全部 JSON 更稳,因为代码可以把数值精度、空值处理、去重这些机械操作做到位。
4.4 运行时执行流程:代码调用模型再调代码
技能目录里的execute.py是运行时真正执行的入口。我把它实现成一个非常单纯的流程:
def execute_skill(skill_name, inputs, llm_client): skill = loader.load(skill_name) # 第一步:参数校验 validate_inputs(skill, inputs) # 第二步:代码读取 Excel 并生成预览 preview = build_preview(inputs["excel_path"], inputs.get("sheet_name"), rows=5) # 第三步:渲染提示词并调用模型 prompt = render_prompt(skill, inputs, preview) raw_output = llm_client.chat([{"role": "user", "content": prompt}]) # 第四步:解析模型返回的转换指令 instruction = extract_json(raw_output) # 第五步:用代码按指令执行真正转换 result = apply_instruction(inputs["excel_path"], instruction) # 第六步:输出结构校验 validate_outputs(skill, result) return resultllm_client.chat只是一个抽象接口,在实际项目里,我在它后面包了各类对话模型的调用适配层,但技能内部不关心底层模型是谁,只要返回一段文本足够。这样的好处是:模型服务商换了,技能不需要跟着改。
4.5 实测一次完整调用
我用一份模拟的混乱表格测试了一次。源数据大致是:
| 日期 | 部门 | 金额 | 备注 |
|---|---|---|---|
| 2025/1/3 | 市场部 | 120.5元 | 线下活动 |
| 1月3日 | 销售部 | 88 | null |
最终技能返回:
{ "status": "success", "data": [ { "date": "2025-01-03", "department": "市场部", "amount": 120.5, "note": "线下活动" }, { "date": "2025-01-03", "department": "销售部", "amount": 88.0, "note": null } ], "errors": [], "warnings": [] }注意第二条的日期被正确识别成了2025-01-03。这个“修正”不是模型硬编码出来的,而是模型通过“当前年份=2025、月份=1”的事实推断出来的。代码不写死年份,是因为技能以后还能继续复用到其他年份的数据上。
5. 技能测试与回归:把一次性的成功变成长期资产
5.1 用快照用例锁住技能行为
技能最怕的不是第一次跑不通,而是跑通之后某个星期突然坏了。原因可能是模型服务升级了,也可能是技能提示词被改过。这时候如果没有回归用例,你根本不知道是哪个环节出的问题。
我在每个技能目录下放tests/cases.json,里面记录典型输入、期望行为和一些需要满足的硬性条件。下面是一个片段:
[ { "id": "basic_with_mixed_dates", "input": { "excel_path": "samples/mixed_dates.xlsx", "sheet_name": "Sheet1" }, "assert": { "status": "success", "data[0].date": { "regex": "^\\d{4}-\\d{2}-\\d{2}$" }, "data[0].amount": { "type": "number" } } } ]我不会拿模型输出和某个固定字符串做完全相等比较,因为模型有随机性。更实用的办法是断言关键字段:日期必须符合正则,金额必须是数字,状态必须是 success。只要这些硬条件满足,一次输出有一点描述性差异完全可以接受。
5.2 每次修改都跑的回归流程
我的做法很简单:每次改完提示词或执行代码之后,就跑一遍技能目录下的全部用例。甚至不用很复杂的测试框架,一个脚本就能搞定:
python agent_skills_test.py --skill excel_to_json --cases tests/cases.json脚本会按用例加载输入,调用真实的运行时流程,然后逐项检查assert里的规则。一旦某个用例失败,我会先判断是模型问题还是代码问题:如果模型输出格式完全偏离 schema,多半是提示词需要加强约束;如果 schema 对了但转换后的数据不对,问题多半在执行代码。
5.3 失败样本的三种命运
回归测试跑得越多,越能发现一些奇奇怪怪的失败样本。我通常把它们分三类处理:
一类是模型能力不够导致的错误,比如它确实看不懂某种缩写表头,这种情况我会考虑补充示例到提示词里,而不是强行要求模型“再聪明一点”。第二类是合约歧义,即输入输出 schema 里没有把边界写清楚,导致模型每个批次的理解都不一样,这种情况需要修改 Schema 或提示词里的描述。第三类是代码处理错误,比如单位转换逻辑没考虑“万元”这种特殊情况,这种情况直接在代码里修。
失败的样本从来不是垃圾,它们是技能最值钱的调试素材。我每次处理完一个失败样本,都会把它作为一个新的回归用例加进tests/cases.json,因为同一个坑第二次踩到的概率非常高。
6. 我踩过的坑,和现在会直接用的几条规则
6.1 提示词模板不是技能,执行入口才是
最开始我偷懒,一个技能就是一个.md文件,里面写上一大段提示词,然后用一个非常通用的 Agent 循环去调用。遇到简单任务还好,一旦任务需要先读文件、再中间计算、最后输出结构化结果,这种纯提示词方案就彻底不够用了。
真正让我改变思路的是一次数据去重任务。模型明明看到了重复行,却还是输出了两条,因为提示词里写了“请去除重复项”,但模型对“哪一行算重复”的理解和我不一样。后来我把去重逻辑直接写进execute.py,模型只负责判断“哪些字段组合起来代表唯一记录”,代码负责真正删掉重复行。从那时起我养成了一个原则:凡是可以确定成代码逻辑的事情,就绝不放进提示词里靠模型猜。
6.2 别想着一个技能解决所有场景
有一阵子我想做一个“万能的文档解析技能”,既能解析 Excel,也能解析 PDF、Word、图片扫描件。结果做了一半就发现整个技能越来越臃肿,光输入参数就有十多个,加载出来的提示词连我都嫌冗长,模型当然更容易分心。
后来我把它们拆成了独立技能,每个技能只解决一种输入类型和一类输出目标。表面上看技能数量变多了,但每个技能的质量更稳。如果某个需求同时需要两个技能配合,我会在调用层做编排,而不是把两个技能强行塞进一个目录。保持技能单一职责,是所有后续扩展的前提。
6.3 对 Agent 成本的一个务实看法
很多人在做技能时会担心提示词太长,浪费 token。我的实测结论是:稳定的输出比省一点 token 重要得多。一个输出不稳定的任务,往往需要反复调用模型、反复人工修正,最后算下来成本远高于在提示词里多写几条规则。
不过确实要注意提示词的有效信息密度。我写prompt.md的原则是:每个句子都只服务一个目的,删掉所有“你是一个优秀的数据处理专家”这类空话。让模型看到的每一行文字,都直接影响它下一步的行为。这样即使提示词长一些,也不会有太多浪费。
6.4 后续可以继续扩展的方向
agent-skills这套技能库还有很多可以挖的地方。第一是技能的依赖关系,比如一个“生成周报”的技能可能需要先调用“ excel 转 JSON”技能;第二是技能热更新,当线上的某个技能版本出现问题,可以快速回滚到旧版本;第三是给技能加权限控制,哪些调用方可以用哪些技能,需要在上层做统一管理。
不过所有扩展都要建立在基础结构足够清晰的前提下。如果每个技能都自带独立的执行入口、清晰的元信息和回归用例,后续无论加什么外围能力都会自然很多。我现在的习惯是每新增一个技能,都先问自己三个问题:它能做什么、它什么时候触发、它怎么验证没问题。三个问题答完,技能也就成型了大半。