ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型Agent技能库设计与编排实践:从提示词到显式技能注册

大模型Agent技能库设计与编排实践:从提示词到显式技能注册

如果你做过基于大模型的Agent类项目,大概会遇到一个很常见的尴尬:一开始把工具调用、任务流程、上下文处理全部塞进系统提示词里,看起来"什么都会一点",可真的跑起来以后,要么指令互相打架,要么新增一个能力就得把整段提示词重写一遍。我上个月刚把一个内部知识库Agent从"灵光一现的提示词"重构为"显式技能库",也就是我在标题里写的 agent-skills 这套实践。这篇文章不是给你一个开箱即用的框架,而是把我在做技能抽象、注册、编排和调试过程中踩过的坑,以及为什么这么设计,全部摊开来讲明白。如果你正准备给Agent加能力,或者已经因为技能之间互相耦合而开始头疼,可以照着这个思路重新捋一遍。

1. 从"什么都会一点"到"专精分工":技能层为什么值得单独做

最开始做Agent Demo的时候,我的方案很朴素:把所有能力都写进系统提示词里。角色设定一段,工具说明一段,任务流程一段。听起来很直接对吧?效果其实也还行,因为Demo里只有两三个技能。等真正把这个思路往生产环境推,问题就开始成串地冒出来了。

首先,提示词是线性文本,所有技能描述被拼接在一起,模型注意力会被严重稀释。我实际测过,技能数量从4个涨到12个之后,选错工具的比率从不到3%跳到了15%上下。为什么会这样?因为技能描述之间有语义重叠。比如"查询用户资料"和"获取用户联系方式",在模型眼里几乎是一回事,它经常挑一个看起来顺眼、但实际不适用的工具。模型不是不懂你的任务,而是你的技能清单让它"选择困难"。

其次是变更成本。每加一个技能,都要重新刷一遍系统提示词,还要担心新增的描述把之前稳定的能力挤掉。加上提示词长度本身有限制,总有一天会装不下。当时我更崩溃的是另一个问题:任何一个技能出现异常,整个系统提示词链路都会受影响,日志里根本分不清是"模型选错了技能"还是"技能本身执行出错"。所有的错误都在一口锅里炖,排查起来极度痛苦。

到了这个阶段,我意识到问题出在架构上,而不是某个技能实现上。Prompt里塞的是"能力的描述",但不是"能力本身",把描述当能力用,迟早会出问题。于是我把整套架构切成两层:一层是"决策层",也就是模型本身,只负责根据对话上下文选择和编排技能;另一层是"执行层",由一套独立的技能注册中心管理具体能力。这就是 agent-skills 的起点。决策层不再关心技能内部长什么样,执行层也不关心模型用的是什么、怎么被调用。两者之间只通过一套固定的协议通信,互不越界。

这个拆分的收益几乎立竿见影:

  • 新增技能时不需要改动提示词,注册一份元数据即可;
  • 模型看到的是精简后的技能清单,而不是一大段混杂的描述;
  • 每个技能可以单独测试、单独加日志、单独灰度;
  • 技能执行失败时不会污染决策层上下文,也不会让整个对话流程雪崩。

后面所有的工作,包括注册中心设计、编排模式、调试手段、评测方法,都是在这条边界上展开的。

1.1 技能与大模型原生工具调用机制的区别

很多人第一反应会问:模型不是本来就有工具调用机制吗?再自己造一个注册中心,是不是多此一举?

我的理解是,模型原生的"工具调用"解决的是"把请求转成结构化参数"这一层问题。它约定了输入输出格式,但完全没有规定"技能应该怎么建模"。同样一个模型,你可以把工具定义成一段本地函数,也可以把它定义成另一个Agent、一个HTTP接口、一条数据库查询。原生机制只是一个管道,管道里跑的东西需要你自己设计。

在我现在的实践里,技能被抽象成"输入-处理-输出"的通用计算单元,而不是绑定某一种具体实现。这样做的好处是,让模型学习的是"哪个技能名对应哪个场景"这种稳定映射关系,而不是每次都要解析五花八门的工具签名。

技能类型底层实现暴露给模型的形式
检索类向量库召回加重排统一skill_name + query
操作类内部API调用统一skill_name + params
计算类本地Python函数统一skill_name + inputs
流程类多步骤子Agent统一skill_name + 任务描述

拿一个实际场景举例。某内部知识库Agent里有一个"查员工信息"的技能,底层调用的是人事系统接口;还有一个"查组织架构"的技能,底层调用的是同一个系统、同一个接口的不同端点。如果让模型直接面对这两个接口的原始参数,它经常会混淆字段;但把它们包成两个语义清晰的技能之后,模型只需要从"查员工信息"和"查组织架构"里二选一,输入参数也都统一成"员工姓名或工号",准确率一下子提上来了。

