多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Agent Skills实战指南:从函数调用到技能编排的工程化落地

Agent Skills实战指南:从函数调用到技能编排的工程化落地 1. 从会聊天到会干活agent-skills到底在解决什么问题今年我大部分时间都在跟让大模型真正干成一件事较劲。聊过天的人都知道GPT、Claude这类模型文本生成能力很强可你真让它帮我把上个月华东区的订单按金额排个序再算一下退款率它就露馅了——它既没有订单库的访问权限也没有执行查询的能力只能给你编一份看着像样、实际对不上的数据。这就是agent-skill要解决的核心矛盾大模型是推理引擎不是执行引擎。它知道怎么做的路径但缺一双能动手的手。所谓skill其实就是把模型之外的真实能力封装成一个个可描述、可调用、可验证的最小单元。一个查库存的skill、一个发邮件的skill、一个操作数据库的skill背后对应的是真实系统的接口、函数或脚本而大模型负责三件事判断该用哪个skill、把用户的话转成skill需要的参数、把skill的返回结果组织成人话。这个分工一旦清晰agent就从嘴替变成了助理。这个抽象不是某个框架发明的而是大家做了一阵子之后自然收敛出来的共识。早期做agent的人各有各的叫法有人叫tool、有人叫plugin、有人叫action后来skill这个词越来越常见因为它比tool强调能力而非工具比plugin强调单体可插拔而非整套扩展。名字不重要关键是这样几个特征可描述能被模型理解它的功能、适用范围、触发条件。可调用有明确的函数签名、入参出参能被程序真正执行。可验证执行结果可以检查出错能定位不会稀里糊涂。可组合一个复杂任务可以由多个skill按顺序或条件拼装完成。过去两年我见过太多demo级agent的失败模式模型选错工具、参数传错、结果没人校验、错误直接暴露给用户。问题不出在模型聪明不聪明而在于底层skill层的设计太粗糙。所以这篇想把agent-skills从概念讲到落地重点放在我实际做过的技能调度系统、踩过的坑、以及一套可以照搬的评测思路上。内容主要面向正在做agent应用、或准备把LLM接进业务系统的工程师也适合产品经理理解为什么给agent加个功能不是一个prompt能搞定的事。2. 一次真实的技能接入从需求拆解到函数签名落地先说一个我反复讲给团队听的例子给电商运营agent加一个查库存技能。看起来很简单对吧但真正动手拆解时会发现至少冒出一堆问题按SKU查还是按品类查要不要带仓库维度过期库存算不算返回格式是列表还是汇总模型怎么知道用户说的那批货对应哪个SKU2.1 把一个业务需求拆成模型能用的函数签名我习惯先写一段能力说明用大白话描述这个skill到底能干什么再倒推函数签名。比如查库存的需求运营实际会说的话包括A1001还剩多少华东仓的iPhone 15库存怎么样那个蓝色的SKU还能发几天。从这三句话能看出一个查库存接口至少需要支持两种入参风格精确SKU查询、按条件过滤查询。所以接口设计成def query_inventory( sku_id: str | None None, category: str | None None, warehouse: str | None None, include_zero: bool False, ) - list[InventoryItem]: 查询商品库存。 sku_id: 精确的SKU编码格式如A1001。 category: 品类名称支持模糊匹配如iPhone。 warehouse: 仓库名称如华东仓不传表示全部仓库。 include_zero: 是否包含零库存记录默认False。 模型侧看到的不是Python函数而是对应生成的JSON Schema。这一步极其关键因为模型的参数抽取完全依赖schema里的类型和description。我在生产里见过最离谱的一次模型把用户说的库存别太少理解成了include_zeroTrue理由竟然是description里出现了零字。所以description要写清楚语义边界而不是罗列字段含义。2.2 JSON Schema描述里的措辞陷阱给skill写schema描述我有一条铁律描述的是做什么而不是是什么。举例{ name: query_inventory, description: 查询商品在指定仓库的实时库存数量。当用户询问现货量、剩余量、可发量、SKU库存时使用。不适用于查询历史库存或成本价。, parameters: { type: object, properties: { sku_id: { type: string, description: 精确的SKU编码如A1001。用户说出完整编码时填写。 }, category: { type: string, description: 品类或商品名称支持模糊匹配如iPhone、蓝色卫衣。 }, warehouse: { type: string, description: 仓库名称如华东仓。不传表示查询全量仓库。 } } } }这么做的好处是把什么情况下触发写进了description模型在做技能选择时就有了明确指引。实际效果上带触发条件描述的skill召回率能比只写函数功能的高出将近二十个百分点——这个数字不是我编的是我在内部评测集上跑出来的对比结果。2.3 返回值设计给模型留好表达素材很多人只关注入参忽略了出参结构对模型回答质量的影响。比如query_inventory返回一个列表每项含sku_id、name、warehouse、quantity、status。模型拿到这些数据后才能组织出A1001在华东仓还有320件状态正常这类回答。反过来如果接口返回的是已经格式化好的字符串模型反而失去了解释和推理的空间遇到哪些SKU低于安全库存这类追问就答不上来。所以我现在的做法是出参尽量结构化字段粒度尽可能细让模型自己决定怎么汇总、怎么展示。这也方便后续做结果校验——结构化数据才能程序化检查纯文本没法断言。3. 技能调度的核心链路意图识别、参数抽取和路由分发skill本身只是能力真正让agent像样的是它背后的调度逻辑。一个典型的技能调用周期是这样的用户输入进来先判断有没有命中的skill再抽取参数然后执行最后把结果交给模型组织回答。看起来就四步但每一步都有不少门道。3.1 原生function calling和自建路由器的取舍大多数情况下我直接用模型的function calling能力做意图识别参数抽取两步。以OpenAI系为例你把所有skill的schema传给模型模型会返回该调用哪个函数、参数是什么这是最省事也最稳妥的路径。但有一个前提条件skill总量别太多。我实际测试下来一次性暴露超过20个schema时模型的选择准确率会开始下滑到50个以上时下滑非常明显。如果你要管理几十上百个skill自建路由器是更好的选择。我的做法是先做一层预筛用模型把所有skill按语义聚类到一个目录树里比如订单类、库存类、营销类、售后类然后外部请求先经一个轻量分类模型或关键词倒排索引定位到子目录只把子目录里的几个schema丢给模型做精确选择。这相当于给技能的查找做了一层索引成本和效果都划算。3.2 参数抽取这是幻觉重灾区参数抽取阶段我踩过最多的坑归纳起来有三类编造参数用户没提仓库模型擅自填了一个华东仓。这类最危险因为接口能查到数据结果也正常用户根本发现不了被误导了。过度泛化用户说看看还有多少货模型不知道该填什么就给sku_id编个A1001。严格来说这不算模型错是skill设计不够好缺一个无明确标识时按条件查询的入口。参数类型错误schema里写了integer模型传了字符串10后端一校验就报错。针对编造和过度泛化我的方案是双管齐下。一方面在description里明确写如果用户未提及不要猜测保持空值另一方面在后端加一层参数合理性校验仓库名必须存在于仓库字典、SKU编码必须匹配格式校验不过就返回参数校验失败给模型让它重新问用户。这比让模型事后自行纠正可靠得多。3.3 无命中时的降级策略用户的表达不可能永远落在skill覆盖范围内。无命中时如果直接对用户说抱歉我做不到体验很差。我现在的降级链路是这样尝试同义改写后重新匹配一轮如果还没有命中进入知识性回答模式用模型自身能力给出通用回答但明确标注这不是实时数据同时记录这次未命中query沉淀到日志里定期分析补skill。这套链路跑下来用户的无效提问比例从最初的30%以上降到了个位数。背后逻辑很简单agent的价值是把不确定性转成确定性而不是把所有不确定性都推给用户。4. 技能仓库的组织方式单体注册表、分层目录与动态装载skill少的时候写个if-else或者dict把函数名映射一下就行。但skill数量过了50个还在用同一个注册表硬撑迟早会因为命名冲突、加载顺序、版本不一致等问题崩溃。我经历过一次线上事故起因就是两个skill都注册了get_order后加载的把先加载的覆盖了用户问订单状态返回的却是统计口径完全不同的接口数据。那次之后我彻底重构了技能仓库。4.1 用一个类实现技能注册和发现我的核心抽象是SkillRegistry每个skill都是一个Skill类的实例元信息包括名称、版本、描述、schema、执行函数、权限标记。注册表负责三件事注册、查找、生命周期管理。class Skill: def __init__(self, name, version, description, parameters, handler, required_roleNone): self.name name self.version version self.description description self.parameters parameters self.handler handler self.required_role required_role property def schema(self): return { type: function, function: { name: f{self.name}_{self.version}, description: self.description, parameters: self.parameters, } }注册表的查找逻辑用了个很朴素的思路按名称精确查找 按描述关键词倒排索引。任何技能注册时都会被解析出一组关键词存进一个内存里的倒排表。这样外部请求进来先用关键词粗筛缩小范围再交给模型精排。这个设计让模型的schema输入窗口始终保持在20个以内命中率稳定。4.2 用目录结构管理技能和配置代码组织上我按领域把skill拆成独立目录每个目录一个包skills/ inventory/ __init__.py query.py adjust.py order/ __init__.py create.py cancel.py marketing/ coupon.py这种组织方式的好处是边界清晰每个人只维护自己负责的目录互不干扰。每个skill文件里除了实现函数还会声明自己的元信息用一个register装饰器挂到全局注册表register class QueryInventorySkill(Skill): name query_inventory version 1.2.0 description ...4.3 动态装载和热更新skill上线不可能每次都重启主服务所以热更新是刚需。我用importlib实现了一个简单的动态装载器监听技能目录的变更发现新文件或版本号变化就重新加载对应模块老版本继续保留一小段时间供运行中的请求收尾。import importlib.util def load_skill_module(module_path, module_name): spec importlib.util.spec_from_file_location(module_name, module_path) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module这里有个容易忽略的坑动态加载的模块在Python里不会自动卸载重复加载同一个模块会覆盖旧对象但旧对象可能还持有连接池或全局变量。我后来强制要求每个skill模块不允许持有全局状态所有连接都通过上下文传入才彻底解决这个问题。技能模块越无状态动态装载越安全这条经验值五个星。5. 实站排障技能冲突、幻觉参数和响应超时喊了这么久的踩坑这部分是重点中的重点。我把自己真实碰到过的三个经典问题完整复盘一遍每个问题的排查链路都比答案本身有价值。5.1 技能命名空间的幽灵覆盖现象用户问帮我查一下订单A1001的状态返回的数据一直是老接口的格式同事新上线的get_order似乎完全不生效。排查过程首先怀疑注册表没有加载新模块检查日志发现新模块确实被加载了但顺序排在老模块之前。然后怀疑是缓存清理后发现依旧。最后打开注册表的内存视图发现同名skill存在两条记录而查找逻辑返回的是最后一次匹配到的那个——但我代码里明明写的是返回第一个。这个坑的根源是我对dict遍历顺序的假设出了问题Python 3.7以后dict保序但我在注册时用了register装饰器模块加载顺序受文件系统扫描顺序影响新文件排在前面于是dict里出现了同键覆盖。看似是注册顺序问题本质是命名空间没有唯一性约束。修复方案很简单注册时检测重名直接抛异常强制开发者显式指定版本或改名。我建议所有做技能系统的团队都把这一点做成硬约束而不是靠编码规范提醒。5.2 幻觉参数模型脑补了一个合法值现象用户问上海仓还有多少手机壳模型调用query_inventory时传了warehouse上海仓但系统里根本没有上海仓只有华东仓。这个看起来不算严重因为返回结果必然是空的用户会意识到不对。真正严重的是它不报错——接口返回[]模型对着空列表还能组织出一句上海仓暂无手机壳库存用户以为真的没货可能转头就让运营补货这就闹出事了。排查链路先在日志里找到这次的完整调用链确认模型确实传了上海仓对比历史数据发现之前有一次成功调用传的是华东仓但因为仓库字典里有别名映射那次成功了进一步查发现模型是在参考用户历史消息时学习到了仓库应该填地名这个模式但把别名映射规则误用到了新query上。解决方案分三层第一层在schema的description里明确写warehouse必须是仓库字典中的标准名称不可使用别名或城市名第二层在参数校验时做标准名称映射把上海映射到华东仓同时返回映射提醒第三层在评测集里加入这类别名误导场景防止回归。三层叠加之后这类错误基本清零了。5.3 响应超时模型等不起用户更等不起现象某个售后skill偶尔会卡住日志显示函数执行耗时高达45秒最终抛出超时异常用户收到的是系统繁忙。原因其实很朴素这个skill后端调了一个慢的外部ERP接口平时1秒返回但赶上ERP批量任务时就会拖到几十秒。模型在function calling模式下有自己的超时上限等不到结果就直接放弃了。排查链路先抓请求耗时分布发现超时集中在每月底和每天下午三点——正好是ERP批量跑数的时间。再去看代码发现调用外部接口时没有设置超时时间用的是requests默认行为会一直等。修复所有外部调用统一加timeout读操作10秒、写操作30秒超时后先返回给模型服务暂时繁忙请稍后重试同时触发一次Redis里的降级标记后续同类请求在五分钟内走缓存或者直接提示用户稍后再试。从那之后这个skill的超时率从大约18%降到了1%以下。给所有外部依赖设超时是agent工程化最便宜的一笔投资。6. 技能评测体系没有度量就没法迭代很多团队做skill是写一个算一个上线后靠用户反馈来发现问题。这个方式在demo阶段没问题但skill数量到了几十个、改动频率起来之后没有评测体系就是盲人摸象。我甚至见过因为改了一个共享参数的schema描述导致另一条链路选错技能的情况——这种回归肉眼几乎发现不了。6.1 三套评测集和它们的建立方法我维护三套测试集分别管不同的事评测集样本规模覆盖内容用途技能选择集300条左右用户query → 期望命中技能验证路由和选择准确率参数抽取集200条左右用户query → 期望参数JSON验证参数抽取和类型转换端到端集100条左右完整对话 → 期望动作序列验证多技能组合和整体效果技能选择集的建立方式是从日志里捞失败样本 人工补充边界case。我会把每个技能配上至少5条正例和3条反例。正例是应该选这个技能的典型说法反例是看似相关但不应触发的干扰说法。比如query_inventory的反例就包括库存报表怎么导出——这是导出报表技能的事不是查询库存的事。反例的价值在防误触发很多团队只做正例不做反例效果差很多。6.2 三个核心指标的算与看评测跑完我只看三个数值选择准确率、参数抽取合格率、端到端任务成功率。选择准确率正确选择技能的次数/总请求次数衡量路由是否可靠。参数抽取合格率要求参数完全正确且没有编造才算合格。部分正确按不合格计这个标准很苛刻但能逼着团队把description写清楚。端到端任务成功率看的是最终结果是否满足用户意图由评测人员打标衡量的是整个链路的最终价值。把三个指标拉通看能发现很多有意思的关系。比如有一次我发现选择准确率提升到了97%但端到端成功率没动一查原因很多query技能选对了、参数也对了但用户真正要的是把结果下载下来而skill只返回了表格数据没有下载入口。这就是典型的局部正确、整体无效每个skill的边界设计必须对着用户完整意图来。6.3 回归测试改动技能必须过一遍旧样本我有一条硬规定任何skill的schema、description、路由逻辑改动都必须跑完全量评测集才能合入。理由是改动description里的一个词表面上是改善A场景但可能因为语义偏移让B场景的模型召唤错误技能。这类回归不靠评测集基本发现不了而评测集本身就是为这种场景设计的。跑评测的方式也简单粗暴离线构造一批与评测集同分布的请求发给带新配置的agent然后自动对比结果与预期。对比不通过就进人工复核复核出问题就回滚。整个过程写成一个CI任务每次改动自动触发大概十几分钟跑完。现在团队改起skill来心里有底再也不用靠上线试两天看看赌运气。7. 关于skill设计我的几条偏执经验聊聊我在无数项目里沉淀下来的几条私货不算标准答案但非常管用。第一条一个skill只干一件事宁可多写几个小而专的skill也不要写一个大而全的。大skill看起来省事但description一复杂模型的技能选择准确率就会掉。我之前把一个订单全流程处理技能拆成查询、修改、取消、导出四个独立skill后端到端成功率从84%提到了92%。拆开的代价是维护多几个文件收益却立竿见影。第二条description要写why触发而不是how执行。skill描述是写给模型看的不是写给程序员看的。它应该告诉模型什么场景下调用这个能力而不是这个能力内部怎么实现的。我第一次把一个数据库查询skill的description从接口说明改成触发场景说明后误召回率几乎砍半。第三条任何skill都必须有显式的权限声明。哪怕是内部demo我也要求每个skill标记required_role比如查库存只需要登录态但改价格必须管理员。agent没权限时宁可拒绝也不要尝试执行后再失败因为后者的代价可能是数据被改错。这个教训来自一次真实事故——测试环境的某个删除接口因为没有权限校验被模型连续调用了十几次开发库的测试数据被清了。从那以后权限检查是skill注册的第一道门槛。最后一条给skill留一个观察模式。每个skill我都默认实现一个dry_run参数不真正执行业务逻辑只返回将要做的操作和参数。这在调试和演示时非常好用能让用户和开发者都看清楚模型到底打算干什么提前发现参数编造的问题。成本很低收益极高。agent-skill这个方向远没到定型的时候我最近在折腾的是给skill加自描述能力——让每个skill能回答你需要什么信息才能执行以及你执行完会发生什么这相当于给模型配备了一个能对话的API。目前踩出了一些有意思的现象等跑稳了再整理一篇具体展开。
返回列表