AI 把 Java 翻译成 Go 后语义悄悄变了,怎么逐块验出来

2026-07-29

数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。

**大多数人把跨语言迁移出错归因于「模型不够聪明」,这个判断是错的。**真正的原因是:翻译代码这件事,AI 做的是逐行的字面等价,而两种语言之间真正会咬人的地方,是那些「源语言里被默认省略、目标语言里必须显式写出来」的语义。空值怎么传播、整数溢出怎么处理、除法是截断还是取地板、时间戳带不带时区、异常是抛出还是返回错误值、并发下的可见性由谁保证——这些在原代码里可能一个字都没写,因为源语言的运行时替你兜了。AI 读不到不存在的字符,它翻译的是你写出来的那部分,然后用目标语言的默认行为补上剩下的。补出来的东西编译得过、测试也可能过,但语义已经变了。

所以这类问题不是「让模型再想想」能解决的,而是要把「哪些语义必须由人来钉死」提前列出来,再按块验证。本篇讲的是迁移这个工程环节:AI 能帮到哪一步、哪一步必须人来定、怎么逐块验、什么时候该止损。

站内另外两篇和本篇分工不同——AI 改坏代码怎么回滚 讲的是改动已经出事之后怎么把仓库拉回安全点,测试假通过怎么排查 讲的是测试绿了但根本没断言到东西这一类信号失真;本篇关注的是迁移场景下「代码跑得起来、测试也绿、但语义已经偏了」的那段,重点在于怎么设计能抓住语义差的对拍验收动作。

一、先分清你遇到的是哪一类偏差

跨语言迁移的故障现场通常长成这样:新版本上线,绝大部分请求正常,某个边界输入下结果和旧版对不上;或者账目差了几分钱;或者高并发下偶发脏数据。你需要先把它归到某一类,因为不同类的验证手段完全不同。

我把常见偏差分成五类:

数值语义差。整数除法在有的语言是向零截断、有的是向负无穷取地板,负数参与运算时结果直接不一样。整数溢出在有的语言是回绕、有的是抛异常、有的在编译期就拒绝。浮点转十进制、四舍五入的进位方向(银行家舍入 vs 常规四舍五入)也常常不一致。金额计算尤其容易中招。

空值与零值语义差。源语言里的 null、目标语言里的零值不是一回事。一个字段「没传」和「传了空字符串」在 JSON 反序列化后,落到目标语言可能都变成同一个空字符串,于是「不更新这个字段」的逻辑被翻译成「把这个字段更新为空」。这是我见过最隐蔽也最贵的一类。

时间与时区语义差。有的语言的时间类型天然携带时区,有的默认是本地时区,有的默认 UTC。跨语言迁移时如果两边对「naive 时间」的默认解释不同,同一份数据库里的时间戳读出来会差几个小时,而且只在跨时区部署或夏令时切换时暴露。

错误传播语义差。异常机制和错误返回值机制的转换是最容易被 AI 简化的:源代码里一个 try 包住三个调用,翻译过去可能变成只检查了第一个调用的错误,后两个错误被忽略。或者反过来,源代码里某个异常本来是被上层统一捕获的,翻译后变成就地吞掉,日志都不打。

并发语义差。共享变量的可见性、锁的可重入性、集合类是否线程安全,各语言默认值不同。源代码依赖某个容器天然线程安全,翻译到目标语言用了普通 map,单测跑不出问题,压测才炸。

判断顺序上,先看现象是「确定性错」还是「偶发错」,注意这里的判断标准是「输入固定时能不能稳定复现」:数值、空值、错误传播这三类是确定性的,同一份输入喂进去必然重现,可以直接用固定用例咬住;并发是偶发的,同一份输入跑一百遍可能只错两遍,必须靠压力和竞态检测才能逼出来。时间这一类要单独说:它在固定输入下也是确定的,但输入里藏着一个你平时不当输入看的变量——机器时区、当前时刻、夏令时状态,所以表现出来像偶发,其实是把系统时间当成参数就能稳定复现。判定时把系统时间显式当成一维输入,它就归回确定性那一侧了。这一步定错了,后面的验证全是白费力气:拿并发问题去跑固定用例,跑一万遍也是绿的。

二、判别表:从现象倒推成因

