资讯动态

Agent技能系统实战:从设计到落地,给大模型装上可靠的手和脚

发布时间:2026/10/8 23:39:48 来源:尧图企业网站定制
作为一个常年折腾Agent开发的人我发现“skills”这个词在今年的技术社区里出现频率高得吓人。不管你是玩开源框架的还是调各家大模型API的最后都会碰上一件事怎么把模型输出真正变成能落地执行的动作。这背后绕不开的就是技能系统也就是给大模型装上一套可复用、可管理、可校验的“手和脚”。这篇文章我就用自己的实际折腾经历把skills从设计到落地掰开揉碎讲一遍包括我踩过的坑、试出来的参数、还有让模型准确调用技能的细节。1. skills到底解决了什么问题1.1 大模型的能力边界在哪很多人第一次接触技能系统是在大模型回答“不够准”的时候。其实不是模型变笨了而是你让它做的任务已经超出了它原生能力的范围。举个例子我让模型去查数据库里的订单表它再聪明也不可能凭空连接PostgreSQL。这时候技能的定位就很清晰它是一段可调用的外部功能封装模型负责理解意图、匹配技能、生成参数执行端负责真正干活。我在项目里见过不少团队把技能系统和插件混为一谈。插件更像是一个完整的功能模块装上就能用技能则更强调“按需调度”这个动作。你给模型挂上十个技能它每次只挑一两个最相关的出来执行这才是技能系统的核心逻辑。也正因为有这个筛选过程模型输出质量才有实质提升而不是把所有能力全部灌进上下文里让模型自己猜。早期的Agent开发有个很痛苦的点。你要么做if-else硬编码把每个用户问题都写一个分支要么让模型自由发挥输出一段格式不太稳定的JSON然后你在下游做字段匹配。这两种方案的体验都很差。硬编码扩展性为0自由发挥稳定性为0。技能系统走的是中间路线先定义好技能的输入输出结构模型只用做选择题和填空题不需要自己发明协议。1.2 技能系统的核心价值我自己常用的一个类比是大模型是个实习生理解能力很强但做事需要工具。技能系统就是给这个实习生准备的工具箱每个tool都贴好了标签“什么时候用、要怎么用”。模型要做的事情就是从工具箱里挑出合适的工具按照标签说明把参数填对。你的工作则是维护好这个工具箱保证标签清晰、工具不坏、权限不漏。这里有个经常被低估的点上下文长度控制。把技能的描述、参数规则、注意事项全都拼到System Prompt里效果虽然也可以但Token开销非常大。一旦技能数量超过二十个Prompt可能就被撑到两三千Token每次调用都是白花成本。设计良好的技能系统会在底层做筛选只把相关的技能定义发给模型无关的留在外面。这个动作大大降低了推理成本也提升了模型处理核心指令的准确率。拿我自己的一个实际场景来说搞一个任务管理Agent的时候刚开始往系统提示词里塞了十几个技能定义结果模型经常混淆“更新任务状态”和“修改任务详情”这两个动作参数也会传错。后来我把技能注册机制改成按需加载先让模型根据用户问题做一轮粗筛再只把相关的技能描述和参数结构传给它准确率从六成多直接拉到九成以上。这就是技能系统相比“一把梭式Prompt”最大的优势。2. 搭建技能系统前先想清楚这三件事2.1 技能拆分的粒度要怎么定这是我在多个项目里反复调整过的核心问题。技能拆得太粗一个技能里塞了五六种逻辑模型不知道到底该用哪部分拆得太细技能数量爆炸光是在一堆备选里做区分就是负担。我的经验是判断粒度是否合适可以看技能描述能不能用一句话说清楚。如果一个技能你需要写三行描述才能表达它“在什么情况下做什么事”那说明这个技能应该再拆一层。比如“处理订单”这个技能就是个典型的过程粒度。它内部既涉及查询订单状态又涉及更新物流信息还可能牵扯退款操作。你强行做成一个技能参数结构会变得极其复杂布尔类型、可选字段一大堆模型反而更容易用错。拆成“查询订单”“更新物流”“发起退款”三个独立技能每个对应一个明确的功能边界参数简洁调用准确率明显提升。不过拆分的反面也有坑。我见过有人把一个发送邮件功能拆成了“创建邮件草稿”“设置邮件主题”“添加收件人”“发送邮件”四个技能美其名曰细粒度。实际跑下来模型经常需要连续调用四五个技能才能完成一次正常的发件流程中间任何一次参数理解偏了整条链路就断了。技能拆分一定要以“用户一次性表达的需求”为单元而不是以“底层函数的最小操作”为单元。总结来说我现在的拆分标准就是三条技能职责单一、技能描述在五十字内讲清楚、一个技能的执行结果能独立满足一次用户请求的核心意图。只要满足这三条粒度就算合理。后续如果发现某个技能调用率特别高但它又要根据不同前置条件走不同分支这时候再拆不迟。2.2 描述文档的质量决定调用准确率技能系统的准确率瓶颈很多时候不是模型不够聪明而是你的技能描述写得不够“像人话”。模型理解技能的机制是在推理时根据用户问题的语义和技能描述做匹配。如果你的描述里全是技术术语或者参数定义太模糊模型的匹配效果会非常差。我踩过的一个典型案例是把一个查询天气的技能描述写成“根据lat和lon参数返回天气数据”。模型在用户说“上海明天冷不冷”的时候压根不会想到要调用这个技能因为描述里没有出现任何与“温度”“体感”“天气预报”相关的语义点。后来我把描述改成了“查询指定城市支持经纬度或城市名当天和未来三天的天气包含温度、降水概率、风力等”调用率一下就上去了。所以我的技能描述模板固定包含三部分触发场景、功能行为、输出内容。触发场景用自然语言写几个典型表达方式比如用户想了解某地天气、出行前查看天气功能行为说明执行后会发生什么输出内容说明返回的数据形态。写参数定义时也遵循同样的原则每个参数的描述必须包含取值范围、默认值、格式示例能写枚举就不写自由文本。另外一个容易被忽略的点技能描述里的别名要覆盖常见的口语化表达。比如“删除任务”这个技能用户实际表达可能是“清掉”“划掉”“收掉”甚至是“把这个待办干掉”。我在描述里会列上“删除、移除、清掉、划掉、完成并移除”作为触发关键词。注意这里不是要求描述里全用口语而是在正式描述之外留一个“alias”字段专门用于模型做相似度匹配。2.3 技能、工具和插件到底什么关系这个关系在社区讨论里经常被混淆搞清楚对设计系统很有帮助。我的理解是这三个概念是不同层面的东西。工具是最底层的能力单元对应一个具体函数或API技能是对工具的封装加了描述、参数约束、使用场景这些元信息让模型能认识和调用它插件则是更完整的功能包可能包含多个技能和多个工具直接面向用户安装和启用。实操中我更倾向于这样定位插件是给用户使用的技能是给模型使用的工具是给代码使用的。用户不需要知道内部有几个技能他只需要决定装不装某个插件。模型不需要关心某个技能背后是调REST API还是查本地文件它只需要知道技能能用。代码则负责把技能绑定到实际工具上做参数转换和结果回传。这个分层设计带来的好处很明显。更换底层API时只要技能层保持兼容模型和上层代码都不用动。我之前把一个物流查询技能从对接快递鸟API换成顺丰API整个过程只改了工具层的对接代码技能定义里的描述、参数、输出结构完全没变。这在实际维护中的价值非常大尤其在技能数量多的时候。3. 手把手从零搭建一套最小可用的skills系统3.1 先定义一套清晰的配置结构我实际干活的时候不会一上来就写代码而是先设计技能配置的Schema。因为技能系统本质上是元数据驱动你把配置结构定义好了后续的注册、加载、调用逻辑都跟着这个结构走。下面是我一直在用的技能配置结构你可以直接抄走改改。这里我用的是JSON格式兼容性最好后续做可视化配置也方便。{ skill_id: order_query, name: 查询订单, description: 按用户提供的订单号查询订单当前状态、物流信息和金额详情适用于用户询问订单进度、到货时间等场景, aliases: [订单状态, 查单, 物流进度, 包裹到哪了], parameters: { type: object, properties: { order_id: { type: string, description: 订单编号通常以ORD开头例如ORD20250101A, minLength: 8, maxLength: 32 }, include_logistics: { type: boolean, description: 是否同时返回物流轨迹默认true, default: true } }, required: [order_id] }, output: { type: object, properties: { status: {type: string}, estimated_delivery: {type: string} } }, execution: { type: function_call, target: OrderService.query, timeout_ms: 5000 } }每个字段都有它存在的理由。skill_id是全局唯一的标识模型调用时传这个IDname和description负责让模型理解技能用途aliases补充口语化触发词parameters就是技能入参的定义这里要和模型输出的参数严格对齐output描述返回结构后续可以做自动校验execution则是技能与底层执行逻辑的绑定关系。这里有个实操要点parameters尽量用JSON Schema的标准格式。我是直接把OpenAPI的规范搬过来用的因为各家模型厂商的function calling接口基本都兼容这套Schema你定义好了之后换模型平台不用重写技能配置只是接的协议不一样而已。3.2 注册中心与加载机制有了单个技能的定义接下来需要一套管理机制把它们组织起来。我在项目里维护了一个技能注册表可以理解为一个Mapkey是skill_idvalue是技能定义和对应执行函数的绑定。整个系统启动的时候会加载所有注册表里的技能构建成内部索引。const skillRegistry new Map(); function registerSkill(skillConfig, handler) { if (skillRegistry.has(skillConfig.skill_id)) { throw new Error(技能重复注册: ${skillConfig.skill_id}); } skillRegistry.set(skillConfig.skill_id, { config: skillConfig, handler: handler }); } function getSkillDefinitions() { const results []; for (const [id, entry] of skillRegistry.entries()) { results.push({ skill_id: id, name: entry.config.name, description: entry.config.description, parameters: entry.config.parameters, aliases: entry.config.aliases }); } return results; }这个加载机制解决的第一个问题是Prompt膨胀。调用大模型的时候我不会傻乎乎地把所有技能定义全塞进去而是先根据用户问题做一遍预筛选。筛选逻辑可以是简单的关键词匹配也可以让模型先做一次轻量分类。我的经验是如果技能数量在十五个以内关键词匹配就够用了超过二十个建议再做一层基于向量的召回。预筛选之后被选中的技能定义会被转换成模型要求的格式。比如你用的是OpenAI的function calling格式那就把skill_id映射成function name把parameters映射成function parameters。这个转换层把内部技能系统和外部模型协议解耦了这是保证系统可迁移性的关键。还有一个细节值得注意技能执行完毕之后的结果要格式化后回传给模型让模型基于执行结果给用户生成最终回复。这个回传不能直接把原始SDK响应或数据库里的字段丢给模型而是要做摘要化和结构化控制Token占用。我在回传之前会检查结果大小超过一定阈值的做个字段裁剪只保留用户最关心的部分。3.3 调用链路与参数校验实战技能系统跑起来之后核心链路是用户输入 - 模型识别意图 - 匹配技能 - 模型生成参数 - 校验参数 - 执行技能 - 返回结果。很多人喜欢在匹配技能这步就开始写各种复杂逻辑我的建议是先跑通最简单的链路再逐步加防护。下面是我在项目里实际用的最小链路代码。async function runAgent(userQuery) { // 1. 预筛选相关技能 const candidates preFilterSkills(userQuery, getSkillDefinitions()); // 2. 构建消息让模型决定调用哪个技能并生成参数 const messages [ { role: system, content: 你是一个智能助手根据用户问题调用合适的技能完成操作。 }, { role: user, content: userQuery } ]; const response await callLLM({ model: your-model-name, messages: messages, functions: candidates.map(toLLMFunctionFormat), function_call: auto }); // 3. 解析模型的function call结果 const callMsg response.choices[0].message; if (!callMsg.function_call) { // 模型没有调用技能直接回复用户 return callMsg.content; } const skillId callMsg.function_call.name; const args JSON.parse(callMsg.function_call.arguments); // 4. 参数校验避免脏数据传到执行层 const validation validateArgs(skillId, args); if (!validation.valid) { // 校验失败把错误信息回传给模型让它重新生成参数 return retryWithFeedback(skillId, validation.errors); } // 5. 执行技能并获取结果 const entry skillRegistry.get(skillId); const result await entry.handler(args); // 6. 结果回传模型生成最终回复 const finalResponse await callLLM({ model: your-model-name, messages: [ ...messages, callMsg, { role: function, name: skillId, content: JSON.stringify(result) } ] }); return finalResponse.choices[0].message.content; }参数校验这步是我反复吃过亏之后才加上的。大模型生成的参数偶尔会出现类型不对、枚举值乱填、必填字段缺失的情况如果直接传给底层函数轻则报错重则把脏数据写进数据库。我用了JSON Schema校验库同时加上一个自动修复机制校验失败时把错误信息明确地反馈给模型让它修正后重新调用。const Ajv require(ajv); const ajv new Ajv({ allErrors: true }); function validateArgs(skillId, args) { const config skillRegistry.get(skillId).config; const validate ajv.compile(config.parameters); const valid validate(args); if (valid) return { valid: true }; return { valid: false, errors: validate.errors.map(e ${e.instancePath || 参数} ${e.message}) }; }反馈重试的逻辑也简单把错误信息拼进一条system message告诉模型“你刚才调用订单查询技能时参数校验失败order_id未填写。请参考技能定义修改参数。”实测中这个重试机制的成功率很高大多数情况下模型会在第二次生成时修正参数。执行技能这一步还有个超时和重试问题。网络请求常常不稳定我在技能层统一加了个超时中间件超过设定时间直接返回一个给模型看的错误提示而不是让整个链路卡死。这个超时时间我一般设置在5000到10000毫秒之间具体取决于技能的复杂度。3.4 执行引擎与事务边界技能系统跑起来之后还要考虑一个更深层的问题一个用户请求可能触发多个技能按顺序执行。比如用户说“把我待办里所有完成的任务删掉再更新一下统计数据”这就涉及读取任务列表、逐个删除、重新计算统计三个动作。如果各自为战中间任何一步出错数据就处于不一致状态。我在系统里引入了一个简单的编排层用数组顺序保存调用计划每个元素包括技能ID和参数。执行引擎按顺序处理但每个技能的执行要记录状态。如果某个技能执行失败后续技能默认不再执行已执行成功的步骤根据策略决定是回滚还是保留。这个设计为Agent执行复杂任务提供了基础保障不会让一次失败的调用毁掉整个任务状态。并发也值得一说。我默认不让多个技能同时写同一个数据源因为大模型生成参数的时候并不具备严格的时序感知并发写很容易导致覆盖。只读类技能可以并发比如同时查天气和查新闻写操作类技能全部串行执行。这个规则我都放在执行引擎层和具体技能无关属于全局策略。4. 踩过的坑和排查技巧实录4.1 模型死活不调用技能怎么办这是技能系统落地时最让人头疼的问题。模型有能力技能也注册好了但每次请求它都直接给文字回复完全无视技能定义。我排查这类问题的心得第一是检查技能描述的触发场景和真实用户问题之间的语义距离。很多人写的描述是“用于查询订单”但如果真实用户的问题是“我昨天买的东西发货了吗”这两者之间的匹配信号太弱。描述里加上商品、快递、发货这些词之后模型就能识别了。第二要检查System Prompt里有没有把技能系统的存在说明白。只把functions参数传进去但Prompt里一句不提有些模型还是会直接把文本输出来。我一般会在System Prompt里加一句“当用户的问题涉及以下技能能力时先调用对应技能再回复把技能执行结果作为你回答的事实依据”。这个引导词简单但有效。第三要关注模型版本。不是所有模型都对function calling做了充分优化同一个Prompt换一个模型版本调用率可能差很多。我维护了一套自己的技能调用测试用例每次换模型前先跑一遍回归看看调用率和参数准确率有没有变化。如果以上都排查过还是没有解决还有一个底牌不用function calling改成输出强格式化的JSON让模型自己决定调哪个技能。虽然看起来没那么优雅但对某些模型来说反而更稳定。我一般在国产模型或者垂直模型上会做这种降级方案。4.2 参数错误和JSON解析失败怎么办大模型输出的JSON偶尔会不合法比如多一个逗号、字符串引号没闭合、甚至把数字写成文本。这种问题虽然看起来低级但在生产环境非常常见。我建议不要直接拿JSON.parse去硬解析而是先做一层宽松处理。我在项目里用的是一个分步策略先截取模型输出中第一个“{”到最后一个“}”之间的内容再做一次简单的括号配对检查最后才丢给解析器。如果解析失败把错误信息反馈给模型让模型重新输出严格的JSON。这里的容错率目标是保证绝大多数情况下模型能在一次重试内恢复。类型错误是另一个高发区。模型把一个int类型的数字参数生成为字符串类型或者把布尔值true传成了字符串“true”。这类问题既可以在校验层拦截也可以在技能执行层做一次类型强制转换。我更推荐在描述里写清楚每个字段的类型和示例因为模型对示例的模仿能力很强。参数枚举值越界也出现过。明明定义了一个三选一的状态参数模型硬生成了一个不在枚举里的值。针对这种情况我不仅在校验层报错还会在反馈信息里列出所有合法的枚举值。基于这个反馈模型第二次生成时基本都能修正。4.3 技能复用性和冲突管理技能多到一定数量冲突问题就来了。最典型的场景是两个部门各自开发了不同的用户查询技能一个叫user_query一个叫get_user_info功能重叠但数据结构完全不同。当模型既看到A又看到B就会随机选一个结果取决于Prompt里的先后顺序这很危险。我的解决方案是建了一个统一入口加命名空间隔离。所有技能ID不能重复重复注册直接报错这在注册中心就拦截掉了。针对功能重叠的问题我会对重叠技能做一个合并或者在描述部分明确标注“当前环境推荐优先使用哪个”。这类清理工作适合定期做不然技能库会越来越臃肿。还有一个小细节是技能输出的Field命名规范。如果两个技能都返回结果一个叫“message”一个叫“msg”模型混淆的概率会大幅增加。我在技术规范里强制要求输出字段命名统一用户侧字段一律用snake_case内部接口字段一律用camelCase并放在一个基础规范文档里新技能开发先对照这个规范。4.4 权限与安全边界怎么控制技能系统的权限治理是个容易被忽视但一定要做的工作尤其是当Agent开始操作真实业务系统的时候。我的做法是把技能分为只读类和写操作类只读类技能可以自由调用写操作类技能必须经过一个确认环节。这个确认环节可以简单也可以复杂。简单版本是在执行前发一条确认消息让用户点确认复杂版本是接入审批流程根据操作类型和数据敏感级别决定走什么审批路径。生产环境我通常还会加一层审计日志记录哪条会话调用了什么技能、传了什么参数、返回了什么结果后续排查问题会方便得多。凭证管理也归到权限这一层。技能不会直接把API Key发给模型而是放到一个独立的凭证中心执行层通过技能注册表里关联的凭证ID去取。这样模型即使输出了参数也无法直接接触敏感凭证。还有一个小的实践约束在回传技能的原始结果给模型之前先过滤掉敏感字段确保模型只看到回复用户所需的字段而不是把它能接触的数据都无差别带入上下文。5. 把skills系统融入实际项目后我的一些真实体会整套技能系统搭建下来之后最大的感受是开发模式变得清爽了。以前加一个新功能要动流程代码、动Prompt、动参数解析逻辑开发和测试周期拉得很长。现在加新技能只是增加一份技能配置、写一个处理函数、注册一下就好现有链路几乎不用动。这套模式对团队协作也很友好。技能配置和技能逻辑实现了分离产品经理可以直接维护描述和参数定义后端工程师只需要关心执行逻辑不需要搞清楚模型的调用细节。每个技能都能独立开发、独立测试、独立上线这比在一个巨大Prompt里埋逻辑要好维护得多。根据我长期实测的经验要把这套系统跑稳核心要持续打磨三块内容技能描述的覆盖度和准确性、参数校验的完整性、执行结果回传的格式与精简度。它们分别决定了模型的调用率、执行的成功率和结果的可用性。如果你正在为Agent的落地效果发愁我建议先不要急着换更大的模型先检查一下你的技能系统这三个方面有没有做到位。最后分享一个小技巧技能系统上线后一定不要只看功能正常就完事建议记下每次用户请求中技能被调用的占比以及调用失败后的重试成功率。这个数据能帮你在模型版本升级、底层服务调整的时候快速定位问题方向比用户反馈来得快得多。我自己就是在一次版本升级后发现某类技能调用率突然掉了一半顺藤摸瓜才发现是描述里某个热词被新模型误解了调整描述后立刻恢复正常。这种事没有数据你根本感知不到。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