2026-09-24 01:10:49 +03:00
|
|
|
|
"""Клиент совместимого API для локальных или внешних моделей.
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
|
|
|
|
|
|
Кэш ответов по хешу контекста лежит в Postgres, а не в Redis: база уже поднята,
|
|
|
|
|
|
лишняя движущаяся часть на стенде не нужна (docs/arch/STACK.md). Кэш работает
|
|
|
|
|
|
и онлайн — экономия и ускорение повторов, — и как накопитель материала
|
|
|
|
|
|
для офлайн-дерева.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import re
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
from dataclasses import dataclass
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from ipaddress import ip_address
|
|
|
|
|
|
from urllib.parse import urlsplit
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
|
|
|
|
|
|
|
|
|
|
from app.config import get_settings
|
|
|
|
|
|
from app.db.models import LlmCache
|
|
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-28 21:14:38 +00:00
|
|
|
|
#: Имена сервисов моделей из корневого docker-compose.yml. Однословное имя
|
|
|
|
|
|
#: разрешает только DNS внутренней сети Compose, наружу оно не уходит.
|
|
|
|
|
|
DOCKER_MODEL_HOSTS = frozenset({"host.docker.internal", "llm-qwen", "llm-vikhr"})
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
class LlmUnavailable(RuntimeError):
|
|
|
|
|
|
"""Сеть, ключ или провайдер отказали. Звонящий откатывается на заготовки,
|
|
|
|
|
|
занятие продолжается — молчащий звонящий хуже шаблонной фразы."""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
def is_loopback_url(value: str, *, allow_docker_host: bool = False) -> bool:
|
|
|
|
|
|
"""В офлайн-режиме модели разрешены лишь на той же машине.
|
|
|
|
|
|
|
|
|
|
|
|
Не доверяем доменам или hosts-записям: они могут указывать наружу.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
url = urlsplit(value)
|
|
|
|
|
|
host = url.hostname or ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
local_host = ip_address(host).is_loopback
|
|
|
|
|
|
except ValueError:
|
2026-09-28 21:14:38 +00:00
|
|
|
|
local_host = allow_docker_host and host in DOCKER_MODEL_HOSTS
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return (url.scheme == "http" and local_host and url.port is not None
|
|
|
|
|
|
and not url.username and not url.password)
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _spoken_content(raw: str, *, strip_reasoning: bool = False) -> str:
|
|
|
|
|
|
"""Убрать только пустой служебный хвост Qwen3, не рассуждения модели.
|
|
|
|
|
|
|
|
|
|
|
|
llama.cpp с выключенным thinking иногда возвращает в `content` один
|
|
|
|
|
|
закрывающий `</think>` перед самой репликой. Внутренний текст размышлений
|
|
|
|
|
|
мы намеренно не вырезаем: если он есть, ответ небезопасен и идёт fallback.
|
|
|
|
|
|
"""
|
|
|
|
|
|
text = raw.strip()
|
|
|
|
|
|
if strip_reasoning:
|
|
|
|
|
|
closing = list(re.finditer(r"</think>\s*", text, re.IGNORECASE))
|
|
|
|
|
|
if closing:
|
|
|
|
|
|
text = text[closing[-1].end():].strip()
|
|
|
|
|
|
elif text.startswith("<|"):
|
|
|
|
|
|
start = text.find("{")
|
|
|
|
|
|
if start >= 0:
|
|
|
|
|
|
text = text[start:].strip()
|
|
|
|
|
|
text = re.sub(r"^(?:</think>\s*)+", "", text, flags=re.IGNORECASE).strip()
|
|
|
|
|
|
if re.search(r"</?think\b", text, re.IGNORECASE) or "<|" in text:
|
|
|
|
|
|
raise LlmUnavailable("ответ содержит служебные токены модели")
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class LlmRequest:
|
|
|
|
|
|
messages: list[dict]
|
|
|
|
|
|
model: str
|
|
|
|
|
|
temperature: float = 0.8
|
|
|
|
|
|
# С запасом на рассуждающие модели: Qwen3 тратит на размышление сотни токенов
|
|
|
|
|
|
# и при малом бюджете возвращает пустой ответ с finish_reason="length".
|
|
|
|
|
|
max_tokens: int = 400
|
2026-09-24 01:10:49 +03:00
|
|
|
|
response_format: dict | None = None
|
|
|
|
|
|
# Только для внутренних структурированных задач. В репликах звонящего
|
|
|
|
|
|
# рассуждение всегда отвергается, чтобы оно не попало в эфир.
|
|
|
|
|
|
strip_reasoning: bool = False
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
|
|
|
|
|
|
def cache_key(self) -> str:
|
|
|
|
|
|
payload = json.dumps(
|
2026-09-24 01:10:49 +03:00
|
|
|
|
{"m": self.model, "t": self.temperature, "msgs": self.messages,
|
|
|
|
|
|
"format": self.response_format, "strip_reasoning": self.strip_reasoning},
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return hashlib.sha256(payload.encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LlmClient:
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
sessionmaker: async_sessionmaker | None = None,
|
|
|
|
|
|
transport: httpx.AsyncBaseTransport | None = None,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
base_url: str | None = None,
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
# Ответ дольше этого бессмысленен: бюджет хода — 1.5 с, а звонящий
|
|
|
|
|
|
# с заготовками ответит сразу.
|
|
|
|
|
|
timeout: float = 8.0,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
settings = get_settings()
|
2026-09-24 01:10:49 +03:00
|
|
|
|
self._base_url = (base_url or settings.llm_base_url).rstrip("/")
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
self._key = settings.llm_api_key
|
2026-09-24 01:10:49 +03:00
|
|
|
|
self._local_only = settings.offline or settings.llm_provider == "local"
|
|
|
|
|
|
self._allow_docker_host = settings.allow_docker_host_models
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
self._sessionmaker = sessionmaker
|
2026-09-24 01:10:49 +03:00
|
|
|
|
self._client = httpx.AsyncClient(timeout=timeout, transport=transport, trust_env=False)
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def configured(self) -> bool:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if self._local_only:
|
|
|
|
|
|
return is_loopback_url(
|
|
|
|
|
|
self._base_url, allow_docker_host=self._allow_docker_host
|
|
|
|
|
|
)
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
return bool(self._key and self._base_url)
|
|
|
|
|
|
|
|
|
|
|
|
async def complete(self, request: LlmRequest, *, use_cache: bool = True) -> str:
|
|
|
|
|
|
"""Ответ модели. Кэш по хешу контекста: та же реплика на том же месте
|
|
|
|
|
|
занятия звучит одинаково у каждой группы."""
|
|
|
|
|
|
if not self.configured:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
raise LlmUnavailable("локальный адрес модели недопустим или провайдер не настроен")
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
|
|
|
|
|
|
key = request.cache_key()
|
|
|
|
|
|
if use_cache:
|
|
|
|
|
|
cached = await self._from_cache(key)
|
|
|
|
|
|
if cached is not None:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return _spoken_content(cached, strip_reasoning=request.strip_reasoning)
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await self._client.post(
|
|
|
|
|
|
f"{self._base_url}/chat/completions",
|
2026-09-26 18:12:27 +03:00
|
|
|
|
# В локальном/offline-режиме ключ не нужен и не должен
|
|
|
|
|
|
# утекать даже в заголовок запроса к loopback-процессу.
|
|
|
|
|
|
headers=(
|
|
|
|
|
|
{"Authorization": f"Bearer {self._key}"}
|
|
|
|
|
|
if self._key and not self._local_only else {}
|
|
|
|
|
|
),
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
json={
|
|
|
|
|
|
"model": request.model,
|
|
|
|
|
|
"messages": request.messages,
|
|
|
|
|
|
"temperature": request.temperature,
|
|
|
|
|
|
"max_tokens": request.max_tokens,
|
|
|
|
|
|
# Рассуждение в ответе не нужно: оно только раздувает трафик.
|
|
|
|
|
|
# Провайдеры, которые про это поле не знают, его игнорируют.
|
|
|
|
|
|
"reasoning": {"exclude": True},
|
2026-09-24 01:10:49 +03:00
|
|
|
|
**({"response_format": request.response_format}
|
|
|
|
|
|
if request.response_format is not None else {}),
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
except httpx.HTTPError as exc:
|
|
|
|
|
|
raise LlmUnavailable(f"{type(exc).__name__}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
if response.status_code != 200:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
# Не включать тело провайдера: оно может повторить персональные
|
|
|
|
|
|
# факты из промпта и затем попасть в системный журнал вызывающего кода.
|
|
|
|
|
|
raise LlmUnavailable(f"HTTP {response.status_code}")
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
try:
|
|
|
|
|
|
message = response.json()["choices"][0]["message"]
|
|
|
|
|
|
content = message.get("content")
|
|
|
|
|
|
if content is not None and not isinstance(content, str):
|
|
|
|
|
|
raise TypeError("content не строка")
|
|
|
|
|
|
text = _spoken_content(content or "", strip_reasoning=request.strip_reasoning)
|
|
|
|
|
|
except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
|
|
|
|
|
|
raise LlmUnavailable("некорректный ответ локальной модели") from exc
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
if use_cache and text:
|
|
|
|
|
|
await self._to_cache(key, request, text)
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
async def aclose(self) -> None:
|
|
|
|
|
|
await self._client.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
# ── кэш ──
|
|
|
|
|
|
|
|
|
|
|
|
async def _from_cache(self, key: str) -> str | None:
|
|
|
|
|
|
if self._sessionmaker is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with self._sessionmaker() as db:
|
|
|
|
|
|
return await db.scalar(
|
|
|
|
|
|
select(LlmCache.response).where(LlmCache.context_hash == key)
|
|
|
|
|
|
)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
except Exception as exc: # noqa: BLE001 — без кэша занятие идёт, без базы тоже
|
|
|
|
|
|
log.warning("кэш LLM: чтение не удалось (%s)", type(exc).__name__)
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def _to_cache(self, key: str, request: LlmRequest, text: str) -> None:
|
|
|
|
|
|
if self._sessionmaker is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with self._sessionmaker() as db:
|
|
|
|
|
|
db.add(
|
|
|
|
|
|
LlmCache(
|
|
|
|
|
|
context_hash=key,
|
|
|
|
|
|
model=request.model,
|
|
|
|
|
|
prompt=json.dumps(request.messages, ensure_ascii=False)[:8000],
|
|
|
|
|
|
response=text,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
await db.commit()
|
2026-09-26 18:12:27 +03:00
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
|
# DB exception traces can include the cached prompt and response.
|
|
|
|
|
|
log.warning("кэш LLM: запись не удалась (%s)", type(exc).__name__)
|