AI Native Rewrite Playbook · v1.0

把 15 个模块、3412 个文件、73 万行的遗留内部系统,
用 AI 原生方式重写一遍

这份手册不是讲道理的,是照着做就能跑的。给的是一个绞杀者式增量替换的完整生命周期:迁移到 TypeScript/Node 或 Python、以 Claude Code 为主力、每一步都有命令、脚本、门禁和验收标准。从第一周该干什么到最后下线旧系统的一行开关,中间没有跳步。

目标系统:持续开发 10 余年的内部信息系统 · 15 个 Maven 模块 · 3412 个 Java 文件 · 约 73 万行代码 | 2026 年 9 月

Section 00

00摘要:十一条核心论断

这份手册的每一章都服务于下面某一条论断。论断是可证伪的:如果你的系统在某一条上不成立,那一章的做法就该改或跳过。只读这一页,你也应该能判断自己要付出什么代价。

  1. 73 万行不是起点,是待压缩的输入。长时间演化的企业级 Java 系统里,未使用或已死的代码占到相当比例:Romano 与 Scanniello 对若干 Java 仓库样本的统计给出未使用函数的中位数约 15%,行业观察则把典型区间放在 5%~30%,且系统越大、越老,比例越高(单一案例报道过清理掉 67% 体量的做法,属个案而非均值)。所以目标规模应先按 150k~300k 行估算,而不是 730k。见证据强度:中位数来自受控样本研究,67% 为单一案例。
  2. 旧系统真正的资产不是代码,是冻结在代码里、且没有任何文档的业务规则。「那个大客户的特殊折扣」「某种币值的舍入规则」「因为合作方系统不稳才加的重试」——这些载荷性行为(load-bearing behaviour)既不写在需求里,也常常不在测试里,只有真实流量会触发它们。第一件要造的东西不是新系统,是能观测并锁定这些行为的装置。
  3. Java → TypeScript/Python 不存在逐行翻译。这是本次选路最大的单点风险。Spring 的事务语义、MyBatis/Hibernate 的一级缓存与延迟加载、反射装配、拦截器链、AO P 切面——这些运行时行为无法在另一语言里被结构性对应。任何「把 XxxService.java 翻译成 TypeScript」的做法都不会通过生产验收,必须改为按行为切片重写 + 等价性判据。
  4. 瓶颈不在生成,在验收;这份手册的重心因此放在 Harness 而不是 Prompt。DORA 2025(n≈5000)发现 AI 采纳虽正向关联交付吞吐量,但同时与软件交付不稳定性上升相关联;CodeRabbit 对 470 个真实 PR 的对照测量显示,AI 撰写 PR 的平均问题数是人工 PR 的 1.7 倍(10.83 vs 6.45),安全问题最多到 2.74 倍。生成器已经够快了,缺的是能自动说「不」的东西。
  5. AI 是放大器,不是捷径——它会放大你现在的样子。同一份 DORA 数据:AI 采纳也需要底层平台能力承接,否则只是更快地堆积技术债。对一个本就缺乏自动化测试、CI 慢、回滚贵的老团队,直接上 AI 重写的结果是灾难性的。因此阶段零不是写代码,是先修平台(快速 CI、一键回滚、可观测)。
  6. 规格取代代码成为一等公民,代码降为「编译产物」。这里说的 spec 不是写文档,而是从旧系统提取 + 人确认过的行为契约,用 markdown 落到仓库里。失败itim要修的是 spec 而不是代码——这正是 MISSION+ 的 ACDC 循环里「验证失败回退到规格」那条纪律。
  7. 上下文工程决定成败,模型强弱是第二位的。一个 60% 塞满探索噪音的上下文窗口,表现不如 15% 精选信号的窗口。3412 个文件的全仓库「你来分析一下」注定产出空泛结论。正确姿势是分层 CLAUDE.md + 每接缝一份 brief + 用子智能体卸载探索。
  8. 绞杀者的最小单位不是 Maven 模块,是路由规则。15 个模块是按技术分层和历史演进切出来的,不是按业务接缝切的。直接把模块当作迁移单元会撞上跨模块调用网。要先把「模块依赖图」还原成「行为接缝图」,再按业务语义重新打包。
  9. 每个切片走同一条七步固定流程,这是「真实能开发出来」的关键。固定流程让工作可被批处理、可被换人执行、可被 AI 半自动执行,也让进度可度量。MISSION+ 的现场纪录是:一名工程师 + 每阶段最多 20 个子智能体,把 35 万行 Java/JSP 重建为 TypeScript,第 5 天跑到可运行的开发版本(注意:是可运行开发版,不是生产上线)。
  10. 护栏必须机器化,不能靠「人仔细看」。Anthropic 的 Claude Code 使用研究里,专业人员达到已验证成功的会话约 30%,约九成代码产出会话只到「部分成功」。差距正是靠 Stop hook、CI 门禁、差异率阈值、准入列表这些确定性机制来补,而不是靠评审态度。
  11. 必须预先写死止损线。如果第 3 个切片在 12 周内没完成生产切换,说明接缝假设或团队能力不成立,应当降级为原地现代化而不是加人硬推。没有 kill criteria 的重写项目会在第 18 个月变成政治问题。
图 1 · 规模漏斗:真实要写的代码远小于 730k 每一级都必须由工具的产出证据驱动,不能凭直觉缩放 ① 现状:730,000 行 / 3412 文件 / 15 模块 静态检出量。含未使用代码、早期实验、被取代的旧实现、只为自证而存在的测试 100% ② 减去未使用代码后:约 480k ~ 620k 行 由运行时插桩 + 调用图 + Jar 依赖扫描三方交叉取证,取保守区间 66~85% ③ 减去可复用为通用能力后:约 300k ~ 400k 行 用开源库代替自写框架、配置取代代码生成器、收敛重复 CRUD 与报表 41~55% ④ 新系统实际体量估计 约 150k ~ 250k 行 TS/Python 21~34% 跨语言后的等价份量,非行数等价
图 1 四级压缩必须逐级取证:①→②靠运行时证据,②→③靠重复度扫描,③→④靠目标栈的表达力。虚线提醒:TS/Python 的 1 行并不等价于 Java 的 1 行,比例给的是估算区间而非换算率。

给决策者的三句话

一、这条路技术上是通的,已有同规模、跨同种语言组合的公开现场纪录。二、它的成本大头不是 Token,是验收体系的建设,以及懂业务的人被占用的时间。三、最危险的做法是把旧系统交给 AI 让它「自由发挥」,那就等于把十年积累的业务规则一次性扔掉。

Section 01

01判定:你的系统究竟处在什么位置

不是所有遗留系统都适合走这条路。先花两周做五项闸门体检,用工具的真实输出填下面这张表,然后按表末的裁决规则选择路线。这五项的共同点是:它们决定了「你我有无办法证明新旧等价」——证明不了等价,绞杀者就退化成赌博。

闸门怎么测(两周内)绿灯黄灯 / 红灯意味着什么
A. 生产调用面 网关或 Nginx 日志按 endpoint 聚合 90 天,统计每个 path 的 PV 与调用方来源 ≥ 60% 的请求集中在 ≤ 20% 的 endpoint 上 黄:长尾很重,说明「隐藏使用者」多,需要更久的绞杀期;红:存在无法定位来源的调用(如 FTP 投递、直连 DB 的 Excel),必须先补齐访问控制与调用方登记
B. 数据写入面 枚举所有写库入口:应用、定时任务、存储过程、触发器、外部 ETL、人工 SQL 写入路径全部可枚举且有清单 红:存在绕过应用直写生产库的外部系统。这是跨语言迁移最大的暗礁,必须先收敛为 API,否则新系统的不变量会被外部写破坏
C. 事务与 DB 逻辑 统计存储过程/触发器/视图数量,标记 Spring 事务边界 存储过程 < 50 个且无触发器写业务逻辑 黄:把存储过程的迁移单独列为一条工作线(见第 09 章);红:大量业务在存储过程里,TS/Python 侧无法天然继承事务语义,需先剥离
D. 目标栈人力 问一句:谁能独立写完一个 TS/Python 服务并保证生产可用? ≥ 3 人具备目标栈生产经验,且含 1 名深入过旧系统的老员工 红:只有 PM 会写 TS。这条红就把整条路线降级——因为人类验收者必须比生成器更懂目标系统
E. 交付平台 量三个数:CI 全量时长、回滚耗时、灰度能力 CI < 15 分钟、回滚 < 5 分钟、支持按百分比切流 黄:CI 40 分钟以上,AI 迭代循环会被拖垮,先优化 CI;红:无灰度开关,只能整包发布的风险不可承受

裁决规则(在开项目之前就写进立项书)

