Продуктово-техническая спецификация · auto-parser / tazzz.ru

Свой LLM вместо OpenRouter

Перевод чат-агента и всего LLM-контура проекта на собственный сервер llm.salam0nn.ru. Каждое утверждение о сервере в этом документе — замер на реальных payload'ах проекта, а не предположение.

Версия 1.0 · 09.09.2026 Основание аудит кода на 2e9f455e + живые замеры Объём 3–4 дня разработки + 1–2 дня eval Статус черновик на согласование

00 Краткое содержание

Меняется один шов, а не двенадцать вызовов

Весь LLM-трафик проекта уже ходит через один класс — OpenRouterClient. Это единственное место, которое надо переписать. Формат ответа нового сервера на /v1 совпал с тем, что уже разбирает llm_stream.iter_stream_events, поэтому нормализатор стрима, LangChain-обёртка, инструменты, блокнот фильтров и ранжирование не меняются вообще.

Сегодня агент, экстрактор фильтров, резолверы марки и модели, оценка цены, подводка к топ-3, батч-перевод, оценка л.с. и VLM-проверка фото идут в OpenRouter через немецкий egress-прокси — с прод-сервера в РФ домен openrouter.ai отравлен по DNS. Бюджет ключа на исходе.

Работы: заменить транспорт на https://llm.salam0nn.ru (Ollama 0.33.2, модель qwen3.5:35b, OpenAI-совместимый /v1/chat/completions), убрать egress-прокси из LLM-пути, адаптировать вызовы под свойства сервера (один запрос за раз, thinking включён по умолчанию, reasoning_effort вместо reasoning), перевести учёт с долларов на токены и время, удалить код и ключи OpenRouter.

12
точек вызова LLM — все через один клиент
0 ₽
стоимость вызовов после перехода
3–4 дня
разработки + 1–2 дня eval
1
прокси освобождается для mobile.de

Что проверено живьём, а не предположено

Замеры 09.09.2026 на реальных payload'ах проекта: настоящий системный промпт агента (37 199 символов), настоящие 10 JSON-схем инструментов, настоящий формат фото data:image/webp;base64, настоящие прод-контейнеры.

ПроверкаРезультат
Прод-контейнеры → llm.salam0nn.ru 200 напрямую /api/tags за 0,55–0,65 с; реальная генерация из celery_worker — 1,58 с. Прокси не нужен
Нативный function-calling с нашими 10 инструментами работает search_cars с корректными человеческими аргументами
Форс tool_choice (нужен экстрактору и оценщику цены) работает на /v1, вернул валидный extract_filters
Стрим /v1 + tools + include_usage работает первый чанк 1,1 с, дельты в формате, который уже понимает наш нормализатор
Системный промпт доходит до модели целиком да «иголка» в начале 12,3k-токенного промпта найдена, обрезки нет
KV-кеш префикса держит несколько префиксов холодный ход 37,4 с → тёплый 6,4–8,0 с; чередование агент↔экстрактор кеш не рушит
reasoning={'effort':'low'} — наш текущий формат молча игнорируется thinking остаётся включённым
Батч-перевод с включённым thinking ломается полностью 24,6 с, 1300 токенов в размышление, 0 из 16 строк переведено
Тот же перевод с reasoning_effort: "none" 3,5 с, 16 из 16 качество хорошее
Vision на нашем формате webp работает оба флага (car, contact_overlay) верно
response_format (json_object / json_schema) работает ответ по схеме
Параллельность 1 запрос за раз, FIFO короткий перевод за спиной хода агента: 2,0 с → 14,6 с
Честная цена перехода

Тёплый ход агента — около 12 с LLM-времени (экстрактор ~4 с + ход ~8 с) против 2–4 с у gemini-3-flash. Первый токен приходит за 1,1 с, поэтому воспринимаемая задержка растёт умереннее полной. Плюс качество вызова инструментов у Qwen ниже, чем у Gemini, под которую написаны промпты (см. §7.3): нужен eval и правка промптов. Это не чистая замена транспорта.


01 Инвентаризация

Всё, что зависит от OpenRouter

1.1. Вызовы LLM — 12 мест, все через один клиент

#purposeМесто в кодеТип вызоваПромпт, ток.
1agent_turnagent_loop.py:1682 · graph/nodes.py:347стрим + tools, reasoning={'effort':'low'}ср. 16 304
макс. 18 192
2agent_turnagent_loop.py:1197 _stream_directedстрим без инструментов—
3agent_extractorgraph/extractor.py:350форс tool_choice, max_tokens=1000, таймаут 8 сср. 4 305
4agent_price_estimateprice_estimator.py:99форс tool_choiceмал
5brand_resolvebrand_resolver_llm.py:75блокирующий, retry=Falseмал
6model_resolvemodel_resolver_llm.py:74блокирующий, retry=Falseмал
7top3_explanationtasks/agent_tasks.py:267стрим~1 100
8translation_batchllm/batch_translator.py:114блокирующий, reasoning={'enabled':False}ср. 334
9translation_batchtranslation_service.py:4854через (8)—
10horsepowerhorsepower_estimator.py:427блокирующий, модель deepseek/deepseek-chatср. 235
11options_translatedistributed_parser_tasks.py:1092блокирующий, google/gemini-flash-1.5мал
12listing_photo_checklisting_photo_llm.py:101vision, data:image/webp;base64~390 / фото

