每日 AI 实战案例

83 万行 TypeScript 重写成 Rust:GitHub Copilot 运行时迁移实战复盘

2026-09-18
高级开发
14.5 周
使用工具:
GitHub Copilot AgentClaude CodeRust AnalyzerCargoNAPI-RSTokioGitHub Actions
AI实战跨语言迁移TypeScriptRustCopilot Agent大型重构增量交付生产级

案例摘要

GitHub 官方博客在 2026 年 9 月公布了一次极端规模的迁移:用 Copilot 智能体把 Copilot 运行时从 TypeScript/Node.js 重写为 **832,378 行 Rust**,耗时约 **14.5 周**,通过 **128 个增量 PR** 合入 main 并持续发布。这是目前公开可查的、规模最大的「AI 主导的生产级跨语言迁移」案例。本文把它拆成可复用的方法论:为什么选择增量 PR 而不是一次性重写、5 个阶段的实施路径(接口冻结 / 边界层搭建 / 分模块迁移 / 双跑验证 / 切流下线)、每阶段配套的 Prompt 模板、效果数据(P99 延迟 -62%、内存 -71%、冷启动 -88%)、以及 15 个真实踩过的坑(生命周期地狱、异步运行时不兼容、FFI 边界开销、类型系统阻抗失配等)。附一份可直接套用的迁移检查清单。

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 主导

三个前提:

  1. 有完整的测试护栏。原项目测试覆盖率 74%,有 8,600+ 单元测试 + 完整的集成测试套件。这是 AI 敢改的底气——改错了测试会叫。
  2. 有可对比的双跑环境。新旧实现可以并行跑,用真实流量对比输出。
  3. 团队有 Rust 专家兜底。AI 写,人审。不是「AI 写完直接合」。

这三个前提缺一不可。没有测试护栏的跨语言迁移,无论人写还是 AI 写都是灾难。


二、工具分工

工具角色使用强度
GitHub Copilot Agent主力:模块级迁移代码生成⭐⭐⭐⭐⭐
Claude Code复杂模块设计 + 疑难 bug 定位⭐⭐⭐⭐
Rust Analyzer实时类型检查 / 补全⭐⭐⭐⭐⭐
Cargo + Clippy编译期强制约束⭐⭐⭐⭐⭐
NAPI-RSTS ↔ 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 层(已迁移部分)             │
└────────────────────────────────────────────┘

规则:

  1. TS 层只能调用 FFI 边界暴露的接口,不能直接访问 Rust 内部结构
  2. FFI 接口先冻结,后实现——先把接口签名定下来,AI 再填实现
  3. 每个模块迁移完,把 TS 侧的实现标记为 deprecated,但不删除(可回滚)
  4. 通过 feature flag 控制走新实现还是旧实现

3.4 迁移顺序的决策依据

不是按代码量,是按依赖拓扑 + 风险:

阶段模块行数选择理由
1工具函数 / 字符串处理 / 路径操作42,000无外部依赖,纯函数,最安全
2配置解析 / 序列化38,000依赖少,测试覆盖好
3文件扫描 / 索引96,000性能收益最大的部分
4上下文收集 / 请求编排187,000核心逻辑,依赖前 3 阶段
5补全渲染 / LSP 协议处理214,000与 IDE 耦合最深,放最后
6子进程管理 / IPC121,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,
}

为什么先写签名:

  1. AI 填实现时有了明确的契约,不会自由发挥
  2. 编译期就能发现接口设计问题(比如生命周期不满足)
  3. 人工审查的成本降到最低——审 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.jsRust变化
冷启动时间1,840 ms214 ms-88.4%
大仓库索引(10 万文件)41.2 s8.7 s-78.9%
P50 补全延迟38 ms21 ms-44.7%
P99 补全延迟412 ms156 ms-62.1%
P99.9 补全延迟1,820 ms340 ms-81.3%
常驻内存(RSS)284 MB82 MB-71.1%
内存峰值(索引时)1.21 GB386 MB-68.1%
CPU 占用(空闲)1.8%0.3%-83.3%
二进制体积—(需 Node)42 MB—
依赖 Node.js✅❌摆脱运行时依赖

5.2 质量数据

指标迁移前迁移后
单元测试数8,64211,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 不保序。

修法:

  1. 需要顺序的地方用 IndexMap
  2. 双跑对比前对 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:

  1. 把测试覆盖率提到 60% 以上
  2. 搭一套双跑对比环境
  3. 找一个真正的 Rust(或目标语言)专家
  4. 把 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 写代码」,而是这三条:

  1. 测试护栏先行——8,600 个测试是敢让 AI 动手的底气
  2. 增量交付——128 个小 PR 而不是 1 个巨型 PR,风险被切成可回滚的小块
  3. 双跑对比兜底——让机器发现行为差异,让人做架构决策

以及一个必须记住的数字:

AI 写了 78% 的代码,但整体效率只提升 1.5-2 倍。

因为软件工程的时间大头不在写代码,在确认代码是对的。想提速,先把验证环节的杠杆做足。

相关推荐

查看更多 →
案例企业应用

31 万行 TypeScript 单仓接入 AI 编码实战:Cursor + DeepSeek V4.1 Flash + MCP 六周落地,PR 周期从 4.2 天降到 1.6 天

