ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

agent-skills实战:从工具调用到技能体系的工程化落地

agent-skills实战:从工具调用到技能体系的工程化落地

很多人第一次听到 agent-skills 这个概念时,第一反应是“这不就是给大模型多接几个工具吗”。我在实际把这类项目从 Demo 推到可用状态的过程中,最大的感受是:工具调用只是最外层的壳,真正决定智能体好不好用的,是背后那套关于“技能如何定义、如何注册、如何被可靠地调度”的工程体系。Agent 光有脑子不够,还得有一双训练有素的手,而这双手就是一套结构化的技能库。这篇东西不聊概念,只讲我在真实项目里怎么拆解需求、怎么设计技能层、怎么踩坑又怎么填坑的过程。

1. 技能包与工具列表:agent-skills 解决的第一个分水岭

1.1 工具调用和技能体系的本质差异

先说一个容易被忽视的问题:让模型调用一个函数,和让模型使用一项技能,看起来是一回事,其实差得很远。

函数调用是瞬时的、单发的,参数和返回结构都固定,模型只要根据用户意图选中正确的函数并填充参数即可。技能则是把一类任务的处理能力完整封装起来,它可能包含多个子步骤,会调用不止一个函数,需要处理内部状态,甚至要决定在什么条件下主动放弃。

我最初做某跨平台系统时,给智能体接了十几个函数,从查库存到发消息都有。跑通的时候很兴奋,但一放到真实场景里马上就露馅了——模型经常在“该用哪个函数”上犹豫,同一件事换个说法就触发错工具,更别提连续多轮操作时的上下文断裂了。后来我才意识到:函数调用处理的是“单个动作”,技能体系处理的是“完整能力”。二者是上下层关系,不是替代关系。

agent-skills 这类项目的核心价值,就是把“模型可以做什么”这件事从散落的函数定义,升级成一套有章法的能力资产管理。每项技能都自带使用说明、适用条件、参数约束、失败处理策略。模型不再主动去大海捞针地挑函数,而是从一个精心编排过的技能目录里选最合适的那一项。

1.2 我在项目里遇到的三类真实痛点

当时触发我做技能层改造的导火索,是三个特别现实的痛点。

第一个是意图识别不稳定。同一句“帮我看看上个月华东区的销售额”,有时候模型会正确选择“查询销售汇总”函数,有时候却会把“查询订单明细”拉出来顶上。说明函数定义里的描述信息量不够,模型根本分不清相似功能之间的边界。

第二个是参数乱填。早期我把所有参数都设计成扁平结构,结果模型经常漏填必填项,或者把一个字段的格式搞错。日期有时候传“7月”,有时候传“2024-07-01”,还有时候传“上个月”,下游解析层叫苦不迭。

第三个是执行链路无法追踪。一次任务可能需要先查数据、再算指标、最后发通知,三步对应三个函数。但模型的调用顺序偶尔会乱,比如先发通知再查数据,而且一旦某一步失败,后续完全没有恢复策略。

这三个问题叠加在一起,让我彻底放弃了“堆函数”的路线,转向把能力按“技能”重新组织。每项技能不是简单对应一个函数,而是对应一个完整的行为模式。模型只需要说“我要做这件事”,剩下怎么做、按什么顺序做,由技能内部去控制。

1.3 什么场景最值得上技能体系

也不是所有项目都需要这么重的一套设计。如果只是让模型做单轮问答、查一下说明文档,函数调用完全够用。我的判断标准很简单:只要任务需要两个以上顺序依赖的步骤、需要跨多个系统拿数据、或者存在同一能力在多个地方复用的可能,就值得走技能化路线。

以我当时做的内部运营助手为例,用户会提“帮我把这份数据整理成周报发给相关负责人”。这个需求背后至少涉及取数、清洗、摘要生成、格式化、查找联系人、发送六个环节。如果让模型自己在一次对话里自由发挥这六个步骤,中间任何一个环节格式不对,整条链路就断了。但把“生成并发送周报”定义成一项技能之后,模型只需要理解用户意图并触发技能,剩余的所有操作都在技能内部按既定逻辑执行,稳定性立刻上了一个台阶。

2. 技能定义层:一份能进生产环境的技能清单长什么样

2.1 一份技能清单里该写什么

技能设计的起点是定义清单,这一步直接决定后续的可用性。我参考了实际运营中大量 Prompt 工程和工具设计的经验,最终把单条技能定义拆成四个字段:技能名称、适用场景描述、参数声明、执行逻辑说明。四个字段缺一不可。

