假设手上有一个 68 万行的 Python 单体仓库,要从 Python 2 迁到 Python 3,或者从旧框架写法迁到新写法。用大模型批量改代码,真正决定成败的不是模型本身有多强,而是流水线能不能做到三件事:切得开、验得住、退得回。模型的具体上下文长度、并发限制与计费方式,以官方文档当前版本为准,本文只讲流程。
下面按「报错 → 原因 → 排查 → 兜底 → 预防」展开,所有命令都可以直接照抄改路径。
报错现象
典型场景是这样的:写一个脚本遍历仓库,把每个文件丢给 Claude Opus 5.5,让它输出迁移后的完整文件,然后直接覆盖写回。脚本跑完,git diff --stat 显示 12000 多个文件变更。接着跑 CI,结果如下:
```
FAILED tests/test_order.py::test_calc_total - AttributeError:
'OrderService' object has no attribute '_calc'
FAILED tests/test_pricing.py::test_discount - TypeError: unsupported
operand type(s) for /: 'str' and 'int'
```
同时 python -m compileall app 直接报出语法级错误:
```
File "app/service/order.py", line 214
return self._calc(a,
^
SyntaxError: '(' was never closed
```
模型侧也会留下痕迹,比如返回内容里出现 ... (其余部分保持不变)、外层被 ``` `python ``` 包住、或者在文件末尾追加一段"以上是迁移后的代码,主要改动有……"的说明文字。落盘之后,这些文字就成了源码。
到了想回滚的时候,问题更明显:
```
error: Your local changes to the following files would be overwritten by merge
CONFLICT (content): Merge conflict in app/service/order.py
```
因为所有改动混在一个巨型提交里,git revert 一撤就是全部,没法只退掉出问题的那个模块。
影响范围:迁移分支无法合并,已迁好的部分也没法保留,整条流水线卡死。
可能原因
按出现概率从高到低:
1. 分片边界切错了。按文件或按固定行数切,把一个函数、一个类从中间劈开,模型看到的是残缺的语法结构,输出必然残缺。
2. 上下文给得不够。只喂单个文件,没给被调用方的签名、调用方的用法、类型定义和对应测试,模型只能猜,猜出来的方法名自然对不上。
3. 没有验证闸门。模型输出直接落盘,没有语法检查、没有静态检查、没有测试,错误一路带到 CI。
4. 输出格式不稳定。被截断、被 Markdown 包裹、加了散文解释、用了省略号偷懒。
5. 缺少幂等性。重跑一次,已经在迁移后的代码上又改一遍,产生二次改写甚至回退。
6. 提交粒度太粗。回滚单元和生成单元不是同一个,退无可退。
7. 并发冲突。多个任务同时改同一个文件,后写的覆盖先写的。
8. 环境差异造成的假失败。换行符 CRLF/LF、文件编码、可执行权限位变化,让 diff 看起来很大,其实代码没变。
逐条排查与解决
原因 1:分片边界切错
判断方法:统计所有 SyntaxError 报错的行号,如果集中在函数或类的中段,基本就是切片问题。用语法树按顶层定义切分:
```python
tools/list_units.py
import ast, pathlib, json, sys
def units(p):
try:
tree = ast.parse(p.read_text(encoding="utf-8"))
except SyntaxError as e:
return [{"file": str(p), "error": str(e)}]
out = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
out.append({"file": str(p), "name": node.name,
"start": node.lineno, "end": node.end_lineno})
return out
root = pathlib.Path(sys.argv[1])
shards = []
for p in root.rglob("*.py"):
shards.extend(units(p))
print(json.dumps(shards, ensure_ascii=False, indent=2))
```
```bash
python tools/list_units.py app > migration/units.json
```
前端项目同理,用 TypeScript 编译器 API 或 tree-sitter 按顶层声明切。原则只有一条:分片边界必须落在完整的语法单元上。宁可多切几刀,也不要把函数劈成两半。单个分片喂给模型时还要留出上下文余量,别贴着上限切。
原因 2:上下文不足
判断方法:失败用例是否集中在跨模块调用?diff 里有没有出现仓库中根本不存在的方法名?
```bash
git grep -n "_calc" -- '*.py'
git grep -rn "from app.service.order import"
```
解决方式是给每个分片构造一个"依赖闭包":本分片的完整源码 + 被调用函数的签名 + 调用方的使用示例 + 相关测试文件。可以半自动化收集:
```bash
找出分片内引用的外部符号
grep -oE "from [a-zA-Z0-9_.]+ import [a-zA-Z0-9_, ]+" shard_001.py
把对应定义文件一起塞进 prompt
git grep -l "def _calc" -- '*.py'
```
原因 3:没有验证闸门
闸门要分层,任何一层不过就不允许合并:
```bash
L0 语法层
python -m compileall -q app
L1 静态层
ruff check app && mypy app
L2 相关单测(只跑受影响模块,速度快)
pytest -q tests/test_order.py tests/test_pricing.py
L3 全量(在合并批次上跑)
pytest -q tests/
```
前端对应 npx tsc --noEmit、npx eslint、npx vitest run。
关键动作是闸门自检:故意在一个分片里注入一行语法错误,看 CI 是否真的拦住。拦不住,说明闸门形同虚设。
原因 4:输出格式不稳定
要求模型只输出 unified diff 或严格 JSON,不要散文。落盘前先校验:
```bash
git apply --check --3way shards/001.patch && git apply --3way shards/001.patch
jq -e . < out/shard_001.json > /dev/null
```
校验失败就重试,重试时把上一次的报错原文一起带回去,最多 3 次。3 次不过,扔进人工队列,不要无限重试烧时间。
原因 5:缺少幂等性
落盘前确认工作区干净,重跑前先回滚上一轮:
```bash
test -z "$(git status --porcelain)" || { echo "工作区不干净,先提交或清理"; exit 1; }
```
每个分片独立成一个 commit,message 里带上分片 ID:git commit -m "migrate: shard-001"。这样重跑某一分片时,先 git revert 掉它的旧 commit 再重新生成。
原因 6:回滚粒度太粗
回滚单元必须等于合并单元等于分片。按分片逆序撤回:
```bash
#!/usr/bin/env bash
set -euo pipefail
for sha in $(git log --format='%H %s' main..HEAD --grep='^migrate: shard-' \
| awk '{print $1}' | tac); do
git revert --no-edit "$sha"
done
```
也可以给每个分片开独立 worktree,互不干扰:
```bash
git worktree add -b shard/001 ../wt-001 main
```
出问题的分片直接 git worktree remove ../wt-001 丢掉,不影响主干。
原因 7:并发冲突
同一文件同一时刻只能被一个分片持有。用一个清单文件加文件锁:
```bash
flock /tmp/migration.lock -c "python tools/generate.py --shard 001"
```
或者更简单:把分片清单按文件路径做成互斥表,调度时先占位,占不到就排队。
原因 8:假失败
```bash
file app/service/order.py # 看编码
git diff --stat --ignore-all-space # 忽略空白后的真实改动量
git config core.fileMode false # 忽略权限位变化
```
在仓库根目录放 .gitattributes,统一换行符:
```
- text=auto eol=lf
```
都不管用时的兜底方案
降级为人工审核模式。模型只产出 patch,不直接落盘,人工 git apply --check 确认后应用。速度慢,但一定能推进。
缩小迁移范围。先只迁没有外部依赖的叶子模块,主干保持原样。叶子模块跑通之后再逐层往上推,每层都有已验证的下层做基础。
影子运行。新旧实现并行存在,同一份输入分别跑,比对输出。差异率收敛到可接受范围再切流。
长期双栈 + 特性开关。不追求一次性全量替换,用开关按流量比例逐步切换,出问题直接关开关,比回滚代码更快。
标记为人工迁移。某个模块连续失败超过阈值,直接打上"人工迁移"标签移出自动队列。把力气花在能自动化的部分。
如何预防再次发生
迁移前先做一次体检:统计文件数、模块依赖图、测试覆盖率、语法单元分布、循环依赖数量。这份体检报告直接决定分片清单长什么样。
把分片清单入库,比如 migration/shards.json,每个分片记录:ID、包含的文件、依赖分片、当前状态、对应 commit sha、生成时使用的模型与提示模板版本。清单是唯一事实来源,回滚、重跑、统计进度都读它。
把验证闸门写进 CI,并且定期做闸门自检——注入一个已知错误,确认能被拦住。
每次生成都留痕:输入内容的哈希、输出内容的哈希、验证结果、重试次数。将来出现"为什么这个文件变成这样"的疑问,能一路查回去。
上线前在预发环境演练一次全量回滚,计时。回滚要多久、会不会冲突、冲突怎么解,这些必须提前知道,而不是出事当天现学。
最后,坚持小步合并。一次只合并一个分片批次,批次越小,出问题时定位越快、回滚代价越低。
68 万行一天迁完,靠的不是模型一口气吃下整个仓库,而是每个分片都能独立生成、独立验证、独立退回。流水线设计对了,规模就只是数字。