把 AI 编码助手引进一个 31 万行、8 年历史、几乎没有类型覆盖的 TypeScript 单仓,第一周就有工程师反馈「它写的东西我全都要重写」。本文记录 M 公司平台团队 2026 年 8 到 10 月用六周完成 AI 编码落地的完整过程:不换模型、不换工具,只做四件事——把 implicit any 清理出来让模型有类型可读(第 1-2 周)、写 14 条 .cursor/rules 把团队约束固化进去(第 3 周)、自建 OpenAI 兼容代理解决 429 中断与 Key 管控(第 4 周)、用 MCP 把内部的服务元数据和埋点查询接进 Agent(第 5-6 周)。结果:PR 从提交到合并的中位从 4.2 天降到 1.6 天,单测覆盖率从 41% 升到 67%,AI 生成代码的「几乎不用改」比例从 12% 升到 58%,月模型支出 ¥3,180(人均 ¥106)。全文含完整的工具分工表、分阶段投入与产出数据、四份可直接复制的配置、12 条踩坑清单,以及一条最重要的结论:历史包袱不是 AI 的天敌,没有类型信息才是。

2026-10-10
#AI实战#遗留代码
案例企业应用

Agent 编码返工率治理实战:spec + 验收测试 + diff 自检三件套,把 AI 代码一次通过率从 38% 拉到 71%

AI 写代码的产能和它的返工量成正比,这是研发团队用AI 编码半年后才真正撞到的墙。我们团队 12 个前端工程师、4 个 AI 编码 Agent Cursor / Cline / Copilot 并行,日均产出 PR 从 11 个涨到 27 个,看起来赢了——但首轮评审通过率只有 38%,意味着三分之二的工作量在被返工吃掉。真正的原因不是模型能力不够,而是任务交给 Agent 的方式错了:需求描述里没有验收标准,Agent 只能猜;Agent 不知道验收标准,就没法自检;不自检就直接提交,返工必然发生。本文记录我们用四个月把一次通过率从 38% 提到 71% 的完整过程。核心是三件套:① 提交前把模糊需求转成可验收的 spec(含验收命令与判定标准);② 在 Agent 里挂可执行的验收测试,让它提交前自己跑一遍;③ 加一道 diff 自检环节专门抓「超出任务范围的改动」。四个阶段的实施顺序和每阶段的踩坑、三条被证明无效的尝试(包括「写更详细的 Prompt」)、以及12 个可复制的 Prompt 模板。附分阶段效果数据表:一次通过率 38%→71%、返工工时占比 44%→19%、交付周期 2.8 天→1.1 天,以及两个必须诚实说明的负面结果。

2026-10-13
#AI实战#AI编码
案例企业应用

AI 代码评审闸门落地实战:三道闸门 + 分级拦截,6 周把评审等待从 19 小时压到 2.4 小时,线上缺陷率降 34%

代码评审是研发流程里最容易堵的一环:PR 提上去平均要等 19 小时才有人看, reviewer 的时间被「这个变量命名不统一」「这里缺个空行」这类机械问题吃掉大半,真正的设计问题反而因为疲劳被放过。本文记录 N 公司基础架构组 2026 年 8 到 10 月用六周建成 AI 评审闸门的全过程:不追求「AI 替代人评审」,而是把评审拆成三道闸门——第一道 AI 机械扫描(风格、缺测试、明显 bug、安全模式),秒级返回、不过直接打回;第二道 AI 深度审查(逻辑正确性、边界、并发),只在第一道通过后跑,输出结构化意见并标注置信度;第三道人工,且 AI 明确标注「本次我覆盖了什么、没覆盖什么」,让人把注意力放在 AI 说不清的地方。关键设计是「分级拦截」:按改动风险自动决定闸门严格度,改个文案不该走全流程。结果:评审首响从中位 19 小时降到 2.4 小时,reviewer 人均每周花在评审上的时间从 6.5 小时降到 2.8 小时,合并后 7 天内发现的缺陷率下降 34%,AI 误报导致的无效返工控制在 8% 以内。全文含完整的工具分工表、三道闸门的判定规则与 Prompt 模板、分阶段投入产出数据、12 条踩坑清单,以及一条最重要的教训:AI 评审失败的头号原因不是能力不够,是它不敢说「这里我没把握」。

2026-10-12
#AI实战#代码评审
案例企业应用

DeepSeek V4.1 Flash 多模型路由降本实战:单任务成本从 $1.47 砍到 $0.33,月账单降 77.7%

2026 年 10 月,DeepSeek V4.1 Flash 的价格比 V4 Pro 低约 40%,把「多模型路由」这件事从可选优化变成了必答题。本文记录一家 420 人 SaaS 公司用 5 周把 Agent 平台从「所有调用都打最贵模型」改造成「按任务难度分档路由」的全过程:月支出从 $71,300 降到 $15,900(降 77.7%),单任务成本从 $1.47 降到 $0.33,P95 延迟从 31.2 秒降到 12.6 秒,而任务完成率反而从 93.4% 升到 96.2%。关键结论是——一次 40 次调用的任务里,真正需要最贵档模型的只有 4 次(10%),但这 4 次吃掉了 82% 的钱;剩下 36 次里有 27 次是「格式化、过滤、抽取」这类确定性活,用 Flash 完全够。全文含可直接复制的难度分级 Prompt 模板、TypeScript 路由判定器完整实现(规则前置 + 模型打分 + 三级 fallback + 预算闸门)、五周灰度切流的逐周数据、以及 12 个踩过的坑。

2026-10-08
#AI实战#多模型路由