1.2. Инфраструктура LLM

АртефактРольРешение
llm/openrouter_client.py 620 стр.клиент: retry, лимитер, circuit breaker, стрим, учётПереписать в own_llm_client.py (§4.2)
llm/openrouter_proxy.py 142 стр.выбор немецкого egress-проксиУдалить из LLM-пути — прод ходит напрямую
llm/langchain_openrouter.py 170 стр.LangChain-обёртка для langgraphПереименовать, сменить импорт. Логика не меняется
llm/rate_limiter.py, distributed_rate_limiter.pyлимитеры RPMЗаменить смыслом: не «запросов в минуту», а очередь на 1 слот (§5)
llm/llm_call_recorder.pyучёт в llm_call_recordsТокены и время вместо cost_usd (§8)
agent/llm_stream.pyнормализатор чанковне меняется формат /v1 совпал
agent/text_tool_calls.pyфолбэк-парсер tool-call из текстане меняется становится ценнее: Qwen иногда печатает вызов текстом
app/admin_api/agent.py:27-114«кошелёк»: /credits, /key, ценыПеределать в «здоровье своего LLM» (§8.2)
frontend-admin · LlmSpendModal.tsx, AgentPage.tsxUI расхода в USDПеределать на токены и время

1.3. Ловушка: мёртвые гарды на OPENROUTER_API_KEY

Сломается раньше, чем ожидается

services/openrouter_translator.py давно не ходит в OpenRouter — внутри Google Translate, имя класса оставлено историческим. Но вызыватели по-прежнему гейтят работу наличием ключа. Если просто убрать ключ из окружения, тихо отвалится админский и авто-перевод, который к OpenRouter отношения не имеет.

  • services/parser_manager.py:303 — if api_key:, иначе авто-перевод не запускается;
  • app/routes/translation.py:93-97 — отдаёт ошибку «OPENROUTER_API_KEY not configured»;
  • app/admin_api/translations.py:96, app/parser_routes.py:1105,1715 — то же.

Гарды снять отдельным пунктом (§6.7), до удаления ключа.

1.4. Конфигурация и деплой

МестоЧто там
backend/config.py:70-96AGENT_LLM_MODEL, AGENT_EXTRACTOR_LLM_MODEL, TRANSLATION_LLM_MODEL, TRANSLATION_BATCH_LLM
docker-compose.ymlOPENROUTER_API_KEY, OPENROUTER_MAX_RPM — в 5 сервисах (стр. 161, 438, 632, 767, 900)
.github/workflows/deploy.yml:50,308секрет OPENROUTER_API_KEY
backend/.env.exampleблок LLM — стр. 38–51, 104–105, 144
app/__init__.py:349-352проброс ключа в os.environ для воркеров без app-контекста

1.5. Что не трогаем

  • STT (голосовой ввод) — agent/transcription.py ходит в Groq/OpenAI, не в OpenRouter. У Ollama нет speech-to-text: замены на своём сервере нет. Остаётся как есть.
  • services/egress_proxy.py, services/telegram/client.py — общий паттерн прокси для других площадок; упоминают OpenRouter только в докстрингах.

02 Сервер

Что llm.salam0nn.ru действительно умеет

2.1. Паспорт

ПараметрЗначение
Базовый URLhttps://llm.salam0nn.ru
АутентификацияAuthorization: Bearer <токен>; токен на сервере в /root/llm-bearer-token.txt. Общий на всех, ротация — правкой nginx
МаршрутИнтернет → nginx (TLS + токен) на salam0nn.ru → Tailscale → макмини mac-mini-agent → Ollama :11434
ДвижокOllama 0.33.2; таймаут прокси 600 с; тело до 100 МБ
ДоступноPOST /api/chat, /api/generate, /api/embeddings, /api/show; GET /api/tags, /api/ps; POST /v1/chat/completions, GET /v1/models
Заблокировано (403)/api/pull, /api/push, /api/delete, /api/create, /api/copy, /api/blobs
Моделиqwen3.5:35b (MoE 36B Q4_K_M, контекст 262 144, vision + tools + thinking), qwen3.5:35b-32k, qwen3.8:27b (dense 27.3B, vision), bge-m3, nomic-embed-text
Keep-alive24 ч
Параллельность1 запрос за раз FIFO, без приоритетов
Thinkingвключён по умолчанию; выключается только reasoning_effort: "none" на /v1

2.2. Производительность qwen3.5:35b

46–48
ток/с генерация
550–620
ток/с чтение холодного промпта
8 700+
ток/с при попадании в KV-кеш
7–8 с
загрузка модели после вытеснения
~0,3 с
сеть прод → сервер

2.3. Замеры на реальных payload'ах проекта

