过去三个月,我们项目组一直在做同一个东西:把那个塞满二十多项指令的 Agent 主提示词推倒,换成一整套可插拔的 skill 体系。项目代号就叫 agent-skills,听上去像个小玩具,实际上它解决的是我们线上 Agent 最头疼的问题——模型越来越不听使唤,工具越来越多,但每个功能都在各自为战。到今天,这套技能层跑了两轮完整迭代,我这个从零上手的人想把整个过程里真正有用的东西写下来,包括目录怎么设计、注册表怎么写、调度怎么控、以及那些教科书里绝对不会告诉你的翻车现场。如果你也在维护一个带工具的 Agent,或者正准备给现有的 prompt 工程师团队搭一套更规范的技能管理方案,这篇应该对你有用。
1. 为什么我先把主提示词拆了:裸奔式 prompt 的三个致命伤
1.1 二十个子功能的调用逻辑全挤在一个 system prompt 里
我们最早的 Agent 结构很原始:一个巨大的 system prompt,里面用自然语言描述所有能力,接下来堆上每个功能的调用参数说明,最后指望模型自己判断什么时候该调哪个。刚开始只有三四个功能时没问题,模型记得住,响应也稳定。但等到功能列表涨到十以上,问题就变得非常具体。
第一个现象是模型开始"选择性失忆"。用户问的是天气,它非要去调日历接口,因为日历描述写在天气前面、权重更高;用户问的是"帮我写一份周报",它直接返回一段固定模板,根本不触发我们的数据汇总技能,理由是这个技能在提示词里出现的次数太少、模型认为"不太重要"。
第二个现象是参数冲突没有负责人。每个功能都要求特定字段,但所有 schema 混在同一段提示词里,字段名一旦出现重复,模型就直接懵掉。我们曾经出现过一个 event_id 同时被日程模块和提醒模块使用的场景,最后模型把提醒的 event_id 塞进日程接口,线上用户收到的提醒时间和日程完全对不上。
第三个现象是 token 消耗失控。主提示词随着功能增加越来越长,每个请求都把这坨东西从头到尾发送一遍,响应时间变长,成本也上去了。最夸张的时候,主提示词接近七千字符,一次普通对话的输入 token 里,将近一半都是在描述那些本次根本用不到的功能。
这些问题的本质,不是模型能力不行,而是我们把"系统能力"和"模型决策"之间的边界放到了同一条提示词里。模型本质上需要一个"岗位说明书",让它清楚今天这个岗位上它只负责哪些事、能调用哪些工具,而不是把整个公司的制度手册都塞给它。
1.2 技能层的思路:把能力变成可注册、可路由、可审计的模块
拆掉主提示词之后,我们换了另一套思路:把每一个独立能力封装成一个 skill。每个 skill 自带元数据、输入说明、执行代码、prompt 模板和测试用例,就像把零散写在系统提示词里的"寄生功能"全部养成了独立的"岗位角色"。
这样一来,模型的核心 system prompt 里只保留身份、语气、总原则和最简短的调用指引。所有可以被具体化的、能独立验证的能力,全部下沉到技能层。平时的运行流程变成了这样:用户请求进来,先由一个轻量的意图识别层决定需要哪些技能,再从技能仓库里把对应的描述、参数说明、调用逻辑拉出来注入给模型,最后模型只需要在选中的技能范围内做决策。
这种设计的核心收益有三个。第一是职责单一,每个技能的 prompt 只需要服务于它自己的场景,上下文干净,模型决策质量明显提升。第二是可测试,每个 skill 都可以独立跑一组验证用例,不用为了一个功能改一次就全量回归整个 Agent。第三是可扩展,新增功能就是新增一个目录,注册到清单里,不需要动主提示词,风险面被压缩到一个很小的地方。
这套方案后来被我们内部叫 agent-skills,项目名就是这么来的。
2. 技能仓库长什么样:一个 skill 的目录结构和元数据到底该怎么定
2.1 三个核心文件加一个测试目录,谁都能看懂谁都能维护
技能仓库我们最终采用了非常朴素的组织方式:一个技能一个文件夹,里面有技能定义、执行代码、提示词模板和测试用例。下面是我们一个天气查询技能的真实目录结构:
agent-skills/ ├── registry.json └── skills/ ├── weather-check/ │ ├── skill.yaml │ ├── run.py │ ├── prompt.md │ └── tests/ │ └── cases.json ├── meeting-summary/ │ ├── skill.yaml │ ├── run.py │ ├── prompt.md │ └── tests/ │ └── cases.json └── schedule-add/ ├── skill.yaml ├── run.py ├── prompt.md └── tests/ └── cases.jsonskill.yaml是技能的身份证,run.py是真正执行的动作,prompt.md是模型在决定调用这个技能时需要看到的完整上下文片段,tests/cases.json则是技能级回归测试的输入输出对。目录结构看起来简单,但每条都是踩过坑之后定下来的。
一开始我们想把技能描述直接写在skill.yaml的 description 字段里,但试了几轮发现,description 太短模型理解不到位,太长又会污染意图识别层。后来我们干脆拆开:description 只写一句话的功能摘要,用于意图路由;详细的调用指引、注意事项、边界情况全部放在prompt.md里,只在技能被选中后才注入给模型。这里的分离,是整个系统后来可维护性的基石。
2.2 skill.yaml 里到底该放什么:字段取舍和血泪教训
我们经过多轮调整,最终的skill.yaml保留这些关键字段:
| 字段 | 用途 | 备注 |
|---|---|---|
| name | 技能唯一标识 | 必须全局唯一,命名为动词短语,如 weather-check |
| version | 语义化版本号 | 技能更新必须升版本,便于排查 |
| description | 一句话功能摘要 | 只描述"这个技能能做什么",供路由层使用 |
| capability_tags | 能力标签 | 用于聚类检索,如"查询""日历""摘要" |
| input_schema | 技能入参声明 | 控制模型生成参数时的边界 |
| output_schema | 技能出参声明 | 用于结果校验和后续编排 |
| permission_level | 安全等级 | 普通/确认/管理员审批三级 |
| author | 维护人 | 便于走查时找到负责人 |
| updated_at | 最近修改时间 | 和 version 联动,改配置文件必改时间 |
字段数量控制在这个粒度是有原因的。我们最开始还设计了 skill 的状态机、运行环境字段、关联知识库索引等一堆内容,但实际用下来发现,字段越多,维护成本越高,而且很多字段模型压根不看,纯属自我感动。真正影响行为的只有 description、input_schema、permission_level 这三个。
input_schema是值得多花心思的地方。我们的经验是:能用一层嵌套解决的事,绝不写两层;能用一个字符串表达的时间,绝不拆成年月日时分开传。前期我们某个技能要三个参数——开始日期、结束日期、是否包含周末,模型经常把日期格式搞错,后来我们直接把参数改成"一个表示时间范围的字符串,如 2025-06-01~2025-06-30",解析在代码里做,模型输出准确率立刻上来了。schema 不是写给程序看的,是写给模型看的,越接近模型的自然表达,成功率越高。
2.3 prompt.md 的写法:让模型在关键时刻看到最少的字
每个技能的prompt.md都遵循一套固定的写作框架,大致分为四段:
- 技能定位:一句话说明这个技能在什么场景下被使用。
- 触发条件:列出必须调用本技能的典型请求,包括正例和反例。
- 使用约束:说明参数格式、边界、不允许做的事。
- 输出要求:规定模型在调用技能后需要如何向用户呈现结果。
写这个文件时最忌讳的是把代码层的实现细节写进去。比如天气技能就不需要告诉模型"run.py 里调的是哪个 HTTP 接口",模型只需要知道输入一个城市名和一个日期范围,输出一个包含温度、湿度、风力的结构化结果。接口细节属于实现层,让模型知道了反而会增加它绕开技能直接编答案的几率。
这套模板成型之后,新人接手维护一个陌生技能的成本大概从半天降到了半小时,因为所有需要关注的信息都收敛在一个文件里,不用去翻别的代码。
3. 注册表与调度实现:技能不是写了就能用,得有一套受控的调用链路
3.1 注册表加载器:扫描目录、解析 YAML、校验重复声明
技能仓库里所有的 skill 最终都要汇总到registry.json,但实际的注册流程不是手工维护这份文件,而是通过加载器自动生成。我们写了一个很简单的扫描脚本,启动时遍历 skills 目录,逐个读取 skill.yaml,做三项基础校验:name 是否重复、input_schema 是否合法、run.py 是否存在可调用的入口函数。校验通过后合并进注册表。
加载器的核心逻辑其实不复杂,我把简化版本贴在这里:
import json import yaml from pathlib import Path def build_registry(skills_root: Path) -> dict: registry = {} for skill_dir in skills_root.iterdir(): if not skill_dir.is_dir(): continue yaml_path = skill_dir / "skill.yaml" if not yaml_path.exists(): continue with open(yaml_path, "r", encoding="utf-8") as f: skill_meta = yaml.safe_load(f) # 校验唯一性 name = skill_meta.get("name") if not name: raise ValueError(f"Missing name in {skill_dir}") if name in registry: raise ValueError(f"Duplicate skill name: {name}") run_path = skill_dir / "run.py" if not run_path.exists(): raise RuntimeError(f"run.py missing for skill {name}") # 校验 schema 基本结构 input_schema = skill_meta.get("input_schema", {}) if "type" not in input_schema: raise ValueError(f"input_schema must have type field in skill {name}") registry[name] = { **skill_meta, "dir": str(skill_dir), "run_path": str(run_path), } return registry这里有个很容易被忽略的细节:扫描时一定要用iterdir()过滤隐藏目录和临时目录,否则某个编辑器生成的 .DS_Store 或者临时文件夹会被误当成技能目录,导致注册失败。我们在开发环境里被这东西坑过一次,后来直接在 build_registry 里加了一层目录名过滤,只接受符合英语动词命名规范的文件夹。
3.2 路由层的两级设计:意图粗筛和上下文细选
技能越多,模型面对的选择越复杂。我们把路由拆成两级,避免让模型在几十个技能里做大海捞针式的决策。
第一级是意图粗筛。用户请求进来后,先计算请求文本和每个技能 description 之间的语义相似度,选出 Top 5 候选。这一步我们用的就是一个轻量级的 embedding 模型,效果稳定,成本便宜,不需要 LLM 参与判断。第二级是上下文细选。把 Top 5 候选技能的完整prompt.md注入主对话上下文,让模型自己决定本次到底需要其中哪几个,以及用什么顺序调用。
分级的好处是:模型始终只面对最多五个技能的决策,而不是二十几个。我们实测下来,单级路由全量注入时模型的工具误用率大概在 18%,改成两级过滤之后降到了 6% 以下。这个差距不是模型变强了,而是模型的决策空间被工程手段压缩了。
路由还应支持显式指定。有些场景非常明确,比如用户说"把明天下午三点的会议移到四点",我们清楚一定是 schedule 和 calendar 两个技能协同,根本不需要走相似度召回。为此我们维护了一张硬编码的技能组合映射表,命中映射表时直接跳过粗筛,提升响应速度,也减少模型出错的机会。
3.3 技能编排与结果回填:让多技能协作不至于乱成一锅粥
单技能调用是基础,真正麻烦的是多技能协作。比如"帮我总结今天所有的会议,再把总结发给老大",这个请求涉及会议检索、摘要生成、消息发送三个技能。我们规定模型在这种情况下必须输出一个调用计划,格式是结构化的步骤数组,然后由执行引擎按顺序逐个执行,每个步骤的执行结果回填到上下文,供下一步使用。
整个流程我画在这里(用文字描述):用户请求进入编排器;编排器调用路由层拿到候选技能;模型生成步骤计划;执行引擎逐条执行;每个步骤执行后做输出校验;校验通过的结果合并进上下文;全部步骤执行完后生成最终回答。
多技能协作场景下一定要做"中间结果校验",否则后一个技能拿到前一个技能的错误输出,会顺着错误继续编。我们给每个技能的输出都配置了 output_schema,执行完先用 jsonschema 校验一层,不通过就标记该步骤失败,重新让模型生成一次计划,而不是直接把它当前结果往下游传。
4. 实测里踩过的四个技能失效坑,以及我完整的排查链路
4.1 两个技能取了一样的名字,路由层居然静默跳过了新的那个
这个坑是在做第二轮技能扩展时踩到的。我们给新增的技能起了个 name 叫 schedule-update,而旧技能列表里早就有一个 schedule-edit,两个技能的 description 还出奇地相似,系统加载时没有报错(因为注册表按 skill.yaml 里的 name 作为 key,而两个目录名不同),但路由召回时,新技能永远排在后面,模型几乎每次都会选中旧的 schedule-edit,导致用户新增的时区处理逻辑从来没有被触发过。
排查链路是这么走的:先看单个技能的单测,发现新技能本身执行完全正常;再看注册表输出,registry.json 里两个技能都存在;最后把路由层的召回日志打开,发现每次召回只有旧技能进入 Top 5。当我们把两个技能的 description 打印出来对比,才意识到问题出在"名称近似 + 描述相似 + 语义召回去重逻辑"三个因素叠加。
修复不复杂,把新技能的 name 改成 schedule-add-with-timezone,再把 description 里的差异点明确写出来,召回就恢复了。但这个坑让我意识到,技能命名规范必须在项目第一天就定死——动词开头、不能有近义别名、同一业务域的技能名之间必须有显式区隔词。要不然,越多技能注册进仓库,名字混淆的概率就越高。
4.2 模型输出参数和 input_schema 对不上:一个参数解析错误的复盘
我们有个技能是生成周报摘要,input_schema 要求传 start_date 和 end_date。某天开始,线上频繁出现参数校验失败的日志,排查后发现模型生成的是 startDate 和 endDate,驼峰格式,而 schema 里定义的是下划线格式。为什么之前一直没事?因为我们早期技能少,模型看到的示例参数命名恰好都是驼峰,而后加的技能统一用了下划线,模型在这个技能上就"按照惯性"输出了。
这个问题的排查不算难,但给我一个教训:input_schema 里的字段名风格必须全仓库统一,切换风格时要在 prompt.md 里写清楚示例,而不是只改 schema。此外解析层要做得宽容一点,我们后来在参数解析器里加了一个字段名归一化逻辑,把驼峰、下划线、短横线统一转成小写,再和 schema 里的定义匹配,这类问题就绝迹了。
还有一类更隐蔽的失败:模型给出了 schema 之外的多余字段。某个技能要求传 city 和 days,模型额外加了一个 units 字段,我们的解析器当时是直接报错的,后来改成"忽略未知字段但记日志",线上稳定性好了很多。这个改动看起来是让步,实际是为了避免一个不关键的多余字段把整个执行流程拖死。
4.3 技能上下文注入过多,主提示词反而被"淹没"
这是我们一次性能优化引发的回归。为了减少模型误用技能,某次我们决定把召回的 Top 5 技能的 prompt.md 全部拼进主上下文,结果每个技能 prompt.md 都有四五百字,五个加一起接近两千五百字。模型确实不乱调技能了,但它的"注意力"被大量技能说明占满,开始忘记主提示词里最核心的回复风格要求。
具体表现是:用户问一个简单问题,模型先长篇大论地说"我将调用 XX 技能",然后才给出答案,而且语气完全偏离我们设定的人格。我们顺着日志查,发现模型生成的内容里大量复述技能 prompt.md 里的术语。
排查结论是:技能注入的上限不应该无脑取 Top 5,而是要看本次技能和用户请求的相关度是否超过某个阈值。我们把阈值设为 0.62,相关度低于阈值的技能一律不注入。同时技能 prompt.md 的篇幅也被我们强制压缩到三百字以内,只允许保留必要信息,逼着维护者写更精炼的说明。这两条改完,主提示词被淹没的问题基本消失。
4.4 技能内部指令和主提示词冲突,Agent 开始"人格分裂"
最后一个坑触发次数最少,但影响最大。我们某个技能的 prompt.md 里写了一句"如果用户表达不满,请先道歉再处理",而主提示词里我们给 Agent 设定的人格是"专业克制、不轻易道歉、优先解决问题"。这两条矛盾指令被同时注入后,模型在同一个对话里的行为变得完全不可预测:同一句话,有时先道歉,有时不道。
排查时我们一开始以为是天气因素或者模型随机性,后来把注入内容日志打开,才发现在一次请求中模型同时看到了两个互相矛盾的指令来源,它根本无法判断该听谁的。
修复方案分两层。第一层,我们把技能 prompt.md 和主提示词做了明显的边界标注,主提示词顶部强制写了一段"当技能文件中的指令与本文冲突时,以本文为准"。第二层,我们在代码评审时增加了一个检查项:任何技能 prompt.md 不允许出现与主提示词重复的行为规范,只允许写技能特有的参数说明和输出要求。经过这两个调整,这个坑就再没出现过。
5. 技能回归与迭代评估:怎么判断一个技能是真的能用,而不是"看起来能用"
5.1 每个技能配一套黄金用例,发布前必须全量通过
技能越拆越小之后,回归测试反而变得更容易了。我们给每个技能目录下的 tests/cases.json 维护了一套黄金用例,每个用例包含用户请求、期望调用的技能名、入参、期望的出参结构。
以天气技能为例,cases.json 里至少有这几类用例:城市名带"市"后缀的、城市名不带的、时间范围跨月的、带"今天""明天"这种相对时间表述的、历史上没有数据的城市。每类用例都标注了期望输出,核心是确保技能的 prompt 和 run.py 不会被一次改动悄悄破坏。
回归测试在三个时机自动触发:技能文件变更时、路由层代码变更时、主提示词变更时。哪怕主提示词只改了一个字,我们也会把全量技能用例跑一遍,因为这个字可能就是某个技能触发条件里的关键词。这种做法确实增加了 CI 时间,但相比线上出现"某技能神秘失效"再回查,这点成本完全可以接受。
5.2 技能健康度指标:执行成功率、参数完整度和用户中断率
除了用例回归,我们还建立了一套技能健康度指标,每项指标都会写进日志,用仪表盘查看。核心指标有三个。
第一个是执行成功率,分母是技能被调用次数,分子是执行成功且输出通过 schema 校验的次数。第二个是参数完整度,统计每次调用时模型传入的参数和 schema 定义之间的覆盖率。第三个是用户中断率,用户看到技能执行结果后是否没有继续追问、直接结束了会话,间接反映技能返回结果是否令人满意。
这三个指标的用途不太一样。执行成功率低,多半是技能本身有问题;参数完整度低,多半是模型对技能理解不到位,应该改 prompt.md;用户中断率上升,则说明技能虽然执行成功,但用户不认,问题可能出在结果呈现方式,需要调整输出模板。
我们还做过一次很有意思的分析:把技能按照调用频次排序,发现排名前二十的技能贡献了全部调用量的 85%,剩下几十个技能几乎没人调用。这个分布说明技能仓库已经出现了明显的"长尾",那些低频技能不一定要删除,但完全没必要进入 Top 候选池,可以直接降级为"手动指定才能触发",先从路由召回里排除掉,降低模型的选择负担。
5.3 技能被反复改坏了三次之后,我们引入了版本差异对比
某个摘要类技能在两周内被改坏了三次,每一次都是维护者改了 prompt.md 里的一句话,导致某个此前能通过测试用例的输入突然失败。问题出在改动人没有意识到那行字会影响某个边界情况。
后来我们做了一个很轻量的工具:每次技能文件变更时,自动保存旧版本,并生成一份"prompt.md 前后差异说明",随代码评审一起提交。这样改动者必须面对一个问题:你改的这句话到底影响了哪些输入?如果答不上来,就不允许合并。这个机制不复杂,但它把技能变更从一个"改了就好"的自由动作,变成了一个有约束、有说明的工程行为。
6. 落地过程中的几条实用经验,以及我建议你避开的无谓弯路
如果让我把这三个月最核心的经验压缩成几条,大概是这些。
第一,技能数量的上限不是技术约束,是维护者的认知带宽。我们仓库里现在有三十多个技能,但真正高频的就那二十个。每加一个技能,都要问一句"它和已有技能的重叠边界在哪里"。如果说不清楚,就先别加。一个边界模糊的技能,比没有技能更麻烦,它会让模型在路由时多一次出错的机会。
第二,权限边界的设置最好前置。permission_level 这个字段一定要在技能设计阶段就定义好,不要等到技能上线后才补。比如发送邮件这种操作,我们一开始没有做确认流程,结果模型在一次自动排程中直接给用户发了一封语气不太对的邮件,虽然内容没有大问题,但体验很差。后来我们把所有带"对外发送"属性的技能统一升级为需要用户显式确认,再没出过类似问题。
第三,技能的 debug 能力要尽早内置。每个技能在运行时都应该打出足够详细的结构化日志,包括:何时被召回、召回的相似度分数、模型生成了什么参数、schema 校验是否通过、执行耗时多少、返回结果是否被后处理改写。这些日志平时不起眼,出问题时所有的排查都依赖它们。我们中间一度为了省日志成本做了降采样,结果某个技能出问题时整整回溯了三天,从那之后我们再也没动过日志的念头。
最后一个小技巧,可能不是所有人都用得上:给低风险技能设置一个"静默验证模式"。也就是在真实的用户请求进来时,技能照常执行,但结果不展示给用户,只写入日志,第二天人工抽查一次。我们用它验证两个新技能的效果,在没有打扰用户的情况下累计了几百条真实场景数据,对判断技能能否转正非常有帮助。
agent-skills 这套东西到现在还在迭代,最近在做的是把技能描述自动生成化,尽量让维护者少写重复的元数据。这个方向能不能走通我还不确定,但有一点我很确定:把 Agent 的能力拆成可注册、可测试、可观测的技能单元,哪怕一开始多花点时间,长远看都是值得的。