- BrainTools - https://www.braintools.ru -
В прошлой статье я рассказывал, как устроено ядро нашего поиска: триграммы, 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, но код второго пути живой и проверенный.
Во-вторых, все грабли, связанные с конкретными моделями, живут в одном файле на сотню с небольшим строк, а не размазаны по сервису.
В-третьих, тесты. Об этом ниже.
С Claude всё скучно: схема уходит в параметры запроса, и ответ приходит валидным JSON по этой схеме. Отдельно проверяем только отказ модели отвечать: если stop_reason == "refusal", пишем в лог и возвращаем None.
С OpenAI-совместимыми эндпоинтами всё интереснее, потому что «совместимые» они в разной степени. Схему мы отправляем просто текстом в конце промпта:
prompt = (
f"{user}nnОтветь ТОЛЬКО валидным 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_content, а content в это время пустой. Если лимит токенов небольшой, модель успевает только подумать: всё ушло в рассуждения, на ответ не осталось. Снаружи это выглядит как пустой ответ без всякой ошибки [1]. Статус 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)
Если в тексте ошибки упоминается параметр, выкидываем его и повторяем [2] один раз. Не элегантно, зато эндпоинт можно менять без правки кода.
И на всякий случай ответ берём из 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. Покупатель, которому посоветовали несуществующий товар, хуже покупателя, которому не посоветовали ничего.
Лимиты держит код, а не промпт. В промпте написано, сколько уточняющих вопросов можно задать. Но если вопросов уже задано достаточно, код обнуляет вопрос, что бы модель ни ответила. Тегов — не больше десяти, даже если модель вернула тридцать.
Подозрительные поля — только при условии. Модель умеет возвращать список того, чего покупатель не хочет: «не китайское», «не б/у». Это поле мы слушаем, только когда у площадки есть явные правила-поправки. Без них это чаще всего фантазия модели, а каждая такая фантазия — исчезнувшие из выдачи карточки.
Тон — тоже правила. Объяснения выбора модель пишет для покупателя, и первые версии звучали как отчёт системы: «я подобрал по тегам», «рейтинг как показатель». Теперь в промпте прямой запрет говорить о тегах, полях, каталоге и о себе, и требование говорить только о самой вещи: чем она подходит и чем отличается от соседних.
Раз модель может пропасть в любой момент, у каждой функции, которая её использует, есть вариант без неё. Не заглушка «сервис недоступен», а нормальное поведение [3].
| Что делает модель | Что происходит без неё | |—|—| | теги-боли для карточек при индексации | теги из словаря | | разбор запроса | теги из словаря и подсказки по задаче | | уточняющий вопрос | вопрос не задаётся | | выбор лучшего с обоснованием | шаблон по фактам карточки | | ответ «почему именно он» | шаблон: рейтинг, опыт [4], цена рядом с соседями |
Шаблоны — отдельная история. Пишешь «34 выполненных заказов», и текст сразу выдаёт робота. Поэтому даже у шаблонной фразы есть функция склонения для «1 заказ», «2 заказа», «11 заказов». Мелочь, но именно на таких мелочах человек решает, разговаривает он с чем-то вменяемым или нет.
Индексация устроена так же. Карточки уходят в модель пачками по 25. Если модель ответила не на все или пропустила какие-то id, недостающие карточки добирают теги из словаря. Пустых карточек после индексации не бывает.
Модель в тестах — плохая идея: медленно, платно, ответы плавают. А проверять нужно как раз логику [5] вокруг неё: что делает диалог, если модель решила, что человек выбирает вариант, а не начинает новый поиск.
Благодаря одной двери подмена тривиальная:
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 [6]: на демо-витринах поиск отвечает на запросы своими словами.
Автор: Haimi
Источник [7]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/36613
URLs in this post:
[1] ошибки: http://www.braintools.ru/article/4192
[2] повторяем: http://www.braintools.ru/article/4012
[3] поведение: http://www.braintools.ru/article/9372
[4] опыт: http://www.braintools.ru/article/6952
[5] логику: http://www.braintools.ru/article/7640
[6] AISSE: https://aisse.ru
[7] Источник: https://habr.com/ru/articles/1091368/?utm_campaign=1091368&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.