五绿或四绿一黄 → 走本手册的全量绞杀路线。
两黄及以上 → 降级为「核心域重写 + 外围通过防腐层沿用」,把 15 个模块里真正有价值的 3~5 个迁走,其余维持 Java 运维。
任一红 → 不要启动迁移。先花 2~3 个月做「原地现代化」:补 CI、补可被iftrastructure 观测、把直写数据库的入口收成 API、把一个模块抽成服务验证可行性。

五个数字的基线,第一天就要测出来

下面这组基线数字会在后面每一步反复用到。它们不是装饰性指标,而是用来判断某一步是否真的推进了的唯一依据。

# 1. 真实体量:排除 generated / protobuf / 前端打包产物
find . -name "*.java" -not -path "*/generated/*" -not -path "*/target/*" | wc -l
find . -name "*.java" -not -path "*/generated/*" -not -path "*/target/*" -print0 \
  | xargs -0 wc -l | tail -1

# 2. 每模块体量分布(决定切片优先看的战场)
for m in */pom.xml; do d=$(dirname "$m");
  printf "%-28s %6s files  %8s loc\n" "$d" \
    "$(find "$d" -name '*.java' | wc -l | tr -d ' ')" \
    "$(find "$d" -name '*.java' -print0 | xargs -0 cat 2>/dev/null | wc -l | tr -d ' ')"
done | sort -k4 -nr

# 3. 生产被调用的接口比例(从 Nginx / 网关 access log)
awk '{print $7}' access.log* | sed 's/[0-9]\{3,\}/:id:/g; s/?.*//' \
  | sort | uniq -c | sort -nr > /tmp/ep.tsv
wc -l /tmp/ep.tsv            # 分母:生产实际出现过的 URL 形态数
awk '$1<10' /tmp/ep.tsv | wc -l   # 90 天内调用 < 10 次的长尾

# 4. 数据库写入面
grep -rn "@Insert\|@Update\|@Delete\|executeUpdate\|\.persist(\|\.save(" --include="*.java" . | wc -l

# 5. CI 与回滚耗时(直接问运维要最近 20 次发布的数字)

关于结论 ① 的证据强度,请如实理解

「清理掉 67% 代码」是单一案例(某美国金融机构),不是行业均值;「未使用函数中位数约 15%」来自 Romano 与 Scanniello 对一组 Java 仓库的样本研究;「典型 5%~30%」属于行业观察汇总。你自己的系统是什么比例,必须用第 05 章的运行时插桩去测,不能引用别人的数字来立项。证据强度:样本研究 > 行业观察 > 单一案例

Section 02

02四条理论根基,各自推出一批具体工程动作

好的理论要能推导。这里每条根基都硬推出一份「必须做 / 不许做」的清单——后面所有章节的操作都是从这四条推出来的,遇到没写到的情况,回到这四条自行推导。

图 2 · 剪刀差:瓶颈已经从「写」转移到「证明」 (定性示意,非实测数据) 高 中 低 2020 2023 2026 时间 . 单位功能所需投入 生成成本 验收成本 x 这个缺口 就是全部风险 已测:AI PR 的缺陷密度 CodeRabbit, 470 个真实 PR AI 撰写 10.83 人工撰写 6.45 安全问题最多达 2.74× 每 PR 平均问题数
图 2 左:定性示意——生成侧成本断崖式下降,验收侧几乎持平,二者拉开的距离正是遗留迁移的主要风险敞口。右:唯一在这张图里是实测数据的部分(CodeRabbit 对 470 个真实 GitHub PR 的对照测量),它说明「看起来完成了」的代码所需的复核量远超直觉。左曲线不可用作业立项的成本依据。

根基一:放大器定律 —— AI 放大的是你现有的样子

DORA 2025 对约 5000 名技术从业者的调研给出一个反复被验证的判断:AI 在软件交付中的作用是「放大器」。它放大高效团队的成效,也放大陷于混乱的团队的功能失调。同一份数据里还有一组常被忽略的张力:90% 的人已在日常使用 AI,八成以上说生产力提升了,但约三成对 AI 输出缺乏信任;与此同时 AI 采纳与软件交付不稳定性上升相关联。

根基二:Feathers 的接缝 —— 先造接缝,再造手术

遗留代码之所以难改,通常不是逻辑复杂,而是依赖藏得太深:函数中间直接连库、直接读时间、直接发 HTTP、直接碰全局单例。Michael Feathers 给的接缝(seam)定义是「无须在该处编辑代码就能改变其行为的位置」。有了接缝,你才能在旧系统上插探针、装容错、把流量引到新实现。

根基三:表征测试 —— 旧系统的行为是唯一真实的规格说明

普通测试是「需求 → 写测试 → 让代码通过」。表征测试(characterization test,Michael Feathers 提出,也叫 Golden Master)反过来:跑一遍代码 → 观察输出 → 把这个输出钉死为期望值。它记录的是「它现在做什么」,而不是「它应该做什么」。对于一份跑了十年、需求文档早就对不上的内部系统,这是唯一诚实的规格来源。

// 表征测试的写法:先故意写错,拿到真实值,再把真实值回填为期望值
test('characterize: calcSettlement', async () => {
  const out = await calcSettlement(fixture.order_20240701);
  expect(out).toBe('THIS_IS_WRONG_ON_PURPOSE');   // 1. 跑 → 失败信息给出真实输出
});
// 2. 把失败信息里的 actual 回填进去,测试转绿 → 行为被钉住
// 3. 之后的任何重构,只要这个值变了,测试立刻报警

Golden Master 的产出物比测试本身更值钱

把 200 个输入的投影(不是原文)写进 master 文件,那往往是这份系统历史上第一份成文的行为描述。同时请记住:golden master 失败时是需要人来判定「这是预期的变更还是回归」——所以它应该生成一个待审阅的差异清单并显式签收,而不是简单地让 CI 变红。

根基四:规格先行 —— 失败时改规格,而不是改代码

MISSION+ 的 ACDC 循环里有一条纪律被反复强调:验证失败要把工作退回规格,而不是退进代码。这与 Endgate 的 ACDC 白皮书所述一致——当 intent 只存在于聊天记录里,团队就丢失了「为什么这么做」的记录,结果是产出更快、返工更多。落到做法上:

Section 03

03总蓝图:七个阶段,和一条不断重复的循环

整个工程分成七个阶段。其中前三个是一次性投入(约 3~4 个月),第五阶段是要被重复 15~25 次的核心循环,第六阶段与第五阶段并行推进,第七阶段收尾。真正决定周期长短的不是一次迁移多少行,而是你多久完成一次完整的切片循环。

图 3 · 七阶段生命周期:一次性投入三段,第五阶段核心循环 阶段五要被 重复 15 ~ 25 次 零 一 二 三 四 五 六 阶段零 · 战前建 Harness 阶段一 · 清创与减负 阶段二 · 抽取规格 阶段三 · 切片定序 阶段四 · 切片七步循环 阶段五 · 数据层迁移 阶段六 · 绞杀与下线 一次性投入(约 3~4 个月) 阶段零 / 一 / 二 产出:平台、清册、 规格库、首个验证结论
图 3 一次性投入的三段(零/一/二)决定后续所有循环能不能跑起来;阶段四是核心循环,重复次数取决于你把系统切成多少个行为接缝;阶段五(数据层)与阶段四并行;阶段六收尾。注意箭头方向是顺时针,表示每个循环的输出(已验证的规格与被测新代码)会沉淀回前面的层。
阶段产出物完成判据(缺一不可)典型工期失败时意味着什么
零 · 建 Harness可跑的 CI、灰度开关、护栏配置、仓库骨架CI 全量 < 15 min;回滚单点 < 5 min;Stop hook 能真实拦住不合格产物;.env 读被 deny2~4 周平台撑不住 AI 的迭代速度,后面全是返工
一 · 清创减负代码资产清册、存活率报告、删除批次给出「生产 90 天未被调用的入口文件」清单,删除批次 ≥ 1 且配置值没变3~6 周后面每轮 AI 探索都要为噪音付费
二 · 抽取规格spec/ 目录、golden master 集至少 1 个高价值域 ≤ 30 页 behavior.md,每条规则可追溯到代码位置或生产样本4~8 周新旧系统无法判定等价,绞杀死于无法验收
三 · 切片定序接缝图、打分表、排期甘特≥ 15 个候选切片全部有「入口、数据所有权、下游调用方、回滚方式」四项明确1~2 周切片边界会反复扯皮,团队失去节奏
四 · 切片循环一个个已上线的替换每片:双跑差异率 < 0.5% 且已知差异全部署名解释;灰度 0→1→5→20→100% 各档无告警每片 4~8 周触发止损线,回到第 11 章裁决
五 · 数据层防腐层、CDC 管道、schema 迁移新数据源独立可提供读能力,且双写期间两侧一致性可测与四并行见第 09 章,是最容易翻车的一条线
六 · 绞杀下线旧模块报废、文档、团队交接旧侧连续 30 天零调用;旧代码被物理删除而不是留注释收尾 2~4 周永远养着两套系统,成本翻倍

