☰
从Tool Calling到技能系统:生产级Agent能力治理实战
2026/10/14 9:52:25 网站建设 项目流程

做Agent接入真实业务时,卡住我的从来不是大模型本身,而是那些“会做事的手”——一次查询、一次写库、一次调用第三方接口。大多数入门教程都在讲tool calling怎么把函数列表喂给模型,但真到了几十个工具、多轮调用、多角色权限的规模,这套做法会先崩。这篇文章聊的是我在模拟项目X里落地的一套agent-skills设计:把技能当成与Agent生命周期绑定的第一公民,从定义、注册、发现到执行、编排、审计全链路遇到的问题,以及我手上的解法。如果你正准备把Agent从Demo推向生产,这篇文章应该能帮你少走不少弯路。

1. 从函数列表到技能系统:为什么单靠tool calling会先崩

1.1 tool calling的现状与瓶颈

先说说最原始的做法。早期我给Agent接工具时,就是把所有函数塞进system prompt,让模型在请求里返回一个JSON,指定调哪个函数、传什么参数。这种方式在小规模下很爽,写个Python装饰器,注册进列表,完事。

但规模一上来就出问题。

第一个问题叫上下文爆炸。我给每个工具写描述、枚举、示例,一个工具少说两百token。拢共二十个工具,光工具定义就占掉四千多个token。如果每个工具的description再写得详细一点,几千token就没了。你别小看这个开销,Agent任务往往需要多轮对话,每次都要把这堆工具定义重新发给模型,Lantency和成本同步上涨。

第二个问题是决策质量下降。工具多了以后,模型经常选错。尤其是两个工具语义接近的时候,比如“查询用户积分”和“查询用户优惠券”,模型可能选错。不是模型笨,而是工具描述本身长得很像,没有足够区分度。我在模拟项目X里就遇到过:用户问“我还有几张券”,模型调了积分查询接口,返回一堆积分流水,LLM往对话里编了一个“您有XX张券”。很尴尬。

第三个问题更隐蔽,是逻辑碎片化。权限校验、限流、重试、日志、审计,这些横切逻辑散落在各个函数handler里。今天A函数加了鉴权,明天B函数忘了加。等安全问题爆发时,你根本不知道哪些技能裸奔过。

一句话总结:工具是“函数级”的东西,而Agent在真实业务里需要的是一整套“能力治理框架”。

1.2 skills这个抽象到底补了什么

技能系统和裸工具列表的最大区别,在于它把“能力”做成了独立治理的对象。

一个技能不再只是函数签名和描述,它包含:自然语言说明、结构化参数契约、执行实现、运行策略(超时、重试、隔离级别)、权限要求、审计配置、版本信息。技能是可注册、可发现、可审计、可编排、可灰度下线的一等公民。

具体来说,技能系统做了三件事:

一是把工具描述从“全量注入”变成“按需检索”。Agent在运行时,先基于用户意图检索技能库,选出最相关的3到5个技能再注入上下文。剩下几十个技能躺在仓库里,不占token。

二是把横切逻辑收拢到平台层。权限检查、限流、预算控制、日志上报,全部由技能执行框架统一处理。技能作者只写纯业务逻辑,不需要关心鉴权怎么做——这在一堆技能由多个小组共建时尤其重要。

三是让组合复用成为可能。单技能是原子操作,但很多用户需求是复合的:查订单状态,如果状态是“待付款”就提醒付款;如果是“已发货”就再拉物流轨迹。把这种“判断+动作”的组合封装成可复用技能,整个Agent的表达能力会上一个台阶。

1.3 什么场景才需要技能系统

这里说句大实话:不是所有项目都需要上技能系统。如果你的Agent总共只有三五个工具、单机部署、用户就你自己调试,裸tool calling完全够用。硬上一个复杂的技能框架,反而让简单事情变复杂。

我判断要不要上技能系统,就看三个条件是否命中:

  • 技能数量已经超过15个,或者预计半年内会超过
  • 有多个人、多个团队要往同一个Agent里贡献能力
  • 有权限、审计、合规要求,不能“谁都能调一切”

三个条件里中了两个,就该把技能系统纳入设计。我当时在模拟项目X里就是三个全中:技能一度膨胀到47个,参与者横跨两个小组,还要对接内部数据权限体系。裸工具列表真扛不住。

