组里有不少“历史脚本”:某个同事几年前用 Python 写的数据清洗、对账、报表生成,跑在一台机器的定时任务里,没人敢动。最近要把其中几个并进 Java 服务,统一部署、统一监控。这种活交给 AI 再合适不过了:输入输出都明确,代码量不大,几百行 Python,它几分钟就能吐出一份结构工整的 Java。
第一份迁移我差点直接合进去。代码能编译,单测能过,命名比原脚本还讲究。后来是跑了一遍真实数据对比,才发现有几十行结果和老脚本对不上。查下来,没有一处是“写错了”,全是两门语言默认行为不一样,AI 按字面翻译了语法,却没翻译语义。
这篇整理一下我踩到的这些差异,以及现在怎么让 AI 做迁移才比较放心。
语法好翻,语义难翻
AI 做跨语言改写,有点像一个语法很好但没在对方国家生活过的翻译。for 循环变成 for,dict 变成 Map,list 变成 List,一一对应,看着毫无破绽。
问题在于,Python 和 Java 里很多“看起来一样”的操作,默认行为并不一样。这些差异平时不显眼,只在特定数据上才冒头:负数、超大数、空集合、特殊字符、恰好在 0.5 上的数。而历史脚本之所以历史,就是因为它在真实数据上跑了好几年,那些特殊数据它早就见过了。
我实际踩到的几类差异
整数除法和取模。Python 的 // 是向下取整,-7 // 2 得 -4;Java 的 / 是向零截断,-7 / 2 得 -3。取模也一样,Python 里 -7 % 2 是 1,Java 里是 -1。老脚本里有一段按金额差值分桶的逻辑,正数完全一致,负数全部错一格。Java 里要用 Math.floorDiv 和 Math.floorMod 才能对上。
四舍五入。Python 3 的 round 是“银行家舍入”,恰好在 .5 上时取偶数,round(2.5) 得 2;Java 的 Math.round(2.5) 得 3。报表里的汇总金额就差在这些边上。要对齐得用 BigDecimal 加明确的舍入模式,而且要先搞清楚老脚本到底是有意为之还是碰巧。
整数溢出。Python 的整数没有上限,Java 的 int 超了会悄悄回绕成负数。AI 很习惯把计数、累加都写成 int,大部分时候没事,碰上大批量累加就出怪数。我现在会要求累加一律用 long,金额用 BigDecimal,必要时用 Math.addExact 让溢出直接抛异常。
字典顺序。从 3.7 开始,Python 的 dict 保证按插入顺序迭代。老脚本输出 CSV 的列顺序就依赖这一点。AI 改成 HashMap 之后,列顺序变得不可预测,下游按列位置读数据的程序就乱了。换成 LinkedHashMap 就好,但你得知道要换。

图:Python 与 Java 默认行为差异对照:除法取模、舍入、溢出、字典顺序、字符串长度、编码、异常(原创示意图)
字符串长度。Python 的 len 数的是字符(码点),Java 的 String.length() 数的是 UTF-16 单元。遇到表情符号或者一些生僻字,Java 会多数一个。老脚本里有按长度截断备注字段的逻辑,迁移后偶尔会把一个字截成两半,产生乱码。
真值判断。Python 里 if items: 同时排除了 None 和空列表。AI 翻成 Java 时,有时写成 items != null,空列表就混进去了;有时写成 !items.isEmpty(),碰到 null 直接空指针。这类代码要逐个看原意。
文件编码。Python 里不指定编码的 open() 会用系统的区域设置;Java 在 JDK 18 之前默认字符集也跟平台走,之后默认改成了 UTF-8。老脚本跑在一台默认编码不是 UTF-8 的老机器上,迁移后读同一个文件,中文全乱了。我现在一律要求显式写出编码,两边都写。
异常和退出码。老脚本出错直接抛异常、带着堆栈退出,定时任务看到非零退出码就会告警。AI 改写时很喜欢包一层 try-catch,打印日志然后继续。看起来更“健壮”,实际上把失败变成了静默成功,告警再也不会响。
第一步:先要差异清单,再要代码
踩过这些坑之后,我现在不会一上来就说“把这个脚本改写成 Java”。第一步是:
这是一个 Python 3 脚本,要迁移成 Java 17。 先不要写代码。请逐个列出脚本中依赖 Python 默认行为、 在 Java 中行为可能不同的地方:整数运算、舍入、字典顺序、 字符串长度与编码、真值判断、异常与退出码、正则匹配方式、 日期时间与时区。每条给出所在行号和你建议的 Java 写法。
这份清单比代码本身更有价值。它的作用不是保证 AI 全都找到,而是逼它在写代码之前,先把“语义”这件事过一遍。我会对着清单逐条确认,有些差异其实无所谓(比如日志格式),有些必须严格对齐(比如金额舍入)。确认过的清单再作为约束发回去,让它按清单写代码。
正则顺便提一句:Python 的 re.match 只锚定开头,re.search 是找子串;Java 的 matches() 要求整串匹配,find() 才是找子串。三种行为两两不同,AI 翻错的概率不小。
第二步:对拍,别只靠单元测试
单元测试是 AI 自己写的,它理解错了语义,测试也会跟着错,一起绿。迁移类的任务,我最信任的验证方式是对拍:同一批输入,老脚本跑一遍,新 Java 跑一遍,输出逐字节比较。
具体做法很土:
从生产数据里抽一批样本,再手工补一些边界数据:负数、零、空字段、超长字段、表情符号、恰好 .5 的金额;
用老 Python 脚本跑出结果,存成“标准答案”;
Java 版跑同样的输入,输出 diff;
每一处差异都要归因:是预期内的(比如浮点数打印格式),还是语义丢了。

