前几天我拿代码辅助工具写一个异步日志组件,姑且叫它模拟项目X。同一个需求用某动态语言半天就写完了,换到 Rust 之后,光编译阶段就来回磨了十几轮。最让人头大的是:AI 给的代码一眼看过去完全没有语法毛病,函数签名工整、逻辑也对,可只要交到编译器手里,就会被借用检查器一顿乱棒打回来。这就是今天想聊的问题——当 AI 写代码遇见 Rust,为什么会卡壳?
这不是哪个模型笨不笨的问题。我在同一个模拟项目X里反复测试过,模型在生成 Python 和 JavaScript 风格的代码时非常顺手,一旦切换成 Rust,失败率肉眼可见地上升。我也见过不少同行在社区里吐槽“AI 写 Rust 不如我自己手写”,但自己折腾下来的结论是:问题更多出在语言与模型机制的结构性错配上,而不是模型偷懒。这篇文章想把这层错配拆开来讲,聊清楚卡壳的三个层次——语料、语言机制、反馈闭环,然后给出一套我现在实际在用的应对流程。适合所有想把 AI 引入 Rust 工作流的开发者看,也适合只对代码生成模型内部机制感兴趣的同学。
1. 先看一个典型的“语法正确、编译失败”现场
1.1 一个20行代码里藏着的三种错误
先来一个我压到最小的复现。需求很简单:读配置结构体的某个字段,然后在原结构体上追加一段字符串。用 Python 写就是“先取变量再改对象”的常规操作,不会有人觉得有什么问题。但换成 Rust,AI 第一版很可能长这样:
struct Config { name: String, } fn main() { let mut config = Config { name: String::from("demo") }; let name = &config.name; // 先借一个不可变引用 config.name.push_str("_v2"); // 又想可变借用,直接撞车 println!("{}", name); }编译器在第三行报 E0502:不可变借用和可变借用同时存在。这个错误对 AI 来说几乎是经典陷阱——它按“先取出值,随后修改原对象”的自然思路生成代码,但 Rust 要求这两个动作在时间上不能重叠。模型没有“借用生命周期”的全局观念,它只是在 token 层面上觉得“这两个步骤顺序合情合理”。
这只是第一层麻烦。更有意思的是,当我让模型自己修复时,它往往会给出下面这种解法:
fn main() { let mut config = Config { name: String::from("demo") }; let name = config.name.clone(); // 复制一份,绕开借用 config.name.push_str("_v2"); println!("{}", name); }这能编译,但代价是每次都做一次不必要的堆内存克隆。如果这个字段不是 String 而是一个大对象,性能开销就很明显。模型的选择逻辑是“找出最可能消掉编译错误的改法”,不是“找出符合所有权语义的最优改法”,所以它天然倾向使用clone()、unwrap()、Arc这类“万金油”来绕过约束。你让 AI 写一个 Demo 没问题,让它在大型项目里这么绕,团队 review 时迟早要删除重写。
第一层错误是借用冲突,第二层错误是绕过策略带来的性能债。如果项目里再涉及生命周期参数,还会看到第三种错误——模型经常写出“编译器都懒得帮你推断”的签名。例如:
fn get_value<'a>(x: &'a str, y: &str) -> &'a str { if x.len() > y.len() { x } else { y } }这个例子一旦返回y,函数签名就必须把y的生命周期也标注成'a,否则 E0106 和 E0308 一起冒出来。对模型来说,'a这种元变量只是一个奇怪的前缀 token,它很难在推理时把这个 token“绑定”到某个具体参数上。整个过程看下来,AI 根本没到“写错业务逻辑”那一步,就已经在类型系统这一关败下阵来。
1.2 报错集中在哪几类:一张我实测过的统计表
我大概统计了过去三个月用 AI 在模拟项目X里生成的 200 多次 Rust 编译结果,把报错归了个类。这张表不是严谨的学术实验,但能反映一个很真实的现象:绝大多数失败都集中在跟所有权、生命周期和 trait 约束相关的诊断上,纯语法错误反而是少数。
| 诊断编号 | 含义 | 出现频次 | 典型触发场景 |
|---|---|---|---|
| E0502 | 不可变借用与可变借用并存 | 高 | 先取引用再修改原值 |
| E0499 | 多个可变借用同时出现 | 中 | 在循环或嵌套闭包里多路访问 |
| E0597 | 借用值生命周期不够长 | 高 | 把局部变量的引用存进外部集合 |
| E0106 | 缺少生命周期注解 | 中 | 函数签名里返回引用却漏了'a |
| E0277 | 类型不满足 trait 约束 | 中 | 泛型代码里没加正确的 trait bound |
| E0308 | 类型不匹配 | 低 | 返回值类型写错或需要自动解引用 |
我自己的体会是,E0502 和 E0597 这两类占据了至少一半的失败样本。它们本质上都是“引用在错误的时间点仍然存活”的问题,却要求程序员和模型同时理解数据流和借用区间。人类开发者靠经验可以做到,模型靠概率做不到。更麻烦的是,Rust 编译器报错往往是“一处触发,多处连锁”,模型修复了第一个 E0502,下一轮编译又冒出新的 E0597。这种连环错在很大程度上构成了“AI 写 Rust 卡壳”的第一印象。
2. 语料分布不会说谎:Rust 在 AI 训练数据里本来就排不上号
2.1 公开代码库里的“马太效应”
第一个卡壳原因很朴素:大模型学写代码主要靠公开语料,而公开语料里 Rust 的占比,和 Python、JavaScript、Java 不在一个数量级。随便去主流的代码托管平台按语言筛选仓库,看到的结果基本是那几个动态语言和经典静态语言霸榜,Rust 虽然增速快,但存量盘子和头部语言差得很远。训练数据直接影响模型的先验分布,语料少的语言,模型见过的函数形态、接口组合、惯用写法都少,生成时选择空间自然更小,也更容易偏离“Rust 社区的常见做法”。
更深一层是语料质量。Rust 社区里质量很高的生产代码,很多集中在一批知名开源项目里,但这类项目数量本身有限,且大量核心逻辑封装在内部模块或闭源业务代码里。公开数据更多是教程示例、入门项目、demo 和重复性的胶水代码。模型如果按照语料里的众数去“模仿”,学到的反而更偏向“能跑的教学式写法”,而不是“能扛住大型项目复杂约束的写法”。
2.2 模型学的是“平均Rust”,不是“优秀Rust”
这可能听起来有点反直觉:公开代码里也有大量工业级 Rust 项目,为什么模型还是学不好?因为语言模型建模的是 token 的联合概率。它从数据里统计出来的是“出现频率最高的写法”,不是“语义上最优的写法”。在 Python 里,最高频写法和最优写法之间的 gap 往往不大——列表推导式、字典操作、函数式辅助方法,教程里这么写,生产代码也差不多这么写。Rust 不一样,同一件事的合法写法太多:是返回Result还是Option,是用&str还是String,是用迭代器还是直接索引,是用 trait object 还是泛型,是用宏还是普通函数。每种选择都有自己适合的上下文,频次最高的“粗略写法”放到真实项目里,往往比精心设计的写法更容易触发所有权冲突或 trait 不匹配。
再加上 Rust 社区本身就有“惯用法”文化,比如推荐用Result传播错误而不是到处unwrap(),推荐用迭代器而不是 C 风格循环。模型在语料里见过这些建议,但它在生成具体代码时,token 级选择会被“最短路径”主导——哪个 token 在当前上下文里的概率最高,它就选哪个。结果就是大量unwrap()、大量String拷贝、大量Box<dyn Trait>被当成“兜底方案”吐出来。你把这些代码扔给编译器,能过,但 review 起来非常痛苦。模型学到的更多是“平均水准的 Rust”,而不是“能在生产环境站住脚的 Rust”。
3. 结构性错配:所有权、生命周期和组合爆炸不是模型能“猜”出来的
3.1 所有权是一条“全局契约”,不是一个局部语法规则
先来一个类比。Python 里的变量像一张便利贴,你随手写个名字,谁都能读,写完还可以换内容,没人管你。Rust 里的借用规则更像图书馆的规定:一本书在同一时间只能被一个人借走,如果你先借了它,就不能同时让别人改这本书。你想同时读和改,就必须把“还书”这一步先办了。这个规则本身不复杂,复杂的是它在整个函数、甚至跨函数跨结构体之间起作用。
大模型做的是逐 token 生成:它看到前文,预测下一个 token 的分布。这个过程没有“全局数据流分析”,它对借用区间的理解非常脆弱。比如函数开头创建了一个引用,中间经过一个分支,分支里又对原对象做了可变修改,结尾还要用这个引用——对人类来说,稍微想想就知道引用不能在分支里“活过”修改。但模型很难在生成过程中一路回溯前面二十行的借用关系。它更可能直接照着“语义顺序”把引用和修改都写出来,把问题丢给编译器。
这是最根本的错配:Rust 编译器本质上做的是程序分析,而大模型做的是概率采样。两者产物看起来都是代码,但前者有严格的全局一致性要求,后者只有局部的文本相似性。即便模型的上下文窗口已经很大,所有权约束分散在不同函数、不同结构体之间时,模型依然很难在生成阶段把约束“串起来”。只要某个关键的引用关系跨越了它实际建模的那一小段输入,卡壳就不可避免。
3.2 表达力越强,“可接受的正确答案”反而越少
Rust 的类型系统表达力强,对开发者是好事,对概率模型却是个坏消息。举个简单的例子,让 AI 写一个泛型函数,要求输入一个容器,返回其中某个元素的引用。需要考虑的东西包括:容器是什么类型、元素借用规则、生命周期参数怎么标注、是否需要Clone约束、返回类型是迭代器还是具体引用。每多一个维度,正确答案在整个 token 空间里的比例就缩小一截。
更直白地说,假设模型在四个独立维度上的成功率分别是 90%、90%、90%、90%,四个维度全部满足的概率就是 0.9 的四次方,只有 65% 左右;如果维度增加到六个,掉到 53%。这还是理想情况,实际每个维度都不是独立事件,一个地方的错误选择往往会连坐另一个地方。Rust 的 trait、泛型、生命周期、宏、异步上下文一叠加,模型“一次写对”的概率被快速摊薄。它不像 Python 那样“大多数写法都能跑,只是风格不同”,Rust 是“只有极少数组合能通过类型检查”。
这里需要说明,我不是在说 AI 在 Rust 上一无是处。对于简单函数、重复模板、结构体定义,它能给得很准。问题恰恰出在“简单正确”和“复杂正确”之间的断层:模型在简单任务上表现好,会让开发者产生“它应该能处理更复杂场景”的错觉,结果一上泛型和异步,就开始反复翻车。
3.3 unsafe 和 FFI 边界:另一个容易被低估的雷区
还有一个很隐蔽的坑:面对编译错误时,有些模型会主动引入unsafe块来“绕过”借用规则。它觉得只要把代码包进unsafe,编译器就不会再追究引用合法性。这恰恰是最危险的误解——unsafe只是让开发者承担起“安全由人保证”的责任,借用检查器依然会检查unsafe外部的代码,而且unsafe内部一旦违反约定,后果是未定义行为,比编译失败严重得多。
我在模拟项目X里还遇到过 FFI 相关的 AI 生成代码:模型把 C 指针直接映射成 Rust 引用,或者漏了指针类型转换,导致类型错误和运行时故障同时出现。这类边界代码需要调用双方的内存布局、对齐方式、生命周期约定,模型几乎没有任何可靠线索。我的建议是:在 prompt 里明确写“禁止生成 unsafe 代码”“保持所有代码在 safe Rust 范围内”,至少能把最危险的那部分错误挡在门外。
4. 修错闭环断在哪里:AI 拿到编译错误后为什么总是原地打转
4.1 “假装修正”比“没有修正”更消耗时间
AI 生成代码的第一轮失败,其实很多人还能接受,因为人类写 Rust 也经常第一遍过不了编译。真正让人崩溃的是接下来的修复过程。模型拿到编译器长长的诊断信息后,经常输出一段看起来很合理的“修复方案”,实际跑一遍又是新错误。更常见的是,它会在同一个函数里反复改同一个点,每次换一个等价写法,但错误本质没变。
我用模拟项目X遇到过特别典型的一个案例:某函数返回Vec<String>的某元素引用,编译器报 E0597 说借用值活不够长。模型第一次尝试把返回类型改成String,第二次在函数里加'static生命周期标注,第三次把参数改成Arc,第四次干脆把整个函数改成async。前三次改动方向都不对,最后一次直接把复杂度拉满。如果由人来改,看到错误信息里的“borrowed value does not live long enough”就能意识到是“数据存活范围”问题,但模型只会把错误信息当成一连串难以定位的文本,尝试在表层语义上做修补。
这背后有个很现实的数据问题:模型要真正学会“修理 Rust 编译器错误”,需要海量的“错误诊断 + 正确修复动作”配对数据。主流训练语料里,这类深度排错数据远比普通代码示例稀有。结果就是模型并没有“理解”错误,它只是在模仿“处理错误时的文本风格”。
4.2 动态语言的错误反馈为什么“更友好”
对比一下 Python 和 JavaScript,会更容易看清这不是模型能力的问题,而是反馈信号的性质问题。动态语言里最常见的错误是NameError、TypeError、KeyError这类,消息短、语义接近自然语言,而且往往直接指向某一行。模型只需找到那个名字、那个键,改成更安全的写法,下一轮就能跑。更关键的是,动态语言允许“先运行、后修正”,开发者可以马上把运行输出贴回模型,等于给它一个非常精准的校正信号。
Rust 的编译诊断信息则长得多,包含代码位置、类型树、借用区域的说明,甚至会带上'a、'b这样的元变量。这些信息对人类友好,但对模型来说就是“结构化的天书”。模型很难从诊断信息里反推出“应该给哪个参数添加生命周期约束”,因为诊断并不会直接把答案写出来,它只是描述约束冲突。再加上 Rust 编译过程比脚本解释慢,来回试错一轮要负担时间和流式输出的双重成本,人的耐心也会被消磨掉。这也是为什么同样一个 AI,写动态语言时像“老手”,切到 Rust 时立刻退化。
5. 我目前最顺手的 AI+Rust 工作流,以及几条实用对策
5.1 让 AI 当“骨架生成器”,而不是“最终代码来源”
我最核心的转变是:不再要求 AI 一次生成完整函数,而是分步骤让它先写骨架。这里当然有现实原因——骨架粒度小、错误容易定位;更关键的是,骨架生成允许人类把“所有权和生命周期”这类全局约束留在自己手里,模型只负责填充不涉及借用冲突的部分。
我的操作顺序大致是这样:先让 AI 根据需求列出模块划分、核心结构体、trait定义;再让模型逐个生成函数签名,先不写函数体,编译一遍确认签名没问题;最后才填充实现。填充时我会在 prompt 里给出局部约束,比如“这个函数接收&self,需要从cache字段读取并返回克隆后的String,不要修改self的字段”。把约束写清楚,AI 翻车的概率立刻下降。这个过程看起来比“一句需求直接生成完整文件”多花了一点时间,但省掉的是后面十几轮编译排错的巨大时间成本。
5.2 把编译器错误信息“喂回”模型,并限制改动范围
第一轮生成失败后,不要跟模型说“请把这段代码修好”,这会触发它的“大改特改”模式。我会把完整函数和编译器输出的前三个错误原样贴回去,然后加一句:“只针对第一个错误做最小修改,不要动其他代码,不要加新依赖,不要引入 unsafe,修改完给出 diff 描述。”
为什么要有这么多限制?因为模型一旦进入修复模式,默认行为是重写整个上下文,连带破坏原本正确的地方。限制改动范围本质上是把“搜索空间”缩小,让模型的 token 预测更容易落在正确答案附近。我试过用这个方法处理 E0502 和 E0597,成功率比整段重写高得多,而且改动可 review 性强。如果你用的是带上下文窗口的代码工具,还可以直接把报错行之后 20 行内的高亮代码作为修改对象,而不是贴整个文件。
5.3 给模型“类型上下文”,而不是只给一句需求
很多人在用 AI 写 Rust 时会陷入一个误区:只给它一句高层的自然语言需求,指望它自己把类型和 trait 推导出来。Rust 的复杂之处恰恰在于“类型上下文”是编译是否通过的决定性因素。我现在的习惯是把相关struct定义、trait定义、关键函数的签名、甚至某个借用关系的文字描述全部塞进 prompt,让模型不需要猜。
比如要用模型生成一个按 key 更新缓存的函数,我至少会提供缓存字段的类型、是否允许多线程访问、期望的锁粒度、返回的错误类型。这个成本看起来高,但比起编译来回折腾要便宜得多。说到底,AI 是“上下文放大器”,你给的约束越精确,它的输出越可靠。尤其在 Rust 这种强类型语言里,“prompt 里少写一行类型,编译就多报三个错”几乎是必然规律。
另外一个使用技巧是把模型当 reviewer:写完代码后让模型站在审查者角度找借用问题,而不是生成新代码。审查模式不需要它一次写出一大段,反而更接近“指点人不伺候代码”,效果常常比直接生成好得多。
如果你也打算在项目里大规模引入 AI 写 Rust,我个人的体会是:把它当“外挂”,别把它当“替身”。模型能帮你快速搭出结构、写完模板代码、解释陌生概念,但所有权、生命周期、unsafe 边界这些问题,最终还是要靠人来把关。“卡壳”不是模型傲慢,也不是 Rust 不配,而是这个语言恰好站到了概率生成模型的弱项上。随着更多“诊断-修复”数据被回灌到训练集,情况一定会改善;但在那之前,在 prompt 里写清楚约束、把编译错误拆小、让人参与每一轮修复,才是真正能把 AI+Rust 用起来的方式。