Портирование 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:
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:
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):
# 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-границе).
Можно дополнительно сделать карту аналогов, которая может пригодиться в дальнейшем при портировании и сравнении доступного функционала в рамках реализации задач.
| Задача | Go | Rust |
|---|---|---|
| ANN | sqlite-vec (vec0, CGO) | usearch 2.x |
| ONNX | onnxruntime_go (CGO) | ort (внешний .so, load-dynamic) |
| SQLite | mattn/go-sqlite3 (CGO) | rusqlite (bundled, без CGO) |
| MCP | mcp-go v0.57.0 | rmcp 3.x |
| YAML | gopkg.in/yaml.v3 | noyalib |
| Логи | zap | tracing (cli) |
| JSON | encoding/json | serde/serde_json |
| Ошибки | fmt.Errorf | thiserror (либы) + anyhow (CLI) |
| CEL | cel-go | cel 0.14 |
| Tokenizer | sugarme/tokenizer | tokenizers |
| Кросс | GOOS/GOARCH | cargo-zigbuild |
CI/CD появляется на этом этапе, до первого доменного кода: fmt, clippy -D warnings, тесты. Он важен для проверки работоспособности проекта на каждом шаге. Пока CI сломан, дальше не двигаемся.
Фрагмент минимального набора задач:
# .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.95 | ANN против 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 566 | 1 556 | движок сайта и компоненты |
| MDX (сайт) | 3 717 | 4 156 | контент, +8 страниц |
| CSS (сайт) | 2 135 | 2 135 | тема, без изменений |
| Markdown (docs) | 7 989 | 18 461 | ADR, openspec, COVERAGE |
| Прочее (JSON/YAML/SQL/XML/TOML/…) | 2 123 | 2 524 | конфиги, миграции |
| Всего (code) | 67 184 | 94 849 | +41% |
| Сложность (scc)¹ | 12 513 | 3 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)
───────────────────────────────────────────────────────────────────────────────
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)
───────────────────────────────────────────────────────────────────────────────
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
Реальные агенты проекта:
| Роль | Агент | Модель | Зона ответственности |
|---|---|---|---|
| Оркестратор | orchestrator | qwen3.8-27b / DeepSeek V4 Flash (Free) / Ox Alpha (Free) | Координирует, не трогает код, маршрутизирует в сабагенты, требует явного human approval |
| Аналитики | task-evaluator + problem-analyst | qwen3.6-35b / DeepSeek V4 Flash (Free) | Оценка запроса/сложности, глубокий анализ |
| Разработчик (backend) | rust-implementer | qwen3.8-27b | Одна задача на вызов, запускает гейты/тесты |
| Техлид/Архитектор | rust-reviewer | qwen3.6-35b / DeepSeek V4 Flash (Free) | 4-ступенчатое ревью (task compliance, code quality, security, performance) |
| Тех.писатель | docs-writer | qwen3.8-27b | Документация |
| Разработчик (frontend) | site-implementer | qwen3.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
Помимо процесса, есть подводные камни, которые всплывают в каждом таком переносе:
| Go | Rust | Подводный камень |
|---|---|---|
error-строки, errors.Is/As | thiserror / anyhow | типизировать ошибки, но сохранить тексты — они часть паритета |
nil | Option<T> | len(nil) == 0 в Go — решить, что означает None в каждом месте |
interface{} / any | enum / serde_json::Value | предпочесть закрытый enum |
| горутины, каналы | потоки, mpsc, rayon | не тащить модель «разделяем всё через мьютексы» |
context.Context | явная отмена (token, флаги) | отмену проектировать явно |
io.Writer | impl Write | дженерики вместо Box<dyn Write> без нужды |
panic | паника = баг | пользовательские ошибки — только Result |
| map (случайный порядок) | BTreeMap | любой порядок, влияющий на вывод, фиксировать |
int | i64 / usize | выбирать осознанно |
| строки = байты | String (UTF-8) / Vec<u8> | бинарные «строки» — не String |
path/filepath | std::path, camino | Windows, OsStr |
И несколько вещей, которые не помещаются в таблицу:
- Коды выхода и буферы.
std::process::exitне запускает деструкторы и не сбрасывает буферы — перед выходом нужен явныйflush. В Gomainс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]