2. 技能注册中心的抽象设计:接口、元数据与运行时解耦

技能注册中心是整个 agent-skills 体系的中枢。它的作用不是"存技能",而是把技能的元信息和实际执行分开,让决策层只跟元信息打交道,执行层只跟运行逻辑打交道。

2.1 技能注册表的数据结构

每个技能在注册中心里有一条类似下面这样的元数据记录:

{ "skill_name": "query_employee_info", "description": "根据员工姓名或工号查询员工基本信息,适用于入职时间、职位、部门等信息的查询。当用户询问某个人的联系方式或所属团队时使用。", "parameters": { "type": "object", "properties": { "employee_id": { "type": "string", "description": "员工工号,如果用户提供了工号则优先使用工号" }, "employee_name": { "type": "string", "description": "员工姓名,如果没有工号时使用姓名查询" } }, "required": ["employee_name"] }, "executor": "internal_api_employee_reader", "tags": ["employee", "info", "contact"], "permission": "read:employee", "timeout_ms": 3000, "version": "1.2.0" }

这套字段里,最需要花心思的是description和parameters,因为它们直接决定模型能不能正确调用。后面踩坑章节里我会细说,这里先强调一点:description不是写给人类看的注释,而是写给模型看的"调用说明书"。它必须说明什么时候用、什么时候不要用、参数怎么填、返回什么。

2.2 执行层与注册层的解耦

注册中心只保存元数据和一个执行器引用,不直接嵌入业务逻辑。运行时通过一个分发器(dispatcher)把请求打到具体的执行器上。这个设计最大的价值是支持"插件式注册"——新技能写好后,只要实现统一接口并注册进去,就能被模型调用,完全不用改Agent主流程。

我用的执行器接口比较简单,类似于这样:

class BaseSkillExecutor: async def execute(self, params: dict, context: dict) -> dict: """ 统一执行入口。 params: 模型按技能参数schema生成的结构化参数 context: 运行时上下文,包含会话ID、用户ID、权限信息等 返回: 结构化结果,必须为可JSON序列化的dict """ raise NotImplementedError

每个技能必须是一个可独立运行的模块。我见过一些项目把技能实现直接在Agent主文件里一手写完,这就又回到"一口锅里炖"的局面了。拆成独立模块之后,好处是可以在不启动完整Agent的情况下单独调试某个技能,跑一个"技能自测"脚本直接喂参数进去看结果。

2.3 技能描述给模型看,还是给人看?

这个问题经常被忽略。在注册中心里,技能描述同时被两个"角色"消费:模型和开发人员。很多人写描述时,默认读者是"未来的维护者",所以写得又长又泛,结果模型根本不知道什么时候该用这个技能。

我总结出一个相对好用的描述模板:

当用户想要[场景A]或[场景B]时,使用此技能。不需要处理[场景C]。参数[名称]必须来自[来源],如果用户未提供,你可以通过[其他技能]获取。返回结果为[格式说明],其中[关键字段]的含义是[说明]。

这样写有三个好处:第一,让模型快速建立"场景→技能"的匹配关系;第二,通过"不需要处理"的排除项降低误用率;第三,明确参数来源和返回值语义,模型就能在多个技能的衔接中做出合理判断。

3. 编排模式:顺序、条件与并行技能如何组织而不失控

注册中心解决了"技能怎么被找到"的问题,但一个真实任务往往需要多个技能接力完成。比如用户问"帮我查一下A同事所在的团队最近上线了什么项目",这个任务至少涉及三个技能:查同事信息、查团队列表、查项目动态。技能之间怎么串起来,就是编排层要做的事。

3.1 顺序编排:上一个技能的输出是下一个技能的输入

最基础的编排就是链式调用。模型先调用技能1,拿到结果后,把技能1的输出作为参数的一部分,再调用技能2。这个模式看起来简单,但有一点需要注意:不能让模型"临场决定"把上个技能的完整原始返回直接塞给下个技能,那样很容易因为字段格式不一致导致失败。

我的做法是,在技能返回结构里加入一个轻量级的extracted_data字段,专门放"可以被下一个技能直接使用的核心数据"。比如"查同事信息"返回的完整JSON可能有几十个字段,但下一个技能真正需要的可能只有department_id。在技能内部就把这个字段提取好,编排层传递时只传extracted_data,既可以减少模型处理负担,也能屏蔽底座接口波动。

3.2 条件分支与循环:让模型做"路由"而不是"思考"

更复杂的场景需要条件分支。比如"如果查不到员工信息,就尝试用别名查询"、"如果团队没有项目动态,就返回空结果而不是报错"。这类分支如果全部要求模型自己推理出全套流程,通常会变成一场灾难,因为模型在长流程中容易"跳步"。

