智能体技能系统设计与实践:从工具调用到可组合能力库
做过好几个智能体项目之后我越来越觉得真正拉开一套智能体系统上限的往往不是选哪个大模型而是它背后那套“技能系统”。这里说的技能系统就是我负责的 agent-skills 模块——一个让大模型智能体具备可执行能力的本地技能库。它解决的问题很实在模型只会生成文本不会真正碰文件、调接口、操作浏览器而把每个功能都写死在主流程里又会让代码越来越僵化。agent-skills 做的事情就是把“能力”拆成一个个可描述、可发现、可组合、可复用的技能单元让模型在执行任务时自己去匹配、调用。这篇文章适合正在搭智能体框架的开发者、准备把手头功能封装给模型用的工程团队也适合那些想搞懂智能体落地难点的人。1. 为什么我坚持给智能体单独做一套“技能系统”1.1 从工具调用到技能系统的进化最早做智能体的时候我用的也是“工具调用”的思路把功能写成函数然后把函数名和参数类型告诉模型让它按 JSON 格式调用。这个方法在小 demo 里跑得很顺但一旦功能多起来问题就逐个冒出来了。第一个问题是功能描述没有统一约束。有的函数注释写得详细有的只写一句话模型理解起来全靠猜经常在相似功能之间选错。第二个问题是参数处理太随意。模型输出的参数经常多一个字段、少一个类型或者把字符串传给了数字参数每次都靠下游代码硬扛。第三个问题是没有反馈协议。函数执行完要么返回一堆原始结果让模型自己解读要么抛出一个异常让整个任务中断缺少结构化的错误信息来引导模型进行下一步决策。后来我接触到“技能”这个概念才逐渐把思路从“函数调用”升级成“技能系统”。技能不是简单给函数包了一层描述而是一个完整的能力单元它有自己的名称、用途说明、参数契约、返回值格式、错误码和调用约束。更重要的是技能可以被打包、被注册、被其他流程发现并复用。相当于把原来散落在代码各处的“能力碎片”整理成了一本有目录、有索引、有使用说明的能力手册模型不需要知道每个技能内部怎么实现只要学会读目录就能正确使用。这里有个很关键的差异工具调用本质上还是“人写死函数模型碰运气去调用”技能系统则是“模型在能力地图里做决策系统保证决策可解释、可恢复”。后者把不确定性控制在了决策层而不是任由不确定在调用链路上扩散。这个设计取舍直接影响后面整套系统的稳定性和可维护性。1.2 模型只负责决策技能负责干活理解 agent-skills 这个模块首先要理解大模型在真实业务里的定位。大模型再怎么强大它的训练目标也是“生成合理的文本”而不是“执行具体的操作”。让它计算一个文件目录的哈希值、批量重命名一批图片、调用一个外部 API 并处理异常这些都不是文本生成能完成的需要外部的“手和脚”去配合。所以我一直坚持一个原则模型只负责决策技能负责干活。模型需要知道的是“当前任务对应哪个技能、需要传什么参数、执行结果该怎么理解”至于具体的文件遍历、网络请求、数据转换逻辑全部封装在技能内部。这样有两个直接好处一是模型上下文里不用塞满实现细节注意力可以集中在任务拆解和路径规划上二是技能实现可以独立迭代比如换了更高效的算法或者修复了边界 bug对模型端的接口保持不变上层推理链路完全不受影响。这个分工也决定了我的调试方式。遇到“模型表现不好”的问题我不会一上来就换模型或者调提示词而是先看技能侧是不是出了问题——描述够不够清楚、参数契约有没有歧义、错误码是不是能引导模型修复行为。多数情况下bug 根本不在这模型身上而在技能定义本身。1.3 为什么最终选了本地技能库的方案在做架构选型的时候我其实纠结过一段时间技能定义和执行逻辑到底放云端还是放本地云端方案的优势是集中管理、动态下发模型推理和技能执行可以在同一片环境里跑但缺点也很明显每一次技能调用都要经过网络往返延迟高而且涉及敏感数据的场景根本不适合把文件内容传出去。本地方案虽然管理成本高一点需要自己处理版本和分发但换来的是低延迟、离线可用和数据隐私。我当时做的是某终端侧 Agent 框架技能库直接打包进应用推理端通过协议访问本地技能注册表。模型决策走云端或者本地小模型具体执行全部落在设备端。这样做下来单次技能调用的本地耗时基本控制在十毫秒级体验上比全云端方案好非常多。如果你也是做端侧或私有化部署的场景我很推荐这个思路把技能库做成一个独立模块和推理引擎解耦用一套标准协议来通信后面无论是换模型还是迁云端都不用动技能层。这个“终端侧技能库”的设计也让我后面做权限控制、沙箱隔离、技能灰度发布都有了天然抓手。因为技能代码在本地跑你可以在进程边界上做权限限制比云端统一执行要灵活得多。2. 技能描述与参数契约核心细节决定上限2.1 技能元数据的四项基本盘我在给技能做定义时每个技能都强制包含四类元信息技能标识id、技能名称name、用途描述description、参数契约parameters。另外还有可选的返回契约returns和错误码表errors。id 是全局唯一标识模型不做匹配只用来最终执行name 是一个简短的动作型短语让模型一眼知道这是什么能力description 是给模型读的长文本要说清楚“这个技能在什么情况下使用、有什么约束、典型输入是什么”parameters 是严格的参数定义框架层直接用 JSON Schema 校验。这四项里最容易被人忽视的是 description。很多人写技能描述习惯性写一句“批量重命名文件”就完事结果模型在“批量重命名”和“移动文件”之间反复横跳。我后来总结出一条经验描述里必须包含动作动词、操作对象、场景约束、典型示例。比如“批量重命名指定目录下的文件可设置文件名前缀、序号起始值和扩展名过滤器当用户提到批量整理、文件名统一格式时使用本技能”。这样的描述模型的命中率会有非常明显的提升。还要补充一点参数契约不能只写参数名和类型要把每个字段的含义、取值范围、必填与否都写清楚。很多模型幻觉参数的问题就是因为只有类型没有语义模型只能猜。字段说明再配一两个示例值模型照着示例填合法率能大幅提升。这个投入非常值因为参数校验的兜底只能在错误发生时拦住问题而写好契约是在源头减少错误。2.2 描述质量直接决定技能命中率我做过一个对照实验同一套技能集合仅仅优化了 description 的写法没有改任何模型和推理逻辑技能选择准确率从 62% 提升到了 89%。这个数据让我印象很深。修改前的描述是“按名称查找联系人”修改后是“在通讯录中按姓名或别名精确查找联系人支持模糊匹配返回联系人的完整资料当用户说找一下某某、查看联系人信息时使用”。差别看似不大但前者缺少边界条件和启动场景模型面对相似任务时就容易犹豫。写技能描述有一个可操作的自检清单第一是否回答了“什么时候用这个技能”第二是否说明了“什么时候不要用这个技能”比如“当用户只是查询天气时不要使用本技能”第三是否有代表性触发场景的明确提示第四是否和相近技能的描述作了差异化说明。这四步都做到位描述就可以说比较合格了。另外我建议每个技能都要有一个“不适用场景”的标注。很多模型选错技能不是因为它不了解技能而是因为两个技能的适用场景高度重叠。你在描述里主动把边界划清楚模型就不用猜。例如“移动文件”和“复制文件”如果都写“处理文件”模型当然会晕把“移动是改变位置、原路径不再保留复制是保留原路径产生新文件”写进描述里选择基本就不会错了。2.3 参数 Schema 与校验兜底参数这块我踩过的坑很深。模型输出 JSON 时经常不按参数契约来把必填字段漏了或者把数组写成了字符串甚至凭空编造出契约里根本没有的字段。如果只靠下游代码一个个判断每加一个技能就要写一堆防御逻辑实在受不了。所以我在 agent-skills 里统一引入了 JSON Schema 校验框架每个技能定义好自己的 schema框架层在技能执行前自动校验校验失败直接返回标准错误码把问题挡在技能执行之前。这个校验层还有一个额外好处——它可以给模型提供“纠错机会”。当校验失败时我不直接终止任务而是把校验错误信息结构化回传给推理端让模型自己基于错误信息修正参数并重试。相当于给模型加了一层看得见的“试错反馈”。实测下来加上这个机制后参数校验失败导致的整体任务失败率下降了很多因为大部分参数错误都是可以自我修正的而不是一次就判死刑。关于返回契约我也建议定义清楚返回值要区分“正常结果”和“副作用结果”正常结果回传模型用于下一步决策副作用结果比如日志、统计信息可以单独存一份不往模型上下文里塞。这样能有效控制上下文长度也让模型拿到的信息更纯粹。3. 从零搭一套 agent-skills实操过程全记录3.1 技能模块的目录结构与定义格式我做技能模块时采用了一个非常朴素的目录结构每个技能一个文件夹里面包含定义文件、实现文件和测试文件skills/ file_ops/ skill.json handler.py test_handler.py search_contacts/ skill.json handler.py test_handler.pyskill.json 就是前面说的元数据内容包括技能的 id、name、description、parameters、returns 和 errors。handler.py 是技能的具体实现只需要暴露一个统一的执行入口。这个结构最大的好处是“定义即文档”——任何人打开一个技能文件夹读一遍 skill.json 就能知道这个技能能不能用、怎么用不需要翻代码。下面是 file_ops 里“批量重命名”技能的 skill.json 示例{ id: file_ops.batch_rename, name: 批量重命名文件, description: 批量重命名指定目录下的文件支持设置文件名前缀、起始序号和扩展名过滤器。当用户提到批量整理、统一命名、修改文件名时使用本技能。, parameters: { type: object, properties: { directory: {type: string, description: 目标目录的绝对路径}, prefix: {type: string, description: 新文件名前缀}, start_index: {type: integer, minimum: 0, default: 1}, extension: {type: string, description: 只处理指定扩展名如 .jpg} }, required: [directory, prefix] }, returns: { type: object, properties: { renamed_count: {type: integer}, examples: {type: array, items: {type: string}} } }, errors: { DIR_NOT_FOUND: 目标目录不存在或不可读, EMPTY_DIR: 目录内没有匹配的文件 } }这个 JSON 看起来简单但它同时承载了三件事给模型看的技能说明、给框架看的参数校验规则、给调用方看的返回和错误约定。三件事放在一个文件里避免了维护多份文档导致信息不一致的问题。3.2 技能注册与发现机制有了技能定义之后接下来要解决的是“模型怎么知道有哪些技能可用”。我把这个设计成一套简单的注册和发现机制。框架启动时扫描 skills 目录下的所有技能文件夹读取 skill.json生成一个技能索引表。这个索引表经过按需筛选后注入模型上下文作为模型决策时的“能力地图”。import json from pathlib import Path def load_skills(skills_dir: Path): skills [] for skill_dir in skills_dir.iterdir(): if not skill_dir.is_dir(): continue manifest_path skill_dir / skill.json if manifest_path.exists(): with open(manifest_path, encodingutf-8) as f: manifest json.load(f) manifest[_handler_path] skill_dir / handler.py skills.append(manifest) return skills技能发现机制的关键不是把全部技能都塞给模型而是要根据任务进行筛选。我一开始把全部技能列表拼进系统提示词里结果技能一多上下文直接臃肿模型开始“忽略”后面的技能描述。后来改成按任务类型分类索引先粗筛一类技能再把候选技能描述注入上下文效果好了很多。这种分层发现机制本质上是帮模型缩小决策空间决策空间越小命中率越高。3.3 推理调用链路与一次完整演练下面把一次完整的技能调用链路拆开看。整个链路分成五段任务解析、技能匹配、参数填充、技能执行、结果反馈。第一步任务解析模型接收用户的自然语言任务将其拆解为子任务并识别子任务对应的意图。第二步技能匹配根据当前子任务和候选技能列表模型选择最合适的技能并输出调用参数。第三步参数校验框架层用技能的 JSON Schema 校验参数失败时回传错误让模型修正。第四步技能执行handler 收到规范化参数执行真实操作。第五步结果反馈结构化结果回传模型模型根据结果决定继续操作还是产出回答。我实际跑过的例子是“把下载目录里的图片整理成按日期命名的文件”。用户原话是“帮我把下载文件夹里的图片按修改日期重命名成照片_20240101这种格式”。模型在技能匹配阶段选中了 file_ops.batch_rename并输出参数{ directory: /home/user/Downloads, prefix: 照片_, start_index: 1, extension: .jpg }这里有个细节用户要求按修改日期重命名但我的 batch_rename 技能只支持前缀加序号。模型这是选错了参数方案如果直接执行生成的文件名会变成“照片_1.jpg”而不是用户要的“照片_20240101.jpg”。因为我在技能描述里没有写“按日期命名”这个能力模型的参数填充就只能基于已有能力猜测。这个例子恰好说明了两点一是参数契约必须覆盖真实需求否则模型只能降级处理二是技能系统的设计不是一次性的要随着真实任务需求不断迭代技能的描述和参数。后来我在 description 里补充了“支持按文件元信息原始日期生成序号”的能力再跑这个任务模型就能选出正确参数把 rename_policy 设置为 date。参数化设计是技能系统迭代的一部分每遇到一次模型“降级执行”就说明技能定义缺了一个能力需要补上。3.4 技能命中率怎么测技能系统上线后不能只看功能跑通就完事需要一套评估指标来度量它的质量。我常用的三道指标是技能命中率、参数合法率、执行成功率。技能命中率是模型选出的技能和人工标注的正确技能一致的占比参数合法率是模型输出参数通过 schema 校验的比例执行成功率是技能执行后产出预期结果的比例。为了测这三个指标我构建了一个评测集每个技能准备了一批典型任务样本人为标注 expected_skill 和 expected_parameters。跑评测时把任务喂给推理链路比对模型输出和标注结果。这个评测集其实不用做得特别大每个技能三四十个样本就已经能暴露大部分问题。我每周会跑一次全量评测记录三个指标的变化重点看修改描述或参数后指标是上升还是下降。实测数据上第一版技能系统跑完评测命中率只有 58%、参数合法率 71%、执行成功率 46%看起来挺惨。但我没有急着改推理逻辑而是按照“先看描述、再看参数、最后看执行”的顺序逐项排查描述模糊的重新写清楚参数语义不明的补字段说明执行失败的修 handler 边界逻辑。三轮迭代之后命中率到了 87%、参数合法率 95%、执行成功率到了 89%。模型没换推理链路没大改就是靠打磨技能本身拿到的提升。这套评测方法最大的价值是让技能优化有了“看得见”的依据而不只是凭感觉“调一调”。后续加新技能、改旧技能我都会先跑一遍评测确认没把其他技能的命中率带下来再上线。4. 常见问题与排查技巧实录4.1 模型总是选错技能先查描述边界技能系统最常见的故障就是模型面对相似任务时选错技能。排查时我不会一上来就怀疑模型能力而是先检查两个技能的 description。大部分情况是描述里没有划清边界。比如“保存文件”和“创建文件”在模型看来几乎是一回事如果没有明确“保存是覆盖已有文件、创建是新建文件并让用户指定具体内容”它就很容易选错。遇到这种情况我一律采取一个最朴素的措施给两个互相干扰的技能各自补上“不适用场景”并明确写出彼此的区别。修改之后再跑评测集看命中率的变化。这个方法很简单但真的管用。如果补了边界后模型还是选错我才会去检查是不是技能的 id 或 name 起了干扰作用比如命名相似度太高模型被名称带偏了。另一个容易被忽略的点是技能库里功能重复的技能不要太多。功能重复哪怕实现路径不同也会让模型犹豫。能合并的技能尽量合并把差异做成参数而不是做成两个技能。技能数量精简、边界清晰模型的选择压力就会小很多。4.2 参数幻觉怎么治校验兜底加上回传纠错模型输出参数时编造字段是发生率很高的现象尤其当用户指令模糊或上下文较长时。治理它三板斧完善字段描述、强化 schema 约束、加反馈纠错。前两板斧从源头减少幻觉第三板斧做运行时兜底。我记得有一次技能定义里只写了参数名、类型和必填信息没有写取值范围。模型在一个整型参数字段输出字符串schema 校验居然直接通过handler 执行时才发现类型不对却已经无法挽回。后来我把每个字段的取值范围和示例都写清楚并给 schema 增加了严格的类型校验。从此这类问题大幅减少。就算校验已经够严模型依然可能产生违反约束的参数。这时候我的策略是把校验错误结构化回传给模型并附上一次性的重试机会。比如说“field start_index: expected integer, got string”模型看到这个信息后自己修正参数成功率很高。实测下来这种“让模型自己改”的做法比直接让用户重试要高效得多。4.3 上下文被技能列表撑爆分层注入代替全量注入技能数量一多把所有技能描述都塞进模型上下文会带来两个问题一是上下文长度增长模型“注意”不到后面的技能二是每次请求的 token 消耗增加成本直线上升。我实际见过把三十个技能全量注入后模型只倾向于选择靠前的技能后面的基本成了摆设。解决办法是分层注入。第一层只注入和高频任务相关的技能列表比如打开应用、搜索、文件操作这类通用技能控制在一个较小的数量。第二层根据用户当前任务的关键词在技能索引里做一次粗筛把候选技能压到十几个。第三层才把这些候选技能的 description 注入模型上下文。这样既保证了模型能看到全部可能用到的技能又没有把无关技能放到它的决策视野里。我这边的经验是注入给模型的技能描述总量最好控制在几千 token 以内。超过这个量模型决策的稳定性会下降。如果你的技能库实在太大还可以给技能按领域打标签做成领域可配置的注入策略不同场景注入不同领域的技能效果比单纯地缩技能数量要自然得多。4.4 技能执行失败后怎么恢复错误码要能“指路”技能执行过程中失败是常事文件被占用、目录不存在、权限不够这些都是真实环境里经常碰到的问题。关键在失败之后怎么恢复。我最开始没有设计错误码handler 一抛异常就让整条链路中断用户重新换个说法再来一次体验非常差。后来我把错误码做成了“指路牌”每个错误码不仅说明发生了什么还告诉模型可以怎么补救。比如“FILE_LOCKED文件被其他进程锁定请尝试稍后重试或选择其他文件”、“EMPTY_DIR目录内没有匹配文件请确认目录路径或调整过滤条件”。模型拿到这些错误后可以从容地决定是重试、换参数、换技能还是如实告诉用户需要人工介入。这个设计看似简单但把很多“死局”变成了“活局”。一次批量重命名任务执行到一半碰到一个文件被占用模型看到 FILE_LOCKED 错误自动跳过这个文件并继续处理其余文件最后在结果反馈里说明“有 1 个文件因占用被跳过”。用户看到的是一个能自己处理部分异常的智能体而不是一个动不动就中断的脚本。4.5 冷启动时技能库是空的先做可复用基础技能新项目启动时技能库通常空空如也这时不要急着堆业务技能先把一批“可复用基础技能”打好底。我这边的清单是文件读写、目录遍历、文本搜索、网络请求、数据转换这几类几乎每个应用都会用到。它们通用性好、边界清晰、实现简单可以直接作为其他业务技能的“基础设施”。打好底之后再按业务需求逐个加技能。加技能时我会遵循“一次只上一个跑通评测再上第二个”的原则避免多个新技能同时上线出了问题很难定位是哪一个引入的回归。每上完一个技能我会在文档里补充一个真实用户完整链路的示例。这个示例既是给后来人看的教程也是评测集的一部分一举两得。4.6 技能版本更新后老任务不兼容给描述留余量给实现留版本技能定义更新前最好先想想现有的存量任务是不是还兼容。模型在某个版本被训练出的调用习惯可能是旧参数的如果新版本直接改了参数名旧调用就全部失效。我的做法是参数 schema 里保留旧的字段作为兼容性接受项新字段作为推荐项并给技能实现标明版本号。这个做法让我在迭代过程中少踩了很多坑。比如我把“重命名文件”技能的参数从 prefix 扩展成 rename_policy旧的 prefix 字段仍然可以解析只是标记为 deprecated。模型如果继续输出旧格式框架层做一次兼容转换再走新的执行逻辑。等过一段时间旧字段在描述里彻底移除才算完成迁移。这个“灰度迁移”的思路让技能系统在不断进化的时候不会频繁打断已有任务。5. 写在最后一点个人体会和后续方向如果让我总结做 agent-skills 这段时间最大的体会那就是技能系统不是“给模型加一堆函数”而是一门关于“如何让模型能力落地”的工程学问。它的价值不在于代码多复杂而在于把模型端的能力边界和执行端的现实约束之间整理出一条清晰、稳定、可观测的通路。每次用户任务失败我第一反应都是先问“是模型决策错了还是技能侧给的信息不够”这个问题的答案往往比我预想的更倾向于后者。后续我准备在几个方向上继续做深一是技能组合把多个基础技能编排成复合技能让模型能够完成更复杂的多步任务二是技能评估的自动化把人工标注评测的过程逐步半自动化让技能迭代的反馈闭环更快三是把技能和业务流程结合做成可配置的“技能模板”让业务方自己也能往库里加技能而不需要每次都由研发介入。最后分享一个小技巧在技能系统上线初期每个技能上线前都跑一遍“假用户真调用”的演练用一个模拟的真实任务走完整条链路看模型选技能、填参数、执行、反馈四个环节哪里有卡顿。多跑几轮把暴露出来的问题修完再正式发布比上线后再补漏要省力得多。技能系统这种东西前期花时间把地基打稳后面才是真正的越用越顺手。