现象大概率成因怎么验证处置动作
负数参与的计算结果差 1,或金额尾数差几分整数除法取整方向不同 / 舍入模式不同用一组含负数、含 .5 尾数的固定输入,两边各跑一遍打印中间值显式写出取整与舍入规则,不依赖语言默认;金额改用定点或整数分
大数值场景结果突然变成负数或极小值整数溢出行为不同(回绕 vs 抛错)构造接近类型上界的输入喂两边换更宽的整数类型或加显式上界校验,别让它静默回绕
部分更新接口把没传的字段清空了null 与零值被压平成同一个值分别发「字段缺失」「字段为 null」「字段为空串」三种请求体对比落库结果用可选类型/指针/显式 present 标记区分三态
时间戳整体偏移固定小时数两边对无时区时间的默认解释不同同一条记录在新旧版各读一次并同时打印时区标识全链路统一按 UTC 存取,仅在展示层转换
只在月末、夏令时切换、跨年时出错日期边界与时区规则处理差异把系统时间设成边界日期跑一遍用例边界日期加进固定用例集,永久保留
某些失败路径没有日志、也没有告警异常被翻译成就地忽略的错误返回全量扫描目标代码里被丢弃的错误返回逐处补回传播或显式记录,禁止空忽略
单测全绿、压测出脏数据并发原语或容器线程安全性不同加并发压力用例,配合竞态检测工具跑显式加锁或换线程安全容器,别靠「原来那边是安全的」
结果对但性能塌方值语义与引用语义不同,参数传递从传引用变成整块复制对热点函数做一次性能剖析对比大对象改传指针/引用,注意随之而来的共享可变性风险

这张表的用法是反向的:先在左列找到你看到的现象,再去做第三列的验证动作,验证成立才做第四列。别跳过验证直接改,因为同一个现象可能对应两类成因,改错了会把问题埋得更深。

三、AI 能帮到哪一步,哪一步必须你来定

把迁移拆成五个动作,边界就清楚了。

**它能干得不错的:结构搬运。**包结构、函数签名、类型定义、控制流的骨架,这些是字面可映射的,AI 做得又快又稳。几千行的模块,让它先出一版骨架,比你从零敲快得多。

**它能干但要盯的:习惯用法转换。**把一种语言的迭代器风格转成另一种语言的循环风格、把装饰器转成中间件、把上下文管理器转成 defer,这类转换 AI 大体知道对应关系,但会漏掉边界:资源释放的顺序、异常路径下会不会执行、嵌套时的作用域。这部分它写,你逐处读。

**它干不了、必须你定的:语义契约。**上面五类语义差,每一类你都得先给出明确规则,再让它按规则写。比如你要先说清「所有金额用整数分表示,四舍五入方向是向零」「所有时间以 UTC 存储,接口层不接受无时区时间」「所有错误必须向上传播,不允许空忽略」。这些规则不给,它就用目标语言的默认值填,而默认值往往和源语言不一样。

**它容易帮倒忙的:顺手优化。**AI 翻译时经常会「顺便」把它认为低效或啰嗦的写法改掉——合并两个循环、去掉看起来多余的判空、把同步调用改成并发。每一处都可能改掉你不知道的隐含约束。迁移阶段的第一原则是保持行为等价,优化留到迁移完成、对拍通过之后单独做。这一点要在动手前就写进你的约定里,并在评审时专门查。

**它做不到的:判定「这份差异可以接受」。**最终哪些差异算 bug、哪些算旧实现本身就有问题、哪些是业务上无所谓的,只能人来拍。想把这个环节也自动化,就会出现明明结果不一样却被判成通过的情况。

关于让 AI 在大仓里读不全上下文导致翻译走样,可以配合 大仓库上下文不足怎么办 一起看。

四、逐块迁移与对拍验收的具体做法

切块的粒度

按「可独立喂输入、可独立看输出」来切,不是按文件切。一个纯函数、一个无副作用的计算模块、一个数据转换层,都是好块。带数据库和外部调用的先别动,把它们的输入输出在边界处固定住,先迁内部。

粒度太大你定位不到是哪一行改的语义;太小则块间接口频繁变动,返工成本高。经验上以「一次能读完、能一次性写完对拍用例」为准。

对拍是唯一靠谱的验收

单测能保证你想到的场景,对拍能抓住你没想到的。做法是:让旧实现和新实现接受同一批输入,逐一比对输出。

输入从哪来?三个来源合起来用:一是从生产日志里脱敏采样的真实请求;二是按边界条件手工构造的用例(空、零、负、极值、超长、非 ASCII、含转义字符);三是随机生成的模糊输入。

跑法上,最省事的是把两边都包成一个进程外可调用的形式,用脚本喂同一份输入文件:

# 同一批输入分别喂给新旧实现,保证「一行输入对一行输出」
run_impl() {   # $1=可执行文件 $2=输出文件
  : > "$2"
  while IFS= read -r line; do
    out=$(printf '%s\n' "$line" | "$1" 2>/dev/null) || out='{"__error__":"nonzero_exit"}'
    [ -n "$out" ] || out='{"__error__":"empty_output"}'
    printf '%s\n' "$out" | tr -d '\n' >> "$2"
    printf '\n' >> "$2"
  done < cases.jsonl
}

run_impl ./legacy_impl out_legacy.jsonl
run_impl ./new_impl    out_new.jsonl