技能名称要短,最好是一个动宾短语,比如“查询销售汇总”“生成周报”“创建工单”。描述字段则是用来消除语义歧义的,需要明确写出“这个技能在什么时候用、什么时候不要用”。比如“查询销售汇总”的描述我会写成:适用于按时间范围或区域查看汇总销售额的场景;如果需要查看每笔订单的明细,不应使用此技能,应改用“查询订单明细”。

参数声明这里有个关键细节:不要只写参数名和类型,要把格式约束、取值范围、示例值全部写进去。比如时间范围参数,我要明确标注“格式为 YYYY-MM-DD,且结束日期不得早于开始日期”;区域参数则要枚举可选值,而不是留给模型自由发挥。模型在参数填充上就像实习生,你不给它明确的边界,它就会拿出各种你想不到的格式。

最后是执行逻辑说明,这一步很多人会忽略。技能内部的流程对模型不完全透明没关系,但最少要让模型知道“调用这个技能会产生什么副作用”。比如“发送周报”这个技能,执行逻辑里必须写明“该技能会读取指定数据源,生成周报内容,并自动发送给流程配置中的联系人列表”。这样模型在决策时才能预判后果,避免在用户只是随口问一句的时候误触发发送类操作。

2.2 从自由文本到结构化约束:参数声明是我的重点项目

参数设计是技能体系里最容易表面化、实际上坑最深的环节。早先我偏好用自然语言描述参数,比如“时间范围:用户想要查询的时间段”,结果模型每次给的参数格式千奇百怪。后来我全面转向 JSON Schema 风格的结构化声明,强制给每个参数绑定type、description、enum(如果有)、format(如果有)、required和example。这几个字段组合起来,基本能把模型的发挥空间框死在实际可用的范围内。

举个例子,我之前做过一个“查询门店客流”的技能,其中一个参数是“对比时间段”,自由文本时代经常收到“上周”这类的值,毫无可用性。改成结构化声明之后,我在format里标明必须是 ISO 日期,在description里写清“仅接受具体日期区间,不接受相对描述词”,并在example里给出完整示例。实测下来,模型填参的准确率一下子从六成提到了九成以上,而且几乎没有再出现过“上周”这种让下游无从解析的输入。

参数声明还有一个容易被忽略的设计:给参数分等级。核心参数只保留能直接影响执行结果的;辅助参数则允许为空,由技能内部按默认策略兜底。这样模型在不确定的时候,不会因为强行填一个错误值把整条链路带崩。

2.3 技能注册与发现机制

定义好技能之后,怎么让模型知道“有这项技能存在”并且“该用的时候用得上”?这就是注册与发现机制要解决的问题。

我在项目里采用的是集中注册 + 按需注入的方式。集中注册指的是所有技能定义统一放在一个配置中心里,为后续的动态增删和版本管理打基础。按需注入则是核心:每次模型需要决策时,我不会把所有技能定义一股脑全塞给它,而是通过一个轻量级的预筛选层,先根据用户请求的关键词和语义特征,从注册中心召回最相关的三到五个技能,再把这些技能的精简定义作为上下文交给模型。

这套设计参考的其实是搜索引擎的思路。全量注入的坏处很明显:一是消耗的上下文长度暴涨,token 成本很高;二是技能一多,模型的选择难度指数上升,相似技能之间的区分度反而变差。通过预筛选把候选集控制在极小范围,模型几乎不需要在“选哪个技能”上烧脑,准确率自然就上来了。预筛选层本身的召回逻辑也没那么玄乎,一个基于关键词加向量召回的双路召回器就够用了,关键是保证召回率优先——宁可多召回一个无关技能,也不能把真正该用的那个筛掉。

3. 三类高频技能从零到可用的构建记录

3.1 只读查询类:数据检索与聚合的最优解

只读查询类技能是日常被调用最多的类型,也是最容易做好的一种。这类技能内部通常只有一个步骤,就是从指定数据源读取数据并按参数做过滤、聚合,最终返回结构化的结果。因为不产生副作用,所以对安全和约束的要求相对低,适合作为第一个吃螃蟹的技能类型。

我在构建这类技能时,重点放在结果格式的规范化上。比如做“查询销售汇总”,技能内部会直接把 SQL 的查询结果重构成一个固定结构:{ time_range, region, metric, value, compare_last_period }。这样技能输出和模型后续的推理之间就建立了稳定的接口。我的经验是,给模型喂规范化的数据,比喂原始查询结果,回答质量要高非常多。因为原始查询结果里的字段名五花八门,模型每次都要重新推理这些字段代表什么,容易出错也容易发散。

