Шесть месяцев с AI-ассистентами: сухие цифры
47% сгенерированного Rust-кода не компилировалось с первого раза. Это не шутка. Полгода я гонял Claude 4.5, GPT-5.2 и Cursor (с бэкендом GPT-5.2) на реальных задачах: от парсеров до асинхронных драйверов. Радовался, когда код проходил cargo check. Но чаще — матерился на borrow checker. В итоге собрал топ-5 граблей, на которые наступает каждая нейросеть. И да, если вы думаете, что LLM понимают Rust — вы ошибаетесь. Они просто угадывают паттерны. А Rust — язык, где угадать без понимания нельзя.
1. Borrow checker: первая жертва
LLM упорно генерируют два mutable borrow в одной области видимости. Вот типичный диалог: я прошу функцию, которая перекладывает данные между структурами. Claude 4.5 выдаёт:
fn transfer(a: &mut Vec<i32>, b: &mut Vec<i32>) { for item in a.iter() { b.push(*item); } a.clear();}Компилятор: cannot borrow `a` as mutable because it is also borrowed as immutable. LLM забывает, что a.iter() уже заимствует a. Решение — клонировать или использовать draining iter. Почему нейросеть так делает? Потому что в её тренировочных данных часто встречаются языки без такого жёсткого контроля. Трансформер просто копирует шаблон «итерация + мутация», не осознавая конфликт. Забавно, но GPT-5.2 справляется с этим в 70% случаев, а Claude 4.5 — только в 55% (моя статистика по 200 запросам).
Правильный код
fn transfer(a: &mut Vec<i32>, b: &mut Vec<i32>) { let items: Vec<i32> = a.drain(..).collect(); b.extend(items);}Совет: если LLM упорно выдаёт конфликт заимствований — попросите её объяснить времена жизни каждого заимствования текстом. Это часто переключает режим «угадай» на «анализируй».
2. Времена жизни: вымершие динозавры в голове LLM
Функция берёт строку, находит подстроку, возвращает итератор по байтам — классика. Cursor (на GPT-5.2) выдал:
fn find_substring_indices(haystack: &str, needle: &str) -> impl Iterator<Item = usize> { haystack .as_bytes() .windows(needle.len()) .enumerate() .filter_map(move |(i, window)| { if window == needle.as_bytes() { Some(i) } else { None } })}Смотрите: impl Iterator не содержит явных аннотаций lifetimes. Но замыкание захватывает needle по ссылке. LLM забыла, что возвращаемый итератор должен жить не дольше haystack. Компилятор ругается: lifetime may not live long enough. А если бы написали fn find_substring_indices<'a>(haystack: &'a str, needle: &'a str) -> impl Iterator<Item = usize> + 'a — всё бы взлетело. Но нейросеть ленится добавлять lt.
Почему? Потому что в большинстве языков (Java, Python, JS) ссылки живут вечно для сборщика мусора. LLM экстраполирует этот паттерн на Rust. Хуже всего с lifetimes справляется Cursor — 68% ошибок. Claude 4.5 — 44%.
3. Unsafe: чёрная дыра для нейросетей
Когда я попросил GPT-5.2 написать быстрый хеш-таблицу на сырых указателях, первая итерация содержала:
let ptr = Box::into_raw(Box::new([0u8; 256]));unsafe { *ptr = 42; // запись в нулевой элемент? Нет — в начало массива}drop(unsafe { Box::from_raw(ptr) });Всё ок, пока не требуется логика освобождения нескольких блоков. LLM легко генерирует double free или use-after-free. Например:
unsafe { let buf = malloc(64); // ... free(buf); // ... free(buf); // второй free!}Нейросеть просто повторяет паттерн «освободить в конце», не отслеживая состояние. Я насчитал 18% всех unsafe-блоков от LLM содержали UB. Половина из них — double free. Код-ревью с LLM здесь бесполезно — нейросеть может не заметить свою же ошибку.
4. Pin и самореферентные структуры: тёмная материя
LLM понятия не имеют, что такое самореферентные структуры, если их не натаскать на async. Вот классика: структура хранит ссылку на поле внутри себя. Cursor предложил такой код для арены:
struct Arena<'a> { data: Vec<i32>, ptr: Option<&'a i32>,}Пока Arena живёт на стеке — всё нормально. Но стоит её переместить — ссылка ptr повисает. LLM не додумывается использовать Pin. И если вы генерируете самодельный Future — ждите сюрпризов. Я потерял день, отлаживая генератор асинхронного стрима, который нейросеть написала без Pin.
Единственный способ — в явном виде просить: «используй Pin<Box<Self>> для self-референтных данных». Но даже после этого Claude 4.5 иногда забывает про unsafe внутри Pin::new_unchecked.
5. Обработка ошибок: путаница между Option и Result
Тривиально: функция парсинга возвращает Result<T, E>. LLM пишет:
let val = something().ok_or("error")?;Здесь "error" — &str, а нужно Box<dyn Error> или кастомный тип. LLM не видит разницы между ok_or и ok_or_else. Или путает map и and_then. Это мелочи, но они отнимают время на компиляции. Из моего журнала: 12% ошибок — неправильное использование комбинаторов Result/Option.
Совет: используйте правильные промпты — просите подсказывать тип ошибки явно, например: «возвращай anyhow::Result».
Сравнение: кто меньше врёт?
Я вёл учёт по трём моделям для 50 задач каждого типа. Результаты (процент ошибок, из-за которых код не компилировался):
| Тип ошибки | Claude 4.5 | GPT-5.2 | Cursor (GPT-5.2) |
|---|---|---|---|
| Borrow checker | 45% | 30% | 52% |
| Lifetimes | 44% | 38% | 68% |
| Unsafe/UB | 20% | 35% | 42% |
| Pin/self-ref | 55% | 60% | 70% |
| Ошибки типов (конечные) | 12% | 10% | 18% |
Вывод: GPT-5.2 чище с lifetimes и borrow checker, но опаснее в unsafe. Claude 4.5 лучше понимает низкоуровневые детали, но проигрывает в системе типов. Cursor — удобный автокомплит, но доверять ему генерацию целых функций не стоит: он теряет контекст через 10 строк.
Важный нюанс: я использовал модельные API без дополнительных флагов strict. Про то, почему параметр strict в VLLM/llama.cpp ничего не даёт, у меня есть отдельный материал.
Как заставить LLM писать рабочий Rust: три принципа
После сотен итераций выработал рабочие приёмы:
- Конкретные сигнатуры. Не проси «функцию для обработки строк» — дай полный прототип с типами и lifetimes. LLM меньше додумывает.
- Инкрементальная генерация. Проси сначала написать тип данных, потом — функцию, потом — тест. Каждый шаг проверяй. Это снижает ошибки на 40%.
- Запрет unsafe без явного разрешения. Добавь в промпт: «НИКАКОГО unsafe, если я не попрошу». LLM лезет в unsafe даже когда не нужно.
И ещё: не используй LLM для рефакторинга кода с lifetimes. Они сломают времена жизни. Лучше напиши сам, а нейросеть попроси добавить комментарии или документацию. Это её сильная сторона.
То, о чём молчат white papers: Rust как лакмусовая бумажка для LLM
Если модель не может сгенерировать корректный Rust — значит она не понимает логику программы. Она просто умеет красиво переставлять токены. Rust — это тест на интеллект для LLM. Python — детский лепет. C — мутная вода. А Rust с его borrow checker и lifetimes — идеальный экзамен.
Лично я перестал ожидать от нейросетей идеального Rust. Я использую их как черновик, а затем переписываю unsafe-блоки вручную. И мой главный совет: не доверяй LLM там, где компилятор молчит, а UB подкрадывается незаметно. Проверяйте с Miri, санитайзерами и code review. А если хотите понять, когда вообще стоит делегировать код нейросети, а когда — писать самому, прочтите мой чек-лист Delegation Filter.