☰
Agent技能库设计与落地:从工具调用到多技能编排的实战指南
2026/10/14 8:15:13 网站建设 项目流程

在LLM应用开发里折腾久了,你就会发现一个特别尴尬的阶段:单轮对话、简单的工具调用都跑通了,但一旦想让Agent稳定地完成稍微复杂点的工作——比如“先查资料,再整理成表格,最后发到群里”,就开始各种翻车。工具定义散落各处,提示词里塞满了“如果...就...”,模型时而漏参数,时而调错函数。我大概在半年前被这个问题折磨得不行,于是动手整理了一个叫“agent-skills”的项目,本质上就是一套给Agent用的“技能仓库”方案。这篇文章就是来聊聊这个项目怎么设计、怎么落地,以及我在过程中踩过哪些坑。

这套东西适合谁?适合那些已经用大模型+工具调用做过几个Demo,但想让Agent真正具备多步骤、可复用工作能力的开发者。不管你是用开源的Agent框架,还是自己在裸写ReAct循环,这套技能抽象的思路都能直接搬过去用。它不是某个框架的插件,而是一套组织代码、提示词、参数校验规则的方法论。下面我把整个项目的设计思路、核心数据结构、从零搭建的实操过程、以及多技能编排的难点逐一拆开讲。

1. 整体设计思路:把Agent的“能力”当作独立工程产物来管理

1.1 为什么要单独做一个“技能仓库”,而不是直接写Function

很多人第一步会问:我直接把所有能力都写成Python函数,注册给模型不就行了吗?为什么还要搞一个“agent-skills”项目出来?答案是:当你只有三五个工具时,直接写函数完全没问题;但当工具数量到二十个以上,并且需要多个工具配合完成复杂任务时,函数列表本身就变成了一个难以维护的“大杂烩”。

举个例子。假设你要做一个“会议纪要高亮提取”的功能。你需要用语音转文字工具把录音转成文本,再调用摘要模型提炼要点,然后提取决定事项和负责人,最后还要把结果按固定模板生成Markdown。这四个步骤如果分别写成四个裸函数,它们在逻辑上是割裂的——模型并不知道“转写文本”之后下一步该做什么。而如果把它们打包成一个“会议纪要处理”技能,并且配上明确的输入输出说明、处理步骤、常见边界情况描述,模型调用时就会像一个老员工拿到SOP一样知道全流程。

所以我在设计这个项目的第一天就定了一个原则:技能(Skill)不是指单个函数,而是指“一个可以独立完成某项任务的最小完整能力单元”。它包含函数实现、参数描述、副产物(比如记录、日志)、以及最关键的——一组教模型“什么时候用、怎么用、用完以后怎么接下一步”的元数据。

1.2 技能库的三层抽象模型

项目把Agent能力拆成了三层,这个分层是我觉得整个项目里最有价值的部分:

第一层叫“工具层”,就是最基础的原子操作,比如调用某个API、读一个文件、发一条消息。工具层不感知业务,只负责把一件事执行到位。

第二层叫“技能层”,它把一个或者多个工具按照特定业务场景编排起来,形成一条“操作路径”。比如“日报生成”这个技能,内部可能是:先读取今日所有聊天记录,再筛选关键进展,再调用模板生成正文。这一层是Agent可以直接通过名称+参数调用的最小业务单元。

第三层叫“任务层”,它负责把多个技能按照目标拆解成执行序列。任务层是一个跑在技能之上的调度器,它决定先执行哪个技能、后执行哪个技能、失败后怎么降级。

这个三层模型最大的好处是:每一层都可以独立测试、独立演进。工具底层换了实现,只要接口不变,技能层不用动;技能层内部调整了步骤顺序,任务层和模型都不用感知。这种隔离性让Agent系统的可维护性提升了一个量级。

1.3 为什么用“DSL+目录结构”而不是纯数据库存储

一开始我也纠结过,技能定义到底放哪。放在数据库里查询灵活,放在代码里好管理版本。最后我选择了折中方案:每个技能用独立的目录+前后端一致的结构化描述文件(YAML)来定义,目录就是技能ID,文件里存放技能的描述、参数、测试用例、调用逻辑入口。整个技能库就是一个普通文件系统,天然支持Git版本管理、Code Review和CI测试。

这么做有一个实打实的好处——技能的变更历史和代码变更历史高度绑定。你新增一个参数校验,审计时可以直接git diff看改动;技能A升级之后导致技能B的编排出现回归,也能快速git bisect定位。如果当初把这一切塞进数据库,做审计和回溯会痛苦得多。

1.4 项目目录总览

整个项目的结构大致长这样,后面实操部分我会逐步展开:

agent-skills/ ├── registry.yaml # 技能注册总表,用于启动时快速扫描 ├── skills/ │ ├── meeting-minutes/ # 一个技能一个目录 │ │ ├── skill.yaml # 技能描述,包含参数、用途、边界 │ │ ├── handler.py # 技能执行逻辑 │ │ ├── samples/ # 给模型看的few-shot示例 │ │ └── tests/ # 单元测试和端到端验证脚本 │ ├── daily-report/ │ └── ... ├── orchestration/ │ ├── planner.py # 任务编排器,把任务拆成技能序列 │ └── executor.py # 执行器,负责调用技能并处理结果 └── runtime/ ├── loader.py # 启动时加载所有技能到注册表 └── context.py # 运行时上下文,跨技能传参和状态管理

2. 技能定义的标准化:让模型“看得懂、调得准”

2.1 技能描述文件的设计要点

一个技能要能被模型准确调用,光靠函数名加注释是不够的。我在实战中反复打磨,最后skill.yaml里固定包含这几组关键字段:

字段说明作用
id全局唯一技能ID模型调用时的唯一标识
name人类可读名称便于调试和日志输出
description一句话说明技能整体作用模型选择技能时的第一判断依据
when_to_use什么时候应该用这个技能大幅提高模型路由准确率
parameters参数列表,每条含类型/是否必填/描述/枚举值决定模型能否正确传参
dependencies依赖的技能ID列表用于编排器判断执行顺序
returns返回结果的结构说明决定模型能否正确解析输出
examples2-3组输入输出样例few-shot提示,降低模型理解成本
version语义化版本号支撑技能独立升级与灰度

这里我最想强调的是when_to_use这一个字段。别人的工具描述往往只写“这个函数能做什么”,但我要求每个技能必须写清楚“什么情况下用它、什么情况下千万别用它”。比如某个“查询天气”的技能,我除了写“输入城市名返回天气”,还会补一句“如果用户问题是‘明天适合洗车吗’,不要直接调用天气查询,应该走‘生活建议生成’技能”。正是这样的“负样本”规则,让模型选错工具的概率大幅下降。

2.2 为什么参数校验必须用JSON Schema

第二个极其重要的点是参数校验。模型通过自然语言抽取参数进行调用时,经常会出现“幻觉参数”——目标函数根本不存在的字段也被它生成出来。前期我的项目里,模型经常把"city": "北京(朝阳区)"传给只接受city_code的接口。自从技能定义里全面引入JSON Schema之后,这类问题几乎绝迹。

JSON Schema的作用体现在两方面。第一,它能对模型输出的参数做运行时校验,发现多传了未知字段立刻拦截并返回友好报错;第二,Schema本身也是给模型看的“参数说明书”。我在生成系统提示词时,会把Schema完整渲染进提示词里,模型看到清晰的字段约束后,输出规范程度明显提升。

一个简化的参数Schema示例长这样:

parameters: - name: city_code type: string required: true description: "城市行政区划代码,例如110000代表北京" pattern: "^\\d{6}$" example: "110000" - name: days type: integer required: false description: "查询未来几天的天气,范围1-7" minimum: 1 maximum: 7 default: 3

这种显式约束,既方便代码做运行时校验,也给模型传递了非常精确的格式预期,相当于把“出题范围”提前画定了。

2.3 few-shot样例的写法:不是数量多,而是差异大

每个技能我在samples/目录里放了两到三组“输入-技能调用参数-期望输出”的完整JSON样例。关键在于,这些样例绝不是简单重复的参数变换,而是故意覆盖不同类型的情况:

  • 第一组:标准情况,参数齐全,模型直接命中。
  • 第二组:模糊情况,用户表达缺省参数,展示模型应该如何主动填默认值。
  • 第三组:边界情况,输入涉及技能不该处理的范围,展示模型应该如何拒绝对接其他技能。

用“日报生成”技能举例,第二组样例就是用户只说“帮我写今天的日报”,没说项目名、没说时间范围,模型需要根据当前日期自动补全date=今天、project_name=未指定,这组样例教会模型“缺省策略”。

2.4 版本管理与兼容性

技能一定会迭代,这是躲不开的。我规定技能升级必须遵守语义化版本规则:大版本号变化意味着接口不兼容——参数变了、返回结构变了;小版本号意味着功能增强但保持兼容;补丁版本意味着只修内部逻辑。编排器和依赖方在声明依赖时,可以写成meeting-minutes: ">=1.2.0 <2.0.0",这样升级一个技能时,不会被其他技能隐性破坏。

3. 从零搭建一套可用技能库:注册、装载、调用全流程

3.1 技能注册总表:启动时快扫而不是全量加载

实际操作中,技能数量多起来以后,全部加载到内存是不现实的,既费token又拖慢启动速度。所以我设计了一个registry.yaml作为技能总表,它不是完整技能的拷贝,而是一份“索引”,启动时只加载这个索引,技能在首次被需要时才做懒加载。

