Портирование MCP-сервера с Go на Rust с использованием LLM

опубликовано: ⏰ 24

Некоторое время назад была статья об агентной разработке для проекта. Это продолжение, но немного в другом направлении, более прикладном. В чём заключалась задача — MCP-сервис/KAG/GraphRAG на Go. После завершения «неожиданно» оказалось, что проект содержит фатальный недостаток в виде G. Поэтому было принято волевое решение о миграции на Rust. Данная авантюра потребовала смену техстека, библиотек, алгоритмов, реализации аналога LSM для векторной базы и так далее. То есть это было не механическое портирование, а перенос с заметными доработками.

Дополнительной целью было использование только локальных моделей, доступных на текущий момент, без оплаты подписок. То есть аналог работы в закрытом контуре без специализированных прокси для фильтрации PII и тому подобного. Тут было некоторое допущение: для оркестратора некоторое время использовались DeepSeek V4 Flash (Free) и Ox Alpha (Free), пока они были доступны бесплатно пару недель. Обе, при желании, можно развернуть локально, если есть подходящее железо.

Введение

MCP-сервер (RAG + knowledge-graph) на Go создавался больше как эксперимент работы через LLM, который оброс функционалом. Дополнительным плюсом Go была быстрая разработка, кросс-компиляция из коробки, хорошее понимание этого языка LLM и компактный код (синтаксис языка простой). В процессе работы всплыли некоторые особенности, которые впоследствии привели к идее переписать всё на Rust, снова используя LLM. Заодно и проверить, возможно это или нет.

Примеры проблем:

  • Медленная скорость vec0 (brute-force) при больших массивах векторов на ноутбуке.
  • CGO, который иногда создаёт проблемы при кросс-компиляции.
  • Много сторонних библиотек, которые либо заброшены и не поддерживаются, либо имеют неполный функционал, что требует собирать некоторые подсистемы из разных компонентов. Rust оказался удобнее для системного программирования. Например, в Rust есть ANN-движки вроде usearch со всем нужным функционалом (HNSW, хранение на диске).
  • GC. Это больше теоретическое ограничение, так как для ноутбука на небольших данных это не очень критично. Отказ от него, так сказать, — задел на будущее для получения максимума производительности на ограниченных ресурсах.

На практике перенос MCP-сервера с CLI для управления — это перенос наблюдаемого поведения: 12 MCP-тулов, CLI-команд, нескольких подсистем для индексации файлов и гибридного поиска. Кода в старой версии много, а спецификации поведения — нет, так как после завершения работы планы были удалены. В статье описан процесс такой миграции целиком.

Общие правила портирования

Если лень читать дальше, то кратко:

  • Переносим поведение, а не код. Портированная версия — это копия по функциональности, но не по архитектуре.
  • Модуль parity harness (Rust-крейт crates/parity-harness) собирается до первого кода на Rust и используется в качестве инструмента проверки паритета портированного функционала. Модуль временный и удаляется после завершения портирования.
  • План будет через openspec, короткими проверяемыми задачами (~500 строк диффа), от ядра к периферии.
  • Стек замораживается до начала переноса (frozen stack в openspec/config.yaml), особенно если в петле LLM-агенты.
  • Агентов проверяем всегда, так как отклонения случаются даже при явных запретах в промпте.
  • Изменения вне принятого процесса запрещены, включая ручные хотфиксы. Очень нежелательно делать изменения вне принятого процесса. В этом случае высока вероятность расхождения с планом и появления неожиданных для LLM изменений.
  • Не стоит оставлять техдолг даже в виде небольших задач. Лучше потратить лишнее время на доработку. Иначе они будут «отравлять» последующую работу, добавляя неверные предпосылки для принятия решений LLM.

Функциональный паритет

Начнём с принципа, который определяет всё остальное — портированная версия не является копией по коду. Это копия по функциональности, спроектированная под новый стек. Паритет измеряется снаружи: что на входе (параметры MCP-вызовов, конфиг, файлы датасета) и что на выходе (JSON-ответы тулов, вывод CLI, коды выхода, тайминги в ожидаемых процентилях).

