Prompt — это код: как я учил LLM не выдумывать фильтры и звать SQL
Подключил локальную LLM к мониторингу OLT — и поймал три категории галлюцинаций. Решение оказалось не в апгрейде модели, а в дизайне tools и system prompt.
В первый день работы ассистента в системе мониторинга OLT бот выдал список ONU «в Алмати». Города «Алмати» в моей базе нет — есть «Алматы» (1200 ONU) и «Алма-Ата» (старое имя той же локации, 80 ONU). Модель решила, что это один город, придумала среднее имя и отдала пустой результат.
Это не баг модели. Это пробел в дизайне tools и system prompt'а. После трёх дней итераций каждая «странность» бота превратилась в commit в prompt — версионируемый, тестируемый, объяснимый.
Симптом 1: модель не зовёт SQL для агрегатов
Вопрос «сколько портов заполнено выше 75%?» бот честно пытался решить через olt.list — выгружал 1000 OLT, по каждому считал в голове, и либо галлюцинировал число, либо говорил «много».
Первая мысль — модель тупит. Реальная причина — у неё не было tool для агрегата. Она знала про olt.list и olt.get, но не знала, что можно посчитать без выгрузки списка. Поведение разумное: нет молотка — стучит ботинком.
Решение — tool под задачу:
{
"name": "olt.stats_by_olt",
"description": "Top-N агрегат: для каждого OLT считает порты с заполнением выше threshold. Возвращает массив (olt_id, name, port_count, total_ports), сортирует по убыванию.",
"params": {
"threshold": "double, 0..1, default 0.75",
"limit": "int, default 10"
}
}
Плюс сразу — inline schema в system prompt: «для агрегатов и top-N используй *.stats_by_* tools, не выгружай списки и не считай в голове». Через два часа итераций модель перестала пытаться суммировать olt.list руками.
Вывод: если модель не зовёт нужный tool — обычно tool'а просто нет, или его описание не отвечает на вопрос «когда меня звать».
Симптом 2: галлюцинации фильтров
«Покажи ONU в Алмати», «найди OLT вендора Huawei», «сколько устройств у оператора БайкалТел» — в моей системе нет ни «Алмати», ни Huawei среди поддерживаемых OLT-вендоров (только CDATA, BDCOM, GateRay), ни оператора с таким именем. Бот их выдумывал, а потом честно отдавал «ничего не найдено», скрывая, что фильтр был некорректный.
Promt-правка «не выдумывай города и вендоров» работала плохо — модель всё равно совмещала похожие. Решение оказалось архитектурным: дать модели способ узнать допустимые значения.
Tool olt.distinct_values:
{
"name": "olt.distinct_values",
"description": "Возвращает уникальные значения для поля. Используй ПЕРЕД любым фильтром, чтобы знать допустимые значения. Никогда не угадывай города/вендоров/операторов.",
"params": {
"field": "vendor | operator | city | po_version"
}
}
Плюс правило в prompt: «перед фильтром по city/vendor/operator — обязательно вызови olt.distinct_values, выбери ближайшее, спроси пользователя если неоднозначно». Поведение изменилось мгновенно: теперь бот сначала проверяет, есть ли «Алмати» в списке, не находит, и спрашивает «вы имеете в виду Алматы или Алма-Ата?».
Вывод: галлюцинации фильтров не лечатся отрицанием в prompt. Лечатся source-of-truth tool'ом и явной инструкцией звать его перед фильтром.
Симптом 3: внутренние id наружу + домен-знания
Два разных, но родственных бага:
Id-leak. Модель выводила olt_port_id: 4521, contract_id: 88273 в ответе пользователю. Полезно для отладки, бесполезно для оператора, и потенциально утечка договорных id. Полечилось одной строкой в prompt: «никогда не выводи поля, заканчивающиеся на _id. Только человекочитаемые: имя OLT, номер порта, ФИО абонента».
Пороги сигнала. Бот сам решал, что «-25 dBm — это плохой сигнал». В нашей системе bad < -27, warn -25..-27, good > -25. Модель брала числа из своих tetnings-данных, не из домена. Прописал пороги прямо в system prompt:
Пороги уровня сигнала ONU (Rx Optical Power, dBm):
good : > -25
warn : -25..-27 включительно
bad : < -27 (требует внимания)
fatal : < -30 (вероятно, обрыв)
Никогда не определяй "плохо/хорошо" сам. Всегда сверяйся с порогами выше.
Проблема ушла за один прогон.
Вывод: доменные значения, пороги, форматы — это часть тех. задания, не часть «общих знаний» LLM. Они должны жить в system prompt и обновляться вместе с продуктом.
Prompt как код: что это значит на практике
После трёх дней итераций мой system prompt превратился в полноценный артефакт со своей дисциплиной:
- Лежит в репозитории в
resources/prompts/system.md, не в строке Java-кода - Версионируется в git, ревью на PR'ах как обычный код
- Покрыт smoke-тестами: набор «канонических вопросов» с ожидаемыми tool-вызовами и проверкой, что модель не выдумала фильтр
- Каждое правило в prompt подкреплено комментарием «added 2026-05-02 — модель путала Алматы и Алма-Ата»
Это похоже на feature-config, чем на «креативный текст». Каждая фраза — реакция на конкретный наблюдаемый баг.
Что я понял
- Tool design > model size. Точность ответов выросла больше от добавления
distinct_valuestool, чем от любых попыток подобрать модель помощнее. - «Модель тупит» обычно означает «нет нужного tool». Если поведение странное — посмотри, чем модель располагает, прежде чем винить веса.
- Галлюцинации лечатся source-of-truth, не запретом. Дай tool, который возвращает правду — и пропиши обязанность его звать.
- Домен живёт в system prompt. Пороги, форматы, политики безопасности — всё, что специфично для продукта, должно быть в prompt'е, а не в надеждах на «здравый смысл LLM».
- System prompt — это код. Версионируй, тестируй, релизи. Не правь живьём в проде.
LLM не «понимает» твою предметную область. Она понимает свою — общечеловеческую. Расстояние между ними закрывается system prompt'ом и tools, а не выбором модели.
Комментарии работают через Giscus + GitHub. По клику ваши данные передаются в GitHub Inc. (США). Не будете кликать — ничего не отправляется.