diff out_legacy.jsonl out_new.jsonl | head -50

这里多出来的那几行不是啰嗦。最朴素的写法是直接把实现的 stdout 重定向出去,但只要有一个用例让实现崩了、或者某个实现对同一条输入吐了两行,两个文件的行号就永久错位,从那一条起后面全报差异,而真正的第一处差异被淹没在几千行噪声里。所以这里做了三件事:非零退出补一条占位、空输出补一条占位、多行输出压成一行。占位本身也是信号——某一侧出现 __error__ 而另一侧正常,说明这条用例在一边直接挂了,这类差异比数值对不上更值得先看。

直接 diff 还会被字段顺序、浮点末位这种无关差异淹没。加一层规范化再比:

import json

def normalize(obj):
    if isinstance(obj, dict):
        return {k: normalize(v) for k, v in sorted(obj.items())}
    if isinstance(obj, list):
        return [normalize(v) for v in obj]
    if isinstance(obj, float):
        return round(obj, 6)
    return obj

def load(path):
    with open(path, encoding='utf-8') as f:
        rows = []
        for line in f:
            try:
                rows.append(normalize(json.loads(line)))
            except json.JSONDecodeError:
                rows.append({'__unparsable__': line.strip()[:200]})
        return rows

legacy, new = load('out_legacy.jsonl'), load('out_new.jsonl')
assert len(legacy) == len(new), f'行数不等:{len(legacy)} vs {len(new)},先修跑法再比对'

bad = 0
for i, (ra, rb) in enumerate(zip(legacy, new), 1):
    if ra != rb:
        bad += 1
        print(f'case {i} diff')
        print('  legacy:', json.dumps(ra, ensure_ascii=False)[:300])
        print('  new   :', json.dumps(rb, ensure_ascii=False)[:300])
print(f'总计 {len(legacy)} 条,差异 {bad} 条')

那句 assert 是必须留的。行数不等意味着上一步的跑法本身有问题,这时候逐条比对出来的结论没有任何意义,先去修跑法,别对着错位的数据分析半天。__unparsable__ 那个分支同理:实现往 stdout 混进了日志或者进度条,比对脚本不该在这里抛栈退出,而该把它当成一条差异记下来继续跑完——你需要的是一张完整的差异清单,不是第一处异常。

最后那行汇总数字也别省。对拍不是一次性动作,是每改一块跑一遍的,差异条数这个单一数字就是你的进度条:它稳步下降说明方向对,横盘或者上涨就是第五节说的止损信号。

浮点保留几位、哪些字段(比如生成的 ID、随机数种子、写入时间戳)要在比对前剔除,这些你得自己定。剔字段时有个纪律:每剔掉一个字段,就等于宣布「这个字段的语义我不验了」,所以剔除清单要写在代码里、并且在评审时被人看到,不能顺手在本地改一行了事。定这份清单的过程本身就是在梳理语义契约——你会发现有些字段说不清该不该剔,那正是需要找业务确认的地方。

影子流量是最后一道

对拍过了之后,上线前再走一段影子期:线上真实流量同时打到新旧两条路径,只有旧路径的结果对外返回,新路径的结果落盘做异步比对。跑够一个完整业务周期(含月末、结算日这类特殊日子)再切。这一步能抓到你在离线环境里永远造不出来的输入分布。

关于工具选择

迁移是长上下文、多文件、需要反复回看原实现的活,不同工具的取舍差别不小,但有一点先要提醒:部分海外 AI 编程工具官方对中国大陆有区域限制、不支持直连使用,市面上存在第三方中转服务,我不背书任何一家;涉及源码外发的迁移工作尤其要先过公司合规。

五、什么情况下别再折腾

迁移最大的成本黑洞不是写代码,是在一个不该继续的方向上反复修。给你几条止损线。

**同一处语义差修了三轮还在冒新的相邻问题,停。**这通常意味着这个模块依赖了源语言的某个整体性特性(比如异常驱动的控制流、某种反射式的动态调度),逐行翻译在结构上就不成立。此时正确的动作是回到设计层,按目标语言的习惯重写这个模块,然后用对拍保证行为等价——重写不等于失败,逐行翻译才是那个错误的假设。

**对拍差异率不降反升,停。**说明你在用改动追改动。回滚到最近一个对拍通过的提交,重新切更小的块。

**旧实现本身行为就说不清,停下来先补文档。**你会遇到旧代码里某段逻辑没人知道为什么这么写,对拍显示两边不一致,但谁也说不清哪边对。这时候继续迁移是浪费时间,先把旧行为的期望写成用例、找业务确认,再继续。

**迁移窗口内业务方还在往旧实现加需求,停或者谈。**双写双改是灾难,两边永远追不平。要么冻结旧实现,要么把新需求只做在新实现上并接受一段时间的功能不对等,两条路选一条,不要含糊。

