模型切换是迁移非开关:审核修正模型切换是迁移非开关:审核修正
上周五我改了一个环境变量,把客服回复功能指向另一家模型供应商,然后下班回家,觉得自己挺聪明。周一回来,导出任务里已经积了三天的数据,refund_days 字段全是空值。没人报警,因为界面照常渲染回复文本,悄悄忽略了那个缺失的字段。
我第一反应是怪新模型。第二反应更诚实一点:为什么我会在没有任何测试样本、没有差异对比、没有卡点的情况下,上线一个会影响数据结构的改动?我们早就不在生产环境手改 SQL 来迁移数据库了,可为什么换掉那个生成持久化数据的东西,还是靠改一行配置?
把立场说清楚:换模型是一次迁移,不是一个开关。输出会落进你的数据库列、你的重试分类器、你的导出文件、你客户的收件箱——只要你把它存下来,你就背上了结构义务。开关意味着干净可逆、不留残渣,而任何回滚过半个迁移的人都知道这有多虚构。所以真正值得争的不是这个月哪个模型赢,而是你换掉手上这个之前,要求看到什么证据。
为什么你的测试套件在这里救不了你
集成测试断言的是你手写的预期,所以它擅长抓你已经想到的 bug。它几乎说不出新供应商在你团队上个月处理的那四百张真实工单上会怎么表现,包括那些带部分退款、混合语种的难看案例。我见过全绿的测试套件放行一次供应商切换,结果打挂了百分之三的导出;也见过全红的套件拦住一次严格更优的改动。两种结果都没告诉我任何关于真实流量的信息。这不就是最该先补上的缺口吗?
最便宜也最诚实的证据,是在动供应商之前,从生产环境冻结一份样本语料。不是合成提示词,不是从文档里抄来的黄金集,而是你已经花过钱处理过的真实输入,把个人数据剥掉。样本一旦存在,切换就变成一次可以随时重跑的比较,而不是你在代码评审里辩护的观点。
工具链:捕获、影子、对比、卡点
我现在的做法分四步:捕获、影子、对比、卡点。捕获为每个真实请求写一个 JSON 文件,保留输入和基线输出,供后续运行比较。
# shadow/capture.py (sketch)import json, pathlib, datetimedef write_fixture(root, request_id, payload, baseline_raw, provider):path = pathlib.Path(root) / f"{request_id}.json"path.write_text(json.dumps({"id": request_id,"captured_at": datetime.datetime.utcnow().isoformat() + "Z","input": payload,"baseline": {"provider": provider, "raw": baseline_raw},}, indent=2))脱敏必须在写入之前完成,绝不能之后——一个泄露工单文本的样本目录,是在第一起事故上再叠一起。脱敏器放在同一个模块里,凡是匹配邮箱、电话、订单号模式的统统丢掉。样本于是成了拉取请求里可评审的产物,这正是迁移需要的东西。
影子运行把每份样本重放进候选模型,独立打两项分:载荷是否还能解析成数据库期望的形状,以及文本相对基线漂移了多远。
# shadow/run.pyimport json, timeREQUIRED = {"reply": str, "refund_days": int}def shape_errors(raw):try:data = json.loads(raw)except json.JSONDecodeError as exc:return [f"unparseable: {exc.msg}"]return [f"{k} missing or wrong type" for k, t in REQUIRED.items()if not isinstance(data.get(k), t)]def token_overlap(a, b):ta, tb = set(a.lower().split()), set(b.lower().split())return 0.0 if not ta or not tb else len(ta & tb) / len(ta | tb)def shadow(adapter, fixtures):rows = []for fx in fixtures:started = time.monotonic()raw = adapter.complete(fx["input"])rows.append({"id": fx["id"],"shape_errors": shape_errors(raw),"overlap": round(token_overlap(fx["baseline"]["raw"], raw), 3),"latency_ms": int((time.monotonic() - started) * 1000),return rows词元重叠率是绊线,不是法官,这点我要说明白:它惩罚合理改写、奖励偷懒照抄,所以它只能告诉你下一步该看哪里。我把这个分数和人工阅读重叠率最低的十份样本配在一起,花二十分钟,抓到的真问题比任何自动指标都多。
卡点是把观点变成可执行的地方。形状失败是绝对的,漂移只有很小的预算,CI 真正读的是退出码。
# shadow/gate.pyimport sysdef evaluate(rows, overlap_floor=0.90, max_drift=2):shape_failures = [r["id"] for r in rows if r["shape_errors"]]drifted = [r["id"] for r in rows if r["overlap"] < overlap_floor]if shape_failures:print(f"shape failures {len(shape_failures)}:", shape_failures[:10])return 2if len(drifted) > max_drift:print(f"drift {len(drifted)} > {max_drift}:", drifted[:10])return 3print(f"pass: {len(rows)} fixtures, {len(drifted)} drifted")return 0sys.exit(evaluate(rows))这张表拒绝做一件事:它从不假装在衡量质量。质量需要人或标注集,假装不是这样,团队就会去信一个只量了格式的数字。
免费层的位置,诚实地说
说点让我愿意动手试的现实部分。MonkeyCode 是一个开源项目,提供免费模型访问和免费服务器选项,这个组合和上面这套工具意外地贴合:免费模型访问给你一个候选槽位去做对比,不用先走采购流程;免费服务器选项给你一个跑影子任务的地方,不用借生产容量。声明:本文是作为 MonkeyCode 产品推广的一部分准备的。
不过这两样我只当便利,绝不当架构。我没有做过长期可靠性或配额测量,所以影子机器上不存任何状态,并假设每个任务明天都能在别处重跑。这套工具在设计上与供应商无关,这正是适配器接口的意义:候选槽位没了,我改指向一个类,重放同一批样本。问问自己,你的切换方案能不能扛住候选供应商毫无预告地消失——这个问题现在问,比在事故中问便宜得多。
还有一条边界值得说出口。影子工作进程只拿只读样本,完全不碰数据库凭证。如果它能写它正在评估的那张表,它就不是影子了,它是另一个带不同提示词的生产写入者。
什么时候不该这么做
输出确实是创意性的、结构比较毫无意义时,别建,你会花一周造一个看不见用户在意什么的指标。还没有真实流量时,别建,二十条手写提示词教不了你什么,只给你虚假的信心。每一份输出在到达客户前都有人审时,别建,那个世界里审核者本身就是卡点。
这套方法还会对一类团队悄悄失效:样本捕获一次就再也不刷新,那不过是照着去年的表结构写的迁移测试。按计划刷新语料,淘汰超过一个季度的样本,把样本数量当健康信号而不是奖杯。
可复用的清单
- 从真实流量捕获样本语料,写入前完成脱敏。
- 把要求的输出形状写在依赖它的解析器旁边。
- 候选模型以影子方式运行,无写权限、无共享状态。
- 先卡形状失败,再卡漂移,延迟和成本作为评审备注。
- 信任任何聚合数字之前,先人工读最差的那十份重叠样本。
这些都不稀奇,整套工具几百行就装得下。正因如此,我很难为“我们就试试新模型,盯着仪表盘”这种说法辩护。盯着仪表盘,就是我丢掉三天 refund_days 的方式,而仪表盘全程是绿的。
所以我想摆给你的论点很简单。下次有人提议换供应商,按顺序要三样东西:样本、漂移预算、退出码。如果答案是一张感觉良好的表格,那这次切换就是一次盲跑的迁移。如果你真在免费服务器选项上把这套工具立起来,告诉我哪一层先坏:捕获步骤、影子工作进程,还是你选的那个阈值。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.