
上个月我把手头一个客服类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 则是单次调用成本的归一化分数越便宜分数越高。这四个因子的权重α、β、γ、δ不是拍脑袋定的我会根据场景微调。比如问答类Agentcost的权重可以高一点capability权重低一点而代码生成Agent正好反过来。实际用下来一开始权重太均衡结果模型频繁切换体验不稳定。后来我把capability权重调到0.4context_fit 0.3speed 0.2cost 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(maxlenWINDOW) 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, keyavg_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建议先从最不起眼的“日志和台账”做起那一件最不炫技但能救你最多次。