
大型后端项目里最消耗团队精力的往往不是写新功能而是迁移。Spring Boot 从 2.7 升到 3.x要把一批javax开头的 import 改成jakarta开头旧服务 SDK 换代要在几十个服务里同步改调用方式内部组件重构包名要追着编译错误改上一整天。这些任务技术难度不高却充满重复、机械、容易遗漏的改动。改到后半程人会进入一种介于疲惫和厌倦之间的状态这就是迁移疲劳Migration fatigue。这篇文章会从迁移疲劳的成因开始说明 LLM 在迁移流程中真正能替代哪部分工作、不能替代哪部分工作然后用一个 Spring Boot 3 升级中最常见的 javax 到 jakarta 迁移作为案例给出完整的 LLM 辅助迁移、验证和排查流程最后整理一份可以直接拿进团队使用的检查清单。文章里的命令和提示词都以真实项目为背景落地时只需要替换成你自己的包名、版本和路径。1. 迁移疲劳到底是什么为什么团队总是低估它1.1 迁移疲劳不是累是注意力衰减迁移疲劳不是单纯的体力消耗。它更像一种注意力资源被持续消耗后的系统性衰减刚开始改前几个文件时效率很高改到中间开始漏掉边界情况改到收尾时已经分不清哪些改动是必要的、哪些是自己怀疑出来的。这种状态在医学或者心理学上可以被理解为工作记忆过载。迁移任务通常由大量高度相似但不完全相同的文件组成开发者必须同时记住目标 API、当前文件的差异、依赖库的版本约束和编译报错的位置。这些信息挤在脑子里互相干扰最后的结果就是越是靠人肉推进越容易改错越是改错越需要从头排查进一步消耗注意力。真正危险的不是效率下降而是质量下降到不可感知的程度。很多迁移事故并不是某个文件改错了而是几百个文件里有一个序列化字段名被顺手改掉导致线上数据反序列化失败。这种错误在 review 阶段极难发现因为整个 diff 看起来都是标准的机械修改。1.2 最容易触发迁移疲劳的场景不同迁移任务的疲劳来源并不相同。下面这张表整理了我在实际项目中见过的高频场景迁移场景典型工作内容疲劳来源框架大版本升级依赖调整、API 替换、配置迁移改动面大且只能靠编译报错逐步推进命名空间或包名调整大量 import 和引用修改重复度高缺少正反馈数据库表结构演进DDL、数据回填、代码映射同步改风险高改错难以快速回滚接口协议替换调用方和提供方同步改动跨模块协调成本高代码风格或规范整改批量重命名、格式化机械操作容易误伤业务逻辑这些场景有一个共同特征单次改动的技术含量并不高但改动次数非常多而且每处改动都必须准确。人对简单但必须准确的任务恰恰最不擅长因为大脑会自动降低对低难度任务的注意力分配。1.3 传统工具为什么不够面对迁移疲劳团队通常先尝试用工具解决。最常见的方案是正则替换其次是 IDE 重构再往上是 codemod。它们各有边界正则替换例如sed -i s/javax\.servlet/jakarta.servlet/g能处理纯文本替换但模式一多就容易误伤。字符串内容、注释、序列化字段名、反射代码里的类名都可能被错误替换。IDE 重构对单个仓库内的符号重命名很强但遇到跨仓库、跨语言、跨配置文件YAML、XML、properties的迁移时能力有限。codemod 和 AST 改写最精确但每个迁移场景都要专门写转换脚本脚本本身的编写和维护成本很高小团队往往负担不起。结论很清楚传统工具擅长解决确定性的语法替换不擅长解决需要理解上下文才能决定的改写。例如同样出现javax.persistence.Entity在 import 语句里要改成jakarta.persistence.Entity在persistence.xml里也要改但如果在反射字符串里出现可能并不需要改甚至改了反而会错。这种判断依赖语境而 LLM 的能力恰好落在这个位置。2. LLM 在迁移流程里真正改变的是什么2.1 从逐文件人肉修改到批量语义改写没有 LLM 时一个典型迁移流程是打开文件对照新旧 API 文档手改 import再改调用点保存打开下一个文件。这个流程里信息检索和代码改写是交织在一起的人脑需要反复切换上下文。有了 LLM 之后信息检索和代码改写可以分离。开发者先整理出一份新旧 API 映射表再让 LLM 基于这份映射表批量处理文件。人的工作重心从改代码转移到定规则、查结果、验质量。这个转变才是缓解迁移疲劳的关键不是省掉了人而是把人放到了更擅长决策的位置。2.2 四个可以交给 LLM 的迁移任务结合生产项目经验以下几类迁移工作交给 LLM 能明显提速任务类型传统方式LLM 方式人工复核重点import 和调用点改写手改或正则按映射表批量语义改写字符串、注释是否被误改新旧 API 对照迁移翻文档逐条对照给出新旧示例对让 LLM 仿写返回值和异常处理是否对齐配置文件调整手改 XML、YAML、properties批量转换并解释变化环境相关的键值是否保留编译错误说明自己看栈和文档让 LLM 结合上下文解释根因是否引入不存在的 API2.3 三个不要交给 LLM 的任务LLM 不是万能的迁移引擎。下面三类任务不建议直接交给它第一目标不明确的架构级重写。如果业务方自己也说不清迁移成什么样算完成LLM 生成的结果无法验证只会制造新的混乱。第二涉及资金、安全、核心交易规则的逻辑改动。这类改动即使形式上是机械替换也要由有业务上下文的人逐行确认。LLM 可以作为辅助起草方案不能作为最终变更的唯一来源。第三没有任何测试保护的存量代码。没有测试兜底LLM 批量改写后的回归成本极高。迁移前如果没有测试基线应该先补关键路径的测试而不是先跑批量改写。3. 一个例子用 LLM 完成 javax 到 jakarta 迁移3.1 迁移前需要确认的版本边界javax 到 jakarta 的改名不是随意的。Jakarta EE 9 开始原来属于 Java EE 的包从javax.*迁到了jakarta.*。Spring Boot 3.0 基于 Jakarta EE 9要求 Java 17 及以上因此这个迁移只对Spring Boot 2.7 - 3.x这类升级有效。如果你的项目还在 Spring Boot 2.x强行把代码里的javax改成jakarta会直接编译失败。在动手之前先确认以下环境信息项目要求说明JDK17 及以上Spring Boot 3 默认要求 Java 17Spring Boot3.0.x 或更高依赖 Jakarta EE 9/10构建工具Maven 或 Gradle建议锁定 wrapper 版本第三方库确认支持 JakartaMyBatis、Swagger 等需要兼容版本如果原始材料没有给出明确版本落地前要先确认依赖版本这个步骤不能跳过。直接改代码而不升级依赖最终会在运行时遇到NoClassDefFoundError。3.2 第一步统计影响面生成迁移清单先在代码库里统计javax的引用情况。下面这组命令可以把影响面摸清楚# 统计 Java 源码里出现 javax 的文件数量 grep -rn javax\. --include*.java src/main/java | wc -l # 列出所有涉及的文件 grep -rln javax\. --include*.java src/main/java # 按 import 分组统计看改动集中在哪些包 grep -rho import javax\.[a-z.]* --include*.java src/main/java | sort | uniq -c | sort -rn输出示例42 import javax.persistence.Entity; 38 import javax.validation.constraints.NotNull; 27 import javax.servlet.http.HttpServletRequest; 19 import javax.annotation.PostConstruct;这一步的目的是生成迁移清单而不是直接开改。清单本身就是后面交给 LLM 的映射依据。注意清单里要区分两类明确有jakarta对应包的例如javax.servlet-jakarta.servlet。没有对应包的例如部分javax.security.*和javax.crypto.*仍然是 Java SE 的一部分不需要改。3.3 第二步给 LLM 一份可执行的迁移契约直接对 LLM 说帮我改成 jakarta是不够的它会根据自己的猜测处理注释、字符串和不确定的包。正确做法是把规则写成契约放进 prompt 里。你是一个 Java 迁移助手。当前项目正在从 Spring Boot 2.7 升级到 3.x 需要把 javax 命名空间下已经迁移到 jakarta 的 import 全部替换成 jakarta 前缀。 规则 1. 只修改 import 语句和代码中实际使用的类引用不改变量名、方法名。 2. 不要修改注释、日志字符串、JSON 键名、反射字符串等内容。 3. 如果某个 javax 包在 jakarta 中没有对应包不要猜测保留原样并标记 UNKNOWN。 4. 输出顺序先给出修改后的完整文件然后列出所有改动点和不确定项。 映射表 javax.annotation.PostConstruct - jakarta.annotation.PostConstruct javax.persistence.* - jakarta.persistence.* javax.validation.* - jakarta.validation.* javax.servlet.* - jakarta.servlet.* 下面是要处理的文件这份 prompt 的关键点是把约束写清楚。第 2 条尤其重要因为 LLM 默认倾向于帮到底会把无关内容也改掉。第 3 条是安全阀防止它编造不存在的 jakarta 包。3.4 第三步逐批改写并保留人工检查点不要一次性把整个仓库塞给 LLM。建议按模块或按包分批处理每批完成后立刻编译验证。这里是一段典型的迁移前后对比// 迁移前 import javax.annotation.PostConstruct; import javax.persistence.Entity; import javax.validation.constraints.NotNull; Entity public class User { NotNull private String name; PostConstruct public void init() { // 初始化逻辑 } }// 迁移后 import jakarta.annotation.PostConstruct; import jakarta.persistence.Entity; import jakarta.validation.constraints.NotNull; Entity public class User { NotNull private String name; PostConstruct public void init() { // 初始化逻辑 } }表面上看这个改动只是前缀替换。但真实的代码里往往有大段日志、注释、反射调用和自定义注解这些地方被误改后的错误形态差异很大。所以在每批文件处理完以后先跑编译./mvnw -e clean compile编译通过只代表语法层面没有明显问题不代表语义正确。紧接着要检查是否还有残留的javax引用grep -rn javax\. --include*.java src/main/java || echo No javax references如果还有输出说明存在两类情况不需要迁移的 Java SE 类或者映射表里缺失的包。把输出内容交给 LLM 让它分类同时人工确认效率比一个个文件查高得多。3.5 第四步处理残留引用和不兼容依赖有时候代码本身改完了但间接依赖仍然引入老的javax类。最典型的是某些第三方库仍然依赖 Java EE 8导致javax.servlet和jakarta.servlet同时出现在 classpath 里。用 Maven 检查依赖树./mvnw dependency:tree -Dincludesjavax.*./mvnw dependency:tree -Dincludesjakarta.*如果发现旧依赖仍然存在需要升级对应的第三方库版本。例如 MyBatis 需要 3.5.x 以上、Swagger 需要支持 Spring Boot 3 的新版本如 springdoc-openapi。这个阶段还会遇到反射调用旧类名的问题建议用grep全量检索字符串形式的javax.grep -rn javax\. --include*.java src/main/java这类字符串通常出现在反射、SPI 加载或配置中心下发的类名里。它们不会被编译报错捕获但如果漏改会在运行时才暴露。4. 迁移质量怎么验证编译、diff、运行时三层兜底4.1 编译和单测是第一道闸门迁移改完以后第一层验证是编译和单元测试。执行./mvnw clean test或 Gradle 项目./gradlew clean test编译能发现 import 路径错误、方法签名不匹配等基础问题。单元测试能发现一部分逻辑语义问题。但要注意import 替换类迁移通常不会破坏编译反而容易在运行时才暴露问题例如某个jakarta.annotation类并不存在于当前容器版本中。4.2 diff Review 和静态扫描第二层验证是 diff review。建议先看整体统计再抽查改动量大的文件git diff --statgit diff -- src/main/java/com/example/service/UserServiceImpl.javaReview 时重点关注三件事是否所有改动都属于预期的映射范围。是否存在注释、字符串、序列化字段被误改的情况。是否存在 LLM 标记为 UNKNOWN 但没有被人工处理的项。静态扫描也可以交给脚本。除了刚才的grep检查还可以用jdeps检查编译后的 class 对 JDK 内部 API 的引用jdeps --jdk-internals target/classes如果输出JDK Internal API相关警告说明代码依赖了 JDK 内部实现这类依赖在新版本 JDK 上很可能被移除需要同步处理。4.3 运行时验证和灰度回滚第三层是运行时验证。启动服务后至少要走一遍主要接口的冒烟测试并且确认以下日志关键字没有出现NoClassDefFoundErrorNoSuchMethodErrorClassNotFoundExceptionClassCastException这些异常通常出现在第一次访问某个类或某个接口时。之所以放在冒烟测试是因为它们往往不在编译阶段出现而是由类加载时机决定。生产环境的发布还需要额外考虑灰度。建议按实例比例灰度例如先放 10% 的流量观察错误率和日志再逐步放大。回滚方案要提前准备至少保证上一个版本可以直接切回。涉及数据库表结构或配置变动的迁移回滚前还要考虑数据兼容问题。4.4 学习环境和生产环境差异环节学习环境生产环境验证手段本地编译、启动、接口测试编译、测试、灰度、监控、日志告警数据影响无真实数据随便改需要备份、回滚方案、兼容性检查依赖变更本地验证即可要先在测试环境全量验证依赖树LLM 输出可以反复尝试要留存 prompt、输出和 review 记录保证可追溯权限与安全低敏感敏感代码不能直接粘贴到外部 LLM 服务把 LLM 接入生产流程时建议把提示词当成代码一样维护记录版本、输入输出样例和评测结果。单次问答效果好不等于批量迁移结果可靠必须用编译、测试、diff review 三层兜底来约束它。5. 用 LLM 做迁移的五个常见坑5.1 把 LLM 当成增强版正则现象LLM 不仅改了 import还把日志里的javax.servlet字样、注释里的历史描述、数据库字段注释全部改成了jakarta。原因prompt 里没有明确划定修改边界。LLM 默认倾向于彻底改写而不是只改谓词指定的部分。解决方式在 prompt 里显式声明不修改注释、字符串、JSON 键名和反射字符串。每次输出后用git diff抽查是否有预期外的改动。5.2 只喂局部代码丢失上下文现象LLM 在处理一个类时把某个只在本文件用到的私有方法名改了导致其他文件调用失败。原因迁移不仅仅是文件内替换。有些改动依赖跨文件引用关系例如方法签名变化、常量值变化。如果把单个文件单独喂给 LLM它看不到调用方的约束。解决方式优先按调用链组织输入把一个接口的实现类和调用方放在同一批上下文里。如果确实放不下就在 prompt 中告知这个方法被 5 个服务调用规则是不能改变方法签名。5.3 不校验依赖版本就直接合并现象代码里javax都改成了jakarta但mvn dependency:tree里仍然有老的 servlet-api、validation-api运行时还是报了NoClassDefFoundError。原因迁移不止是代码源码的事。第三方库的传递依赖会把旧类重新带回 classpath。解决方式在迁移前、迁移后各跑一次mvn dependency:tree对比差异。第三方库版本表要作为迁移清单的一部分维护而不是交给 LLM 自由发挥。5.4 只改代码不跑测试不看 diff现象代码看似全部替换完成但某个自定义注解的处理器还在用字符串方式读取旧的注解全名线上功能静默失效。原因LLM 没有能力替你做验证闭环。它只能生成文本不能运行编译也不能保证语义等价。解决方式每一批改动后强制跑编译 单测 diff review三步。尤其是静态字符串、注解处理器、AOP 切点表达式这些不参与编译检查的内容必须靠 diff review 和运行时冒烟测试兜底。5.5 把敏感代码直接粘贴进外部 LLM现象团队为了快速完成迁移把包含内部网关地址、密钥、算法细节的核心代码整段粘贴到外部 LLM 服务。原因便利性优先于安全边界。解决方式先确认公司允许使用的外部工具边界。对于敏感代码使用内部部署的模型或者先对代码做脱敏处理把类名、地址、密钥替换成占位符再交给 LLM。迁移完成的人工 review 阶段也要避免把敏感数据留在外部工具的对话记录里。6. 可复用清单LLM 辅助迁移的完整检查表6.1 迁移前阶段迁移前要确认的不只是代码还有版本、依赖和验证基线[ ] 确认目标版本要求例如 Spring Boot 3.x 需要 Java 17 和 Jakarta EE 9/10。[ ] 用grep或扫描脚本统计影响面生成迁移清单。[ ] 确认第三方库对目标版本的兼容性。[ ] 确认当前测试基线是绿色的必要时先补关键路径测试。[ ] 准备好可以被 LLM 使用的映射表且映射表由人工确认过。[ ] 确认哪些代码可以交给外部 LLM哪些必须内部处理。6.2 每次批量改写后的检查清单每一批 LLM 输出的代码进入仓库前都要过一遍[ ] 仅修改预期范围内的 import 和类引用。[ ] 注释、字符串、JSON 键名、反射类名没有被误改。[ ] LLM 标记的不确定项已有人工结论。[ ] 编译通过。[ ] 相关模块的单元测试通过。[ ]grep检查残留旧命名空间后结果符合预期。6.3 发布前检查清单[ ] 灰度发布方案和回滚方案已确认。[ ] 依赖树中不存在新旧命名空间同时存在的问题。[ ] 冒烟测试覆盖主要接口。[ ] 日志监控无NoClassDefFoundError、NoSuchMethodError等异常。[ ] prompt、LLM 输出和 review 记录已存档便于问题回溯。这份清单可以直接作为团队内部 MR 模板的一部分。迁移类变更比普通功能变更更需要约束因为有大量的改动来自机器生成 快速审阅如果没有统一检查项任何一环漏掉都会在线上暴露。7. 迁移疲劳的长期解法把迁移当成工程问题LLM 解决了迁移过程中最消耗注意力的批量改写环节但它没有解决迁移的所有问题。长期来看团队应该把迁移本身设计成一个工程流程而不是每次遇到升级临时动员人力。第一把自动化分层。确定性的替换用 codemod 或脚本语义相关的改写用 LLM约束和验证用 CI 门禁。三者各管一段而不是让某一个工具承担全部责任。第二坚持增量迁移。大型迁移尽量拆成多个可独立验证的小批次配合 feature flag 或兼容层让每批改动都能独立上线和回滚。一次性提交几千行迁移代码只会把风险集中到一次发布里这正是迁移疲劳的另一种极端表现。第三沉淀迁移知识。每次迁移完成后把映射表、prompt、常见报错和排查路径整理成文档。下次遇到类似迁移团队不需要重新踩一遍坑。LLM 的 prompt 同样需要版本管理不同的模型版本、不同的提示词模板会产生不同质量的输出记录这些差异对后续改进很有价值。迁移疲劳不会因为引入 LLM 自动消失但可以把人的注意力从批量改代码转移到设定规则、检查边界、验证结果上。对团队来说这个转变比多招一个人去改 import 更值得投入。