AI Agent技能体系设计:从原子工具到可编排技能库
接手一个“智能体”相关的项目我最先做的往往不是选模型、调Prompt而是先看它的“技能”是怎么组织的。模型本身决定的是智商上限而技能体系决定的是它能干多少活、干活靠不靠谱这直接关系到最终交付给用户的是一个能解决实际问题的助手还是一个只会聊天的Demo。这篇文章想聊聊被我命名为agent-skills的一套技能库设计思路与落地实践。它不是某个特定框架的插件而是一套“怎么把原子能力组织成可复用、可编排、可评估的技能系统”的方法论。我会从头梳理设计拆解再到具体实现细节、问题排查全程不贴大段框架源码重点讲清楚每个环节“为什么这么做”以及我在实际项目中踩过的坑。对于正在做AI Agent、Copilot类应用或者准备把内部工具“Agent化”的开发者来说这篇文章应该能提供一套可以直接抄作业的骨架。无论你用的是现成的Agent框架还是想从零手写一套这套思路都能兼容。1. 整体设计与思路拆解1.1 为什么单独抽出一层“技能”这么重要在刚开始做智能体项目时很多人会落入一个陷阱把所有的工具函数都堆在代码里让大模型通过“系统提示词一堆函数定义”去选择调用。这种方式在Demo阶段完全够用API也就十个以内模型往往都能选对。但一旦进入真实业务API数量膨胀到几十甚至上百个麻烦就来了。模型面对一长串函数定义开始出现“选择困难症”要么选错工具要么参数填得驴唇不对马嘴。更麻烦的是业务方会不断提出新需求比如“新加一个查询工单的接口”如果每次新增API都要工程师手动修改系统提示词、重写函数描述维护成本会呈指数级上升。我设计agent-skills的核心出发点就是把“工具调用”这一层抽象为“技能层”。一个技能不再是一个孤立的函数而是一个自包含的模块它拥有独立的描述与命名空间让模型能理解“这个技能是干什么的”明确的结构化输入输出定义而不是贴着业务Schema裸奔可配置的准入条件和上下文依赖避免在错误场景下被误调用独立的测试与评估入口方便我们规模化地验证技能质量。架设这一层之后Agent的主逻辑就变成了理解用户意图 → 检索候选技能 → 编排技能序列 → 执行并反馈。新增能力时只需要新增一个技能包而不用去改动Agent的主流程代码。整个系统从“改代码才能加功能”变成了“加配置就能加技能”。1.2 技能层 vs 工具层差的不是名字很多刚接触这个概念的开发者会问技能层和工具层到底有什么区别在我看来工具是技能的原子子集。工具更偏向于“单一动作的执行器”例如“发送HTTP请求”是一个工具“查天气API”是另一个工具。而技能则更偏向于“面向目标的任务单元”。举个例子。一个天气机器人底层工具只有两个get_weather_by_city和get_air_quality_by_city。这两个工具可以分别被调用但如果我们定义了一个技能全面天气咨询它可以编排为“先根据城市取天气再根据城市取空气质量最后合并输出一份报告”。对Agent来说它面对的候选对象就是这个技能而不是两个孤立的API。表面上看是多包了一层实际上解决了三个关键问题降低模型决策复杂度模型不需要在一堆函数签名里组合出任务流只需要选中现成的技能提升结果稳定性任务编排逻辑被写死在技能内部不受模型“临场发挥”的影响输出格式更可控沉淀可复用的经验同一个业务领域频繁使用的动作组合可以被沉淀成一个技能供不同Agent复用不用每次从头定义。所以我的建议是不要因为这是一个“Agent项目”就直接把所有工具交给模型。先梳理业务把高频出现的“动作组合”提炼成技能再进入开发后边会省掉非常多的调试时间。1.3 领域隔离与动态扩展思路既然技能是面向任务的组织单元那么它天然应该具备领域属性。我在设计agent-skills的模块划分时遵循了“三明治”式分层底层是基础技能库跨领域通用能力如“获取当前时间”、“搜索网页”、“生成摘要”中间层是领域技能包针对具体业务域的技能集合如“客户工单处理包”、“订单查询包”上层是场景流针对特定用户场景预置的技能编排链如“售后退款处理流”。这种划分带来的直接好处是团队内部可以按领域分工维护各自的技能包。做售后的同事只维护“售后技能包”做推荐的同事只维护“推荐技能包”互不干扰。同时为了让技能体系具备动态扩展能力我在运行时引入了“技能清单”概念。系统启动时扫描所有已注册的技能包构建一份统一索引而不是硬编码在代码里。这样发布新技能时不需要重启Agent服务只需要把新的技能包注册进动态目录即可。对于一套要长期迭代的Agent系统这个“可插拔”特性值回票价。2. 技能定义与核心细节解析2.1 技能描述写给模型看的“说明书”技能定义的第一件事是写清楚“这个技能是干嘛的”。很多初版技能定义只有一句话描述看似够了但实际召回效果很差。原因在于大模型语义检索依赖的是“描述与用户意图之间的语义相似度”描述写得越模糊召回就越飘。我之前的一个教训是某个技能叫做“查询账户状态”描述是“用于查询用户的账户情况”。听起来没问题但真实用户问法是“帮我看看我账号是不是被锁了”“为什么我登录不了”“我的权限还在吗”。这三个问法跟“查询用户的账户情况”的语义匹配度都很低结果模型要么召回了别的技能要么干脆不调用硬答。后来我把描述改写成了带有“意图触发词”的版本比如当用户询问账户是否正常、是否被锁定、登录失败原因、权限到期时间、账号异常状态等问题时使用此技能。能返回用户账户的当前状态、限制项以及剩余有效期。改完之后召回率提升非常明显。所以我把“写技能描述”当成一项正经事来做而不是顺手填几个字。描述里至少要包含“触发场景用户常见问法技能能返回什么”这三要素缺一不可。另外我还习惯为每个技能设置别名Alias。例如技能“账户状态查询”别名可以加上“账号锁定查询”、“登录问题排查”、“账户权限校验”。这个设计是为了弥补向量召回对“不常见表述”不敏感的缺陷给模型提供额外线索。2.2 参数的严格Schema解决“填不对参”的良药技能执行是否成功很大程度上取决于入参是否可靠。我在项目早期吃过亏技能定义里只写了参数名没有写类型、格式、取值范围结果模型时常把“用户姓名”填到“用户ID”字段里接口返回错误后Agent还一脸无辜地尝试重试。后来所有技能的参数定义都强制走JSON Schema而且绝不偷懒。一个完整Schema应该包括type字符串、数字、数组、对象等required必填项列表properties中的每个字段都带description描述“这个参数是什么含义、什么格式、从哪里获取”可选的enum枚举值防止模型填入不在范围内的值additionalProperties: false防止模型添加预期之外的键。举例说明原本某个技能的参数写的是{ type: object, properties: { user_id: { type: string }, start_date: { type: string }, end_date: { type: string } } }问题显而易见任何字符串都合法模型自由发挥的空间太大。后来改为{ type: object, required: [user_id], properties: { user_id: { type: string, pattern: ^U\\d{6}$, description: 用户编号以U开头后跟6位数字 }, start_date: { type: string, format: date, description: 查询起始日格式YYYY-MM-DD }, end_date: { type: string, format: date, description: 查询结束日格式YYYY-MM-DD不早于start_date } }, additionalProperties: false }加上格式约束后模型“胡填”的概率明显下降。特别是pattern和enum这种硬约束比任何Prompt里的“请仔细填写参数”都管用。给模型写约束时能硬编码校验的绝不放任自由发挥这是我在参数设计上学到最深刻的一条经验。2.3 技能的原子性与组合性权衡到底一个技能应该多大这是一个持续困扰设计者的问题。技能拆得太细数量爆炸模型选择困难依旧存在技能拆得太粗灵活性和复用性大打折扣用户换个问法就不知道该走哪条技能路径。我采用的判断标准是“能否用一句话说清该技能的职责边界”。如果一个技能需要你用三句话以上才能解释清楚边界那说明它太胖了应该拆如果一个技能涉及的两个操作经常被独立调用且中间存在其他分支那它也应当拆成两个独立技能。举个真实例子。在早期版本中我把“订单查询”做成一个技能同时支持查询“订单详情”、“物流信息”、“退款进度”。设计时觉得“反正都是订单域统一入口挺好”。后来模型经常把所有意图都往这个技能上引导致一个问题用户问“退款到账没有”模型调用了“订单查询”技能返回了订单状态但根本不含退款进度字段还得二次调用其他工具。问题出在技能内部存在多个互斥的业务分支。最后我把这个技能拆分成了订单基础信息查询;订单物流跟踪查询;订单退款进度查询;每个技能对应独立的接口和返回结构参数也更聚焦。拆分之后模型的选择准确度直线上升。所以对于技能粒度我宁可偏细也不偏粗。再说由于技能层支持编排细粒度技能可以通过组合去覆盖复杂场景并不会损失上层能力。2.4 技能状态与上下文依赖技能不是永远可用的它在不同场景下应当具备不同的“可调用性”。如果系统中有权限体系这一点就尤为重要。例如“查看他人工单”这个技能必须校验调用者身份又如“创建企业订单”这个技能只有具备特定角色的人才允许调用。我在技能定义里增加了一个preconditions字段用于描述“该技能在什么条件下才可被调用”。这不是Prompt层面的口头约束而是硬性的执行前检查。Agent在执行技能前会先调用一个校验器评估前置条件校验不过则直接返回“技能不可用”及原因而不是让模型强行执行后收到报错。上下文依赖同理。某些技能的入参需要依赖对话状态中的实体信息例如用户在前几轮提到了“订单号”后续轮次的Agent才能调用订单类技能。这种跨轮信息传递需要在技能层做“参数注入”而非每次让模型从头理解一遍对话历史。我在技能调用链中增加了一个“实体槽位”模块负责在对话上下文中提取并缓存关键实体当技能声明需要某个槽位值时执行引擎自动从槽位中取值补足模型可能遗漏的参数。3. 技能注册、调用与编排的实现逻辑3.1 技能注册与动态发现机制维护一套可扩展的技能体系第一步是把“注册”和“使用”解耦。我在项目中为每个技能定义了一个独立的注册入口注册内容不仅包括函数实现还包括那个“写给模型看的描述”、参数Schema、前置条件和示例。Agent运行时只感知技能列表不感知具体注册过程。实际代码设计大致如下class SkillRegistry: def __init__(self): self._skills {} self._descriptions [] def register(self, skill): self._skills[skill.name] skill self._descriptions.append({ name: skill.name, description: skill.description, aliases: skill.aliases, parameters: skill.parameters_schema }) def get_skill(self, name): return self._skills.get(name) def get_descriptions(self): return self._descriptions这个注册表不需要使用数据库进程内存字典即可。复杂应用里可以把它做成独立服务但初创阶段用内存实现启动时扫描技能包目录自动完成注册简单又够用。关于技能包的组织方式我采用的是目录即模块的方式skills/ ├── base/ │ ├── time_skill.py │ └── web_search_skill.py ├── order/ │ ├── order_query_skill.py │ └── logistics_query_skill.py └── customer_service/ └── account_status_skill.py运行时遍历目录发现技能类自动实例化并注册。这套机制带来的最大好处是新增技能不再需要修改任何Agent主流程代码只要把技能文件放进目录重启或动态热加载后即可生效。3.2 技能触发从“让模型选”到“检索精排”最初我实现技能选择时走的是最朴素的路子——把全部技能描述拼接进系统Prompt让大模型自己选。这个方案在技能少于20个时表现尚可但一旦超过这个数量模型注意力会被稀释误选率明显上升。后来我改成了两段式召回召回阶段基于用户输入对技能描述做向量相似度检索取出Top-K候选技能K一般取5到8精排阶段把这Top-K候选技能的完整描述包括参数Schema喂给模型让模型从中选择匹配的技能并填充参数。这个改动非常有效。对模型来说从8个选项里选一个跟从50个选项里选一个决策难度完全不在一个量级。而且因为召回阶段是纯检索不含LLM调用所以整体耗时还可以接受。检索精度则需要持续优化最直接的手段是“丰富触发词”和“定期根据失败case扩展描述”。我不建议在召回阶段只用大模型“猜”因为吞吐和成本都扛不住。混合使用检索召回模型精排才是当前工程上靠谱的组合。如果你用的框架自带Agent工具选择机制也可以沿用同样的思路去裁剪候选集。3.3 多技能编排从单步执行到任务流单技能调用是Agent的基础能力但真实业务往往需要多个技能接力完成。比如用户问“我的订单显示已发货但我想改收货地址”这至少涉及“订单查询技能”和“地址修改技能”两个动作。如果要做得更完善中间还需要校验订单状态是否允许修改。我处理多技能编排的方式是由执行引擎层面支持“链式调用”模式而不是在单个技能内部硬编码Next节点。也就是让模型在一次执行中输出一个有序的技能调用序列每个技能调用的输出作为下一个技能的上下文输入。这个链式调用可以通过一个简单的“执行计划”数据结构来实现class SkillInvocationPlan: def __init__(self): self.steps [] self.context {} def add_step(self, skill_name, parameters): self.steps.append((skill_name, parameters)) def execute(self): for idx, (skill_name, parameters) in enumerate(self.steps): skill registry.get_skill(skill_name) # 支持步骤间上下文传递比如前一步输出的字段可注入后一步参数 resolved_params self._resolve_parameters(parameters) result skill.run(resolved_params, self.context) self.context[fstep_{idx}_output] result return self.context链式编排的难点不在于代码怎么写而在于如何控制“编排质量”。如果模型在一开始就规划错了步骤序列后面每一步都会错。为此我做了一个预防手段在执行完第一步后增加一个“结果校验节点”对当前步骤的输出做一个轻量级验证确认它符合预期后再执行下一步。比如订单查询的结果中不存在订单号那“修改地址”技能就不应继续执行而应回到“追问用户获取正确订单号”的流程。3.4 技能运行的反馈闭环技能运行不是“调完拉倒”它需要把执行状态、报错信息和中间结果写回轨迹。我在执行引擎里统一记录三类信息调用元数据技能名、入参、出参、耗时、状态码业务反馈技能返回给用户的话术文本以及给主Agent使用的结构化结果异常路径技能失败时产生的错误码、可读错误信息、以及是否可重试。这套闭环的价值在调试阶段非常巨大。没有调用轨迹时排查一个问题需要到处打日志、翻会话记录有了统一反馈直接在可视化面板里看技能调用的每一步一目了然。我甚至会把“反馈是否可用”作为技能验收的一个标准一个技能如果无法清晰反馈业务错误比如“查询失败”它就不合格。4. 实操过程从零搭建一套可选可用的技能库4.1 明确业务场景与技能边界动手写代码前先做一步“业务场景盘点”。找一个真实用户在高频使用Agent完成的任务把所有涉及的动作穷举出来。我拿“企业客服助手”来举例。业务盘点后归纳出高频任务大概有账户查询、订单查询、退货申请、发票申请、物流跟踪、优惠券查询。每个高频任务对应一到两个技能。这个阶段一定要克制“技术洁癖”不要一上来就设计超泛化的抽象技能。最好针对每一个业务需求直接设计对应的技能命名也直接用业务语言比如“查询订单物流”就不要抽象成“查询服务”。业务命名能最大限度地减少模型在技能选择时的语义漂移。然后把这些技能排一下优先级。先实现最高频、最刚需的5个技能跑通整个链路后再扩展。这个“最小可用技能集”策略能让你在早期就验证Agent的整体体验而不是花了一个月把50个技能做完后才发现问题。4.2 技能实现的统一基类与接口为了实现统一的注册表管理模式所有技能类应继承同一个抽象基类。基类负责公共逻辑如通用参数校验、调用轨迹记录子类只关心自己的核心业务。下面是我常用的接口抽象示例class BaseSkill: name description aliases [] parameters_schema {} preconditions [] def validate_parameters(self, params): # 根据JSON Schema校验参数统一返回校验错误详情 pass def check_preconditions(self, context): # 根据context判断技能是否可执行 pass def run(self, params, context): # 核心业务逻辑 raise NotImplementedError子类实现run即可。例如“订单查询技能”的run方法内部调用订单服务的API解析结果并返回给Agent。而“物流跟踪技能”的run内部则调用物流开放接口可能需要额外处理时间格式化之类的转换逻辑。我不建议把事情搞得太“花活”。Agent技能层优先保证职责单一、接口统一、参数明确至于泛化能力、层级继承、多态设计这些在技能数量达到一定程度之前都是过度设计。4.3 参数注入与上下文槽位设计实例看一个具体例子理解参数注入如何避免模型“乱猜”。某客服Agent的对话场景用户我上周买的那双鞋到现在没发货帮我看看啥情况。这是一个典型的多轮对话场景。“上周买的”没有明确给出订单号模型直接调用“订单查询技能”时必然填不出order_id。如果技能Schema里order_id是必填项执行会失败。我在上下文槽位中预置了诸如“最近订单缓存”这样的槽位。执行引擎在调用“订单查询技能”前先尝试从槽位中取用户的最近订单对象。若能取到就自动注入order_id参数若取不到才要求模型主动追问。这种方式显著提升了多轮对话场景下的成功率。技能只管执行它不关心参数是从对话里抽取的还是从槽位里补全的只需要在入参阶段拿到一个完整且合法的参数对象。这也是我把“上下文管理”单独拎出来的原因它不应该混在技能实现的代码里。4.4 技能评估构建一套可回归的测试集技能系统是可以被量化评估的这一点很多人忽略。我在项目里维护了一套“用户意图-技能期望”测试集每一条测试用例包含模拟用户输入尽量覆盖各种说法正式、口语、省略期望调用的技能名称期望填充的关键参数期望返回中的关键字段。每次更新技能描述或改动召回逻辑后我会整体跑一遍测试集比较“实际调用技能”和“期望调用技能”的匹配率。这个匹配率是技能体系健康度的核心指标。如果变化超过阈值说明这次改动可能引入了回归需要复查。测试集的编写质量直接决定评估的可靠性。我在积累测试用例时有一条原则从线上失败的case中补充用例而不是自己凭空编造。因为凭空想到的用户输入往往还是“教科书式”的真实用户表达千奇百怪这才是召回率的大敌。5. 常见问题与排查技巧实录5.1 模型总是选错技能这是出现频率最高的问题。我排查时会按以下顺序逐项检查看技能描述是否精确描述里是否包含了用户可能的问法没有触发词就可能召回不到。看候选技能之间边界是否清晰如果“账户信息查询”和“账户安全状态查询”两个技能描述高度重叠模型混淆是必然的。这时候要么收敛描述要么考虑合并技能。看用户输入是否真的包含意图如果用户只是说“我账号出问题了”其实这个表述本身还是模糊的模型无法判断该调哪个。这种情况下应当先引导澄清而不是强行调用技能。另外我强烈建议对每次“选错技能”的case做线上日志留痕。只有看到错误的完整上下文才能找到描述的漏洞。空泛归因于“模型能力不够”往往是无解的但修正描述是立竿见影的。5.2 技能参数频繁填错或缺失原因大概率有两种Schema约束不足或者上下文实体抽取不到位。如果是前者就按照之前提到的方式给参数加pattern、enum、required等硬约束。如果是后者优先做槽位补全保证对话历史中的实体能自动注入而不是完全依赖模型从上下文推理。还有一个很实用的技巧如果某个参数不是关键路径尽量把它设成非必填。必填参数越多模型“编造”的概率越大。让智能体在参数不全时主动追问一句比让它猜一个错参数然后报错要好得多。真实业务里“问我也不丢人”是一个重要设计原则。5.3 多技能编排时执行中断链式调用最怕的就是第一步执行成功、第二步报错导致整个流程半途而废。这个问题的根源往往是“步骤间数据契约不一致”。比如第一步返回订单的基本信息第二步要使用订单中的“收货地址”但第一步结果里其实没有这个字段。我解决这个问题的方式是在编排执行前先做数据契约检查。也就是每个技能声明它的输出字段集编排引擎在发起链式调用前检查“下一步技能所需的输入前一步是否真的会产出”。如果不产出就提前知晓而不是等执行失败后才看到错误。另外对于“失败后是否重试”也要有清晰策略。临时性错误如网络超时可以重试一两次而业务性错误如订单不存在重试毫无意义应当立即停下并返回可读错误信息。5.4 技能响应格式混乱主流程无法稳定解析技能的输出最终要喂回给模型让模型生成回复。如果技能输出格式不统一模型解析就会出现随机性波动。比如有时候返回的是纯文本有时候是JSON字符串有时候还夹带了一大堆日志信息模型处理起来会很痛苦。解决方案是规定所有技能的返回值统一为结构化对象{ status: success | failed, data: { }, message: 可读的信息 }无论内部逻辑怎样最终返回必须经过这个“响应标准化”包装。我在基类里就强制了这一点技能子类只负责生成data内容外层统一组装 status 和 message。这个简单的约定对后面的可观测性建设和测试回归都帮助极大。最后的经验小结我把这套技能体系跑通之后最大的感触是Agent能力的上限取决于技能定义的质量而不是模型参数的大小。这套架构维度上可以从“客户端工具调用”一直延伸到“企业级工作流编排”落地的核心就是善待技能描述、严格定义参数、统一反馈路径、沉淀可回归测试集。很多人觉得“Agent很玄幻”但真正把它落到工程上你会发现它无非是把“工程纪律”搬到“语义决策”中而已。如果你正在搭自己的Agent别急着追新框架新模型先把技能这层基础打牢。几个技能跑顺之后再上复杂编排你会有不一样的体会。