节奏感比总量重要

行业现场反复观察到的模式是:每个切片 4~8 周,切成一笔会谈专项demo,每个 demo 用来争取下一片的资源。组织看到的是连续进展而不是一个黑盒的大重写。这也让你在任何时刻都能停止,且手上是可用的东西。

Section 04

04阶段零 · 战前两周:把 Harness 建起来

这一步完全不碰业务代码。它的产出是一个「可以让 AI 在里面连续工作而人敢离开」的环境。跳过这步的团队,通常会在第 3 个月发现自己在用人工 review 补 AI 的漏洞,速度优势全部吐回去。

图 4 · Harness 四层:越往上越高杠杆,但下层不牢上层会塌 L3 验收层 自动说「不」 typecheck + lint golden master shadow 差异率 测试覆盖率门禁 人机双签 spec diff 评审 L2 上下文层 喂什么给它 根 CLAUDE.md 子目录分层 每接缝 brief spec 索引 archaeology快照 子智能体卸载 L1 护栏层 不许做什么 deny 密钥文件 allowlist Stop 门禁 hook 沙箱 Bash 副作用拦截 工作树隔离 L0 交付基座 团队既有能力 CI < 15 min 一键回滚 traceID 全链路 百分比灰度 生产只读副本 不可变构建 自下而上:L0 不牢,L3 的门禁就只是「跑得慢的装饰」 自上而下:L3 不做,L0~L2 的全部投资都换不来对 AI 产出的信任
图 4 四个层次缺一不可。多数团队从 L3 开始(先买/装个测试工具),然后发现 CI 跑一趟 40 分钟、没有灰度开关、密钥还在仓库里——这就是典型的「上层塌在松软的下层上」。

第 1 天 · 建三条硬护栏(半天)

先写 .claude/settings.json 并提交进仓库,让每个参与的人都继承同一套护栏。三条护栏对应三类真实事故:读走密钥、一次改动跨太多 file、以及「看起来完成了」。