СценарийХолодный префиксТёплый префикс
Ход агента — 12,3k промпт + 6,5k инструментов = 18,8k ток.37,4 с6,4–8,0 с
длинный ответ — 13,4 с
Экстрактор фильтров — 5,0k ток., форс tool_choice12,7 с4,0–4,1 с
Батч-перевод, 16 строк, reasoning_effort=none—2,0–3,5 с
Оценка л.с. с response_format—2,1–3,2 с
ответ фактически верный
VLM-проверка фото, 1 webp 640×400—9,8 с
на 27b — 20,3 с

Вывод: узкое место — не объём входа (KV-кеш снимает его почти полностью), а один слот и thinking.

2.4. Три находки, определяющие дизайн

находка 1 reasoning_effort: "none" обязателен

Наши текущие параметры reasoning={…} — диалект OpenRouter, Ollama их молча игнорирует. Цена ошибки на примере перевода:

времяcompletion-токеновпереведено
thinking включён — то, что будет «из коробки»24,6 с1300 весь бюджет в размышление0 / 16
reasoning_effort: "none"3,5 с13516 / 16

Скрытое размышление не утекает в content — уезжает в отдельное поле reasoning, то есть пользователю в чат не попадёт. Но оно съедает max_tokens и время, а у узких вызовов с малым бюджетом (экстрактор — 1000, резолверы — сотни) выжигает его целиком и возвращает пустоту. Это ровно те грабли, что описаны в langchain_openrouter.py для model_resolver.

находка 2 KV-кеш крепче, чем ожидалось

Сервер держит несколько префиксов одновременно. Чередование «агент (18,8k) → экстрактор (5k) → агент → экстрактор → агент» дало попадание в кеш на каждом шаге — ход агента стабильно 6,4–7,0 с, экстрактор 4,0–4,1 с.

Условие — байтовая стабильность начала запроса. У нас оно уже соблюдено: SYSTEM_PROMPT идёт первым сообщением (agent_loop.py:1586, graph/nodes.py:246), а дата и динамический контекст — следующими system-сообщениями. Это нельзя ломать (инвариант И-3).

Кеш рушит смена модели: после vision-вызова на qwen3.8:27b следующий ход агента стоил 42,8 с вместо 8 — выгрузка модели плюс перечитка префикса. Отсюда решение Р-2.

находка 3 Head-of-line blocking требует очереди в приложении

Замер: перевод, который соло идёт 2,0 с, за спиной хода агента (13,4 с) ждал 14,6 с — плюс 12,5 с чистого ожидания. Ollama очередь не приоритизирует. Значит, приоритеты — наша забота (§5).

находка 4 Параллельность на маке невозможна — упирается в архитектуру модели

Проверено на самом макмини 09.09.2026. Железо запас имеет: Mac mini M4 Pro, 14 ядер, 64 ГБ. Веса модели 20,7 ГБ (общие для всех слотов), KV-кеш всего 5 ГБ на полный контекст 262 144 — кеш живёт только на 10 слоях из 40. Занято 29,7 ГБ из 64; при генерации свободна половина памяти.

Но OLLAMA_NUM_PARALLEL=4 не применяется. Ollama читает переменную и всё равно передаёт -np 1, а в лог пишет:

level=WARN source=sched.go:509 msg="model architecture does not
currently support parallel requests" architecture=qwen35moe

Причина в модели: qwen3.5 — гибрид Transformer + SSM (ssm.state_size=128, ssm.conv_kernel=4, full_attention_interval=4). Рекуррентное состояние SSM нельзя размножить по параллельным слотам, поэтому Ollama запрещает их для всей архитектуры.

Загрузка GPU 86–91 % одним запросом, генерация 48 ток/с. То есть даже будь параллельность доступна, запас по вычислениям невелик — узкое место GPU, а не память.

Следствие для проекта: очередь допуска (§5) — не временная мера до «включим параллельность», а постоянная часть архитектуры. Один слот — это данность, пока не сменится модель или машина.


03 Нагрузка

Что реально нужно переварить

По таблице llm_call_records прода — retention 30 дней, срез на 09.09.2026.

purposeвызовов / 30 дношибокср. промптср. ответ$
translation_batch2 583216334520,765
horsepower923235420,010
agent_turn9016 3045320,088
agent_extractor504 305470,012
Итого2 689219≈ 0,87

Пиковый час за всё время наблюдений — 408 вызовов, из них 400 перевод (18.08.2026, 08:00, парс-пик). Диалогов агента за историю — 313, сообщений пользователей — 1 136.

Ёмкость на этой нагрузке

Средний перевод на нашем сервере занимает слот ≈ 2,0 с. Пиковый час: 400 × 2,0 с = 800 с работы на 3 600 с часа — около 22 % занятости слота. Запас есть.

Но есть два разрыва:

  1. Чанк перевода — DEFAULT_CHUNK_SIZE = 50 (batch_translator.py:21), а максимум ответа в логах — 627 токенов ≈ 13 с генерации. Такой чанк держит слот 13 с, и ход агента, пришедший следом, ждёт всё это время. Отсюда Р-5 и §5.3.
  2. Текущий трафик агента ничтожен — 9 ходов за 30 дней: агент фактически простаивает из-за исчерпанного бюджета OpenRouter. При возврате к историческим ~38 сообщениям в день это ~76 вызовов × ~12 с = 15 минут слота в сутки. Тоже не проблема. Потолок наступит примерно на 250 ходах агента в сутки при сохранении парс-пиков.