只读查询类还有一个容易踩的坑:数据权限。技能内部接收到的参数包含“查询哪些区域”这类信息,但真正执行查询时,必须根据当前会话的用户身份,在技能内部二次过滤数据范围。比如运营岗的用户即使传了“华东区”,技能也只能返回他权限范围内的数据。这个过滤必须做在技能内部,不能依赖模型自觉,因为模型根本不知道当前用户的权限边界。

3.2 状态变更类:写操作的安全护栏

写操作类技能比查询类复杂一个量级,因为它会产生副作用,一旦出错影响面远超一次无效查询。做“创建工单”“发送通知”“修改配置”这类技能时,我在设计上加了两个强制机制:确认机制和软删除/回滚机制。

确认机制展开说,就是在技能定义里明确标注“本技能会产生写操作”,并要求模型在触发前先向用户输出确认提示,等用户明确同意后再真正执行。这个动作不需要多复杂,核心是让用户对将要发生的后果有预期。早期我没做这一步,结果出现过一次模型未经确认就把一条测试通知发给了真实用户,吓得我当时立刻把所有发送类技能全部加了确认闸门。

软删除/回滚机制针对的则是更棘手的场景:有些操作一旦执行,很难无损撤销。比如“批量修改商品价格”这样的技能,我要求它执行前必须自动生成一条变更前快照,并且快照要保留足够长的时间。这样即使执行后发现方向错了,还能基于快照完整还原。这个设计在真实运营里救过我很多次,某次批量调价时一个计算逻辑弄错了参数,全靠快照才没有造成损失。

3.3 多步骤任务类:跨系统编排

最复杂的一类技能是跨系统的多步骤任务,比如“生成周报并发送”。它内部会先取数、再聚合、再调用内容生成模块、最后走发送通道,涉及至少两套以上外部系统的交互。这类技能的构建,关键不在每一步怎么实现,而在步骤之间的状态管理。

我现在的做法是给这类技能定义一个内部状态机,明确区分init → data_ready → content_ready → sent这几个状态。状态机的好处是,任何一步失败,技能都能清楚知道自己停留在哪个状态、下一步应该重试当前步骤还是往前回退。举例来说,如果内容生成成功但发送失败,技能知道它可以安全地重试发送;如果取数就失败了,它不会贸然进入内容生成,而是先回到数据源检查问题。

这个状态机不一定要独立成一个复杂框架。我在实现上就是用一个类里的一组状态字段加一个简单的转移表,核心思想是让技能的执行路径可观测、可恢复。没有状态管理时,模型很容易在长链路任务的中间环节迷路;有了状态机,技能本身变成了一个稳定的小程序,模型只负责启动和必要时介入决策,剩下的事情全都在可控轨道上进行。

4. 技能编排层:上下文、幂等与并发的实战取舍

4.1 技能调用与记忆窗口的协同

技能单独用得好,还不够,真实场景里模型经常需要连续调用多个技能才能完成一次用户请求。这时候就轮到编排层的上下文管理登场。我在实践中发现一个很有意思的现象:模型对“当前对话进行到哪一步”的判断,往往比人想象的薄弱很多。它能记住用户第一句话提的需求,却容易忽略第三步技能之后的中间结果。

所以我给技能编排层设计了一个“轻量记忆窗口”机制。每次技能调用结束后,系统会提取该次调用的关键结论,比如“已查询到华东区销售额为 1200 万”,压缩成一条简短的摘要写入上下文。这样即使经过多轮技能调用,模型依然能够通过摘要回忆起前因后果,而不是面对一堆原始 JSON 数据发呆。摘要生成本身可以交给模型完成,但要注意设定长度上限,避免摘要越写越长把上下文塞满。

4.2 失败重试与幂等设计

技能执行失败的场景永远躲不掉,关键是失败之后怎么优雅处理。我踩过的最大一个坑,是重试时产生重复副作用——比如发送类技能第一次执行时其实已经发出去了,但因为网络超时导致技能误判失败,于是自动重试,结果用户收到了两封一模一样的通知。

要解决这类问题,其实核心就一条:所有会产生副作用的技能,必须设计成幂等的。具体落地时我采用了请求唯一标识的方案。每一项写操作在触发时都生成一个全局唯一的 request_id,技能执行前先检查这个 request_id 是否已经在执行记录里存在;存在就直接返回历史结果,不存在才真正执行。这样无论重试多少次,实际的副作用都只发生一次。

网络超时、下游服务报 5xx、返回格式不符合预期,这三种失败是重灾区。对于查询类技能,失败后直接重试两到三次基本就能解决;对写操作类技能,则不应该无脑重试,而是优先通过 request_id 查询执行状态——如果状态是“已完成”,说明第一次调用其实成功了,直接取结果;如果状态是“未开始”或“失败”,再重新触发执行。

