Как построить ИИ-агента поддержки, который не галлюцинирует: ограниченные интенты, типизированные действия, отказ как норма. Читайте и внедряйте!
Большинство демо «ИИ-клиентского сервиса» проваливаются одинаково: вы задаёте вопрос, на который модель не может ответить из данных, и вместо остановки она выдаёт правдоподобный ответ. В чат-игрушке это курьёз, а в постпродажном обслуживании — обещание, которое компания обязана выполнить: возврат, который никто не одобрял, или дата доставки, которой никогда не было.
Я создаю таких агентов для интернет-магазинов: отвечаю на предпродажные вопросы, которые решают, купит ли клиент, и постпродажные, которые решают, вернётся ли он. Почти вся инженерия сводится к одной проблеме: сделать честность агента свойством архитектуры, а не промпта. Примеры ниже касаются постпродажного обслуживания, где ошибка стоит дороже всего, но та же схема работает и для предпродажных вопросов.
Наивный дизайн — одна модель, один большой промпт и куча документов в векторной базе. Спрашиваете «где мой заказ 41822?» — слой поиска возвращает три чанка, похожих на вопрос. Ни один не содержит заказ 41822, потому что это живая строка в БД, а не документ. Модель получает контекст, полный текстов про заказы, и всё равно отвечает. Ответ неверный.
Лучший промпт это не исправит. Исправляет только удаление у модели возможности отвечать на такой класс вопросов вообще.
Каждый запрос, который обрабатывает агент, направляется ровно в один из фиксированного набора интентов: статус заказа, задержка доставки, возврат, статус возврата, обмен, счёт, вопрос о товаре, отмена. Набор закрыт. Нет запасного интента «ответить в любом случае».
Каждый интент сопоставляется с типизированным действием и явным контрактом:
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class Reason(Enum):
NOT_FOUND = 'not_found'
OUT_OF_SCOPE = 'out_of_scope'
POLICY_UNCLEAR = 'policy_unclear'
LOW_CONFIDENCE = 'low_confidence'
HUMAN_REQUESTED = 'human_requested'
EMOTIONAL = 'emotional'
@dataclass(frozen=True)
class OrderStatus:
"""Читает систему записи заказов. Никогда не генерирует статус."""
order_ref: str
def resolve(self, ctx) -> 'Resolution':
order = ctx.commerce.get_order(self.order_ref) # API Shopify/WooCommerce
if order is None:
return Resolution.escalate(
reason=Reason.NOT_FOUND,
say="Я не могу найти номер заказа на этом аккаунте."
)
return Resolution.answer(
template="order_status",
facts=order.public_facts(), # только белый список полей
)Две детали делают основную работу. Первая: facts — это белый список. public_facts() возвращает перевозчика, трек-номер, время отправки и текущий статус. Он не возвращает маржу, внутренние заметки, другие заказы клиента или скоринг фрода. Модель не может утечь поле, которое ей никогда не передавали.
Вторая: слой естественного языка только формулирует. Модель получает факты и шаблон интента и пишет одно-два предложения в тоне магазина. Она не решает, каков статус; ей передают статус и просят хорошо его озвучить. На этапе генерации не остаётся открытых вопросов, поэтому галлюцинации не к чему прицепиться.
Resolution.escalate — это не путь ошибки. Это ожидаемый результат со своей планкой качества, и он самый важный.
class Resolution:
@staticmethod
def escalate(reason: Reason, say: str) -> 'Resolution':
return {'type': 'escalate', 'reason': reason, 'say': say}
@staticmethod
def answer(template: str, facts: dict) -> 'Resolution':
return {'type': 'answer', 'template': template, 'facts': facts}Каждая причина даёт разную передачу: конкретное сообщение клиенту, приоритет в очереди людей и сводку для тикета, чтобы тот, кто поднимет, не начинал с нуля.
Ветка EMOTIONAL важнее, чем кажется. Разгневанный клиент — это не сбой поиска; система может владеть всеми фактами. Всё равно передаём человеку, потому что «технически решаемо» и «должно обрабатываться машиной» — разные вопросы. Ошибка здесь — как автоматизация теряет доверие, которое должна была заслужить.
LOW_CONFIDENCE требует реального порога, откалиброванного под каждый магазин, а не жёстко заданного 0.7. Порог должен быть асимметричным: неверный возврат стоит намного дороже лишней эскалации.
Чтение заказа безопасно, а возврат — нет. Они находятся за разными шлюзами, и шлюз — это конфигурация, которой владеет мерчант, а не промпт:
actions:
order_status:
mode: auto
return_label:
mode: auto
max_value_eur: 80
refund:
mode: propose # агент создаёт черновик, человек нажимает «отправить»
cancel_order:
mode: propose
address_change:
mode: auto
before_dispatch_only: trueРежим propose — это то, что делает первый месяц внедрения жизнеспособным. Агент выполняет работу и создаёт черновик; человек одобряет; вы наблюдаете за процентом одобрений. Когда действие проходит без правок достаточно часто, эта история оправдывает переключение на auto: решение, заработанное на своих данных, а не обещание вендора.
Я запускаю каждый магазин на отдельном инстансе, во Франции, на французской модели (Mistral). Это звучит как маркетинг, поэтому вот инженерия за этим.
Разговоры поддержки — одни из самых чувствительных данных магазина: не столько номера заказов, сколько то, что клиенты пишут вокруг них: адреса, причины возврата по здоровью, финансовые трудности, жалобы на сотрудника по имени. Как только это проходит через общий мультитенантный пайплайн в другой юрисдикции, «где мои данные» перестаёт быть вопросом, на который вы можете ответить, и становится вопросом, который вы пересылаете вендору.
Выделенный инстанс также делает обратимость реальной, а не контрактной. Когда мерчант уходит, экспорт — это дамп базы данных и конфиг-файл, передаваемые без единого тикета в поддержку.
Обязательство прозрачности в EU AI Act (статья 50) указывает в ту же сторону: клиент должен знать, что говорит с машиной. Это легче гарантировать, когда раскрытие живёт в вашем собственном слое компоновки сообщений, чем когда это тумблер в чужой панели управления.
Компромисс реален: такая схема решает меньше разговоров, чем модель со свободными руками. Закрытый набор интентов не покроет длинный хвост, ограниченные действия не могут импровизировать, а режим propose держит человека в цикле неделями.
Я бы пошёл на этот компромисс каждый раз. Разрешительный дизайн имеет худший режим отказа. Он даёт не чуть худший ответ, а уверенное ложное утверждение, на которое действует реальный человек, в канале, где юридически ответственным является магазин.
Агент, который говорит «давайте подключу человека», — это лёгкое разочарование. Агент, который выдумывает политику возврата, — это инцидент, который магазину потом приходится разгребать.
Начните с малого: выберите один интент, например статус заказа, и реализуйте его как типизированное действие с белым списком фактов. Добавьте эскалацию с причинами. Настройте режим propose для всех записей. Через месяц вы увидите, какие действия можно автоматизировать, опираясь на данные, а не на обещания.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →