Как мы встроили LLM в инструмент, где ошибка портит layout. Большая часть «интеллекта» у нас обычный Rust: инструменты, политика, роутер, паспорта нарушений. Модель диспетчер и рассказчик, не источник правды.

Зачем вообще ассистент

Инструмент работает с топологией: GDSII/OASIS и LEF/DEF, верификация DRC/LVS/PEX (зазоры, сверка со схемой, паразиты), список нарушений. Типичный вопрос инженера: «почему LVS ругается на этот инвертор» или «покажи spacing на met2».

Три условия, без которых чат в таком продукте вреден.

Полностью офлайн по умолчанию. Layout это IP заказчика. Кусок дизайна в облако не уезжает, пока человек сам не выбрал облачный бэкенд. Часть установок air‑gapped(изолировано).

Нельзя молча испортить данные. Правка геометрии идёт через preview: Apply или Discard. Delete и крупные батчи помечаются жёстче.

Нельзя выдумывать. Числа по нарушениям и правилам берутся из движков и локальной документации. Общая эрудиция модели сюда не допускается.

Ассистент вызывает инструменты, а не вспоминает дизайн

Модель не отвечает «по памяти». Она вызывает инструменты. Сейчас их 36. Среди них find_violations, query_lvs, query_net, explain_violation, run_drc, focus_camera, apply_actions, расследования (investigate_*), память проекта, поиск по базе знаний.

Каждый инструмент это маленький Rust‑трейт: schema() отдаёт имя, описание и JSON Schema для модели, execute() гоняет детерминированный код и возвращает JSON. Ошибка тоже JSON с is_error, модель может поправиться сама.

Читение и запись разведены. Читающие тулы берут снапшот состояния. Те, что меняют сцену (apply_actions, focus_camera, generate_chip, запись в память), мир не мутируют. Они кладут команды в outbox. Хост‑поток дрейнит буфер после диспетча и применяет сам, в одном месте, под своими инвариантами. Тул остаётся синхронной функцией без mutable‑доступа к ECS.

Камера и layout это разные вещи. focus_camera двигает вид, не геометрию, поэтому политика считает его Automatic. apply_actions и generate_chip уже Confirm: правка или новый файл на диск стейджится до Apply/Discard.

Часть «AI‑фич» нейросети не содержит. explain_violation собирает паспорт нарушения тем же кодом, что карточка в UI: rule‑id, measured/required, delta, fix_hint. Числа совпадают с тем, что видит инженер, дрейфа нет. compare_devices это diff параметров двух транзисторов из того же атласа, что таблица LVS. summarize_risks и report_risks агрегируют severity и quality score. Модель только формулирует готовые факты.

Отдельно про run_drc. Имя врёт, и это сознательно зафиксировано в схеме: тул не запускает проверку. Он читает последний прогон. Свежий DRC инженер жмёт в UI. Если прогона не было, в ответе status: "not_run" и uncertified_reason. Ноль нарушений без прогона не читается как «дизайн чистый». Тот же маркер стоит на find_violations.

apply_actions режет с разгона: не больше 100 операций за вызов, не больше 25 удалений, координаты дальше 10 м отвергаются. id элемента берётся из предыдущего find_violations или query_selection, не из головы модели. Удаление path пока не поддержано и возвращает явную ошибку, не «почти удалил». Весь батч откатывается одним Ctrl+Z.

Цикл: worker, бюджеты, политика

Диалог крутит отдельный поток. Хост один раз отдаёт LoopRequest, дальше worker: спросил модель, получил текст или пачку tool calls. Каждый вызов уходит в хост через mpsc, тул исполняется там, worker ждёт ответ до 20 секунд. Результат дописывается в историю, снова к модели.

Вокруг цикла бюджеты: 8 вызовов на запрос и 60 секунд wall‑time. Это защита от двух известных поломок tool‑calling. Модель не поняла ответ и зовёт тот же тул по кругу. Или запрос завис. Бюджет кончился, пользователь видит «не успел, переформулируй», не вечный спиннер. Stop бросает in‑flight HTTP, в UI это нейтральная отмена, не ошибка.

Политика исполнения одна на все точки диспетча.

Automatic: чтение, расследования, разбор последнего прогона верификации. Исполняется сразу.

Confirm: правки геометрии, undo, генерация тестового чипа. Preview, человек жмёт Apply или Discard.

MandatoryConfirm: delete в батче и батчи длиннее 10 операций. В текущей версии это аннотация в action_plan, не отдельный гейт поверх preview. Waiver из ассистента недоступен вообще, только руками.

Автономный agent_run (“доведи до sign‑off”) тоже ходит через эти классы. Сам layout он не чинит.

Дешёвый слой перед моделью

Большая часть фраз шаблонная: «покажи нарушения», «что выделено», «статус LVS». Гонять их через LLM значит платить токенами и секундами за три слова.

Перед бэкендом стоит роутер. Это обёртка над тем же трейтом LlmBackend. Якорные слова собираются в tool call без модели. Незнакомая фраза уходит во внутренний бэкенд как есть, хуже чем без роутера не становится нигде.

Порядок правил важен. Роутер берёт первое совпадение, широкий якорь перехватывает чужой интент. Слово plan матчится на planar в «создай чип 20 planar 7nm», поэтому генерация тестового чипа стоит раньше signoff‑плана. Вопросы «почему…» стоят раньше статусных, иначе якорь «lvs» съест «почему LVS падает» и отдаст сухой статус вместо расследования. Каждый такой порядок зафиксирован комментарием ORDER MATTERS и тестом.

Роутер без состояния. Вся история приходит в msgs каждый раунд, как требует трейт. Скрытого стейта между вызовами нет.

