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.
Что проверено живьём, а не предположено
Замеры 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 | Место в коде | Тип вызова | Промпт, ток. |
|---|---|---|---|---|
| 1 | agent_turn | agent_loop.py:1682 · graph/nodes.py:347 | стрим + tools, reasoning={'effort':'low'} | ср. 16 304 макс. 18 192 |
| 2 | agent_turn | agent_loop.py:1197 _stream_directed | стрим без инструментов | — |
| 3 | agent_extractor | graph/extractor.py:350 | форс tool_choice, max_tokens=1000, таймаут 8 с | ср. 4 305 |
| 4 | agent_price_estimate | price_estimator.py:99 | форс tool_choice | мал |
| 5 | brand_resolve | brand_resolver_llm.py:75 | блокирующий, retry=False | мал |
| 6 | model_resolve | model_resolver_llm.py:74 | блокирующий, retry=False | мал |
| 7 | top3_explanation | tasks/agent_tasks.py:267 | стрим | ~1 100 |
| 8 | translation_batch | llm/batch_translator.py:114 | блокирующий, reasoning={'enabled':False} | ср. 334 |
| 9 | translation_batch | translation_service.py:4854 | через (8) | — |
| 10 | horsepower | horsepower_estimator.py:427 | блокирующий, модель deepseek/deepseek-chat | ср. 235 |
| 11 | options_translate | distributed_parser_tasks.py:1092 | блокирующий, google/gemini-flash-1.5 | мал |
| 12 | listing_photo_check | listing_photo_llm.py:101 | vision, 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.tsx | UI расхода в 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-96 | AGENT_LLM_MODEL, AGENT_EXTRACTOR_LLM_MODEL, TRANSLATION_LLM_MODEL, TRANSLATION_BATCH_LLM |
docker-compose.yml | OPENROUTER_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. Паспорт
| Параметр | Значение |
|---|---|
| Базовый URL | https://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-alive | 24 ч |
| Параллельность | 1 запрос за раз FIFO, без приоритетов |
| Thinking | включён по умолчанию; выключается только reasoning_effort: "none" на /v1 |
2.2. Производительность qwen3.5:35b
2.3. Замеры на реальных payload'ах проекта
| Сценарий | Холодный префикс | Тёплый префикс |
|---|---|---|
| Ход агента — 12,3k промпт + 6,5k инструментов = 18,8k ток. | 37,4 с | 6,4–8,0 с длинный ответ — 13,4 с |
Экстрактор фильтров — 5,0k ток., форс tool_choice | 12,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. Три находки, определяющие дизайн
reasoning_effort: "none" обязателенНаши текущие параметры reasoning={…} — диалект OpenRouter, Ollama их молча
игнорирует. Цена ошибки на примере перевода:
| время | completion-токенов | переведено | |
|---|---|---|---|
| thinking включён — то, что будет «из коробки» | 24,6 с | 1300 весь бюджет в размышление | 0 / 16 |
reasoning_effort: "none" | 3,5 с | 135 | 16 / 16 |
Скрытое размышление не утекает в content — уезжает в отдельное
поле reasoning, то есть пользователю в чат не попадёт. Но оно съедает
max_tokens и время, а у узких вызовов с малым бюджетом (экстрактор — 1000,
резолверы — сотни) выжигает его целиком и возвращает пустоту. Это ровно те грабли, что
описаны в langchain_openrouter.py для model_resolver.
Сервер держит несколько префиксов одновременно. Чередование «агент (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.
Замер: перевод, который соло идёт 2,0 с, за спиной хода агента (13,4 с) ждал 14,6 с — плюс 12,5 с чистого ожидания. Ollama очередь не приоритизирует. Значит, приоритеты — наша забота (§5).
Проверено на самом макмини 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_batch | 2 583 | 216 | 334 | 52 | 0,765 |
horsepower | 92 | 3 | 235 | 42 | 0,010 |
agent_turn | 9 | 0 | 16 304 | 532 | 0,088 |
agent_extractor | 5 | 0 | 4 305 | 47 | 0,012 |
| Итого | 2 689 | 219 | ≈ 0,87 |
Пиковый час за всё время наблюдений — 408 вызовов, из них 400 перевод (18.08.2026, 08:00, парс-пик). Диалогов агента за историю — 313, сообщений пользователей — 1 136.
Ёмкость на этой нагрузке
Средний перевод на нашем сервере занимает слот ≈ 2,0 с. Пиковый час: 400 × 2,0 с = 800 с работы на 3 600 с часа — около 22 % занятости слота. Запас есть.
Но есть два разрыва:
- Чанк перевода —
DEFAULT_CHUNK_SIZE = 50(batch_translator.py:21), а максимум ответа в логах — 627 токенов ≈ 13 с генерации. Такой чанк держит слот 13 с, и ход агента, пришедший следом, ждёт всё это время. Отсюда Р-5 и §5.3. - Текущий трафик агента ничтожен — 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 без фенсов |
| Р-3 | reasoning_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) |
| Р-8 | Circuit 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 | Кто | При занятом слоте |
|---|---|---|---|
| Интерактивная | INTERACTIVE | agent_turn, agent_extractor, agent_price_estimate, brand_resolve, model_resolve | Встаёт в голову очереди, ждёт до LLM_QUEUE_TIMEOUT_INTERACTIVE — по умолчанию 45 с |
| Отзывчивая | NEAR_RT | top3_explanation, listing_photo_check | Ждёт до 60 с |
| Пакетная | BATCH | translation_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. Новый клиент и его подключение
- Создать
services/llm/own_llm_client.py(§4.2) иservices/llm/admission.py(§5). services/llm/__init__.py— экспортироватьget_llm_client()как основную точку;get_openrouter_clientоставить алиасом на один релиз (Р-7), чтобы не ломать импорты.- Заменить 12 импортов
from services.llm.openrouter_client import get_openrouter_clientнаfrom services.llm import get_llm_client— список в §1.1. 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_SIZE50 → 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 с на загрузку модели, если её вытеснили.
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_ms | Integer | время ответа сервера |
queue_wait_ms | Integer | ожидание слота (§5) |
provider | String(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.
.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.example | grep -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 |
| И-2 | reasoning_effort — единственный рабочий выключатель thinking. Формы reasoning={'effort': …} / {'enabled': False} сервер игнорирует молча |
| И-3 | SYSTEM_PROMPT остаётся первым сообщением и байтово стабильным. Любая динамика — дата, фильтры, контекст поиска — следующими system-сообщениями. Нарушение превращает каждый ход из 8 с в 37 с |
| И-4 | Одна модель на все задачи. Смена модели в рантайме = 8 с загрузки плюс потеря KV-кеша |
| И-5 | Пакетный вызов не должен занимать слот дольше ~5 с — иначе интерактивный ход ждёт |
| И-6 | Сбой LLM никогда не роняет ход агента, парсинг или публикацию объявления — только деградация к фолбэку |
14 Трассируемость
Требование → файлы
| Требование | Раздел | Файлы |
|---|---|---|
| Транспорт на свой сервер | 4.2 | llm/own_llm_client.py (новый), llm/__init__.py |
| Выключение thinking | 4.2, И-2 | own_llm_client._thinking |
| Очередь на один слот | 5 | llm/admission.py (новый), distributed_rate_limiter.py |
| Убрать egress-прокси | Р-4 | llm/openrouter_proxy.py — удаление из LLM-пути |
| Агент и экстрактор | 6.2, 6.3 | agent_loop.py:518,1682, graph/nodes.py:347, graph/extractor.py:45,286 |
| Перевод | 6.4 | llm/batch_translator.py:21,36,114 |
| Оценка л.с. | 6.5 | horsepower_estimator.py:121,427 |
| VLM-фото | 6.6 | listing_photo_llm.py:36,101 |
| Мёртвые гарды на ключ | 6.7 | parser_manager.py:303, routes/translation.py:93, admin_api/translations.py:96, parser_routes.py:1105,1715 |
| Учёт | 8 | llm/llm_call_recorder.py, миграция Alembic, admin_api/agent.py |
| Админка | 8.2 | frontend-admin/src/pages/{LlmSpendModal,AgentPage}.tsx |
| Конфигурация | 10.1 | config.py:70-96, docker-compose.yml ×5, deploy.yml:50,308, .env.example, app/__init__.py:349 |
| Тесты | 9 | backend/test_own_llm_*.py, удаление test_openrouter_*.py |
| Документация | В4 | agent-knowledge/09-agent.md §9, §11, CONTEXT.md, CLAUDE.md |