version: 1.0.0 skills: - id: meeting-minutes path: skills/meeting-minutes enabled: true load_policy: lazy - id: daily-report path: skills/daily-report enabled: true load_policy: eager

load_policy字段区分了两种加载策略:常驻型技能(比如会话管理)用eager预加载;重型和低频技能(比如视频转码)用lazy按需加载。这一设计让Agent的冷启动时间在一个80技能规模的库里依然能控制在几百毫秒级别。

3.2 技能处理器(handler)的接口约定

每个技能的handler.py都遵循同一个接口约定,这很重要。统一接口让编排器不用关心每个技能内部逻辑长什么样:

# handler.py 统一入口示例 from dataclasses import dataclass from typing import Any @dataclass class SkillResult: status: str # "success" / "failed" / "partial" data: Any # 业务返回数据 error: str | None # 失败原因 metadata: dict # 副产物信息:耗时、调用链等 class BaseSkill: async def execute(self, params: dict, context: dict) -> SkillResult: """所有技能必须实现这个异步入口""" raise NotImplementedError

为什么必须异步?Agent类应用里,技能执行往往要并发调用多个底层服务,同步阻塞会严重拖慢整体响应。统一异步接口之后,编排器可以用简单的asyncio.gather同时跑多个技能,实现“并行能力组合”。

3.3 技能执行逻辑里的“内聚”原则

写技能内部逻辑时,最容易犯的错误是“把Agent的编排逻辑写死在技能里”。有些同学会在“周报生成”技能里直接判断“如果当前是星期五,则额外调用邮件发送技能”,这就把编排责任放错了位置。技能内部应该只负责“完成本技能的既定任务”,至于何时触发、触发后要串联什么,统统应该交给上层编排器。

我后来的做法是:技能内部只包含“完成这项任务必须要做的事”,所有分支逻辑都通过context里的开关量注入。比如周报技能是否发送邮件,由编排器根据“当前日期”决定并在参数里传入send_email: true,技能自己不做时间判断。这么改完以后,技能的可复用性大大提高,测试也好写。

# 一个典型技能的骨架 async def execute(self, params: dict, context: dict) -> SkillResult: raw_text = params.get("transcript") # 步骤1:文本清洗 cleaned = clean_text(raw_text) # 步骤2:调用摘要模型 summary = await self.summarizer.run(cleaned) # 步骤3:结构化提取 action_items = await self.extractor.run(summary) return SkillResult(status="success", data={ "summary": summary, "action_items": action_items }, metadata={"model": "summarizer-v3"})

3.4 装载器与上下文管理

技能的装载不能只把函数加载进来,还要把技能声明里的描述、参数Schema、few-shot样例全部结构化存好,后续生成系统提示词时直接引用。我做了个SkillRegistry类,启动时扫描注册表,调用load()时解析YAML、加载Python模块、校验Schema合法性,最后注册到全局。

上下文管理方面,我引入了一个轻量的Context对象,所有技能可以读取和写入键值对。它解决的核心问题是“步骤与步骤之间的数据接力”——技能A产出的结果,要通过上下文传给技能B作为输入。这里的坑在于:上下文里的数据必须是可序列化的Json,天然规避了Python对象跨技能传递时出现引用混乱的问题。

4. 多技能编排实战:从“会调用”到“会配合”

4.1 编排器的工作机制

当Agent面对一个复杂用户请求时,光有技能库是不够的,还需要一个“拆解和调度的大脑”。我实现的编排器分为两步:第一步是planner,接收用户目标和可用技能索引,输出一个技能执行计划;第二步是executor,按计划逐个调用技能,并在每一步之间传递数据。

规划阶段的提示词里,我会把技能索引压缩成“技能ID+一句话描述+主要参数”的紧凑列表,不让模型直接读全部YAML。实测这样能显著减少模型翻看无用信息而导致的注意力分散。

一个典型计划输出长这样:

{ "plan_id": "8f1a2a3e", "steps": [ {"skill": "meeting-transcribe", "params": {"file": "rec.mp3"}, "order": 1}, {"skill": "meeting-minutes", "params": {"transcript_from": "step_1"}, "order": 2}, {"skill": "daily-report", "params": {"include_minutes": true}, "order": 3} ] }

4.2 失败降级与重试策略

真实环境里,技能调用失败太常见了——第三方API超时、模型返回非JSON、依赖服务限流等等。编排器必须有明确的失败处理策略,我沉淀了两条原则。

第一条,失败信息必须回传到决策点,而不是让编排器直接吞掉。技能返回的error字段会被拼接成格式化消息,由模型决定下一步是重试、换参数还是换技能。第二条,幂等技能才允许自动重试。比如“查询”“生成草稿”这类无副作用的操作,超时可以自动重试一次;而“发送邮件”“创建订单”这类有副作用的操作,宁可返回失败让用户确认,也不能盲目重发。