我在实践中的策略是:把分支逻辑写成显式的规则,让模型只负责"在当前节点选择下一跳"。也就是说,模型做一个决策,执行层就检查一个条件,条件满足就继续,不满足就按预设路径降级。这样可以避免模型一口气把五六步全部规划完,然后在第三步就出错的情况。

这里给一个我用过的简单决策矩阵:

条件动作降级路径
技能A执行超时重试一次如果重试仍失败,转人工提示
技能B返回空结果触发技能C进行补充检索技能C也无结果时,返回"未找到"
技能D需要权限但用户无权限跳过D,返回部分结果记录日志并提示用户权限不足

这种"每步一判"的方式,跟我之前让模型"一口气规划到底"相比,成功率显著更高。毕竟模型在单步决策上的可靠性远高于多步规划。

3.3 并行技能:共享状态是最容易埋雷的地方

有些任务里,多个技能之间没有依赖关系,可以并行执行以缩短总耗时。比如用户想同时知道天气和会议室状态,这两个技能互不依赖。但并行带来的一个坑是共享状态冲突。

我踩过一次很典型的坑:两个并行技能同时写入同一个会话上下文变量,导致其中一个技能读到的数据被另一个技能覆盖。后来我定了一条铁律:并行技能之间禁止共享可变状态。如果某个技能确实需要读取另一个技能的运行结果,那就把它们改成顺序编排,或者在执行前把所需数据通过参数显式传进去。

并行执行时,每个技能拿到的context必须是同一个会话共享上下文的一个只读快照,写操作要么不写,要么只写进自己的局部命名空间,最后再由编排层统一合并。

3.4 编排层的超时与降级

任何一个技能都可能慢、可能挂、可能返回不合法数据。如果编排层没有兜底机制,一个技能挂了,整个Agent调用链都会卡住。我现在的做法是给每个技能设置独立的超时阈值,并且在编排层设置总预算时间。技能超时后不阻塞主流程,而是生成一个"技能异常报告"返回给决策层,让模型决定是换一个技能还是直接给用户一个可接受的答复。

降级还有一个容易被忽视的点:降级动作本身也要有预算。如果降级链路又调用了三个技能,实际上是把超时问题放大了。我一般限制降级链路最多跳转两次,超过就返回固定的安抚提示。

4. 真实项目里的踩坑记录与调试方法论

这一节我把实际开发中遇到的最有价值的问题整理出来,每一个都是真金白银换来的经验。

4.1 技能描述写太长,模型反而不会用了

我最早给技能写描述,总想把所有细节都交代清楚,一段描述写到两百多字。结果模型经常在几个语义相近的技能之间犹豫,甚至有时干脆报"没有合适的工具"。后来我做了一个对照实验:同一个技能,把描述压缩到60字以内,并明确写清"使用条件"和"禁止使用条件",模型调用准确率从76%涨到了90%。

为什么会这样?因为模型在决策时,本质在做一次极度受限的文本匹配。描述越长,匹配的模糊空间越大;短描述反而让边界更清晰。所以我现在写描述的原则是:能用20个字说明白的场景,绝不用40个字。

4.2 参数Schema嵌套太深,模型生成的参数总是漏字段

这是另一个高频问题。技能A需要查项目列表,项目列表需要过滤条件,过滤条件里又要传排序规则和分页参数,于是我把parameters嵌套了三层。结果模型经常只生成第一层的字段,内层参数干脆丢掉了。

后来我把参数Schema全部拍平,能拆成多个独立技能就拆开,不能拆开的就把嵌套结构控制在一层。模型生成参数的可靠性提升非常明显。这里面有个规律:模型对"结构化参数补全"的能力远不如人类直觉想象的那么强,你的Schema每多一层,出错率大约翻一倍。这句话我是在项目里反复验证过的。

4.3 技能副作用与重试:不是所有技能都可以无脑重试

最初我给所有技能统一加了"失败重试一次"的逻辑,结果出过一次事。某个"发送通知"技能,因为下游接口超时导致第一次调用其实已经发出去了,但Agent这边收到的是超时错误,于是触发重试,结果用户收到了两条一模一样的通知。

从此之后,我把技能按"是否有副作用"分成了两类。查询类技能可以无脑重试;有副作用的技能(发消息、改状态、下单)默认禁止自动重试,只返回失败原因,由决策层决定要不要换一个方式或请求用户确认。这个分类建议直接写进技能元数据里,像side_effect: true/false这样的字段,重试逻辑在编排层统一读取。

4.4 调试方法论:先看"模型选没选对",再看"技能跑没跑对"

