上个月,我把手头一个客服类Agent项目重构了一遍,起因特别简单:老板嫌贵、客服嫌笨。系统里的模型、工具全是我在代码里写死的——简单问题也去调大模型,复杂问题又经常被小模型的上下文长度卡住;工具倒是接了一堆,但Agent根本分不清什么时候该查数据库、什么时候该调计算器。憋了一周之后,我决定让它自己来:让Agent自己挑模型、自己挑工具。这篇博客就讲讲我为完成这件事做的五件自制件,以及在这些小零件上反复踩过的坑。
先说清楚定位:这不是LangChain那样的大框架,也不是Harness那种纯调度壳。Harness和Agent的区别,我理解是——Harness负责“让模型能调工具”,Agent负责“决定该不该调、怎么调”。我做的更接近后者,但重点放在“自主选择”上:模型、工具、上下文、验证、复盘,五件自制件串成一个闭环,运行起来后确实省了不少钱,也少了很多人工干预。适合正在做Agent项目、又不想被固定流程绑死的人参考。
1. 为什么我要做“能自己选”的Agent,而不是写死模型和工具
1.1 从一次固定搭配翻车说起
最开始,我的Agent只有一条链路:用户提问 → 丢给GPT-4 → 拿到结果 → 返回。结果有两个问题很明显。一是贵,内部客服场景里大部分问题其实是“你们营业时间几点”“发票抬头是什么”这种高频重复问题,用顶配模型纯属浪费。二是不稳,遇到需要查订单状态的请求,模型没有工具调用能力,只能编一个看似合理的答案,用户自然不满意。
后来我试图做简单的分发:判断问题类型,然后调用不同的模型。但这个判断逻辑写死之后,每加一个新场景就得改代码,改完还要回归测试。更麻烦的是,同一个问题在不同措辞下会被分到不同分支,用户问“你们的退款流程是啥”,和“我要退钱”,明明是一个意图,却走了不同的模型,体验非常割裂。
这时候我意识到,规则永远追不上需求的组合爆炸。与其我去替每个任务挑选模型和工具,不如把这件活交给Agent自己。但“交给Agent”不是把一堆模型ID和工具名字塞给它就完事,那样它会乱来。我需要做一套机制,让它在有限范围内自由选择,又不会跑偏。这就是五件自制件的出发点。
1.2 五件自制件的分工和整体边界
我划分了五块,每块只干一件事:模型选择器负责评估该用哪个模型;工具注册中心负责让Agent“看见”并“选对”工具;上下文管理器负责保证选择过程不撑爆token、不丢关键信息;结果闸门负责验证工具返回的东西是不是真的能给用户;复盘回路则把每次选择的成败反馈回模型选择器,形成自适应。
这五个件从结构上看是一条流量:用户请求进来,先由上下文管理器整理出精简的当前状态,然后工具注册中心给出候选工具菜单,模型选择器结合状态和菜单决定模型,Agent开始推理调用工具,结果闸门验证、必要时触发重试或换选择,最后复盘回路记录一切。
边界原则就三条:第一,任何选择都必须有记录,不能黑盒;第二,Agent有选择权,但硬边界(比如最大重试次数、单次工具调用超时)由我自己设,不交给模型;第三,每个组件都能独立关掉,比如我可以让模型选择器强制固定某个模型,只让工具选择保留自主权。这三条原则在后面帮了大忙,因为出问题时我可以快速隔离,而不是在整条链路上瞎猜。
2. 第一件:模型选择器——让模型“择优上岗”的评分逻辑
2.1 模型评分公式:能力、成本、时延怎么换算成同一个量纲
模型选择器要做的事,是给当前任务匹配一个最合适的模型。难点在于“合适”是个复合概念:一个模型可能能力很强但很贵,另一个模型便宜但回答质量一般,还有一个本地模型响应快但需要硬件资源。不能只看单项指标,所以我设计了一个可调权的评分公式:
score = α × capability + β × context_fit + γ × speed + δ × cost
这四个因子的具体含义是:capability 是该模型处理当前任务类型的能力评分,来自历史成功率矩阵;context_fit 是任务需要上下文长度 vs 模型最大上下文长度的匹配度,太接近上限会扣分,因为容易截断;speed 是最近5次调用的平均响应时间经过归一化后的分数;cost 则是单次调用成本的归一化分数,越便宜分数越高。
这四个因子的权重α、β、γ、δ不是拍脑袋定的,我会根据场景微调。比如问答类Agent,cost的权重可以高一点,capability权重低一点;而代码生成Agent正好反过来。实际用下来,一开始权重太均衡,结果模型频繁切换,体验不稳定。后来我把capability权重调到0.4,context_fit 0.3,speed 0.2,cost 0.1,效果才好一些。但这个比例不是通用的,建议你自己根据线上数据调。
2.2 滑动窗口滤波:为什么模型选择最怕“高频抖动”
模型评分算出来之后,如果每次都选最高分,你很快会遇到一个问题:Agent在模型A和模型B之间反复横跳。这轮用户问“帮我查天气”,轻量模型A得分最高,选A;下轮用户问“写段Python”,重模型B得分最高,选B;再下轮又问天气,又跳回A。每一次切换,不同模型对上下文的处理方式不同,之前会话里的一些隐含状态会丢失,甚至出现“切换模型后原对话不停跳闪”的诡异表现。
我用的是滑动窗口滤波思路:不是根据当前这一条请求的即时分数来选择,而是看最近几轮的平均分。维护一个长度为N的窗口,对每个候选模型记录最近N轮评分,取均值或中位数再比较。N我一般选5或者7,奇数能避免平票。窗口太小拦不住抖动,窗口太大会让模型选择变得迟钝,比如用户连续问了三轮代码问题,Agent还坚持用通用模型,因为旧窗口里聊天类任务权重太大。
实际实现里,我对分数做指数加权移动平均(EWMA)也可以,但滑动窗口更直观,也更好排查。你可以理解为:模型选择器不是“瞬间下注”,而是“看趋势下注”。
2.3 打分函数最小实现
给一个最简可运行的Python实现,方便你理解:
import numpy as np from collections import deque MODELS = { "light": {"capability": 0.6, "max_ctx": 4096, "cost": 0.001, "speed": 8}, "heavy": {"capability": 0.95, "max_ctx": 32000, "cost": 0.02, "speed": 4}, } WINDOW = 5 history = {name: deque(maxlen=WINDOW) for name in MODELS} weights = {"capability": 0.4, "context_fit": 0.3, "speed": 0.2, "cost": 0.1} def score_model(name, task_type, ctx_len): m = MODELS[name] cap = m["capability"] # context_fit:越接近上限越差,留出 20% 余量 fit = min(1.0, (m["max_ctx"] * 0.8) / max(ctx_len, 1)) speed = min(1.0, m["speed"] / 10.0) cost = 1.0 - min(1.0, m["cost"] / 0.03) return weights["capability"] * cap + weights["context_fit"] * fit + weights["speed"] * speed + weights["cost"] * cost def select_model(task_type, ctx_len): current_scores = [score_model(name, task_type, ctx_len) for name in MODELS] for idx, name in enumerate(MODELS): history[name].append(current_scores[idx]) avg_scores = {name: np.mean(list(history[name])) for name in MODELS} return max(avg_scores, key=avg_scores.get)这个实现省略了归一化和日志,但核心是清楚的:先把当前得分入窗,再取窗口均值,最后选均值最高的模型。注意我第一次写的时候忘了把history只读出来,结果窗口越攒越长,模型选择越来越迟钝,后来发现deque的maxlen没生效,是因为我初始化时把同一个deque对象赋给了所有模型。这种低级失误,在真实项目里特别容易埋雷。
3. 第二件:工具注册中心——给Agent看一份合格的“工具菜单”
3.1 工具描述写不好,Agent就不会调用
模型选择器解决的是“用哪个脑子”,工具注册中心解决的是“拿哪双手”。刚开始我图省事,只给工具写一行描述,比如“数据库查询工具”。结果Agent根本不知道什么时候该用,它宁愿瞎编也不调用。后来我才明白,工具描述不是给人看的,是给模型“检索”用的,它得说清楚三件事:这个工具解决什么问题、什么场景下触发、调用时有哪些坑。
我现在的工具描述模板是:
{ "name": "order_query", "description": "查询订单状态。仅当用户询问订单物流、发货时间、退款进度时使用。不要用来查用户余额。", "parameters": { "type": "object", "properties": { "order_id": {"type": "string", "description": "订单编号,来自用户输入,不要猜测"} }, "required": ["order_id"] } }注意description里我把“不要用来查用户余额”也写进去了,这种负面约束特别有效。模型经常会把相似功能的工具混淆,明确排除项能大幅降低乱调率。另外参数一定用JSON Schema描述清楚,别自己发明格式,模型对标准格式的理解是最好的。
描述写太长又会撑爆上下文。我自己的经验是:一个工具的description加parameters控制在200个token以内。如果超过,就拆功能或精简,绝不让Agent在20个工具里翻一个300token的说明书。
3.2 返回结果兼容层:把工具差异关在门内
工具层最大的坑不是调用,而是返回结果格式五花八门。我接过一个老的数据库工具,返回的是带ANSI颜色码的文本,里面还混着SQL日志;另一个内部API返回的JSON里有个字段叫“data”,但有时候是数组、有时候是字符串。模型直接看这些东西很容易懵,导致它编一个“看似合理但完全错误”的回答。
解决办法是在工具注册中心外面套一个兼容层:每个工具注册时,都要声明自己的返回类型,并提供一个normalizer函数,把原始返回统一成下面这个结构:
{ "status": "success", "data": {...}, "error": null }normalizer负责把冷启动错误、超时、格式非法等情况都转成结构化的error信息。模型看到这个结构后,判断下一步行动就容易多了:status是success就用data,是error就看error决定重试还是换工具。这样把工具之间的差异关在门内,Agent的推理逻辑可以保持统一。
3.3 工具调用的可观测性设计
工具注册中心还负责记台账。每一次工具被选中、调用成功、失败、超时、重试,都要写入日志。我一开始没做这块,结果无法复盘——用户反馈“这次答得不对”,我根本不知道是模型选错了,还是工具选错了,还是工具本身返回了脏数据。
台账里最少要有这几项:本轮任务ID、选中的工具名、工具描述快照、入参、返回状态、耗时、错误信息。有了这个,后面第四件“结果闸门”和第五件“复盘回路”才有数据可用。可以说,工具注册中心是整个系统里最不酷、但最不能缺的一块。
4. 第三、四件:上下文管理器和结果闸门——给自由选择兜底
4.1 上下文滑动窗口:压缩和重建的取舍
让Agent自己挑模型、挑工具,最直接的副作用是上下文体积快速增长:候选模型列表、工具描述、历史选择记录,全都得塞进prompt。我以前试过把20个工具的描述全放进去,模型直接开始乱答,因为有用信息被淹没了。所以必须有一个上下文管理器,它做三件事。
第一,历史压缩。不是简单地把旧对话删掉,而是用滑动窗口保留最近N轮完整对话,更早的内容交给一个小模型或摘要器,压缩成三五句话的摘要。第二,工具列表动态裁剪。如果当前任务明显和订单相关,就不要把天气、计算器、翻译等不相关工具全部列出来,而是用轻量模型或规则先粗筛一遍,把候选工具从20个减到5个。第三,做“上下文预算”。按当前模型的max_ctx反过来控制prompt拼装,工具描述、历史摘要、当前问题按比例分配token,超了就先压缩历史,而不是截断当前任务。
模型切换时上下文管理器要特别小心。不同模型的上下文格式不完全兼容,尤其本地模型和云端模型混用的时候(比如Claude Code调用本地LM Studio的模型,再切回云端模型,旧历史里可能残留对方不认识的控制符)。我的做法是:切换模型时,如果检测到原对话里有特殊标记,就先强行走一遍摘要流程,用干净文本把历史重建一遍,而不是带着原始串直接喂给新模型。
4.2 结果闸门:不能工具说成功就成功
工具返回来了,Agent直接拿去用,这是另一个大坑。“status: success”不代表结果是对的。比如订单查询工具可能返回一个空列表,但原因不是“该用户没有订单”,而是“数据库连接超时后被降级成了空结果”。又比如计算器工具返回了一个float,但精度不够,四舍五入后对账差了一分钱。这些情况,模型单看status是发现不了的,所以我加了结果闸门。
结果闸门的规则很简单:对每个工具声明它的成功标准。订单查询的成功标准是data里至少有order_id,计算器工具的成功标准是返回字段里有原始算式和结果两个字段,数据库工具的成功标准是error为空且影响行数大于等于0,等等。只要不满足,就触发重试:先询问工具层是否有多余的详细信息,再让Agent换一种调用参数或换一个工具,最多重试两次,超过就向用户道歉并转人工。
这个闸门还有一个作用:防止“模型自嗨”。有些Agent在工具返回格式错误时,会选择忽略工具结果、自己编一个答案,结果闸门能直接终止这种路径,迫使它去修复工具调用流程。本质上,它是在给“自由选择”加一道安全带。
4.3 并发与沙箱控制
还有一个不能忘的现实问题:Agent扛并发。很多Agent框架demo演示时只处理单线程,一上线就崩。模型选择器可能同时给几十个请求决策,工具注册中心也要面对几十个工具调用并发。我在系统里加了两个“刹车”:一是每类工具的并发调用数限制,用信号量控制,比如数据库查询工具最多同时3个,避免模型一激动同时调20个;二是所有工具调用都必须有超时,默认5秒,到期还没返回就标记失败,并把超时错误交给重试逻辑。
沙箱也很重要。有些工具能执行代码或写文件,Agent在选工具时可能不知道这个工具有什么副作用。我给程序执行类工具加了一行专门的描述:“此工具会在隔离容器中运行,不能访问真实文件系统,不要用来读取敏感信息。”描述本身就提醒模型别拿它干越权的事,同时底层再用docker限制网络和文件权限。双保险,比只靠模型自觉靠谱得多。
5. 第五件:复盘回路——让Agent从“选过”变成“选对”
5.1 记录哪些信号,才算完整的一次决策
五件自制件里,复盘回路是让整个系统“长脑子”的关键。没有它,模型选择器只能靠主观权重打分,永远不知道自己的选择到底是好是坏。我定义了一条完整的决策记录,包含:任务类型、候选模型、实际选中模型、候选工具、实际选中工具、调用耗时、花费、结果闸门是否放行、用户后续是否有纠正动作。
这里有个容易忽略的点:用户反馈不一定是显式的。用户没有点“不喜欢”,但追问了一句“你确定吗”,这本身就是弱反馈信号。我把这类行为也记下来,作为“可见的不满意度”。虽然它不能直接进评分公式,但分析时能帮我发现模型或工具的系统性问题。
5.2 用EWMA做在线反馈,避免一次失败带偏全局
拿到历史记录后,怎么更新模型选择器的capability矩阵?最直接的办法是:成功了就加分,失败了就减分。但这样太敏感。比如某模型被选中100次,其中2次失败,如果其中一次是用户故意输入乱码导致的,那次失败不应该让该模型的评分大幅跳水。
我用了EWMA(指数加权移动平均)来做更新,每次成功或失败都只对评分做小幅调整:
alpha = 0.1 new_capability = alpha * observed_success_rate + (1 - alpha) * old_capability这个alpha取0.1,意味着新的一次结果影响有限,需要积累一段时间才能明显改变评分。实测下来,这个数字既不会让选择器反应迟钝,也不会被单次异常带偏。如果你想更快响应变化,可以调到0.2,但我建议先保守一点,让系统先稳定再灵敏。
5.3 防污染机制:不要让脏数据污染选择器
复盘回路有个隐蔽风险:输入数据是脏的。如果用户问了个内容违规的问题,Agent拒绝回答,这个“拒绝”被记成“失败”,模型可能因为一次合理拒绝而被降分。又比如工具返回了格式错误,但Agent靠重试绕过去了,最终结果没问题,结果闸门放行了,那这次的“最终成功”不能说明模型选择是对的,因为中间已经费了很大周折。
我加了两个门槛:一是只有在结果闸门明确判定success或fail之后,才允许更新评分,中间态一概不更新;二是对用户取消、重复提问、明显恶意输入的任务,单独打标签,不进评分矩阵。这样能避免选择器被“脏样本”污染,也不会出现“有些模型因为不愿意说违心话被打低分”的荒谬局面。
模型中毒攻击是另一个要防的点——不是网络安全意义上的攻防,而是数据层面的。如果复盘回路的日志可以被篡改,有人把大量“某工具成功”的假记录灌进去,选择器就会被带偏。所以我给日志加了一层校验:重要记录只允许追加,不改写;分析脚本只能读最近N天的数据,不能全量重算历史。
6. 我踩过的一堆坑,以及排查思路实录
6.1 模型选择在高频任务上反复横跳
第一个大坑就是模型选择器震荡。现象是用户多轮对话中,Agent每隔两轮就在轻量模型和大模型之间切换,切换后原对话记录经常出现跳闪,用户体验很糟糕。排查时我先看了选择日志,发现打分权重里的cost因子权重设得太高,轻量模型偶尔因为上下文短而拿高分,下一轮上下文变长又换成大模型——每次单个请求的评分都在波动,但整体任务并没有变化。
解决办法有两层:一是加滑动窗口,这是架构层面的;二是给切换动作加惩罚项——如果上一轮选的是A,这一轮候选模型B的最终得分要扣掉一个固定惩罚值(比如0.05),让选择器倾向于“保持现状”。惩罚值不能太大,否则任务真正变化时切不过去;我试过0.03和0.05,后者在客服场景里更稳。
6.2 工具描述撑爆上下文:从一阶段到两阶段选择
接入工具数量到15个以后,prompt明显变肿。某次线上问题排查时,我发现一个工具的描述有340个token,加上参数说明接近400,把历史摘要的空间都挤没了,模型开始忽略上下文,回答越来越机械。第一版我想的是压缩描述文字,但怎么压都怕丢失关键约束。后来改成两阶段选择:先用规则或轻量模型,根据用户问题里的关键词粗筛工具,只把候选工具的描述拼进prompt,其他工具用半行文字列个名字。实测粗筛把候选从15个压到4个,prompt体积立刻降了一半,而且模型调用准确性反而提高了,因为干扰项少了。
6.3 工具返回格式千奇百怪,解析器差点崩掉
另一个经典问题:工具返回结果格式太乱。某次接入内部知识库系统,返回的是XML,里面夹着几个非法字符,我用的标准JSON解析器直接抛异常,Agent拿不到结果就乱答了一通。后来我写了一个lenient解析器:先做预处理,去掉控制字符、修正尾随逗号、把裸key变成带引号的key,再解析。兼容层上线后,这类问题少了很多。重要教训:任何工具返回,都不要假设它是干净的,宁可多写几行防御代码。
6.4 本地模型与云端模型混用时的超时与上下文错乱
我还做过一版混用方案:云端模型负责复杂推理,本地模型负责脱敏处理。本意是省成本,结果发现本地模型响应时间波动很大,有时顶配GPU也要算好几秒,工具调用超时频繁。排查时发现模型选择器里speed因子用的是静态值,但我接的是本地模型服务,它的实时负载一直在变。后来我把speed因子改成该模型最近5次实际调用的移动平均响应时间,并且把工具调用超时从5秒放宽到8秒,问题才缓解。另外本地模型和云端模型切换时,上下文里的隐藏字符不一致,会出现“跳闪”,必须在切换前做一次干净的摘要重建,不能直接拼接历史。
6.5 滑动窗口平滑的副作用:对任务切换反应迟钝
滑动窗口解决了震荡,但带来了新问题:任务切换反应迟钝。用户连续问了三轮天气后突然问“帮我写个正则表达式”,模型选择器还停留在天气场景,选了轻量模型,结果生成质量惨不忍睹。我的修复是在滑动窗口之外加了一个“任务类型突变检测”:用当前用户输入与上一轮输入计算一个简单的语义相似度,相似度低于阈值时,直接把窗口清空,重新开始计数。阈值我设的0.3,调得太高会频繁清窗,太低又等于没有。清窗后还要把切换惩罚项临时置零,保证新任务能自由跳到合适的模型上。
排错时还有一个小技巧:给每个选择记录打trace_id,一路串起模型选择、工具选择、工具调用、结果验证、复盘更新。这样一旦用户在某个环节反馈异常,我能从日志里把整条链路拉出来,而不是对着分散的日志猜来猜去。这套trace机制,是我踩着上面五个坑换来的,强烈建议你在设计第一天就加上。
7. 五件自制件跑通之后,我的真实体会
这套系统稳定运行了一个多月,成本大约降了40%,用户满意度没降反升。但我还是要说句实话:不要把“让Agent自己选”理解成“彻底放权”。我的做法是在模型选择、工具选择上给Agent自主空间,但在上下文预算、重试次数、并发上限、结果验证这些关键节点上,全部用硬代码兜底。自主和约束不是对立的,约束越清晰,自主越安全。
最后分享一个后续想做的扩展:目前工具注册中心的粗筛还是规则为主,我想把它也交给一个轻量分类模型,让工具选择更自然。另外复盘回路里的EWMA虽然稳定,但对突发性工具故障响应还是慢了,我准备加一个“熔断器”——如果连续三次同工具失败,短期内直接冻结它,不让模型选择器再选它。这些都是在五件自制件基础上可以继续长出来的东西。你如果也在做类似的Agent,建议先从最不起眼的“日志和台账”做起,那一件最不炫技,但能救你最多次。