Вы запускаете тест второй раз подряд, не тронув ни строчки кода, - и он падает. Не из-за сети и не из-за случайного таймаута: ответ просто пришел другой. Те же факты, другие слова, другой порядок предложений. Первая реакция - искать баг. Бага нет. Сломалось не поведение программы, а ваше представление о том, что такое «правильный ответ».
Я пришла в ИИ-системы из обычной разработки: 19 лет в ИТ, путь от инженера-программиста до архитектора. Писать код я не разучилась, и почти все, что я знала про инженерию, осталось в силе. Но несколько рабочих привычек пришлось перебрать заново, и лучше сделать это осознанно, чем по итогам инцидента. К концу текста вы поймете, чем работа над ИИ-системой отличается от привычной разработки и какие четыре привычки ломаются первыми.
Два слова для начала. Языковая модель - программа, которая по тексту запроса порождает текст ответа: она предсказывает продолжение, а не вычисляет его по формуле. Токен - кусочек текста размером примерно со слово или часть слова; в токенах меряют и объем запроса, и счет от провайдера.
Первая перемена: результат перестает быть детерминированным
Обычная программа детерминирована: одинаковый вход дает одинаковый выход. На этом стоит весь привычный тест - вы сравниваете полученное с ожидаемым, и он либо проходит, либо нет.
С языковой моделью это не работает. Один и тот же запрос дает разные ответы, и «правильно» перестает быть точкой - становится диапазоном. Сравнивать ответ со строкой-эталоном бессмысленно: тест будет краснеть на верных ответах и зеленеть на неверных, если те случайно совпали формулировкой.
Отсюда первая смена привычки: проверять не текст ответа, а его свойства. Не «совпало ли слово в слово», а:
- есть ли в ответе ссылка на источник
- сходятся ли числа в ответе с числами в источнике
- соблюден ли формат, который ждет следующий шаг системы.
Такие проверки переживают переформулировку. И они же становятся гейтом - местом, где ответ, не прошедший проверку, не уходит дальше, а отправляется на повтор.
flowchart LR
A["Запрос пользователя"] --> B["Ответ модели"]
B --> C{"Есть ссылка на источник?"}
C -- нет --> F["Не отдавать, повторить запрос"]
C -- да --> D{"Числа сходятся с источником?"}
D -- нет --> F
D -- да --> E{"Формат соблюден?"}
E -- нет --> F
E -- да --> G["Показать пользователю"]
F --> BВторая перемена: качество живет в данных, а не в коде
Раньше, когда результат был плохим, я шла в алгоритм. В ИИ-системе я иду в данные - и почти всегда нахожу причину там.
Самый наглядный случай - поисковый ассистент: система, которая ищет ответ в ваших документах и отвечает по найденному, а не «из головы» модели. Почти все ошибки в нем чинились не кодом, а двумя вещами: нарезкой документов на фрагменты и разметкой этих фрагментов.
Звучит скучно, пока не увидишь механику. Документ режут на куски, потому что целиком он в запрос не влезает. Кусок находится по запросу верно - слова совпали, поиск отработал. А понимается неправильно: в нем нет контекста. Абзац с условием или сроком верен внутри своего раздела и вводит в заблуждение, когда вырван из него: непонятно, к чему условие относится и на кого распространяется. Модель получает фрагмент без этой рамки и честно отвечает по тому, что видит.
Поэтому граница нарезки - инженерное решение, а не технический пустяк, и к каждому фрагменту нужна разметка: откуда он, из какого раздела, к чему относится. Меняете нарезку - меняется качество ответов, хотя в коде вы не тронули ничего.
flowchart LR A["Очередь задач пуста"] --> B["Служебный опрос уходит в модель"] B --> C["Запрос: около 29 700 токенов"] C --> D["Ответ: 90 токенов, смысл: задач нет"] D --> E["Доля в счете за модели: 45 процентов"] A -.-> F["Тот же ответ обычным кодом: 0 токенов"]
Третья перемена: у каждого вызова есть цена
В привычной разработке лишний вызов функции почти ничего не стоит. Обращение к модели стоит денег всегда, и стоимость не обязана быть пропорциональна пользе.
Мой самый обидный пример: служебный опрос - периодический вопрос системы самой себе, есть ли работа в очереди. При пустой очереди он все равно уходил в модель с полным контекстом: около 29 700 токенов запроса ради ответа в 90 токенов, смысл которого - «задач нет». Такой вызов выглядит дешевым: он редко попадается на глаза и не тормозит пользователя. По итогу он съел 45 процентов всех расходов на модели.
Вывод простой: смотреть не на общую сумму счета, а на разрез по типам вызовов. Общая сумма ничего не подсказывает - она просто растет. Разрез показывает, что почти половина денег уходит на вопрос, ответ на который вы знаете и без модели: очередь пуста, и проверить это можно обычным кодом.
Четвертая перемена: человек становится частью системы
Привычная роль человека - пользователь: он снаружи, он нажимает кнопки. В ИИ-системе он становится звеном самой системы, наравне с очередью и хранилищем.
Работает это так: действия с последствиями требуют его подтверждения, а публикация чего-либо - его явного решения. Не «модель обычно не ошибается», а «без подтверждения шаг физически не выполняется».
Важная деталь: это проектируется заранее, а не добавляется после инцидента. Дописать подтверждение в готовый пайплайн - значит разрезать его пополам и заново продумать, что происходит с задачей, пока она ждет человека: где она лежит, что будет, если ответа нет сутки, кто увидит ее следующим. Заложить точку подтверждения с самого начала дешевле, чем встроить ее задним числом.
Что осталось прежним и оказалось важнее прочего
Ничего из обычной инженерной дисциплины не отменилось. Наоборот, поверх недетерминированного компонента она стала цениться выше:
- таймауты на внешние вызовы - модель отвечает медленнее вашей базы, и ждать ее бесконечно нельзя
- порядок операций при сбое - что уже произошло и что откатывать, если система упала между шагами
- воспроизводимость - возможность повторить конкретный прогон и получить понятную картину, а не гадать
- журналы, по которым можно разобрать инцидент, - что именно уходило в модель и что вернулось.
Без последнего пункта разбор превращается в спор о воспоминаниях. Историю со служебным опросом я нашла именно в журналах: без них у меня была бы только строчка «расходы выросли».
И один соблазн
Почти любую проблему хочется решить сменой модели: это одна строчка в конфиге, и попробовать быстрее, чем разобраться. Иногда помогает. Но почти всегда починка лежит уровнем ниже - в нарезке данных, в лишнем вызове, в отсутствующей проверке. Смена модели маскирует эти вещи, и они возвращаются на следующем релизе.
Применить у себя
Что проверить на практике
- Прогоните один и тот же запрос к модели пять раз подряд и сравните ответы. Это самый быстрый способ почувствовать разницу между «сломалось» и «так устроено».
- Перепишите один свой тест со сравнения текста на проверку свойств: наличие ссылки, совпадение чисел с источником, валидность формата.
- Возьмите документ, по которому у вас идет поиск, и посмотрите на найденный фрагмент отдельно от документа. Спросите себя: понятен ли он без остального текста? Если нет - вы нашли причину будущей ошибки.
- Посчитайте расходы за неделю в разрезе по типам вызовов, а не одной суммой. Найдите самый частый вызов и посмотрите, сколько токенов он отправляет и сколько получает обратно.
- Выпишите список действий вашей системы, у которых есть внешние последствия. Отметьте, какие из них сейчас происходят без подтверждения человека.
Как понять, что получилось
Критерий простой: когда система в следующий раз ответит неправильно, вы сможете назвать причину, не меняя модель.
Если вы можете открыть журнал, увидеть, какой фрагмент попал в запрос, и сказать «вот здесь нарезка разрубила смысловой кусок пополам» - привычки перестроились. Если единственное объяснение звучит как «модель сглючила, давайте возьмем другую» - еще нет, и дело не в модели.
Второй признак - про деньги: вы знаете, какой тип вызова у вас самый дорогой, и можете сказать, почему он столько стоит. Не общую сумму за месяц, а конкретный вызов и его цену.