踩过这么多坑之后,我总结出了一套排查套路。当Agent回复有问题时,先不要急着看技能代码,而是先看日志里模型这一步的决策记录。

完整的排查链路是这样的:

  1. 确认模型选择的是哪个技能、输入参数是什么;
  2. 如果选择错误,去对比技能描述和用户问题的语义差异;
  3. 如果选择正确,再看执行器是否正常返回、是否有异常日志;
  4. 异常日志里重点看超时、参数校验失败、下游接口报错;
  5. 如果是下游接口问题,判断是重试可解,还是需要降级。

我在注册中心里给每个技能挂了两层日志:决策日志(模型选了什么技能、为什么选)和执行日志(参数、耗时、状态码、返回摘要)。并把它俩按request_id串联起来。这样每一次用户会话都可以从决策起点一路追到执行终点,排查效率高了很多。

另外还有一个细节:技能返回值一定要做"结构化归一"。不同技能内部用的数据源五花八门,但返回给决策层的格式必须统一,否则模型在编排时会因为"看不懂"某个返回结果而做出错误的下一个决策。

5. 技能评估与迭代:怎样让一套技能库越用越好用

技能库不是写完就完事的,它和代码库一样需要持续迭代。没有评估机制的技能库,最后一定会退化成"谁都不敢动的老代码"。

5.1 构造技能选择的评测集

我建了一个相对小但有效的评测集,大概三百条用户问题,覆盖了Agent的主要业务场景。每条评测数据包含三部分:用户原始输入、期望使用的技能名、期望的参数关键字段。每次对技能描述做改动,或者新增技能,我就在这个评测集上跑一遍,统计"技能选择准确率"和"参数正确率"。

这个评测集一开始是从历史对话日志里人工挑出来的,后来按场景类型做了补充,比如模糊提问、别名提问、跨技能提问。它不需要很大,但一定要覆盖关键边界。

5.2 覆盖率与误用率:光看准确率是骗自己的

评估技能选择时,只看"正确率"会掩盖很多问题。比如某个技能调用次数很少,即使选对了,对整体指标的贡献也不明显,但它在业务上很关键。所以我还会额外统计每个技能的覆盖率和误用率。

覆盖率:该场景出现时,技能是否被选择; 误用率:技能被选择时,是否其实不应该用它。

这两个指标结合起来,能发现一类很隐蔽的问题:某个技能描述写得太泛,导致它频繁被选中,但它真正合适的场景只占少数。这种技能就需要把描述收窄,或在描述里加更明确的"禁止使用"条件。

5.3 技能版本管理与灰度

技能升级和代码升级一样,需要有版本概念。我把技能元数据里的version字段作为硬性字段,每次改描述或改执行逻辑都必须递增版本号。同时,在Agent的配置层加了一个"技能灰度比例"的开关,可以控制某个新版本技能只对固定比例的流量生效。

灰色发布这一步特别重要。有一次我想把某个检索技能换一个重排模型,如果没有灰度,直接上线,可能会有相当比例的检索结果出现质量问题;通过灰度,我先让5%的流量走新版本,观察一天,再逐步放大。这个习惯帮我躲过了好几次隐性事故。

5.4 维护技能库的三条经验

最后分享三条我觉得最实在的维护经验。

第一,技能数量超过20个之后,每新增一个技能都必须回答一个问题:已有技能是否已经覆盖了这个场景?如果覆盖,就去优化原技能描述,而不是新增。技能越多,模型选择负担越大,边际成本并不低。

第二,定期做"技能调用审计"。我每周导一次上周的技能调用占比,发现那些连续数周调用率为零的技能,要么是有Bug没人修,要么是描述跟实际业务脱节了。对零调用技能,我会选择下线或重构,而不是让它继续占着注册中心的位置。

第三,技能内部不要写"一次性业务代码"。如果一个技能只服务某一个特定问题,它就是伪技能,早晚会被更通用的技能替代。真正值得注册成技能的东西,必须满足"可复用"和"场景明确"两个条件。

收尾:一点个人的体会

在做完这套技能库重构之后,我最大的体会是:Agent的复杂度不会因为你把东西都塞进提示词而消失,它只是被你藏起来了,然后在你最想不到的地方反咬一口。把技能显式地建模、注册、编排、评估,本质上是在把"模糊的复杂度"变成"结构化的复杂度"。后者虽然看起来多了很多代码和流程,但它可测试、可观测、可追溯,这才是生产环境的真正底线。

如果你也在做类似的Agent项目,我建议不要一上来就做一个大的Agent框架,而是先用一个技能注册中心加两个技能,把调用链路跑通,再慢慢往上面加编排、加评估、加灰度。这条路我走下来整体的感受是:前面慢一点,后面省出来的时间远超预期。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进