
最近很多人都在聊skills聊怎么把一套复杂任务打包给AI。我也花了几周时间把一个叫skills的实验项目从零做了出来。这里不打算做概念科普而是把它当成一次工程实践复盘skills到底是什么、目录结构怎么设计、SKILL.md怎么写、脚本和提示怎么分工、调试中踩过哪些坑都会完整拆开。直接说结论skills可以理解成一系列结构化的技能包每个技能包就是一个目录里面放着说明文件、脚本和参考材料。AI助手在遇到对应任务的时候会加载这份说明、调用里面的脚本把原本靠临场发挥的提示词变成可测试、可复用、可交接的资产。它能解决的具体问题很实在同样一件事不用每次从头描述复杂流程不会漏步骤换一个模型或者换一个AI客户端执行标准还是一样。适合正在用大模型做自动化、做AI应用或者经常需要交付同一类文档和数据的同学参考。如果你是纯写代码的老手这套思路也能帮你在团队里沉淀内部工具。1. 项目定位Skills不是一段Prompt而是一套可复用的操作资产1.1 为什么要把“技能”重新定义为文件包我们在日常用大模型的时候最常见的做法是把一段长长的指令复制给AI让它按提示执行。这个方式在简单任务上没问题可只要任务步骤超过五步长Prompt的弱点就暴露了指令只存在于一次对话里想要修改某一步就得把整段文字改一遍换一个模型版本之后很可能换一种理解方式执行结果就全变了而且Prompt没法测试你没法断言“这一次执行到底符合不符合要求”。我最初做skills这个项目就是因为受不了这种每次都是开盲盒的状态。我的目标很朴素把一段操作流程变成一个可以单独存放、单独维护、单独测试的文件包。就像给同事写SOP一样把“什么时候用”“环境是什么”“第一步做什么、第二步做什么”“遇到错误怎么办”写清楚AI读到这份说明之后按流程执行。这样提示词不再是一次性的而是变成了仓库里的资产。文件包还能被版本管理改坏了回滚改好了记录变更这些都是一段Prompt给不了的。1.2 技能包的组成与最小交付物一个最小可用的技能包其实只有三样东西说明文件、脚本、参考材料。对应到目录里就是SKILL.md、scripts/和refs/。我习惯再加一个templates/目录用来放输出模板这样模型生成内容的时候有个固定格式可参照不至于每次产出都不一样。skills/ web-archiver/ SKILL.md requirements.txt scripts/ archive.py refs/ examples.md templates/ output_template.mdSKILL.md是整个技能包的门面AI首先读它里面写清楚技能的启动条件和执行步骤。scripts/目录放确定性的逻辑代码凡是能在命令里完成的抓取、转换、统计、文件操作都尽量写成脚本。refs/放参考案例、示例对话、补充说明模型在执行过程中如果遇到描述模糊的情况可以翻参考材料。templates/是输出模板让最终结果保持统一。这样拆分的好处是决定部分交给说明文件计算部分交给脚本兜底部分交给参考材料各干各的活。1.3 解决的核心场景与目标人群我目前跑通的核心场景有三类第一类是网页内容归档输入一个URL输出一份排版干净的Markdown文件把标题、正文、来源、抓取时间一起收进去第二类是每周数据报表生成自动从几份CSV里聚合数据画图并写出结论第三类是接口冒烟测试对着接口文档自动生成用例并执行最后输出一份测试报告。这三个场景的共同特征是频率高、流程固定、输出格式要求统一特别适合做成技能包。如果你正在做AI应用开发需要让模型稳定地调用外部工具这套结构可以直接用。如果你不是开发但每天大量使用AI处理同样的文件比如整理会议纪要、批量改写文案、生成周报也可以通过写一份简化版SKILL.md把操作稳定下来。我自己的体会是不需要懂很多代码能把“步骤”写清楚skills的架子就搭起来了一大半。2. 技术要点解析从目录设计到SKILL.md编写2.1 目录结构设计原则设计技能目录结构时我给自己定过三条原则。第一是单一职责一个技能只解决一类任务宁可多建几个技能也不要把“网页归档”和“数据报表”塞进同一个目录。第二是按任务聚合不按技术栈分类不要搞一个python-scripts文件夹再把所有脚本堆进去而要让每个技能自带全部依赖和脚本这样复制一个目录就能带走一个能力。第三是可见即所得打开仓库的人扫一眼目录名就知道这个技能是干什么的。我见过一种反面做法把公共函数抽到很深的共享目录里技能目录里只留一个调用入口。看起来节省了空间实际上一旦共享目录里的函数悄悄改了签名所有技能都可能坏掉而且模型在查看技能包时根本不知道共享目录里有什么。相比之下每个技能目录自包含哪怕有一小段重复代码换来的也是稳定和独立。复制、删除、发版都不会误伤其他技能。2.2 SKILL.md的frontmatter与正文写法SKILL.md的开头是一段YAML格式的frontmatter作用类似文件的身份证。我常用的字段包括name、description、version、author、tags。其中description是最关键的一项因为它决定了模型要不要调用这个技能。--- name: web-archiver description: 抓取指定网页并输出排版干净的Markdown归档文件。当用户提供URL并希望保存网页内容时使用也适用于“帮我存一下这个页面”“把链接内容整理成文档”等表达。 version: 1.0.0 author: your-name tags: [web, archive, markdown] ---写description有个窍门用动词开头讲清楚在什么场景下用、用户通常会怎么说而不是干巴巴地写“网页抓取工具”。比如“当用户提供URL并希望保存网页内容时使用”就比“网页归档工具”更容易被模型识别。正文部分我通常固定几个小节何时使用、环境要求、执行步骤、输出格式、注意事项。执行步骤要写成可操作序列不要留解释空间。2.3 如何设计可复用的执行步骤很多人写步骤喜欢一句话写完比如“整理网页并保存”。这句话说了等于没说。好的步骤应该是原子化的每一步都有输入、动作、输出。拿“网页归档”来举例1. 从用户输入中提取URL。 2. 校验URL格式必须以http://或https://开头否则拒绝执行并说明原因。 3. 调用脚本执行抓取python scripts/archive.py --url URL --out 输出目录 4. 查看脚本输出。如果输出包含“SAVED”读取生成的Markdown文件向用户返回文件路径和内容摘要。 5. 如果脚本返回错误根据错误信息中的提示判断是网络失败、页面解析失败还是输出目录不存在。每一步之间都有明确产物第一步得到URL第二步确认可用性第三步得到文件路径第四步得到内容摘要。模型在任何一个环节出错使用者都能定位到具体位置。我还会在步骤里加一个强制动作“如果没有抓到有效正文不要自己编造内容直接告诉用户抓取失败”。这类负面约束比正面指令还重要。2.4 脚本与说明文件的边界技能包里面脚本和说明文件的分工要非常清楚。我的原则是脚本做确定性的事情AI做判断和解释。脚本可以判断URL返回状态码、提取正文、统计字数AI负责判断用户是否表达了归档意图、根据脚本返回值决定下一步、把技术报错翻译成用户能听懂的话。如果反过来硬要让模型自己写一段抓网页的代码执行每次生成的代码都可能不一样遇到页面结构变化也没有稳定的报错机制。说得更直白一点脚本是为了把不可控的代码执行锁在一个可控的盒子里。这个盒子接收参数返回结构化结果要么成功要么失败成功给路径失败给原因。模型不需要理解网页解析的细节只需要理解脚本输出。这套边界一旦清晰技能包的调试和测试就变得简单了因为你只需要测试脚本本身不需要反复测试Prompt。3. 实操过程从零做一个“网页内容归档”技能3.1 技能需求与输入输出定义动手之前我先花半小时把需求和验收标准写清楚。这个技能叫web-archiver输入很简单一个URL。输出是一份Markdown文件包含标题、来源链接、正文内容、抓取时间。如果页面里包含标题和段落文字就完整保留如果页面是纯视频或者空页面不做强行提取直接报错。验收标准一共有三条第一同一URL在同一天内重复归档会生成新文件而不是覆盖旧文件第二输出文件的文件名要可读能看出时间和来源不能叫output.txt第三脚本在缺少参数时能给出用法提示而不是抛一个Python堆栈。这三条写下来后续开发就不会跑偏。建议你也给自己正在做的每个技能写一句“完成定义”否则做了一半很容易开始不断加功能最后技能包越来越臃肿。3.2 搭建目录与编写采集脚本我先在skills/web-archiver下建好目录然后写脚本archive.py。脚本的任务很直接用requests拉取HTML用BeautifulSoup解析标题和正文整理成Markdown写到输出目录。import argparse import re from pathlib import Path from datetime import datetime import requests from bs4 import BeautifulSoup def fetch(url: str) - str: resp requests.get(url, timeout10) resp.raise_for_status() return resp.text def html_to_markdown(html: str, url: str) - str: soup BeautifulSoup(html, html.parser) title soup.title.get_text(stripTrue) if soup.title else 未命名页面 parts [] for p in soup.select(p, h1, h2, h3, li, pre, blockquote): text p.get_text(stripTrue) if text: parts.append(text) body \n\n.join(parts) return ( f# {title}\n\n f 来源{url}\n\n f{body}\n\n f---\n抓取时间{datetime.now().isoformat()}\n ) def main(): ap argparse.ArgumentParser(description归档网页为Markdown文件) ap.add_argument(--url, requiredTrue, help目标网页URL必须以http(s)://开头) ap.add_argument(--out, defaultoutput, help输出目录) args ap.parse_args() if not re.match(r^https?://, args.url): raise SystemExit(ERROR 无效URL必须以http://或https://开头) html fetch(args.url) md html_to_markdown(html, args.url) out_dir Path(args.out) out_dir.mkdir(parentsTrue, exist_okTrue) file_name farchive_{datetime.now().strftime(%Y%m%d_%H%M%S)}_{len(args.url)}.md out_path out_dir / file_name out_path.write_text(md, encodingutf-8) print(fSAVED {out_path}) if __name__ __main__: main()这里有几个细节是踩过坑之后才加上的。文件名里带len(args.url)是为了即使同时归档多个相同来源的页面也不会重名输出用UTF-8编码是避免Windows机器上写出乱码URL校验放在抓取之前是防止用户把“帮我存一下这个页面”里的非URL内容传进requests。写脚本的时候就要想到模型调用它时可能出现的各种状况脚本越健壮后面的调试越轻松。3.3 把脚本暴露给AISKILL.md中的调用协议脚本写好了但模型不知道它存在也不知道怎么调用。这时候SKILL.md就变成了一份调用协议让模型能准确理解何时启动脚本、使用什么命令、怎么解读输出。我会在SKILL.md的执行步骤里直接写命令不给模型“你自己写一个等价脚本”的机会。## 执行步骤 1. 从用户输入中提取URL。如果用户给出的是多个URL逐个处理。 2. 校验URL格式。若不以http://或https://开头拒绝执行并建议用户重新提供链接。 3. 确认输出目录。若用户未指定默认使用仓库下的output/目录。 4. 运行命令 python scripts/archive.py --url URL --out 输出目录 5. 读取脚本输出 - 如果输出包含“SAVED”用文本查看工具打开该文件向用户返回文件路径和文件中的标题、摘要信息。 - 如果输出包含“ERROR”不要自行猜测原因将错误信息原样返回给用户并补充说明可尝试更换网页或稍后重试。 6. 记录本次执行结果到当前目录下的logs/archives.csv包括时间、URL、文件路径。把命令写进说明文档相当于给模型划定了一条执行通道。我不会在步骤里写“如果脚本下载失败可以用curl重试”因为一旦给了多个选择模型就可能选择那个不受控的后续问题就不可控了。执行协议里最好只保留一条推荐路径和一个明确的失败反馈路径。3.4 从调试到落地三个回合的改进第一版技能包我偷懒了没有写脚本只是在SKILL.md里让模型“先获取网页内容再提炼成Markdown”。试了三次效果很差第一次它把网页导航栏的链接全部保留正文反而不多第二次输出格式变了标题跑到正文后面第三次干脆说无法访问页面。问题不在模型笨而是我交给它的任务太自由它只能用自己脑子里的网页解析经验来做。第二版我加了脚本但把输出路径写死在SKILL.md里模型执行脚本后只能看到一个路径没法确认文件内容也没法把路径告诉用户。这一版暴露了另一个问题脚本和说明文件之间缺少“输出解读”环节。第三版我把步骤改成上面那套协议加上了失败处理、文件读取、结果返回。调试了两次就稳定了。此后每次做新技能我都直接沿用这个模式脚本负责执行SKILL.md负责告诉模型如何启动、如何解读、如何失败。4. 技能库管理让Skills成为可成长的工具箱4.1 用Git管理技能版本技能包数量一旦超过五个就必须引入版本管理。我采用一个monorepo管理所有技能每个技能目录有自己的语义化版本号记录在SKILL.md的frontmatter里。给技能发版时在git里打一个tag比如web-archiver-1.0.0方便回滚和对照。git add skills/web-archiver git commit -m feat: 增加网页归档技能 git tag web-archiver-1.0.0我还养成了一个习惯每次修改技能都顺手更新SKILL.md里的version并在仓库根目录的CHANGELOG.md里写一行说明。别小看这一行几个月后再去翻历史能想起来这个版本改了什么、为什么改。技能包和软件项目一样最大的成本不是写出来的那一下而是后续维护时搞不清楚当前状态。4.2 技能间的引用与依赖技能不能完全孤立。我做过一个“周报生成”技能需要调用“读取数据文件”和“生成图表”两个基础能力。这时候就要在SKILL.md里写明前置依赖并且只在必要的时候引用其他技能的输出。我的做法是如果两个技能经常一起出现就把其中一个技能需要的公共定义放到refs/目录并让另一个技能通过相对路径引用。比如周报技能引用web-archiver技能时我会在周报技能的SKILL.md里写“需要读取HTML归档文件时使用skills/web-archiver/SKILL.md中约定的Markdown格式”。尽量避免一个技能直接往另一个技能的目录里写文件否则依赖关系会变成一张蜘蛛网。一个简单判断标准是删掉任何一个被引用技能另一个技能能否独立完成80%以上的核心任务如果不能说明边界没切好。4.3 为技能构建测试集技能也应该有测试否则你根本不知道一次升级会不会破坏原有行为。我给每个技能建一个tests/目录里面放三个用例正常用例、边界用例、失败用例。拿web-archiver来说正常用例抓一个普通博客页面边界用例抓一个需要登录的重定向页面失败用例传一个不存在的域名。我写了一个简单的冒烟脚本跑全库for skill in skills/*/; do echo Testing $skill if [ -f $skill/tests/test_basic.py ]; then python $skill/tests/test_basic.py || exit 1 fi done测试里不追求覆盖所有页面类型只做“黄金样例校验”。把每个技能应该生成的Markdown结构存成模板断言输出文件包含标题、来源、抓取时间三个元素。跑通这套之后我改脚本代码时的安全感明显上来了再也不用担心改了一个正则表达式把上个技能整坏。4.4 团队协作中的规范如果技能库是几个人一起维护规范就比实现更重要。我整理过一张新技能合并检查清单技能目录是否独立、SKILL.md的description是否包含典型触发语、执行步骤是否明确到可以直接执行、是否包含失败处理、是否声明了环境依赖、是否包含测试用例。任何一条不满足就先不改并入主线。命名方面统一用kebab-case目录名和技能名保持一致。SKILL.md里不允许出现绝对路径一律用相对于技能目录的路径这样clone到任何位置都不会坏。敏感信息比如API密钥只允许放环境变量不允许写进技能包。这些规范不会提高单个技能的质量但会阻止技能库烂掉。团队协作最忌讳的就是每个人的技能风格各不相同今天一个样式明天一个命令格式。5. 常见问题排查我从这些坑里爬出来的实录5.1 模型根本不调用技能我最早遇到的问题是我做好了技能包但模型就好像没看见一样回答用通用能力硬做。查了一圈根因绝大多数在description上。要么描述里只有功能名没有用户意图要么把触发条件写得太抽象。这次实践之后我固定下来一种写法description 任务动词 对象 典型用户表达。例如“当用户提供URL并希望保存网页内容时使用”而不是“网页归档工具”。排查的时候也有一点小技巧在description里加几个用户原话变体比如“帮我存一下这个页面”“把链接内容整理成文档”模型命中率会明显提升。这相当于给技能加上了搜索关键词而且是最贴近用户说话习惯的关键词。5.2 技能被过度触发和“不调用”相反的问题是“什么都想插一脚”。我给web-archiver的description最初写得太宽导致用户只要在对话里提到一句“网页”模型就启动归档流程连用户只是想问问链接里的某个数据点也要先抓整页。这个问题比不调用还烦因为它会把简单对话变得很重。解决方式是在description里加入限定词“仅当用户明确要求保存网页内容或抓取整个页面时使用如果用户只询问某个片段使用普通对话能力”。同时在执行步骤的第一步增加输入校验如果用户并没有给出完整URL直接返回提问而不是猜测。说到底一个技能应该像精密的插头咬合必须严丝合缝而不是像胶布什么都粘得住。5.3 脚本执行环境不一致脚本在本地能跑换到另一台机器就崩这是典型的依赖问题。我踩过两个坑第一次是路径拼接用了反斜杠在Windows上正常在Linux直接报错第二次是没有声明依赖版本requests升级之后抓取结果少了一截。现在的统一做法是脚本内全部用pathlib.Path处理路径禁止用字符串拼路径每个技能自带requirements.txt并写明Python版本执行前先跑一条环境检查命令比如python -c import requests, bs4看看是不是缺包。如果技能要在别人的机器上跑最好在SKILL.md里加一句“建议使用虚拟环境安装依赖pip install -r requirements.txt”。虽然多一行字但能省掉很多远程报错。5.4 输出格式与下游解析冲突技能的输出不只是给用户看的还经常被其他脚本消费。最早我写归档文件时时间直接用了Python默认的datetime.now()结果有的文件带时区有的不带下游解析时就乱了。后来我统一约定所有脚本输出时间使用ISO 8601格式文件名使用YYYYMMDD_HHMMSS文件编码一律UTF-8。这些规则不需要写进代码注释直接写进SKILL.md的“输出格式”小节。如果下游是另一个技能我还会在refs/里放一份输出样例。这个样例不是给模型做参考的是给下游脚本做契约的。一旦输出结构变了跑测试马上能发现。别小看这种契约文件它能让两个本来独立的技能在不互相写代码的情况下保持协作稳定。5.5 安全性问题技能包本质上会执行任意脚本这是最大的风险点。我给自己定了三条安全红线第一SKILL.md里禁止出现把用户输入直接交给shell执行的行为第二脚本里涉及文件写入时必须校验最终路径在指定输出目录内防止路径穿越第三抓取外部URL前先做协议校验只允许http/https不允许file://、ftp://这类协议。如果技能需要访问网络我会尽量让脚本在独立环境运行不给它项目根目录的写权限。团队协作时任何新增技能在合并前都要过一遍安全评审重点看脚本里有没有子进程调用、有没有eval、有没有不安全的文件路径拼接。技能包是给人用的但执行的却是代码该有的敬畏心不能缺。6. 落地经验与后续扩展6.1 先沉淀三个最常做的操作很多朋友一上手就想把所有工作都做成技能结果写了一堆半成品。我的建议是反着来只挑三个你一周至少做三次的操作先把它们做成能稳定跑通的技能。做完三个之后你会对“什么该进技能包、什么不该进”有感觉再往更大范围扩展也不迟。比如我当时选了网页归档、周报数据汇总、接口冒烟测试。这三个操作都不是特别难但足够暴露大部分问题URL校验、文件命名、依赖管理、输出格式。把这三个跑通相当于有了三块地基后续做更复杂的技能时直接复用这套架子。6.2 如何让新技能快速被AI接受新技能上线后我会做一次“冷冻测试”用一句完全相同的用户话术反复跑五次看模型是不是每次都触发同一个技能。如果十次里少于八次命中我就回去改description而不是改步骤。另一个好用的办法是把成功案例存进refs/examples.md每次只追加一个真实对话片段示例多了之后模型对技能的调用会更稳定。我还发现一个规律一个技能放在技能库里很久不用就会逐渐被模型遗忘。所以我每周会挑两个技能各触发一次当作“热身运动”。别觉得这个动作多余技能和工具一样经常用才有存在感。6.3 从个人技能库到团队能力库个人项目跑通之后就可以考虑放大成团队能力库。我目前做的就是把技能库挂到了内部git仓库大家各拉各的分支通过PR提新技能。评审不看代码多漂亮只看SKILL.md是否清晰、测试是否包含失败用例、脚本是否安全。团队共享还有一个额外好处别人会对你的技能提需求这些需求能推动技能继续演进。度量的标准也不要只盯着技能数量。我更关心三个数字技能被调用的次数、测试通过率、用户在对话里明确说“这次结果不行”的反馈次数。技能是活的需要根据反馈不断调。把技能库经营成工具箱比经营成收藏夹有意义得多。如果再让我重来一遍我会先写一页纸的“高频操作清单”而不是一上来就建二十个文件夹。大部分折腾时间其实都花在了调整边界上。最后分享一个小技巧在每个SKILL.md的最后加一个“调试记录”小节每次技能不生效时就把失败现象、猜测原因、最终解法写进去。坚持两三个星期你会发现这本“病历”比任何文档都值钱。