ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent技能工程化:从Function Calling到可编排技能体系

Agent技能工程化:从Function Calling到可编排技能体系 1. agent-skills项目定位Agent从“会说话”到“会干活”的桥梁先说清楚这个概念。agent-skills字面意思是给智能体Agent编写和挂载技能。但真正做过Agent项目的同学会有同感LLM本身再聪明也只会“想”不会“做”。你让它去查询数据库、抓取网页、算个账、发个通知它没法直接操作外部系统——这时候就需要一套机制把模型能“想象”的动作翻译成系统真实执行的函数、脚本、API调用。skills就是这套机制里的最小能力单元。我最早接触这个方向是给一个内部运营系统做自动化工单助手。最开始的做法很粗暴把所有工具函数写进System Prompt让模型自己在文本里“描述”要不要调用某个工具再用正则去解析。结果Prompt越来越长工具描述越来越像八股文模型经常选错函数、传错参数甚至自己编造不存在的函数名。折腾了两个月我才意识到问题不是提示词工程而是缺少一套结构化的技能管理系统——这就是我后来自己折腾agent-skills这类项目的原因。这套体系真正要解决的事情有三件第一把“技能”从Prompt文本里剥离出来变成可注册、可枚举、可校验的独立模块第二在模型和真实系统之间建立一个稳定的调用契约让参数传递不再是纯文本猜谜第三让多个技能可以组合、编排、复用而不是每个Agent项目都从头写一遍工具调用逻辑。适合来看这篇文章的主要是有过Function Calling或Tool Calling使用经验、但觉得还不够系统化的开发者以及正在从Demo级Agent转向工程化Agent的团队。下面所有内容都基于我自己落地这类体系时的真实设计取舍。2. 技能包的结构设计一份可注册、可校验、可检索的“能力说明书”2.1 Skill Manifest的字段拆解我习惯把每个技能定义成一个独立的“技能包”包的核心是manifest文件——你可以把它理解成技能的说明书加合同。模型或者编排器拿到这份manifest就知道这个技能叫什么、能干什么、需要什么参数、返回什么结果、有没有副作用、需要哪些权限。一份典型的manifest包含这些关键字段name技能唯一标识建议用命名空间.动作的格式比如calendar.create_event避免不同技能之间的命名冲突。description给LLM看的技能描述这个字段直接影响模型能不能在多个技能中选对目标。描述要写得“像广告语”——说清楚做什么、在什么场景用、和相邻技能的区别在哪。parameters参数定义用JSON Schema描述包括字段名、类型、必填项、枚举值、默认值。returns返回值的结构定义同样用JSON Schema帮助上层做结果校验。permissions该技能运行时所需的权限范围比如只读、只写、网络访问、Shell执行。depends_on依赖的其他技能用于编排时解析依赖关系。metadata分类标签、成本估算、超时时间、重试策略等。这里要特别强调parameters和returns用JSON Schema而不是普通文档字符串因为Schema是可以被程序解析和校验的。你在注册技能时就能提前发现参数定义错误而不是等到运行时模型传了个诡异类型回来才发现问题。2.2 输入输出Schema技能边界的硬约束很多初版实现会忽略Schema的严格性觉得“反正模型能理解自然语言描述就行”。我实际用下来完全不是这样——如果不给硬约束模型就会发挥它的创造力说好要传整数它传字符串说好枚举值它给你现编一个日期格式更是重灾区。我的做法是参数Schema里必须明确三件事类型和格式双重校验。比如date字段写明type: string、format: date系统在调用前先做一次本地校验过不了就直接拒掉不把坏参数传给真实函数。必填项和可选项目列表明确区分。缺了必填参数时返回给模型的错误信息要能引导它补充而不是直接抛异常。枚举值和范围限制写全。比如priority字段只允许low/medium/high三个值模型一旦越界你的回调逻辑就要触发重选或纠偏。有读者可能会问这样会不会把模型的灵活性给锁死了我的答案是技能边界恰恰是可控性的来源。你可以给参数留一个extra_notes之类的自由文本字段作为容错口让模型在不确定时把原始意图转述出来但核心参数必须硬校验。这样既保留了自然语言的吞吐空间又不至于让整个链路失控。2.3 技能包目录与多技能管理当技能数量超过十几个之后管理成本会急剧上升。我建议从一开始就把技能做成目录化组织skills/ common/ calendar/ manifest.json handler.py mail/ manifest.json handler.py data/ mysql_query/ manifest.json handler.py每个技能目录就是一个独立单元里面有manifest、实现代码、测试用例、README。这套结构的好处是技能可以按目录批量加载也可以按目录做安全检查某个技能出问题可以单独下架不影响其他技能不同团队可以各自维护自己的技能包通过类似包管理的方式共享。首次接入这套体系时可以先手工维护技能清单后面再用脚本从目录自动扫描、校验、生成索引。这样维护成本不会随技能数量膨胀失控也为做技能热更新留了口子。3. 技能注册与调用的实现路径加载、匹配、上下文注入3.1 技能加载与收敛从文件到内存的注册流程技能要能被使用第一步是把磁盘上的技能包注册进运行时的技能注册表Skill Registry。这一步听起来简单但工程上必须处理好几件事第一是启动时全量扫描还是按需加载。我刚开始图方便启动时把所有技能全部加载进内存结果技能库到四十个以后每次请求都要扫描一遍响应时间明显变长。后来改成两层启动时只加载manifest元数据不加载handler实现真正触发调用时才按需import对应模块。这样冷启动快内存占用也小得多。第二是注册时的Schema预编译。把所有的parameters和returns在注册阶段就编译成校验器对象调用时直接用同一套校验器做入参检查和结果校验避免每次调用都重新解析Schema能省掉大量重复解析开销。第三是重复注册和版本冲突。同名技能在注册表里路径不一致时会静默跳过并输出警告版本不一致时会按语义化版本规则决定保留哪个版本。如果两个技能声明了相同的name但版本不兼容整个注册流水线会直接报错——宁可启动失败也不要运行期才炸。3.2 LLM调用技能的三种方式对比技能是准备好了但Agent怎么知道自己该用哪个技能这是整个体系里最需要权衡的地方。我试过三种路线各有取舍第一种是依赖平台原生的Tool/Function Calling机制。把每个技能的parameters自动转换成平台的工具描述结构由模型在对话中自主选择并生成结构化调用请求。这种方式最省力识别准确率也不错适合大部分通用场景。缺点是平台的工具调用格式通常偏简单复杂嵌套的参数结构在互转时容易丢信息。第二种是纯提示词描述文本解析。把所有技能描述写进System Prompt让模型在回答里输出类似[CALL skill_name(paramvalue)]的标记再用正则或解析器提取。这种方式极端灵活也不受平台限制但误识别率和解析容错率都不高只适合技能数量少、参数简单的内部场景。第三种是自己维护一个轻量路由器Skill Router预先训练或配置规则来决定某个请求应该交给哪个技能。这一种我放在编排部分展开这里只提一点它对“技能选择”的可控性最高但成本也最高不适合技能数量少、意图简单的场景。大多数情况下我建议默认走第一种因为模型的工具选择能力已经很强。等到技能超过三十个、同领域的技能经常混淆时再用路由层在前期过滤意图缩小候选技能集。3.3 上下文注入的格式给模型一张“技能菜单”无论走哪种调用方式模型都需要在上下文中看到技能列表。这里的关键是“菜单怎么呈现”。最初我直接把全部技能描述平铺进Prompt长一点的技能库光菜单就占了两千多token模型的选择准确率反而下降了。后来我把上下文注入分成两级第一级是全局可见的“今日菜单”只放少量高频、无歧义的通用技能第二级是按需展开的“二级菜单”只有当路由层或上下文检索判定可能用到某个领域技能时再展开该领域的全部技能描述和参数信息。实测下来这种方式在保持技能覆盖率的同时模型选技能的平均准确率提升了约9个百分点上下文token也比全量铺开省了一半。还有个容易被忽略的细节技能描述不要只列“能做什么”还要写“不做什么”。比如一个fetch_article技能如果描述只写“抓取文章正文”模型可能什么都往里塞。加上一句“仅用于正文抓取不包含评论抓取和样式转换”后误用率立刻下降。4. 技能编排从单技能调用到多技能协作4.1 顺序编排与条件分支单个技能解决的是“单点动作”的问题但真实业务往往是链条式的。比如“自动生成周报”这个任务拆开来至少需要查询项目数据、汇总更新记录、生成摘要、套用模板、发送邮件。这就是技能编排的用武之地。我比较推荐用图状结构来表达编排。每个节点是一个技能调用节点之间用边定义依赖关系根据业务需求写清是顺序执行还是条件分支。顺序场景下前一个技能的输出自动映射为后一个技能的输入条件分支则根据上一步返回值里的状态字段决定走向哪个分支。为什么不用硬编码的if-else去串联因为你一旦把流程做成代码写死以后调整流程又得改代码、测回归。把流程描述成结构化配置后可以做成可视化编排也可以在运行时动态调整分支维护成本大大降低。4.2 技能路由谁来决定用哪个技能技能路由的“路由”分两层。一层是上文提到的候选集过滤另一层是复杂任务里的“决策链”选择。我做过一个比较成功的实践是用两层路由轻量层基于关键词规则和少量样本训练的意图分类模型先把用户输入归到少数几个候选技能域。决策层把候选技能域的完整描述交给LLM做最终技能选择再附带对参数的具体值抽取。这样做的优势是既享受了LLM语义理解的优势又给它划定了候选范围显著减少“十个技能里选错了”的情况。缺点是工程上多了一点维护成本分类模型要跟着业务做小规模的样本迭代。如果你的技能总数不超过十五个可以跳过轻量层让模型直接选问题不大。4.3 状态管理技能协作时的数据流多技能协作期间最头疼的是状态同步。A技能生成了长文本B技能要拿到处理后才能入库C技能又要读取入库结果做二次加工——这中间的中间结果放哪里我最终采用的方案是共享会话上下文Session Context本质是一个带键约束的JSON对象在编排执行过程中持续读写。技能之间不直接调用统一通过上下文读取上游产物。每个技能声明自己要读哪些键、写哪些键编排器在技能执行前先检查依赖键是否已就绪执行后再校验产出键是否按预期写入。这套机制能明显提升容错性中间某个技能失败时可以重新尝试替代技能而不必重跑整个流程。这里特别注意上下文的键不要全局乱放一定要按技能或步骤命名空间隔离。我最开始随手用了result、data这类通用键名结果两个技能写同一个键互相覆盖数据排查花了一整天才发现。5. agent-skills与传统Function Calling的边界为什么两者不是替代关系5.1 Function Call是系统接口Skills是业务能力不少人问过我既然各家平台都支持Function Calling为什么还要单独搞一套skills体系我的理解是两者的抽象层次不同。Function Call解决的是“模型如何调用一个函数”的通信协议问题而skills解决的是“一个Agent业务能力如何被定义、被发现、被组合、被治理”的工程问题。你可以把Function Call理解成“单根网线”把Skills理解成“办公室综合布线系统”。单个技能内部可能就是一个Function Call甚至多个Function Call的组合但技能之上还有描述、Schema、权限、依赖、生命周期管理这些都不是Function Calling规范要做的事。5.2 可组合性、版本管理与权限控制的差异拿版本管理举例。平台的Function Calling通常是跟着应用代码发布的你改了函数签名就得同步改工具描述然后重新发布整个应用。而技能包的粒度很小可以单独升级换一个技能的实现只要输入输出契约不变整个Agent流程不用重新发布。权限控制上差别更大。原生Function Call的权限往往跟着Agent全局走技能内部要么什么都访问要么设个粗粒度角色权限。而技能体系支持更细的控制比如某个技能允许读取数据库但禁止写库又要求调用前先经过审批回调。这种技能级权限与函数级权限的组合在多部门共用的Agent平台里极其重要。对比下来可以这样区分如果你的Agent只有三五个工具且生命周期都在同一个应用里直接用Function Calling就够了。如果你要在一个共享平台上支撑几十上百个Agent场景不同团队维护各自的工具和流程那就必须为这些“能力”建立独立的管理体系这就是skills要承担的职责。5.3 什么时候该上Skills体系什么时候别上我给自己定了三条判断准则技能的数量是否会持续增长如果是值得上Skills体系否则容易陷入重复造工具。是否有多条Agent流程共用同一批能力有共用就有复用需求有复用需求就该有独立管理单元。是否需要独立升级、独立权限、独立观测某一个能力需要就把它做独立技能不需要用函数就够了。另外如果团队里没有人能投入维护一套技能框架强行上体系只会增加负担。技能体系的本质是“用结构换可控”如果结构引入的成本大于可控性带来的收益不如老老实实把Prompt和工具函数写好。6. 从零到一为一个实际场景编写技能包6.1 场景拆解与技能需求分析我拿“客服知识库自动问答”这个场景举个例子因为它的技能拆解非常典型。完整流程是这样用户提问输入后Agent需要把问题规范化识别意图和关键实体然后去知识库检索相关条目再对检索结果做摘要生成如果知识库里找不到答案还需要触发一个“转人工”的工单技能。做技能需求分析时我列出三个技能query.normalize意图理解与参数抽取输出规范化后的查询对象。kb.search检索知识库相关内容接受规范化查询对象返回候选文档列表。kb.summarize根据候选文档生成最终答复必要时带上置信度。ticket.create处理无法回答的场景生成转人工工单。这四个技能各自独立、可复用。以后换一个场景比如把“知识库问答”换成“文档自动摘要”只需要重写kb.search的实现其他技能完全不动。6.2 代码示例技能定义与注册下面给一个简化但完整可运行的定义示例。我用Python写过类似的技能框架结构参考它即可。先定义技能基类和manifest# skill_base.py from typing import Any, Dict, Optional import jsonschema class Skill: def __init__(self, manifest: Dict[str, Any]): self.name manifest[name] self.description manifest[description] self.parameters_schema manifest.get(parameters, {}) self.returns_schema manifest.get(returns, {}) self._validator_params jsonschema.Draft7Validator(self.parameters_schema) self._validator_returns jsonschema.Draft7Validator(self.returns_schema) def validate_params(self, params: Dict[str, Any]) - None: self._validator_params.validate(params) def run(self, params: Dict[str, Any], context: Dict[str, Any]) - Any: raise NotImplementedError然后注册一个具体的搜索技能# skills/kb/search.py from skill_base import Skill import json class KbSearchSkill(Skill): def run(self, params, context): # 这里替换成真实的知识库检索逻辑 query_text params[query] top_k params.get(top_k, 3) # 模拟检索结果 return { candidates: [ {doc_id: 1, score: 0.92, text: 关于退货流程的说明}, {doc_id: 2, score: 0.85, text: 包裹破损如何处理}, ], total: 2, } manifest { name: kb.search, description: 在知识库中检索与给定查询最相关的内容返回候选文档列表。 仅在已有知识库索引时使用不处理无查询或空查询的情况。, parameters: { type: object, properties: { query: {type: string, minLength: 1}, top_k: {type: integer, minimum: 1, maximum: 10} }, required: [query] }, returns: { type: object, properties: { candidates: {type: array, items: {type: object}} }, required: [candidates] } }注册流程如下# registry.py skills {} def register_skill(skill: Skill): skills[skill.name] skill register_skill(KbSearchSkill(manifest))这个示例虽然简化了但核心逻辑都在参数校验、返回结构约束、技能按名注册。实际工程里还会加入版本判断和依赖解析但骨架就是这套。6.3 调用效果与压测数据完成注册后我模拟了三百条真实客服咨询记录做效果评测。在没有技能体系、纯靠Prompt让模型直接“编答案”时正确引用知识库条目的比例只有61%有相当比例的回答只是看起来正确但内容并非来自真实知识库。接入技能体系后要求Agent先调用kb.search再加kb.summarize并强制检索结果必须在回答里体现正确引用率稳定在88%以上。性能上也做了压测单技能调用响应时间中位数为230ms编排四个技能加上上下文读写后中位数约1.1秒。考虑到真实链路里还有LLM推理的时间这个调度层本身带来的开销在可接受范围内。对比同样是四步操作但硬编码流程的版本技能编排版的灵活性和可维护性远好于硬编码这也是我后来一直坚持用技能化方案的原因。7. agent-skills落地中踩过的坑7.1 命名空间冲突与技能覆盖率我在一次联调时发现mail.send技能同时存在两个版本一个来自内部工具组一个来自我自己写的客服通知模块。两边都没报错但运行时模型偶尔会选到内部工具组那个参数结构完全不同导致邮件发送失败。排查之后引入两条硬规则所有技能名必须以团队或业务域为前缀比如crm.notify.send、kb.search注册时如果检测到同名前缀但非同一版本的技能直接拒绝启动并打印冲突报告而不是让系统带病运行。这个约束听起来简单但真正解决了大规模共享场景下的核心问题。另外一个经验是技能覆盖率问题你以为把主要功能都技能化了但模型还是会遇到“没有技能可调用”的情况。我的处理是在工具菜单最后固定加一个fallback.reason技能专门用来让模型表达“我看不出来该调用哪个技能原因是什么”既防止模型硬凑技能又能把未知需求收集起来反哺技能库迭代。7.2 参数校验与幻觉参数幻觉不只是答案里的幻觉还有参数层面的。模型有时候会自作主张补充一个并不存在的参数名比如给kb.search传一个date_range字段而我们的Schema里根本没定义。第一次遇到时我的校验器直接报Additional properties are not allowed触发的是整体异常处理导致整轮对话失败。后来我把参数校验策略改成分层处理校验失败的参数先做一次“可纠正性评估”——如果只是多了未知字段记录警告并从入参里剥离如果缺少必填字段或者类型错误再返回错误信息让模型补充或改正。这样既能容忍模型的小失误又不真正让坏参数落到底层函数上。7.3 幂等性与重试机制技能编排过程中网络抖动可能会让某个技能执行超时触发重试。但重试不是所有场景都安全的如果是查询类技能重试问题不大如果技能内部已经写了一条工单记录重试就会产生重复工单。我的解决方案是把技能分成三类幂等型如查询、可安全重试型如下载文件后校验MD5、不可重定型如创建工单、发邮件。不可重定型技能在manifest里加retryable: false标记编排器遇到这类技能失败时不会盲目重试而是进入人工确认或补偿流程。同时建议所有写操作都允许传入_request_id作为去重键服务端记录已处理过的ID从机制上挡住重复执行。7.4 技能热更新与灰度发布技能升级是另一种容易踩坑的地方。早期我直接在线上热加载技能文件结果有一次新版本技能引入了不兼容的参数Schema改动存量流程立刻大面积报错。后来强制规定技能发布必须走灰度流程先在独立的测试Agent环境运行新版本技能跑一批回归样本再逐步放开流量。切流量时采用按技能名滑动百分比的方式配合手工回滚开关确保问题出现时能在一分钟内下线异常版本。这一套做下来之后技能的迭代节奏变得快而稳。内部团队从每周只能发布一次Agent功能变成随时可以独立评审并发布某个技能包线上事故数明显下降。最后再分享一个小体会Agent技能的工程化本质上是在“模型的原生创造力”和“系统的确定性要求”之间铺一层缓冲带。你不可能让LLM既完全自由又绝对可靠技能体系的意义在于把不可控的部分压缩到最小把可控的部分用契约和校验锁死。这个东西做得好Agent才真正有资格从“聊天机器人”进化成“干活系统”。
返回列表