- 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, но код второго пути живой и проверенный.

Во-вторых, все грабли, связанные с конкретными моделями, живут в одном файле на сотню с небольшим строк, а не размазаны по сервису.

В-третьих, тесты. Об этом ниже.

Две модели — два способа получить JSON

С 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-модели сначала думают, а потом отвечают. У части провайдеров размышления приходят в отдельном поле 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

www.BrainTools.ru

Rambler's Top100