回滚点怎么留:每完成一块对拍通过就打一个标记,出问题时能精确回到「上一个已验证等价的状态」。

# 每块对拍通过后打标记
git tag -a migrate/block-order-calc -m "order calc block: diff clean on 12k cases"

# 需要回退时先看清楚差了什么,别直接 reset
git diff migrate/block-order-calc..HEAD --stat

上线后的回滚更要提前想好:新旧实现能否并存、切换开关放在哪一层、已经由新实现写入的数据在回滚后旧实现读不读得懂。最后一条最容易被忽略——如果新实现改了存储格式,回滚就不再是改个开关那么简单了,参考 数据库迁移不可逆怎么办 里对不可逆变更的处理思路。

六、避坑清单

**坑一:让 AI 一次性翻译整个模块,然后整体审阅。**为什么会踩:一次给出的量大,看起来效率高,评审时人的注意力在前两百行就耗尽了,后面基本是扫过去。怎么避:单次翻译控制在你能逐行读完的量,读不完就说明切块切大了。宁可多切几刀。

**坑二:以「编译通过 + 单测绿」当验收标准。**为什么会踩:这两个信号在迁移场景下几乎不含语义信息——单测多半也是 AI 从旧测试翻译过来的,旧测试没覆盖的语义差,新测试同样覆盖不到。怎么避:验收标准换成对拍差异清零,单测只作为快速回归手段。

**坑三:把旧测试也交给 AI 一起翻译。**为什么会踩:测试和实现被同一个模型用同一套(可能错误的)理解翻译,错误会对称地出现在两边,于是自洽地绿了。怎么避:测试用例的输入输出对从旧实现的实际运行中导出,而不是从旧测试代码翻译,让预期值来自真实执行结果。

**坑四:默认目标语言的标准库行为和源语言一致。**为什么会踩:函数名长得像,比如都叫 split、都叫 trim,但对空串、对连续分隔符、对 Unicode 空白的处理各不相同。怎么避:对每一个跨语言映射的标准库调用,用空串、单字符、连续分隔符、含中文和 emoji 的串各测一遍,把结果写进用例。

**坑五:迁移中顺手升级依赖或换框架。**为什么会踩:手上已经在动一大片代码,看到依赖版本旧就想一并处理。结果出问题时你分不清是翻译错了还是新版本行为变了。怎么避:迁移期间锁死所有依赖版本,升级排在迁移完成之后单独做,一次只让一个变量在动。

**坑六:字符编码和换行符在迁移中被静默改掉。**为什么会踩:不同语言的默认文件编码、默认换行处理不同,AI 生成的文件可能带 BOM 或用了不同的换行符,涉及中文的字符串比对就会莫名不等。怎么避:迁移前把编码和换行规则写进仓库配置并加进 CI 检查,不靠人眼看。

**坑七:只对拍正常路径,不对拍错误路径。**为什么会踩:造错误输入麻烦,且人的直觉认为「报错就是报错,还能怎么错」。实际上错误码、错误信息结构、HTTP 状态码(401 和 403 被翻译时互换、该 429 的地方返回了 500)都可能悄悄变,下游的重试和熔断逻辑就跟着乱。怎么避:错误路径用例和正常路径同等对待,把状态码和错误结构一起纳入对拍字段。

**坑八:没有把「AI 不确定」的地方标出来。**为什么会踩:模型输出总是流畅自信的,它猜的部分和它有把握的部分看起来一模一样。怎么避:要求它在每处涉及上述五类语义的转换旁留一行注释说明它按什么规则处理的,评审时优先看这些注释。这也是识别幻觉的常规手段,可以结合 大模型幻觉怎么识别 的判别方法一起用。

七、收束

跨语言迁移里,AI 是一台很好的搬运机器,但它不知道你的语义契约,因为契约本来就没写在代码里。你要做的是把它显式化,然后用对拍把它钉死。

一份可以直接用的自检清单:

  • 五类语义(数值取整与溢出、空值三态、时区、错误传播、并发可见性)是否各写下了一条明确规则,并在代码中显式体现?
  • 每一块是否有对拍用例,输入是否覆盖了空、零、负、极值、非 ASCII、错误路径?
  • 对拍的预期值是否来自旧实现的真实运行结果,而不是翻译过来的旧测试?
  • 迁移期间依赖版本是否锁死,是否禁止了顺手优化?
  • 每块通过后是否打了标记,出事时能否精确回到上一个已验证等价的状态?
  • 上线是否走过影子流量,是否跨过了月末、结算日这类特殊日期?
  • 如果新实现改动了存储格式,回滚方案是否验证过?

这七条里任何一条答不上来,就先别急着切流量。迁移出事的代价,通常远高于多花一周做对拍的代价。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。