2. 一份技能该怎么定义:元数据、参数契约与语义描述

2.1 技能元数据到底有哪些字段

说到技能定义,很多人的第一个反应是“写个函数再加个description”。但技能系统里,一个技能是结构化的元数据对象。我在项目里用的技能定义大概是这个结构:

skill: name: query_user_points display_name: 查询用户积分 description: 查询指定用户的当前积分余额和近30天积分变动。 version: 1.2.0 visibility: internal owner_team: user-service tags: [user, points] parameters: type: object properties: user_id: type: string description: 用户唯一标识 required: [user_id] returns: type: object properties: total_points: { type: integer } recent_changes: { type: array } policies: timeout_seconds: 5 max_retries: 1 isolation: process rate_limit: 100 permissions: require: [user_service.read]

字段看起来多,但每个都有用途。

  • name和display_name:前者是机器唯一标识,后者给模型和用户看
  • description:给LLM看的核心语义
  • version:技能也是软件,也会有变更和回滚
  • visibility:控制谁能发现这个技能,内部/外部/灰度可见
  • owner_team:出问题了找谁
  • returns:这个字段很多人忽略,但它对结果归一化特别关键,后面详细说

我坚持的一点:技能定义必须走代码仓库,经过评审合并,而不是在数据库界面里手工改。技能是运行时Asset,得有版本历史。

2.2 参数契约:JSON Schema的“宽进严出”设计

参数契约是技能系统里最容易做过头、也最容易做塌的部分。

做过头是指用JSON Schema的additionalProperties: false把参数卡得死死的,还套一堆oneOf、嵌套anyOf。大模型按你的schema填参数,十次有几次填出非法结构。尤其嵌套场景,模型即使只犯了字段名大小的错,解析器直接报错,技能调用失败。

我的做法是“宽进严出”:入参schema尽量宽松,执行层内部再做强校验和默认值兜底。具体来说:

  • 除必需字段外,都允许缺省
  • 对外暴露简单扁平结构,尽量不搞多级嵌套
  • 选择性地关闭additionalProperties: false,但代价是在执行处理器里主动忽略未知字段
  • 数字字段不要用enum严格枚举,除非这个字段确实只接受固定值,否则给模型留解释空间

举个例子。一个“发站内信”技能,input如果要求:

{ "user_id": "string", "template": "string", "variables": "object" }

这就够了。不要强迫模型把variables展开成一个极其严格的模板结构。模板参数缺了某个变量?执行层捕获后补默认值,并把告警写入日志。这样技能成功率会明显提升。

2.3 description的写法直接决定模型选不选得对

这一节我要多写几句。在技能系统里,你给LLM看的description质量,几乎决定了它的选择质量。我在实践中总结了几条基本规则。

第一,用动词开头,第一句就点明能干什么。比如“查询指定用户的积分余额和近期变动记录”,比“这是一个用户积分服务”好得多。模型是拿description去匹配用户意图的,动词开头的句式匹配效率高。

第二,明确写“这个技能不干什么”。听起来很怪,但我用过很成功。比如:

查询用户积分余额。注意:只支持查询,不支持修改;不做兑换、不做扣减;如需查询优惠券请使用query_user_coupons。

为啥要写反面?因为两个技能描述如果都很正面,模型无法区分边界。你直接把边界写清楚,模型选错的概率大幅下降。我做了一批技能description的对比测试:加入“不要做什么”之后,技能选择准确率从82%提到93%。

第三,给一个使用样例。模型对例子的理解通常比抽象描述好。样例可以是“例如:user_id=u12345时返回该用户的积分总额”。这个样例不需要很长,一两行就够。

第四,控制长度。description不是越长越好。太长会稀释重点。我控制在一个中等的区间:技能的全部提示描述(包括参数说明)加起来不超过几百token。超出部分要么拆成另一个技能,要么挪到执行文档里。

3. 技能注册与发现:让Agent在运行时“找得到、选得对”

3.1 注册中心与schema校验

技能定义写好了,下一步是进注册中心。注册中心本质上就是一个带索引的技能仓库,我把它分成三层:存储层、校验层、索引层。

