Языковая модель в поиске магазина: как подключить её так, чтобы её падение никто не заметил

В прошлой статье я рассказывал, как устроено ядро нашего поиска: триграммы, crc32, словарь болей и два сита. В конце был короткий раздел «А где же нейросеть?», и он заслуживает отдельного разговора. Сегодня про то, как модель встроена сверху и почему мы с самого начала исходили из того, что она когда-нибудь подведёт.
Коротко о контексте. Мы делаем поиск для сайтов, которому человек пишет своими словами: «нужно вырыть траншею под кабель на даче», «подарок папе, он рыбачит». Без модели поиск работает на словаре и векторах. С моделью он начинает понимать, чего человек хочет, задаёт уточняющий вопрос и объясняет, почему предложил именно этот вариант.
Одна дверь
Первое решение, которое оказалось самым полезным: в коде есть ровно одно место, которое разговаривает с моделью. Одна функция:
def complete_json(system: str, user: str, schema: dict,
effort: str = "medium", max_tokens: int = 8000) -> dict | None:
"""Один вызов LLM с JSON-ответом по схеме. None при любой проблеме."""Контракт у неё жёсткий: на вход системный промпт, текст и JSON-схема, на выход словарь или None. Никаких исключений наружу. Нет ключа, сеть упала, модель ответила ерундой, кончились лимиты — во всех случаях вызывающий код получает None и обязан знать, что делать дальше.
Звучит банально, но у этого решения три приятных последствия.
Во-первых, провайдер меняется одной строкой в настройках. У нас два пути: Claude со строгой JSON-схемой и любой OpenAI-совместимый эндпоинт. Сейчас в бою DeepSeek, но код второго пути живой и проверенный.
Во-вторых, все грабли, связанные с конкретными моделями, живут в одном файле на сотню с небольшим строк, а не размазаны по сервису.
В-третьих, тесты. Об этом ниже.
Две модели — два способа получить JSON
С Claude всё скучно: схема уходит в параметры запроса, и ответ приходит валидным JSON по этой схеме. Отдельно проверяем только отказ модели отвечать: если stop_reason == "refusal", пишем в лог и возвращаем None.
С OpenAI-совместимыми эндпоинтами всё интереснее, потому что «совместимые» они в разной степени. Схему мы отправляем просто текстом в конце промпта:
prompt = (
f"{user}\n\nОтветь ТОЛЬКО валидным JSON, без пояснений и markdown, "
f"строго по этой JSON-схеме:\n{json.dumps(schema, ensure_ascii=False)}"
)Плюс response_format: json_object и низкая температура. Это не гарантия, а просьба. Поэтому ответ разбирается мягко:
def _parse_json_loose(text: str) -> dict | None:
text = (text or "").strip()
if "</think>" in text: # reasoning-модели
text = text.split("</think>")[-1].strip()
m = re.search(r"\{.*\}", text, re.S)
if not m:
return None
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return NoneВыглядит грубо, и это сознательно. Модель может обернуть ответ в тройные кавычки с json, может написать «Вот ваш ответ:» перед скобкой, может приложить рассуждения. Нам нужно содержимое между первой { и последней }, всё остальное не интересно.
Грабля с reasoning-моделями
Reasoning-модели сначала думают, а потом отвечают. У части провайдеров размышления приходят в отдельном поле reasoning_content, а content в это время пустой. Если лимит токенов небольшой, модель успевает только подумать: всё ушло в рассуждения, на ответ не осталось. Снаружи это выглядит как пустой ответ без всякой ошибки. Статус 200, JSON от API валидный, content пустой.
Самое неприятное, что поиск при этом не ломается. Он тихо уходит в режим без модели, и единственный симптом — «что-то стал хуже понимать запросы».
Лечение в два слоя. Первый: просим модель не рассуждать там, где рассуждения не нужны:
body = {
"model": settings.llm_model,
"temperature": 0.2,
"reasoning_effort": "none",
"response_format": {"type": "json_object"},
...
}Второй: не все эндпоинты знают эти параметры. Кто-то отвечает на незнакомый параметр ошибкой 400. Поэтому:
r = httpx.post(url, json=body, headers=headers, timeout=90)
if r.status_code == 400: # не все модели умеют эти параметры
for param in ("response_format", "reasoning_effort"):
if param in r.text:
body.pop(param, None)
r = httpx.post(url, json=body, headers=headers, timeout=90)Если в тексте ошибки упоминается параметр, выкидываем его и повторяем один раз. Не элегантно, зато эндпоинт можно менять без правки кода.
И на всякий случай ответ берём из content, а если там пусто — из reasoning_content. Иногда JSON лежит именно там.
Тихий откат — не всегда хорошо
В первой версии любая ошибка модели гасилась молча: лог, None, поиск работает по словарю. Посетитель ничего не замечает, это и было целью.
Проблема в том, что владелец сайта тоже ничего не замечает. И мы тоже. Ключ протух, баланс у провайдера закончился, а поиск неделю работает без модели, и единственный сигнал — субъективное «вроде стал глупее».
Теперь каждый сбой провайдера, кроме записи в лог, попадает в раздел инцидентов в админке с высокой важностью: какой провайдер, какая модель, текст ошибки. Посетителю по-прежнему всё равно, а нам нет.
Мораль, которую я бы повесил над столом: fallback, о котором никто не знает, — это просто медленно накапливающийся баг.
Не верить модели на слово
Модель отвечает JSON по схеме, но это не значит, что ей можно доверять содержимое. Каждый ответ проходит проверку в коде, и вот несколько правил, которые появились после конкретных случаев.
Выбор только из того, что предложили. Когда модель выбирает лучший вариант из кандидатов, она возвращает его external_id. Если такого id нет среди кандидатов, ответ выбрасывается, и обоснование строится по шаблону:
if data and any(c["item"].external_id == data.get("external_id") for c in candidates):
return data
return _template_focus(task_summary, candidates)Модели иногда «вспоминают» товар, которого в выдаче не было, или слегка искажают id. Покупатель, которому посоветовали несуществующий товар, хуже покупателя, которому не посоветовали ничего.
Лимиты держит код, а не промпт. В промпте написано, сколько уточняющих вопросов можно задать. Но если вопросов уже задано достаточно, код обнуляет вопрос, что бы модель ни ответила. Тегов — не больше десяти, даже если модель вернула тридцать.
Подозрительные поля — только при условии. Модель умеет возвращать список того, чего покупатель не хочет: «не китайское», «не б/у». Это поле мы слушаем, только когда у площадки есть явные правила-поправки. Без них это чаще всего фантазия модели, а каждая такая фантазия — исчезнувшие из выдачи карточки.
Тон — тоже правила. Объяснения выбора модель пишет для покупателя, и первые версии звучали как отчёт системы: «я подобрал по тегам», «рейтинг как показатель». Теперь в промпте прямой запрет говорить о тегах, полях, каталоге и о себе, и требование говорить только о самой вещи: чем она подходит и чем отличается от соседних.
Без модели — не значит плохо
Раз модель может пропасть в любой момент, у каждой функции, которая её использует, есть вариант без неё. Не заглушка «сервис недоступен», а нормальное поведение.
| Что делает модель | Что происходит без неё | |---|---| | теги-боли для карточек при индексации | теги из словаря | | разбор запроса | теги из словаря и подсказки по задаче | | уточняющий вопрос | вопрос не задаётся | | выбор лучшего с обоснованием | шаблон по фактам карточки | | ответ «почему именно он» | шаблон: рейтинг, опыт, цена рядом с соседями |
Шаблоны — отдельная история. Пишешь «34 выполненных заказов», и текст сразу выдаёт робота. Поэтому даже у шаблонной фразы есть функция склонения для «1 заказ», «2 заказа», «11 заказов». Мелочь, но именно на таких мелочах человек решает, разговаривает он с чем-то вменяемым или нет.
Индексация устроена так же. Карточки уходят в модель пачками по 25. Если модель ответила не на все или пропустила какие-то id, недостающие карточки добирают теги из словаря. Пустых карточек после индексации не бывает.
Как тестировать код, который зовёт модель
Модель в тестах — плохая идея: медленно, платно, ответы плавают. А проверять нужно как раз логику вокруг неё: что делает диалог, если модель решила, что человек выбирает вариант, а не начинает новый поиск.
Благодаря одной двери подмена тривиальная:
def fake_complete_json(system, user, schema, effort="medium", max_tokens=8000):
if "intent" in schema.get("properties", {}):
return {"intent": NEXT["intent"], "question": NEXT.get("question"), ...}
if "external_id" in schema.get("properties", {}):
return None # пусть выбор уйдёт в шаблон
return None
llm.available = lambda: True
llm.complete_json = fake_complete_jsonФейк смотрит на схему и понимает, какой вопрос ему задали. Дальше тест через обычный HTTP-клиент гоняет диалог и проверяет ветвление: «новая задача», «уточнение», «выбор», «подтверждение», болтовня. Отдельно проверяется, что будет, если модель вернула None на выборе: обоснование должно прийти из шаблона, а не пустой строкой.
И, конечно, эталон качества поиска, про который я писал в прошлый раз, гоняется без модели. Если базовый слой хорош сам по себе, модель его только улучшает. Если базовый слой держится на модели, вы узнаете об этом в день, когда у провайдера будут проблемы.
Что не получилось или получилось так себе
Схема текстом — это просьба. Мягкий парсер спасает от обёрток, но не от ответа с пропущенным обязательным полем. Каждое место, которое читает ответ, проверяет поля руками. Это многословно, и иногда что-то забывается.
Повтор при ошибке 400 опирается на текст ошибки. Если провайдер поменяет формулировку, параметр не выкинется, и запрос будет падать. Ловится инцидентом, но не сразу автоматически.
Шаблоны беднее модели. Без модели объяснение выбора звучит суше, уточняющих вопросов нет совсем. Для размытых запросов разница заметна.
Таймаут в 90 секунд — компромисс. Для индексации пачкой нормально, для живого диалога долго. Пока это решается тем, что диалоговые вызовы короткие по объёму ответа, но в целом это стоит развести.
Вместо заключения
Если сжать всё в одну мысль: модель стоит подключать как внешний сервис, который может не ответить, ответить неправду или ответить в неожиданном формате. Одна дверь в код, проверка каждого ответа, нормальный режим без модели и громкий сигнал, когда она пропала. Тогда она делает продукт лучше, а не хрупче.
Посмотреть, как это работает вживую, можно в AISSE: на демо-витринах поиск отвечает на запросы своими словами.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.