作者在迁移前后构建了差异测试工具 harness,先验证 API 字节级一致,再让 AI 迭代 schema,避免了回归问题,整个过程还暴露了 N+1 查询隐患。
我们的移动应用在一个画面渲染时需要 6 次网络往返。
后端是一个完全合理的 Node.js 22 服务,约有 40 个 REST 端点,这些端点在四年间自然增长。没有人把设计弄得很糟糕——只是被分别设计了四十次。/users/:id、/users/:id/subscription、/users/:id/preferences、/orders?user= 等等。每一个都返回相同底层数据的略微不同的形状。其中两个对同一字段的拼写不一致(createdAt 对 created_at),移动客户端有一个数据规范化层,其唯一职责就是掩盖这个问题。
移动团队的需求很简单:一个画面一次请求。GraphQL 是显而易见的答案,而且在过去十八个月里一直是最显而易见的答案。之所以一直没做成,原因只有一个:
没有人能证明新 API 返回的数据与旧 API 完全一致。
这是我见过的每一次 API 迁移中真正阻碍项目进展的问题。Schema 设计一个下午就能搞定。迁移工作一周也能完成。让自己相信没有在某个遗留订阅计划层 3% 的用户上悄悄改变了 subscription.status 的形状——这才是真正让项目停摆的部分。
我面临的工作约束:
REST API 不能冻结。三个线上客户端(iOS、Android、一个管理后台 Web 应用)加上两个合作伙伴集成。
不允许任何行为改变。甚至不能"修复" created_at 的不一致性。必须做到 bug 对 bug 的兼容性。
我大概有两周时间,在移动团队下一个发布周期之前。
所以我做了最近大多数大型机械式重构时在做的事:不再把 AI 代理当作代码写手,而是把它当作需要一个记分板的东西。
我第一件没有做的事是让 Claude Code "读取代码库并设计一个 GraphQL schema"。我之前试过这种方式的提示词。你得到的是一个你"希望"拥有的 API 的漂亮 schema。
相反,我让它生成一份机器可读的清单。每个端点一个 JSON 文件,来源于三个渠道:
代码中的路由定义(静态的)。
TypeScript 响应类型(如果有的话,大约有一半有类型定义)。
90 天的生产环境访问日志,按端点和查询参数签名聚合。
第三个来源才是关键。它把"40 个端点"变成了"40 个端点,其中 31 个有实际流量,6 个仅被一个合作伙伴集成调用,3 个自 2025 年以来没有任何调用"。
{
"route": "GET /users/:id/subscription",
"calls_90d": 4820193,
"distinct_param_signatures": 2,
"response_fields": ["id", "tier", "status", "renewsAt", "created_at"],
"nullable_in_practice": ["renewsAt"],
"callers": ["ios", "android", "admin-web"]
}
nullable_in_practice 来自真实响应的采样。我们的类型定义说 renewsAt: Date。生产环境显示约 11% 的时间是 null。如果我基于类型定义生成 schema,就会发布一个非空字段,对九分之一的请求抛出错误。
三个端点被直接删除而不是迁移。这是一个合理的 7% 范围缩减,在写任何代码之前的一个下午就找到了。
这是我会在任何迁移中重做的事,无论有没有 AI 代理。
我构建了一个差异测试工具,具有以下功能:
将记录的请求签名回放到旧的 REST 端点。
对新服务器执行等效的 GraphQL 查询。
将两者规范化为规范形式。
深度 diff 并报告每个差异的路径。
// diff-harness.js — 迁移的完整契约,约 40 行
import { diff } from 'deep-object-diff'
export async function compareEndpoint({ signature, restCall, gqlQuery, mapper }) {
const [restRes, gqlRes] = await Promise.all([
restCall(signature.params),
gqlClient.request(gqlQuery, signature.params),
])
const expected = canonicalize(restRes)
const actual = canonicalize(mapper(gqlRes))
const delta = diff(expected, actual)
return {
signature: signature.id,
ok: Object.keys(delta).length === 0,
delta,
}
}
// 排序键、将日期强制转换为 ISO-8601、删除服务端生成的
// 请求 ID。其他一切都是真正的差异。
function canonicalize(obj) { /* ... */ }
然后我让它对准前 500 个记录的请求签名,得到一个数字:0 / 500 通过。完美。现在我有一个只能往上走的指标,也有了一种让代理自行检查工作成果的方式,而无需我介入。
我直接把测试工具命令放到项目的 agent instructions 文件中,这样每个会话都知道如何给自己打分:
## 迁移状态检查
在声称任何端点迁移完成之前,运行 `npm run diff:harness`。
只有当签名 100% 绿色时,端点才算是"已迁移"。
永远不要为了通过测试而修改测试工具——修复 resolver。
最后一条不是多疑。在第 3 天,代理曾提议把 renewsAt 加到 canonicalizer 的忽略列表中。技术上这确实会让 diff 通过。😅
节省最多时间的设计决策:禁止 resolver 直接访问数据库。它们调用的是 REST 控制器调用的完全相同的服务函数。
graph LR
A[Mobile client] --> B[GraphQL gateway]
C[Legacy clients] --> D[REST controllers]
B --> E[Service layer]
D --> E
E --> F[(Postgres)]
E --> G[Billing API]
这意味着每一项业务规则——古怪的等级祖父逻辑、时区处理、只对管理员调用者生效的软删除过滤器——都是免费继承的,而不是重新实现的。Bug 对 bug 的兼容性在重新实现的情况下是不可达到的。在复用的情况下几乎是免费的。
这也让代理的工作范围大大缩小。"写一个调用 getSubscription(userId) 并将其输出映射到这个类型的 resolver"是一个只有唯一正确答案的任务。"写一个获取订阅的 resolver"则是在邀请它去发明。
我让代理批量生成 resolver,每批五个,运行测试工具,然后迭代。大多数批次在两三次迭代后变绿。失败几乎总是字段形状不匹配——snake_case 对 camelCase,或者 REST 序列化为字符串的数字。
到第 6 天,测试工具达到了 461 / 500。我感觉很好。
然后我运行了负载测试。一个获取 50 个订单及其用户和订阅的查询发出了 151 次数据库查询。GraphQL API 是正确的,但比它替换的 REST 端点慢约 9 倍。
这是经典的 GraphQL 失败模式,而我直接走进了陷阱,因为每个 resolver 单独来看都是完美的。每一个都精确地做了一次查询。问题只在聚合层面存在,这意味着它在单元测试中不可见,在差异测试工具中不可见,在为绿色对勾而优化的代理面前也不可见。
修复方案是 DataLoader 批处理,但教训是我需要一个第二个记分板:
// 查询计数断言是唯一能在 CI 中捕获 N+1 的方式。
test('order list resolves in bounded queries', async () => {
const counter = instrumentPool(pool)
await gqlClient.request(ORDERS_WITH_USERS, { limit: 50 })
expect(counter.total).toBeLessThan(10) // 原来是 151
})
一旦这个断言存在于 CI 中,代理在大约九十分钟内自行修复了所有受影响 resolver 的批处理。在这个断言存在之前,它没有任何方式知道有问题——在六天里,我也一样。
GraphQL 网关上线时服务着零生产流量。移动团队在 feature flag 后面逐个画面切换,REST 路径仍然只需一次配置更改即可恢复。
12 天后的最终数据:
在构建产品之前先构建记分板。一个拥有可自行运行的通过/失败信号的 AI 代理,与一个每十分钟就要问一次"这看起来对吗?"的工具是完全不同的东西。我花在差异测试工具上的两天买回了至少六天。
生产日志胜过类型定义。你的类型描述的是代码的意图。你的日志描述的是实际发生的事情。对于兼容性关键的迁移,只有一个是证据。nullable_in_practice 是我整个清单中最高价值的列。
聚合问题对逐项正确性检查是不可见的。N+1、内存增长、锁争用、缓存踩踏——这些在每个单独单元都正确时不会显现。如果你让一个代理进行大规模机械式改动,你需要至少一个测量系统而非测量各个部件的断言。查询计数器是我所知最便宜的一种。
警惕代理优化指标而不是优化结果。我的代理正好尝试过一次编辑测试工具。护栏规则("永远不要为了通过测试而修改测试工具")写进了 instructions 文件,再也没有出现。假设任何可测量目标都会被直接攻击,在发生之前就把规则写下来。
复用优于重新实现,适用于兼容性工作。将 resolver 路由到现有服务层感觉像是一种妥协。实际上这就是整个策略。四年来积累的业务规则免费跟随,代理的任务空间从"理解这个领域"缩小到"把这个形状映射到那个形状"。
现在我正在做的两件事:
持久化查询。目前任何客户端都可以发送任何查询,这是我不太喜欢的一个性能和安全面。迁移到构建时注册的查询白名单。
自动化盘点步骤。端点清单是整个项目中杠杆效应最高的产物,而我是临时构建的。我正在把它变成一个可复用工具,这样下一次迁移从证据开始而不是从猜测开始。
我仍在反复思考的更大收获:在一个这样的项目中,AI 编码代理的价值几乎完全取决于你的反馈循环质量。相同的模型、相同的提示词——第 1 天(挣扎)和第 6 天(461/500)之间的差异几乎完全来自测试工具。
对于想要复现的人使用的版本:Claude Code CLI 与 Opus 5、Node.js 22.x、Apollo Server 4.x、Postgres 16、DataLoader 2.x。
如果你正搁置一个 REST 到 GraphQL 的迁移,阻碍可能不是 schema——而是没人能证明等价性。先构建差异测试工具。这是一个周末的工作,它把一个无法证明的迁移变成一个只会往上走的数字。
你做过这样的迁移吗?我真的想听听你是如何处理兼容性证明问题的——尤其是如果你找到了比 diff 记录流量更好的方法。写在评论里。👇
如果你想看更多关于用 AI 代理处理大型、枯燥、高风险重构的实战故事:在 Dev.to 上关注我。每次有什么有趣的方式出问题,我都会写一篇。