存储层我用的是普通关系库,技能元数据存JSONB字段,方便整体读写。校验层在写入前做几件事:name是否重复、name是否符合命名规范、description是否为空或过短、parameters是否合法JSON Schema、required字段是否都在properties里声明过。这些校验看起来基础,但能拦下不少低级错误。我印象里有一次技能上线直接把整个发现服务的内存打爆,原因是某技能描述里塞了一个超长的base64样例。后来加了长度校验,问题就没再出现。

索引层负责把技能转成可检索的形式:为description、display_name、tags做一些分片文本索引,同时调用embedding接口为向量索引计算向量。

3.2 检索式技能发现:embedding不能解决所有问题

技能数量一多,全量注不行,所以触发方式变成“检索”。我一开始以为embedding检索就够,实测发现单独只有它并不可靠。

问题出在embedding的语义边界。用户问“我还有几张优惠券”,技能库里有“查询优惠券”和“查询积分”,两者的向量距离有时还挺接近。只靠top-k召回,可能把两个都捞上来,把上下文让给了不相关技能。

我的最终方案是“混合检索”:文本关键词召回加向量召回,然后合并排序。关键词负责精确匹配,比如技能name、tag里的user、coupon等词;向量负责语义泛化。排序时引入一个小的意图分类器做粗排:先在候选技能里筛出与当前对话意图明显相关的,再用规则精排(比如用户明确提到“积分”时,“查询积分”的权重直接拉高)。

这听起来复杂,但落地上很简单:候选集基本只有几十个技能,在这上面做规则排序毫秒级完成,不会有性能压力。

还有一层容易被忽略:检索必须带上权限和可见性过滤。一个普通用户查询技能时,发现服务在检索前就把visibility: internal的技能过滤掉,模型根本不会“看到”它。这比执行时再拦截更干净,因为模型压根不会提出非法调用。

3.3 命中结果怎么塞进上下文

检索召回之后,不能直接把全部字段倒进system prompt。上下文是稀缺资源,我控制最终注入技能数在3到5个之间。

注入前做一次裁剪:每个技能只保留name、缩短后的description、参数JSON Schema、以及一两个关键示例。owner_team、policies这些治理字段不进入模型上下文。description我有一套截断逻辑:先取第一句话(动词开头那句),再视剩余预算决定要不要补充“不做什么”部分。

有一点我特别想提醒:注入顺序也有讲究。模型倾向于调用列表里排在前面的技能。所以我把排序分数最高的技能排在最前面,并且用分隔符把每个技能块隔开。实测这样比把所有技能凑成一坨让模型自己挑的效果好得多。

4. 技能的执行链路:参数校验、权限检查与结果归一化

4.1 执行前的三道闸

技能被模型选定了,接下来进入执行链路。这一段是生产环境里最容易出事故的地方。我在模拟项目X里总结出执行前必过的三道闸。

第一道:schema参数校验。虽然前面说“宽进”,但基础结构还是要验:必填字段是否都有、类型对不对。这层校验执行在框架层,不依赖模型判断。校验失败时,框架会组装一个结构化错误返回给模型,让它重新提参,而不是直接失败。

第二道:权限与审批。技能执行前,框架拿当前会话的身份上下文,与技能声明的permissions做匹配。权限不足时,看技能配的是“拒绝”还是“申请审批”。高危技能(比如删除、发钱、改密)默认走审批,审批消息直接推到人工工作台。

第三道:限流与预算。每个技能有自己的限额,同时整个Agent会话也有总预算。如果技能属于内部高频,而且没有限流,一次并发尖峰可能把下游服务打挂。我给所有技能都加了一个统一的熔断器,QPS超过阈值直接返回“当前技能繁忙,请稍后再试”。

4.2 沙箱与隔离

技能执行不是简单调用函数,它需要隔离策略。我按危险级别分三档:

  • 低危:纯查询、纯计算,在线程池里跑就够了,加超时
  • 中危:涉及文件读写、网络请求但范围受限,放进独立子进程
  • 高危:执行不可信内容、需要完全隔离,直接跑在容器里

线程、子进程、容器,三种模式的代价差别很大。线程快但隔离差;子进程每次启动有开销;容器最安全但最重。我大多数查询技能走线程池,只有涉及数据变更的才升级为子进程。

超时和重试是另一个坑。不要对所有技能用统一超时。查数据库接口5秒可能就够,但调用第三方外部接口可能30秒都不够。我在技能元数据里加了timeout_seconds字段,每个技能单独配。

