我最近在做Agent相关的东西时撞上了一个特别尴尬的局面:能出效果的方案都在烧token,效果一般的开源模型又总在意图识别和工具选择上翻车。当时手头有个12B的开源基座模型,跑在单张专业显卡上,一开始让它直接干Agent的活儿,技能选择准确率只有六成出头,比我预想的差了太多。后来我把整套Agent调用逻辑重构,装进了一套叫“Agent Skill”的技能选择框架里,同一个12B模型,技能选择Top-1准确率直接拉到89.7%,接近90%,线上推理成本算下来比原来调云端大模型API的方案降低了50%以上。这篇文章不做任何保留,把框架设计、数据构造、训练细节、成本核算以及踩过的坑完整复盘一遍,希望能给正在折腾小模型做Agent的朋友提供一条可以照着走的路。
1. 为什么小语言模型需要“技能”框架:问题不在模型,在使用方式
先说说我最初的失败经历,这个过程很典型。项目背景是一个客服助手Agent,需要调用查询订单、物流、商品信息、优惠券、退换货等几十个技能。当时的主流玩法很直接:把所有技能描述拼成一大段system prompt,让模型自己读、自己决定调用哪个。结果12B模型在这个方案上表现非常不稳定,经常出现三四个技能描述放在一起就互相混淆的情况,比如用户说“帮我看看订单到哪了”,模型有时候去调物流查询,有时候去调订单详情,还有时候干脆把自己会什么工具都忘了。
1.1 大模型方案的三座大山
先用云端大模型API的时候,其实也能跑通,但有三座大山压着,越到后面越难受。
第一是成本。客服场景的每一次请求都要把几十个技能描述完整放到上下文里,加上对话历史,一轮交互轻松突破上万token。技能数量涨到上百个之后,光技能描述就能吃掉几千token。我们当时接的是某主流大模型API,一个月跑下来账单很惊人,而且技能列表每次调整,成本跟着涨,几乎没有边际成本优化的余地。
第二是延迟和耦合。技能描述要动态更新,每次都要改prompt重新请求,一旦技能数量多到几百个,prompt总长度会逼近上下文窗口上限。技能更新、下线、参数调整这些操作全部变成了对prompt的改动,业务侧根本没法自维护,所有改动都得走算法这边排期。
第三是小模型在这条路上完全走不通。同样的“把所有技能描述堆进prompt”的做法,在云端大模型上勉强能用,放到12B小模型上就是灾难。小模型的语言理解和长文本注意力分配能力本来就更有限,塞进一大段描述以后,别说选出正确技能,有时候连JSON格式都输出不稳定,十个调用有两次直接格式飘逸。
1.2 小模型不是“不行”,而是“不会用”
被12B模型连续教育了一周之后,我开始反思一个问题:技能选择这件事,本质上到底是什么任务?
大模型方案里,技能选择是一个开放式文本生成任务——模型读了一堆描述,然后自由地生成一段JSON告诉你调用哪个技能。这对模型的“世界知识”和“长文本推理能力”要求极高,正是小模型的弱项。
但如果换个角度想,技能选择本质上是“给定用户请求,从N个候选技能中选一个最匹配的”,这不就是个分类任务吗?分类任务的参数量敏感性远低于开放生成。一个12B模型做阅读理解可能比不过几百B的大模型,但让它在一组被充分检索和组织的候选技能里做选择决策,这个能力储备是完全足够的。
这个认知转变是整个项目的分水岭。我们需要的不是一个“什么都会生成”的全能Agent,而是一个“会正确做选择题”的路由器。把技能选择从生成式重构为判别式,小模型的潜力才能被释放出来。这也是Agent Skill框架最核心的设计哲学。
2. Agent Skill 框架核心设计:检索、精排、路由三段式
想明白了方向之后,我重新设计了整套Agent调用链路。这套框架按“技能生命周期”来组织,核心是把技能选择变成一个三段式流水线:先召回,再精排,最后路由决策。
2.1 技能注册:让每个技能拥有结构化身份
第一步是给每个技能做结构化注册。过去技能只是一段文字描述,在Agent Skill框架里,每个技能都是一条带Schema的注册记录。我采用的技能Schema包含以下关键字段:
{ "skill_id": "order_status_v3", "name": "订单状态查询", "description": "查询订单当前状态,包括待付款、待发货、已签收、退款中", "trigger_conditions": ["用户询问订单状态", "用户催发货", "用户咨询物流进度"], "do_not_use_when": ["用户询问商品库存", "用户询问退款原因时需先调退款查询"], "input_schema": { "order_id": "string", "user_id": "string" }, "examples": [ "我的订单到哪里了", "上周买的东西发货了吗" ] }这里我特别强调一个字段:do_not_use_when。这个字段在常规工具描述里几乎见不到,但它在训练阶段帮了大忙。两个功能相近的技能,真正区分它们的往往不是“能做什么”,而是“什么时候不该做”。比如“订单状态查询”和“物流轨迹查询”听起来都能回答“我的东西到哪了”,但它们的数据来源和使用场景差异很大,如果不写清楚负面触发条件,模型在后续训练时会始终分不清这两个类别。
技能注册完成之后,系统会把描述、触发条件、示例查询拼接成一份技能文档,后续的检索和路由都基于这份结构化文档进行。
2.2 技能仓库与召回:先缩小候选集,再让模型做选择
第二个环节是技能仓库。所有技能文档用向量化模型映射成向量,存入本地内存索引。用户请求进来时,先用向量检索从几百个技能中召回Top-10候选,再配合一层关键词/字面匹配的BM25召回结果,做加权融合,最终把候选集缩小到Top-10以内。
这一步的工程价值很大。如果不做召回,直接把几百个技能描述全部塞给路由模型,模型的分辨压力极大,延迟也高。做了召回之后,路由模型每次只需要看10个左右的候选技能,上下文长度从几千token压缩到几百token,推理速度显著提升,准确率反而更容易控制。
我在这步设计上吃过的亏值得单独说:开始我只用向量检索一种召回方式,效果很不稳。原因在于技能描述里经常包含精确的名词,比如技能名里的版本号、特定活动名称、商品类目编码,这类信息往往是字面匹配更强,而向量检索对罕见词和拼写变体不敏感。加入BM25这种稀疏检索之后做倒序融合,召回的稳定性才有了质的提升。
召回率是这个三段式链路上的硬约束。整套系统的技能选择准确率可以拆成两个数的乘积:召回覆盖率乘以路由正确率。如果召回阶段已经把正确技能漏掉了,路由模型再强也白搭。所以我在这步设定的目标是召回率必须超过99%,留足余量给下一步。
2.3 路由决策:判别式小模型的用武之地
最后一个环节是路由决策。召回的Top-10候选技能连同用户原始请求一起送入12B路由模型,模型输出每个候选技能的得分,取最高分作为最终选择。这个过程不是让模型“写”一个技能ID出来,而是让模型在候选集上做判别式打分,本质上是带约束的分类决策。
为了进一步控制风险,路由模型还会输出置信度。置信度低于设定阈值时,系统不会强行调用技能,而是把请求降级给对话模型做兜底回复。这个兜底策略非常重要,它保证了一个原则:宁可不调用技能,也不能调用错误的技能。客服场景里,调用错技能造成的信息误导比不调用严重得多。
框架的四要素总结下来就是:技能注册标准化、技能仓库向量化、候选召回混合化、路由决策判别化。四者缺一不可,任何一环只做到“差不多”,最终准确率都会打折扣。
3. 12B模型逼近90%准确率的工程拆解:数据、训练与检索的组合拳
很多人在看到“12B模型技能选择准确率逼近90%”这个数字时,第一反应是“这模型训练得好”。其实训练只是最后一公里,前置的数据构造和检索设计占了大头。下面按时间顺序拆解我实际操作中的每一个环节。
3.1 技能描述的质量直接决定准确率的起点
我踩过的第一个坑,是随手复制业务文档来当技能描述。那些文档里充满了“模块”、“接口”、“逻辑”这类上下文词汇,模型根本学不出边界。后来我统一了一套描述写作模板,每一项技能强制包含四个部分:做什么、什么时候用、什么时候不能用、输入输出示例。重写之后,我明显感觉到路由模型的训练收敛速度快了很多。
这里有个现象值得展开说。两个相似技能是否容易混淆,很大程度上在描述阶段就已经决定了。比如“查询优惠券”和“领取优惠券”,如果描述里都是“用户问优惠券”,模型必然懵。但如果在“查询优惠券”的描述里强调“用户已有优惠券,询问使用规则或剩余数量”,在“领取优惠券”的描述里强调“用户想要获得新的优惠券”,两者的特征空间就自然分开了。
我建议写技能描述时主动做一次两两对比:找所有描述里关键词高度重叠的技能对,人为给它们加上差异化的触发条件和反向约束。这项工作做一次,比后面调十个训练参数都有用。
3.2 训练数据构造:难负例才是准确率的真正瓶颈
接下来的重头戏是训练数据。我最终使用的是一个大约2万条的query-技能配对数据集,其中正样本5000条,负样本15000条。数据来自三部分:历史真实调用日志、人工标注种子数据、模型辅助生成的模拟查询。
先说说负样本的构造,这是全部工作中最容易被低估的部分。如果只是随机从技能库里挑不相干的技能当负样本,训练出来的模型会“看起来很强”,离线准确率轻松超过93%,一上线就原形毕露。原因很简单:线上用户问的请求往往高度相似,真正的挑战是区分“订单状态查询”和“物流轨迹查询”这种近似技能,而不是区分“查天气”和“查订单”。
我的做法是构造“难负例”。对于每一个正样本,先挑出所有在语义上与该技能最接近但不匹配的其他技能,作为负候选;再用查询改写的方式,把同一个用户意图改写成多个不同表述,配上错误的技能标签。比如“我的订单到哪了”是正样本,标签为“订单状态查询”;那么改写后的“帮我看看快递走到哪了”就作为“订单状态查询”的难负样本,正确标签是“物流轨迹查询”。难负例参与训练之后,离线准确率从虚高的93%降到了诚实的78%,这一个“下降”才是真实能力的开始。后续通过优化描述、调整检索、增加数据,才一步步把78%抬到89.7%。
在训练目标上,我采用了“路由头+低秩微调”的方案。冻结12B模型的大部分参数,只训练一个轻量分类头和适配器。训练输入是拼接后的“召回技能列表+用户请求”,输出是技能ID的类别概率。对比目标也很直接:正确技能的logit要远高于其他候选,不仅要比错的高,还要拉开足够大的间隔,这样推理时置信度才有区分度。
# 简化的训练样本构造逻辑 def build_route_sample(query: str, candidates: list[Skill], target_id: str): prompt = build_router_prompt(query, candidates) candidate_ids = [skill.skill_id for skill in candidates] label_index = candidate_ids.index(target_id) return { "input_ids": tokenizer(prompt), "label_index": label_index }训练配置上我用了比较保守的设置:初始学习率2e-5,批次大小32,训练轮数3轮。因为数据量本身不大,过拟合风险比欠拟合更值得警惕。一轮训练大概需要单张专业显卡跑3小时,成本完全可控。
3.3 路由回归测试:不只是追求准确率
训练完成后,我没有直接上线,而是搭了一套回归测试集。这个测试集非常关键,它专门用于发现“新增技能后旧技能被带偏”的问题。测试集里的样本按技能类别分层抽取,数量均衡,难负例比例刻意调高。
我记录三个核心指标:Top-1准确率、Top-3召回率、兜底触发率。Top-1准确率衡量路由是否选对了技能;Top-3召回率衡量正确技能是否在模型前三候选里,它决定了后续是否有二次确认的空间;兜底触发率则反映模型在不确定情况下有没有“不乱选”。
最终跑出来的结果,在150个技能的测试集上,Top-1准确率89.7%,Top-3召回率97.2%,兜底触发率控制在了12%左右。如果你发现自己的准确率上不去,先别急着调模型,回头检查负样本质量和技能描述的边界,大概率问题出在前面这两环。
3.4 混合检索的细节:为什么召回必须“溢出”
第三部分的另一重点是检索细节。我用向量召回和BM25召回做融合,候选集取Top-10,但有一个原则:宁可召回一些明显不相关的技能,也不能漏掉正确技能。因为路由模型有能力从10个候选中挑出正确的,但如果正确技能根本不在候选集里,路由模型无能为力。
实际测试中,只靠向量召回,正确技能在Top-10内的覆盖率大约95%;加入BM25并用倒序融合后,覆盖率提升到99.3%。这4个百分点在最终系统准确率上的体现是:Top-1准确率跟着涨了大约3.5个百分点。所以检索融合不是锦上添花,是实打实的准确率贡献者。
4. 算力成本降低50%,这笔账到底怎么算出来的
标题里最吸引人的可能不是90%准确率,而是50%算力成本下降。很多人会觉得这是营销口径,我这里把账摊开算清楚。
4.1 对比基线:云端大模型API方案
先明确基线。我们项目改造前用的是云端大模型API,每一次技能选择请求的输入包含:上百个技能描述、最近10轮对话历史、系统提示词,大约折算为4万tokens输入,输出是一个结构化的技能选择结果,大约500tokens。按主流大模型API的公开计价水平估算,单次请求成本大约在0.15元到0.2元之间。
客服场景一天调用量可轻松破万次,这部分成本是实实在在的运营开销。
4.2 新方案的成本构成:本地12B推理
切换到本地12B模型后,单次技能选择请求的输入变成了“用户请求+召回后的10个候选技能摘要”,上下文只有800到1500tokens,输出就是技能ID打分的分类结果,几乎不产生额外token开销。12B模型在4bit量化后显存占用从24G左右降到8G以内,单张专业显卡即可承载线上并发。
成本核算主要包括显卡折旧和电费。一张专业显卡按3年折旧摊薄,外加功耗和机房成本,折合到每次推理大约0.06到0.08元。相比原来的0.15到0.2元,单次下降50%以上,而且随着调用量增加,本地部署的边际成本几乎不变,而API方案的成本是线性上升的。
4.3 为什么能降低这么多:算力结构的变化
成本下降的本质不是模型变小了这么简单,而是算力结构发生了变化。
第一是token消耗量下降了一个数量级。召回机制保证了路由模型只看10个候选技能,不用每次全量理解几百个技能的描述。这是成本下降的最大来源。
第二是量化带来的显存效率提升。4bit量化让12B模型可以跑在单卡上,批处理能力比FP16方案高了将近一倍。实测中量化对技能选择准确率的影响不超过0.5个百分点,完全可以接受。
第三是请求合并与缓存。技能描述的向量化结果可以预先构建并缓存,用户请求到来时只需要计算query的向量和路由头的推理,大量计算从“请求时”变成了“请求前”,线上压力进一步降低。
我把演进前后的成本结构列成表格,方便做预算的朋友直接参考:
| 项目 | 云端大模型API方案 | 本地12B Agent Skill方案 |
|---|---|---|
| 单次请求输入tokens | 约4万 | 约1200 |
| 单次请求输出tokens | 约500 | 分类打分,几乎为0 |
| 单次调用成本(人民币) | 0.15-0.2元 | 0.06-0.08元 |
| 10000次日调用成本 | 1500-2000元 | 600-800元 |
| 平均延迟 | 1.5-3秒 | 0.4-0.8秒 |
| 技能数量扩展成本 | 随tokens线性增加 | 检索索引增量扩展,成本极低 |
这套数据是我们在“某客服Agent模拟项目”中实测得到的,虽然具体数值会因为使用场景、并发量和硬件采购价格有所浮动,但成本量级的差距是真实存在的。
5. 实操过程:从零搭建一个最小可用的Agent技能选择器
前面讲了很多设计和理论,这一部分我给出一套可以直接复现的实操路径。假设你现在有一个12B模型,想在本地搭一个技能选择系统,按下面的步骤走就可以了。
5.1 定义技能注册表
先建立一个JSON文件作为技能注册表,手动录入技能。数量少的时候可以人工维护,数量涨到百级之后,建议做一套管理页面来维护。最少可用版本包含:skill_id、name、description、trigger_conditions、do_not_use_when、examples。
5.2 构建混合检索索引
第二步是把技能文档拆成检索引擎可以使用的格式。向量索引使用embedding模型将技能文档编码,BM25索引则用关键词词频构建。两个索引构建完成后,实现一个融合查询接口:
def hybrid_search(query: str, top_k: int = 10): vector_hits = vector_index.search(query, top_k=top_k * 2) bm25_hits = bm25_index.search(query, top_k=top_k * 2) fused = reciprocal_rank_fusion(vector_hits, bm25_hits) return fused[:top_k]融合算法我用的是经典的倒序融合(RRF),对每个候选技能在两个索引中的排名取倒数相加,分数越高排名越前。这个方案不需要大量调参,融合后召回覆盖率比单一索引高得多。
5.3 训练路由模型
第三步是准备训练数据并微调。训练数据的格式是(query, candidate_skills, target_skill_index),候选技能来自混合检索结果,target是正确技能在候选列表中的位置。
# 训练数据迭代器示例 def train_route_model(train_samples): for sample in train_samples: candidates = hybrid_search(sample.query, top_k=10) if sample.target_skill_id in candidates.ids: label = candidates.ids.index(sample.target_skill_id) yield build_route_sample(sample.query, candidates, label)训练完成后需要做一次回归测试。在150个技能的测试集上,Top-1准确率达到89.7%之后,我才决定放到线上环境。
5.4 上线部署与观测
最后一步是部署。12B模型导出为量化格式,运行在本地推理服务中。路由请求通过一个轻量级HTTP接口暴露给上层Agent系统。接口输入是用户query,输出是技能ID和置信度。
我在线上额外加了一个日志系统,记录每一次路由的query、候选技能、最终技能、置信度、是否触发兜底。这些日志每天都会回捞分析一次,抽样标记错误案例,逐渐回流到训练集。这个数据飞轮是整个系统持续变好的核心动力。
6. 常见问题与排查技巧实录
实操过程中一定会遇到各种问题。我把最典型的几个按症状、原因、解法整理成了速查表,方便大家直接对照排查。
| 典型症状 | 根因分析 | 解决动作 |
|---|---|---|
| 离线准确率高,线上准确率大幅下降 | 离线训练负样本太随机,没有难负例 | 重构负样本采样逻辑,增加相似技能对抗样例 |
| 两个技能经常互相选错 | 技能描述特征重叠度过高 | 检查并重写描述,强化do_not_use_when等反向约束 |
| 新增技能后整体准确率下降 | 路由类别空间变大,旧技能分类边界互相侵扰 | 新增技能后做全量回归测试,补充增量训练数据 |
| 兜底触发率过高 | 置信度阈值偏高或技能描述过泛化 | 下调阈值,或将泛化技能拆分为多个细粒度技能 |
| 推理延迟偏大 | 候选技能数量过多、模型未量化、批处理未开启 | 控制候选集在10个以内,开启动态批处理和量化推理 |
| 检索召回环节就漏掉正确技能 | 单一索引召回能力不足 | 采用向量加BM25的混合检索,并验证召回覆盖率 |
避坑技巧里再补几条容易被忽略的。
技能数量接近500个时,先不要急着依赖路由模型。技能注册表的合理分组会比模型训练更快带来收益,比如把客服技能按“订单域”、“商品域”、“售后域”分组,在召回前先做一次粗粒度域过滤,准确率提升非常明显。
置信度阈值不要盲目追求高值。我们把阈值从0.7调低到0.6之后,兜底触发率从22%降到12%,Top-1准确率只下降了0.8个百分点。在客服场景里,把请求交给对话模型兜底,体验远好于硬着头皮选错技能。
还有一次线上故障给我教训很深。当时运营新增了一个“查发票”技能,数据只加了正样本,没有为它构造与其他“订单查询”类技能的难负例。结果一周后线上数据显示,所有订单类技能的整体准确率下降了两个点,原因就是新增技能把原本清晰的分类边界搅乱了。从那以后,所有新增技能都强制进入难负例生成流程,再统一回归测试。
这个内容后续还可以扩展的方向是:把路由模型换成更小的比如3B甚至1.5B模型,在技能选择这种判别式任务上,模型参数量的压缩空间可能比我们想象的更大。如果你也在做相似的系统,我个人建议先从技能描述模板和难负例数据这两步入手,这两块的改动成本最低,收益却往往最大。