04 Целевая архитектура

Восемь решений и один новый класс

4.1. Реестр решений

#РешениеОснование
Р-1Транспорт — /v1/chat/completions, не нативный /api/chatНа /v1 аргументы инструмента приходят строкой JSON — ровно то, что склеивает llm_stream.py:74. Нативный /api/chat отдаёт dict и сломал бы нормализатор. На /v1 проверены стрим, include_usage, tools, форс tool_choice, response_format
Р-2Одна модель на всё — qwen3.5:35b (текст, инструменты, vision)В память влезает одна большая модель; смена модели = 7–8 с загрузки плюс потеря KV-кеша (замер: ход агента 42,8 с вместо 8). На vision она вдобавок вдвое быстрее qwen3.8:27b — 9,8 с против 20,3 — и отдаёт чистый JSON без фенсов
Р-3reasoning_effort: "none" — дефолт всех вызовов; thinking только явным флагом§2.4, находка 1
Р-4Убрать egress-прокси из LLM-путиПрод ходит напрямую: 200 за 0,55–0,65 с из всех трёх контейнеров, реальная генерация 1,58 с. Освобождает немецкий прокси для mobile.de
Р-5Очередь допуска на 1 слот с приоритетами вместо RPM-лимитера§2.4, находка 3. RPM-лимитер бессмыслен: сервер и так делает один запрос за раз
Р-6Учёт — токены и время, не долларыСвоя модель бесплатна; ценность учёта смещается на «сколько занят слот»
Р-7Килсвитч LLM_PROVIDER=own|openrouter; код OpenRouter живёт один релизЕдиная точка отказа — домашний макмини (§11)
Р-8Circuit breaker, фолбэки и «LLM никогда не роняет ход» — сохранить целикомЭто боевые шрамы; при менее надёжном бэкенде они нужнее, чем были

4.2. Новый клиент services/llm/own_llm_client.py

Публичная поверхность совпадает с OpenRouterClient — чтобы 12 точек вызова менялись одним импортом, а не переписыванием.

class OwnLLMClient:
    """Клиент собственного LLM-шлюза llm.salam0nn.ru (Ollama за nginx, OpenAI-совместимый /v1).

    Совместим по сигнатурам с прежним OpenRouterClient: chat_completion /
    stream_chat_completion. Отличия под капотом:
      - базовый URL и Bearer-токен из LLM_BASE_URL / LLM_API_TOKEN;
      - reasoning_effort вместо OpenRouter-диалекта reasoning (см. _thinking);
      - НЕТ egress-прокси (сервер доступен с прода напрямую);
      - вместо RPM-лимитера — очередь допуска на ОДИН слот (llm/admission.py).
    """

    def chat_completion(self, messages, model=None, temperature=0.1, max_tokens=4000,
                        timeout=90.0, retry=True, reasoning=None, purpose=None,
                        conversation_id=None, response_format=None,
                        priority=Priority.INTERACTIVE) -> dict: ...

    def stream_chat_completion(self, messages, model=None, temperature=0.1, max_tokens=4000,
                               tools=None, tool_choice='auto', timeout=180.0, reasoning=None,
                               purpose=None, conversation_id=None,
                               priority=Priority.INTERACTIVE) -> Iterator[dict]: ...

Переносится из openrouter_client.py без изменений логики

Circuit breaker (_check_circuit / _record_success / _record_failure), экспоненциальный retry с fail-fast на 4xx, форс response.encoding = 'utf-8' на SSE, построчный разбор data:-чанков, обработка GeneratorExit (стоп из UI), единый _commit_llm_acc в finally.

Удаляется

Импорт и вызовы openrouter_proxy (property session упрощается до обычной сессии), _rotate_proxy, заголовки HTTP-Referer / X-Title, RateLimiter.

Добавляется

_THINKING_OFF = {'reasoning_effort': 'none'}

def _thinking(self, reasoning):
    """Перевод «намерения» вызова в диалект Ollama.

    ВАЖНО: OpenRouter-формы reasoning={'effort': ...} / {'enabled': False} сервер
    МОЛЧА игнорирует и продолжает думать — это ломало батч-перевод (0 из 16 строк).
    Единственный рабочий выключатель на /v1 — reasoning_effort: "none".
    """
    if reasoning is None or reasoning.get('enabled') is False \
            or reasoning.get('effort') in (None, 'none'):
        return dict(_THINKING_OFF)
    if os.environ.get('LLM_ALLOW_THINKING', '0') == '1':
        return {'reasoning_effort': reasoning.get('effort', 'low')}
    return dict(_THINKING_OFF)

Сигнатура reasoning= у вызывающих сохраняется — чтобы не править 12 мест; смысл переопределяется здесь, в одной точке. Это же даёт возможность включить thinking обратно одним env, если eval покажет выигрыш.

4.3. Что не меняется — важно для оценки объёма

