1. 从“会聊天”到“能干活”:agent-skills 到底在解决什么问题
大模型刚火起来那阵子,大家最兴奋的是“它能陪我聊天”。但真把它丢进业务里,问题立刻暴露:你问它今天天气,它能编出一段像模像样的回答;你让它帮你把一份表格里的异常数据挑出来、按规则重命名、再生成一份汇总报告,它就开始顾左右而言他。原因很简单——聊天能力不等于执行能力。
agent-skills这个项目标题,核心指向的就是这件事:给智能体(Agent)装上一套可复用、可组合、可调用的“技能包”,让它从“只会说”变成“真的能做”。你可以把它理解成给一个刚入职的实习生配了一本《岗位操作手册》,手册里写清楚了每项任务怎么做、需要什么输入、产出什么结果、遇到异常怎么处理。没有这本手册,实习生只能靠猜;有了这本手册,他就能稳定地把活干出来。
我接触过不少团队做智能体,最常见的误区是:把所有逻辑都塞进一个巨大的提示词里,指望模型“自己领悟”。结果就是提示词越写越长,行为越来越不可控,改一处崩三处。agent-skills的思路恰恰相反——把能力拆成一个个独立的技能单元,每个技能只负责一件明确的事,通过标准化的接口被调度。这样做的好处是:可测试、可替换、可复用,出了问题能定位到具体哪个技能,而不是对着一整段提示词干瞪眼。
这篇文章适合三类人看:一是正在做智能体应用、被“行为不稳定”折磨的开发者;二是想把重复性工作交给智能体、但不知道从哪下手的业务同学;三是对 Agent 架构感兴趣、想搞清楚“技能”这一层到底怎么设计的技术爱好者。我会从整体设计思路讲到具体实现,再到踩过的坑,尽量把每个“为什么”都讲透,让你看完能直接照着搭一套自己的技能体系。
2. 整体设计思路:为什么要把能力拆成“技能”
2.1 一个技能应该长什么样
先给结论:一个合格的技能,本质上是一个带元数据的函数。它至少包含四个部分——技能名称、功能描述、输入参数定义、执行逻辑。名称用于被调度器识别,描述用于让模型判断“这个技能适不适合当前任务”,参数定义用于校验输入,执行逻辑则是真正干活的代码或提示词链。
为什么强调“带元数据”?因为智能体调度技能时,靠的不是硬编码的 if-else,而是让模型根据任务描述去匹配技能描述。这就要求技能描述必须写得像一份清晰的说明书:什么场景下用、需要什么前置条件、会返回什么。我见过太多项目把技能描述写成“处理数据”,模型根本不知道它处理的是什么数据、什么时候该调用,最后调度全乱套。
举个具体的例子。假设你要做一个“日报生成”技能,描述不能只写“生成日报”,而要写成:“当用户提供了当日的工作记录文本,且需要整理成结构化日报时调用。输入为原始记录文本,输出为包含‘今日完成’‘遇到问题’‘明日计划’三个字段的 Markdown。”这样模型一看就知道什么时候该用它。
2.2 拆分粒度:太粗和太细都是坑
拆分粒度是设计技能体系时最纠结的问题。拆得太粗,一个技能干十件事,等于没拆,还是不可控;拆得太细,一个技能只做“把字符串转大写”,那调度开销比执行本身还大,模型光在选技能上就耗尽了注意力。
我的经验是遵循**“单一职责 + 可独立验证”**原则。判断标准很简单:如果这个技能的执行结果无法单独写一个测试用例来验证,那它可能拆得不够独立;如果两个技能总是被一起调用、从不分开,那它们可能该合并。比如“读取文件”和“解析 CSV”可以分开,因为读取可能来自不同来源;但“解析 CSV”和“提取某列”如果永远绑定,就可以合成一个“从 CSV 提取指定列”的技能。
实际项目中,我一般把技能分成三层:基础技能(读写文件、发请求、格式转换)、领域技能(业务规则计算、特定格式解析)、编排技能(把前两者串起来完成一个完整任务)。这样分层的好处是,基础技能可以跨项目复用,领域技能跟着业务走,编排技能则随需求变化快速调整。
2.3 调度机制:让模型“选对工具”的关键
技能拆好了,接下来是调度。调度器要解决的核心问题是:给定一个用户请求,怎么从几十个技能里选出正确的那几个,并按正确顺序执行。
常见做法有两种。一种是让模型直接输出技能调用,比如要求模型返回 JSON 格式的{"skill": "read_file", "args": {...}}。这种方式灵活,但对模型的指令遵循能力要求高,容易输出格式错误。另一种是先检索再调用,用向量检索把候选技能缩小到三五个,再让模型从中选。这种方式稳定,但需要维护技能描述的向量库。
我实测下来,混合方案最稳:先用关键词和向量检索做粗筛,把候选技能控制在 5 个以内,再让模型做精排和参数填充。这样既避免了模型面对几十个技能时的“选择困难”,又保留了灵活性。粗筛阶段的关键是技能描述里要有足够的关键词,比如“文件”“读取”“路径”这些词要自然出现在描述中,方便检索命中。
提示:调度器一定要有“兜底技能”。当模型无法匹配任何技能时,调用一个“澄清询问”技能,让智能体反问用户,而不是硬着头皮瞎编。这个兜底能挡掉大量幻觉。
3. 核心细节解析:技能定义、参数校验与执行隔离
3.1 技能定义文件怎么写才不容易出错
技能定义通常用一个结构化文件描述,YAML 或 JSON 都行。我偏好 YAML,因为可读性好,写注释方便。一个典型的技能定义包含这些字段:name(唯一标识)、description(给模型看的说明)、parameters(参数列表,含类型和是否必填)、handler(执行入口)、timeout(超时时间)、retry(重试策略)。
这里有个容易忽略的点:参数类型要尽量收窄。不要用“字符串”这种宽泛类型,能用枚举就用枚举,能用数字就用数字。比如“输出格式”这个参数,直接定义成enum: [markdown, json, plain],模型就不容易填错。我踩过的坑是,早期把参数都定义成 string,结果模型传进来一个“大概”“可能”这种词,执行逻辑直接崩掉。
另一个细节是description的写法。它不仅是给模型看的,也是给检索用的。所以要把同义词写进去。比如“读取文件”这个技能,描述里要同时出现“读取”“加载”“打开”“文件”“路径”这些词,这样无论用户怎么说,检索都能命中。
3.2 参数校验:别让脏数据流进执行层
模型填参数,出错是常态。可能少填、多填、类型不对、值超出范围。所以参数校验必须放在执行逻辑之前,作为一道硬闸门。校验不通过就返回明确的错误信息,让模型有机会重新填。
校验分两层。第一层是结构校验:必填项有没有、类型对不对、枚举值在不在范围内。这层用现成的校验库就能做,比如 Python 的 pydantic。第二层是业务校验:比如“日期不能晚于今天”“文件路径必须在允许的目录下”。这层需要自己写,但非常值得,因为它能挡住很多“看起来合法但实际有害”的输入。
我一般会在校验失败时返回这样的信息:“参数date格式错误,期望 YYYY-MM-DD,实际收到 ‘明天’。请重新提供。”这种带具体原因和期望格式的反馈,能让模型快速自我修正。实测下来,加上这层反馈后,参数填充的一次成功率能从六成提到九成以上。
3.3 执行隔离:一个技能崩了不能拖垮整个智能体
技能执行时可能出各种问题:超时、抛异常、返回超大结果、甚至死循环。如果不做隔离,一个技能卡住,整个智能体就挂了。所以每个技能的执行必须放在独立的沙箱或至少独立的超时控制里。
我的做法是给每个技能调用包一层执行器,负责三件事:超时中断、异常捕获、结果截断。超时时间根据技能类型设,读文件可以短一点,发网络请求长一点,但都要有上限。异常捕获后不要直接把堆栈抛给模型,而是转成人类可读的错误描述,比如“读取文件失败,原因:路径不存在”。结果截断也很重要,有些技能可能返回几万字的文本,直接塞回上下文会撑爆窗口,所以要设一个最大长度,超出部分截断并提示“结果过长已截断”。
注意:执行隔离不只是技术问题,也是安全问题。如果技能涉及执行代码或访问外部资源,一定要限制权限范围,避免智能体被诱导去做危险操作。
4. 实操过程:从零搭一套可运行的技能体系
4.1 环境准备与目录结构
先说一下我用的技术栈,你可以按自己的习惯替换。语言用 Python,因为生态成熟、写起来快。核心依赖就几个:pydantic做参数校验,pyyaml读技能定义,asyncio做异步调度。不需要什么重型框架,几百行代码就能跑起来。
目录结构我习惯这样组织:
agent_skills/ skills/ # 技能定义文件 read_file.yaml parse_csv.yaml generate_report.yaml handlers/ # 技能执行逻辑 read_file.py parse_csv.py generate_report.py core/ registry.py # 技能注册与检索 executor.py # 执行隔离与超时控制 validator.py # 参数校验 main.py # 入口这样分的好处是,技能定义和执行逻辑分离,改定义不用动代码,改代码不影响定义。新人接手时,看skills/目录就知道系统有哪些能力,一目了然。
4.2 写第一个技能:从定义到执行
我们拿“读取文件”这个最基础的技能开刀。先写定义文件read_file.yaml:
name: read_file description: 读取指定路径的文本文件内容。当用户需要加载、打开、查看某个文件时调用。支持 txt、md、csv 等文本格式。 parameters: - name: path type: string required: true description: 文件的完整路径或相对路径 - name: encoding type: enum values: [utf-8, gbk] required: false default: utf-8 description: 文件编码,默认 utf-8 handler: handlers.read_file:run timeout: 5 retry: 1定义里我把encoding做成枚举,就是为了防止模型填出“UTF8”“utf8”这种变体。handler指向具体的执行函数,格式是“模块:函数名”。timeout设 5 秒,读文件足够。retry设 1,读文件失败重试一次通常没意义,但如果是网络文件就有用,这里先留着。
执行逻辑handlers/read_file.py:
def run(path: str, encoding: str = "utf-8") -> str: with open(path, "r", encoding=encoding) as f: content = f.read() if len(content) > 10000: content = content[:10000] + "\n...[内容过长已截断]" return content注意最后那个截断,这是必须的。我见过太多项目忘了截断,结果一个大文件把上下文撑爆,后续所有技能都执行不了。
4.3 技能注册与检索的实现
技能注册就是把所有 YAML 定义加载进来,解析成对象,存到一个字典里。检索则分两步:先按关键词粗筛,再按向量相似度精排。关键词粗筛很简单,把用户请求分词后,和技能描述里的词做匹配,命中越多分越高。
向量检索需要先把技能描述转成向量存起来。我一般用轻量的本地嵌入模型,避免依赖外部服务。检索时把用户请求也转成向量,算余弦相似度,取 top 5。然后把粗筛和精排的结果合并去重,得到最终候选列表。
这里有个细节:技能描述里要包含“反例”。比如“读取文件”的描述里可以加一句“不适用于写入或修改文件”。这样当用户说“修改文件”时,这个技能就不会被误选。反例能显著降低误调度率。
4.4 调度循环:让智能体自己决定下一步
调度循环是整个体系的心脏。它的逻辑是:接收用户请求 → 检索候选技能 → 让模型选择技能并填参数 → 校验参数 → 执行技能 → 把结果喂回模型 → 判断任务是否完成 → 未完成则继续循环。
这个循环要有最大轮次限制,比如 10 轮。防止模型陷入“调用-失败-再调用”的死循环。每轮结束后要检查是否达成目标,达成则输出结果,未达成且轮次用尽则返回当前进展并说明原因。
我实测下来,轮次限制设 8 到 10 比较合适。太少会导致复杂任务做不完,太多则浪费 token 且增加出错概率。另外,每轮之间要把历史调用记录带上,让模型知道“我已经试过什么”,避免重复调用同一个技能。
5. 常见问题与排查技巧实录
5.1 模型选错技能怎么办
这是最高频的问题。表现是:用户要“生成报告”,模型却调了“读取文件”。排查思路分三步。第一,看技能描述是否清晰,有没有把适用场景写明白。第二,看候选技能是不是太多,如果一次给模型 20 个选项,它很容易选错,要把候选压到 5 个以内。第三,看用户请求是否太模糊,如果用户只说“处理一下”,那模型选错也正常,这时候应该触发澄清询问。
我的经验是,**在技能描述里加“触发词”**特别有效。比如“生成报告”的描述里明确写“当用户提到‘报告’‘汇总’‘总结’时调用”。这样模型匹配时有了明确锚点,准确率能提升不少。
5.2 参数填错或漏填怎么修
参数问题通常表现为:必填项没填、类型不对、值超范围。修复的关键是错误信息要具体。不要只说“参数错误”,要说“参数path是必填项,但未提供,请补充文件路径”。模型看到这种反馈,下一轮基本能改对。
如果模型反复填错同一个参数,那可能是参数定义有问题。比如参数名太抽象,模型理解不了。这时候要把参数名改得更直白,或者在描述里给例子。我遇到过一个案例,参数叫cfg,模型总是填错,改成config_content后立刻就好了。
5.3 技能执行超时或卡死怎么处理
超时是执行层的常见问题。处理原则是:每个技能必须有超时,超时后必须能中断。Python 里可以用asyncio.wait_for实现。中断后返回超时错误,让模型决定是重试还是换技能。
卡死则更麻烦,通常是技能内部有死循环或阻塞调用。预防办法是代码审查时重点看循环和网络请求,确保都有退出条件。另外,执行环境要设资源上限,比如内存和 CPU 时间,防止单个技能耗尽资源。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决技巧 |
|---|---|---|---|
| 模型选错技能 | 描述不清、候选过多 | 检查描述触发词、候选数量 | 加触发词、压候选到 5 个内 |
| 参数反复填错 | 参数名抽象、缺示例 | 检查参数命名和描述 | 改直白命名、加示例值 |
| 技能执行超时 | 无超时控制、逻辑阻塞 | 检查超时设置和循环 | 加超时、审查阻塞调用 |
| 结果撑爆上下文 | 无截断、返回过大 | 检查结果长度限制 | 加截断、设最大长度 |
| 调度陷入死循环 | 无轮次限制、重复调用 | 检查轮次上限和历史记录 | 设 8-10 轮上限、带历史 |
提示:这张表建议打印出来贴在工位上,出问题时按行排查,能省不少时间。
6. 技能体系的扩展与复用经验
6.1 怎么让技能跨项目复用
技能复用的前提是接口稳定。一个技能如果参数老变、返回值老变,就没法复用。所以设计时要尽量把技能做成“纯函数”:给定相同输入,永远返回相同输出,不依赖外部状态。比如“解析 CSV”技能,只依赖传入的文本,不依赖数据库或全局变量,这样换个项目直接就能用。
另外,技能定义和执行逻辑要分离。定义文件描述“是什么”,执行代码实现“怎么做”。复用的时候,定义文件可以直接拷,执行代码如果依赖特定环境,就写一个适配层。我一般会把通用技能打包成一个独立的包,新项目直接安装依赖就能用。
6.2 技能版本管理怎么做
技能会迭代,迭代就会有不兼容。所以技能定义里要带版本号,比如name: read_file@v2。调度时默认用最新版,但允许指定版本。这样老项目不会被新版本意外影响。
版本升级时,旧版本要保留一段时间,等所有调用方都迁移完再下线。我踩过的坑是直接覆盖旧版本,结果某个老项目突然报错,排查半天才发现是技能行为变了。从那以后,我坚持版本号管理,再也没出过这类问题。
6.3 性能优化:让调度更快更省
技能多了以后,检索会成为瓶颈。优化方向有两个:一是缓存检索结果,同样的用户请求不用重复检索;二是预加载技能定义,启动时就把所有定义加载到内存,避免每次调用都读文件。
执行层面,能异步的技能尽量异步。比如多个独立的读取操作可以并发执行,不用串行等。但要注意并发上限,别把系统压垮。我一般设一个信号量,控制在 5 到 10 个并发。
最后再分享一个小技巧:给技能打标签。比如“文件类”“网络类”“计算类”,检索时先按标签过滤,再在标签内精排。这样能把检索范围快速缩小,速度提升很明显。标签不用多,三五个大类就够,太多反而增加维护成本。
这套技能体系我用了大半年,最大的体会是:智能体的能力上限,不取决于模型多强,而取决于技能设计得多清晰。模型再聪明,给它一堆模糊的技能,它也干不好活;反过来,技能定义得清楚,中等模型也能稳定输出。所以别急着换更大的模型,先把技能这层打磨好,收益往往更直接。