重试也要考虑幂等性。查询类技能重试没问题,但“创建订单”这类技能重试可能产生两笔订单。我的规则是:只有声明了idempotent: true的技能才允许框架自动重试,否则直接把失败抛给模型,让模型决定要不要换个方式处理。

4.3 结果回传:把非结构化输出折算成模型能用的信号

技能执行完,结果怎么回给模型,决定了Agent最后一轮回答的质量。我看到很多项目直接把函数返回值原样塞回去,比如一个JSON对象嵌套五六层。模型拿到后要么理解不了,要么把字段名报给用户,毫无体验。

我的做法是结果归一化。在技能元数据里定义returns结构,执行框架把原始返回包成统一的响应信封:

{ "status": "success", "data": { "total_points": 1200, "recent_changes": [] }, "error": null, "note": "查询成功" }

如果出错则:

{ "status": "error", "data": null, "error": { "code": "BOOM_003", "message": "用户不存在或已注销" }, "note": "无法查询该用户积分,请向用户解释用户可能已注销" }

重点在note字段:它是给LLM的自然语言提示,告诉模型遇到了什么问题、该怎么收场。这就是“把程序语言翻译成模型语言”的过程。

结果数据过大也要截断。一次查询可能返回几千条流水,全塞给模型既不经济也没必要。我统一在返回层做聚合和截断:只保留前20条明细,并在note里说明“还有XX条未显示”。

5. 多技能编排:从单一技能到可复用工作流

5.1 主动式编排:Agent自主选择技能序列

技能系统的能力不只是单个调用,还包括多技能组合。最自然的组合方式是“主动式编排”:模型自己决定先调哪个、再调哪个、根据结果判断下一步。

这种方式的优点是灵活,处理意外情况的能力强。用户在Agent里来一句“帮我查一下订单,到了的话提醒我取件”,模型会自己拆解:查询订单状态,如果已到站则调用“生成取件提醒”技能。

缺点也明显:模型自主编排容易碰壁。一是时序错乱,比如先查物流再查订单,两步反过来问题不大;但“先查询后更新”如果顺序反了,数据一致性就出问题。二是对话上下文过长后,模型可能会遗忘早期步骤的结果,导致编造数据。

我对主动式编排的底线是:允许模型自主选择技能,但禁止它调用带副作用的技能(写库、发送、删除)作为编排中的“尝试性步骤”。带副作用的技能只有经过明确的用户确认才会放行。

5.2 被动式编排:预定义DAG/顺序链

真正的多技能组合任务,我更推荐用被动式编排:预先定义一张DAG,每个节点是一个技能,边代表依赖关系。模型只选择执行哪个工作流模板,而不是完全自由发挥。

比如“售后工单处理”工作流:

workflow: after_sales_handler nodes: - name: verify_user skill: query_user_info - name: get_order skill: query_order_status depends_on: [verify_user] - name: decide_refund skill: evaluate_refund_policy depends_on: [get_order] - name: apply_refund skill: create_refund_order depends_on: [decide_refund] condition: "${decide_refund.eligible == true}"

我在这里会把工作流本身也注册为一个“组合技能”。用户问“我要退货”,发现服务召回的可能是after_sales_handler这个组合技能,而不是五个原子技能。模型只需要记住工作流的入口和结果,具体内部逻辑由执行引擎按DAG处理。

这种方式的收益是稳定:工作流中间任何一个环节失败,流程可以精确反馈到模型,A技能成功但B技能失败时,错误信息清晰可见,不会让模型在黑暗中瞎猜。

5.3 技能之间共享数据

做了编排之后,“技能之间的数据怎么共享”立刻变成绕不开的问题。

简单方案是一个会话级的内存数据区,工作流执行器把上一个技能的data字段塞进共享上下文,下一个技能按key取用。但这里有一个矛盾:技能不该隐式依赖全局共享,否则技能A改了全局变量,技能B的路由全乱套。

我采用的折中方案:工作流节点之间只通过显式声明传递数据。在上面YAML里,每个节点可以加inputs映射,比如get_order的user_id来自verify_user输出的user_data.user_id。执行器在节点间做一次显式的参数传递,技能本身不知道全局上下文存在。

这样做的好处是每个技能仍然是一个黑盒,只管自己的输入输出,便于单独测试、单独复用。坏处是编排定义会啰嗦一点,但这点配置成本换来的是可排查性和稳定性,值。