// .claude/settings.json —— 提交进仓库,全团队继承
{
  "permissions": {
    "allow": [
      "Bash(npm run verify)", "Bash(npm run test *)", "Bash(npx tsc *)",
      "Bash(git status)", "Bash(git diff *)", "Bash(git log *)"
    ],
    "deny": [
      "Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
      "Read(./src/main/resources/**/*.properties)",
      "Bash(cat .env*)", "Bash(rm -rf *)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      { "matcher": "Edit|Write",
        "hooks": [{ "type": "command",
          "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write",
        "hooks": [{ "type": "command",
          "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-on-write.sh" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command",
          "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-gate.sh" }] }
    ]
  }
}

注意:权限是一层层叠加的(user → project → local),而 .claudeignore 或允许规则都可能被索引、全局搜索、系统提醒绕过;要真正挡住对密钥文件的读取,靠的是 PreToolUse hook 退出码 2,而不是某条 allow 规则。把 hook(覆盖动态逻辑和 Bash 读取)与 deny 规则(在权限层覆盖文件工具)成对使用。

# .claude/hooks/protect-files.sh   —— 挡住 .env / lock 文件 / .git
#!/bin/bash
INPUT=$(cat)
FP=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
for p in ".env" "package-lock.json" ".git/"; do
  if [[ "$FP" == *"$p"* ]]; then
    echo "Blocked: $FP matches protected pattern '$p'" >&2
    exit 2                      # 退出码 2 = 阻断该次工具调用
  fi
done
exit 0

# .claude/hooks/verify-gate.sh    —— Stop hook:不通过就不许这一轮结束
#!/bin/bash
set -o pipefail
OUT=$(npm run verify 2>&1)      # 指向你的 typecheck + lint + test + golden 比对
if [ $? -ne 0 ]; then
  echo "$OUT" | tail -40 >&2     # 把失败输出喂回给模型,它会接着修
  exit 2
fi
exit 0

Stop hook 是「让 AI 连续工作而人敢离开」的关键装置

它的契约极其简单:跑一遍验证脚本,通过就退出 0 让这一轮结束;失败就把失败输出打到 stderr 并返回退出码 2,模型会带着错误信息继续修。中间不必有人参与。记录里有提到:连续被拦 8 次后 Claude Code 会强制结束这一轮——这正好是「这个任务可能根本不具备自动验收条件」的天然信号,别把它当成要绕过的障碍。

第 2~3 天 · 定义 npm run verify(或对应的 make verify)

这一条命令是整个 Harness 的心脏。所有门禁最终都收敛到它身上,因为 hook 只能跑一个命令。它的设计原则:快、确定性、无副作用、失败信息可读。

// package.json (Python 侧对应 Makefile + pytest + ruff + mypy)
{
  "scripts": {
    "verify": "npm run typecheck && npm run lint && npm run test:unit && npm run golden",
    "typecheck": "tsc --noEmit",
    "lint": "eslint . --max-warnings 0",
    "test:unit": "vitest run --reporter=dot",
    "golden": "node scripts/golden-compare.js --dir ./golden --fail-on-diff",
    "shadow:report": "node scripts/shadow-diff.js --since 24h --threshold 0.005"
  }
}

第 4~5 天 · 三层 CLAUDE.md 骨架

单文件 CLAUDE.md 超过一两百行后,指令遵循度会明显下降——模型会有选择地忽略。正确做法是分层加载:Claude Code 会向上加载从当前目录到根目录沿路所有 CLAUDE.md,子目录则是访问到才加载,兄弟目录永不互相加载。这意味着你可以精确控制「在某个切片里工作时,AI 看到什么」。

repo/
├── CLAUDE.md                    # 根:全局规则 + 架构地图 + 入口索引(≤ 200 行)
├── packages/
│   ├── gateway/CLAUDE.md        # 仅在这个包工作时加载
│   ├── settlement/CLAUDE.md     # 仅在这个包工作时加载
│   └── ...
└── spec/                        # 规格库,见第 06 章
    └── index.md                 # 所有 spec 的索引,根 CLAUDE.md 指向它
# CLAUDE.md(根,示范)

## 这是什么项目
旧系统 X(Java / 15 Maven 模块)正在被绞杀式替换为新栈(TypeScript)。
旧代码在 `legacy/`,只读,不要修改。新代码在 `packages/`。

## 永远不要做
- 不要把 `legacy/` 下的 Java 文件整份喂给上下文,按 `spec/` 里的 brief 走
- 不要在没写 spec 的情况下生成业务代码 —— 先建 `spec/<slice>/behavior.md`
- 不要读 `.env`、不要碰 `resources/*.properties`(已被 hook 拦截,这里再写一次)

## 这次任务怎么开始
1. 读 `spec/<slice>/behavior.md` —— 它是唯一的需求来源
2. 允许直接读的旧代码:behavior.md 里「溯源」字段列出的那些文件
3. 实现完成后跑 `npm run verify`;Stop hook 会替你跑,不要假装通过

## 验证纪录片
- `npm run verify`        主门禁(typecheck + lint + 单测 + golden)
- `npm run shadow:report` 生产影子流量差异率报告,阈值 0.5%

## 各包入口
- gateway:      packages/gateway/src/index.ts
- settlement:   packages/settlement/src/app.ts
(改动涉及多包时,先写 plan 到 `.plan/` 下的 md,再动手)

写完 CLAUDE.md 之后请记得这句话

CLAUDE.md、memory.md、constitution.md 都不保证任何事情。写 Rules 不要写感受——用具体可执行的指令,而不是抽象的「必须」。真正起作用的是结构化的 CLAUDE.md + 分层加载 + 机械化的 hook 三者叠加,缺一个就会漏。

Section 05

05阶段一 · 代码考古:先把 730k 压下去

这是投产比最高、也最容易被跳过的一步。理由很直接:后面每一个 AI 探索任务,都要为仓库里的噪音付 Context 税。把 30% 的死代码删掉,等价于让后面所有对话免费获得 30% 的额外上下文预算。

图 5 · 资产清册流水线:四条独立证据源交叉,单一来源不得作为删除依据 证据源(四条独立) ① 静态调用图 import / 方法引用的反向索引 ② 运行时插桩 生产 JVM 实跑过的方法集 ③ 网关 / Nginx 日志 90 天真实到过的端点 ④ Git 与 DB 审计 改动频率、作者数、写库入口 交叉取证 任一来源单独都不能作为 删除依据 —— 反射、动态代理、 字符串查表会让静态分析漏报; 季度报表、年末结算会让运行时 样本短期看起来像死代码。 三方以上同时判定「未存活」 才进入候选删除名单,且仍需 「加日志观察两周」这道工序。 窗口须覆盖完整业务周期 (月结 / 季报 / 年结) A. 可删除名单 连同只为了让它存在的测试一起删 单笔隔离 commit,随时可 revert B. 观察名单 季节性 / 低频业务的路径 加埋点,延长观测窗口到一整个周期 C. 高风险保留名单 罕见失败分支、合规路径、离线兜底作业 默认保留,原样迁移,禁止「顺手优化」 产出物是一份可查询的清单,而不是一篇报告 —— 后面每一步都要按条目 ID 回查
图 5 四个证据源独立采集再交叉。关键纪律:窗口必须覆盖一个完整业务周期(月结、季报、年结),否则会把季节性代码误杀;删除必须单笔隔离 commit,因为「误删的可恢复性」正是敢于删除的前提。

步骤 1 · 静态调用图:谁从来没被人引用过

不需要买商业工具。用正则做粗粒度近似就够了——因为它的作用只是筛出候选,定罪权交给运行时证据。

#!/usr/bin/env python3
"""fanin_scan.py —— 粗粒度静态存活性筛查(候选生成,不定罪)
用法: python3 fanin_scan.py /path/to/repo > /tmp/fanin.tsv"""
import re, sys, os, collections

ROOT = sys.argv[1]
CLASS_RE   = re.compile(r'(?:class|interface|enum)\s+([A-Z][A-Za-z0-9_]*)')
IMPORT_RE  = re.compile(r'import\s+(?:static\s+)?([\w.]+)\.([A-Z][A-Za-z0-9_]*)\s*;')
USAGE_RE   = re.compile(r'\b([A-Z][A-Za-z0-9_]{2,})\b')

decls, files = {}, {}
for dp, _, fns in os.walk(ROOT):
    if '/target/' in dp or '/generated/' in dp: continue
    for fn in fns:
        if not fn.endswith('.java'): continue
        p = os.path.join(dp, fn)
        src = open(p, encoding='utf-8', errors='ignore').read()
        files[p] = src
        for name in CLASS_RE.findall(src):
            decls.setdefault(name, set()).add(p)

imp_cnt = collections.Counter()
body_cnt = collections.Counter()
for src in files.values():
    for full, cls in IMPORT_RE.findall(src):
        imp_cnt[cls] += 1
    body = IMPORT_RE.sub('', src)
    for name in USAGE_RE.findall(body):
        body_cnt[name] += 1

print("class\timports\tbody_uses\tsource_file")
for name, paths in sorted(decls.items()):
    i, b = imp_cnt.get(name, 0), body_cnt.get(name, 0)
    if i == 0 and b <= 1:                      # 只出现在自己的声明里
        print(f"{name}\t{i}\t{b}\t{','.join(sorted(paths))}")
# 出来的数字应该让你警觉:通常几百到一两千个类属于这一类
python3 fanin_scan.py . > /tmp/fanin.tsv
wc -l /tmp/fanin.tsv
awk -F'\t' '{print $4}' /tmp/fanin.tsv | tr ',' '\n' | sed 's#/[^/]*$##' | sort | uniq -c | sort -nr | head -20
# ↑ 按包聚合:死代码往往高度集中在几个「当年做完没上线」的模块里

步骤 2 · 运行时插桩:生产到底跑了哪些方法

静态分析会被反射、动态代理、字符串查表骗过去。唯一诚实的答案来自运行的 JVM。两个成本递增的做法:

做法 A(低门槛,先做这个)

Spring 端点清单 × 网关日志交集

// 在旧系统里临时加一个 actuator endpoint,dump 所有注册路由
@ReadOperation
public Map<String,String> routes(RequestMappingHandlerMapping m) {
    return m.getHandlerMethods().entrySet().stream()
        .collect(toMap(e -> e.getKey().toString(),
                       e -> e.getValue().getBeanType().getName() + "#"
                            + e.getValue().getMethod().getName()));
}

拿这份清单和 90 天网关日志里的 URL 形态做交集,立刻得到「注册了但生产一年没被调用过的接口」——这类接口在你系统里通常有几百个,是最干净的第一批候选。

做法 B(更彻底)

JVM 级的方法调用采样

让 MyBatis/JPA 之外的基础设施记录「本次运行加载并执行过的类」—— 记录决策可以落在字节码解释器、AppCDS 或 JIT 侧。关键是给每次 JVM 运行打上环境标签(prod / uat / test),否则会出现「代码和单测互相证明对方活着」的死循环。观测窗口建议覆盖一整个业务周期,先把显而易见的清掉,剩下的随着窗口延长继续收敛。

步骤 3 · Git 考古:给每个文件算 bus factor

# 每个文件的:最后修改时间 / 改动次数 / 独立作者数
git log --since="3 years ago" --name-only --format="COMMIT|%at|%ae" \
  | awk -F'|' '/^COMMIT\|/{ t=$2; a=$3; next } NF{ print $0"\t"t"\t"a }' \
  | sort | awk -F'\t' '{ c[$1]++; who[$1"|"$3]=1; if($2>last[$1]) last[$1]=$2 }
    END{ for(f in c){ n=split("",x); # 统计 who[...]
        for(k in who){ split(k,p,"|"); if(p[1]==f) authors[f]++ }
        printf "%s\t%d\t%d\t%d\n", f, c[f], authors[f], last[f] } }' \
  | sort -k4 -n > /tmp/git-heat.tsv

# 「两年没人动 + 只有 1 个作者 + 静态零引用」→ 高置信候选
join -t$'\t' <(sort /tmp/fanin-code.tsv) <(sort /tmp/git-heat.tsv) | awk -F'\t' '$5==1'

步骤 4 · 删除纪律

  1. 先加日志再删。对候选路径打点,观察两周零命中后才允许删除
  2. 连带删除它的测试。一个只用来验证已删行为的测试,以及只为了让这个测试通过而存在的代码——它们是一对共生的僵尸,必须成对清除,否则 CI 会变成维护死代码的成本
  3. 单笔隔离 commit。一次删一个自包含的包,跑完构建再删下一批。误删的代码在 Git 历史里,git revert 一次就回来
  4. 分批递增。从「自包含包 → 明确下线功能的开关 → 已知废弃的旧接口」逐步升级到更大的块

这一步的收益率

删掉 20%~30% 代码的收益不止于「少几行」:新工程师的心智负担下降、PR 更小更易评审、CI 更快、AI 探索时能被更少的噪音污染。前面提到的行业实证里,清理后变更发行频次有显著改善,还解锁了此前被依赖卡住而无法升级的老库。

Section 06

06阶段二 · 抽取规格:让旧系统说出它真正在做什么

这一步是整个工程的价值枢纽。请注意它不是「让 AI 读完代码生成一份设计文档」——那样做出来的文档漂亮但没法验收。规格 = 可被追溯的行为断言 + 人工签署。

三层材料,缺一层就不能签字

层从哪来解决什么问题典型规模(每个切片)
输入/输出样本生产影子请求、DB 快照、消息队列里的历史消息把「散落在几十处的参数与分支组合」变成真实 fixtures100~500 组
golden master旧系统对样本的实际输出(投影化后钉死)证明新旧等价的唯一可执行判据与样本同数量
behavior.mdAI 读该切片范围内的旧代码 + 上面的样本后写,人签字把隐性规则写成规范陈述,供人与 AI 共同引用15~40 页

严禁的做法:让 AI 从它自己生成的代码反推 spec

那会形成自洽但错误的闭环——模型把自己写出来的行为当作「原本就是这样」。spec 必须始终双向溯源:向上追溯到业务方确认,向下追溯到旧系统的代码位置或生产样本。每条规则后必须附这两条溯源字段,缺任一条就不允许进 spec/。

步骤 1 · 造接缝,把真样本抓出来

在要迁移的切片入口插一层薄薄的拦截,把每次调用的入参与返回值落盘(脱敏后)。这就是 Feathers 说的 seam——不改业务逻辑,只为观测。

// 旧系统侧:一个 request-scoped 的采集切面,两周即可下线
@Aspect @Component @Profile("capture")
public class CaptureAspect {
    @Around("within(com.corp.settlement..*) && @annotation(Capture)")
    public Object capture(ProceedingJoinPoint pjp) throws Throwable {
        long t0 = System.nanoTime();
        Object out; Throwable err = null;
        try { out = pjp.proceed(); }
        catch (Throwable e) { err = e; throw e; }
        finally {
            Captured c = new Captured(
                MDC.get("traceId"),
                pjp.getSignature().toShortString(),
                scrub(pjp.getArgs()),          // 必须脱敏:手机号/身份证/金额
                scrub(out), err,
                System.nanoTime() - t0);
            captureSink.write(c);              // 落 Parquet / JSONL,勿写生产库
        }
        return out;
    }
}

步骤 2 · 生成 golden master:金的是「投影」而不是「原文」

直接快照返回值在两种场景下会失效:输出本身不确定(时间戳、随机 ID、浮点结合率),以及 LLM 类组件的温度导致的抖动。正确的做法是钉住一个确定性投影函数的结果。常见投影,按稳定度大致排序:

投影例子它能抓到什么回归
结构(shape)JSON 的 key 集合与各值类型新增/删除字段、类型漂移——最容易在迁移中静默发生
分类(label)从固定集合中选出的那个标签业务判定规则的回归,正是最常见的迁移破坏
副作用调用序列调用了哪些外部模块、参数 key 列表,按顺序某一步悄悄不再调用某个系统
实体召回输入里的哪些标识符出现在输出里某一行代码悄悄不再携带必需的事实
数值(带容差)金额、税率,比到两位小数舍入规则的变化——财务类系统的头号事故来源
粗粒度桶输出长度区间、语言、是否拒绝单看很弱,聚合起来有用
// golden/api/settlement.calc.jsonl —— 每行一组输入,首行是 pin 下来的配置
{"_config":{"captured":"2026-08-11","samples":5,"old_version":"v10.4.2","note":"临时="-journalable"}}
{"case_id":"inv-0031","shape":["amount:currency:due_date:order_id"],
 "label":"overdue","entities":["4417","CNY"],
 "amount":128.40,"side_effects":["notifyFinance"]}
{"case_id":"inv-0032","shape":["amount:currency:due_date:order_id"],
 "label":"partial_overdue","entities":["4418","CNY"],
 "amount":0.00,"side_effects":[]}

稳定性的判据要「测」出来,不能凭直觉选

做法:抽 150~300 组真实输入,固定模型 id / 参数 / SDK 版本,每组跑 3~5 遍;对每个候选投影计算跨运行的稳定性,只在全部样本都一致的投影写进 master。今天就跑飞的字段从来就不是保证,把它冻起来只会制造一个 flaky 测试。把不一致的那些样本也一并记下来——那些是真正在抖的输入,知道它们是哪些本身就是个发现。

步骤 3 · 让 AI 写 behavior.md,人来签

这一步的 Prompt 要给「bound context」而不是「请你总结一下」。关键:在 Prompt 里指定可读哪些文件,这样探测器就不会被庞大的仓库污染。

> 你在为一个使用了 10 年的遗留系统做行为规格提取。严格遵守:
>
> 1. 只能读我列出的这些文件:`legacy/module-a/src/main/java/com/corp/settlement/` 下
>    的 Calc*.java、Rule*.java、DiscountPolicy.java。**不要**读 com.corp.settlement.batch/ 之外的目录,
>    不要读任何 *Test.java(那些是错的,别被它们误导)。
> 2. 同时读样本:`golden/api/settlement.calc.jsonl`(前 60 行即可),它是真实生产输入的投影。
> 3. 产出 spec/settlement/behavior.md,每条规则必须写成三段:
>      规则 / 判定:  〈一句话,必须是可被测试用例反驳的陈述〉
>      溯源:         〈代码位置,或某个 case_id 的样本〉——两者都没有就整条删掉
>      状态:         confirmed-by-business |疑似bug-待确认 |无法确认
> 4. 遇到看起来像 bug 的行为:**照实记录**并标记「疑似bug-待确认」,
>    不许替我把 bug 修好。我们要的是「它现在做什么」,不是「它应该做什么」。
> 5. 用到 URL/IQ/报表等外部依赖时列清单,不要深入展开。
> 6. 每产出 10 条规则暂停,等我确认方向后继续。
# spec/settlement/behavior.md (示范片段)

## R-017 部分还款的逾期标签判定
**判定**:当还款额 > 0 但 < 应还额,且距到期日 > 3 天时,标签取 `partial_overdue`,
         并且**不**触发催缴通知。
**溯源**:`DiscountPolicy.java:184-192`;样本 `inv-0032`(2026-08-11 采集)
**状态**:疑似bug-待确认 —— 业务方表示「应当触发催缴」,此行为疑似 2019 年为某大客户临时放宽

## R-018 外币结算的舍入方向
**判定**:外币金额一律按 `ROUND_HALF_UP` 保留两位,本位币按 `ROUND_HALF_EVEN`。
**溯源**:`CalcSettlement.java:311`;样本 `fx-0007`、`fx-0041`
**状态**:confirmed-by-business

双模型交叉提取,用差异找「真正难以确定的地方」

用两个不同系列的模型各自独立提取同一切片,然后 diff。交集部分是高置信的;差异部分恰恰是值得人工介入的地方——通常落在反射调用、隐式约定、被注释掉但仍有副作用的代码这些位置。这比让一个模型反复检查自己便宜且有效。

步骤 4 · 签字的意义

behavior.md 必须由既懂业务又看得懂这份代码的人逐条签署,包括那些「疑似 bug」。签字的那一刻你在回答一个不可逆的问题:我们要把这个 bug 一起搬过去,还是趁此机会修正它?无论哪种都要写下来——这决定了后面双跑时哪些差异是「预期的」。

有一个现场经验值得记住:一个跑了十年、几乎没有文档、只有过时用户故事的系统,在重建之后拥有数十万行 markdown——其中约八成是模型改代码的副产物而不是额外劳动,其余约一成半由从旧代码抽出来的规格种子长出来。关键在于让规格成为流水线的副产物,而不是额外任务。

Section 07

07阶段三 · 切片定序:把 15 个模块打散成 15~25 个行为接缝

这一步最常见的错误是:直接拿 Maven 模块当切片。15 个模块是按技术分层和历史演进切出来的,不是按业务接缝切的——你会发现「订单」这个业务概念横跨 6 个模块,而「报表」模块里躺着三种完全不相干的东西。切片必须按能被一条路由规则切出来的行为边界来定义。

图 6 · 接缝准入决策树:四道闸门任一不过,先做补救动作而不是硬迁 闸门一 这张表/这个域的数据所有权能落到单一团队吗? 存在多个团队都能写同一张表 → 先做数据治理 否 → 先立 data owner 登记写入方,收敛为 API 是 闸门二 所有下游调用方能枚举出来吗? 含定时任务、MQ 消费者、报表、直连 DB 的脚本 否 → 先做调用方登记 traceID + 来源标识全量埋点两周 闸门三 能构造出可执行的 golden master 吗? 没有它,就无法证明等价 —— 只能「看着像」 否 → 先补采集 按第 06 章做样本与投影 闸门四 能在网关层按百分比切流并一键回退吗? 不能灰度 = 只能big-bang切换,风险不可接受 否 → 先加路由层 防腐层(见阶段零 / 09 章) 进入候选池 → 按优先级分数排序 → 取前三个作为首批 首批切片的挑选原则 ① 高业务价值、含痛点的 ② 影响面可控(blast radius) ③ 数据所有权清晰 不该选的头三个: ✗ 交易引擎的核心 ✗ 归属不明的模块 ✗ 别人正在重构的地方 常见的好第一个: ✓ 用户/权限/通知 ✓ 查询类的读接口 ✓ 你自己最熟的那一片 第一个切片的目标不是 「做出多大成果」,而是 「把整套机器跑通一遍」。 它的工期会被拉长,正常。
图 6 四道闸门是「能不能被绞杀」的工程判据,不是业务排序。右侧任一红色补救动作本身就是有价值的工作——调用方登记、数据 owner、路由层这些即便不做迁移也该做。

用静态依赖矩阵找回真实的业务边界

把「module → module」的调用关系画成矩阵,再和数据库表的所有权叠起来看,通常几分钟就能看出哪几处才是真正的接缝。

# 1. 从 Maven 模块”到“模块”的调用强度矩阵(正则近似胜过没有)
python3 - <<'PY'
import re, os, collections, itertools
ROOT='.'; mods=['module-a','module-b','module-c']        # 换成你的 15 个模块名
imp = collections.Counter()
for dp,_,fns in os.walk(ROOT):
    if '/target/' in dp: continue
    for fn in fns:
        if not fn.endswith('.java'): continue
        src=open(os.path.join(dp,fn),encoding='utf-8',errors='ignore').read()
        src_mod=next((m for m in mods if f'/{m}/' in dp), None)
        if not src_mod: continue
        for pkg,cls in re.findall(r'import\s+([\w.]+)\.([A-Z]\w+)\s*;', src):
            tgt_mod=next((m for m in mods if f'corp.{m.replace("-",".")}' in pkg), None)
            if tgt_mod and tgt_mod!=src_mod: imp[(src_mod,tgt_mod)]+=1
print('\t'+'\t'.join(mods))
for a in mods:
    print(a+'\t'+'\t'.join(str(imp.get((a,b),0)) for b in mods))
PY
# 2. 把表的所有权叠加上去
SELECT table_schema, table_name, COUNT(*) AS write_sites
  FROM information_schema.tables t JOIN audit_log_table_access a USING(table_name)
 WHERE a.op IN ('INSERT','UPDATE','DELETE') GROUP BY 1,2 ORDER BY 3 DESC;

打分公式(推荐用这一个,简单且不容易被操纵)

优先级分数 = 业务价值 × 团队熟悉度 ÷ (依赖出度 + 1)。分母用依赖出度是因为:一个被调用很多的地方迁移后要通知很多人,一个调用很多别人的地方迁移时要先把别人都带上。分子里的「团队熟悉度」常被忽略,但它是决定前几片能不能打胜仗的关键——第一批必须赢。

排期:把 24 个月切成能看见的刻度

窗口在做什么里程碑(可对外展示的那种)此刻可以停下来的安全点
第 1~3 月阶段零 + 阶段一:Harness、清创、调用方摸底「连续 90 天一个请求都没来过的数百个接口」清单 + 首个删除批次上线随时可停:删掉的代码本来就没用,平台改良不吃亏
第 4~6 月阶段二 + 首个切片的双跑第一个切片完成影子双跑,差异率降至阈值以下——这是整个项目的第一个真凭据可以停在「有 spec 未 IBAN迁移」,此时已收获一份行为规格
第 7~12 月切片 2~5每 4~8 周一个 demo;小团队开始能独立跑完一轮可停:已切的片都在生产上跑着
第 13~20 月核心域:交易/结算/主数据最值钱也最难的几片完成不建议在此处停:半空的核心域是最难维护的状态
第 21~24 月数据平台、批处理、报表,然后下线旧系统变 Chernobyl 实例,最后一天物理删除可停:只剩尾巴,成本极低

排期上最容易被忽略的一条命令

永远不要先迁移 schema 再迁移代码。在一个还活着的生产系统依赖它确切形状的时候,你没法轻松地迁移数据库。正确顺序是:先动代码 → 用仓储层把 schema 隔离起来 → 最后才动存储。把先后顺序搞反,是现代化项目最容易踩的坑之一。

Section 08

08阶段四 · 切片七步:这套循环要跑 15~25 遍

这是手册的核心。每一个切片都走同七步,不多不少。固定流程的价值在于:它让工作可批处理、可换人、可被 AI 半自动执行,也让「进度到哪了」变成一个客观数字。

图 7 · 七步管道:注意回退箭头指向「规格」而不是「代码」 ① 度量 调用面 依赖清单 ② 造接缝 路由开关 默认全走旧 ③ 提规格 behavior.md golden set ④ 生成 AI 实现 Stop 门禁 ⑤ 双跑 影子流量 差异归因 ⑥ 灰度 1→5→20→100 每档观察窗 ⑦ 摘除 物理删旧代码 更新生死簿 验证失败 → 退回规格,不退进代码 补一条 behavior.md 的 Rule、或修正某一组 golden 的投影,然后重跑④ ①②一般只需要做一次(基础设施层);③~⑦ 是每片的固定动作
图 7 虚线回退是整个方法的灵魂:MISSION+ 的 ACDC 循环把「验证失败 → 回规格」列为要被训练出来的习惯,而不是权宜之计。回退到代码会让你在同一个错误上打转;回退到规格才会真正收敛。
图 8 · 步骤⑤ 的骨架:镜像真实流量,比对之后再谈切换 生产请求 真实流量 路由 / 防腐层 主路径照常返回 同时异步镜像 到新实现 按 % 抽样 旧实现(主) 结果返回给用户,这是唯一真相 新实现(影子) 结果不返回,只用于比对 副作用必须被抑制 禁写库 / 发信 / 扣款 / 推 MQ 比对与归因 按 traceID 对齐,先归一化再比 差异率 目标 < 0.5% 响应 ① 影子侧出错不影响用户 ② 影子路径的性能不必对齐生产 ③ 关掉镜像开关即可瞬时停止,这是最便宜的回滚
图 8 三条不变的性质:零用户影响(用户只看主路径结果)、异步(影子侧慢不拖累主请求)、可逆(关掉镜像开关就停)。红色框是最危险的实现细节——影子侧绝不能产生真实副作用,这一条必须被验证而不是被假定。
步骤 ①

度量:先把这片的生产画像量出来

在动任何代码之前,拿出这张切片的一张体检表,它同时也是最后验收时的对照组。

# 每片必须量化七个数,写在 spec/<slice>/README.md 顶部
日请求量:      128,000        P99 延迟:    340 ms
读 / 写比:     92 : 8         数据量:      表 14 张,主表 2.3 GB
下游依赖:      5 个内部服务 + 1 个外部 API
上游调用方:    11 个已登记,0 个未知
变更频率:      近 6 个月 34 次提交,3 个作者

「上游调用方 0 个未知」这一项做不到就先回到第 07 章的闸门二。

步骤 ②

造接缝:在旧系统前面加路由,默认全走旧

这一步交付的代码不改变任何行为,只增加一层可以被切换的开关。它的目的是让「替换」变成一个可以回滚的配置变更,而不是一次发布。

# Nginx 侧:先把镜像跑起来(响应丢弃,用户无感)
location /api/settlement/ {
    proxy_pass      http://legacy-upstream;
    mirror          /shadow;
    mirror_request_body on;
}
location = /shadow {
    internal;
    proxy_pass      http://new-svc$request_uri;
    proxy_connect_timeout 1;   # fire-and-forget:不等待影子结果
    proxy_send_timeout    1;
    proxy_read_timeout    1;
}

如果是服务网格,等价设施是 VirtualService 的 mirror + mirrorPercentage;用 10% 起是很常见的做法,因为 100% 镜像会让负载翻倍。

步骤 ③

提规格:产出 behavior.md 与 golden set,人签字

按第 06 章的工序执行。这一步不能压缩——它是后面所有「自动通过」的依据,也是 @失败时唯一能回退到的地方。产出要求:

  • 每条规则有「判定 / 溯源 / 状态」三段,溯源缺失的条目直接删掉
  • golden set 覆盖常规流、边界值、空值、0、负数、以及至少一组已知的异常输入
  • 标注哪些差异是「预期变更」(业务方同意顺手修掉的 bug),它们会在双跑里被显式豁免
步骤 ④

生成:让 AI 按规格实现,而不是按旧代码翻译

这是唯一「AI 唱主角」的一步,也是最容易跑偏的一步。核心纪律:给它规格和样本,不给它整个旧模块。

> 实现 `packages/settlement/src/settle.ts`。
> 需求来源只有 `spec/settlement/behavior.md`(你现在读它),
> 验收标准是 `golden/settlement.jsonl`(跑 `npm run golden` 比对)。
>
> 硬性约束:
> - **不要**去读 legacy/ 下的 Java 源码来「对齐实现」。如果你发现 behavior.md
>   有遗漏或自相矛盾,停下来告诉我,我会补 spec,而不是让你自己悟出来。
> - 不确定性的来源必须作为参数注入:`now: Clock`、`random: Random`、`fx: RateProvider`。
>   不允许在函数内部直接调用 Date.now() 或 Math.random()。
> - 输出必须与 golden 里的 shape 完全一致;金额一律用 decimal 库,禁止浮点累加。
> - 完成后跑 `npm run verify`;Stop hook 会替你跑,但你也要自己看一次输出。

为什么禁止读旧源码

因为一旦读了,模型会复刻旧系统的偶然实现细节(包括那些 bug 和权宜之计),并且你会失去一次「用规格重新审视这个行为」的机会。要让 .claude/settings.json 真的帮你执行这条:把 legacy/ 放进 Read 的 deny 列表,只在需要时由人显式解除。

一次并行多个子任务时注意:探索类任务交给子智能体,主上下文只收结论。改动跨多个包时,先让 Claude 把 plan 写到一个 markdown 文件再动手——压缩过的历史会丢,写下来的 plan 不会。

步骤 ⑤

双跑:让真实流量告诉你哪里不一样

这是整套方法唯一能把「我们认为行为一样」变成「有证据表明行为一样」的环节。三个必须做对的细节:

  1. 先归一化再比对。时间戳、生成的 ID、顺序无语义的列表——不处理这些,差异日志会被噪音淹没,然后被所有人忽略。这是整套机制最常见的死法。
  2. 自动分类而不是手工抽样。手工抽样只会找到你已经知道的那些差异。自动归类到「新实现缺陷 / 未记录的旧行为 / 有意的变更」三类。
  3. 副作用必须被验证而非假定。影子侧不得写库、不得发邮件、不得扣款、不得推 MQ。这条出问题,安全机制本身会变成一次事故。
// scripts/shadow-diff.js —— 归一化后按 traceID 对齐比对
const normalize = (o) => ({
  ...omit(o, ['requestId','timestamp','latencyMs','serverHost']),
  items: sortBy(o.items ?? [], 'id'),        // 顺序无语义时先排序
  amount: round2(o.amount),
});
for (const [tid, {old: a, new: b}] of aligned) {
  const d = deepDiff(normalize(a), normalize(b));
  if (!d) continue;
  classify(tid, d);
  // 三类:NEW_BUG / UNDOCUMENTED_OLD_BEHAVIOR / EXPECTED_CHANGE
}
report({ total, diffCount, rate: diffCount / total, unexplained });

退出判据要事先写死:差异率持续低于阈值(建议先设 0.5%),并且每一条剩余差异都有署名解释。没有这两个条件,不允许进入步骤⑥。

步骤 ⑥

灰度:0 → 1 → 5 → 20 → 50 → 100

每一档都不是「等一会儿」,而是带着明确的观察指标等待。推荐每档观察窗:读接口 24 小时,写接口 72 小时或跨过一个完整工作日。需要盯的四件事:错误率、延迟分布、业务指标(下单成功率 / 金额正确率 / 下游告警)、以及差异率是否回升。

  • 双写期间保留双写 4 周作为保险,把读翻到新侧之后再继续观察,然后才废弃旧代码路径
  • 每一档都要有一键回退的开关,并且回退路径本身要被演练过——没演练过的回滚等于没有回滚
  • 不要跳过 1% 直达 20%。1% 的意义是让「极少数人才会触发的路径」在小范围内先暴露
步骤 ⑦

摘除:物理删掉旧代码,而不是注释掉

这是一个容易被无限推迟的动作,但它是整套方法唯一真正兑现收益的动作。经验规律很清楚:每一片被绞杀之后如果不马上把旧路径移除,你最终会永远养着两套系统。

  1. 连续 30 天「旧侧零调用」作为前置条件
  2. 删除代码 连带删除只为它存在的测试,避免 CI 继续为死代码付费
  3. 更新那份「生死簿」(哪个模块还剩多少 Java 在生产跑),这是向上汇报最好的材料

每个切片的人力配比(一个被反复验证过的配置)

2 名工程师 + AI 智能体池。其中一人必须同时熟悉旧系统和目标栈——「新团队去另起炉灶、老团队维持运转」的组合在这种项目里几乎必败。公开现场纪录里出现过「一名工程师调度每阶段最多 20 个子智能体」的配置,但那是在规格与门禁都已就位之后才成立的,不要倒因为果。

Section 09

09阶段五 · 数据层:整个迁移里最硬的那块骨头

把 Java 换成 TypeScript 或 Python,最容易被低估的不是语法,而是整套运行时语义的迁移:Spring 的事务传播、ORM 的一级缓存与延迟加载、AOP 切面、拦截器链、以及藏在存储过程里的业务。这一章单独列出这些「结构性无法一一对应」的地方。

先给一条不可违背的顺序

永远不要先迁移 schema,再迁移代码

当一个还活着的生产系统在依赖数据库的确切形状时,你无法轻松地迁移它。正确顺序是:① 先动代码 → ② 用仓储层(Repository)把 schema 隔离起来 → ③ 最后才动存储。把顺序搞反,是现代化项目里最经典的自杀方式之一。

六个「跨语言不天然等价」的点,逐个给对策

旧侧机制迁移时的坑对策
@Transactional 传播 Spring 的传播行为(尤其是默认的 REQUIRED 与同类内自调用不经过代理那个经典坑)在 TS/Python 里没有内建对应物 在调用栈顶一处显式声明事务边界:用一个 UnitOfWork 对象把连接/会话显式传下去,禁止「隐形全局事务」。把旧侧每个 @Transactional 的传播类型抄进 spec,逐条对照
ORM 一级缓存 同一 Session 内两次相同查询返回同一个对象引用,业务代码依赖了这个「隐式去重」和由此带来的写回 新侧默认不启用任何隐式缓存;如果 spec 里记录了依赖此行为的地方,改为显式查询一次传入,并在 behavior.md 标注
延迟加载 Java 侧 LazyInitializationException 的存在意味着「某些访问会炸」,而它常常被用作隐式的数据访问边界 新侧用显式 eager_load,把「该次调用会碰哪些表」写进 spec 的副作用清单;用 golden 的 side_effects 投影去校验 SQL 序列
AOP 切面与拦截器 日志、鉴权、审计、重试这些横切逻辑不在调用栈里可见,代码读起来仿佛不存在 这正是第 05 章「单一静态证据不可定罪」的典型场景:用运行时插桩列出每个入口实际经过的切面链,把结果作为 spec 的一部分
存储过程与触发器 业务藏在数据库里,应用写库时和有人绕过应用直写时都会触发,两边都看不见 单独列为一条工作线:先清点出来、翻译成服务层代码、加守卫;过渡期在 DB 侧保留 triggers 做 double-check,最后才下触发器
报表与批处理 常常直连库、用临时表、依赖时区与会话变量 放到最后再做。报表对行为的容忍度与联机交易不同:可以并行产出同一份报表做全量比对,这是天然的 golden master

过渡期的两面:防腐层 + CDC

在「新旧并存」的那段时间,数据要两边同步。做法是在新侧前面加一层防腐层(Anti-Corruption Layer)——它的职责是把旧系统的数据与操作以干净的现代形态暴露出来,所有新消费者只跟它对话。它是这 24 个月里你要维护最久、也最应该写得无聊的一段代码。

# 数据面:CDC 把旧库的变更实时复制到新侧
# 1. 用 Debezium / Canal 之类抓 binlog,落到 Kafka
# 2. 消费者把变更 UPSERT 到新库,注意保留 __op 与 __ts_ms 以便回溯
# 3. 一致性校验:定期对边比对,不能只靠 CDC 的「我发了」

async function verifyConsistency(sampleRate = 0.01) {
  const keys = await pickSampleKeys(sampleRate);           // 按主键空间均匀抽样
  let mismatch = 0;
  for (const rowPk of keys) {
    const [oldRow, newRow] = await Promise.all([
      legacyRepo.find(rowPk), typedRepo.find(rowPk),
    ]);
    if (canonicalize(oldRow) !== canonicalize(newRow)) {
      mismatch++;
      await driftLog.write({ rowPk, oldRow, newRow });      // 人工判定:CDC 延迟 or 真漂移
    }
  }
  metrics.gauge('drift.rate', mismatch / keys.length);      // 必须长期 < 0.1%
}

关于抽样的建议

全表比对成本太高,但随机抽样会漏掉「刚好这一批数据有问题」的情况。更实用的法是主键空间均匀抽样 + 对最近变更过的行做全量,两者结合。

并行期的不变量要写下来

在双写阶段必须先定义清楚:谁是 source of truth?冲突以谁为准?写入失败一侧怎么办?这三个问题没有答案就开双写,等于在生产上做不可控实验。本书推荐的做法:过渡期旧侧仍是权威,新侧为追随者;只有当某一片完成 100% 切换后,该片的权威才转移。

Section 10

10阶段六 · 组织怎么排,以及旧系统怎么下线

三种编队,别只用一种

平台组 · 1~2 人

只负责 Harness:CI、灰度、门禁、安全防护、 spec 工具链。KPI 是切片 Pod 的周期时间,而不是自己交付多少功能。

切片 Pod · 2 人

一名熟旧系统 + 一名熟目标栈,;必须有一人两边都熟。KPI 是「本片完成 100% 切换且旧路径被删」。

业务方 · 兼职

唯一的职责是给 behavior.md 签字,以及参与差异归因会议。没有这一角色,spec 就退化为 AI 的猜测。

最有害的一种编队

「新团队去创新 + 老团队维持运转」。现代化项目的工程师必须像熟悉新栈一样熟悉遗留系统。把两边分开,注定得到一个漏掉全部隐性规则的新系统。

三种循环,第三种最花钱但最必需

循环怎么跑谁参与频率
全自动短循环智能体 A 写代码,智能体 B 审查,A 修 B 提的问题,往复到你设定的上限无人每次提交
人工门循环在预设检查点停下等人。人的提问与反应会纠正方向工程师每隔几条提示
长周期再验证循环回头整体复查已经上线的功能,每次换一个角度看;至少三分之一的项目时间花在这里工程师 + 业务方每 2~4 周

第三种循环是「能上线的原型」和「正在服务真实用户的系统」之间的差别。单个智能体每次只盯着一个任务,而「所有功能彼此怎么协调一致」这件事只存在于你的脑子里。所以需要一个位于单个任务之上的层:批量复查已建成的功能,而且每次刻意换角度。两类典型的启动提示:

> 【指定角度】看看 product 表是怎么实现的,然后把后台里所有类似的列表
>   在功能、展示、链接行为上对齐它。列一张不一致清单,逐条处理。

> 【开放角度】检查所有注册场景潜在的错误情况:网络超时、重复提交、
>   第三方返回未知状态、验证码过期。列出目前一处都没处理的场景。

上下文的多样性与「笨区」

一个很反直觉的教训:AI 辅助开发的质量,主要由智能体能访问到的上下文的多样性决定,而不是 Prompt 写得多么精巧。在那个 550K 行系统重建的现场,被允许的上下文远不止旧代码、文档和数据库 schema——还包括让智能体直接查询一份本地的旧数据库副本、让它自己去浏览旧站点的页面;接入不熟悉的外部域时,甚至直接问它「你还需要哪些上下文源」。

但别一次性打开所有源

一个塞到 60% 满、充斥探索噪音的上下文窗口,表现不如只有 15% 精选信号的窗口。这被称作「笨区」:上下文过多会让智能体飘向复杂或错误的解法,甚至去解决不存在的问题。「给不给、什么时候给」这种取舍本身就是上下文工程的核心内容。另外提醒一句:编码类智能体默认不会主动搜索网页,需要时你得明说。

下线清单:最后一周要做的事

Section 11

11度量体系,和预先写死的止损线

度量要覆盖三层,并且每一层都要能被操纵至失效——所以配套给出反制。

图 9 · 差异率收敛:唯一的放行依据,也是防止「差不多就这样」的装置 5% 2.3% 0 影子双跑差异率 W1 W2 W3 W4 周 4.7% 3.4% 1.8% 0.9% 0.2% 放行阈值 0.5% 每一段下降对应的动作 W1 4.7% → 补归一化规则 W2 3.4% → 补 spec 缺失规则 W3 1.8% → 修新实现缺陷 W4 0.9% → 归因剩余差异 最后,剩余差异必须 全部有署名解释,才放行 「看得见的下降」必须能 归因到具体动作,否则是噪音 典型的收敛形状:前两周下降最快(噪音类差异),之后每条都对应一次真正的行为修正
图 9 这张图给的是形状,不是你的具体数值——不同切片的初始差异率可以差一个量级。要学的判据只有两条:持续下降,以及剩余差异全部有署名解释。只满足阈值而不做归因,等于把问题推迟到上线后。

三层指标与它们的反滥用条款

层指标健康区间反滥用条款
交付切片周期(从开片到 100% 切换)4~8 周,且逐片下降不许通过缩小切片来保证周期。同时看「周期 × 切片规模」的复合量
质量双跑差异率、生产逃逸缺陷、回滚率< 0.5%、逐季下降、< 5%不许通过放宽归一化规则来降低差异率——归一化规则的每一次改动都要进评审
业务需求交付时长、故障平均恢复时长迁移期不应显著劣化把这两条做成红线指标:任何劣化都优先于迁移进度

止损线(写在立项书里,而不是事后讨论)

  1. 第 3 个切片若在 12 周内未完成生产切换 → 停下复盘。通常是接缝假设错了(边界不该这样切),而不是人手不够
  2. 连续两片上线后出现被判定为「新实现缺陷」的生产逃逸 → 回到阶段零,重审验证体系而不是继续迁移
  3. 业务交付时长连续两个月劣化超过 30% → 降速,把 Pod 的一部分人挪回维持性工作
  4. 关键人员离职且无人接手其负责的 spec → 立即冻结该片的迁移,先把规格补齐
  5. 降级路径要提前设计:从「全量绞杀」降到「核心域重写 + 外围沿用」,再降到「原地现代化」。每一级都明确要放弃什么

最后一句关于这份方法的最后判断(这是观点,不是共识)

这类项目真正的分水岭,往往不在技术而在有没有一个人持续对「新旧是否等价」这个问题负责任。技术动作——绞杀、规格、双跑、灰度——都是已知的;组织迟迟拿不到进展的项目,几乎都能追溯到「没有人被明确指派去回答那句「你凭什么说它们一样」」。

Section 12

12反模式清单:症状 → 后果 → 修正

层反模式典型症状修正
战略一次性 big-bang 重写18 个月后仍无可用产物;旧系统在期间继续生长,目标一直在跑改为按行为接缝绞杀;每个循环 4~8 周并对外 demo
先迁移数据库应用天天为适应新 schema 打补丁,没有人能说清当前权威在哪一侧先动代码 → 仓储层隔离 schema → 最后动存储
让「新团队」独立于老团队新系统缺全部隐性规则;生产一上线就爆出一整类未记录的行为差异每片都必须有既熟旧系统又熟新栈的人
工程让 AI 逐行翻译 Java 文件产出的 TypeScript 长着 Java 的样子;Spring 的事务/缓存语义悄悄丢失改为按 behavior.md + golden set 重写实现,禁止参考旧源码
把整个仓库塞给 AI 分析产出空泛、自我矛盾的总结;上下文迅速耗尽分层 CLAUDE.md + 每接缝 brief + 子智能体卸载探索
golden master 快照原文第一次重跑就红了,之后被加 --update 一路刷新,最终成为形式改为钉住确定性投影(shape/label/side_effects/数值带容差)
跳过双跑直接灰度差异在生产上才暴露,且此时已无退出路径把双跑作为硬门禁:没有差异率报告不许进入灰度
治理spec 由 AI 从其产物反推文档自洽但与真实行为不符,所有人以为有文档spec 必须双向溯源 + 人工签字;溯源缺失即删除
差异参观了不归因差异日志堆了几千条无人认领,最后被静音差异分三类归档,每条必须有署名解释才允许放行
切片上线后不删旧路径系统变成双胞胎,成本翻倍,任何人都不敢动把「旧代码物理删除」作为切片完成的唯一定义
没有 kill criteria项目在第 18 个月变成政治问题而不是工程问题立项时就写好四条止损线与降级路径

Section 13

13附录:可以直接复制的东西

A. 命令速查

什么时候命令
量体量find . -name "*.java" -not -path "*/target/*" | wc -l find . -name "*.java" -print0 | xargs -0 wc -l | tail -1
生产调用面awk '{print $7}' access.log* | sed 's/[0-9]\{3,\}/:id:/g; s/?.*//' | sort | uniq -c | sort -nr
静态存活性python3 fanin_scan.py . > /tmp/fanin.tsv(第 05 章脚本)
Git 热度git log --since="3 years ago" --name-only --format="COMMIT|%at|%ae" | ……(第 05 章)
主门禁npm run verify = typecheck + lint + unit + golden
差异率报告npm run shadow:report -- --since 24h --threshold 0.005
上下文用量/compact(建议用到 50% 就手动执行,别等自动压缩);探索任务用子智能体
权限 closed loop把 legacy/ 加入 deny,只在需要时由人显式解除

B. Prompt 模板库(每张便利贴对应一个固定动作)

用途模板要点
行为提取限定可读文件清单 → 三条字段(判定/溯源/状态)→ 遇到疑似 bug 照实记录不许修好 → 每 10 条暂停
按规格实现需求来源只有 behavior.md → 验收只有 golden set → 不确定性必须参数注入 → 发现 spec 矛盾就停下来问
差异归因给出 traceID 清单 → 要求分三类(新缺陷 / 未记录的旧行为 / 预期变更)→ 每条给代码位置 → 不许只给结论
再验证循环指定角度(对齐某处的实现)或开放角度(穷举某类错误场景)→ 要求产出清单而不是直接改代码
反向诘问让另一个智能体专门尝试推翻上一份结论;执行任务者不得评判自己

C. 每个切片的文件夹骨架

spec/<slice>/
├── README.md          # 七个数:请求量/P99/读写比/数据量/上下游/变更频率
├── behavior.md        # 行为规格,人签字;每条含「判定/溯源/状态」
├── migration-plan.md  # 接缝位置、路由开关名、回滚方式、灰度档位与观察窗
├── diff-log.md        # 双跑差异归因记录,每条有署名解释
└── done.md            # 完成证明:100% 切换日期 + 旧代码删除 commit 号
golden/<slice>.jsonl   # 投影化的 golden master
packages/<slice>/      # 新实现

D. 一句话提醒,写给三个月后的自己