28 KiB
Заметки реализации (сессия «Бэкенд», 2026-07-04)
Вердикт шага 0 — перевалидации решений v2 глазами реализатора перед Фазой 0. Метод: собственное чтение донорского кода vojo + воркфлоу из 4 критиков (линзы: консистентность доков, исполнимость в Go, сверка с донором построчно, онлайн-фактчек цен/API; ~940k токенов, находки ниже отобраны и объединены). Формат: подтверждаю / правки реализации (фиксируются здесь, архитектурные доки не трогаю) / [НУЖНО РЕШЕНИЕ] (продублировано в PROGRESS).
1. Общий вердикт
Архитектура v2 исполнима, кодить можно. Противоречий, блокирующих Фазу 0, нет. Подтверждаю:
- Р1 (Go + порт vojo): донор сверен построчно — llm.go/httpllm.go/failover.go/pricing.go/telemetry.go/store.go
существуют, объёмы и механизмы соответствуют research/10 (retry-классификация 429/5xx vs терминальный 4xx,
типизированная
httpStatusError, reserve/settle с TOCTOU-защитой, анти-flapping брейкер, «2xx с пустым контентом = оплаченный вызов», priceFor никогда $0, 137 тест-функций). - Р3/Р6 (SQLite, modernc.org/sqlite): FTS5 и WAL в pure-Go драйвере есть; чанк-чекпоинты и kill -9-тест реализуемы (одна маленькая транзакция на LLM-вызов при вызовах в 10–300 с — тривиальная нагрузка).
- Р5 (cache-раскладка): схема «стабильный префикс → волатильный хвост» совместима одновременно с Anthropic
cache_control(явные брейкпоинты, max 4) и DeepSeek-автокэшем (prefix-match с 0-го токена, блоки по 64 токена) — селективный глоссарий в хвосте кэш DeepSeek не ломает. - Конфликты, разрешённые ревью v2 (кэш↔селективная инъекция, modernc↔sqlite-vec, интерим 18+, чанк-чекпоинты), действительно согласованы по всей цепочке доков.
2. Упущения передавшей сессии, найденные валидацией (главное)
- В
Usageдонора нет cache-write — у Anthropic запись в кэш платная (×1.25 за 5m TTL / ×2 за 1h), полеusage.cache_creation_input_tokens. Без него — систематический недоучёт расходов, тот же класс ошибки, что уже оплаченный в vojo недоучёт reasoning-токенов 30–44%. research/10 это предлагал (§3 п.1), но в обязательства Фазы 0 в плане не попало. → ВUsageдобавляюCacheCreationTokens, вModelPrice—CacheWritePerM; формула:(prompt−cached)·in + cached·cacheRead + cacheCreation·cacheWrite + (completion+reasoning)·out. - Скрытый третий таймаут донора: полигон знал про 45 с (нога failover) — но в транспорте httpllm.go:214
захардкожен ещё и per-attempt 60 с, а maxTry=3. С чанками 63–278 с любая генерация >60 с была бы убита и
трижды бесплатно ретраена. → Все таймауты/ретраи — параметры клиента из конфига (профиль per-провайдер
per-роль), + чтение
Retry-Afterна 429 (донор его игнорирует, backoff капится 8 с — молоток по rate-limit'ам массового прогона). - Прокси-ловушка глубже, чем в памяти проекта: Go stdlib НЕ проксирует loopback никогда, так что
127.0.0.1 сам по себе безопасен; опасны не-loopback локальные адреса (WSL-gateway 172.x, как в самом
vojo). Все клиенты донора —
&http.Client{}сProxyFromEnvironment. → В адаптере локального провайдера и его пробах явныйTransport{Proxy: nil}. - Grok 4.1 Fast мёртв (retired 15.05.2026, слаги молча редиректятся на grok-4.3 c ДРУГОЙ ценой $1.25/$2.50) — таблица Р4 в этой части устарела ещё до старта кода. Состав/цены — только configs/models.yaml (Р4 это и предписывает), но канал B остаётся без «дешёвого Grok» — see [НУЖНО РЕШЕНИЕ].
- store.go — переписывание, а не «адаптация <20%»: advisory-локов в SQLite нет, весь файл на pgx-API, диалект Postgres (IDENTITY, JSONB, GREATEST, now()). Переносится семантика (reserve/settle/release, миграции per-step, идемпотентные шаги, тесты как поведенческий контракт), не код. Оценку research/10 по этому файлу считать оптимистичной; трудоёмкость Фазы 0 это не ломает (семантика простая).
- Приёмка Фазы 0 требует больше, чем список работ Фазы 0:
tmctl translateна один чанк draft→edit подразумевает мини-раннер, промпты Translator/Editor и сборку контекста. → Мини-раннер C1 — явный деливерабл Фазы 0 (здесь зафиксирован).
3. Контракты Фазы 0 (обязательства реализации)
3.1 Детерминизм и request-hash (Р6)
Рендер промпта — чистая функция от (snapshot джобы, закоммиченные чекпоинты предыдущих чанков, индекс чанка). Запрещены timestamps/UUID в промпте; все выборки из памяти — с ORDER BY; все map сортируются по
ключу перед рендером. Проверяется unit-тестом «два рендера байт-в-байт идентичны».
request_hash = sha256(book_id | chapter | chunk_index | stage | role | model+sampling | snapshot_id | sha256(rendered_messages)).
Противоречие буквы Р6 («ключ: request-hash» vs «волатильные части исключаются») разрешаю так: хэш считается от запроса, отрендеренного из snapshot, — волатильность заморожена снапшотом, исключать нечего.
3.2 Snapshot контекста джобы
Материализованная запись с собственным snapshot_id: brief_hash, версии промпт-шаблонов per-role,
model+sampling per-role, версия чанкера, зафиксированное состояние approved-глоссария (версия-счётчик),
style sheet, резюме книги/арок на момент старта джобы. STM в snapshot не входит — он детерминированно
пересобирается из чекпоинтов. Батч-подтверждение терминов (Фаза 1) применяется только на границе джоб.
3.3 Деньги: атомарность settle+checkpoint
Дыра «settle прошёл — kill -9 — чекпоинт не записан» = двойная оплата вызова. → Settle и запись сырого ответа — одна SQLite-транзакция в одном файле БД (ledger и чекпоинты живут в проектной БД). Тогда неустранимая потеря — только in-flight вызов (провайдер списал, ответ не дошёл) — это и есть честные «≤1 вызов» приёмки. На старте процесса — recovery-проход: снять зависшие reserve джоб без чекпоинта.
3.4 Ключ TM (Фаза 1, фиксируется сейчас — влияет на схему store)
tm_key = (lang_pair, brief_hash, sha256(нормализованный СЫРОЙ src-чанка)) — нормализация только
whitespace/Unicode NFC, до pre-replace терминов (иначе подтверждение термина инвалидирует TM через
чёрный ход, что прямо запрещено Р6). prompt_version, model, glossary_version — метаданные записи,
НЕ ключ. Открытые вопросы политики инвалидации — [НУЖНО РЕШЕНИЕ] п.2.
3.5 Нейтральный интерфейс LLM (расширения к типам vojo)
Message.CacheBoundary bool— конец стабильного блока префикса. Anthropic-адаптер вешаетcache_controlна соответствующий блок (максимум 4 брейкпоинта); OpenAI-совместимые адаптеры игнорируют (автокэш по префиксу). Единственная точка, где Р5 материализуется в коде, — раскладку задаёт сборщик контекста, не адаптеры.Usage{Prompt, Cached, CacheCreation, Completion, Reasoning}— семантика reasoning per-adapter (xAI аддитивно, OpenAI-спека/ollama subset → 0, Anthropic thinking внутри output → 0) документируется в каждом адаптере, как в доноре.ReasoningEffort: значение"off"— явное выключение thinking; маппится адаптером в провайдер-специфичный флаг. Для нестандартных ручек (GLMthinking.type=disabled, Qwenenable_thinking=false) —extra_bodyper-модель в models.yaml, мёрджится в JSON запроса OpenAI-совместимым адаптером (эмпирика полигона: thinking GLM-4.6 — таймауты ×3, Gemini молча жрёт max_tokens на thinking).- Стриминг в Фазе 0 НЕ реализуется, но интерфейс проектируется под него (второй метод в будущем не ломает адаптеры); длинные генерации закрываются длинными attempt-таймаутами из профиля. См. [НУЖНО РЕШЕНИЕ] п.4.
- DeepSeek-адаптер парсит оба варианта cache-полей usage (
prompt_tokens_details.cached_tokensиprompt_cache_hit_tokens/prompt_cache_miss_tokens) — иначе эксперимент Фазы 0 по кэшу покажет ложный ноль. - Ответ транспорта читается через
LimitReader(у донора кап есть только в gemini-native). - Failover-декоратор портируется, но состав пары primary/fallback задаётся снаружи per-роль (позже per-канал: в канале B fallback = только пермиссивный провайдер или fail loudly — иначе пустой ответ локальной abliterated молча уйдёт в облачную ногу, дыра в изоляции Р4).
3.6 SQLite-рецепт (modernc.org/sqlite)
- На файл БД два пула: write (
MaxOpenConns=1, транзакцииBEGIN IMMEDIATE) и read (N соединений). - Прагмы на соединение:
journal_mode(WAL),busy_timeout(5000),foreign_keys(1),synchronous(NORMAL)(для kill -9-приёмки достаточно; отключение питания — вне модели угроз MVP). - Замена advisory-локов: сериализация check-and-reserve через write-пул +
BEGIN IMMEDIATE. - Регистр/леммы кириллицы нормализуются на стороне Go до записи (NOCASE в SQLite — ASCII-only); сравнения в SQL — только по нормализованным колонкам.
- FTS5 в горячий путь v1 не тащить (unicode61 не сегментирует CJK; селективной инъекции хватает точных ключей/алиасов по обычным индексам).
- Файлы БД — только на ext4 (WSL: не /mnt/c — WAL-локи ломаются на drvfs); задокументировать для установок.
3.7 Coverage-гейт v1 (Фаза 1, спека фиксируется сейчас)
Портировать классификацию исходов из eval/refusal_bench.py (hard_refusal / soft_refusal /
excision_suspect / ok) 1:1, не изобретать заново. Метрика — символы без пробелов (не токены!);
пороги per-пара из эксперимента 02: нижние zh<2.2 / ja<1.4 / en<0.70 (вырезание), верхние >4.2 / >2.6 / >1.4
(аномалия), sent_cov<0.75; минимальная длина применения (калибровка на 500–2500 симв.). Сегментация — класс
[。!?.!?…] со схлопыванием последовательностей терминаторов и поглощением закрывающих кавычек/скобок.
Отдельные ветки по finish_reason: content_filter = refusal; length = обрезание бюджетом → ретрай с
бОльшим max_tokens, НЕ эскалация (реальный кейс Gemini из эксп. 02). Blacklist refusal-паттернов — en/ru/zh/ja.
3.8 Конфиги (граница Р2 — закрытие 6 недоопределённостей)
Три файла: book.yaml (translation brief + ссылки) → configs/pipeline-c1.yaml (ядро) →
configs/models.yaml (модели/цены/таймауты, с датой проверки). В конфиг ядра входят: состав/порядок стадий,
роль→модель, версии промптов, пороги гейтов, режим инъекции глоссария (selective | full_prefix) (Р5
требует обе схемы), токен-бюджеты сборки контекста (инъекция, STM, overlap), лимит регенераций до эскалации,
именованные эскалационные цепочки (список моделей; channel-aware фильтр применяет раннер), fan-out N для
C2, cache TTL per-стадия. В models.yaml: цены (+cache write/read), профиль таймаутов/ретраев per-модель,
extra_body. Зашито в раннер: циклы по главам/чанкам, ветвление по гейтам, механика эскалации/ретраев,
семантика «эскалация → re-gate → флаг» (диаграмма pipeline.puml перегейтовку не рисует — реализую re-gate
с ограничением попыток, иначе эскалированный вывод коммитится непроверенным).
3.9 Онлайн-факты (проверены 2026-07-04, источники — официальные доки)
- DeepSeek:
deepseek-chat/deepseek-reasonerотключаются 24.07.2026 15:59 UTC (сейчас алиасыdeepseek-v4-flash); v4-flash: $0.14 in / $0.28 out / $0.0028 cache-hit, контекст 1M. Пиковых надбавок для V4 официально НЕТ (только пресс-слух) — в конфиг не закладывать, заложить переключаемость тарифа. - Anthropic: cache write ×1.25 (5m) / ×2 (1h), read ×0.1; поля
cache_creation_input_tokens/cache_read_input_tokens; максимум 4 брейкпоинта; минимальный кэшируемый префикс зависит от модели (Sonnet 5 — 1024 ток., Opus 4.8 — 1024, Haiku 4.5 — 4096) — короче лимита кэш молча не создаётся; thinking биллится как output (полный объём, даже при summarized-отображении). Sonnet 5 промо $2/$10 до 31.08.2026 (кэш write $2.50/5m, hit $0.20; batch $1/$5); Opus 4.8 $5/$25. - GLM (Z.AI, международный): флагманы уже GLM-5.1/5.2 ($1.4/$4.4), GLM-5 $1/$3.2 (cached $0.2).
- Kimi: актуальны kimi-k2.6 ($0.95 in / $4.00 out / $0.16 cache-hit) и k2.7-code; API-домен теперь platform.kimi.ai (301 с platform.moonshot.ai).
- xAI: grok-4.3 $1.25/$2.50 (1M контекст); Grok 4.1 Fast retired (см. §2 п.4).
- Gemini: 3.1 Pro (Preview) $2/$12 до 200k промпта, $4/$18 свыше; batch −50%. Эмпирика полигона: judge-роли нужен нативный API (safety_settings через OpenAI-слой не передаются) — нативный Gemini-адаптер планирую Фазой 2 (в vojo есть донорское v1beta-лицо), в Фазе 0 закладываю только место в раскладке адаптеров.
Ловушки телеметрии кэша: TTL 5m может истекать между вызовами одной стадии (латентности чанков 63–278 с +
гейты) — TTL параметр стадии; телеметрия обязана показывать cache_read=0 при повторных вызовах (тихая
инвалидация). DeepSeek-кэш best-effort — экономику считать только по фактическому usage.
4. Мелкие поправки к research/10 (для истории)
- request_log донора — 46 колонок после v8, не 43; тест-функций 137, не «125+».
- «Table-driven тесты» — преувеличение: основной стиль донора — сценарные функции + httptest wire-контракты +
конкурентные тесты под
-race; копирую именно этот стиль. - Из config.go переносимы хелперы (~150–200 строк: getenv/getSecret
*_FILE/fail-fast «problems списком»/ Summary с redaction), а не ~400; YAML-слой конфигов — целиком новый код (yaml.v3 в доноре только для Matrix). - telemetry/trace:
safegoживёт в bot.go — выносится в obs; reqInfo (sender/verbose) заменяется на ось book/chapter/chunk/role; прайсеру нужен явный default-price якорь (у донора —Prices[XAIModel]).
5. [НУЖНО РЕШЕНИЕ] — продублировано в PROGRESS (## Бэкенд)
- Премиум-бюджет Р5 «≤2× input-объёма» после токен-калибровки неоднозначен (какой токенизатор, входит ли output; эксп. 01: полный проход уже ~2.8×B o200k-input). Реализую как конфигурируемый $-потолок книги (без зашитого «2×»); формулу должен уточнить владелец/оркестратор.
- Политика инвалидации TM сверх brief_hash: (а) входит ли prompt_version в ключ (да = тюнинг промпта обнуляет TM библиотеки; нет = TM молча переживает смену промптов); (б) модель в ключе или метаданных (эскалация перезаписывает запись или дополняет?); (в) смена параметров чанкера = полная инвалидация — трактовать как «смену brief» с показом стоимости? Реализую (а) нет, (б) метаданные+дополняет, (в) да — но это меняет экономику пере-переводов, нужно подтверждение.
- Канал B без дешёвого Grok: Grok 4.1 Fast retired; grok-4.3 — $1.25/$2.50 (~9× дороже по input). Основную модель канала B на Фазу 2 надо перевыбрать (кандидаты: open-weights через OpenRouter, DeepSeek, локальная abliterated). Фазу 0–1 не блокирует (интерим-правило 18+ действует).
- Стриминг: план относит к Фазе 3 «нужен только IDE-фронту», но эмпирика полигона требует его как keep-alive транспорта для генераций 63–278 с (иначе фейловер ложно роняет здоровую локалку). В Фазе 0 закрываю длинными таймаутами из профиля; предлагаю оркестратору перенести провайдерский стриминг (не SSE-фронт!) в Фазу 1–2.
- Оркестратору (не блокирует): цифры Р5 устарели ~2× против эксп. 01 (Sonnet-проход $46, не $92); задача «проверка cache-hit агрегаторов» назначена и Фазе 0 бэкенда, и бэклогу полигона — предлагаю отдать полигону (у него харнесс сравнения usage), бэкенд потребит результат в конфиге; TMX в диаграммах не помечен «(Фаза 3)»; морфодетектор/CometKiwi/NSFW-классификатор в диаграммах без фазовых пометок; режим онгоинга отсутствует в диаграммах (и не определено, перегоняется ли Analyst при дозагрузке глав).
6. Селфревью Фазы 0 (агентное, 2026-07-04)
Финал Фазы 0: /code-review (8 finder-линз + верификация) + воркфлоу-критики (4 линзы задания: архитектура/
соответствие v2, корректность и деньги, идиоматичность Go, устойчивость к сбоям) с адверсариальной проверкой
каждой находки. Подтверждённые исправлены двумя волнами (коммиты dc6ea8e → a32ec2e → фиксы v2):
Волна 1 (коммит a32ec2e): общий retry-движок (устранена копипаста Anthropic↔OpenAI, кап Retry-After
maxRetryAfterWait 5м, overflow-safe backoff); BilledDecodeError — 2xx с нечитаемым телом консервативно
селтлит оценку, не возвращает резерв; пустой оплаченный ответ — громкая остановка, не отравляет редактуру;
snapshot-pinning гейт с --resnapshot; flock-владение проектным файлом; цены локальных тегов $0; однопроходный
рендер; temperature без omitempty (Anthropic вообще не шлёт sampling — 400); attempt в ключе и схеме.
Волна 2 (эта): snapshot-покрытие расширено (Defaults ratio/min, Title→BriefHash, модельный ExtraBody —
правки конфига больше не инвалидируют чекпоинты в обход гейта); PriceForResponse — неизвестный слаг ответа
книжится по ЗАПРОШЕННОЙ модели, не по дешёвому якорю (устранён недоучёт премиума); usage-кламп в CostUSD
(отрицательные числа не уменьшают committed); zero-usage платный 2xx → settle оценки + WARN; жизненный цикл
статуса джобы (не остаётся running на отказе потолка/ошибке клиента); CheckRunnable — fail-fast на
нереализованную механику (C2/C3, fan-out>1, роль judge — иначе pipeline-c2.yaml прогнал бы стадии линейно и
сжёг деньги Opus на мусор); CheckKeys — preflight API-ключей used-моделей (пустой ключ ловится до reserve, не
401-м после); request-hash — length-prefix полей + счётчик сообщений (устранена NUL-коллизия); нормализация
источника BOM/CRLF/NFC (детерминизм хеша между ОС/редакторами); LimitReader-усечение терминально (не ретраит
оплаченный >16 MiB); recoverReservations → stderr.
Остаточный техдолг (осознанные ограничения Фазы 0, не блокеры):
- Дневной потолок — пер-книжный (ledger в файле проекта одной книги):
day_usdсбрасывается в UTC-полночь и ограничивает burn-rate одной книги, но не «на оператора за день по всем проектам». Общий потолок оператора потребует shared spend-файла — отложено (раскладка «файл БД на книгу» из Р3/Р6); пометить в UI при мультикниге. - Timeout-путь «оплачено, но потеряно»: если провайдер начал биллить 2xx, а соединение оборвалось по
таймауту до получения тела, вызов повторяется — недоучёт на один вызов. Это неустранимо для любой системы с
retry-on-timeout и укладывается в честные «≤1 вызов» (гарантия Р6 — про kill -9, не про billing на брошенном
соединении).
BilledDecodeErrorзакрывает случай, когда тело ЧАСТИЧНО дошло; чистый таймаут до тела — нет. - cache_ttl ↔ cache_write_per_m: цена записи в кэш в models.yaml задаётся под ОДИН TTL (у нас 5m). Если
оператор поставит
cache_ttl: 1h, реальная запись стоит ×2 (не ×1.25) — недоучёт. Боевой конфиг 5m совпадает; при переходе на 1h требуется отдельная цена. Валидацию/вторую цену — Фаза 1 (когда включим 1h осознанно). - Гонка
Runner.clientsmap: ленивая инициализация клиентов не синхронизирована — безопасна в Фазе 0 (последовательный проход стадий), но параллельные чанки/fan-out Фазы 1–2 потребуют мьютекса/предзагрузки.