8.5 万个模型分三方对账:一个 Agent Skill 是怎么从跑不完改到 20 分钟的
8.5 万个模型分三方对账:一个 Agent Skill 是怎么从跑不完改到 20 分钟的
TL;DR 改了一段核心代码之后,怎么证明它算出来的结果和原来一模一样?我的做法是把线上真实流量复制一份给新代码跑,然后逐条对答案。
对答案这件活,人工根本做不完:每天 8.5 万个结果,分散在 36 台机器上。于是我把它交给 AI,做成了一个一句话就能触发的 Skill。第一版跑了 3 个多小时也没跑完;按三条思路改完后,20 分钟出结果。 对账过程中,还顺带揪出了一个在老代码里藏了很久的多线程 bug。
不懂我们的业务也没关系,文中每一节最后都有一条换个场景也能用的经验。
一、先讲清楚:我们在对什么账
上一篇讲过背景:我重构了一个风控服务里给用户打分的逻辑,让它变快。风控分数不能错,所以上线前必须证明:新代码算出来的每一个分,都和老代码一样。
最可靠的验证方式,是用线上的真实请求来测。这里会用到几个工具和概念:
- tcpcopy:一个流量复制工具。线上机器每收到一个请求,它都会原样复制一份,发给另一台机器。相当于让新代码跟着线上一起“考同一张卷子”。
- 沙箱:和线上隔离的测试机器,只接收复制过来的流量,算出的结果不会返回给真实用户。
- 流水号:每个请求的唯一编号。对账时就靠它把三台机器上的同一个请求对上。
为了区分“新代码的问题”和“测试环境本身的差异”,我准备了三台机器一起考试:
| 机器 | 跑什么 | 作用 |
|---|---|---|
| 线上 | 老代码 | 标准答案 |
| 沙箱 A | 老代码 + 测试环境 | 看测试环境本身会带来多少偏差 |
| 沙箱 B | 新代码 + 测试环境 | 看新代码有没有问题 |
判卷规则很简单:三台机器里只要有两台答案相同,就算一致;三台答案各不相同,才需要人去看。
为什么要这样定?举个例子:如果 A 和 B 一样、只是和线上不同,说明差异来自测试环境,和新代码无关,可以放心。
这样的对照一共有 12 组,每天产生约 8.5 万个分数。每个分数背后还有约 3000 个计算用的中间变量(下文称“入参”)。用人工去对,根本不可能。
可带走的经验:验证一次重构,最有说服力的方法不是写多少测试用例,而是拿真实流量让新老代码同时跑,再逐条对答案。同时要准备一个“对照组”(这里的沙箱 A),用来把环境带来的噪音和代码问题区分开。
二、第一版:AI 写得没错,但跑不完
我把需求告诉 AI,它很快写出了第一版对账脚本。逻辑完全正确,但跑不完。
10 月 9 日第一次跑完整的 12 组,跑了 3 个多小时,其中一台机器卡了 15 分钟没有任何动静。最后我只能叫停:没拉到的先不要了,拿已经拉到的先出结论。
问题出在拉数据的方式上。我们的服务器在内网,要先登录一台“跳板机”,再从跳板机跳进目标机器,而跳板机限制了同时登录的数量。第一版脚本的做法是:
- 一台一台地登录,把整份日志拉回本地,每台约 300MB;
- 不管用不用得上,先全部拉回来再说。
一句话总结:排队搬运,而且搬的大多是用不上的东西。
可带走的经验:让 AI 写数据处理脚本时,如果你只说“要做什么”、不说“要多快、数据量有多大”,它默认会选最稳妥的写法:全量拉回、逐个处理。性能约束要你主动给出来。
三、从“跑不完”到 20 分钟:三条优化思路
10 月 12 日,我给 AI 提了三条要求。这三条思路和具体业务无关,大部分“批量拉数据再比对”的场景都能用上。
思路 1:别排队,各干各的
12 组对照之间没有依赖关系,完全不必排队。改成每组一条独立的流水线:
拉沙箱 A、B 的数据 → 拉线上数据 → 初步比对 → 有问题再补拉 → 出结论
哪组的数据先拉完,哪组就先往下走,不用等其他组。跳板机能承受多少并发,就用一个开关控制同时登录的数量(默认 12,跳板机报错多就调到 6)。
分组信息也不再手动填写,脚本直接读取流量复制平台上的任务列表,自动配对出“哪台线上机器对应哪两台沙箱”。
思路 2:在服务器上先筛一遍,只带回需要的
这是提速最关键的一步。 与其把 300MB 日志整份拉回本地再筛选,不如在服务器上先筛一遍,只把结果带回来。
最大的那块数据是每个请求的 3000 个入参。这里用到了“指纹”的做法:
把 3000 个变量按名字排好序,拼成一个长字符串,再计算它的 MD5 值。得到的结果只有十几个字符,但只要这 3000 个变量里有任何一个不同,这个值就会完全不一样。
这就像核对两本书是否完全相同,不用逐页比对,只要比较两本书的“校验码”就行。代码只有三行:
def fp(variables):
s = sorted(f"{name}\x01{value}" for name, value in variables.items())
return hashlib.md5("\x02".join(s).encode("utf-8")).hexdigest()[:16]
两台沙箱的指纹相同,说明 3000 个入参完全一样,不用再看细节。这样一来,每台服务器只需要回传打分结果和指纹。
思路 3:有问题的再细看
只有两种情况需要回头拉明细:两台沙箱的指纹不同,或者三方答案各不相同。这时才针对这几个请求,再登录一次服务器,把完整的入参、发给模型的请求和调用过程拉回来。
8.5 万个结果里,需要这样细看的只有几十个。
实际效果
| 第一版 | 优化后 | |
|---|---|---|
| 每台服务器回传的数据量 | 约 300MB | 约 0.3MB |
| 12 组全部跑完 | 3 个多小时,没跑完 | 最慢的一组约 20 分钟 |
优化完我没有马上去跑线上,先拿 10 月 9 日已经拉回来的数据在本地重放了一遍,确认新旧两版脚本的结果逐字段一致。性能优化最怕的是“快了,但结果错了”。
几个只有真跑才会遇到的坑
- 服务器上只有 Python 2.7。第一次真实运行时,脚本里有一行中文注释,Python 2 直接报语法错误,回传的是空数据。之后做了两处调整:脚本只用英文字符;先在服务器上做一次自检,不通过就自动退回用 Linux 自带的文本工具来处理;
- 网络会抖动:单台失败自动重试 2 次,仍然失败的单独标成“拉取失败”;
- 拉到的数据都缓存在本地:跑到一半中断了,重跑时直接复用已经拉到的部分。
可带走的经验:大批量比对数据,可以按这三步来:并行、就地筛选、先粗后细。用指纹做粗筛,把逐条比对变成比较一个短字符串;只对有差异的数据拉明细。另外,改造前先留一份可以重放的数据,用来验证改完以后结果没变。
四、不接受“需要人工核对”
第一版脚本遇到解释不了的差异时,会在“原因”一栏写“需核对”或“疑似 xxx”。我直接打了回去:
“需要核对的,你直接去核对,原因里要写明确的结论。”
把核对工作留给我,等于没做。 AI 后来把自己的排查过程整理成了一套固定的判断流程,遇到差异时一层层往下查:
| 看到的现象 | 结论 | 是不是新代码的问题 |
|---|---|---|
| 只有“是否命中缓存”的标记不同 | 两台沙箱读缓存的时间点不同,一台读到了,另一台没读到 | 不是 |
| 只有和时间相关的变量不同 | 两次计算恰好跨过了一个时间点 | 不是 |
| 老代码对同一个查询自己查了两遍,两次结果就不一样 | 老代码自身的波动 | 不是 |
| 入参完全一样,模型返回的分数却不同 | 模型平台侧的问题 | 需要找平台确认 |
| 模型明明返回了分数,最后取到的却是空值 | 见下一节 | 是 bug |
每条结论都必须附上证据:是哪几个变量、三台机器上各是多少、对应哪一次调用。打开结果表,看到的是一句完整的结论,而不是一项待办。
结果表也只写需要人看的行:三方都不同的才写明细,其他情况只在汇总里计数。之前每一行都写,一天一万多行,保存时次次超时;现在一天只有几十行。
可带走的经验:给 AI 定一条规矩:不允许输出“建议人工核对”。它其实能查下去,只是默认会把麻烦的部分推给你。规矩定下来之后,再让它把排查思路整理成固定流程,下次遇到同类问题就能自动归因。
五、意外收获:一个藏了很久的多线程 bug
判断流程里,有一类情况一直解释不了:模型平台明明已经返回了分数(而且和另一台沙箱的分数一模一样),最后取到的却是空值。 这个分最后就成了默认值 -99。
先用一个比喻讲清楚问题
把调用模型想象成寄快递:
- 你(请求线程)把包裹交给快递员,快递员给你一张回执,说“结果稍后送到”。这时回执上什么都没写,是空的;
- 稍后,结果被送到(回调线程),放进你桌上的收件盒;
- 你拿着那张空回执回到座位,按习惯把回执也塞进同一个收件盒,把刚送到的结果盖住了;
- 后面的人打开收件盒一看:空的。
对应到代码上,是两个线程在写同一个变量,中间没有任何保护:
// 请求线程:发起异步调用,request() 本来就会立刻返回 null(空回执)
response = request();
// 回调线程:结果到了,写进同一个变量(收件盒)
response = 模型返回的结果;
正常情况下,第 1 步比第 2 步早很多,不会出问题。但如果结果送得特别快,抢在第 3 步之前到达,结果就被空回执覆盖了。 这就是常说的“竞争条件”(race condition):程序的结果取决于两个线程谁先谁后。
10 月 12 日:AI 发现了疑点,但没能定案
我挑了一条有问题的数据,让 AI 对着代码查。它找到了上面这个隐患,但也很诚实地说:这一条数据里,空回执比结果早到了 21 毫秒,所以不是这个原因,我没能定案。
这时有两个选择:直接把隐患修掉,或者先加日志观察。我选了后者:
“加上诊断日志,这个隐患也先只打日志,看看到底是不是它导致的。”
原因是:如果直接修掉,就再也没法证明问题到底是不是它引起的。 修完以后不一致消失了,可能是修对了,也可能只是这几天碰巧没出现。所以先让问题“看得见”,再让数据说话。
10 月 14 日:日志抓到了现场
带诊断日志的版本部署一天后,抓到了一个完整的现场(时间精确到毫秒):
| 时间 | 谁 | 发生了什么 |
|---|---|---|
| .242 | 请求线程 | 拿到空回执 |
| .254 | 回调线程 | 结果到了,放进收件盒,分数和另一台沙箱完全一样 |
| .255 | 请求线程 | 这时才把空回执塞进收件盒,结果被覆盖 |
| .255 | 回调线程 | 去收件盒取结果,取到的是空 |
从拿到空回执到塞进收件盒,中间只隔了 13 毫秒,结果恰好就落在这 13 毫秒里。这台机器一整天只发生了这一次。
更关键的是,出事的是跑老代码的沙箱 A。也就是说,这个 bug 和这次重构无关,线上一直都有。只是概率太低,表现又只是“某个分偶尔变成 -99”,单看线上日志根本不会有人注意到。只有在三方对账里,同一个请求另一台沙箱有分、这台却没有,它才一下子显露出来。
修复:只改两处
protected volatile O response; // 加 volatile:一个线程写入后,其他线程能立刻看到
O requestReturn = request();
if (requestReturn != null) { // 空回执不往收件盒里放
response = requestReturn;
}
AI 还提了一个更彻底的方案:重构回调的处理方式,彻底不再共用这个变量。但这是所有异步调用共用的基础代码,我选择先用最小改动止血,重构以后单独做。
顺带一提,AI 在这次排查中也判断错过一次。最早它把原因写成“回调本身返回了空值”,诊断日志出来后才证明不对:回调拿到的是正常分数,只是后来被覆盖了。所以 AI 的结论也要有证据。 没证据的时候,它说一句“我没能定案”,比给一个听起来很像的答案有价值得多。
可带走的经验:
- 偶发、复现不了的 bug,先加日志让它可观测,别急着修。否则修完了你也不知道修没修对;
- 多个线程写同一个变量,一定要想清楚谁先谁后。“正常情况下 A 总比 B 早”不是保证;
- 对比测试除了能验证新代码,还能把老代码里的低概率问题暴露出来。
六、做一个长期可用的 AI Skill,我的几条心得
“Skill”可以理解成交给 AI 的一份标准作业手册,里面写清楚这件事怎么做、用什么脚本、有哪些坑。之后我只要说一句“跑一下 13 号的对比”,它就照着手册从头做到尾,把结论写进在线表格。
| 心得 | 具体做法 |
|---|---|
| 一句话就能触发 | 日期、分组、结果写到哪里,全部有默认值 |
| 主动给出性能约束 | 不给的话,AI 默认全量拉取、逐个处理 |
| 先粗后细 | 先用指纹粗筛,有差异的再看明细 |
| 改造前先留重放数据 | 新旧两版跑同一份输入,结果逐字段比对 |
| 失败要分类 | “拉取失败”“没有数据”“找不到对应请求”分开统计,各自处理 |
| 不接受“待核对” | 每个结论都要附上证据 |
| 只输出需要人看的部分 | 明细只写异常,其余只计数 |
| 踩过的坑写回手册 | 服务器只有 Python 2、表格太大会超时……写进去,下次就不会再踩 |
最后一条最容易被忽略:Skill 文档不只是一份使用说明,它是 AI 的长期记忆。 AI 每次对话都是从零开始,你不写下来,它就会在同一个坑里再摔一次。
结语
这套对账里,几乎所有代码和排查都是 AI 做的。我做的事情只有三件:
- 提约束:并行、就地筛选、先粗后细;
- 定标准:“需要核对”不算结论;
- 在岔路口拍板:先加日志,不急着修;修的时候只做最小改动。
AI 能跑得很快,但往哪个方向跑、跑到哪儿算完,要由人来定。
下一篇:服务器一升级,24 个流量复制任务全挂:AI 15 分钟查清根因,之后一句话修好。其中有一个让任务一启动就报错的坑:同一台机器,居然有两个不同的 IP。