Почему это важно? У Go и Rust разные идиомы, и «перевод строка в строку» даёт худшее из двух миров — Go-архитектуру на Rust-синтаксисе. Типичные артефакты буквального переноса (примеры общего случая):

  • интерфейсы, введённые в Go ради моков, превращаются в dyn Trait там, где хватило бы генериков или enum’а;
  • ошибки-строки fmt.Errorf("...") вместо типизированных thiserror-ошибок.

Например, Go:

Go
type Converter struct {
    out io.Writer
}

func (c *Converter) Emit(s string) {
    if c.out == nil {          // nil — легальное состояние
        c.out = os.Stdout
    }
    fmt.Fprintln(c.out, s) // ошибка записи проглатывается
}

Идиоматичный Rust:

Rust
pub struct Converter<W: Write> {
    out: W,                     // nil-состояния не существует
}

impl<W: Write> Converter<W> {
    pub fn emit(&mut self, s: &str) -> io::Result<()> {
        writeln!(self.out, "{s}") // ошибка возвращается вызывающему
    }
}

Сигнатуры и семантика другие, поведение («напечатать строку с переводом строки») то же.

Примеры выше — только для иллюстрации, а не реальный код проекта. В реальном портировании переносятся:

  • доменные алгоритмы (индексация, поиск, ранжирование, NER);
  • форматы входных/выходных файлов (конфиги YAML, векторы-бинарники);
  • тестовые данные и фикстуры;
  • тексты сообщений об ошибках (частично, с адаптацией).

Тесты переносим по смыслу, адаптируя к особенностям языка. Отсюда правило для LLM-агентов, которое стоит прописать в AGENTS.md:

Не копируй структуру исходного проекта, спроектируй модуль заново и сверь поведение по паритету.

Без явного запрета агент по умолчанию будет транслитерировать Go — это самый короткий путь к «правильному ответу» с его точки зрения.

Процесс миграции

В целом последовательность шагов следующая:

Ключевая особенность — эталон заморожен на весь срок миграции. Меняется только новая версия.

Модуль проверки паритета

Модуль проверки паритета (parity harness) — это автоматизированный механизм сравнения двух бинарников как чёрных ящиков. Если его нет, миграция превращается в «перепишем, запустим руками, вроде работает» — и расхождения находят пользователи или не находят. Очевидно, что чем сложнее проект, тем сложнее проверять паритет вручную.

Реализация в проекте: сделать Rust-крейт crates/parity-harness (различные инструменты для сверки, обёртка rmcp MCP-клиента и прочее). Крейт временный и будет удалён после завершения, от него никто не зависит, и ничего из его кода не используется в остальном приложении.

Фикстуры записаны ОДИН раз с портируемого проекта. Это конфиги, дамп базы с данными, вектора (для проверки сходимости семантического поиска), примеры JSON-ответов для MCP и так далее.

Данные в моём случае НЕ мигрировались: Rust строит БД с нуля. Если нужна миграция, то нужно ещё делать скрипты переноса данных.

Самое главное — это постоянная сходимость с исходным проектом. Пока этот модуль выполняется с ошибками, он бесполезен. Вы не отличите регрессию от шума. Минимальный набор мер:

  • TZ=UTC, LC_ALL=C, NO_COLOR=1, TERM=dumb — если важно проверять вывод консоли;
  • изолированные HOME и TMPDIR на каждый прогон (mktemp -d);
  • фиксированные seed’ы для LLM;
  • время — через флаг приложения или нормализацию вывода;
  • сеть — фейк или полное отключение;
  • недетерминированные фрагменты вывода (оценки при ранжировании результатов поиска, внутренний порядок, временные метки) корректировать через нормализацию.

Схема работы модуля сверки:

План миграции

Формализованные спецификации (openspec) здесь работают лучше, чем набор планов и списков задач в неструктурированном или слабо структурированном виде, по одной причине: спека — это машиночитаемый источник требований, общий для людей и агентов. Для LLM это критично, так как код и описание задачи не должны расходиться ни на шаг.

Свойства «правильной» задачи:

  • Короткая — объём изменений до ~500 строк, закрывается за один заход агента.
  • Проверяемая — критерии приёмки (Definition of Done) ссылаются на конкретные правила обеспечения паритета и перенесённые тесты.
  • С явными границами функционала — список файлов и функционал, которые можно менять.
  • С явными запретами — замороженный стек, доступ к соседним модулям на изменение и прочее.
  • С зависимостями — от каких задач зависит, какие блокирует.