agent/llm_stream.py, agent/text_tool_calls.py, agent/tools.py, tool_handlers.py, filter_notebook.py, suggestions.py, ranker.py, finalize_registry.py, граф agent/graph/* (кроме строки импорта и таймаута), весь транспорт хода (turn_worker, turn_stream, SSE), фронтенд агента.


05 Очередь допуска

Один слот, три полосы приоритета

Сервер обслуживает один запрос, очередь у него FIFO без приоритетов. Значит интерактивный ход агента может встать за спиной пакетного перевода и ждать до 13 с (замер — плюс 12,5 с). Нужна наша очередь перед отправкой: services/llm/admission.py.

5.2. Дизайн

Распределённый семафор на Redis — все воркеры и бэкенд делят один слот.

ПолосаPriorityКтоПри занятом слоте
ИнтерактивнаяINTERACTIVEagent_turn, agent_extractor, agent_price_estimate, brand_resolve, model_resolveВстаёт в голову очереди, ждёт до LLM_QUEUE_TIMEOUT_INTERACTIVE — по умолчанию 45 с
ОтзывчиваяNEAR_RTtop3_explanation, listing_photo_checkЖдёт до 60 с
ПакетнаяBATCHtranslation_batch, horsepower, options_translateЖдёт до 20 с, иначе сразу уходит на детерминированный фолбэк — Google-перевод, пропуск оценки

Реализация — переиспользовать механику llm/distributed_rate_limiter.py (Lua на Redis), заменив «скользящее окно RPM» на «лизованный слот плюс очередь ожидающих по приоритету».

Обязательные свойства

  • лиз с TTL (LLM_SLOT_LEASE_TTL, по умолчанию 300 с) — упавший воркер не блокирует всех;
  • освобождение слота в finally, в том числе на GeneratorExit стрима;
  • при недоступности Redis — деградация в локальный threading.Semaphore(1) на процесс (как уже сделано в _acquire_local), а не отказ;
  • метрика queue_wait_ms пишется в llm_call_records (§8).

5.3. Ограничение размера пакетного запроса

Приоритет не вытесняет уже идущий запрос

Поэтому пакетные вызовы обязаны быть короткими — иначе интерактивный ход всё равно ждёт.

  • batch_translator.DEFAULT_CHUNK_SIZE: 50 → 20 — целевое время чанка ≤ 5 с;
  • max_tokens перевода: сейчас min(8000, 512 + len(items)*48) → min(2000, 256 + len(items)*40). При reasoning_effort=none реальный расход — около 8 токенов на строку (замер: 16 строк → 135 токенов);
  • horsepower: max_tokens=200 оставить, добавить response_format (§6.5).

06 Работы по модулям

Что именно править

6.1. Новый клиент и его подключение

  1. Создать services/llm/own_llm_client.py (§4.2) и services/llm/admission.py (§5).
  2. services/llm/__init__.py — экспортировать get_llm_client() как основную точку; get_openrouter_client оставить алиасом на один релиз (Р-7), чтобы не ломать импорты.
  3. Заменить 12 импортов from services.llm.openrouter_client import get_openrouter_client на from services.llm import get_llm_client — список в §1.1.
  4. llm/langchain_openrouter.py → llm/langchain_own.py, класс ChatOpenRouter → ChatOwnLLM с алиасом имени на релиз. Внутри — только смена _get_client(); _stream, bind_tools, lc_to_openrouter не трогать.

6.2. Агент — agent_loop.py, graph/nodes.py

  • _agent_model() (agent_loop.py:518) и _extractor_model() (extractor.py:286) — дефолт google/gemini-3-flash-preview → qwen3.5:35b. Дефолты обязаны совпадать с config.py: ход бежит в greenlet без app-контекста и доходит до os.environ-ветки.
  • reasoning={'effort': 'low'} в agent_loop.py:1682 и graph/nodes.py:347 — оставить как есть: смысл переопределяется в клиенте. Комментарий рядом обновить.
  • Таймаут стрима хода — явно 180 с (холодный префикс плюс длинный ответ; nginx даёт 600 с).

6.3. Экстрактор фильтров — graph/extractor.py

Дефект миграции, а не вкусовщина

EXTRACTOR_TIMEOUT_S = 8.0 → 30.0 (extractor.py:45). Замер холодного префикса экстрактора — 12,7 с. При 8 с он падал бы на каждом первом сообщении после простоя и молча уходил в {'status': 'failed'}.

  • EXTRACTOR_MAX_TOKENS = 1000 — оставить: при выключенном thinking хватает с запасом (замер 265 токенов). Но только вместе с Р-3.
  • Форс tool_choice — работает, менять нечего.

6.4. Батч-перевод — llm/batch_translator.py

  • resolve_model() дефолт → qwen3.5:35b.
  • DEFAULT_CHUNK_SIZE 50 → 20, формула max_tokens — по §5.3.
  • reasoning={"enabled": False} в вызове (:120) — оставить, смысл даёт клиент.
  • Приоритет BATCH.
  • Промпт не менять: качество на замере хорошее — 16/16, «真皮座椅» → «Кожаные сиденья», «선루프» → «Люк», латиница и числа сохранены.

6.5. Оценка л.с. — horsepower_estimator.py

  • DEFAULT_MODEL = "deepseek/deepseek-chat" (:121) → qwen3.5:35b.
  • Добавить response_format={'type':'json_schema', …} со схемой {horsepower:int, confidence:number, note:string} — проверено, работает, ответ фактически верный (BMW X5 xDrive30d 2019 → 249 л.с.). Это снимает половину _parse_response.
  • Приоритет BATCH.

6.6. VLM-проверка фото — listing_photo_llm.py

  • _vision_model() дефолт google/gemini-2.0-flash-001 → qwen3.5:35b (Р-2, не 27b).
  • Формат data:image/webp;base64,… — проверен, работает, менять не надо.
  • _MAX_PHOTOS = 6 — оставить, но замерить на 6 фото перед включением: один снимок ≈ 390 токенов промпта и ~10 с; шесть могут дать 30–40 с в слоте. Если так — снизить до 3 или дробить на два вызова с приоритетом NEAR_RT.

6.7. Снятие мёртвых гардов на ключ

В parser_manager.py:303, app/routes/translation.py:93-97, app/admin_api/translations.py:96, app/parser_routes.py:1105,1715 — убрать условие «есть OPENROUTER_API_KEY». Перевод там идёт через Google и в ключе не нуждается. Делать до удаления ключа из окружения (§1.3).

6.8. Резолверы, оценка цены, подводка к топ-3

brand_resolver_llm.py:50, model_resolver_llm.py:50, price_estimator.py:65, agent_tasks.py:261 — сменить дефолт модели. retry=False у резолверов оставить.


07 Бюджеты и качество

Чего ждать после перехода

7.1. Ход агента — тёплый префикс, движок langgraph

ЭтапВремя
Ожидание слота (пусто)~0 с
Экстрактор фильтров4,0 с
Проверка наличия (availability)код, без LLM
Ход агента — стрим, первый токен 1,1 с6,5–8,0 с
Итого до конца ответа≈ 11–12 с
было 2–4 с на gemini

Холодный старт — первое сообщение после долгого простоя: плюс 30 с на перечитку префикса и плюс 8 с на загрузку модели, если её вытеснили.

Смягчение beat-задача-«грелка» раз в 30 минут: пустой дешёвый запрос с тем же системным префиксом. Держит и модель, и KV-кеш горячими.

7.2. Прочие вызовы

ВызовСлотПользовательский эффект
Батч-перевод, 20 строк~2,5 сНезаметно — фон парсинга
Оценка л.с.~2,5 сНезаметно
VLM, 1 фото~10 сМодерация объявления, фон
Подводка к топ-3~3 сПосле финализации поиска

7.3. Качество: честный риск

На трёх контрольных репликах с реальным системным промптом Qwen вызвал remember_filters там, где ожидался search_cars («ищи прямо сейчас… не переспрашивай»), а на одной — не вызвал инструмент вовсе. Промпты писались под нативный function-calling Gemini.

Смягчение уже есть в коде — его надо проверить, а не изобретать
  • детерминированный форс-диспатч из блокнота, когда модель объявила поиск, но не вызвала инструмент — agent_loop._dispatch_search, _has_searchable_filters;
  • parse_text_tool_calls — вызов, напечатанный текстом, исполняется как обычный;
  • _FORCE_SEARCH_DIRECTIVE (agent_loop.py:1958);
  • build_default_suggestions — кнопки есть всегда, даже если модель их не предложила.

Работа: прогнать eval (§9.3) и по его результатам править prompts.py под Qwen. Заложить 1–2 дня. Это единственная часть работ, объём которой определяется замером, а не кодом.


08 Учёт расхода

Токены и время вместо долларов

8.1. Таблица llm_call_records

Схему сохранить — админка и retention уже построены. Добавить миграцией Alembic:

КолонкаТипСмысл
duration_msIntegerвремя ответа сервера
queue_wait_msIntegerожидание слота (§5)
providerString(16)own | openrouter — чтобы период двух провайдеров читался

cost_usd оставить в схеме и писать 0 для provider='own', иначе сломается агрегат админки. model — писать qwen3.5:35b.

extract_usage_from_response уже читает usage.prompt_tokens / completion_tokens — формат /v1 совпал, менять не нужно. Проверено: {'prompt_tokens': 18807, 'completion_tokens': 351, 'total_tokens': 19158}.

8.2. Админка

  • app/admin_api/agent.py:27-114 — «кошелёк OpenRouter» (/credits, /key, цены модели) заменить на GET /api/admin/agent/llm-health: GET /api/tags (есть ли модель), GET /api/ps (загружена ли в память, expires_at), последний успешный вызов из llm_call_records, средние duration_ms и queue_wait_ms за период.
  • frontend-admin/src/pages/LlmSpendModal.tsx — колонку «$» заменить на «время слота» и «токены»; подпись «внутренний учёт OpenRouter» → «собственный LLM».
  • AgentPage.tsx — блок кошелька заменить на здоровье шлюза.

09 Тест-план и eval

Чем доказываем, что не сломали

9.1. Что удалить

test_openrouter_proxy_offline.py, test_openrouter_rpm.py, test_openrouter_stream_retry.py — про прокси и RPM, оба механизма уходят.

9.2. Что переписать и добавить

Тесты в репозитории — ad-hoc скрипты, запускаются как python test_<name>.py из backend/. Держимся этой формы.

СкриптПроверяет
test_own_llm_client.pyсборка payload, reasoning_effort вместо reasoning, retry, fail-fast 4xx, circuit breaker, учёт в finally — на моках
test_llm_admission.pyприоритеты, лиз с TTL, освобождение на GeneratorExit, деградация без Redis
test_own_llm_live.pyживые: /api/tags, стрим с инструментами, форс tool_choice, response_format, vision на webp, thinking выключен
test_agent_llm_stream.py, test_langchain_openrouter.py, test_batch_translation.py, test_llm_spend_accounting.pyсуществующие — подправить импорты и имена, логику не менять

9.3. Eval агента — обязателен перед выкаткой

Набор из 30 реплик реальных диалогов; материал есть — 1 136 пользовательских сообщений в chat_messages прода. На каждой фиксировать:

#МетрикаЦелевое
1вызван ли ожидаемый инструмент (search_cars / list_brands / …)≥ 85 %
2корректность аргументов — страна, марка, модель, цена≥ 90 %
3полнота извлечения фильтров экстрактором против ручной разметки≥ 90 %
4время до первого токена, p95≤ 2 с
5время полного ответа, p95≤ 20 с

Сравнение — с текущим gemini-3-flash на тех же репликах. Пункты 1–3 ниже целевых → правка prompts.py под Qwen, а не откат.


10 Конфигурация и выкатка

Пять волн с обратимым откатом

10.1. Переменные окружения

# --- Собственный LLM ---
LLM_PROVIDER=own                       # own | openrouter (килсвитч, Р-7)
LLM_BASE_URL=https://llm.salam0nn.ru
LLM_API_TOKEN=<токен>                  # /root/llm-bearer-token.txt на сервере salam0nn
LLM_MODEL=qwen3.5:35b                  # одна модель на всё (Р-2)
LLM_ALLOW_THINKING=0                   # 1 — вернуть thinking (по результатам eval)
LLM_MAX_CONCURRENCY=1                  # ёмкость сервера
LLM_SLOT_LEASE_TTL=300
LLM_QUEUE_TIMEOUT_INTERACTIVE=45
LLM_QUEUE_TIMEOUT_BATCH=20
LLM_WARMUP_ENABLED=1                   # beat-грелка префикса (§7.1)

# --- Совместимость (модель теперь общая) ---
AGENT_LLM_MODEL=qwen3.5:35b
AGENT_EXTRACTOR_LLM_MODEL=
TRANSLATION_LLM_MODEL=
OPENROUTER_VISION_MODEL=qwen3.5:35b

# --- Удаляются после релиза N+1 ---
# OPENROUTER_API_KEY, OPENROUTER_MAX_RPM, OPENROUTER_PROXY, OPENROUTER_PROXY_SITE_SOURCES

docker-compose.yml: заменить блок OPENROUTER_* на LLM_* во всех пяти сервисах — стр. 161, 438, 632, 767, 900: бэкенд, ws и три воркера. deploy.yml: секрет OPENROUTER_API_KEY → LLM_API_TOKEN (стр. 50, 308). app/__init__.py:349-352 — пробрасывать LLM_API_TOKEN.

Грабля деплоя · audit-tasks/44-env-regenerated-on-deploy

.env на проде пересобирается из секретов GitHub Actions. Добавить LLM_* в секреты до выкатки, иначе после первого же деплоя переменные исчезнут и LLM отвалится целиком.

10.2. Волны

ВолнаСодержаниеПроверка готовности
В0Секреты в GitHub Actions, LLM_* в compose, снятие мёртвых гардов на ключ (§6.7). Код OpenRouter ещё боевойДеплой прошёл, поведение не изменилось
В1Клиент, очередь допуска, учёт. LLM_PROVIDER=openrouter — новый код на бою, но не активенЮнит-тесты, живые тесты §9.2
В2Не-интерактивные потребители: перевод, л.с., коды опций, VLM-фото. Переключаются первыми — у них есть детерминированные фолбэкиСутки на бою: доля успеха перевода не ниже прежней, queue_wait_ms p95 < 5 с
В3Агент: ход, экстрактор, резолверы, оценка цены, подводка к топ-3. Только после eval (§9.3)Eval ≥ целевых, ручной прогон 10 диалогов
В4Удаление кода OpenRouter, прокси, ключей, тестов; правка админки и frontend-admin; обновление agent-knowledge/09-agent.md §9 и §11, CONTEXT.md, .env.examplegrep -ri openrouter backend/ — только исторические докстринги

Откат. В2 и В3 — LLM_PROVIDER=openrouter плюс рестарт бэкенда и воркеров: код обоих провайдеров жив до В4. После В4 откат — только возврат коммита.


11 Риски

Что может пойти не так

#РискОценкаЧто делать
Р1Единая точка отказа: домашний макмини за NAT. Выключили свет, уснул мак, упал Tailscale — LLM недоступен целикомвысокий главный риск проектаCircuit breaker и фолбэки уже есть везде: перевод → Google, л.с. → пропуск, экстрактор → инструменты агента. Для агента фолбэка нет — ход вернёт ошибку. Решение — §12, В-1
Р2Качество вызова инструментов ниже, чем у Gemini (§7.3)среднийEval и правка промптов; страховки в коде уже есть
Р3Рост нагрузки упрётся в один слотсредний потолок ~250 ходов/суткиОчередь допуска даёт честную деградацию, а не хаос. Распараллелить нельзя (замер на маке 09.09): Ollama форсит -np 1, в логе architecture=qwen35moe … does not currently support parallel requests — гибрид Transformer+SSM. Рост — только другая модель без SSM либо вторая машина
Р4Задержка Tailscale через DERP-релей (Хельсинки)низкийЗамер «прод → ответ 1,58 с» уже включает релей. Прямой P2P обычно поднимается сам
Р5Ротация токена ломает прод молчанизкийТокен в секретах GitHub; llm-health в админке покажет 401
Р6.env пересобирается на деплоесредний известная грабляВолна В0 обязательна до всех прочих

12 Открытые вопросы

Решает человек, не разработчик

В-1. Что делать агенту, когда свой LLM недоступен?

  • (а) честная ошибка пользователю «ассистент временно недоступен» — просто, бесплатно;
  • (б) оставить OpenRouter как аварийный провайдер за флагом, не удалять в В4 — надёжнее, но требует живого ключа с деньгами;
  • (в) поднять маленькую резервную модель на самом проде (CPU) — только под экстрактор; ход агента на CPU не потянуть.
Рекомендация (б) на первый месяц, затем пересмотреть по статистике доступности.

В-2. Продуктовая планка задержки

Ход агента вырастет с ~3 с до ~11–12 с, первый токен — 1,1 с. Приемлемо? Если нет — рассматривать слияние экстрактора с основным ходом (минус ~4 с, ценой возврата к «сказал красную — забыла записать») либо сокращение системного промпта: сейчас 37 199 символов, каждые убранные 2,3 тыс. символов ≈ 1 тыс. токенов ≈ 1,8 с на холодном префиксе, на тёплом — почти ничего.

В-3. Оставлять ли платный STT (Groq)?

Голосовой ввод к OpenRouter отношения не имеет, но это второй внешний платный сервис. У Ollama замены нет.

В-4. Кто владеет доступностью макмини?

Нужен регламент: кто и как быстро поднимает сервис, есть ли уведомление о недоступности. Можно повесить на существующие Telegram-алерты.


13 Инварианты

Нарушение = тихая поломка прода

#Инвариант
И-1Все LLM-вызовы идут через get_llm_client(). Прямой requests.post на llm.salam0nn.ru мимо клиента ломает учёт, очередь и circuit breaker
И-2reasoning_effort — единственный рабочий выключатель thinking. Формы reasoning={'effort': …} / {'enabled': False} сервер игнорирует молча
И-3SYSTEM_PROMPT остаётся первым сообщением и байтово стабильным. Любая динамика — дата, фильтры, контекст поиска — следующими system-сообщениями. Нарушение превращает каждый ход из 8 с в 37 с
И-4Одна модель на все задачи. Смена модели в рантайме = 8 с загрузки плюс потеря KV-кеша
И-5Пакетный вызов не должен занимать слот дольше ~5 с — иначе интерактивный ход ждёт
И-6Сбой LLM никогда не роняет ход агента, парсинг или публикацию объявления — только деградация к фолбэку

14 Трассируемость

Требование → файлы

ТребованиеРазделФайлы
Транспорт на свой сервер4.2llm/own_llm_client.py (новый), llm/__init__.py
Выключение thinking4.2, И-2own_llm_client._thinking
Очередь на один слот5llm/admission.py (новый), distributed_rate_limiter.py
Убрать egress-проксиР-4llm/openrouter_proxy.py — удаление из LLM-пути
Агент и экстрактор6.2, 6.3agent_loop.py:518,1682, graph/nodes.py:347, graph/extractor.py:45,286
Перевод6.4llm/batch_translator.py:21,36,114
Оценка л.с.6.5horsepower_estimator.py:121,427
VLM-фото6.6listing_photo_llm.py:36,101
Мёртвые гарды на ключ6.7parser_manager.py:303, routes/translation.py:93, admin_api/translations.py:96, parser_routes.py:1105,1715
Учёт8llm/llm_call_recorder.py, миграция Alembic, admin_api/agent.py
Админка8.2frontend-admin/src/pages/{LlmSpendModal,AgentPage}.tsx
Конфигурация10.1config.py:70-96, docker-compose.yml ×5, deploy.yml:50,308, .env.example, app/__init__.py:349
Тесты9backend/test_own_llm_*.py, удаление test_openrouter_*.py
ДокументацияВ4agent-knowledge/09-agent.md §9, §11, CONTEXT.md, CLAUDE.md