4.3 上下文数据命名的防冲突设计

多个技能在同一个Context里读写数据时,命名冲突是很容易踩的坑。两个技能都用output这个key,后写覆盖先写,下游技能拿到的数据就是错的。我的解决办法是强制要求技能写入上下文时,key必须以技能ID为前缀,例如:

meeting-minutes:summary meeting-minutes:action_items daily-report:content

这个约定虽然简单,却让跨技能的数据流动变得非常清晰,调试时候一眼就能定位数据是谁写的。

4.4 组合技能的“原子性”问题

最后一个编排层的核心问题是:当一个任务需要多个技能配合完成时,如果中间某一步失败,前面已经执行的操作怎么处理?比如一个“生成周报并发送到群”的任务,周报生成成功了但发送失败了,整个计划算成功还是失败?

我采用了“手动补偿”策略:不搞复杂的分布式事务,而是在计划层面定义了on_rollback回调。一旦后续步骤失败,编排器会调用前面已执行步骤注册的回调,做一些补偿动作——比如发送失败时,回调负责把已生成的周报保存到草稿箱,并附上失败原因。这种做法实现成本低,在实际业务里已经覆盖了九成以上的补偿需求。

5. 常见问题与排查技巧实录

5.1 问题速查表

症状根因解决方案
模型调用了错误技能技能描述与when_to_use写得太模糊重写description,增加负例描述
模型传参总是多余字段参数Schema缺少严格unknownFields配置校验器开启禁止未声明参数
技能执行成功但结果不达预期技能内部缺少输入数据前置校验在execute开头检查params完整性
多技能编排时上下文数据被覆盖未按ID前缀隔离数据强制key以skill-id前缀开头
技能升级后其他流程报错大版本升级未同步下游依赖严格执行语义化版本约束
启动加载过慢全部技能做了eager加载低频技能改为lazy并按需加载
模型不按样例格式输出few-shot样例覆盖不足增加边界情况、缺省情况样例

5.2 三个绕不开的坑

第一个坑是模型会在技能返回数据的“缝隙”里自己脑补内容。技能A返回了三个字段,模型准备调用技能B时,如果B需要四个字段,模型很可能“编造”一个看起来合理的值。这非常危险。我的对策是在技能B的Schema里把关键字段标记为required,并在校验失败时明确返回“该字段与技能A输出字段不一致”的报错,引导模型回头取数。

第二个坑是提示词里的技能描述过多过长。技能库有三四十个技能后,如果把所有完整描述都塞进提示词,模型的表现反而会大幅下降。不能偷懒“全量灌输”,而要先做意图路由——用一次轻量分类把候选技能缩小到三五个,再让模型在候选集里做选择。这一步改造之后,我的实测准确率提升了大概两成。

第三个坑是本地测试没问题,一接真实模型就出岔子。原因是本地测试往往使用固定的合成输入,而真实用户输入里的噪声和省略语五花八门。后来我把每个技能的samples/扩展成一套“自动化用例集”,每次修改技能描述文件后必须跑一遍回归,夹具里特意混入用户多发的最典型几种错误输入,形成“护栏”。

5.3 调试技能调用的“侦察兵”手段

定位技能调用问题时,整个链路太长:用户输入、模型推理、规划输出、技能执行、数据回传。每一步都可能出错。我的经验是给Agent的运行时加一个“调试日志模式”,所有关键节点都输出可追踪的结构化日志:

{"event": "skill_call", "skill_id": "meeting-minutes", "params": {"file": "rec.mp3"}, "ts": "2025-06-01T10:00:00Z"} {"event": "skill_result", "skill_id": "meeting-minutes", "status": "success", "duration_ms": 3200} {"event": "plan_step_start", "plan_id": "8f1a2a3e", "step": 2}

这个日志模式还有一个妙用:我可以用它快速“回放”一次完整的Agent执行过程,观察模型是在哪一步开始“跑偏”的。加上时间戳和耗时之后,还能顺手发现性能瓶颈——比如有几次用户体感很慢,就是某个技能长期在冷启动加载,加了预热之后立刻缓解。

最后分享一个我后来越用越顺手的小技巧

技能库刚搭起来的时候,我的维护方式完全靠手工改YAML。后来我发现,最省力的做法是给每个技能配一个“自检脚本”,每次提交前跑一遍,把描述文件里过时的example、缺失的when_to_use、未覆盖的parameters自动检测出来。刚开始写这个自检脚本花了半天,但之后每次升级技能都节约了大量人工复核时间。如果你也准备长期维护一套Agent技能体系,建议从第一天就把“自动化校验”考虑进去,而不是等技能多到改不动了再回头补。

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

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

立即咨询