
Игрок пишет: «Пока орк отвлечён, я тихо выхватываю меч и прыгаю в окно». Для человека за столом это один ход. Для бота — разбор фразы на атомы, зависимости и броски, иначе «выхватываю» случится после «прыгаю» и всё запутается. Ниже — как ИИ превращает такую реплику в исполняемый граф действий.
Эта статья — разбор архитектурного пайплайна для детерминированной обработки свободного текста. В качестве кейса — ДнД‑бот, где вместо надежды на «умную LLM» я использую атомизацию, граф зависимостей и специализированных агентов. Будет полезно всем, кто проектирует сложные LLM‑системы обработки запросов и пытается победить галлюцинации и высокую стоимость токенов.
Однако я постарался использовать максимально простой язык, чтобы рассказать не связанным с разработкой игрокам, как ДнД‑бот из их запроса создаёт логичный и лаконичный ответ мастера.
Итак, поехали!
На самом деле, если закинуть в ИИ (я так буду называть LLM для простоты) запрос игрока и попросить обработать, как настоящий ДнД мастер, ИИ прекрасно справится. Любой может убедиться в этом, открыв ИИ‑ассистента типа ChatGPT или Gemini и попытавшись поиграть в ДнД.
Моя первая версия бота по сути и представляла собой обёртку вокруг одной‑единственной модели, которой я передавал данные сеттинга, немного истории ходов и запрос игрока. Если довольствоваться малым, это работало, и с запросом игрока ничего делать не надо было.
Потом архитектура немного усложнилась, и я задался рядом вопросов. Например:
-
а если напишут спам?
-
а если я добавлю команды типа «
/help» — их же надо отдельно обрабатывать? -
а зачем простые вопросы про правила прогонять через движок бота?
Так родилась идея LLM‑классификатора, который на вход получает запрос игрока и определяет, что это. Если игровое действие — направляем запрос дальше в движок. Если спам — игнорируем. Если вопрос про правила — отдаём другой небольшой LLM, которая просто ответит про правила. А если игрок напишет «ещё раз» — находим предыдущее сообщение и скармливаем его движку. В общем, классификация стала первым шагом на пути анализа запроса.
Наличие одного лишь классификатора какое‑то время меня устраивало. Именно на модели «классификатор + 3 ключевых LLM» я запустил первый закрытый альфа тест с игроками 1 июня, и это тоже работало. Но тест выявил ряд проблем, а именно, невозможность обработки сложных запросов. Вайбом свободы действий в рамках правил ДнД, которую я мечтал реализовать, даже не пахло. Тогда я принял решение полностью переделать архитектуру под «параллельный пайплайн» (чем и продолжаю заниматься спустя 2 месяца).
Идея в том, чтобы разные LLM параллельно обрабатывали запрос игрока, а на выходе некий арбитр склеивал бы разрозненные ответы в единый и непротиворечивый результат. То есть игрок пишет: «Я подбегаю и атакую орка» — а отдельные LLM‑специалисты смотрят: а не сработает ли какая ловушка? а как просчитать атаку? а вдруг игрок бежит по льду, и поэтому надо сделать проверку атлетики? Подробно обо всём этом я писал вот в этой статье; там же объясняется, почему одна модель не справится с такой задачей.
Так вот. Для такой архитектуры нужен роутер, который бы определял, какого специалиста когда вызывать. Конечно, можно не париться и каждый запрос отправлять всем специалистам сразу. Но это будет дорого по токенам. Поэтому — роутер.
Задача роутера очень похожа на задачу классификатора: взять запрос и понять, к каким доменам он относится (то есть каких специалистов вызывать для его обработки). Например, боёвка, социалка или импровизированные действия.
Вроде бы всё просто, но дьявол кроется в деталях. Оказалось, что при таком подходе и роутер, и каждый вызываемый специалист должны понять по контексту, а чего вообще хочет игрок и как это относится к их домену. Т.е. одно и то же действие делают, условно, на каждый ход сразу 5 моделей! Это оверхед и по стоимости токенов, и по времени ожидания ответа, и по качеству самого ответа. Для ДнД‑бота критично и то, и другое, и третье.
Так родилась идея нормализации запроса. Игрок пишет: «бью его». Кого? Чем? Нужно дать LLM историю переписки и попросить: напиши нормально, что хочет игрок. На выходе получается нормализованная фраза: «игрок бьёт мечом орка». Ага, супер! Вот эту фразу уже можно отдавать специалистам без всякой истории — им уже сразу понятно, что происходит.
Итак, имеем: классификация, роутер, нормализация. Уже немного сложнее, чем просто отдать весь запрос ИИ, но и работает качественнее.
Однако и этого недостаточно. Данная схема вполне подходит под простые запросы. Но что делать с запросами посложнее? Например, игрок пишет: «Я хватаю меч, приказываю врагу сдаваться и выбегаю за дверь». Когда вся эта «колбаса» попадает к специалисту, он путается. Он отвечает за социалку, например, и понятия не имеет, может ли игрок выхватить меч, существует ли вообще этот меч, и как переместить игрока в локацию за дверью.
И вот тут уже понадобятся атомарные действия. Их тоже нужно нормализовать, им нужно персонально назначать домены — и тогда их можно обрабатывать отдельно друг от друга. Если взять фразу из предыдущего абзаца, из неё можно получить 3 отдельных действия, каждое со своими доменами:
|
ID |
Действие |
Домены |
|
1 |
Игрок хватает меч |
свободное действие |
|
2 |
Игрок приказывает гоблину сдаться |
социалка |
|
3 |
Игрок выбегает за дверь таверны |
перемещение, ловушка |
Отлично, теперь мы точно знаем, что кому передавать. Тем самым параллельные специалисты разгружаются, время и токены экономятся, а фокус на своих задачах делает их ответы более точными (меньше лишнего контекста, меньше путаницы, меньше lost in the middle и тому подобное).
Очень крутой бонус, который даёт такой подход — возможность валидировать и обрабатывать результаты каждого действия отдельно. Например, первые два действия удались, а последнее — выбежать за дверь — нет, потому что дверь закрыта на замок. И именно такую цепочку вы можете выдать игроку — но не как общий ответ одной модели (где высока вероятность галлюцинаций), а как результат пошагового анализа каждого действия.
Второй бонус связан с тем, что одно атомарное действие может относиться сразу к нескольким доменам. В примере выше попытка убежать за дверь — это и перемещение, и ловушка — допустим, потому что дверь связана с ловушкой. И здесь возможна ситуация, что оба LLM‑специалиста выдадут противоречивые вердикты: один позволит пройти, другой бабахнет скрытую растяжку. Так вот, зная точно идентификатор действия, к которому относятся противоречивые суждения, вы можете изолированно разбираться с этим действием (заскриптованными правилами или через LLM — как удобно).
На этом этапе система уже выглядит громоздкой, но именно здесь появляется необходимость в графовой структуре.
"normalized_message": "Я выхватываю меч, подхожу к орку и бью его.","atomic_actions": [ { "action_id": 1, "normalized_action": "Игрок выхватывает меч", "domains": [ "environment" ], "depends_on": [ ] }, { "action_id": 2, "normalized_action": "Игрок подходит к орку", "domains": [ "transition" ], "depends_on": [ ] }, { "action_id": 3, "normalized_action": "Игрок бьёт орка мечом", "domains": [ "combat" ], "depends_on": [ 1, 2 ] }]
Дело в том, что действия могут быть зависимые. «Я хватаю меч и бью орка». А что, если у игрока нет меча? Будь у нас одна умная модель, она бы справилась — сама бы провела параллели и решила, что если меча нет, то и атака на орка невозможна. Но мы пошли по пути пайплайна, и теперь просто некому связать эти действия. Классификатор скажет, что действие игровое. Роутер выберет домены специалистов. Специалисты провалидируют каждое свое действие. Типа так:
|
ID |
Действие |
Вердикт |
|
1 |
Игрок хватает меч |
🚫 нет меча |
|
2 |
Игрок бьёт орка мечом |
⚔️ минус 10 хп орку |
Как понять, что действие 2, на самом деле, невалидно? Вот тут и нужен граф, в данном примере (да и вообще на большинстве ходов) очень простой: 2 → 1 (то есть действие 2 зависит от действия 1). И тогда, получив вердикты по отдельным действиям, мы можем вывести простое правило: если исходное действие невозможно, проваливаем все последующие действия по каскаду. А независимые действия не трогаем.
Есть здесь ещё один момент. А что, если первое действие потребует броска? Например, игрок хочет перепрыгнуть расщелину и атаковать орка. А если не сможет перепрыгнуть? Опять же, раз у нас есть зависимости, можно что‑то придумать: попытка первого действия, результаты броска, а дальше либо провал — тогда блокировать второе действие, либо успех — тогда атаковать. Но это уже совсем отдельный топик — каскадные события.
Итак, если собрать всё вместе, получается, что одно‑единственное сообщение игрока проходит через следующие стадии: классификация, нормализация, разбиение на действия, присвоение действиям доменов, а также определение зависимостей. Просто чтобы обеспечить дальнейшую обработку.
А в результате рождается «магия». Игрок пишет запрос, а ИИ‑мастер выдаёт очень продуманный ответ. Например, так:
Вы тянетесь к мечу, но оказывается, что у вас его нет, и вам нечем атаковать орка. Однако вы смогли подбежать к двери… и не заметили ловушку! Сделайте спасбросок телосложения! 🎲
Та самая свобода в рамках правил, но созданная не абстрактным «ИИ», а архитектурными решениями.
Сейчас я как раз переписываю архитектуру движка под описанный пайплайн, каскад и граф зависимостей. Если вам интересна тема LLM‑пайплайнов, хочется следить за развитием проекта или принять участие в Alpha 2 — заходите в мой тг канал.
Буду рад любому фидбэку и инженерным идеям.
ссылка на оригинал статьи https://habr.com/ru/articles/1063710/