先说一个我前段时间调过的真实场景某公司内部的知识管理助手早期把所有技能说明都揉进了一个超长系统提示词里。结果每个新技能上线模型行为就会“漂移”——该走检索流程的时候去闲聊该生成报表的时候又去乱翻文件。改一次提示词全链路回归测试就要做一遍还是动不动出幺蛾子。后来我把这摊东西重构成一组带 SKILL.md 的技能包按需加载逐项收紧权限问题才算真正解决。这也是今天想聊透的主题Agent Skills 为什么不能只是“长提示词”如果你已经在做智能体应用或者正准备把一个会调用工具、能处理多任务的 Agent 放进生产环境这篇文章应该能帮你少踩几个坑。我会从长提示词的根本缺陷说起再拆 SKILL.md 的结构、按需加载的运行机制最后落到权限边界——每一段都有能直接照搬的写法。1. 长提示词方案为什么撑不住1.1 三个你迟早会踩的坑第一个坑是上下文窗口被前缀挤占。假设你的 Agent 有 10 个技能每个技能说明平均 600 字全部塞进提示词就是 6000 字。再加上多轮对话历史、用户当前输入、工具返回结果模型真正用来“思考当前任务”的窗口其实少得可怜。更麻烦的是上下文超过一定长度后模型对中间区域的注意力会明显衰减——早些年的研究者把这个现象叫“丢失在中间”。也就是说你辛辛苦苦写在第 4000 字位置的技能指令可能压根没被模型认真看过。第二个坑是指令之间互相干扰。长提示词里所有指令的“优先级”是一样的模型只能靠语义猜。技能 A 说“用户问价格时回复报价区间”技能 B 说“用户问价格时先查库存再回复”两个指令同时存在模型经常随机挑一个执行。我见过最离谱的一次是模型把 A 技能的回复模板套在了 B 技能的表格生成流程里输出出来一张带报价的统计表完全没法用。第三个坑是迭代成本失控。修改一个技能影响的不是这一个技能而是整个上下文里的所有行为。因为所有技能说明混在同一段文本里改一行字就可能改变其他技能的触发概率。这导致你做局部优化时必须做全局回归做全局优化时又分不清是哪个改动引起的效果变化。时间一长维护这份提示词就成了一个谁都不想碰的活。1.2 技能应该是模块不是段落长提示词方案的核心问题是把一个本该模块化的东西硬塞成了线性文本。你回想一下正经的操作系统是怎么干活的内核不会把所有驱动程序都写进启动脚本而是维护一个设备列表等硬件接入时再动态加载对应驱动。Agent 技能也应该走同一条路。一个技能包在工程上应该是独立单元自己的描述文件、自己的执行脚本、自己的依赖资源和自己的权限清单。这样的好处立刻就能看到技能之间不互相污染调试时可以只加载一个技能单独验证权限也能细化到每个技能各自能碰什么、不能碰什么。从“长提示词”切换到“技能模块”本质上是把“让模型记得所有规则”换成“让系统在需要时把规则递给模型”。前者赌的是模型的注意力和理解力后者赌的是调度逻辑和执行时的强制约束——哪个更可靠答案很清楚。2. SKILL.md 是一份给调度器看的“技能说明书”2.1 一份标准 SKILL.md 长什么样先别急着谈抽象概念直接上一份我实际用过的 SKILL.md 骨架。这个文件放在技能目录的根下文件名固定叫 SKILL.md整个技能包就靠它描述自己--- name: data_clean description: 清理用户上传的表格数据用于生成分析报告或合并数据集。 当用户提到“清洗”“去重”“处理脏数据”“补齐缺失值”时优先使用。 当输入不是 xlsx、csv、tsv 等表格文件时不要使用本技能。 when_to_use: 需要处理结构化表格数据、去重、纠错、补全缺失值时 permissions: read_paths: - ./workspace/** write_paths: - ./workspace/output/** network: none exec: - ./scripts/clean.py --- # 数据清洗技能 ## 执行步骤 1. 读取输入文件确认分隔符和编码格式兼容 utf-8 与 gbk。 2. 检查表头标准化字段命名下划线命名首字母小写。 3. 删除完全重复的行记录删除数量并写入执行摘要。 4. 对缺失值做策略性补全数值列取中位数分类列取众数。 5. 输出清洗后的文件到 ./workspace/output/并生成执行报告。 ## 边界提醒 - 只处理表格类文件不做自然语言问答。 - 不修改原始输入文件一律写出到 output 目录。这个文件分成两大块。上面由---包裹的部分是 YAML frontmatter给调度引擎读的下面的 Markdown 正文是给模型读的。这个划分非常重要机器能从结构化字段里快速提取路由信息模型则从正文里获取具体执行步骤。两者各司其职比一段糊在一起的纯文本清晰得多。我见过很多人写技能包时把所有信息都堆进正文frontmatter 只留一个 name。这样的问题是调度器匹配技能时要么得把整篇正文都塞给模型去判断要么只能靠文件名猜意图。前者违背了按需加载的初衷后者基本就是碰运气。2.2 核心字段到底该怎么写name 字段不用多说保持全局唯一即可。真正决定调度质量的是 description。这里有一条我总结的铁律description 不是写给人看的自我介绍而是写给调度引擎的路由标签。好的 description 必须包含三类信息触发条件、排除条件、输入输出形态。触发条件告诉调度器“什么时候用”排除条件明确“什么时候千万别用”输入输出形态让调度器能判断当前任务是否匹配。对比一下就能看出差距# 差 description: 这个技能可以清理数据。 # 好 description: 用于清洗表格类文件xlsx/csv/tsv中的重复行、错误值和缺失值。 当用户说“清洗”“去重”“补全数据”且给的是文件路径时优先使用。 如果用户只是询问数据统计结果或输入不是表格文件不要使用本技能。后面的 instructions 部分重点是“给模型留出判断空间”。你不需要把每个分支都写成绝对指令那样技能正文会无限膨胀重新走上长提示词的老路。比较好的策略是规定步骤顺序和关键输出格式但让模型在具体操作方式上有自主权。比如上面例子里的“数值列取中位数分类列取众数”这属于有明确规则的内容必须写死但“检查表头并标准化命名”这种动作不需要逐字符规定模型会根据实际表结构自己处理。还要注意的是正文里应当有“边界提醒”这类反向约束。模型是生成式的你不告诉它什么不能做它就可能自作主张。边界提醒写得越具体运行时出格行为越少。2.3 一句话总结这章SKILL.md 的价值不是“又多了一个配置文件”而是把技能从一段不可测试的文本变成了一件可以注册、可以索引、可以校验的工程资产。你可以为它写单元测试、做版本对比、按目录组织、加权限声明——这些东西是纯提示词给不了的。3. 按需加载别把整个图书馆都搬进大脑3.1 两阶段加载的运行闭环搞清楚了 SKILL.md 的结构下一步就是让它在运行时发挥作用。按需加载的原理用一句话说就是先看目录再取书——而不是把图书馆里每一本书的正文都背下来。具体到 Agent 的运行闭环过程分四步启动时扫描技能根目录解析所有 SKILL.md 的 frontmatter构建一份轻量的技能注册表。注册表里只有 name、description、when_to_use、permissions不含instructions 正文。用户请求进来后调度引擎先把用户输入和注册表里的 description 做匹配计算每个技能的触发分数。取分数最高的 Top-K 个技能把它们的完整 SKILL.md 正文注入当前上下文。任务执行完毕后从上下文中移除本次注入的技能内容标记相关缓存过期等待下一次调度。这套机制的好处在于技能数量再多上下文里同时存在的完整技能也只有少数几个。10 个技能时你只需要一次加载描述而不是把 10 个技能的正文常驻在上下文里增加到 50 个技能时注册表扫描的开销是可控的但每个请求注入的正文仍然只来自命中的技能。我做过的粗略估算一个技能正文平均 800 字10 个技能全量注入就是 8000 字而按需加载方案下一次请求通常只注入 1 到 2 个技能的正文加上注册表描述 1500 字左右上下文占用能压到原来的四分之一甚至更低。3.2 匹配排序的实战做法匹配排序是用好按需加载的关键动作。不一定要上多复杂的语义模型规则匹配很多时候已经足够稳。我常用的一个朴素实现思路是这样的def match_skills(user_input, registry): scored [] for skill in registry: score 0 for kw in skill.get(keywords, []): if kw in user_input: score 1 for exclude in skill.get(exclude_keywords, []): if exclude in user_input: score - 5 # 排除条件是强约束命中直接压分 if any(kw in user_input for kw in skill.get(file_hint, [])): score 2 scored.append((score, skill[name])) return [name for score, name in sorted(scored, reverseTrue) if score 0]这段代码的核心在于正向关键词加分、排除关键词重罚。因为现实场景里用户的话经常同时触发多个技能的匹配比如“帮我清洗这个表格并生成分析报告”既可能匹配数据清洗也可能匹配报表生成。这时候如果没有排除条件两个技能都会被加载模型就面临着选择困难。但如果在数据清洗技能的 description 里写清“当用户同时要求生成最终分析报告时将分析步骤交由报表技能处理”调度器就能通过 exclude_keywords 把职责分清楚。另一种思路是用向量召回做模糊匹配把 description 转成 embedding再和用户输入算余弦相似度。它可以弥补关键词漏匹配的问题但缺点是不可解释。我的建议是两者结合规则匹配作为主筛选向量召回作为兜底谁也不完全依赖。3.3 调度中的三个高频故障第一个故障是误加载。用户只是闲聊了一句“今天清洗数据好累啊”结果系统匹配到了数据清洗技能把一大堆清洗指令注入上下文。解决办法是写清排除词比如“累”“不想”“吐槽”这类情绪词并把需要文件路径这类强条件写进触发要求。第二个故障是漏加载。用户表达得比较含糊说“帮我处理一下这个表”没有明确的“清洗”“去重”关键词。这种漏匹配靠关键词很难解决要么靠向量召回兜底要么在注册表里增加 file_hint——识别到用户附带上传了 xlsx 文件时自动加分。第三个故障是技能残留。上一次任务加载了报表技能执行完后没有清理下一轮用户问天气时系统上下文里还挂着报表指令结果模型把天气回答也做成了表格。这就是我在 3.1 里强调“任务完成即移除”的原因。上下文不是垃圾桶用完的技能必须立刻释放。3.4 缓存与更新的隐形问题技能包不是永远不变的。你改了某个 SKILL.md 的 description但调度引擎启动时构建的注册表还在用旧版本就会一直匹配错误。我习惯的做法是给技能目录加一个版本号或者 mtime 监控一旦检测到 SKILL.md 发生变化就重新构建注册表技能正文本身不常缓存因为它是按需注入的每次加载都现读文件反而最安全。另一个隐形问题是注入顺序。命中多个技能时谁先出现在上下文里模型往往会先执行谁。如果你希望报表技能先做汇总、清洗技能后做预处理就在注入时按调用链排序别让模型自己判断。模型判断不了那么细的工程顺序它只会顺着上下文顺序走。4. 权限边界技能能跑但不能乱跑4.1 权限声明不是写给模型看的装饰很多人在 SKILL.md 里写 permissions只是照着模板填几个字段以为“告诉模型别乱动”就够了。这是完全错误的认知。模型作为一个生成式系统提示词对它只有软约束作用——它可能因为上下文里的一个小误导就绕开你的规定。真正能挡住越权行为的是执行引擎层面的强制性校验。也就是说SKILL.md 里的 permissions 声明必须被运行时环境真实执行。以文件访问权限为例permissions: read_paths: - ./workspace/** write_paths: - ./workspace/output/** network: none exec: - ./scripts/clean.py这份声明表达的意思是该技能只能读取 workspace 目录下的文件只能往 output 目录写入内容禁止任何网络访问只允许运行 clean.py 这一个脚本。当技能在运行过程中试图读取其他目录、请求外网接口或者调用未被列出的命令时运行时会直接抛出权限错误而不是等模型“自觉”纠正。人生动点讲这就像给临时员工发门禁卡——只开放他工作所需的那几个办公室其他房间刷不过去。你不需要每次都叮嘱员工“别去财务室”门禁系统自己会把不合法的行为挡住。4.2 越权场景与应对防线我实际遇到过的越权场景大致有三种列出来你可以对照排查。文件越权最常见。一个生成报表的技能date_clean 的 write_paths 只给了 output 目录但技能在生成报告时试图把中间结果写进输入文件所在目录。权限限制生效后会被拦截。但更隐蔽的是读取越权技能通过相对路径穿越的方式比如传入../../secret/data.csv绕过了看起来是白名单的目录校验。所以白名单匹配必须做路径归一化解析掉..和符号链接后再判断。网络越权也不少见。有些技能包为了“增强能力”会在 instructions 里写“如需补充信息可以调用外部接口”。如果不显式设置network: none模型完全可能把内部数据拼进请求发出去。我见过最危险的情况是技能把用户上传的表格内容转成 JSON 发给一个外部服务去“做格式转换”而这个外部服务根本不可信。子进程越权属于进阶场景。模型的输出不直接执行命令而是通过运行脚本间接操作这些脚本才是真正产生副作用的地方。所以 exec 白名单要精确到具体的脚本文件不能给整个目录的星号。如果把./scripts/*全放开等于给了技能任意写脚本的自由那权限边界就名存实亡了。应对这些越权场景三道防线缺一不可一是权限声明精确到路径、域名、命令级二是运行时环境强制校验校验失败立即终止任务并报错三是审计日志记录所有被拦截的尝试方便你事后复盘到底是技能设计缺陷还是真的遇到了恶意外部输入。我个人在权限配置上有一条经验新技能默认不开网络权限默认只能读写自己的 workspace 子目录只在确认必要后逐个放宽。这个从紧到松的节奏能避免 90% 以上的数据泄露风险。4.3 权限边界排错速查表现象可能原因排查方向技能无法读取输入文件read_paths 没有覆盖输入文件所在目录检查路径归一化后的实际解析结果技能无法写入结果write_paths 指向的目录不存在确认目录是否需要预创建技能网络请求被拦截network 设置为 none在权限声明中显式列出允许的域名列表子进程执行失败exec 白名单没有匹配脚本路径确认脚本路径用的是绝对路径还是相对路径明明禁止了写入文件还是被改了脚本内部绕过权限检查直接调用了本地命令检查脚本代码并考虑引入沙箱隔离权限拦截了但模型还在继续执行运行时未终止任务只返回了错误文本拦截后必须强制中断并进入重试或放弃流程这个表格可以贴在技能开发文档的显眼位置。实践下来权限问题大多不是模型“故意”越权而是你声明的权限和技能实际执行路径不匹配排错时先看路径再看域名最后查子进程基本就能定位。最后说两句我自己的习惯我做完这个知识管理助手之后给自己定下两条铁律任何技能描述正文控制在 800 字以内超过就拆成多个技能任何新技能默认没有网络权限确认必要性之后再逐项放开。这两条规则救了我不止一次——技能包短意味着按需加载快、调度误判少权限默认收窄意味着就算模型出现幻觉数据也不会被带出去。如果你正卡在“长提示词不听话”这个阶段我的建议是先别急着找更牛的大模型先把手里的技能做成分层模块。SKILL.md 负责声明按需加载负责调度权限边界负责兜底这三件套组合起来比再调一百遍提示词都管用。