数据血缘也顺带解决了:任何一个工作流的会话,都能查得出某条最终结果是由哪些技能、哪些原始数据推导出来的。出了问题,链路一眼可见,而不是在日志里捞针。

6. 实测踩坑记录:权限、并发与技能漂移

6.1 权限粒度:技能级、操作级、行级

权限这块是我在生产环境里踩得最深的一个坑。最初我给技能只定义了“谁能调”这一层,但真实业务很快告诉我这远远不够。

举个例子。“查询用户信息”这个技能,客服角色和用户自己都能调。但客服查用户A的信息,和用户A查自己的信息,能看到的字段完全不同。客服可以看到手机号、历史订单;用户自己只能看到昵称、头像。如果权限只做到技能级,那用户一旦被允许调用这个技能,可能间接摸到他人的隐私数据。

于是我把权限拆成三层:

  • 技能级:该角色能不能调这个技能
  • 操作级:同一技能在特定参数模式下,角色能做什么。比如查询技能,客服模式允许查全量,用户模式只允许带自己的user_id
  • 行级:返回数据里,哪些字段对当前角色可见。敏感字段在结果归一化前直接被抹掉

行级过滤是我强烈建议从第一天就做的。不要指望模型帮你做脱敏,模型没有可靠性可言。框架层在技能返回后、回传模型前,做一次字段级过滤,才是最稳妥的防线。

6.2 并发与状态冲突:技能不能偷偷持有可变状态

这个坑源自一次线上事故。某个技能在内部维护了一个字典缓存,用来存上次查询结果。单线程调试一切正常,但并发上来后,两个会话的查询结果互相污染:A用户问积分,返回的却是B用户的数字。

排查过程比较曲折,最后定位到问题不是出在LLM的提示词,而是技能实现里偷偷保留了模块级可变状态。这给我立了一条硬约束:技能必须是无状态的。状态要么来自入参,要么来自工作流上下文,技能自身不允许持有多轮会话缓存。

如果确实需要缓存,也要把缓存键绑定到会话ID和用户ID,并且在上层框架统一管理,而不是散落在技能内部。这条约束写进团队的技能开发规范之后,并发类问题基本绝迹。

6.3 技能漂移与回归测试

技能系统上线后,最阴险的坑是“技能漂移”:技能对应的下游接口升级了,字段变了,但技能元数据里的description和returns还是老样子。模型按旧的描述调用,传了旧的参数名,执行层解析失败,Agent表现断崖式下跌。

解决漂移靠人工排查是不现实的。我给模拟项目X配了一套自动回归测试:

  • 每个技能配一组预置测试用例(输入、期望输出、期望状态码)
  • 每天晚上自动跑一遍全量技能回归
  • 哪几个技能导致失败,立刻生成告警并定位是description问题还是参数契约问题

这轮回归还顺手解决了一个问题:技能description过时。我有一次发现某技能选准确率骤降,跑回归测试后发现,下游接口把“账单编号”改成了“账单ID”,但description里没更新,导致模型按老字段生成参数。回归测试一跑,这类问题当夜就能发现,而不是等用户投诉。

另外,技能发版也要走灰度流程。凡是涉及参数契约改动的技能,我先让它在灰度可见范围内运行几天,确认命中率和错误率没有劣化,再全量放开。技能系统本身就是可灰度、可回滚的,这比裸工具列表强得多。

6.4 最后想留给大家的一句话

文章写到这,差不多把我在技能系统上踩过的重要坑都讲完了。要问我个人最深的体会是什么,其实就一句:不要把技能系统当成一个“工具管理后台”,它是Agent工作流的核心数据面。技能定义得越规整、发现机制越健壮、执行链路越有纪律,Agent的聪明程度才能真正转化为业务成果。

如果你正准备从裸tool calling迁移到技能系统,我的建议是从小处动手:先挑三五个高频技能,完成注册、检索、执行、归一化的最小闭环跑通,再逐步把更多工具迁进去。不要一上来就想做完美平台,先让它跑起来,再让它变稳。

最后分享一个小技巧:技能上线前花十分钟,用一个固定问题集跑一遍“选择准确率”评测。把每个技能的description反复打磨到评测集上稳定通过,再放开给Agent。这个简单习惯,比你在上线后调十次prompt都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询