ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI技能工程实战指南:从零构建标准化的可复用技能模块

AI技能工程实战指南:从零构建标准化的可复用技能模块 说实话第一次看到 “skills” 这个标题时我愣了一下。这东西说起来太宽泛了简历上写的是 skills游戏里加点也是 skills结果等我点进去才发现这词在 AI 工程圈里早就有了一层更具体的含义——把一项能力打包成模型可以复用的模块。我花了两周时间把自己手头一个零散的脚本工程整理成了一个标准化的 skills 工程整个过程踩了不少坑也把很多原本模糊的设计决策重新想清楚了。这篇文章就记录一下我是怎么拆解、设计和落地这套东西的希望能给正在做类似方向的同行一点参考。1. 为什么我决定把“技能”产品化1.1 提示词和技能的差别在哪里很多人觉得给模型写一段详细的提示词不就等于教了它一个新技能吗理论上是的但实际用起来你会发现差得远。提示词是一次性的、无状态的它存在于对话上下文中模型每次都要重新“理解”你的要求。如果你想让模型完成一个稍微复杂一点的任务比如“把文件夹里的图片按拍摄日期归档”提示词只能描述这个任务但模型没有能力真正去遍历文件系统、读取照片的 EXIF 信息、创建新目录。它只能给你一段代码让你自己拿去跑。而一个标准化的 skills 工程核心思想是把“描述”和“执行”绑在一起。它不只是一个说明文档还携带了可运行的代码、参数定义、输入输出约定。模型可以自己判断什么时候该调用这个技能然后调用脚本去真正把活干完。这种“描述 执行”双通道的设计是我认为 skills 工程和传统提示词工程最本质的区别。我这套工程最初就是一个文件夹里堆着十几个脚本每个脚本干一件小事自己用的时候靠记忆调用换个环境就完全抓瞎。后来我意识到问题的关键不在脚本本身而在于这些能力没有被结构化地暴露给模型和队友。我开始动手把它整理成标准技能仓库。1.2 技能工程解决的核心痛点在整理之前我面临的痛点非常具体团队里每个人都会写一点脚本但脚本的命名风格、参数格式、输出格式各搞各的。比如有人喜欢把结果打印在控制台有人喜欢写 JSON 文件有人甚至直接弹个对话框。当我要把这些能力接入自动化流程时每次都得先看一遍源码才能知道怎么调用。这其实就是技能工程要解决的核心问题——能力复用。把一项能力封装成统一接口的模块后谁都不需要关心内部实现只需要按照约定传入参数、解析输出就行了。这和我们做后端 API 的思路一模一样只不过这里的调用者不仅是人也可能是模型。另一个痛点是冷启动。新同学加入项目看代码仓库要花好久才能搞清楚有哪些工具可用。如果有一个技能清单文件把每个技能的用途、参数、输出格式都写清楚冷启动时间能压缩到一个小时以内。我就是冲着这个目标去的。1.3 我给自己定的三条设计原则动手整理之前我给自己定了三条硬性原则后来的事实证明这三条原则帮我避免了很多返工。第一单一职责。每个技能只做一件事哪怕这件事很简单。不要试图做一个“全能工具”脚本因为一旦职责变多参数、输出、错误处理都会快速膨胀最终变得不可维护。第二无外部依赖优先。有外部服务调用的技能确实炫酷但依赖越多出故障的概率越高。我在设计技能仓库的第一版时绝大多数技能只用 Python 标准库保证在任何机器上 clone 下来就能跑。等基础稳了再慢慢加外部依赖。第三可测试性。每个技能必须配套测试用例和样例输入输出。没有测试的技能本质上就是一段不敢改动的祖传代码。我宁可多花半小时写测试也不愿意在线上调试两小时。2. 技能仓库的整体设计拆解2.1 标准目录结构长什么样我最终的技能仓库目录结构长这样skills/ ├── README.md ├── skills.yaml ├── file_archiver/ │ ├── SKILL.md │ ├── src/ │ │ ├── __init__.py │ │ └── archiver.py │ ├── tests/ │ │ └── test_archiver.py │ └── examples/ │ ├── sample_input.json │ └── sample_output.json ├── meeting_minutes/ │ ├── SKILL.md │ ├── src/ │ │ └── minutes.py │ └── tests/ └── text_summarizer/ ├── SKILL.md ├── src/ │ └── summarizer.py └── tests/每个技能是一个独立的子目录包含四个部分SKILL.md 是技能说明书src 是实际执行的代码tests 是测试用例examples 是样例数据。顶层的 README 和 skills.yaml 是整个仓库的索引和总览。这样设计的逻辑很简单每个技能目录独立演进互不干扰。如果你只想用某个技能直接进对应目录就行如果你想给别人分享其中一个技能打包这个目录也够了。顶层索引文件只是为了方便框架或者人在全局视角上快速发现和加载这些技能。2.2 元信息文件 skills.yaml 的作用skills.yaml 是整个仓库的“注册表”。我最初的版本没有这个东西结果随着技能数量变多我发现就算有 README也没法快速回答“我到底有哪些技能”这个问题。后来加了索引文件收获立竿见影。version: 1.0 skills: - id: file_archiver name: 文件归档器 description: 按扩展名或日期自动整理文件夹 entry: file_archiver/src/archiver.py arguments: - path - mode output: json tags: [filesystem, automation] - id: meeting_minutes name: 会议纪要助手 description: 将会议录音转写文本整理成结构化纪要 entry: meeting_minutes/src/minutes.py arguments: - transcript output: json tags: [text, productivity]这个文件看起来简单但它实际上是一个机器可读的 API 清单。任何一个加载框架——不管是我自己写的脚本还是市面上任何兼容的技能加载器——都能通过解析这个文件自动注册所有技能而不需要人去逐个目录翻找。2.3 SKILL.md 怎么写才不废话SKILL.md 是整个技能目录的灵魂。它的读者有两个人和模型。写得太简略模型看不懂什么时候该调用写得太啰嗦人看着累模型也会被噪声干扰。我采用的模板结构如下--- name: file_archiver description: 将指定目录下的文件按扩展名归档到对应子目录。 when_to_use: 当用户需要整理文件夹、按文件类型分类、清理目录时使用。 when_not_to_use: 当用户需要重命名单个文件、修改文件内容时不要使用。 --- ## 行为 1. 扫描输入路径下的所有文件不递归子目录。 2. 按扩展名分组创建对应子目录。 3. 移动文件到子目录或复制文件由 mode 参数控制。 4. 返回包含文件数量和分组信息的 JSON 结果。 ## 参数 - path: 要处理的目录路径。 - mode: move 或 copy默认为 move。 - dry_run: 布尔值为 true 时只输出计划不实际执行。 ## 示例 用户帮我把 Downloads 文件夹整理一下 调用{path: /home/user/Downloads, mode: move, dry_run: false}注意when_to_use和when_not_to_use这两段。这是在告诉调用方不管它是人还是模型“你在这个场景下该用我在那个场景下不该用我”。有了这两段模型误调用的概率会明显下降。我实测下来加了这个之后误触发率大概降了一半多。3. 核心实现一个可复用技能从零到落地3.1 选型Python 标准库就够了吗我先拿file_archiver这个技能练手因为它足够简单又足够典型有输入参数、有文件系统操作、有结构化输出完全可以代表一类常见的技能模式。实现语言上我选了 Python没有用 Node 也没有用 Go。原因很简单Python 是 AI 工程生态里最通用的语言不管是模型生成的代码还是同行贡献的代码大概率都是 Python。而且文件系统操作在 Python 里非常顺手os 和 shutil 两个标准库就能覆盖绝大多数需求不需要引入任何第三方依赖。“无外部依赖”这条原则在选型这里起到了决定性作用。如果这个技能需要调用什么云服务 API或者需要安装一个第三方库那么它在任何没有预装该依赖的环境里就会直接报错。而使用标准库意味着你只要装了 Python 3.8 以上这个技能就能跑。3.2 核心代码的实现与注释我实现的archiver.py核心逻辑概略如下#!/usr/bin/env python3 文件归档技能按扩展名将目录下的文件分组归档。 用法python archiver.py --path 目录 [--mode move|copy] [--dry-run] import argparse import json import os import shutil from collections import defaultdict def scan_files(target_dir): 扫描目录下所有文件不含子目录返回文件名列表。 return [f for f in os.listdir(target_dir) if os.path.isfile(os.path.join(target_dir, f))] def group_by_extension(files): 按扩展名对文件列表分组扩展名统一小写。 groups defaultdict(list) for f in files: ext os.path.splitext(f)[1].lower() or no_ext groups[ext].append(f) return dict(groups) def plan_archive(target_dir, mode, dry_run): 生成归档计划并执行。 返回一个包含计划、执行结果、摘要的报告字典。 target_dir os.path.abspath(target_dir) files scan_files(target_dir) groups group_by_extension(files) report { source: target_dir, mode: mode, dry_run: dry_run, groups: {}, moved: 0, errors: [], } for ext, file_list in sorted(groups.items()): ext_dir_name ext[1:] if ext.startswith(.) else ext if ext no_ext: ext_dir_name no_extension dest_dir os.path.join(target_dir, ext_dir_name) os.makedirs(dest_dir, exist_okTrue) group_info {files: [], destination: dest_dir} for f in file_list: src_path os.path.join(target_dir, f) dst_path os.path.join(dest_dir, f) if mode copy: shutil.copy2(src_path, dst_path) elif mode move and not dry_run: shutil.move(src_path, dst_path) group_info[files].append(f) report[moved] 1 report[groups][ext] group_info return report def main(): parser argparse.ArgumentParser(description文件归档技能) parser.add_argument(--path, requiredTrue, help要归档的目录路径) parser.add_argument(--mode, choices[move, copy], defaultmove) parser.add_argument(--dry-run, actionstore_true, help只显示计划不实际执行) args parser.parse_args() report plan_archive(args.path, args.mode, args.dry_run) print(json.dumps(report, ensure_asciiFalse, indent2)) if __name__ __main__: main()有几个细节我想专门强调一下。第一os.path.abspath这一步不能省。用户传入的路径可能是相对的如果不转成绝对路径后续所有操作都基于当前工作目录一旦调用方和脚本的工作目录不一致结果就完全错了。第二扩展名统一转小写。一个目录里可能同时存在.JPG和.jpg的文件从用户角度来看它们属于同一类如果大小写敏感就会拆成两个子目录体验很割裂。第三shutil.copy2vsshutil.copy的选择。copy2 会保留文件的元数据修改时间等对归档场景更友好。我最初用的是 copy后来发现文件的创建时间丢了导致后续按日期归档时出错才改成了 copy2。第四返回结构里必须有errors字段不能保证每次操作都成功。文件可能被占用、权限可能不足把这些错误收集到报告里比让脚本直接异常退出要优雅得多。3.3 测试用例怎么设计测试用例我采用了“构造临时目录 → 运行技能 → 校验结果”的模式。Python 标准库里的 unittest 足够用了不需要引入 pytest。关键测试用例有这么几类基本归档在临时目录里创建若干个不同类型的文件运行技能后断言它们被移动到了对应子目录。空目录处理传入一个空目录技能应该正常返回moved 数量为 0而不是抛异常。已存在目标目录如果子目录已经存在文件应该被追加进去而不是覆盖整个目录。dry-run 模式文件不应该被移动但返回的报告应该包含完整的计划信息。文件名冲突如果目标目录里已有同名文件move 应该自动覆盖还是报错我在设计决策中选择了默认覆盖但用--suffix-conflict参数可以让它自动改成“文件名 (1).ext”的格式。测试里要覆盖这种场景。其中 dry-run 模式的测试经常被忽略但在我看来它极其重要。没有 dry-run 的归档工具就像没有安全网的杂技表演用户都不敢真的用。3.4 样例数据的重要性每个技能目录下的examples/sample_input.json和sample_output.json看着不起眼但作用非常大。它们有双重价值一是给开发者和模型展示“这个技能期望什么样的输入和输出”二是可以作为集成测试的基准数据。我会在每次修改技能代码之后用样例数据跑一遍并 diff 输出和sample_output.json的差异。如果差异不是我预期中的改动那就说明有地方出了问题。这是一个很朴素但很有效的回归保护机制。4. 结构化输出与框架兼容性4.1 为什么输出必须是 JSON你可能注意到代码里输出用的是json.dumps。这是整个技能工程里我认为最重要的一个设计决策。文档型输出或者纯文本输出调试的时候看着舒服但没法程序化消费。当你希望另一个系统无论是调度平台、模型还是你同事写的脚本来接收这个技能的结果时结构化字段远比人类可读的段落有价值。JSON 作为输出格式的最大好处是自描述性。字段名即语义嵌套结构表达层次关系任何消费方都可以按需提取。我的报告里有moved这个汇总字段也有groups这个详细的分类信息不同消费需求都能满足同时不需要解析文本。4.2 输出字段的语义约定为了让输出更可预测我明确约定了几条字段语义source操作的原始目录必须是绝对路径。mode实际执行模式可能和请求参数不一致因为用户可能没传。dry_run是否只生成计划而未实际执行。moved实际处理的文件数量dry-run 时表示计划处理的数量。errors错误列表没有错误时是空列表而不是缺省该字段。groups按扩展名分组的信息每组包含文件列表和归档目标目录。我甚至在文档里写了一条总原则所有布尔字段不得省略所有数量字段必须为数字所有路径字段必须为绝对路径。这样约定之后消费方写起来非常省心不需要各种判空和类型转换。4.3 和主流加载框架的对接做技能工程一个绕不开的问题是你的技能目录要能被加载框架识别才有实际价值。目前主流的 Agent 框架和开发工具大多支持从目录加载技能但 frame 的加载逻辑可能有差异。我的经验是不要为目标框架写定制化代码而是让自己的技能仓库遵守通用规范。也就是说SKILL.md 的格式尽量采用通用标准代码入口用标准的命令行参数约定比如--argvalue形式输出用纯 JSON。这样无论哪个框架来加载都能无缝对接。如果某些框架要求特定的加载入口文件比如要求skill.py而不是archiver.py那我的建议是在技能目录里加一个薄适配层而不是重命名核心模块。适配层只做参数转发和格式转换核心逻辑保持稳定。5. 常见问题与排查技巧实录5.1 技能冲突和命名空间污染技能多了以后最常见的坑是命名冲突。比如我做了file_archiver另一个同事做的是archive_manager两个技能功能几乎一样但名字不同模型可能随机选一个结果不确定。我的解决办法是在skills.yaml里加tags字段做分类并且靠加载器的去重机制优先加载描述更具体、覆盖场景更明确的技能。遇到冲突时不要拖合并两个技能比同时维护两个相似技能划算得多。5.2 参数解析的兼容性问题不同框架加载技能时传参方式很不一样。有的框架用命令行传参有的框架用 JSON 文件传参有的直接用环境变量。如果技能代码只支持一种传参方式换一个框架就很可能跑不起来。我采用的兼容策略是核心函数不直接依赖 argparse而是接收一个 dict 参数。外层做一层薄适配命令行用 argparse框架调用直接传 dict。这样无论上层怎么变化核心逻辑都不受影响。5.3 超时和后台运行问题有些技能跑起来很慢比如处理大量文件的归档。如果调用方是实时等待的 HTTP 服务可能出现超时。这个时候要区分“技能本身耗时”和“技能被调用的方式耗时”两个问题。我在设计 file_archiver 时加入了一个--background参数。当调用方可以接受异步时脚本会把任务放到后台线程执行并立即返回一个任务 ID然后调用方可以轮询任务状态。但这个功能不是必须的初期可以不做等有真实需求了再加上。5.4 排查思路和调试记录我在调试过程中遇到过一个特别隐蔽的问题同一个脚本在终端跑得好好的在某个框架里跑就报No such file or directory。排查了半天最后发现是框架在某些情况下把当前工作目录切换到了别的路径而我的脚本里又有读取相对路径配置文件的逻辑。这个问题的根治办法是技能脚本内部不要隐式依赖当前工作目录。所有需要读取的路径要么由参数明确传入要么基于脚本文件自身的位置来推导。也就是在代码开头加上import pathlib BASE_DIR pathlib.Path(__file__).resolve().parent然后用BASE_DIR去拼其他路径。这个习惯一旦养成可以避开无数环境相关的玄学问题。另外一个排查技巧如果你发现技能加载后模型总是不调用它先检查 SKILL.md 里的description是否太抽象。模型是靠语义匹配决定是否调用技能的它不会因为你的代码写得好就主动用它只能看到描述。把“什么时候该用”写具体一点比把代码优化得更高效更能提升调用率。6. 个人体会与下一步扩展回头来看把零散脚本整理成标准化技能工程最核心的收获不是代码质量提升了多少而是思考方式变了。以前我写脚本默认是给自己用的参数随便、输出随便、错误处理随便。现在我会默认这个技能会被别人、甚至被模型调用所以参数、输出、错误处理都变成了对外契约的一部分。这种“默认有人会调用你的代码”的心态对写出更稳健的工程有非常大的帮助。下一步我计划做三件事。第一把技能仓库做成可安装的 Python 包。通过pip install skills就能把全部技能装到环境里然后任何 Python 程序都能直接 import 技能模块而不是通过命令行调用子进程。这种方式性能更好也更容易被框架加载。第二增加技能的健康检查。写一个doctor子命令遍历所有技能目录检查 SKILL.md 是否完备、入口文件是否存在、能否用样例数据跑通。这样技能仓库本身的维护就有了一个自动化保障。第三补充更多类型的技能。文件归档只是文件系统类技能的一个代表我还在做日志分析、配置文件校验、批量重命名等几个方向。每做一个技能我都会尽量让它符合这篇文里总结的规范。最后再分享一个实操小技巧在写 SKILL.md 的时候试着把它当作“写给一个不熟悉你项目的同事的交接文档”而不是“写给模型的提示词模板”。这个心态调整会让你把很多细节写清楚而模型恰好也能因此更好地理解你的技能。我的实测数据是这个简单的心理切换让技能的准确调用率又提升了一截。
返回列表