1. 智能体为什么需要一套“技能”而不仅仅是工具列表
我在这大半年的 agent-skills 项目里踩得最深的一个坑,就是一直把智能体当成“提示词 + 工具列表”的组合。早期版本挂过一份万能工具包:读文件、查数据、算指标、发邮件,加起来大概十个能力接口。我当时天真地认为,只要把每个接口的说明写得足够清楚,模型自然会像程序员一样灵活组合。结果在演示环境里看起来一切正常,一旦换成真实业务场景,整个流程就频繁翻车。
1.1 一个翻车案例:工具全给,任务全崩
先说个很典型的例子。我要做一个“把销售数据整理成日报并发送给负责人”的智能体,按照常规思路给它挂了读取表格、查询数据库、计算汇总、渲染模板、发送邮件等十个工具。单步操作确实能完成,比如“帮我读一下昨天那份CSV”,模型会准确调用读取接口。可一旦任务升级成“读完CSV,筛选出异常值,再给负责人发一封摘要邮件”,问题立刻暴露。
模型有时候直接跳过读文件步骤,凭借上下文里出现过的字段名“脑补”出一份数据;有时候调用了一个系统里根本不存在的函数名,比如把send_email写成了email_send_reminder;更多时候是反复试错,一会调查询接口,一会又回头读文件,最后生成一封内容空洞的邮件。那段时间我几乎以为是大模型推理能力不够,换了更强的模型也一样,问题照旧。
后来我才想明白,这不是模型笨,而是设计姿势不对。工具列表把所有“能力原子”都暴露给了模型,却完全没告诉它“以什么顺序、在什么边界内、失败之后怎么兜底”。这就像给一个新手厨师把所有食材和厨具都摊在桌上,但不给菜谱。厨艺好的人可能随手做出一桌菜,但普通水平的人大概率手忙脚乱。真实场景下可不止十个接口,一旦有二三十个能力暴露出来,指望模型靠系统提示词里的几句话完成自主调度,基本等于要求它边开车边读说明书。
1.2 Skills的边界:行为封装与提示隔离
所以我在 agent-skills 里重新定义了“技能”这个词。一个技能不是单个 API,而是一个“能够独立完成一项任务”的封装。它内部可以包含多个工具调用、判断条件、提示词模板、错误处理逻辑,甚至子技能调用;但对外部模型来说,它只暴露三样东西:名字、一句话描述、参数结构。
这样做的好处很具体。第一,模型面对的决策面变小了。它不再需要从十个函数里逐个选,只需要判断“用户意图是否匹配这个技能”以及“参数怎么填”。第二,技能内部可以实现提示隔离,每个技能自己管理执行规则、输出格式,不污染主对话的上下文。第三,技能一旦封装出来,就可以被单独测试和复用,这个价值在长期迭代时会越来越明显。
我常用一个类比:工具列表是“食材和厨具”,技能是“菜谱”。模型是厨师,只给它食材和厨具,它得自己发明做法,翻车概率极高;给它一份写好的菜谱,它只需要照着执行,稳定性立刻上一个台阶。这也是为什么 agent-skills 项目里最核心的不是模型选型,而是如何把一个个技能定义得干净、准确、边界清晰。
2. agent-skills 的技能描述:用机器可读的方式告诉模型“你会什么”
见过不少团队把技能定义写成一大段自然语言,然后整段塞进系统提示词。之前我也这么干过,效果非常差。自然语言适合给人看,但模型在生成时需要的是高度结构化、带触发条件、带参数约束的声明。在 agent-skills 里,每个技能都是一个独立的 YAML 文件。用 YAML 而不是 JSON,主要是因为人机都能读,写起来不容易漏逗号,解析也足够稳。
一个技能描述大概长这样:
name: send_daily_report description: 当用户要求发送销售日报或运营日报时使用;必须指定日期与收件人;不适用于查询历史数据。 input: date: type: string required: true example: "2025-06-10" recipients: type: array items: string required: true steps: - read_sales_data(date) - generate_markdown_summary(data) - send_mail(content, recipients) output: mail_id: string这里面的description、input、steps三个字段各有讲究,我实际使用过程中踩过不少坑,逐个说一下。
2.1 技能声明里的“人话”部分
description是整个技能能否被模型正确调用的关键。很多人把它当成产品说明文档来写,比如“本技能支持销售日报的生成与发送,能够自动汇总数据并渲染成 Markdown 格式,同时支持多人发送”。这种描述太抽象,模型看完还是不知道什么时候该用。
我后来总结出一个公式:动词开头 + 触发条件 + 排除边界。一个合格的描述应该是“当用户要求发送销售日报或运营日报时使用;必须指定日期与收件人;不适用于查询历史数据”。注意后半句的“不适用”,这是绝大多数人会忽略的。模型在相似技能之间犹豫时,明确写出“不要用于什么场景”,能显著降低误调用率。
另一个容易被忽略的点是,description里提到的触发词要尽量贴近用户的真实口语。用户不一定会说“发送日报”,他可能说“把今天的汇总发给我”“给几位负责人推一下昨天的情况”。我做过几轮统计,把真实会话里出现的高频表达吸收进描述之后,技能的命中率从不到七成提升到了九成以上。这属于典型的“语法正确但不贴近真实需求”的问题。
2.2 为什么我不把技能写进系统提示词
早期版本我把所有技能描述拼接成一份很长的系统提示词,结果技能超过二十个之后,每次对话光技能说明就要占掉几千个 token。更麻烦的是,无关技能的信息会变成噪声,干扰模型对用户真实意图的判断。用户问一句“今天天气怎么样”,模型反而开始思考要不要调用某个报表技能,因为它也出现在同一段上下文里。
agent-skills 的运行时把上下文拆成了三层:技能注册索引、技能完整定义、技能执行结果摘要。第一轮模型只能看到每个技能的名字加一句话描述,比如send_daily_report:生成并发送日报邮件。只有当模型明确表示需要某个技能时,系统才把完整 YAML 中的input和output定义加载进上下文。
这样做就像搜索引擎的倒排索引,先用廉价的方式做粗筛选,再按需加载详情。每个技能平均占用几百 token 的话,二十个技能就是上万 token,还没开始干活就用掉了一半上下文预算。现在大部分简单请求只需要消耗几十个 token 的索引信息,成本明显下降,模型的选择准确率反而更高了。
3. 调度与执行:让模型在合适的时机选对技能
技能定义写完,真正难的是调度。很多团队卡在“模型根本不会主动用技能”或者“技能调用时机完全不靠谱”这两类问题上。agent-skills 的调度策略不是靠模型自由发挥,而是把“选技能”这件事拆成更细的步骤。
3.1 路由不是黑魔法:先列清单,再做语义匹配
我的实现里有一个两阶段路由。第一阶段,把技能注册索引发给模型,要求它只输出“最相关的 Top-3 技能 ID”,并且明确禁止编造不存在的技能名。系统拿到这三个 ID 后,先做白名单校验,任何一个未注册的技能名都会直接拦截并让模型重新生成。
第二阶段,把 Top-3 技能各自的完整定义注入上下文,让模型从中选择一个并输出结构化的调用参数。参数会经过 JSON Schema 校验,校验失败就返回错误提示,让模型重新组织,而不是直接带病执行。整个过程可以简单理解成:
[用户输入] -> [技能索引] -> [初选Top3] -> [完整技能定义] -> [调用参数] -> [执行技能] -> [结果摘要] -> [继续对话]为什么非要分两步而不是一步到位?我在项目里对比过很多次。如果一次性把二三十个技能的全部细节丢给模型,它会严重依赖“最近位置”和“描述相似度”,经常在两个相似技能之间反复摇摆,甚至会尝试混合多个技能能力。先粗排序后精选择,每一步的决策难度都被降下来了,模型不需要一次性做太多判断。
这里还有一个关键细节:第一轮生成的 Top-3 是给系统看的,不是直接给用户看的。模型如果连一个技能都不选,说明它判断当前任务不需要外部能力,那系统就直接走普通对话链路,不再加载任何技能定义。这套逻辑让 agent-skills 既可以当一个对话机器人,也能成为一个任务执行引擎,靠技能路由自然衔接。
3.2 上下文预算与技能切换的代价
技能执行之后会产生结果,有的结果特别大。比如从数据库里取五百行明细,直接全部喂给模型,不仅费 token,还容易让模型被无关细节带偏。agent-skills 的做法是给每个技能定义“结果摘要规则”。销售日报这类技能只返回总数、同比、Top3 异常这些关键指标,而不是整张表的内容;资料检索技能返回的是过滤后的小标题和摘要,而不是原始全文。
还有一个必须处理的坑是技能切换。用户先让模型读一份 CSV,模型执行完,用户紧接着又说“按部门汇总一下”。如果不处理旧技能状态,模型容易把上一次读取的完整数据错误地当成新任务的输入,甚至继续沿用上一个技能的输出格式。我给这套机制起了个名字叫“技能状态过期”:在新一轮调用开始前,系统会主动标记上一轮的内部状态为不可用,所有变量重新初始化。
为什么必须这样做?因为大模型的长期记忆并不可靠,它会把所有历史消息都当作文本来处理,旧结果一旦残留,就会变成新决策的上下文。与其让模型去判断哪些数据还有效,不如从系统层面直接断开。这个设计一开始看起来很粗暴,实际跑下来非常省心。
4. 实战样例:三个能被复用的 Skill 怎么写
讲了这么多原理,还是需要落到具体技能上。agent-skills 里我维护了二十多个技能,这里挑三个最有代表性的类型展开,分别是资料检索与摘要、结构化数据清洗、多步骤任务编排。它们覆盖了三种完全不同的使用模式。
4.1 技能A:资料检索与摘要
这个技能解决的是典型的“开放信息收集”场景,比如“找一下最近三个月该行业有意义的新方案”“帮我整理几份背景资料”“这个话题有没有比较新的说法”。用户要的不是一堆原始链接,而是简洁的摘要列表。
最初版本我直接把搜索结果返回给模型,让它自己总结,结果内容不仅又长又乱,还经常混入无关信息。后面改成了在技能内部先过滤再总结:
name: search_and_summarize description: 在指定时间范围内检索公开信息并返回简洁摘要;适合背景调研、资料收集;不适合回答需要具体计算的问题。 input: query: string max_results: integer = 5 time_range: string = "7d" outputs: items: - title: string - summary: string - source: string技能内部第一步用一个轻量相似度算法,把与query相关度低于阈值的搜索结果直接丢弃,然后再让模型对剩下的内容做摘要。轻量过滤比大模型过滤便宜得多,响应速度快,还不会消耗宝贵上下文。实际效果是,模型拿到的是已经清洗过的候选集,摘要质量稳定,也不会被明显无关的信息带偏。
4.2 技能B:结构化数据清洗
第二个技能针对的是另一个高频需求:用户上传一份 CSV 或 Excel,里面既有重复行又有缺失值,还有类型不一致的问题,要求整理成目标结构。这个技能的本质是一个可执行工作流,而不是一个提示词。
name: clean_structured_data description: 清洗CSV或Excel中的重复、缺失与格式异常;当用户要求整理数据文件时使用。 input: file_path: string rules: dedupe: boolean = true fill_blank: boolean = false steps: - detect_issues(file_path) - generate_report(issues) - fix_issues(file_path, rules) - verify_output(file_path)技能内部会依次执行检测、生成报告、修复、验证四个步骤。模型不需要知道底层代码怎么处理缺失值,它只需要看到技能输出的统计信息:“共发现 27 处异常,自动修复 23 处,剩余 4 处需要人工确认。”如果检测到剩余问题超过阈值,技能会返回一个“需要人工介入”的状态,而不是强行生成结果。
这个技能最有价值的地方是复用性极强。任何涉及表格处理的业务,不管是销售、运营还是财务,都可以直接挂载同一套技能,只需要根据场景微调字段规则。
4.3 技能C:多步骤任务编排
第三个技能是后台任务型,比如“每天凌晨生成运营报表并发送给指定人”。它和前两个技能的区别在于,触发方式不是用户在对话里的自然语言,而是定时器或者外部事件。
name: scheduled_report_runner description: 每日定时生成项目状态报表并发送给指定接收者;只在后台任务触发时使用。 trigger: type: cron expression: "0 1 * * *" steps: - collect_metrics() - render_template() - send_report() timeout: 120 max_retries: 3这种技能把我最喜欢的点体现得特别完整:对外部模型来说,它只看到一个名字和一组固化参数;对系统来说,它内部有超时控制、失败重试和状态锁。实际运行时,我会为每一个任务实例生成独立的上下文快照,避免不同任务之间互相串数据。用户可以完全不知道“怎么取指标、怎么填模板、怎么处理发送失败”,只需要在结果里看到“已发送,邮件 ID 12345”或“执行失败,原因:邮箱服务超时”。
5. 迭代中的坑、护栏与评估方法
agent-skills 不是写完就能跑,它需要持续迭代。我在这个过程中踩过的坑非常多,最后沉淀下来的是一套“护栏 + 评估”的组合机制。如果没有这套机制,技能库规模越大,系统就越容易失控。
5.1 模型不按技能走的三种原因
第一种是描述和用户意图错位。用户说“把这个表发给我”,但技能描述里只写了“销售日报”和“运营日报”,模型就识别不到。第二种是相似技能互相打架。两个技能都涉及“总结”,模型经常在两者之间随机选一个,命中率直接掉一半。第三种最危险,模型会幻觉出系统里根本不存在的技能名,比如某次它输出了fetch_analytics_data,可整个注册表里都没有这个技能。
针对这三类问题,我的处理方式各有不同。描述错位靠“吸收真实语料”,从历史会话里提取用户的实际表达,补充进description;相似技能打架靠“合并或边界声明”,要么把两个技能合并成一个大技能,要么在描述里明确写“本技能只处理 X,不处理 Y”;幻觉技能名则靠系统层兜底,任何未注册的技能 ID 直接拦截,不让对话继续跑偏。
这个经验很重要:永远不要指望模型靠自觉避开错误。护栏必须放在系统层,而不是提示词层。
5.2 用“技能命中率”和“任务完成率”做回归
技能迭代如果没有量化指标,很容易出现“这次改完感觉更好了,但过几天又觉得不对劲”的情况。我给 agent-skills 建立了一套基础回归体系,每次修改技能描述或执行逻辑都要跑一遍历史测试集,重点看三个指标:
| 指标 | 定义 | 我的合格线 |
|---|---|---|
| 技能命中率 | 调用到的技能是否属于预期技能集合 | ≥95% |
| 参数合法率 | 调用参数通过 JSON Schema 校验的比例 | ≥98% |
| 任务完成率 | 用户目标在技能执行后是否被满足 | ≥85% |
这套指标最典型的场景是:我为了优化某个冷门技能,修改了它的description,结果发现另一个热门技能的命中率从 98% 降到了 80%。这种情况下,再好的优化也只能先搁置,因为核心链路不能受损。回归测试跑得越勤,系统长期稳定性越好。
5.3 我的三条护栏设置
第一条是参数预检。任何技能调用在真正执行前,都必须先通过参数校验层。比如send_daily_report要求date必须是“YYYY-MM-DD”格式,recipients必须是邮箱列表。校验不过就返回错误描述,让模型重新组织参数,而不是直接触发真实副作用。
第二条是危险操作二次确认。凡是涉及发送、删除、写库、对外告警这类动作,技能不能直接执行,必须先返回一个“待确认指令”,由外层业务系统或用户确认后才真正触发。这块是最容易忽略的,因为开发阶段所有操作都是测试环境,一旦上线到真实环境,模型的任何一次误调用都可能导致不可逆结果。
第三条是自动回滚。技能执行完成后,如果模型在生成最终回答时开始出现重复、跑题或幻觉,系统检测到异常信号就自动回到最近一个成功的技能结果,重新生成一次回答。这个机制不是万能的,但它能把“错误答案继续扩散”的概率降到最低。实测下来,配合参数预检,整体异常率比裸跑降低了大概七成。
我在这套项目上反复调整了很久,最后发现 agent-skills 的核心其实不是写了多少 YAML,也不是路由算法多复杂,而是建立起了一套“技能评审”的节奏。我现在每新增一个技能,都要先回答三个问题:它是不是一个能够独立完成的任务单元?它有没有明确的副作用?模型会在什么情况下误触发它?三个问题只要有一个答不上来,这个技能就先不进注册表。另外还有一个小技巧值得分享:每个月回头看一眼技能列表,把从上线起一次都没被调用过的技能标记为灰标,连续三个月没有触发就直接下架。技能数量越多,模型的选择空间越大,上下文消耗也越高。保持简洁,本身就是一个智能体最容易被忽视的竞争力。