83 万行 TypeScript 重写成 Rust
一、背景:为什么敢做这件事
1.1 项目基本情况
GitHub Copilot 运行时是跑在开发者本地、负责代码上下文收集、请求编排、补全渲染的常驻进程。原始实现:
| 项 | 值 |
|---|---|
| 语言 | TypeScript |
| 运行时 | Node.js |
| 代码规模 | 约 61 万行 TS(不含测试) |
| 部署形态 | 随 IDE 插件常驻 |
| 进程数 | 主进程 + 3-5 个子进程 |
| 常驻内存占用 | 180-320 MB |
| 冷启动时间 | 1.2-2.8 s |
问题不是「跑不动」,而是「跑得不够好」:
| 痛点 | 具体表现 | 业务影响 |
|---|---|---|
| 冷启动慢 | 大仓库首次补全要等 2s+ | 用户感知明显,NPS 下降 |
| 内存占用高 | 多开 IDE 时累计 1.5GB+ | 低配机器卡顿投诉 |
| 大仓库索引慢 | 10 万文件仓库索引 40s+ | 大客户流失风险 |
| GC 抖动 | 补全偶发 200ms+ 尖刺 | 补全体验不一致 |
1.2 为什么是 Rust
评估过的方案:
| 方案 | 优势 | 劣势 | 结论 |
|---|---|---|---|
| 优化现有 TS | 成本最低 | 天花板明显,GC 抖动无解 | ❌ |
| 换 Go | 上手快 | 内存占用仍偏高,无所有权保证 | ❌ |
| 换 C++ | 性能最好 | 内存安全无保障,团队无积累 | ❌ |
| Rust | 零 GC、内存安全、可渐进替换 | 学习曲线陡、迁移周期长 | ✅ |
| Zig | 轻量 | 生态不成熟 | ❌ |
决定性因素:Rust 能通过 FFI 渐进替换 TypeScript 模块。这一点 C++ 做不到(内存模型不兼容会很痛苦),Go 也不理想(需要独立进程 + IPC)。
1.3 为什么敢让 AI 主导
三个前提:
- 有完整的测试护栏。原项目测试覆盖率 74%,有 8,600+ 单元测试 + 完整的集成测试套件。这是 AI 敢改的底气——改错了测试会叫。
- 有可对比的双跑环境。新旧实现可以并行跑,用真实流量对比输出。
- 团队有 Rust 专家兜底。AI 写,人审。不是「AI 写完直接合」。
这三个前提缺一不可。没有测试护栏的跨语言迁移,无论人写还是 AI 写都是灾难。
二、工具分工
| 工具 | 角色 | 使用强度 |
|---|---|---|
| GitHub Copilot Agent | 主力:模块级迁移代码生成 | ⭐⭐⭐⭐⭐ |
| Claude Code | 复杂模块设计 + 疑难 bug 定位 | ⭐⭐⭐⭐ |
| Rust Analyzer | 实时类型检查 / 补全 | ⭐⭐⭐⭐⭐ |
| Cargo + Clippy | 编译期强制约束 | ⭐⭐⭐⭐⭐ |
| NAPI-RS | TS ↔ Rust FFI 桥接 | ⭐⭐⭐⭐ |
| Tokio | 异步运行时 | ⭐⭐⭐⭐ |
| GitHub Actions | 每 PR 全量 CI + 双跑对比 | ⭐⭐⭐⭐⭐ |
| 人工 | 架构决策 + PR 审查 + 边界处理 | ⭐⭐⭐⭐ |
2.1 一个关键决策:AI 不做架构决策
分工非常明确:
| 决策类型 | 谁做 |
|---|---|
| 模块拆分边界 | 人 |
| FFI 接口设计 | 人 |
| 异步模型选型(Tokio vs async-std) | 人 |
| 错误处理策略 | 人 |
| 模块内部实现翻译 | AI |
| 测试补充 | AI 生成 + 人审 |
| 性能优化 | AI 建议 + 人验证 |
为什么:AI 擅长「把一个明确的输入翻译成另一个明确的输出」,不擅长「在多个各有权衡的方案里做选择」。让 AI 做架构决策,它会给出看起来合理但实际考虑不周的方案。
三、策略设计:为什么是 128 个增量 PR
3.1 被否决的方案
方案 A:一次性全量重写
分支:rewrite-in-rust
工期:6 个月
风险:合入时与 main 冲突不可解
否决原因:6 个月里 main 会变。61 万行代码的项目,半年内 main 会有数千个提交。一次性合入的冲突量是天文数字。
方案 B:按大模块分 5 个大 PR
PR1: 核心引擎(20 万行)
PR2: 上下文收集(15 万行)
...
否决原因:单个 PR 20 万行,没人能审。Code review 形同虚设,等于直接合入。
3.2 采用的方案:128 个增量 PR
| 维度 | 设计 |
|---|---|
| 单个 PR 规模 | 3,000 - 12,000 行(中位数 6,500 行) |
| 单个 PR 周期 | 2-4 天 |
| 合入频率 | 每周 8-9 个 |
| 可发布性 | 每个 PR 合入后 main 都是可发布状态 |
| 审查方式 | 人工审 + AI 辅助 diff 摘要 |
核心原则:任何时刻 main 都能发布。这就要求迁移必须是渐进的——新旧代码共存,通过开关切换。
3.3 增量迁移的架构支撑
关键在于边界层设计:
┌────────────────────────────────────────────┐
│ TypeScript 层(未迁移部分) │
├────────────────────────────────────────────┤
│ FFI 边界层(NAPI-RS 生成) │ ← 稳定的契约
├────────────────────────────────────────────┤
│ Rust 层(已迁移部分) │
└────────────────────────────────────────────┘
规则:
- TS 层只能调用 FFI 边界暴露的接口,不能直接访问 Rust 内部结构
- FFI 接口先冻结,后实现——先把接口签名定下来,AI 再填实现
- 每个模块迁移完,把 TS 侧的实现标记为 deprecated,但不删除(可回滚)
- 通过 feature flag 控制走新实现还是旧实现
3.4 迁移顺序的决策依据
不是按代码量,是按依赖拓扑 + 风险:
| 阶段 | 模块 | 行数 | 选择理由 |
|---|---|---|---|
| 1 | 工具函数 / 字符串处理 / 路径操作 | 42,000 | 无外部依赖,纯函数,最安全 |
| 2 | 配置解析 / 序列化 | 38,000 | 依赖少,测试覆盖好 |
| 3 | 文件扫描 / 索引 | 96,000 | 性能收益最大的部分 |
| 4 | 上下文收集 / 请求编排 | 187,000 | 核心逻辑,依赖前 3 阶段 |
| 5 | 补全渲染 / LSP 协议处理 | 214,000 | 与 IDE 耦合最深,放最后 |
| 6 | 子进程管理 / IPC | 121,000 | 涉及进程模型,风险最高 |
| 7 | 收尾:删除 TS 旧实现 | — | 全部验证通过后 |
原则:先迁「错了也不致命」的,再迁「错了会炸」的。前 3 个阶段即使出问题,回滚成本也很低。
四、五阶段实施路径
阶段 1:接口冻结(2 周)
目标:把所有要迁移的模块接口用 Rust 语法先写出来,不写实现。
产出:一份 trait 定义文件,约 1.2 万行。
// src/ports/path_utils.rs
// 阶段 1 产出:只有签名,没有实现
pub trait PathUtils {
/// 规范化路径,解析 . 和 ..
fn normalize(&self, path: &str) -> Result<String, PathError>;
/// 判断 child 是否在 parent 下
fn is_subpath(&self, parent: &Path, child: &Path) -> bool;
/// 转换为相对路径
fn to_relative(&self, base: &Path, target: &Path) -> Result<PathBuf, PathError>;
}
pub struct PathError {
pub kind: PathErrorKind,
pub message: String,
}
为什么先写签名:
- AI 填实现时有了明确的契约,不会自由发挥
- 编译期就能发现接口设计问题(比如生命周期不满足)
- 人工审查的成本降到最低——审 1.2 万行签名比审 61 万行实现容易得多
Prompt 模板(阶段 1):
你正在协助把一个 TypeScript 项目迁移到 Rust。现在处于「接口冻结」阶段。
## 你的任务
阅读以下 TypeScript 模块,输出对应的 Rust trait 定义。
## TypeScript 源码
{{ts_source}}
## 输出要求
1. **只输出签名,不输出实现**。函数体用 `todo!()` 占位。
2. 每个 trait 方法必须有完整的 Rustdoc 注释,包括:
- 一句话说明做什么
- `# Arguments` 每个参数
- `# Returns` 返回值
- `# Errors` 什么情况下返回 Err
- `# Example` 一个使用示例
3. 错误处理:定义模块专属的 Error enum,不要直接用 String 或 anyhow。
使用 thiserror 派生。
4. 所有权规则:
- 只读访问用 `&str` / `&Path`
- 需要所有权用 `String` / `PathBuf`
- **不要用 `'static` 生命周期,除非确实需要**
5. 异步:如果 TS 版本是 async 函数,Rust 侧用 `async fn`。
明确标注是否需要 Send。
6. 类型映射严格遵循下表:
| TypeScript | Rust |
|---|---|
| `string` | `&str`(只读)/`String`(拥有) |
| `number` | `i64` 或 `f64`,**不要用 `i32`** |
| `boolean` | `bool` |
| `T[]` | `Vec<T>` |
| `Map<K,V>` | `HashMap<K,V>` |
| `T \| undefined` | `Option<T>` |
| `Promise<T>` | `Future<Output = T>` |
| `unknown` | **必须明确具体类型,不允许 any 等价物** |
## 约束
- 不添加 TS 版本里不存在的函数
- 不合并 TS 版本里的函数(即使看起来功能相似)
- 如果 TS 里有重载,拆成不同名的方法
## 输出格式
一个完整的 .rs 文件内容,不要 Markdown 代码块包裹。
阶段 2:边界层搭建(1.5 周)
目标:用 NAPI-RS 把 Rust 侧 trait 暴露给 TypeScript。
// src/napi/path_utils.rs
use napi_derive::napi;
use crate::ports::path_utils::*;
#[napi]
pub struct JsPathUtils {
inner: PathUtilsImpl,
}
#[napi]
impl JsPathUtils {
#[napi(constructor)]
pub fn new() -> Self {
Self { inner: PathUtilsImpl::default() }
}
#[napi]
pub fn normalize(&self, path: String) -> napi::Result<String> {
self.inner.normalize(&path).map_err(|e| {
napi::Error::from_reason(format!("{}", e))
})
}
}
TS 侧生成的类型:
export declare class JsPathUtils {
constructor();
normalize(path: string): string;
isSubpath(parent: string, child: string): boolean;
toRelative(base: string, target: string): string;
}
关键坑:NAPI-RS 的类型转换有成本。实测数据:
| 数据规模 | 单次调用开销 | 建议 |
|---|---|---|
| 小字符串(<1KB) | 0.8 μs | 可接受 |
| 中等(1-100KB) | 45 μs | 可接受 |
| 大对象(>1MB) | 12 ms | 必须改批量接口 |
规则:FFI 边界上不要传大对象。要么改成流式,要么改成「Rust 侧持有句柄 + TS 侧拿 ID」。
错误示范(被 CI 拦下的):
// ❌ 每次传整个文件内容过 FFI
for (const file of files) {
const content = fs.readFileSync(file, 'utf8'); // 可能 5MB
rustModule.analyze(content); // 12ms × 10000 文件 = 120s
}
正确做法:
// ✅ Rust 侧自己读,只回传结果
#[napi]
pub fn analyze_files(&self, paths: Vec<String>) -> napi::Result<Vec<AnalysisResult>> {
paths.par_iter()
.filter_map(|p| std::fs::read_to_string(p).ok())
.map(|content| analyze(&content))
.collect::<Vec<_>>()
.pipe(Ok)
}
阶段 3:分模块迁移(8 周,主力阶段)
这是 128 个 PR 里 96 个所在的阶段。
单个 PR 的标准流程:
1. 选模块(人工)
↓
2. Copilot Agent 生成初版实现(AI)
↓
3. cargo clippy + cargo test(自动)
↓
4. 双跑对比:新旧实现跑同一批输入,diff 输出(自动)
↓
5. AI 自动修复 diff 不一致(AI)
↓
6. 回到 3,最多 5 轮
↓
7. 人工审 PR(人)
↓
8. 合入
Prompt 模板(阶段 3):
你正在把一个 TypeScript 模块迁移到 Rust。接口已在阶段 1 冻结,不要修改签名。
## 已冻结的 Rust trait(不要修改)
{{rust_trait}}
## TypeScript 原始实现(要翻译的源)
{{ts_implementation}}
## 对应的 TypeScript 测试(必须全部通过)
{{ts_tests}}
## 迁移要求
### 正确性
1. **行为必须完全等价**。包括:
- 边界条件(空输入、超长输入、非法 UTF-8)
- 错误类型要一一对应(TS 抛 X → Rust 返回 Err(X))
- 数值精度(TS 的 number 是 f64,注意整数溢出行为差异)
2. **不要「改进」实现**。即使你发现 TS 版本有 bug,也先照搬,
在注释里标注 `// NOTE: 保持与 TS 版本一致的行为,疑似 bug,见 issue #xxx`
### Rust 惯用法
3. 用 `Result` 而不是 panic。禁止 `unwrap()` / `expect()`,
除非你能证明它不可能失败(并加注释说明)。
4. 迭代器优先于手写循环。
5. 能用借用就不用克隆。但**不要为了省一次克隆把代码写复杂**。
### 异步
6. 如果 TS 版本用了 Promise.all,Rust 侧用 `futures::join_all` 或 `try_join_all`
7. **不要用 `block_on`**。Tokio runtime 内部调用 block_on 会死锁。
### 测试
8. 把 TS 测试逐条翻译成 Rust 测试,放在 `#[cfg(test)] mod tests`
9. **测试数量不得少于 TS 版本**
10. 补充 Rust 特有的测试:所有权 / 生命周期 / 并发安全
## 自检(输出前必做)
| 检查项 | 结果 |
|---|---|
| 所有 TS 测试都有对应的 Rust 测试? | 是/否 |
| 有没有 unwrap/expect(除已论证的)? | 是/否 |
| 错误处理是否与原版一一对应? | 是/否 |
| 有没有 block_on? | 是/否 |
| 是否修改了已冻结的 trait 签名? | 是/否 |
## 输出
完整的 .rs 文件(实现 + 测试),不要 Markdown 代码块包裹。
阶段 4:双跑验证(贯穿全程,2 周集中处理)
这是整个迁移里最重要的质量保障机制。
原理:同一份输入,同时跑 TS 实现和 Rust 实现,逐字节对比输出。
// dual_run.rs
pub struct DualRunResult {
pub ts_output: serde_json::Value,
pub rust_output: serde_json::Value,
pub diff: Option<String>,
pub ts_duration_us: u64,
pub rust_duration_us: u64,
}
pub fn dual_run(module: &str, input: &[u8]) -> DualRunResult {
let t0 = Instant::now();
let ts_out = call_ts_implementation(module, input);
let ts_dur = t0.elapsed().as_micros() as u64;
let t1 = Instant::now();
let rust_out = call_rust_implementation(module, input);
let rust_dur = t1.elapsed().as_micros() as u64;
let diff = if ts_out != rust_out {
Some(json_diff(&ts_out, &rust_out))
} else {
None
};
DualRunResult { ts_output: ts_out, rust_output: rust_out, diff, ts_duration_us: ts_dur, rust_duration_us: rust_dur }
}
采样策略:生产环境 1% 流量走双跑,每天约 420 万次调用。
diff 分类处理:
| diff 类型 | 占比 | 处理 |
|---|---|---|
| 完全一致 | 96.3% | 通过 |
| 浮点精度差异 | 1.8% | 定义为可接受(阈值 1e-9) |
| 对象 key 顺序不同 | 1.1% | 排序后对比 |
| 时间戳格式 | 0.5% | 归一化后对比 |
| 真实行为差异 | 0.3% | 人工介入,阻塞发布 |
0.3% 听起来小,但 420 万 × 0.3% = 12,600 条/天。所以必须做自动化分类,人工只看最后那类。
踩过的坑:一开始 diff 率高达 17%,排查后发现绝大部分是 Map 迭代顺序问题——TS 的 Map 保持插入顺序,Rust 的 HashMap 不保证。修正方式:Rust 侧改用 IndexMap,或在对比前排序。
阶段 5:切流下线(2 周)
切流节奏:
| 阶段 | 流量比例 | 观察时长 | 回滚条件 |
|---|---|---|---|
| 内测 | 0.1% | 3 天 | 任意 P0 |
| 小流量 | 1% | 3 天 | 错误率 > 0.05% |
| 扩大 | 10% | 5 天 | 错误率 > 0.02% |
| 半量 | 50% | 5 天 | P99 劣化 > 10% |
| 全量 | 100% | 7 天 | 任意 P0 |
| 下线 TS | — | — | — |
关键设计:feature flag 必须支持秒级回滚,且回滚路径要每月演练一次。演练时发现的问题:
- 第一次演练:回滚后 TS 侧因为 FFI 类型已删除而崩溃 → 修法:TS 实现保留到全量后 1 个月
- 第二次演练:回滚后用户配置格式不兼容 → 修法:配置格式双向兼容层
删除 TS 代码的时机:全量后 1 个月。过早删除会让回滚失效。
五、效果数据
5.1 性能对比
| 指标 | TypeScript/Node.js | Rust | 变化 |
|---|---|---|---|
| 冷启动时间 | 1,840 ms | 214 ms | -88.4% |
| 大仓库索引(10 万文件) | 41.2 s | 8.7 s | -78.9% |
| P50 补全延迟 | 38 ms | 21 ms | -44.7% |
| P99 补全延迟 | 412 ms | 156 ms | -62.1% |
| P99.9 补全延迟 | 1,820 ms | 340 ms | -81.3% |
| 常驻内存(RSS) | 284 MB | 82 MB | -71.1% |
| 内存峰值(索引时) | 1.21 GB | 386 MB | -68.1% |
| CPU 占用(空闲) | 1.8% | 0.3% | -83.3% |
| 二进制体积 | —(需 Node) | 42 MB | — |
| 依赖 Node.js | ✅ | ❌ | 摆脱运行时依赖 |
5.2 质量数据
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
| 单元测试数 | 8,642 | 11,287 |
| 测试覆盖率 | 74% | 81% |
| 生产 P0 事故(迁移期间) | — | 0 |
| 生产 P1 事故(迁移期间) | — | 3 |
| 回滚次数 | — | 2(均为预案演练触发) |
| 崩溃率 | 0.031% | 0.004% |
P99.9 从 1.82s 降到 340ms 是最有价值的数字。它意味着 GC 抖动导致的极端延迟基本消失——这是换 Rust 最直接的收益,也是优化 TS 永远做不到的。
5.3 效率数据
| 指标 | 数值 |
|---|---|
| 总代码量 | 832,378 行 Rust |
| 迁移周期 | 14.5 周 |
| PR 数量 | 128 |
| 平均单 PR 规模 | 6,503 行 |
| 参与工程师 | 9 人 |
| AI 生成代码占比(估算) | 约 78% |
| 人工审查耗时 | 平均 47 分钟/PR |
| 人均产出 | 约 92,500 行/人 |
对比:同等规模的人工迁移,行业经验值是 40-60 人月(约 9-15 个人做 4-6 个月)。这个项目是 9 人 × 3.6 月 = 32 人月。
效率提升约 1.5-2 倍。注意:不是 10 倍。AI 在翻译环节快,但在设计、验证、排错环节没有明显加速,而后者占了约 60% 的时间。
六、15 个踩过的坑
坑 1:生命周期地狱(出现 340+ 次)
现象:AI 生成的代码频繁触发 borrowed value does not live long enough。
根因:TS 里到处都是「返回内部数据的引用」的模式,Rust 不允许。
// ❌ AI 初版:想返回配置里的字符串引用
fn get_name(&self) -> &str {
&self.config.name // 看起来没问题
}
// 实际问题出现在:config 是 Arc<RwLock<Config>> 时
fn get_name(&self) -> &str {
&self.config.read().unwrap().name // ❌ 临时值被借用
}
修法:约定规则写进 prompt——「跨锁边界的函数必须返回拥有所有权的类型」。
// ✅
fn get_name(&self) -> String {
self.config.read().unwrap().name.clone()
}
代价:多一次内存分配。实测对整体性能影响 < 0.3%,可接受。
坑 2:Tokio 运行时嵌套死锁(出现 12 次)
现象:服务偶发卡死,CPU 0%。
根因:AI 在 async 函数里调了 block_on。
// ❌ 死锁
async fn process(&self) -> Result<()> {
let result = tokio::runtime::Handle::current().block_on(async {
self.inner_work().await
});
Ok(result)
}
修法:CI 加静态检查,禁止 block_on 出现在 async fn 里。
# .github/workflows/lint.yml
- name: 禁止 async 内使用 block_on
run: |
if grep -rn "block_on" src/ --include=*.rs | grep -v "tests/" | grep -v "main.rs"; then
echo "❌ 发现 block_on,async 上下文中禁止使用"
exit 1
fi
坑 3:FFI 边界传大对象(出现 8 次,每次都是 P1)
现象:迁移初期某些操作慢了 10 倍,比 TS 还慢。
根因:TS 侧读大文件,整个内容过 FFI 传给 Rust。
修法:见阶段 2。原则——FFI 只传句柄和小数据,大数据让 Rust 自己读。
加性能门禁:
#[cfg(test)]
mod ffi_bench {
#[test]
fn ffi_overhead_within_budget() {
let payload = "x".repeat(1024); // 1KB
let start = Instant::now();
for _ in 0..10_000 {
black_box(js_boundary_call(&payload));
}
let per_call = start.elapsed() / 10_000;
assert!(per_call.as_micros() < 5, "FFI 单次开销 {}μs 超预算", per_call.as_micros());
}
}
坑 4:HashMap 迭代顺序不一致(diff 率飙到 17%)
现象:双跑 diff 率异常高。
根因:TS Map 保序,Rust HashMap 不保序。
修法:
- 需要顺序的地方用
IndexMap - 双跑对比前对 JSON 做递归排序
fn normalize_json(v: &mut serde_json::Value) {
if let Some(obj) = v.as_object_mut() {
let sorted: serde_json::Map<_, _> = obj.iter()
.map(|(k, val)| (k.clone(), val.clone()))
.collect::<BTreeMap<_, _>>()
.into_iter()
.collect();
*obj = sorted;
}
}
坑 5:数值溢出行为差异(出现 23 次)
现象:极端输入下 Rust 报 panic,TS 版本返回 Infinity。
根因:TS number 是 f64,溢出得 Infinity;Rust i64 溢出 panic(debug)或回绕(release)。
修法:prompt 里明确——TS 的 number 一律映射为 f64,除非确认该字段在业务上是整数且不会溢出。
补充规则:
- TS 的 `number` 默认映射为 `f64`
- 仅当满足以下全部条件时才用 `i64`:
1. 该字段在业务语义上是整数
2. 有明确的上界且 < 2^53
3. TS 侧已有校验保证不越界
- 不满足条件的一律用 f64,并在注释说明原因
坑 6:错误处理语义丢失(出现 190+ 次)
现象:TS 抛 5 种不同错误,Rust 侧全变成 anyhow::Error。
根因:AI 图省事,用 anyhow 一把梭。
修法:prompt 强制要求——每个模块定义专属 Error enum,变体与 TS 错误类型一一对应。加 CI 检查:
# 禁止在 src/ 下使用 anyhow(tests/ 和 bin/ 除外)
if grep -rn "anyhow" src/ --include=*.rs | grep -v "tests/"; then
echo "❌ src/ 下禁止使用 anyhow,请定义模块专属 Error enum"
exit 1
fi
坑 7:AI 自作主张"改进"实现(出现 60+ 次)
现象:AI 发现 TS 版本有个明显的低效实现,自作主张换了算法。测试通过,但行为在某些边界下不一致。
修法:prompt 里写死——"不要改进实现,行为等价优先"。并在 PR 模板里加一条检查:
- [ ] 我确认本 PR 只做翻译,没有改动业务逻辑
- [ ] 如发现疑似 bug,已在代码中加 NOTE 注释并开 issue,未自行修复
坑 8:异步取消语义不同(出现 15 次)
现象:用户取消补全请求时,TS 版本会中止,Rust 版本继续跑完才返回。
根因:TS 的 Promise 没有真正的取消(只是忽略结果),Rust 的 tokio::select! 能真取消。语义差异导致资源释放时机不同。
修法:显式实现 CancellationToken,在双跑里加入取消场景测试。
坑 9:字符串编码差异(出现 31 次)
现象:含 emoji / 组合字符的文件名处理出错。
根因:TS 用 UTF-16 code unit 索引(str.length、.slice()),Rust 用 UTF-8 字节索引。
// TS:按 UTF-16 code unit
"😀abc".length // 5
"😀abc".slice(0, 1) // "\ud83d" (半个 emoji,损坏)
// Rust:按字节 / char
"😀abc".len() // 7(字节)
"😀abc".chars().count() // 4(char)
修法:prompt 里加映射规则——TS 的 .length → .chars().count(),.slice(a,b) → .chars().skip(a).take(b-a).collect()。并在测试集里加入 emoji 用例。
坑 10:并发安全假设不成立(出现 9 次)
现象:单测全过,集成测试偶发数据竞争。
根因:TS 单线程,很多「全局可变状态」的代码在 TS 里安全,翻译到 Rust 多线程后出事。
修法:AI 生成的代码一律要求标注 Send + Sync 约束,编译期就暴露问题。
// 强制要求:跨线程传递的类型必须 Send + Sync
fn spawn_worker(state: Arc<dyn State + Send + Sync>) { ... }
坑 11:Clippy 警告被批量 allow(出现 1 次,但后果严重)
现象:某个 PR 里出现了 47 处 #[allow(clippy::all)]。
根因:AI 为了让编译通过,直接关闭警告。
修法:CI 禁止 #[allow(clippy::all)],只允许带具体 lint 名的 allow,且必须加注释说明理由。
- name: 检查 allow 使用
run: |
if grep -rn "#\[allow(clippy::all)\]" src/; then
echo "❌ 禁止 blanket allow"
exit 1
fi
坑 12:测试被"翻译"但没被"增强"
现象:AI 把 TS 测试逐条翻译成 Rust,覆盖率数字达标,但没覆盖 Rust 特有的风险(所有权、并发、FFI 边界)。
修法:prompt 里明确要求补充三类测试,并在 PR 模板里检查:
- [ ] 所有权/生命周期测试(至少 2 条)
- [ ] 并发安全测试(至少 1 条,用 loom 或多线程断言)
- [ ] FFI 边界测试(至少 2 条,含空输入和超长输入)
坑 13:PR 太大导致审查失效
现象:中期有几个 PR 达到 3 万行, reviewer 实际只看了前 2000 行。
修法:CI 硬性限制——单 PR 超过 12,000 行自动打标签 needs-split,禁止合入。
坑 14:回滚路径从未演练
现象:第一次真实需要回滚时发现,TS 侧的 FFI 类型已被删除,回滚后直接崩溃。
修法:每月演练一次回滚。这个演练发现了 2 个真实缺陷。
坑 15:AI 生成的文档是"看起来对"的
现象:Rustdoc 写得很漂亮,但示例代码编译不过。
修法:CI 加 cargo test --doc。文档示例必须能编译运行。
- name: 文档测试
run: cargo test --doc --workspace
七、可复用的方法论
7.1 迁移前的六个必答问题
| # | 问题 | 不通过就不要开始 |
|---|---|---|
| 1 | 现有测试覆盖率 ≥ 60%? | 否 → 先补测试 |
| 2 | 有可对比的双跑环境? | 否 → 先搭 |
| 3 | 有目标语言专家兜底? | 否 → 先招/培养 |
| 4 | 能接受增量发布(新旧共存)? | 否 → 别做 |
| 5 | 有秒级回滚能力? | 否 → 先做 |
| 6 | 迁移收益可量化? | 否 → 先量化 |
7.2 迁移检查清单
阶段 1:接口冻结
- 所有待迁移模块的 trait 签名已写出
- 每个方法有完整 Rustdoc(含 Example)
- 错误 enum 已定义,与 TS 错误一一对应
- 类型映射表已确认(尤其 number 和字符串索引)
- 人工已审签名
阶段 2:边界层
- FFI 接口已生成 TS 类型定义
- 大对象不过边界
- FFI 开销有性能门禁
- TS 侧旧实现仍在(可回滚)
阶段 3:模块迁移
- 单 PR ≤ 12,000 行
- 行为等价,未"改进"实现
- 无 unwrap/expect(除非已论证)
- 无 async 内 block_on
- 无 anyhow(src/ 下)
- 测试数 ≥ TS 版本
- 含所有权/并发/FFI 三类补充测试
- Clippy 零警告(无 blanket allow)
-
cargo test --doc通过 - 人工审 PR
阶段 4:双跑
- 生产流量双跑已开启
- diff 自动分类(精度/顺序/格式/真实差异)
- 真实行为差异 = 0 才放行
- 每天看 diff 报告
阶段 5:切流
- 0.1% → 1% → 10% → 50% → 100%,每步有观察期
- 每步有明确回滚条件
- 回滚路径已演练
- TS 旧代码保留至全量后 1 个月
7.3 效率的真实来源
AI 在这个项目里真正加速的只有一件事:把 TS 代码翻译成 Rust 代码。
| 环节 | 占总耗时 | AI 加速比 |
|---|---|---|
| 架构设计 / 接口冻结 | 12% | 1.0×(无明显加速) |
| 代码翻译 | 28% | 约 6× |
| 编译调试循环 | 22% | 1.3× |
| 测试补充 | 11% | 2.5× |
| 双跑 diff 排查 | 15% | 1.2× |
| 人工审查 | 8% | 1.0× |
| 切流与回滚 | 4% | 1.0× |
加权后总加速比约 1.5-2 倍。这符合实际观察。
别被「AI 写了 78% 的代码」这个数字误导——写代码的耗时只占 28%,且 AI 写代码后需要人花时间验证。
7.4 什么情况下不要做这种迁移
| 情况 | 建议 |
|---|---|
| 测试覆盖率 < 40% | 不要做。先补测试。 |
| 没有目标语言专家 | 不要做。AI 填不了架构坑。 |
| 收益无法量化 | 不要做。找不到「因为更快所以赚更多」的链条就别动。 |
| 团队 < 3 人 | 不要做。审查力量不足。 |
| 原系统还在快速迭代 | 慎重。main 变动太快,增量迁移的冲突成本会吃掉收益。 |
| 业务处于关键期 | 不要做。任何大改都是风险。 |
八、经验总结
8.1 三条最重要的经验
1. 测试护栏是前提,不是配套
8,600+ 个测试是这次迁移敢用 AI 的唯一底气。没有测试护栏的跨语言迁移,AI 写得越快,埋的雷越多。
2. 增量是唯一可行的路径
128 个 PR 而不是 1 个巨型 PR,区别不是「工作量分摊」,而是风险被切成了可观测、可回滚的小块。任何时刻 main 都能发布——这一条压倒一切。
3. 双跑对比比代码审查更可靠
人工审查 6,500 行 Rust 代码,实际能发现的问题有限。但 420 万次真实流量双跑,能把行为差异精确到 0.3%。让机器做它擅长的事。
8.2 一个反直觉的结论
AI 生成代码占比 78%,但效率只提升 1.5-2 倍。
原因:软件工程的时间不是花在「写代码」上,而是花在**「确认代码是对的」**上。AI 把前者压缩了 6 倍,但后者一点没少——甚至因为要验证 AI 的输出,还多了。
所以真正的杠杆点在验证环节,而不是生成环节:
| 验证手段 | 自动化程度 | 发现问题能力 |
|---|---|---|
| 类型检查(Clippy) | 全自动 | 中 |
| 单元测试 | 全自动 | 中 |
| 双跑对比 | 全自动 | 高 |
| 性能门禁 | 全自动 | 中 |
| 人工审查 | 人工 | 高但慢 |
投入产出比最高的是双跑对比。它自动化程度高,且能发现类型系统和单测都发现不了的行为差异。
8.3 给想做类似迁移的团队的建议
先做这四件事,再谈用 AI:
- 把测试覆盖率提到 60% 以上
- 搭一套双跑对比环境
- 找一个真正的 Rust(或目标语言)专家
- 把 feature flag 和秒级回滚做扎实
这四件事做好之后,AI 才能发挥价值。否则 AI 只是让你的团队更快地制造技术债。
九、附:分阶段 Prompt 模板汇总
| 阶段 | Prompt 核心约束 | 关键检查项 |
|---|---|---|
| 接口冻结 | 只出签名不出实现;类型映射表;错误 enum | 不改 TS 语义、不合并函数 |
| 边界层 | 大对象不过边界;错误转换 | FFI 开销门禁 |
| 模块迁移 | 行为等价优先、不"改进";禁 unwrap/block_on/anyhow | 测试数 ≥ TS;三类补充测试 |
| 双跑排查 | 给出 diff 分类;只修真实差异 | 不改测试来迁就实现 |
| 文档 | 示例必须可编译 | cargo test --doc |
小结
83 万行、14.5 周、128 个 PR、0 个 P0 事故。
这个案例最值得抄的不是「用 AI 写代码」,而是这三条:
- 测试护栏先行——8,600 个测试是敢让 AI 动手的底气
- 增量交付——128 个小 PR 而不是 1 个巨型 PR,风险被切成可回滚的小块
- 双跑对比兜底——让机器发现行为差异,让人做架构决策
以及一个必须记住的数字:
AI 写了 78% 的代码,但整体效率只提升 1.5-2 倍。
因为软件工程的时间大头不在写代码,在确认代码是对的。想提速,先把验证环节的杠杆做足。