攒了上百个 Skill,Agent 知道怎么用吗?拆解多 Skill 场景下的分层加载机制
这几年只要和 Agent 沾边,几乎都会遇到同一个场景:Skill 越攒越多,几十个还算能管,上百个之后,模型反而开始“选择困难”。用户问一句“我上周买的键盘还能退吗”,Agent 明明有十个相关的技能可用,最后却调了一个查物流的,或者在一个退换货的入口上反复横跳。问题不在大模型推理能力不够,而在我们对技能的“喂法”有问题。
我自己的经验是:多 Skill 场景真正要解决的,不是“有没有技能”,而是“什么时候把哪个技能放进模型的注意力里”。全量塞进上下文,是对模型和成本的浪费;单靠模型自己挑,是对比例和概率的赌博。唯一比较稳的路线,是做一套分层加载机制:把技能从一个大仓库,拆成系统层、场景层、原子技能层,按需动态注入。这篇文章我就用实际项目的血泪换来的经验,拆一拆这套机制该怎么设计、怎么落地、怎么排查问题。
1. 上百个 Skill,Agent 面对的到底是“选择困难”还是“上下文爆炸”
1.1 先把账算清楚:全量注入的Token与延迟代价
很多人一开始做多 Skill Agent 时,想到的最简单方案就是把所有技能描述拼进系统提示词里,让模型自己选。这个方案在技能少于 10 个时确实可行,但技能上百个之后,第一个扛不住的是 Token 预算。
我见过一份比较典型的技能描述文件,每个 Skill 包含名称、描述、触发条件、参数说明、示例用法,平均大约 300 到 500 token。100 个技能,光描述部分就是 3 万到 5 万 token。这还只是基础消耗,实际请求中还有对话历史、用户输入、中间推理结果,全部加起来很容易把上下文窗口顶满。
Token 变多不只是费用变高那么简单,更重要的是延迟。大模型的推理耗时和输入长度基本呈正相关,输入从 2k 涨到 30k,首 token 响应时间会拉长不少。用户问一句普通的售后问题,Agent 背后却要“通读”上百个技能手册才能回答,体验上就是慢半拍、慢一拍、慢到用户等不及。
当年我改造的第一个项目就是这么翻车的。一开始 20 个技能时全量注入还能忍受,后来技能涨到 80 多个,线上平均响应时间直接翻倍,还有不少请求因为超时被网关拦掉。做技能管理的第一课就是:不是所有技能都值得长期驻留在上下文中,这个账必须先算清楚。
1.2 真正麻烦的是“看起来都对”的同类技能
Token 问题还算可以量化,真正让人头疼的是相似技能互相干扰。
上百个技能里,必然会出现“查订单”“查物流”“查退款进度”“查售后审批状态”这种高度相近的能力。它们的名词可能相似,语义可能有重叠,触发条件也边界模糊。如果全部塞给模型让它在这些描述里自己拿主意,它很容易被描述文字的长度、措辞的相似度带偏。
我踩过最深的坑是:为了覆盖业务场景,往技能库里加了“物流时效查询”和“发货进度查询”两个技能。表面上看一个偏规则、一个偏实时状态,但用户嘴里问的可能是一模一样的话。结果就是模型大概率每次都选中描述写得更详细的那一个,不管用户到底问的是哪个。这不是模型笨,而是我们的描述和路由机制没有给模型足够清楚的边界。
真正的解法不是让模型“看一遍再做选择”,而是通过分层和检索,在技能进入模型视野之前,就先把最相关的少数几个挑出来。选择这件事,应该发生在上下文注入之前,而不是注入之后。
1.3 没有分层时,新增技能等于给旧技能“下毒”
技能库是动态生长的,这个月加一个“优惠券叠加计算”,下个月再加一个“满减活动推荐”。如果所有技能都压在同一个提示词里,新技能进入系统的那一刻,会影响所有旧技能的触发概率。特别是新技能描述里如果有大量和旧技能相似的关键词,旧技能被选中的概率会被明显稀释。
我在实际运营中体会特别深:全量注入的方式下,每新增一个技能,都必须重新回归一遍所有旧技能的核心用例。技能到 100 个以后,回归成本几乎每周都在涨,而且经常出现“这周修好了 A 场景,下周 B 场景又触发不对了”。这就是典型的没有分层导致的系统性脆弱。
分层加载解决的核心问题,就是把技能的“存放位置”和“注入时机”解耦。同类技能放在同一层,不同类技能互相不干扰;新增技能只影响它所在的那一小撮候选集,而不是全局概率池。这句话看着简单,真正做对需要一整套配套设计。
2. 多 Skill 分层加载的整体设计:仓库、索引、运行时三者分离
2.1 分层加载的本质是“就近获取,按需注入”
所谓分层加载,拆开来说就是三件事:技能仓库只负责存,索引层只负责找,运行时只负责把找到的技能注入到当前会话的上下文中。三者职责分离之后,技能数量的增长才不会直接拖垮响应速度。
用生活里的事来做类比:一个大型工具仓库,不会把电钻、螺丝刀、钳子全部摊在工作台上,而是先分类摆进货架,再按工单去取。工作台上永远只放当前任务需要的那几件工具。Agent 的技能管理也是同样的逻辑,工作台就是上下文窗口,货架就是技能仓库,取工具的人就是检索路由。缺了哪一环,都会积压成一团乱麻。
实际操作上,我把技能分成三层来管:系统技能层、场景工作流层、技能原子层。每一层在生命周期、注入频率、检索方式上都有完全不同的策略,下面逐个拆。
2.2 系统技能层:雷打不动的那一小撮
系统技能层是那些在任何一次会话里都可能用到的基础能力。比如身份认证、权限检查、通用参数校验、兜底话术生成、会话状态记录。这类技能的特点有两个:一是跨场景通用,二是本身数量很少。
我把系统技能层的目标控制在 5 到 10 个以内,并且每次请求都会直接注入到上下文中。它们不参与语义检索,也不需要动态加载,因为它们是整个 Agent 运行的底座。底座技能如果经常缺席,上层技能就算加载得再对,也会因为缺少权限或通用处理模块而出问题。
这里有一个经验原则:系统技能层的判断标准是“如果没有它,任何场景都会受影响”,而不是“这个技能好像挺重要的”。把所有看起来重要的技能都放进系统层,就会退化回全量注入的老路。系统层要克制,只放基础设施。
2.3 场景工作流层:跟着会话状态走
场景工作流层解决的是“当前用户处在什么业务阶段”的问题。同样一句“帮我查一下上次的订单”,在售前阶段和售后阶段,背后需要的技能组合是完全不同的。
这一层的设计我会配合会话状态管理一起做。比如判断当前会话处于支付流程中、售后流程中、还是普通咨询中,每个状态对应一组工作流技能。工作流技能不一定每个回合都用,但只要状态切到这个流程,它们就会被整体注入。
和原子技能相比,工作流技能粒度更粗,数量也更稳定。我一般控制在每个场景 10 个以内,全局 3 到 5 个主要场景,总共 30 到 50 个技能常驻在场景层。这里需要特别注意:场景层不是靠“用户说了关键词”来触发,而是靠会话级的状态机来维护,否则很容易和原子层的检索逻辑打架。
状态机的好处是让技能加载有“记忆”。用户上次停留在退款申请流程,下一次提问,系统会优先加载退款相关的技能组。这个机制让 Agent 看起来更像是“理解上下文”,而不是每次都从零开始猜用户要干什么。
2.4 技能原子层:交给语义检索动态决定
技能原子层是数量最庞大、增长速度最快的一层,那些零散的查询类技能、计算类技能、规则类技能都归在这里。这一层的加载方式不再是“注入”,而是“检索”。
每次收到用户输入,先做一次意图理解和语义召回,从原子技能层中挑出最相关的 3 到 5 个技能,再注入到上下文中。这个检索过程是分层加载机制里最核心的环节,我后面会在实操部分展开讲。
原子层技能的特点是颗粒度小,独立性强,彼此之间边界清晰。它们不依赖特定的会话状态,任何用户都可能在任何时候问任何一个细节。因为数量太大,绝不可能全量注入,所以检索的精度直接决定了 Agent 能不能用对技能。这个部分做得好,Agent 会显得非常聪明;做得不好,就是“工具箱里什么都有,但维修工每次拿错扳手”。
2.5 全量加载和分层加载的对比实拍
我自己做过一组比较简单的对照测试,同一个项目,技能总数 110 个,问题集 50 条,分别用全量注入和三层加载跑一遍,结果差异很大:
| 指标 | 全量注入 | 三层加载 |
|---|---|---|
| 平均每轮输入 Token | 约 45000 | 约 9000 |
| 平均响应时间 | 约 6 秒 | 约 2.5 秒 |
| 技能误用率 | 约 18% | 约 5% |
| 新增技能回归成本 | 全量回归 | 局部回归 |
Token 消耗和响应时间的大幅下降是预期内的,让我意外的是技能误用率也同步下降。原因其实也好理解,候选集合小了,模型做选择的难度就低了,就像在 5 个选项里做选择题,肯定比在 100 个选项里做要准得多。
这套对比做完之后,我对分层的信心就非常明确了。单纯说“需要分层”太空泛,真正有价值的是把每一层的边界和职责定清楚。接下来就到实操环节,说说具体怎么落地。
3. 从零搭一套分层加载机制:目录规范、双检索引擎、运行时路由
3.1 给每个 Skill 写一份“合格的身份证”
分层加载的第一步,不是写代码,而是先定技能描述的规范。检索效果好不好,一半取决于检索算法,一半取决于你给技能写的描述是否合格。
我的做法是给每个技能建立一个独立的目录,目录下包含一个元信息文件和一个执行逻辑文件。元信息文件用 YAML 写,这是整个分层机制的“身份证”。
技能目录结构大概长这样:
skills/ ├── system/ │ ├── auth_verify/ │ │ ├── skill.yaml │ │ └── executor.py │ └── common_validator/ ├── workflow/ │ ├── after_sale/ │ │ ├── workflow.yaml │ │ └── main.py │ └── order_payment/ └── atomic/ ├── query_order_status/ ├── query_refund_progress/ ├── calc_shipping_deadline/ └── ...其中skill.yaml是核心,我一般要求至少包含以下字段:
name: query_refund_progress description: 查询用户退款申请的当前进度状态,包括已提交、审核中、已打款、已拒绝。 trigger_conditions: - 用户询问退款到哪里了 - 用户询问退款什么时候到账 - 用户催退款 scene: after_sale keywords: [退款进度, 退款状态, 退款到账, 退钱进度] cost_level: low这里描述字段的写法特别关键。我给出一个反例和一个正例,对比看更直观:
| 类型 | 示例描述 | 问题分析 |
|---|---|---|
| 反例 | 负责处理与退款相关的所有事情 | 颗粒度太大,职责边界模糊,和“退款申请”“退款审核”等技能严重重叠 |
| 正例 | 查询用户退款申请的当前进度状态,包括已提交、审核中、已打款、已拒绝 | 动词“查询”+ 对象“退款申请进度”+ 行为边界“当前状态”,模型和检索器都能快速定位 |
写描述的时候有一个朴素的判断标准:如果让一个不了解业务的人只看描述,能不能准确判断这个技能该不该在某条问题下触发。如果描述里有大段的话术、营销词汇、无意义的修饰,统统删掉。检索器不是人,它只会老老实实做文本匹配,描述越规范,召回越精准。
3.2 双通道召回并打分:关键词兜底,语义找相似
原子技能层的检索,我不会只靠向量相似度一条路,而是采用“关键词召回 + 向量召回”双通道融合打分。原因在实战里很现实:向量检索擅长找语义相近的,但对精确数字、具体名词、专业术语往往不够敏感;关键词检索正好互补,能保证查询里出现的专有词不会被漏掉。
我用一组简单伪代码来表达整个路由核心逻辑:
def route_skills(user_query: str, scene: str, top_k: int = 3): # 1. 场景过滤:先用会话状态缩小候选集 scene_pool = skill_index[scene] # 2. 双通道召回 bm25_hits = bm25_search(user_query, scene_pool, top_k=20) vector_hits = vector_search(user_query, scene_pool, top_k=20) # 3. 融合打分,关键词命中加权 candidates = merge_rank(bm25_hits, vector_hits, keyword_weight=0.4, vector_weight=0.6) # 4. 阈值过滤 + 上限截断 final = [s for s in candidates if s.score >= 0.25][:top_k] return final关于 Query 改写这一步,我也在持续做。以前是拿原始用户输入直接检索,效果波动比较大,因为口语里经常有“那个”“东西”“咋样了”这类模糊指代。后来加了一个轻量级的改写层,把“那个上周买的键盘还能退吗”改写为“查询 退款申请 键盘 上周购买”,再去做召回,命中率明显提升。这个改写层可以用大模型做,也可以用规则加词典配合完成,关键是成本不能太高,不能让它变成一个新的延迟瓶颈。
打分权重这里,我给的 0.4 和 0.6 是调试出来的经验值,不一定通用。如果业务里专业名词特别多,可以适当提高关键词权重;如果业务里用户表达方式非常口语化、千奇百怪,则向量权重需要调高。建议做一个参数配置,而不是写死在代码里,方便后续灰度调整。
3.3 加载顺序与生命周期管理:缓存、TTL、预热
分层加载机制的运行过程,不能每次请求都重新做全链路检索。实际运行时我把加载顺序安排成下面这个流程,已经比较稳定:
1. 收到用户输入 2. 从会话上下文取出当前场景状态 3. 注入系统技能层(固定 5-8 个) 4. 按场景状态注入场景工作流技能(固定 8-12 个) 5. 解析用户输入,改写 Query 6. 在原子技能层做双通道检索,取 top 3-5 7. 合并三层技能,去重,拼接系统提示词 8. 交给大模型生成最终回答第 6 步的检索结果会做一层缓存。同一场景、相似 Query 的检索结果,在 5 到 10 分钟内直接复用,避免重复计算。我用的是一种带温度衰减的本地缓存,命中一次就刷新有效期,长时间没人触发就自动淘汰。这样在用户连续追问同一个问题时,响应速度可以更快,成本也更低。
另外还有一个预热机制值得说。我会把高频场景和热门技能在服务启动时预先加载到内存索引里,不要让第一个用户来的时候才慢慢建索引。特别是在技能数量上百以后,冷启动建索引可能要几百毫秒,预先把热技能 index 起来,能让线上请求稳定在一个更低延迟水平。
生命周期管理的关键指标是命中率。我每周都会看一次缓存命中率和检索 top 3 命中率。如果某个技能的缓存命中率特别高,说明它是高频技能,可以考虑考虑提升到场景工作流层;如果某个技能长期检索命中率为零,就要考虑是不是该下线,或者描述是否已经和用户需求脱节。技能库也需要“新陈代谢”,不能只进不出。
3.4 Token 预算怎么分配才不打架
分层之后,每层技能都要吃掉一部分上下文空间。如果不做预算控制,还是会出现“检索加载了 5 个原子技能,每个技能描述 800 token,结果又把上下文撑爆”的情况。我给项目定的基础预算表如下:
| 层级 | 技能数量 | Token 预算 | 加载方式 |
|---|---|---|---|
| 系统技能层 | 5-8 个 | 约 1200 token | 每次固定注入 |
| 场景工作流层 | 8-12 个 | 约 2500 token | 按会话状态注入 |
| 技能原子层 | 3-5 个 | 约 2000 token | 动态检索注入 |
| 对话历史和当前输入 | - | 约 4000 token | 每次携带 |
| 总上下文预算 | - | 约 10000 token | - |
这个数字不是凭空定的,而是根据我常用模型的上下文窗口(128k)倒推出来的。我不会把窗口用满,而是控制在总窗口的三分之一以内,给模型留出足够的生成空间和“思考余量”。如果窗口本身较小,预算表整体压缩,但比例关系可以保留。
在给技能写入提示词时,还有一个细节:不是把 YAML 原封不动塞进去,而是把name和description转成自然语言工具说明,类似于“当用户需要查询退款进度时,可以调用 query_refund_progress 技能,该技能支持查询已提交、审核中、已打款、已拒绝四类状态”。这种格式比直接怼一堆结构化字段更易于模型理解。实测下来,同样一组技能,用这种自然语言转写方式和用 YAML 原始结构相比,模型的选择准确率能提升 3 到 5 个百分点。
4. 上线之后的常见问题与排查实录
4.1 技能检索不到,多数时候是描述“太文艺”
我在项目落地阶段遇到的第一个高频问题,就是一个技能明明存在,但用户怎么问,Agent 都不调用它。排查下来绝大多数原因是描述里的用词和用户真实表达不一致。
举个实际例子,某个技能原本描述是“辅助用户完成退货流程中信息收集与表单申报工作”。用户不会说“表单申报”,他们只会说“我要退货,怎么退”。后来我把描述改成“引导用户发起退货申请,收集退货原因并生成退货单”,再配了关键词[退货, 退货申请, 我要退],命中率立刻从不到 20% 涨到 80% 以上。
排查技巧是完全站在用户视角去扩充描述里的同义表达。一种办法是把历史客服聊天记录里用户提到这个功能的句子摘出来,去掉语气词后作为关键词补充。不要只依赖产品经理写的“标准功能描述”,那是给人看的,不是给检索器用的。
4.2 相似技能互相抢单:约束条件和否定词的用法
还有一个高频问题是两个技能描述确实不同,但用户表达它们时用的词几乎相同。比如“查询物流进度”和“查询发货进度”,用户口中根本没区别。
这种情况下,光靠关键词和向量都被打了一顿还是分不开。我的解决思路是给技能描述加约束条件,把“什么时候不该用”写进去。比如发货进度查询技能描述末尾加上“不适用:用户询问运输途中的实时位置”。物流跟踪技能描述末尾加上“不适用:用户只关心仓库是否发货”。约束条件越多,模型越容易找到边界。
实践中我还给路由层加了一个互斥规则表,当检索出来的 top 技能里包含已知的互斥组合时,再根据用户意图里的实体信息做一次过滤。这种规则听起来不“高级”,但非常实用,能把误用率进一步压到很低的水平。智能不等于高级算法,把边界条件管好,比再堆一个模型靠谱。
4.3 检索太慢或加载太多:调参顺序与缓存手段
如果分层之后响应还是很慢,我建议按下面的顺序排查:先看是不是技能描述文件太大,压缩描述长度和无关字段;再看是不是检索候选集太大,把场景过滤提前到最前面;最后看缓存命中率,如果命中率太低,说明用户的意图分布太分散,可以适当提高缓存时间或增加聚合粒度。
我遇到过一种比较隐蔽的情况:单技能描述本身不长,但每次检索都召回七八个技能,全部写进提示词后,上下文还是很大。后来我把 top_k 从 8 改成了 3,效果不但没有变差,准确率还略微上升了。原因是候选越多,模型越容易在相似选项之间犹豫和犯错。给模型做减法,很多时候比给模型做加法更有效。
4.4 回归验证:把“误路由”变成可追踪的指标
技能库是活的,每加一个新技能,都有可能在某个角落里误伤旧技能。我的做法是建立回归用例集,每个技能至少配 3 到 5 条典型用户提问,汇总成一个数据集。当技能描述或路由逻辑有变动时,跑一遍离线路由回归,统计命中率和误用率,不达标就不允许上线。
这个回归步骤不是可选的,是必须的。我有过一次教训,新加了一个“电子发票下载”技能,结果原本正常触发的“订单详情查询”突然开始频繁调用发票技能,就是因为描述里太多重复的业务关键词,把路由带偏了。如果没有回归挡一下,这种问题会直接带进线上,等到用户投诉才反应过来就晚了。
回归集要持续维护,每次从线上日志里挑出“路由正确但回答错误”和“路由就错了”两种样本,补充到测试集里,下一次回归就会覆盖这段历史教训。我还会把线上误路由的输入和相关技能检索分数记录下来,带时间戳和场景标签,方便回溯。用这套机制跑一段时间后,线上误用率会呈现一个很稳定的下降曲线。
4.5 安全兜底:技能调用失败后不能裸奔
分层加载做的是“把合适的技能送进模型视野”,但模型选了技能之后,技能执行本身还可能失败。所以我还给整个机制加了一层兜底:当技能执行报错,或检索结果为空时,模型必须走一个固定话术路径,明确告诉用户“当前暂时无法处理,请稍后再试”,而不是硬编一个不存在的技能继续往下编。有时候模型会在技能缺失时强行捏造一个工具调用,这种情况要提前在提示词里加好规则,确保技能调用失败时能优雅降级。安全兜底和加载机制是配套的,少了这一环,前面的精准路由做得再好,也架不住偶发的执行异常。
结尾的个人体会
分层加载这套东西,听起来有点架构味道,但真正做下来,我发现它最大的价值不是“省了多少 Token”,而是让技能库变成一个可持续运营的资产。以前我是把技能当成提示词的一部分来维护,越维护越乱;现在我是把技能当成产品的一部分来维护,每一层都有归属、有边界、有生命周期。个人最大的建议是别等到上百个技能再从零搭机制,二三十个技能的时候开始分层,成本最低,踩坑最少,后面每个技能加入都有清晰的位置。我踩过无数次全量加载翻车的坑,现在回头看,最重要的不是方案本身有多高级,而是尽早给技能找到它该待的那一层。