图:对拍流程:同一批输入跑新旧两版,逐字节 diff 后逐条归因(原创示意图)
浮点数打印格式是个典型的“预期内差异”:Python 打印 1e16 得到 1e+16,Java 的 Double.toString 得到 1.0E16。如果输出是给程序读的,这也得对齐;如果只是给人看的日志,可以放过。关键是每一处都要有人看过、做过决定,而不是“差不多就行”。
对拍的另一个好处是,它能发现连差异清单都没想到的问题。我那次清单里漏了编码,就是对拍时一眼看出中文乱码才补上的。
依赖库也要“迁移”,不只是语法
还有一类问题藏在 import 里。老脚本用了 Python 标准库的 csv、datetime,或者第三方的表格处理库,AI 改写时需要在 Java 这边找对应物。它常常会顺手引入一个新的第三方库,名字听着都很合理。
这里我有两条要求。第一,优先用 JDK 自带的东西和项目里已经有的依赖,新增依赖必须单独列出来,说明理由,我去核实这个库真实存在、还在维护、许可证没问题之后再加。第二,库换了,默认行为也可能换:CSV 库对引号和换行的处理、日期库对时区的默认假设,都可能和老脚本用的库不同。这些也要进差异清单,也要靠对拍兜底。
第三步:先等价,再优雅
AI 迁移代码还有一个习惯:顺手优化。它会把老脚本里的笨循环改成 Stream,把几个函数合并,把它认为多余的判断删掉。每一项单看都是改进,合在一起,就很难判断差异来自哪里了。
我现在明确把迁移拆成两个阶段:
第一阶段,只求等价。结构尽量贴着原脚本,函数一一对应,连变量名都尽量保留。不准删判断,不准合并逻辑,看着再丑也先忍着。目标是对拍零差异。
第二阶段,再谈重构。对拍跑通之后,对拍数据就成了回归测试。这时候再让 AI 改成地道的 Java 写法,每改一步重新对拍一次。

图:迁移分两阶段:先只求等价,再谈重构,每步重新对拍(原创示意图)
这个节奏比一步到位慢一点,但出问题时定位快得多。以前一份“又对又漂亮”的迁移,出了差异我得在几百行里猜;现在第一阶段的代码几乎能逐行对照原脚本,第二阶段每次只动一小块。
那些说不清的“原意”
最后说一个 AI 帮不上太多忙的部分:原脚本里有些写法到底是不是有意的。
比如那个银行家舍入,究竟是当年业务要求如此,还是作者根本不知道 round 的行为?AI 会给出两种写法,但没法替你判断哪个才对。我的做法是:默认保持和老脚本一致,因为下游已经按这个结果跑了好几年;同时把疑点列出来,找业务方确认,确认完再决定要不要改,改也要单独提交、单独说明。
迁移最怕的不是新代码有问题,而是新代码“修好”了一个老问题,却没人知道。下游对账突然对不上,大家要查很久才能发现,原来是迁移顺手改了口径。
说到底,让 AI 改写语言是一件很划算的事,它省掉了大量体力活。但体力活之外的那部分,语义对不对、原意是什么、差异要不要保留,还是得我自己一条条过。差异清单和对拍,就是我给自己留的两道闸。


2026-10-16鱼鱼