程序员最熟悉的疲惫,不是加班到凌晨三点,而是接到一个看似简单实则无底洞的任务:把一套运行了五六年的老系统,从一个技术栈迁移到另一个技术栈。
依赖冲突、语法差异、隐式约定、文档缺失、环境不一致……每一项单独拎出来都不难,叠在一起就变成了“迁移疲劳”。你很难说清楚到底哪一步最累,但整个项目做完,所有人都像被抽空了一样。更可怕的是,这种疲劳会在团队里沉淀成一种潜意识:能不迁就不迁,能不动就不动。
而 LLM 的出现,正在改变这件事。
这篇文章不是要告诉你“LLM 能自动迁移一切代码”——这是不现实的。我想聊的是:LLM 如何从理解、转换、审查、验证这四个环节,把迁移从“靠人肉硬扛”变成“半自动重构”,以及在实际项目里,该怎么一步步接入这套流程。
1. 迁移疲劳到底是什么
“迁移疲劳”不是一个严谨的技术术语,但它描述的现象,几乎所有后端开发者都见过。
一个典型的 Java 老项目准备升级到 Spring Boot 3 / Jakarta EE,或者一个 Python 2 项目终于决定迁到 Python 3,又或者 PostgreSQL 要迁移到国产数据库。刚开始大家觉得“工作量不大,两周搞定”。真正动手后才发现:
- 依赖之间的版本冲突比想象中复杂得多;
- 很多写法在新框架里已经废弃,但旧文档根本没提;
- 线上有很多隐蔽分支逻辑,测试覆盖不到;
- 迁移完以后,行为变得不一样,但没人说得清是哪里变了。
于是项目从两周拖到两个月,从“顺手升级”变成“专项攻坚”。中途不断有人接手,又不断有人离开,知识在交接中流失,文档和代码越来越对不上。
这就是迁移疲劳的本质:它不是某一次技术难点,而是高密度、低创造性、持续消耗认知资源的重复劳动。
人脑天然不擅长这种任务。读旧代码要维持大量上下文,改代码要时刻警惕边界差异,验证结果又高度依赖经验。LLM 恰好在这种场景下最有优势——它的上下文窗口足够大,能够一次读入大量文件;它不厌烦重复;更重要的是,它可以基于大规模代码语料,快速识别那些“看起来没用、实际上有用”的隐式约定。
2. 对抗迁移疲劳的传统方案,为什么总差一步
在 LLM 成熟之前,业界也有很多迁移工具,大致分三类:
第一类是语言/框架官方迁移工具。比如 Python 的2to3、Java 的OpenRewrite、Angular 的ng update。它们对标准语法的覆盖很好,但处理不了业务语义。
第二类是静态分析工具。比如 SonarQube、SpotBugs、ESLint 迁移规则,能扫出一批“不兼容写法”,但它只能告诉你“这里有风险”,不能帮你理解“为什么这里要这么写”。
第三类是手工重构。最可靠,也最慢。它要求团队里至少有一个对新旧技术栈都非常熟的人,由他充当“人肉编译器”。
这三种方案各解决了一部分问题,但都没有解决一个核心矛盾:迁移过程中最大的成本不是改代码,而是理解旧代码“为什么要这么写”。
传统工具擅长机械替换,但面对那些历史遗留的兼容逻辑、性能优化技巧、防御性判断,它们无能为力。老程序员靠经验能看出来“这里为什么要 catch 这个异常然后又吞掉”,新人看不出来,工具更看不出来。于是要么误删逻辑,要么不敢动,最后迁移就变成了“全部重写”。
LLM 的价值恰恰在于:它可以成为一个会读代码、能解释语义、能提出建议的“虚拟老同事”。它不一定每次都对,但能极大降低理解门槛,让你把有限的精力放在真正需要判断的地方。
3. LLM 真正改变的,是迁移全流程
很多人对 LLM 辅助开发的想象停留在“你说需求,它写代码”。但在迁移场景里,LLM 最大的帮助不在“写”,而在“读”。
一次完整的迁移,可以拆成四个阶段:
3.1 理解阶段:LLM 是代码讲解员
拿到一个老旧模块,第一件事不是改,而是搞懂它。
传统方式:人肉读源码,顺着调用链一个个跳,遇到不懂的再搜文档。一个 5000 行的老模块,经验丰富的人也要读一两天。
LLM 方式:把整个模块的关键文件丢给它,直接提问:
- 这个模块的核心流程是什么?
- 这个类有哪些外部依赖?
- 这段逻辑里有哪些边界条件?
- 如果迁移到新框架,哪些部分风险最高?
它给你的回答不一定 100% 准确,但能给你一张带猜测标记的地图。你可以拿着地图去看代码,比自己从头摸索快得多。
3.2 转换阶段:LLM 是初步改写器
这是大家最熟悉的部分:让 LLM 把旧 API 调用改成新 API,把旧语法改成新语法。
要注意,这个阶段 LLM 的输出只能当草稿,不能当成品。因为 LLM 没有运行环境,它不知道一个方法是否有副作用,也不知道一个配置项在运行时是否真的被加载。
正确的用法是:让 LLM 批量完成“机械性大于判断性”的转换,比如 Java EE 的javax.*到jakarta.*、Python 2 的print语句、Spring 的WebSecurityConfigurerAdapter废弃替换。这些转换规则清晰、重复性高,LLM 做得又快又好。
3.3 审查阶段:LLM 是差异分析器
代码改完了,最大的问题是:行为是否发生了变化?
传统方式:靠 code review + 测试。但测试覆盖不全的话,很多差异会漏到线上。
LLM 方式:把迁移前后的代码对拍(diff),让 LLM 重点分析“行为差异”,而不是“语法差异”。你可以这样提问:
这两个版本的代码在功能上有没有差异?有没有哪个异常处理、边界条件、默认值发生了变化?
LLM 能识别出很多肉眼容易忽略的细节:比如Integer.valueOf和parseInt的区别、getOrDefault和先get再判空的区别、异常被吞掉后逻辑走向的变化。
3.4 验证阶段:LLM 是测试生成器
迁移最怕的是“没有回归测试”。
但老项目往往测试覆盖极低,不能指望重构前先补一套完整测试——这不现实。
LLM 可以基于旧代码行为,快速生成一批冒烟测试 + 特征测试,把那些容易在迁移中出问题的逻辑先钉死。等代码迁移完成后,跑一遍这套测试,能挡住大部分低级回归。
这四个阶段串联起来,才能真正达到“LLM 缓解迁移疲劳”的效果。反过来,如果只停留在“让 LLM 写转换代码”,那迁移还是照样累——因为最累的理解和验证环节没有被解决。
4. LLM 辅助迁移的典型工作流
下面用一个贴近实战的工作流来说明,从接到任务到迁移完成,每一步 LLM 应该承担什么角色。
假设场景:一个老的 Spring Boot 2.x + JDK 8 项目,要迁移到 Spring Boot 3.x + JDK 17。项目规模大概 200 个 Java 文件,混杂着 XML 配置、JSP 页面、自定义 Starter。
4.1 第一步:摸清现状
不要上来就让 LLM 改代码,先让它做“项目体检”。
你可以写一个脚本,把项目里的关键信息汇总出来,然后交给 LLM 分析。需要汇总的信息包括:
- Java 文件列表、每个文件的代码行数;
- 所有 import 的第三方包;
- 所有
javax.*的引用; - 所有
application.properties里的配置项; - 自定义注解和反射调用的位置。
# 1. 统计 javax 引用 grep -rl "javax\." --include="*.java" . | wc -l # 2. 列出所有 import javax 的文件 grep -rh "import javax\." --include="*.java" . | sort | uniq -c | sort -rn | head -30 # 3. 查看 Spring Boot 版本 grep -E "spring-boot|spring-cloud" pom.xml把这些命令的输出塞给 LLM,让它帮你评估:
分析这个项目的迁移风险。哪些模块依赖 javax,哪些依赖 Spring Boot 2.x 特有的 API,哪些地方可能有反射调用需要手工确认?
LLM 的回答会给你一个迁移优先级建议,而不是让你盲目地从第一个文件开始改。
4.2 第二步:确定批次
200 个文件不能一次性迁移,得拆批次。
判断依据是依赖关系:没有内部依赖的公共类、工具类先迁;然后迁服务层;最后迁入口和配置。
这个批次计划可以让 LLM 帮你制定。你只需要把包结构贴给它,让它按“依赖关系 + 风险等级”排序。
4.3 第三步:批次内迁移
对每个批次内的文件,用一套固定的提示词模板操作。
推荐模板:
你是 Java 迁移专家。请将以下 Spring Boot 2.x 代码迁移到 Spring Boot 3.x。 要求: 1. 替换 javax.* 为 jakarta.* 2. 替换已废弃的 Spring Security API 3. 保持业务逻辑变量名和注释不变 4. 如果遇到不确定的 API,在代码注释中标记 TODO_MIGRATION_CHECK 5. 输出完整文件内容,不要省略 原代码: [粘贴代码]这里的关键点是要求 LLM 不要自作主张重构逻辑。迁移和重构是两件事,混在一起会让问题定位变得困难。
4.4 第四步:对拍审查
迁移完成后,运行git diff,把 diff 结果发给 LLM:
请审查以下 diff,重点检查:1、行为和语义是否发生变化;2、异常处理是否正确;3、空指针风险是否增加;4、是否有遗漏的 javax 引用。
这一步能过滤掉很多低级错误。
4.5 第五步:测试生成
对每个迁移完的 Service 类,让 LLM 生成“迁移回归测试”:
基于以下 Service 的原始逻辑,生成 JUnit 5 测试用例。测试应该覆盖正常路径、边界路径、异常路径。只用 Mockito,不用真实数据库。
这些测试拿到项目里跑,能尽早暴露行为差异。
整个流程里,LLM 承担的是“增强理解、批量转换、辅助审查、生成测试”四个角色,而人的精力应该集中在决策和判断上:迁移顺序怎么安排、行为差异是否可接受、老逻辑是否要借机修改。
5. 一个最小可复用的 LLM 辅助迁移示例
为了让你能快速跑通这套流程,我给你写一个最小示例。假设我们要把一个 Python 2 风格的脚本迁移到 Python 3。
原始代码legacy.py:
# -*- coding: utf-8 -*- import urllib2 import ConfigParser class LegacyHandler(object): def __init__(self, config_path): self.config = ConfigParser.ConfigParser() self.config.read(config_path) def fetch_data(self, url): req = urllib2.Request(url) response = urllib2.urlopen(req, timeout=10) data = response.read() return data def get_config(self, section, key): try: return self.config.get(section, key) except Exception, e: # Python 2 语法 return None第一步:让 LLM 解释这个脚本的作用。
请解释这个 Python 脚本的功能,重点说明: 1. ConfigParser 在这里承担什么职责 2. 这个类的异常处理有什么问题 3. 迁移到 Python 3 时,哪些地方容易出错第二步:让 LLM 生成迁移后的代码migrated.py:
# -*- coding: utf-8 -*- import urllib.request import urllib.error import configparser class LegacyHandler(object): def __init__(self, config_path): self.config = configparser.ConfigParser() self.config.read(config_path) def fetch_data(self, url): req = urllib.request.Request(url) try: response = urllib.request.urlopen(req, timeout=10) data = response.read() return data except urllib.error.URLError as e: raise RuntimeError("Failed to fetch data from {}".format(url)) from e def get_config(self, section, key): try: return self.config.get(section, key) except (configparser.NoSectionError, configparser.NoOptionError) as e: return None第三步:让 LLM 对比差异。
对比 legacy.py 和 migrated.py,列出所有行为差异。 特别注意异常处理的变化,原来的 except Exception 在新代码里改成了什么,这个修改是否有风险。第四步:生成测试。
为 migrated.py 的 LegacyHandler 生成 pytest 测试,覆盖: 1. 配置文件存在且 key 存在 2. 配置文件存在但 key 不存在 3. 配置文件不存在 4. fetch_data 成功和失败场景这个最小示例看起来简单,但它的流程和 200 个文件的大型迁移完全一致:先理解,再转换,再对比,再测试。你把示例跑通了,再放大到真实项目里就顺理成章。
在项目里,你一定会遇到的实际情况是:迁移前后的行为差异不会在 diff 里自动标红,但会让线上的某条数据链路突然异常。LLM 能帮你减少这类事件,但没法替你根除——因为根除需要完整的行为验证体系,那已经是另一个工程问题。
6. LLM 辅助迁移的关键边界与风险控制
LLM 不是银弹。在迁移项目里,它有非常明确的边界。
6.1 LLM 不能做什么
第一,不能做运行时验证。LLM 没有编译器和执行环境,它不知道一段代码在真实 JVM / Python 解释器里会不会炸。
第二,不能做架构决策。微服务要不要改成模块化单体、数据库要不要换、缓存策略要不要调整,这些必须由人基于业务判断。
第三,不能完全依赖它对隐藏语义的理解。老代码里经常有“这个字段虽然是 String,但其实是序列化后的 JSON”“这个看似死代码的 if 分支其实在线上触发过 bug”。LLM 读不出来这些。
6.2 LLM 输出幻觉的处理
LLM 生成迁移代码时,最常见的幻觉是:
- 使用了不存在的 API;
- 记错了方法签名;
- 把旧的依赖关系理解错,生成错误 import;
- 为了“看起来合理”,擅自加了一些逻辑。
应对方法只有一条:所有 LLM 生成的代码,必须有编译和测试兜底。
在 Java 项目里就是mvn compile必须通过;在 Python 项目里就是pytest必须通过。通过的代码才进入人工 review 环节,通不过的直接丢弃或修改。
6.3 人工与 LLM 的分工
我把这个分工总结成一句话:LLM 负责理解辅助和转译,人负责判断和决策。
- LLM 说“这个 API 在新版本里被替换成了 XX”——可以信,但要自己核实。
- LLM 说“这段逻辑在迁移后行为一致”——不能信,必须跑测试。
- LLM 说“建议重新设计这个模块”——听听就好,架构决策自己拍板。
7. LLM 辅助迁移的坑与排查思路
下面整理了一些实际接入 LLM 做迁移时会遇到的问题,按“现象-原因-排查-解决”给你一份参考表:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| LLM 生成的代码编译不过 | 使用了不存在的 API 或错误的 import | 查看编译错误信息,检查 API 是否存在 | 把错误信息重新喂给 LLM,要求修正;或人工修正后继续 |
| LLM 生成的代码通过编译,但运行时报错 | 对旧代码行为理解有误 | 对比运行时日志和迁移前行为 | 用测试用例锁定行为差异,逐项修复 |
| LLM 在迁移时擅自重构代码 | 提示词里没有明确限制 | 检查 diff 中超出迁移范围的改动 | 重新生成,强调“只做迁移,不做重构” |
| LLM 生成的测试全部通过,但线上仍出问题 | 测试覆盖不足,或 LLM 按照被迁移后代码“自我验证” | 用人肉 review 补充关键路径测试 | 基于旧代码行为手写关键测试,不让 LLM 自问自答 |
| 大型项目一次喂给 LLM,上下文不够 | 一次性粘贴太多代码 | 按模块拆分,分批生成 | 用“先列目录 → 再选关键文件 → 逐步深入”的策略 |
| LLM 对框架新版本理解滞后 | 训练数据截止时间早于框架版本 | 在提示词中补充官方迁移文档关键片段 | 把官方迁移指南喂给 LLM 充当参考,再让它生成代码 |
| 迁移后隐藏的反射调用失败 | 反射字符串没有被修改 | grep 检查旧包名/类名 | 让 LLM 扫描所有反射调用相关字符串,重点排查 |
| 配置文件中仍引用旧配置项 | 只迁移了 Java 代码,没迁移配置 | 检查 application.yml/properties 中的类名和包名 | 用脚本统一替换配置中的旧包名,并让 LLM 核对 |
| LLM 生成代码引入了新依赖 | 为了让代码“好写”而擅自添加第三方库 | 检查 pom.xml 或 requirements.txt 的 diff | 约束 LLM 不得添加新依赖,除非用户明确要求 |
| 迁移后性能下降 | API 调用方式和底层实现不同 | 对比迁移前后的响应时间和 GC 日志 | 对热点方法做针对性性能测试,定位差异 |
8. 工程落地建议与渐进式采纳路径
看完前面的流程,你大概想知道:这个方案应该怎么在团队里落地?
我的建议是:不要一开始就搞“全量 LLM 辅助迁移”,先找一个 200 行左右的中等模块练手。
8.1 推荐的渐进式路径
第一阶段:单人试用。找一个对 LLM 工具感兴趣的同事,在一个小模块上跑完整流程,记录耗时、准确率和踩坑点。
第二阶段:小团队试点。选一个低风险的服务做真实迁移,要求所有 LLM 生成的代码必须经过人工 review + 测试通过。
第三阶段:沉淀模板。把好用的提示词、流程文档、风险清单沉淀到团队 Wiki 里,形成可复用的“迁移手册”。
第四阶段:规模化应用。把 LLM 辅助迁移融入日常升级流程,例如每次技术栈升级,都按“体检 → 分批 → 生成 → 审查 → 测试”的固定路径执行。
8.2 几个直接可用的工程建议
建议一:给 LLM 一个“知识包”
在开始迁移前,把以下材料放进提示词上下文里:
- 官方迁移指南的要点;
- 项目现有依赖清单;
- 项目目录结构;
- 一条“禁止事项”清单。
LLM 有了这些参考,生成质量会显著提升。
建议二:使用统一的 TODO 标记
要求 LLM 在无法确定的地方标注统一标记,方便人工排查。
// TODO_MIGRATION_CHECK: 请确认 Spring Security 6 中此写法是否仍然适用这样你可以用一行命令找出所有需要人工复核的位置:
grep -rn "TODO_MIGRATION_CHECK" --include="*.java" .建议三:把代码库索引交给 LLM
大型项目没法一次全量喂给 LLM。你可以先用工具把项目索引生成出来,再用查询方式让 LLM “按需阅读”。
# 生成项目文件清单 find . -name "*.java" > file_list.txt # 统计每个文件的依赖关系(简化版) grep -r "^import " --include="*.java" . | awk -F'import ' '{print $2}' | sort | uniq -c | sort -rn | head -50把这些输出给 LLM,它就能对项目全貌有个基本认知。
建议四:重视“迁移说明书”的沉淀
迁移过程中,LLM 会发现很多有趣的细节,比如“这个类里有个逻辑是专门兼容某个旧版本的 bug”。这类发现如果不记录,下一个人接手还是要重新踩坑。
建议在迁移文档中专门留一个“发现与决策”章节,把 LLM 分析的结果、人工确认后的结论都写下来。这份文档的价值,往往比迁移代码本身还高。
9. 总结与后续学习方向
迁移疲劳不是一个被广泛讨论的技术概念,但它真实存在于每一个维护过老系统的人身上。LLM 没办法替你做架构决策,也没办法替你承担线上风险,但它确实能帮你把迁移过程中最耗神的“读代码、改语法、找差异、补测试”这四个环节压缩到原来的几分之一。
回到开头那个判断:LLM 对迁移带来的最大变化,不是“自动化”,而是“降低理解门槛”。在代码迁移这件事上,真正决定成败的从来不是某个工具,而是你是否能快速准确地理解旧系统的真实意图。LLM 恰好补上了这个短板。
如果你接下来想深入实践,可以考虑以下几个方向:
第一,把本文提到的工作流做成一个团队的内部工具脚本,把“体检 → 分批 → 转换 → 审查 → 测试”的流程固化下来,减少对个人经验的依赖。
第二,研究一下开源社区的迁移工具与 LLM 的结合。比如 OpenRewrite 这类工具擅长规则化转换,LLM 擅长语义理解,两者结合往往比单用任何一方效果更好。
第三,在提示词工程上多花时间。迁移场景的提示词和通用编程场景完全不同,你需要把你的约束写清楚,把参考文档放进去,把“不确定就标 TODO”这样的要求固化在模板里。
迁移的脚步不会停。技术栈永远在迭代,老系统永远存在,但以后面对迁移时,你至少可以不用一个人扛着所有疲劳往前走了。