4.3 双技能并发触发的冲突仲裁

比失败更隐蔽的问题,是多个技能并发执行时的冲突。举个例子,用户在一个会话里同时请求了“导出订单数据”和“生成销售周报”,两个技能都需要访问同一批数据源,如果同时执行,可能因为资源竞争造成数据读取超时。又比如“修改门店营业时间”和“批量调整排班”两个技能同时被触发,修改的内容可能互相覆盖。

我的处理方案是给技能目录增加“资源锁”概念。每项技能在执行前声明它需要锁定的资源域,比如“门店配置”“订单库”“通知通道”。编排层维护一个锁表,当多个技能申请的锁域有重叠时,后启动的技能进入等待队列,等前一个技能释放锁之后再执行。这个设计模仿的是数据库的行锁机制,实现并不复杂,但能有效避免大部分互斥冲突。

锁机制也要防止死锁。我的策略是为每个技能设定最大等待时间,超过时间直接放弃,并在上下文中向用户反馈“某项操作暂时无法完成,请稍后重试”。虽然没有完美解决并发问题,但已经把冲突概率从“经常发生”降到了“几乎遇不到”。

5. 上线后踩过的坑:从误触发到幻觉参数

5.1 相似技能之间的误触发复盘

技能库上线运行一段时间后,我注意到一个比较隐蔽的问题:模型在遇到模糊表述时,会在两个相似技能之间“左右横跳”。比如用户问“这个月的收入和上个月比怎么样”,它有时候会触发“查询销售汇总”,有时候会触发“查询财务收支”,两者返回的数据口径完全不同。

排查之后发现,根因不在模型,而在技能描述的区分度不足。两个技能的描述里都出现了“月”“对比”这些关键词,但都没有明确写清数据口径上的差异。修复方式也很直接:我把“查询销售汇总”的描述改成“仅包含商品交易相关的收入与退款,不含费用类支出”,而“查询财务收支”则明确标注“包含运营费用、人力成本,适合财务视角的分析”。加上这段口径说明之后,误触发率明显下降。这个案例让我意识到,技能描述要像写接口文档一样严谨,把边界条件和口径差异都写透。

5.2 模型幻觉参数引发的下游问题

还有一次比较头疼的事故,和模型幻觉参数有关。当时技能“创建营销活动”里有一个“预算分配比例”参数,模型在用户没有明确给出比例的时候,自作聪明地填了一组分账比例,下游系统照单全收,导致活动预算全部倾斜到了某个渠道。

这个问题的本质是:我对可选参数设计了“为空则用默认值”的兜底逻辑,但模型并不知道“留空比乱填更好”,它的倾向是填满所有参数。我的修正方案是双管齐下。一方面,在技能定义里对这类易产生幻觉的参数增加“未指定时使用默认策略,无需填写”的说明。另一方面,在参数校验层增加一个规则:凡是在用户消息里找不到依据的参数,一律不允许模型自行赋值,必须显式留空走默认逻辑。打上这个补丁之后,此类问题几乎绝迹。

5.3 技能日志与全链路追踪的观测设计

在技能体系运行稳定之后,我最后补上的一块短板是观测性。技能调用的日志如果只记录“调用了哪个技能、返回了什么结果”,其实远不够用。真正要回答的问题是:技能为什么被选中、执行了哪些内部步骤、在哪一步耗时最长、失败时是哪一次依赖调用出了问题。

我把技能执行日志升级成了结构化追踪,每个技能实例分配一个 trace_id,全程记录参数快照、内部状态转移、每次外部调用耗时和返回码。排查问题的时候,不再靠猜,而是直接按 trace_id 拉出整条执行链路。再加上一套简单的成功率和响应耗时看板,哪个技能状态不佳一目了然。观测性建设可能不是技能体系里最炫的部分,但没有它,后面每一次排障都会变成一场灾难。

写在最后的一点体会

agent-skills 这套东西做到今天,我最深的感触是:技能化不是为了让模型显得更聪明,而是为了让系统的行为变得更稳定、更可预期。模型负责理解意图、做决策,技能负责把决策变成可靠的动作。这个边界一旦划清楚,很多以前觉得“模型太不靠谱”的问题,其实都可以通过工程手段弥补大半。如果你也在做类似的智能体项目,我建议从只读查询类技能起步,先把技能定义和注册机制跑顺,再逐步往写操作和编排层扩展。这条路不一定是最快的,但一定是走起来最稳的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进