Один трейт, три провайдера

Абстракция это один async‑метод chat: текст или список tool calls. Под ним Ollama (локальные модели с нативным tool‑calling: qwen2.5-coder, llama3.1, mistral‑nemo), Anthropic (tool_use / tool_result) и OpenAI‑совместимый endpoint. Проволочные форматы разные, логика одна, трейт сводит их к паре внутренних типов.

По умолчанию Ollama: локально, без сети. Облако опция для тех, кому можно.

RAG: локальная база на BGE‑M3

Вторая половина «не выдумывай» это поиск по документации. Вендорные мануалы, PDF по правилам техпроцесса(PDK), внутренняя документация. Индекс строится локально.

Стек под требование «pure Rust, офлайн, разные OS и отдельные их возможности с ускорением».

Модель BGE‑M3. Мультиязычная: запросы и документы смесь русского и английского. 568M параметров, около 2.3 ГБ весов, hidden_size 1024. В базе это XLM‑RoBERTa‑large, для dense‑эмбеддингов хватает XLMRobertaModel из candle‑transformers. Sparse и multi‑vector головы не используем, для cosine нужен только dense‑выход.

Инференс Candle: pure Rust, Metal на macOS, CPU как запасной путь. Веса из tokenizer.json + config.json + model.safetensors в локальной директории. Скачивание с HuggingFace за feature‑флагом, дефолтная сборка живёт без сети. Без весов индекс не строим: stub‑эмбеддер дал бы семантически случайный поиск, это хуже, чем пустая база.

Пайплайн обычный: forward, mean‑pooling последнего hidden state по непаддинговым токенам, L2. После нормализации cosine это скалярное произведение.

Стор in‑memory, линейный проход по всем чанкам. На нашем масштабе (до 50 тысяч чанков × 1024 float32) это миллисекунды. HNSW не нужен, и это записано как решение, не как долг. Индекс пишется атомарно (tmp, fsync, rename) в домашнюю директорию, формат JSON: его можно открыть и сравнить диффом. Имя файла исторически rag_index.bin, внутри JSON.

Размерность стора берётся у живого эмбеддера, не из литерала. Когда‑то с двух сторон торчали 384 (эпоха bge‑small) и 1024, индекс и модель расходились. Сейчас перед загрузкой 2.3 ГБ весов стор ещё и peek'ает dim в файле: чужой индекс отбрасывается за миллисекунды, без прогрева модели.

Чанкинг свой для PDF, HTML, Markdown и PDK, окна общие: цель 512 токенов, перекрытие 64, минимум 50. Retrieval между типами источников тогда сравним. PDK узнаём по расширению. .json по содержимому: ищем ключ drc_rules. Расширение сигнал, не доказательство.

В ассистент база входит обычным тулом search_knowledge_base. Модель сама решает, когда нужна документация. Ответ: top‑k выдержек (по умолчанию 5, потолок 15), каждая обрезана до 800 символов, весь JSON под 50 КБ. В системном промпте требование цитировать [Source N]. Если в диалог уже подмешан always‑on блок контекста, тул просит не звать себя тем же запросом ещё раз.

Три скучных решения, без которых стек врёт

StubEmbedder вместо моков. Чанкеры, стор, инжест и тул гоняются на hash‑эмбеддере. В unit‑тестах нет модели и нет скачивания весов. BGE‑M3 только в проде и в явно помеченных интеграционных тестах.

Громкие гарды. Размерность эмбеддинга не совпала с стором: запись дропается, в лог «переиндексируй базу», cosine‑оценки не портятся молча. То же при поиске: чужой query dim даёт ноль хитов и error, не мусорный рейтинг.

Память проекта отдельно от RAG. Решения, правила, предпочтения, история правок, плюс architecture и наблюдения. На категорию один JSONL, поиск по подстроке и тегу, детерминированный Markdown‑дайджест в системный контекст. Семантика это работа базы знаний. Память хранит истину проекта, тут важнее аудит, чем близость векторов. Ключ директории это усечённый SHA-256 канонического пути дизайна: память переживает переименование рабочей папки и не протекает в read‑only корпуса с GDS.

PDF‑чанкер это attack surface

Единственный реально достижимый путь атаки на этот стек: чанкер PDF. Он парсит чужие файлы из базы знаний. Когда вышел advisory на старый lopdf (stack overflow, SIGABRT, catch_unwind не ловит), подняли pdf-extract с 0.7 на 0.12 и сверили Cargo.lock: транзитивно приехал lopdf 0.42, та самая исправленная линейка, не «просто новая мажорка». Если инструмент ест произвольные файлы, утилитарный парсер надо версионировать так же придирчиво, как сетевой стек.

Чего нет

Векторной СУБД нет. До 50 тысяч чанков brute‑force быстрее handshake с внешним сервисом. Появится сотня тысяч, появится HNSW. Интерфейс стора это позволяет без переписывания вызывающего кода.

Инкрементального индекса по mtime нет. Полный reindex. На текущих объёмах это минуты. Сложность отложили сознательно.

Модель не исполняет правки в обход политики. Никакого «агент сам всё починил».

Выводы

Полезное поведение ассистента почти целиком обычный код: тулы, политика, роутер, паспорта, гарды. LLM занимает узкое место: разобрать нестандартную фразу и связно изложить факты, которые код уже посчитал.

Локальный RAG закрывается Candle, BGE‑M3 и небольшим стором. Внешний сервис сюда не встаёт: чужой IP не должен уезжать за контур.

Бюджеты и громкие отказы важнее выбора модели. 8 вызовов, 60 секунд, preview на запись, запрет читать ноль как «чисто». В инженерном инструменте предсказуемость стоит дороже ещё одного бенчмарка.