Agent-skills这个项目,说穿了就是一句话:把大模型从“什么都会一点”变成“该会的时候真能上手”。我做这个项目的起点其实很狼狈:手边同时维护着五六个自动化工件,每个工件里都塞了大段大段的工具调用提示词,改一个功能要翻好几个文件;更难受的是,模型经常在几种相似任务之间犯迷糊——你明明告诉它有“搜索”和“查资料”两个技能,它偏要用搜索去查资料。被折腾了几周之后,我决定把技能这件事单独拎出来做一套统一管理的东西,这就是agent-skills的来历。这篇文章不打算讲什么高深的理论,就说说我是怎么设计技能定义、怎么让模型正确选择技能、又在实际跑任务时踩了哪些坑。如果你正在做智能体相关的东西,或者手头有一堆半自动化的LLM调用逻辑想梳理,这篇应该对你有用。
1. 为什么技能库比提示词拼接更可靠
1.1 提示词堆叠的三个失效场景
先交代一下我原来的做法。典型的一个自动化任务,我的系统提示词大概长这样:“你是一个可以访问文件系统的助理。你可以使用以下工具:列出目录、读取文件、写入文件、运行Python代码、执行Shell命令……”然后我在后面列了几十行JSON格式的工具定义。刚开始小规模用没问题,任务一多、工具一多,问题就接踵而至。
第一个失效场景是注意力稀释。模型对每一条工具定义都会分配注意力,工具数量超过十几个之后,定义之间开始互相“抢戏”。我曾经在同一个任务里同时挂了搜索、数据库查询、文件检索三个功能,模型经常把数据库查询写成文件检索,或者反过来。你单独看每一条定义都很清楚,但放到一起就是会串。
第二个失效场景是边界模糊。自然语言描述天然存在歧义。“获取信息”这个动作,到底该走搜索技能还是知识库技能?模型自己的理解和你写定义时的理解经常不在一个频道上。你以为“获取”是检索已有文档,模型却理解成发起一次网络请求,结果调用了完全错误的技能。
第三个场景不是模型问题,是工程问题:维护成本。几十处提示词分散在不同脚本里,每个脚本还都有一份“自己的版本”。某天你要给“搜索”技能加一个超时参数,得去四个文件里同步修改,漏一个就出线上事故。这已经不是“模型不够聪明”的问题,而是代码结构本身撑不住复杂度了。
1.2 把“技能”当作一等公民
后来我换了一个思路:不再往提示词里堆工具定义,而是把每一个能力封装成一个“技能对象”。技能不是一个字符串,而是一组结构化数据加一段可执行代码。它的组成部分是固定的:
- 技能名(全局唯一)
- 描述(给模型看,包含触发条件、使用场景、明确不要用的情况)
- 参数规范(JSON Schema,声明参数名、类型、必填、枚举、示例)
- 执行入口(一个可调用函数,入参是解析好的参数,出参是结构化结果)
- 校验与回退(参数校验、超时、异常处理)
提示词里只保留一句话:“你是一个具有多项技能的智能体。需要某项能力时,从技能注册表中选取匹配的技能并调用之。”其余全部细节都由技能对象自己带走。
这样做的好处,可以用一个生活化类比:以前是把所有工具散在桌子上,模型在“一堆钳子扳手螺丝刀”里找哪个是梅花扳手;现在每个工具都有自己的卡槽、标签和使用手册,模型只需要根据需求去取对应的那个。
与工具调用(function calling)的区别也值得说一下。function calling解决的是“如何把模型的输出转成结构化函数调用参数”,而技能体系解决的是“模型该调用哪个能力、这个能力如何被稳定执行”。两者是互补关系,我的agent-skills实现里,最终执行层仍然走的是结构化函数调用,但上层的技能注册、描述管理、调度逻辑都是自己控制的。
2. 技能元数据设计:让模型自己学会“什么时候用什么”
2.1 描述字段怎么写得让模型不误解
描述是整个技能系统里最重要、也最容易被低估的字段。它不是给人看的注释,而是模型做“技能选择”时的全部依据。我的经验是三条。
第一条,用触发场景而不是功能定义。不要写“本技能提供数据库查询能力”,要写“当用户需要获取已入库的订单、用户、库存等结构化数据时使用;例如‘查一下订单总量’‘看看库存还有多少’。若数据不在数据库中而在外部网页,请勿使用本技能,改用网页检索技能。”这样模型看到的不只是一个抽象能力,而是一组可以匹配的实例。
第二条,主动给出反面约束。只写“何时使用”不写“何时不用”,模型就会扩大适用范围。我会在描述里固定加一句“不要用于XXX”。比如文件列目录技能里写“不要用本技能读取文件内容”,网络检索技能里写“不要用本技能查询数据库内容”。这一点在技能数量增多之后价值特别明显。
第三条,一个技能只做一件事。如果你发现某个技能描述里出现了“以及”“另外”“还可以”,基本就是拆分的信号。我在初期犯过这个错,把一个“数据处理”技能搞成了既能清洗又能统计还能画图,结果模型面对三个完全不同的任务都选了它,执行时又因为没有对应功能而报错。
2.2 参数规范:不如 Schema 可靠
技能好不好用,一半看描述,一半看参数。参数我直接用JSON Schema描述,不需要发明新格式。
- 参数名用小写下划线,语义明确:
file_path、max_results、timeout_seconds - 必填与选填要清楚,选填都要给默认值或说明
- 枚举值必须给,优先于自由输入
- 每个参数带一个
example,这是模型最容易参考的
一个教训:如果某参数本质上是布尔开关,就定义成 boolean,不要定义成“可选字符串,传入'yes'表示开启”。模型有时候传'Y',有时候传'true',你的解析层就要写一堆 if 语句。布尔就是布尔,字符串就是字符串,类型定死了,解析层的分支就少一半。
2.3 注册表结构
我设计了一个本地注册表,本质是一个JSON文件加一个内存索引。结构大概这样:
{ "schema_version": 3, "skills": [ { "name": "list_files", "version": "1.2.0", "description": "列出指定目录下的文件与子目录,适合在需要了解文件结构时使用。例如:'看看data目录里有什么''把下载文件夹列一下'。", "negative_description": "不适用于读取文件内容,不清算行数,不修改文件。", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "目录绝对路径", "example": "/home/user/data" }, "recursive": { "type": "boolean", "default": false } }, "required": ["path"] }, "entry": "list_files", "timeout_seconds": 15, "tags": ["filesystem", "basic"] } ] }注册表只负责描述,执行入口在另一个模块映射。这样设计的好处是注册表可以单独灰度发布,不经过系统提示词的大改。更新一个技能描述,不需要重新部署主程序,热加载注册表就行。
这里有个小经验:技能描述要留一个“模型可读版本”和“开发者可读版本”吗?实测下来不需要。你写的时候有面向模型意识的精炼就够了;给协作的人看的就是这个文件本身,没有额外文档。靠代码注释补充。
3. 从定义到可调用:一个“代码解释器”技能落地全程
光有元数据不行,还得让技能真能被跑起来。我用“代码解释器”技能当例子说明完整落地链路。这是整个系统里最复杂、也最有价值的技能之一。
3.1 技能基座接口
我用的Python。定义了一个BaseSkill基类:
class BaseSkill: name: str description: str parameters_schema: dict def validate(self, params: dict) -> dict: # 基于 jsonschema 校验,返回清洗后的参数 pass def run(self, params: dict, context: dict) -> dict: # 执行技能,返回 {"status": "ok", "data": ..., "summary": ...} raise NotImplementedError每个技能只需要继承实现run方法。调度器拿到模型决定的技能名和参数后,先去注册表找到技能对象,做参数校验,然后调用run。异常、超时、返回格式统一封装。这样新增技能时不用改任何调度代码,只需要注册一个新类和一个新描述。
3.2 沙箱与资源限制
“代码解释器”技能本质是执行LLM生成的代码,如果不加限制等于让模型直接操控宿主机。我的限制分几层:
- 运行在独立子进程(用
subprocess),进程有独立的临时工作目录 - 设置执行超时,默认30秒,超过直接强杀进程
- 限制内存使用(
resource模块设置地址空间上限) - 默认禁用网络请求,需要联网的能力走专门的网络技能
- 不开文件系统写权限,除了工作目录
成本其实不高,真正会写代码让模型干的场景,那几行Python都到不了很复杂的程度。重点是不管多信任模型都不要完全放开执行权限。我把这个经验总结成一句话:代码解释器的价值上限由沙箱边界决定,而不是由模型能力决定。
一个非常容易踩的坑:你以为“只在本地跑跑脚本没关系”,但脚本可以访问你所有的本地文件和内网服务。沙箱不是给恶意攻击者准备的,是给模型的天马行空兜底的。
3.3 调度器怎么把技能暴露给模型
我遇到的第一个问题是格式选择。主流大模型接口都有结构化工具调用格式,我自己控制的技能体系也支持。但实测下来,对于“技能数量较多、且需要模型自己判断优先级”的场景,直接把全部工具定义塞给接口,效果并不好。原因和前面说的注意力稀释一样。
所以我做了一层“技能召回”:先把用户请求和技能描述做一次相关性计算,取 top k 个技能,再把它们的结构化定义作为候选工具传给模型。也就是说,模型每次看到的不是全部几十个技能,而是最相关的三五个。这样既保留了结构化调用的稳定性,又规避了工具过多时模型选择错乱的问题。
这一步做法:离线把所有技能描述做成向量索引,在线对当前用户请求做相似度检索。混合策略是“向量检索得分 + 关键词命中 + 规则加权”。我实测下来,纯向量召唤有时候会把语义相近但实际不同的技能混进来,所以还是会加一套简单的规则过滤,比如用户提到“文件”就优先文件类技能。
4. 动态调度实验:意图解析、打分排序与多技能协作
4.1 技能匹配不只是一次选择
当候选技能数量收敛到三五个以后,真正的选择还得靠模型判断。我的调度流水分四步:
- 用户请求预处理:提取目标、约束条件(路径、格式、时间范围)
- 技能候选召回:向量 + 规则,得到 top 5
- 模型决策:把用户请求和候选技能的完整描述发给模型,要求输出技能名和参数
- 执行与反馈:调用技能,把结果摘要回填给模型,必要时继续下一步
在第3步里,我要求模型输出必须是严格JSON,同时给出confidence字段。confidence 低的时候,调度器会走“一次澄清”而不是直接跑。这个设计看起来多了一次交互,实际上节省了大量因为“选错技能”而产生的返工。
4.2 一个多技能协作的完整流程
举一个我经常用来验收系统稳定性的任务:“统计/home/user/scripts目录下所有 Python 脚本的平均行数,并生成一份 Markdown 报告。”
这个任务只看表面要调“代码解释器”,实际会拆成三步:
- 用
list_files技能列出目录,拿到所有.py文件名单 - 用代码解释器逐文件统计行数并计算平均值(这里也可以让代码解释器自己调用文件,但为了统一安全边界,倾向于先 list 再传参)
- 用一个
report_writer技能生成 Markdown 报告
我的调度器本身不负责“任务分解”——那仍然是模型的工作。但我在技能依赖声明里做了辅助:list_files的结果会以结构化摘要形式进入上下文,避免模型把几百个文件名原样带进去;report_writer的入参之一就是前一步的结构化数据。这样模型自然会被引导着走“先获取列表→再计算统计→最后成文”这个顺序。
一个容易忽略的点:技能执行结果别把原始大 JSON 全塞回提示词。我在run返回里强制规定summary字段,长度不超过几百个字符,原始数据只保留在本地缓存。这样上下文不会被巨大的中间结果撑爆,多技能协作才走得下去。
4.3 失败回退:不要让模型在错误路径上硬走
实际运行里,模型选错技能、参数解析失败、技能超时都经常发生。我的回退策略分三档:
- 可恢复错误(参数不合法):清洗参数后重试一次,不行则把错误信息回传模型重新决策
- 匹配失败(confidence 低或无合适技能):走澄清流程,提交“我理解为…是否正确”
- 不可恢复错误(技能根本不存在或超时):直接终止任务,返回用户“当前请求无法完成,原因是……”
重点是区分“模型决策错误”和“技能执行错误”。前者应该回到模型重新选,后者不应该让模型背着锅无限重试。我加了一个 attempt 计数器,同一个技能同一任务最多重试两次,超过就把控制权还给模型换一条路径。
5. 一个月实测里我踩过的三个坑和修复方式
5.1 技能描述成了“幻觉重灾区”
有一次我发现模型经常在一个简单任务上报出离谱的技能名。排查后定位到描述字段里出现了“获取网页信息”这类短语,模型把“网络检索”和“读取本地文件”两个技能都召回进来,最后选了网络检索,但给的参数里却是文件路径。问题根源是描述里的名词和动词太宽泛。“获取”这个词既覆盖了网络请求,也覆盖了本地读取,模型在语义空间里没法精确区分。
修复:给描述加了“不要用于”段,并为容易混淆的技能补充了互斥说明。比如网络检索技能明确写“本技能仅用于外部网页数据,不适用于本地文件、数据库”;本地文件技能明确写“本技能仅操作用户工作目录内的文件,不适用于外部网页”。改动后的效果很明显,两个技能的选择准确率从大概七成提升到九成以上。这个土办法比任何复杂的排序算法都管用。
5.2 参数传递污染
另一个坑是长会话场景。任务A使用了time_range参数,任务B没有这个参数,但模型在连续对话里会把上一轮的time_range残留带到B的参数里。一开始我以为是自己解析层写错了,排查完发现模型就是会“沿用上文的JSON结构”。这是上下文学习带来的副作用,模型倾向于模仿最近的输出格式。
修复方式是调度器在调用技能前强制做一次参数白名单清洗:不在 schema 里的键全部丢弃,然后重新校验;同时在技能结果回填时只回传 summary,不回传全部参数。这样即使模型把历史参数带进来,也会被白名单过滤掉。
5.3 循环调用与超时黑洞
第三个坑是技能之间出现隐性循环。report_writer在生成报告前会查数据库,数据库技能在连接异常时会调用诊断技能,诊断技能又会尝试执行代码解释器,如果代码解释器里恰好有“读取数据源”的逻辑,整个链路就循环起来了。当时日志里能看到一层层的嵌套调用,直到资源耗尽。
修复是两层:调用深度上限(单任务最多5层技能调用),以及每层必须带超时熔断。现在日志里能看到每一层的调用链和耗时,循环问题要么被深度限制截断,要么被超时熔断,不至于卡死整个任务。后来我还把技能间的依赖关系抽成了depends_on字段,调度前先做一次依赖图检测,有环就直接拒绝执行。
5.4 三类问题的对照表
| 问题 | 根因 | 修复 |
|---|---|---|
| 技能选择幻觉 | 描述宽泛、无负面约束 | 触发场景 + 反面约束 |
| 参数串扰 | 上下文结构残留 | 参数白名单清洗 + 只回传摘要 |
| 循环调用 | 技能依赖无边界 | 深度限制 + 超时熔断 |
6. 给同样在做 Agent 技能的几点可复用建议
最后给几点我现在的设计习惯,未必适合所有场景,但值得参考。
6.1 技能版本化和灰度开关
技能描述改一行,效果可能天翻地覆。我现在每个技能都有版本号,注册表里能同时存在两个版本,通过开关切流量。分批灰度比一次性替换安全很多。比如我改list_files的描述,先在灰度环境里放1.1.0和1.2.0两个版本,观察一天的选择准确率,再决定是否全量切到1.2.0。
6.2 技能依赖要显式声明
上面说的循环调用问题,后来我在技能元数据里增加了depends_on字段,声明了依赖关系。调度器在组合技能前先做一次依赖图检查,发现环形依赖就拒绝执行,并给出哪几个技能参与成环的信息。这一步把“运行时崩溃”变成了“启动时拒绝”,排查成本降低了不少。
6.3 可观测性:每个技能调用都要有日志
每个技能调用记录输入参数摘要、耗时、状态、summary 输出。排查问题的时候,没有这些日志等于盲人摸象。我现在的做法是结构化日志,每个技能调用一行,包含技能名、版本、调用深度、耗时、status。长任务跑挂了,直接按任务ID过滤日志,就能看到在哪一层断掉的。
6.4 一个很土但有效的起步建议
如果你刚开始做 agent-skills 这类东西,别急着写调度框架。先把两三个技能用手工 if-else 的方式跑通,感受一下“模型选择技能”的真实行为和失败模式,再上调度器。我最初的调度器就是在一堆手工链路跑通了之后才抽象出来的。接触过真实失败案例之后,你写调度逻辑的时候心里才有数——哪些错误需要回退,哪些错误需要澄清,哪些错误干脆不该让模型碰。
最后分享一条我在反复调试中的体会:技能系统真正难的,不是执行器,不是调度器,而是你写给模型看的那几行描述。模型在技能选择上的表现,很大程度上由描述质量决定。把描述当成核心代码来写,像写测试用例一样给每条描述配触发例句和反例,这套东西就成功了一大半。