Пример (адаптировано под реальный openspec):

Markdown
# change: add-usearch-ann-engine

## Задача 1.2 — UsearchEngine за трейтом VectorIndex (bf16, L2sq, disk-backed)

- Scope: `crates/vectors/src/usearch_engine.rs`, `crates/vectors/Cargo.toml` (фича `engine-usearch`)
- Dependencies: 1.1 (спайк: cxx FFI собирается на 5 CI-таргетах)
- Запрещено: `crates/parity-harness/`, чужие крейты

## Критерии приёмки (машинные)

- [ ] `cargo test -p vectors --features engine-usearch` зелёный: create → add 100 векторов dim=1024 → search top-10 → save → restore → те же ключи
- [ ] harness: recall@10 ≥ 0.95; p50 (search) ≤ 2× и p95 (search) ≤ 5× Go-baseline
- [ ] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` чисты
- [ ] Oracle refs: N/A (у Go-оригинала ANN = vec0, эквивалента usearch нет; шаблон тестов — `crates/vectors/src/engine.rs`, LanceEngine)

Почему короткие задачи вылавливают баги старой версии? Перенося подсистему малым срезом, вы обязаны точно понять её контракт. Все «неудобные для портирования» места (странная обработка ошибок, неявные дефолты, порядок инициализации) чаще всего и есть баги или недокументированное поведение. В большом переносе они тонут в общей массе изменений, в маленьком — всплывают и требуют решения прямо сейчас.

Отклонения фиксируются в openspec-change (proposal/design, раздел «Deviations»), ADR и (потом удалённом) port-verification-report.md. На E2E-этапе каждое необъяснённое изменение будет сверяться с этими записями.

Критерии приёмки в основном генерируются LLM для LLM на основе постановки требований от человека; читать их самому сложно. Если всё-таки захочется скорректировать вручную, то нужно учитывать весь контекст изменения, чтобы не было конфликтов. Поэтому лучше просить это сделать LLM.

Ориентировочное распределение усилий:

ЭтапДоля
Parity harness~5%
Этап 1: инфраструктура~10%
Этап 2: перенос подсистем45–55%
Этап 3: E2E + покрытие~15%
Этап 4: документация5–10%
Этап 5: чистка~5%

Этап 1 — инфраструктура, ADR и R&D

Цель этапа: до переноса логики зафиксировать техстек и инфраструктуру. Всё, что влияет на выбор зависимостей, решается здесь — дальше стек замораживается.

Реальные ADR проекта:

  • ADR 0001: SQLite + FTS5 (rusqlite bundled, без CGO)
  • ADR 0002: ONNX Runtime (ort, load-dynamic, внешний .so)
  • ADR 0003: ANN Engine (usearch vs lance)
  • ADR 0004: usearch LSM-сегменты
  • ADR 0005: Event task queue: one typed event queue for all background work

Три R&D (spike):

  • SQLite+FTS5 (rusqlite)
  • ONNX (ort — преемник неактивного onnxruntime-rs)
  • ANN (usearch vs lance)

Результаты сохраняются в виде ADR. MCP-транспорт (rmcp) отдельного анализа не потребовал.

Замороженный стек сохраняется в openspec/config.yaml, в котором явно перечисляются все зависимости и версии. Например:

  • Rust + tokio (async) + axum (HTTP для /health);
  • rusqlite 0.40 (bundled FTS5, без CGO);
  • ort (ONNX, внешний .so, load-dynamic);
  • usearch 2.x (ANN, C++11 HNSW через cxx, disk-backed, scalar-quantized);
  • rmcp 3.x (MCP);
  • cel 0.14; tokenizers;
  • clap 4.6.6 — Builder API;
  • tracing (только в cli);
  • noyalib (YAML, преемник serde_yaml);
  • thiserror (ошибки в либах) + anyhow (только на CLI-границе).

Можно дополнительно сделать карту аналогов, которая может пригодиться в дальнейшем при портировании и сравнении доступного функционала в рамках реализации задач.

ЗадачаGoRust
ANNsqlite-vec (vec0, CGO)usearch 2.x
ONNXonnxruntime_go (CGO)ort (внешний .so, load-dynamic)
SQLitemattn/go-sqlite3 (CGO)rusqlite (bundled, без CGO)
MCPmcp-go v0.57.0rmcp 3.x
YAMLgopkg.in/yaml.v3noyalib
Логиzaptracing (cli)
JSONencoding/jsonserde/serde_json
Ошибкиfmt.Errorfthiserror (либы) + anyhow (CLI)
CELcel-gocel 0.14
Tokenizersugarme/tokenizertokenizers
КроссGOOS/GOARCHcargo-zigbuild

CI/CD появляется на этом этапе, до первого доменного кода: fmt, clippy -D warnings, тесты. Он важен для проверки работоспособности проекта на каждом шаге. Пока CI сломан, дальше не двигаемся.

Фрагмент минимального набора задач:

YAML
# .github/workflows/ci.yml
checks:
  - run: cargo fmt --check
  - run: cargo clippy --all-targets -- -D warnings
  - run: cargo test

Этап 2 — подсистемы

На этом этапе происходит движение от базовых частей ядра, от которых зависят другие модули, к периферийным подсистемам (интерфейсам). В случае этого проекта разделение было таким:

  • Базовый слой: config, db, vectors, utils, llm
  • Средний слой: embedding, ingestion, graph
  • Интерфейсы: search → mcp → cli

Порядок определяется графом зависимостей. Простой способ его получить — например, развернуть граф импортов пакетов (для Go это goda):

  • ядро — это узлы, на которые больше всего зависимостей;
  • интерфейсы — это узлы, на которые меньше всего зависимостей;

Перенос идёт в обратной топологической последовательности — сначала узлы, от которых зависят остальные. Почему не наоборот? Если начать с CLI и вывода, придётся писать заглушки на всё доменное поведение — а каждая заглушка, которая мимикрирует под рабочий функционал, становится ложной предпосылкой для следующих задач (об этом отдельно в разделе про LLM). При переносе от ядра каждая подсистема сразу получает настоящие зависимости и настоящие тесты.

Заглушки всё равно появляются — для сквозных вещей (логирование, конфигурация). Правила:

  • Заглушка обязана падать с ошибкой: todo!("S-042"), а не возвращать правдоподобное значение.
  • Каждая регистрируется и имплементируется в дальнейшем.
  • Заглушка на доменную логику — сигнал, что порядок обхода нарушен.

Очень желательна сквозная проверка после каждой задачи — прогон связанного функционала в модуле обеспечения паритета. Кейсы могут падать «по ожидаемым причинам» (зависимости ещё не перенесены) — это нормально. Новые ошибки — нет.

В теории, независимые ветви DAG можно отдавать разным агентам. Условие — ветви не пересекаются по файлам, и слияние каждой — через общий CI.

И последнее и важное — баги или отклонения от оригинала не делаются скрытно. Есть два пути: скопировать поведение как «legacy bug»/«deviation», если внешние скрипты могли на него завязаться, или исправить и записать в change/ADR. Исправление без какой-либо записи в истории — это необъяснённое или утерянное изменение при проверке на E2E-этапе. Например, было изменение механизма линковки сущностей графа уже в процессе реализации. Задача была выполнена, но было упущено требование встроить новый механизм в существующий функционал, так как старый был полностью удалён вместе с кодом (эта зависимость больше ниоткуда не выводилась). В итоге он существовал, тестировался в юнит-тестах и просто нигде не использовался.

Этап 3 — E2E и проверка через сверку

Полный прогон всех кейсов. Реальные метрики паритета:

МетрикаДопускКомментарий
recall@10≥ 0.95ANN против brute-force оригинала (та же embedding-модель)
p50/p95 латентностьp50 ≤ 2×, p95 ≤ 5×инструмент search против Go (p50≈107 мс, p95≈109 мс)
Примеры JSON-ответовнормализованноечисло результатов, top-результат, счётчики, нормализация ранжирования ответов/порядок/временные метки
CLI –helpструктурноефункциональный эквивалент, не побайтовый

Каждое отклонение получает одну из четырёх причин: баг новой версии / осознанное отклонение (зафиксировано в change/ADR) / баг старой версии / шум нормализатора. Выход из этапа — ноль необъяснённых отклонений.

После этого — нефункциональные проверки: холодный старт, RSS, размер бинарника. Пороговые значения, в которые необходимо попасть, стоит задать ещё на этапе 1: если аргументом миграции был размер, финальная цифра должна это подтверждать.

Этап 4 — документация

На этом этапе пишется финальная документация по проекту (в том числе --help для CLI). Почему сейчас? Потому что на ранних этапах могут быть и будут изменения функционала, которые обязательно ещё придётся отражать в документации, что значительно увеличивает время на портирование. Это буквально бессмысленная работа.

Учитывая, что проект может иметь подробную документацию (сайт, пользовательская, разработчика), это может выполняться в несколько этапов.

Этап 5 — чистка и финализация

Это финальный этап, но с оговорками (об этом ниже).

Проверяются критерии готовности. В случае этого проекта:

  • Проверяется паритет с оригиналом: recall@10 ≥ 0.95, p50 ≤ 2×, p95 ≤ 5× Go (search), JSON чистый (с нормализацией).
  • Ноль заглушек, TODO и «временных» решений.
  • Спеки синхронизированы с реализацией.
  • Все сверки успешно проходят.
  • clippy, fmt, тесты чистые.
  • Документация перенесена.
  • --help — структурный паритет (Rust-идиоматичные тексты).
  • Все промежуточные планы и задачи выполнены и заархивированы (openspec).

Выполняется очистка проекта:

  • Поиск по маркерам заглушек и временных решений — должно быть ноль результатов.
  • Удаление ссылок на старый проект: имена, пути, legacy-префиксы env-переменных (что оставляем сознательно — зафиксировано в change/ADR).
  • Удаление модуля сверки.
  • Удаление всех упоминаний прошлого проекта и причин миграции. Эта информация была полезна LLM для понимания частей кода и того, с чем сравнивать; теперь она не требуется и будет только мешать.
  • Финальный полный прогон на чистых окружениях, включая сборку с нуля.
  • Тег и релиз.

Временный код живёт ровно до этого этапа. Не дольше.

А теперь об оговорках. На этом этапе обязательно выяснятся ошибки или пробелы в реализации функционала. Например, на финальном прогоне. Хорошо, если это будут вещи вроде того, что писал выше, — про забытое добавление линковки сущностей. Некоторые могут потребовать сложного рефакторинга. Решение: удлинять и усложнять процесс верификации на промежуточных этапах. Я проверял мельком, поэтому упустил как минимум две проблемы: нерабочая линковка (решалась простым подключением), неполный функционал для очередей задач (решался через рефакторинг).

Размер кода: было → стало (scc)

Добавлю для сравнения объёмы проектов по scc. В миграцию вошёл не только сервер, но и сайт (перенесён, контент переписан под Rust).

Язык (scc, code)Go — былоRust — сталоЧто
Go (сервер)49 654—оригинал
Rust (сервер)—66 017перенос
TypeScript (сайт)1 5661 556движок сайта и компоненты
MDX (сайт)3 7174 156контент, +8 страниц
CSS (сайт)2 1352 135тема, без изменений
Markdown (docs)7 98918 461ADR, openspec, COVERAGE
Прочее (JSON/YAML/SQL/XML/TOML/…)2 1232 524конфиги, миграции
Всего (code)67 18494 849+41%
Сложность (scc)¹12 5133 080см. оговорку

¹ Complexity в scc — эвристика, зависящая от языка (разный набор эвристик для Go и Rust), поэтому отношение Go ↔ Rust по этой строке условно.

  • Сервер переписан Go → Rust: 49 654 → 66 017 (+33%) — перепроектирование, явные типы и обработка ошибок, doc-комментарии.
  • Сайт перенесён и переписан: движок сайта и компоненты не менялись (TS/CSS ~равны), контент MDX +12% (в новых страницах под Rust).
  • Внутренняя проектная документация (Markdown) ×2.3: артефакты процесса (openspec, ADR, COVERAGE).
  • Всего по проекту: 67 184 → 94 849 (+41%).

Таймлайн реального проекта: 3 недели, 600 коммитов, 52 заархивированных изменений в openspec.

Сырой вывод scc

Go (synopsis)

Text
───────────────────────────────────────────────────────────────────────────────
Language            Files       Lines    Blanks  Comments       Code Complexity
───────────────────────────────────────────────────────────────────────────────
Go                    225      61,086     8,201     3,231     49,654     12,415
TypeScript             56       1,801       164        71      1,566         64
Markdown               42      10,239     2,250         0      7,989          0
MDX                    40       4,862     1,145         0      3,717          0
JSON                   20         663         0         0        663          0
CSS                    19       2,807       399       273      2,135          0
YAML                    7         575        53       110        412          0
SQL                     5         163        21        17        125          3
XML                     5         511        51        80        380          0
Go Template             4         120        19         0        101         14
SVG                     3          34         5         0         29          2
Shell                   2         168        25        30        113         12
Dockerfile              1          43        13        12         18          0
License                 1         202        33         0        169          0
Makefile                1         117        24        16         77          3
Plain Text              1          36         0         0         36          0
───────────────────────────────────────────────────────────────────────────────
Total                 432      83,427    12,403     3,840     67,184     12,513
───────────────────────────────────────────────────────────────────────────────

Rust (synopsis-rs)

Text
───────────────────────────────────────────────────────────────────────────────
Language            Files       Lines    Blanks  Comments       Code Complexity
───────────────────────────────────────────────────────────────────────────────
Markdown              243      23,226     4,765         0     18,461          0
Rust                  172      90,535     7,569    16,949     66,017      3,011
TypeScript             55       1,787       160        71      1,556         66
YAML                   49       1,294       106       271        917          0
MDX                    46       5,568     1,412         0      4,156          0
CSS                    19       2,807       399       273      2,135          0
TOML                   13         699        51       351        297          0
Go Template             8         274        26         0        248          0
JSON                    7         217         0         0        217          0
XML                     6         686        59        94       533          0
SQL                     2         268        19       113        136          1
SVG                     2           6         0         0          6          2
C Header                1          10         0         9          1          0
License                 1         202        33         0        169          0
───────────────────────────────────────────────────────────────────────────────
Total                 624     127,579    14,599    18,131     94,849      3,080
───────────────────────────────────────────────────────────────────────────────

Использование LLM

Миграция — почти идеальная задача для LLM-агентов: есть эталон, есть объективный критерий (parity harness), декомпозиция на задачи простая и понятная. Но специфика именно этого класса задач — огромный объём механической работы при высокой цене скрытых ошибок.

Подробно про human-in-the-loop и про выбор специализированных агентов останавливаться не буду — это темы предыдущих статей (раз, два).

ℹ️ Предупреждение

Использование LLM с контекстом меньше 140к практически бесполезно: в большинстве случаев контекст будет попадать на сжатие. Рекомендуется как минимум 262к и выше. Особенно с «говорливыми» моделями вроде qwen3.8

Реальные агенты проекта:

РольАгентМодельЗона ответственности
Оркестраторorchestratorqwen3.8-27b / DeepSeek V4 Flash (Free) / Ox Alpha (Free)Координирует, не трогает код, маршрутизирует в сабагенты, требует явного human approval
Аналитикиtask-evaluator + problem-analystqwen3.6-35b / DeepSeek V4 Flash (Free)Оценка запроса/сложности, глубокий анализ
Разработчик (backend)rust-implementerqwen3.8-27bОдна задача на вызов, запускает гейты/тесты
Техлид/Архитекторrust-reviewerqwen3.6-35b / DeepSeek V4 Flash (Free)4-ступенчатое ревью (task compliance, code quality, security, performance)
Тех.писательdocs-writerqwen3.8-27bДокументация
Разработчик (frontend)site-implementerqwen3.8-27bСайт

Проверять агентов всегда

Главное, что нужно принять: агенты отклоняются от правил даже при явных запретах в промпте. Запрет в промпте — это не гарантия, что он будет выполнен. Типовые отклонения и контрмеры:

ОтклонениеКонтрмера
Добавил крейт «чтобы быстрее решить задачу»Замороженный техстек в config.yaml + подтверждение человеком в оркестраторе
Обновил зависимость до мажорной версииCargo.lock закоммичен, запрет cargo update
Переписал падающий тест вместо фикса кодаРевьюер проверяет тесты первыми
Отрефакторил соседний модуль «для консистентности»Ограничение границ функционала в задаче, лимит размера изменений
«Улучшил» тексты сообщений об ошибкахJSON-diff с нормализацией ловит автоматически
Обошёл блокер хитрым хакомПравило: блокер = возврат задачи, а не быстрый фикс

Контроль выстраивается слоями: CI ловит механические нарушения, ревьюер-агент — семантические/отклонения от постановки задачи, человек — выборочно, но обязательно при планировании.

Отдельно про трассировку. В описании каждого PR агент обязан указывать источник поведения, например, ссылку на контрактную спеку или номер фикстуры. Это превращает ревью из «проверь всё» в «проверь соответствие источнику» — проверяемую и быструю операцию.

Замороженный стек

Описание замороженного стека обязательно и позволяет блокировать изменения на усмотрение LLM. LLM-агенты склонны подключать дополнительные зависимости или использовать старые по своему усмотрению. Это ограничение заставляет работать в явных рамках и искать решения в них. Расширение стека санкционируется явно и человеком. Механика описана на этапе 1 (config.yaml). Здесь важно, что она существует специально для агентов, а не для людей.

Изменения только через openspec

В этот запрет входят и ручные правки. Здесь openspec приведён в качестве примера — это может быть другой фреймворк или свой процесс документирования. Зачем? Агент строит контекст задачи из спеки и кода. Если код не тот, что в спеке, возникает конфликт источников. Типичный сценарий: разработчик пофиксил баг руками мимо процесса. В следующей задаче агент видит код, противоречащий спеке, и либо исправляет его обратно, либо строит новую логику на старых предпосылках. Чинится это дорого, так как расхождение уже размазано по нескольким закрытым задачам.

Правило: единственный путь изменений — запрос на изменение (спека → код → тесты), и для агентов, и для людей. Хотфикс тоже очень желательно делать через спеку, просто короткую.

Избегать техдолга

Механика «отравления» кода прошлым техдолгом такая: небольшой TODO или недоработка в коде становится частью контекста всех последующих задач. Пример: // TODO: временно возвращаем пустой список, пока нет кэша. Через двадцать задач агент проектирует новую фичу, находит этот TODO, принимает пустой список как свершившийся факт и встраивает его в логику. Никто уже не помнит, что поведение было временным, а для модели TODO формально «в силе» и легитимен. Ещё хуже варианты без явного TODO — просто не совсем корректная логика. Это крайне тяжело потом рефакторить и исправлять даже через LLM. Плюс последствия расходятся волной по остальному функционалу.

Отсюда правила:

  • Задача закрывается только без TODO/FIXME;
  • Если блокер реален — задача честно переводится в blocked, а временное поведение описывается отдельной спекой с критериями удаления, а не комментарием в коде.
  • Ревьюер-агент проверяет наличие TODO/FIXME и блокирует коммит.

Лучше потратить лишний час на доработку сейчас, чем неделю на раскапывание неверных предпосылок потом.

Контекст-гигиена

Правила для агентов живут в AGENTS.md в корне репозитория и попадают в каждый контекст. Пример общей структуры AGENTS.md из synopsis-rs:

  • Execution model: fresh agent per task, ~100k token context budget, self-contained task body
  • Frozen stack: config.yaml — no new dependencies without openspec change
  • Hard constraints: no 1:1 Go copy, re-architect for Rust idioms; scope enforcement
  • Layout: config, db, vectors, utils, llm → embedding, ingestion, graph → search → mcp → cli
  • Commands: cargo fmt, cargo clippy --all-targets -- -D warnings, cargo test
  • Gotchas: CGO alternatives (rusqlite bundled, ort load-dynamic), async (tokio+axum for server)
  • OpenSpec workflow: proposal → design → specs → tasks → apply → archive

Спека обновляется вместе с кодом. На каждую задачу — свежий контекст: спека, релевантные файлы, результаты прошлого прогона, а не свалка предыдущих диалогов.

Пайплайн одной задачи:

Специфика Go → Rust

Помимо процесса, есть подводные камни, которые всплывают в каждом таком переносе:

GoRustПодводный камень
error-строки, errors.Is/Asthiserror / anyhowтипизировать ошибки, но сохранить тексты — они часть паритета
nilOption<T>len(nil) == 0 в Go — решить, что означает None в каждом месте
interface{} / anyenum / serde_json::Valueпредпочесть закрытый enum
горутины, каналыпотоки, mpsc, rayonне тащить модель «разделяем всё через мьютексы»
context.Contextявная отмена (token, флаги)отмену проектировать явно
io.Writerimpl Writeдженерики вместо Box<dyn Write> без нужды
panicпаника = багпользовательские ошибки — только Result
map (случайный порядок)BTreeMapлюбой порядок, влияющий на вывод, фиксировать
inti64 / usizeвыбирать осознанно
строки = байтыString (UTF-8) / Vec<u8>бинарные «строки» — не String
path/filepathstd::path, caminoWindows, OsStr

И несколько вещей, которые не помещаются в таблицу:

  • Коды выхода и буферы. std::process::exit не запускает деструкторы и не сбрасывает буферы — перед выходом нужен явный flush. В Go main с os.Exit имеет ту же ловушку, но легко забыть, что поведение должно совпасть.
  • SIGPIPE. Rust по умолчанию игнорирует SIGPIPE: пайп в head даст ошибки записи вместо «тихой смерти» процесса, привычной Unix-пользователям. При необходимости обработчик восстанавливается вручную.
  • Грамматика флагов. flag из stdlib — не GNU-стиль. Если использовались cobra/pflag, грамматика близка к clap, но есть граничные случаи, которые нужно перепроверить.
  • Форматирование чисел. Короткие представления float в Go и Rust совпадают в большинстве случаев, но граничные случаи есть — закрывать кейсами parity harness.
  • init() и глобальное состояние. Не переносить в lazy_static-синглтоны: сделать явную инициализацию и передачу по коду. Это как раз тот случай, когда «копия по коду» ломает архитектуру.

Антипаттерны

Самые значимые вещи, которые делать не стоит:

  • Одна гигантская задача на переписывание просто не сработает. С большой вероятностью получится набор каких-то изменений и запускающийся проект, который ничего полезного не делает.
  • Буквальный перевод кода без перепроектирования под целевой язык и экосистему. По сути, даёт скрытый техдолг и необходимость переписывания проекта.
  • Скрытые фиксы багов или доработки оригинала — необъяснённые изменения при прогоне E2E-сценариев. Придётся восстанавливать согласованность документации по проекту.
  • Одна задача = много разных изменений. Не стоит группировать или дополнять по ходу, необходимо сохранять фокус и чистоту спек.
  • Недетерминированные варианты использования, которые сложно проверить или вообще никак не проверяются.
  • Заглушки, возвращающие правдоподобные данные. Думаю, последствия понятны без пояснений.
  • TODO в закрытых задачах или пропущенные некорректные реализации функционала. Это накапливает технический долг, который всё равно придётся закрывать, но с большим масштабом изменений.
  • Правки мимо openspec, потому что «руками быстрее». Это классический подход из «херак-херак-продакшн». Сейчас будет быстрее, потом — медленный процесс восстановления согласованной картины.

Заключение

Задача портирования в каком-то смысле упрощается наличием примера. Старый проект — это, по сути, исполняемая спецификация, и для каждой спорной ситуации есть способ спросить у эталона: «а как правильно?». Но нужно учитывать, что техстек и структура отличаются: «похоже» не значит «эквивалентно», поэтому арбитром выступает модуль сверки данных, а не ощущения корректности кода.

Вторая, не совсем очевидная, проблема — потеря понимания проекта. Без детального ревью итог формально ваш, а фактически — «незнакомый» проект. Если разработка и дальше будет идти через LLM, это менее критично. Смягчается это детальным ревью каждого среза, ADR по ключевым решениям и участием человека в проектировании границ подсистем.

И последнее. Все правила выше — замороженный стек, ADR, документация, openspec как единственный источник истины и запрос на изменение, запрет техдолга — дисциплинируют в первую очередь не агентов, а людей. Люди отклоняются от процесса так же. Миграции терпят неудачу не потому, что проект сложен, а потому, что процесс не выстроен.

Проект SYNOPSIS [MEMEX]

Porting Go Rust Agentic Llm Ai Genai