不知道你们有没有经历过这个阶段给 Agent 加 Skill 加到停不下来。最开始觉得某个能力缺了就写一个写完效果不错于是越攒越多某天一看总数已经超过了一百个。这时候问题来了——Agent 的实际表现不但没有变得更好反而越来越“笨”偶尔还会在明明应该调用 A 技能的时候非要调用 B甚至干脆自作主张乱答一通。我本人就因为这个被某项目组吐槽过不止一次后来才真正静下心去拆这个多 Skill 场景下的分层加载机制。这篇内容适合两类人一类是已经在做 Agent 开发、手上有几十上百个 Skill 但不知道怎么组织的人另一类是准备设计 Agent 工具体系、想提前避坑的人。我会把自己搭建这套机制时踩过的坑、想清楚的原理、最终落地的实现方案全部讲清楚尽量做到看完能直接照着改。1. 上百个 Skill 之后Agent 是怎么“翻车”的1.1 从“够用”到“失控”攒 Skill 的三个阶段大部分人是这样过来的。第一个阶段是苦哈哈地写基础 Skill比如查天气、查日历、读网页、算数学加起来十几个。这个阶段你会觉得 Agent 非常聪明指哪打哪因为每个 Skill 的存在感都极强模型基本不会选错。第二个阶段开始膨胀。业务方提需求今天要一个“报销单格式校验”明天要一个“工单紧急程度判定”后天要一个“会议纪要素材提取”。你一边抱怨一边写Skill 数量慢慢爬到五六十。这个阶段你会开始感受到细微的异常偶尔 Agent 会花很长时间才给出答案偶尔会把两个功能相近的 Skill 搞混。第三个阶段就是“收藏夹吃灰”阶段Skill 总数破百看起来功能无比丰满实际上每次请求都要把这些技能的定义、描述、参数格式全部塞进上下文。这个时候模型已经不是“智能体”更像一个被迫记住一百个产品说明书、还要当场做阅读理解的人。它的回答变慢、变啰嗦、选错工具甚至有时候为了强行走完流程会自己编造一个并不存在的调用参数。1.2 翻车现场一段让人恼火的调用日志我手上有个真实的系统日志场景很简单——用户问“帮我看看明天下午三点有没有空会议室”。这是个再普通不过的日历查询需求但系统当时的行为是这样的[Agent] 思考用户需要查询会议室我拥有“会议室预订查询”技能。 [Agent] 调用 Skillmeeting_room.search [Agent] 入参{ date: 2025-01-08, time: 15:00 } [Agent] 返回找到3间空闲会议室 [Agent] 继续调用room_reservation.create [Agent] 入参{ room_id: A201, time: 15:00 } [Agent] 返回预订成功看到问题了吗用户只是说“看看有没有”Agent 却直接预订了。原因是系统里同时挂了“查询会议室”和“预订会议室”两个 Skill而且它们的描述字段都包含“会议室”“时间”“空闲”这几个高频词。在上百个 Skill 全量注入的上下文里模型很难区分“查询”和“预订”这两个动作的微妙差异于是一口气把两个动作都执行了。这不是个例。我后来又翻了不少日志发现类似的“技能串台”发生频率相当高尤其是那些功能相近、关键词重叠的 Skill。更糟糕的是有问题的调用很难通过改 Prompt 来解决因为问题根源根本不在于 Prompt 写得不够好而在于整个上下文结构上就没给模型一个合理的选择空间。1.3 全量注入的连锁反应上下文挤占与注意力稀释全量注入所有 Skill 会带来三个连锁反应每一个都是致命的。第一是上下文预算被挤占。假设平均每个 Skill 的元信息名称、描述、参数 Schema、使用说明需要 120150 个 Token一百个 Skill 就是 1200015000 个 Token。这还没算系统提示词、对话历史、工具返回结果。放在 128K 上下文的模型里看起来好像还放得下但留给真正任务推理的空间已经非常有限了模型会变得短视只记得最近的内容前面的 Skill 定义虽然躺在上下文中但跟没看到差不多。第二是注意力被稀释。注意力机制的本质是在所有 Token 上做选择当上下文里有大段大段跟当前任务无关的 Skill 描述时模型需要在一堆噪音里找信号。它也许能勉强找对但概率会显著下降。日常简单任务还好一遇到能力边界模糊的任务错误率就直线上升。第三是“ Prompt 指令冲突”。不同 Skill 的描述可能是不同时期、不同人写的风格和口径完全不统一。有些写“必须调用”有些写“仅在用户明确要求时使用”有些写“如果用户提到 XX 关键词优先调用本技能”。这些指令放在一起时模型很容易被最强烈的那条带走于是频繁出现偏好性误用。我在调优过程中越来越确信一件事问题本质不是模型能力不够而是“全量平铺”这个组织方式本身就是错的。一个一百个技能的工具箱不应该一次性全部摊开摆在桌面上正确的做法是——按需把最可能用到的那几个工具递到模型手里。这就是分层加载机制的核心出发点。2. 分层加载的本质把“告诉模型所有技能”改成“只给它要用的那几把”2.1 两个常见错误解关键词路由与“让模型自己记住”想解决问题第一反应通常是做路由写一个关键词匹配器根据用户问题里的关键词预判需要哪个 Skill然后把匹配到的 Skill 单独注入。这个方案我在早期尝试过效果并不理想。关键词路由的致命问题是它假设用户的问题能通过简单关键词被正确分类。但自然语言的表达方式太多变用户说“帮我看看明天方不方便开个短会”这里面既没有“会议室”也没有“预订”关键词匹配很容易落空。为了覆盖更多的说法你得写大量规则规则越多越难维护最后变成一个比 Skill 管理本身还要复杂的怪物。第二个常见错误解是“让 Agent 自己记住所有 Skill”。具体做法就是开一个超长系统提示把所有 Skill 的作用、触发条件写在里面期待模型能“记住并在合适的时机自行调用”。这种做法短期有效但 Skill 一多就会退化因为模型的长期记忆能力在单次会话内是非常有限的而且它根本没有机会提前“复习”这些技能的定义只在真正需要时凭模糊印象去猜猜错概率极高。2.2 四层分层模型目录层、索引层、加载层与执行上下文层后来我参考了几套主流思路结合自己的项目情况把方案收敛为一个四层结构。第一层是 Skill 目录层也就是“主清单”。它不是给模型看的而是给系统看的。所有 Skill 的元信息都登记在这一层包含名称、版本、功能描述、依赖关系、是否启用、所属业务域等。这一层解决的是“我们有哪些技能”的问题是后续检索和加载的数据来源。第二层是索引层解决“当前问题可能和哪些技能相关”的问题。我在这一层做了两套索引一套是语义索引用嵌入模型把每个 Skill 的描述向量化查询时把用户问题也向量化做余弦相似度检索另一套是标签索引每个 Skill 自带业务域和场景标签比如“日历”“审批”“数据分析”查询时做布尔匹配。第三层是动态加载层解决“最终把哪些技能放进上下文”的问题。这一层把索引层召回的候选技能做一次过滤、排序、去重结合当前会话的上下文预算决定最终注入哪些 Skill、注入到什么完整度。它是整个机制的决策中枢。第四层是执行上下文层解决“模型实际看到什么”的问题。这一层负责把选中的 Skill 拼接成紧凑的工具定义按照优先级顺序放入上下文同时保证不超出 Token 预算、不与系统提示冲突。这个四层结构的好处在于每一层只干一件事更换索引算法、调整加载策略、改上下文拼装格式都不会影响另外几层。我在实际开发中得到一个体会分层设计带来的不只是性能提升更重要的是把“怎么选技能”和“怎么用技能”两件事解耦了排查问题时定位特别快。2.3 为什么分层能同时解决上下文预算和选择准确率本质上分层加载就是给模型开了一扇“聚焦窗口”。它不需要知道你有 120 个 Skill只需要在当前请求里看到最相关的 38 个 Skill。上下文预算被释放模型可以把注意力集中在真正的任务推理上选择准确率自然就上来了。我习惯用一个比喻来解释这件事全量注入就像把所有业务线员工的简历全部塞给面试官面试官每面一个人都要在一堆无关简历里翻找分层加载则像安排了一个 HR 助理先根据岗位要求筛一遍只把最匹配的候选人简历递到面试官手里。面试官的工作压力小时判断自然就准了。这背后有一个更底层的逻辑工具选择这件事本质上是一个信息检索问题而不是一个“模型记忆力”问题。既然模型难以在海量工具定义中凭记忆做准确选择那就让一个专门的信息检索系统替它完成初筛再用模型做精细化决策。3. 手把手搭建分层加载核心实现与关键代码3.1 先做好 Skill 清单的元数据设计起步阶段我踩过最大的坑就是元数据设计得太随意。最开始每个 Skill 只有“名称”和“描述”两个字段后来做语义索引和路由时才发现信息根本不够用只能一个个补。所以如果你现在准备动手第一步一定不是写代码而是把所有 Skill 的元数据规范化。我自己最后定下来的核心字段大概长这样{ skill_id: meeting_room_query, name: 会议室查询, version: 2.1.0, domain: [calendar, office], tags: [会议室, 空闲查询, 时间安排], summary: 查询指定时间段内空闲的会议室列表不包含预订操作。, trigger_examples: [ 明天下午有空会议室吗, 查一下今天十点的会议室, 下午三点能不能找到一个小会议室 ], dependencies: [], conflicts: [meeting_room_book], enabled: true }这里有几个字段必须重点强调。第一个是summary它跟description不一样description是给模型看的长说明summary是给索引层用的短摘要必须精确、无歧义上面这个例子里我故意加了后半句“不包含预订操作”就是为了让索引层能区分查询和预订两个技能。第二个是trigger_examples非常重要这是语义索引的核心数据源写三五个典型问法向量检索效果会好很多。第三个是conflicts用来标记功能相近、容易混淆的 Skill 对加载时如果同时召回可以做冲突处理。3.2 构建语义索引把 Skill 描述向量化元数据规范化之后下一步就是给每个 Skill 构建向量。我选择的方案是把summary、domain、tags、trigger_examples拼接成一段索引文本然后丢进嵌入模型转成向量。这里有个细节为什么要把 trigger_examples 拼进去因为用户提问的表达方式跟开发者写描述的表达方式差别很大直接拿描述做语义搜索效果往往一般但拿“用户真实的问法”做搜索命中率明显更高。用户问“明天下午有空房吗”跟 trigger_examples 里的“明天下午有空会议室吗”向量距离很近检索就能命中。索引构建的核心代码不复杂我用 Python 实现了雏形import json import numpy as np def build_skill_index(skills, embed_fn): vectors [] metadata [] for skill in skills: text .join([ skill[summary], .join(skill[domain]), .join(skill[tags]), .join(skill[trigger_examples]) ]) vec embed_fn(text) vec vec / np.linalg.norm(vec) # 归一化 vectors.append(vec) metadata.append({ skill_id: skill[skill_id], name: skill[name], domain: skill[domain], }) return np.vstack(vectors), metadata def retrieve_candidates(query, vectors, metadata, top_k10): q_vec embed_fn(query) q_vec q_vec / np.linalg.norm(q_vec) scores vectors q_vec # 余弦相似度等价于归一化向量的点积 top_indices np.argsort(scores)[::-1][:top_k] results [] for idx in top_indices: results.append({ metadata: metadata[idx], score: float(scores[idx]) }) return results归一化这一步别省不归一化直接算点积向量长度会影响分数导致长描述的 Skill 天然占优势。这里我用了 NumPy 手动计算实际项目里如果 Skill 数量上千建议直接用量化索引加速不过上百个 Skill 的规模用简洁的 NumPy 完全够用不用过度设计。3.3 场景路由与动态加载器过滤器、排序器、去重器有了候选技能列表接下来是动态加载层的决策逻辑。这一步需要依次做四件事。第一是硬性过滤。把enabledfalse的技能排除掉把已经被当前会话调用超过次数上限的技能排除掉如果 Skill 声明了 sensitive 属性而当前会话没有对应权限也要排除。第二是软性排序。按相似度分数从高到低排列但我会做两个微调。一个微调是“领域一致性加权”如果用户问题已经被分类到某个业务域就优先排该领域的 Skill。另一个微调是“近期使用加权”如果当前会话里 Agent 刚刚调用过某个 Skill那么相似度相近的情况下优先保留它因为多轮对话的后续问题通常延续同一个业务场景。第三是冲突去重。如果两个 Skill 的conflicts字段互相指向对方只保留分数更高的那个。这一步就是用来解决“查询会议室”和“预订会议室”这类问题。第四是数量截断。最终需要的 Skill 数量可以由两个因素决定一个是业务复杂度简单任务 3 个以内足够复杂任务 58 个另一个是上下文预算算一下剩余 Token 能塞下几个完整定义。整体逻辑用一段伪代码描述就是def load_skills(query, user_context, skill_store, top_k6): candidates retrieve_candidates(query, skill_store.vectors, skill_store.metadata, top_ktop_k*2) filtered [] for item in candidates: skill skill_store.get(item[metadata][skill_id]) if not skill[enabled]: continue if skill[skill_id] in user_context.recently_used: item[score] * 1.1 # 近期使用加权 filtered.append((skill, item[score])) # 冲突处理同一冲突组只保留最高分 conflict_groups {} for skill, score in sorted(filtered, keylambda x: -x[1]): cid skill[skill_id] skipped False for group in conflict_groups.values(): if cid in group[conflicts] and skill[domain] group[domain]: skipped True break if not skipped: conflict_groups[cid] {conflicts: skill[conflicts], domain: skill[domain]} selected list(conflict_groups.keys())[:top_k] return [skill_store.get(cid) for cid in selected]实际生产环境里这个加载器还要接入缓存。比如同一个 query 在短时间内反复出现就不需要重新做向量检索直接走缓存返回之前的结果。Skill 索引本身就几百 MB 的内存都不到缓存几万条 query 完全没压力。3.4 上下文预算计算与降级策略动态加载层的最后一个关键点是算预算。与其让模型在 128K 的上下文里裸奔不如主动规划系统提示词固定占多少对话历史占多少工具返回结果占多少剩下的留给新注入的 Skill 定义。我通常会做一个最简单的预算模型工具定义预算 总上下文窗口 x 0.25 - 已使用Token - 安全余量比如某个模型上下文窗口是 128K当前系统提示 历史对话 工具结果已经用了 60K那么工具定义预算就是 32K - 60K - 6K安全余量如果结果是负数说明会话太长需要做历史摘要或截断否则干脆不放新 Skill。预算决定了两个东西一个是“放几个 Skill”另一个是“每个 Skill 放多完整”。老办法是把 Skill 的完整参数 Schema 都塞进去后来我发现绝大多数 Skill 可以拆成“短格式”和“长格式”两种注入形态。短格式只包含名称和 summary大约 3050 Token长格式包含完整参数 Schema 和使用约束大约 150200 Token。检索排名前三的用长格式稍后的用短格式。这样既保证模型对最关键的工具理解完整又可以多放几个候选供参考边际收益很高。降级策略也非常实用其实就是“按最坏情况设计”如果向量检索模块挂了怎么办如果嵌入模型服务超时怎么办我在生产环境里做了一个兜底方案——当语义索引不可用时自动退化为标签关键词匹配只匹配tags字段里的精确关键词照样能覆盖大部分典型场景只是召回略差但至少不会出现“一个 Skill 都不加载”导致 Agent 空手回答的情况。这个降级策略后来救过我好几次真心建议做。4. 我用真实项目实测优化前后对比与调参经验4.1 测试环境与指标设计方案落地之后我在自己负责的“某跨平台智能助手项目”示意名上做了一轮完整的数据对比。这个 Agent 对接了 112 个 Skill覆盖日历、审批、数据分析、文档处理、日程安排等十几个业务域。测试集从真实生产日志中随机抽取了 200 条请求按业务域分层均匀分布。三条核心指标是工具选择准确率由人工判断模型是否选择了正确工具、平均首响应延迟、上下文 Token 占用。为了保证公平我只改了 Skill 加载方式其他 Prompt 和模型参数保持不变。4.2 对比数据全量注入 vs 分层加载结果表格如下指标全量注入所有 Skill分层加载机制工具选择准确率61.5%88.0%平均首响应延迟3.2 秒1.8 秒上下文 Token 占用约 14800约 2600错误调用误预订、误发送等11 次2 次平均用户中断修正次数2.4 次/对话0.7 次/对话最直观的变化就是准确率提升了 26.5 个百分点从 61.5% 拉到了 88%。延迟降了 1.4 秒上下文占用从 14800 降到 2600剩下来的空间让模型有更多余力做推理。业务侧的实际感受是之前动不动就“帮我订了不该订的东西”现在基本不会出现。坦白讲第一次看到这个数据我是有点出乎意料的。因为我最初的目的只是想“把上下文挤爆的问题解决一下”没想到顺带把工具选择准确率提升了这么多。这强化了我之前的判断模型技术选型时给的工具越多它反而越不敢选、选不准给一个聚焦的候选集合它反而果断且精准。4.3 参数调优TopK、相似度阈值、标签粒度怎么定数据好看但背后不少参数都是一点点调出来的。我最想分享的三个调参经验如下。第一是 TopK 选择。最开始我把候选召回数设成 20最终加载 6 个效果并不好。因为 Top20 里充斥着大量相似度很低的“边缘候选”它们的存在只会增加噪音。后来我把召回数降到 10加载数设为 35效果反而稳定了。核心原则是召回可以稍微宽松但加载到模型面前的绝不能宽松宁可少而准不要多而杂。第二是相似度阈值。我统计了测试集里正确答案的相似度分布发现大部分正确 Skill 的相似度集中在 0.650.85 区间低于 0.6 的几乎都是错误召回。所以我最终把“候选进入加载阶段”的下限阈值设为 0.62低于阈值的 Skill 直接丢弃。这样会牺牲一小部分高难度长尾问题的召回率但换来的是整体精度的显著提升在绝大多数业务场景下这个取舍非常划算。第三是标签粒度的粗细。一开始我把标签做得非常细比如“会议室预订_指定日期”“会议室预订_按人数推荐”结果按标签匹配时经常落空因为用户根本不会这样说。后来我把标签统一收敛到“粗粒度业务域 细粒度动作”两层比如“日历域-查询”“日历域-预订”用户查询先被分类到“日历域”再用语义检索细分具体动作准确率高了很多。标签不是越细越好而是要和用户提问的粒度对齐。5. 踩坑记录分层加载不是银弹这些边界 Case 值得留意5.1 相似 Skill 误召回导致的选择漂移分层加载提升准确率但它没有完全消除“相似 Skill 混淆”的问题。最典型的一个场景是“审批通过”和“审批驳回”两个 Skill共享了几乎一样的域名、描述关键词它们的向量也高度相似经常被同时召回。我一开始的做法是硬写conflicts字段标记它们互斥靠分数高低决定加载哪个。但后来发现如果用户的意图经过两轮对话才明确下来单轮召回时根本不知道应该偏袒哪一个。这个问题的解法是引入“多轮决策状态”系统记录上一轮已经加载了哪个 Skill如果下一轮用户的问题带有明显延续性比如“对就按这个来”就沿用上一轮的 Skill 集合并只在其中做微调不重新全量召回。这个改动带来一个额外的好处它同时减少了向量检索的调用次数因为多轮对话里大部分后续请求根本不需要重新检索直接沿用加载结果加一两个候选补充即可。低成本、高收益。5.2 Skill 之间隐式依赖与调用冲突还有一个我之前很容易忽略的问题有些 Skill 不是独立的它们存在隐式依赖。比如“生成周报”这个 Skill 依赖“查询本周事件”和“统计工时”两个 Skill 的数据输出。如果“生成周报”被加载了但另外两个依赖 Skill 不在上下文里Agent 就会直接硬编造数据或者用完全错误的方式去拼周报。这个问题是我在数据统计时发现的周报生成质量在被加载但缺少依赖工具的情况下准确率只有 31%。后来我给 Skill 元数据加了一个dependencies字段加载器在选中某个 Skill 时会递归加载它依赖的所有 Skill把它们一起放进上下文。这样“生成周报”一出现模型自然知道可以先调用查询工具拿数据再执行生成逻辑整个调用链路就完整了。依赖关系的语义化表达很简单但它把“单个技能”思维升级成了“技能链”思维。你不再关心某个 Skill 能不能用而是关心这条服务链路能不能跑通。5.3 缓存一致性更新 Skill 后索引没刷新的“幽灵技能”这个坑很隐蔽一开始我根本没意识到。有一次我把一个 Skill 的名字从“旧日程查询”改成“日程与会议查询”但在构建向量索引时用的还是旧元数据。结果线上系统依然能通过“日程查询”之类的问题召回它但注入到上下文里的工具定义却已经变成了新名字。模型面对一个“名字和描述对不上”的技能时表现非常迷惑有时候说“我没有日程查询工具”有时候拿新技能做旧功能。排查了半天最终发现是索引更新策略的锅。技能元数据是增量更新的但向量索引每次都是全量重建偏偏当次实时的服务请求读到了旧的缓存索引文件导致老版本技能在系统里“幽灵般”存在。后续解决方式是给索引加版本号每次重建索引都带上最新的元数据版本服务请求时先校验版本号不一致就强制刷新。另外我保留了“索引落盘时间”字段超过 24 小时自动重建一次从根上杜绝了这种缓存悬挂问题。5.4 多轮对话场景下的加载策略差异上述场景里所有设计都默认“一次请求就是一次技能选择”但真实业务中大量请求发生在多轮对话内。多轮场景和单轮场景对分层加载的要求完全不一样这里面有一个我总结过的核心差异单轮场景追求“一次性选对”多轮场景追求“保持稳定且可调整”。什么意思呢用户第一个问题问“明天下午有没有会议室”模型已经加载了会议室查询 Skill用户接着问“那后天呢”模型不应该重新检索一遍再选一次而是应该沿用上一轮加载的查询 Skill把参数直接从请求里更新。如果重选了选错风险大增如果完全沿用又可能导致“查完会议室后用户问附近有没有咖啡店”时咖啡店相关的 Skill 没有被加载进来。所以多轮加载策略是这样的每轮请求先检查上一轮加载的 Skill 是否与当前问题语义相关相关则沿用同时做一次轻量召回找到新增业务域的候选合并到候选集合里再做最终加载决策。本质上是一个“延续为主、增量补充”的策略既稳又灵活。这个策略调到稳定之后多轮会话里的误调用率又下降了一个量级。6. 最后说几句这套机制的适用范围与后续可扩展方向做完整套分层加载机制我最大的体会是在 Agent 开发里Skill 管理不是写代码问题而是架构问题。凡是 Skill 数量超过五十、业务域超过三个的项目全量注入就已经是负资产了越早做分层加载越好。但我也得说句公道话这套机制并不是银弹如果你的 Agent 本身只有十来个 Skill做一层简单的排序注入就够了没必要上完整的分层方案过度设计反而会增加维护成本得不偿失。后续我准备在三个方向上继续迭代第一个方向是“技能自动合并”把长时间未被单独调用且功能高度重合的 Skill 做自动聚合控制技能数量的自然膨胀第二个方向是“基于用户反馈的调参闭环”把用户的纠正行为作为信号自动调整 Skill 的加载偏好和相似度权重第三个方向是“行业场景模板”把同一行业常用的 Skill 组合沉淀为预设模板新项目初始化时可以直接加载一套成熟的目录结构不需要从头攒一百个技能再倒推整理。最后分享一个非常个人的小技巧把所有 Skill 的 trigger_examples 当成产品需求文档来维护。每次业务方提新需求先让业务方用两三句“人话”描述用户会怎么问再把这些话写进 Skill 元数据。这一步看似是写文档实际上是在为语义索引积累最有价值的数据后期所有检索质量都建立在这些样本上。我早期轻视了这一步后来补齐之后整个系统的召回质量有了肉眼可见的提升。可以先从这步开始改再逐步搭分层加载效果会更顺。
阅读完成 · 觉得有帮助?