Как превратить свободный текст игрока в исполняемый граф

от автора

Игрок пишет: «Пока орк отвлечён, я тихо выхватываю меч и прыгаю в окно». Для человека за столом это один ход. Для бота — разбор фразы на атомы, зависимости и броски, иначе «выхватываю» случится после «прыгаю» и всё запутается. Ниже — как ИИ превращает такую реплику в исполняемый граф действий.

Эта статья — разбор архитектурного пайплайна для детерминированной обработки свободного текста. В качестве кейса — ДнД‑бот, где вместо надежды на «умную LLM» я использую атомизацию, граф зависимостей и специализированных агентов. Будет полезно всем, кто проектирует сложные LLM‑системы обработки запросов и пытается победить галлюцинации и высокую стоимость токенов.

Однако я постарался использовать максимально простой язык, чтобы рассказать не связанным с разработкой игрокам, как ДнД‑бот из их запроса создаёт логичный и лаконичный ответ мастера.

Итак, поехали!


На самом деле, если закинуть в ИИ (я так буду называть LLM для простоты) запрос игрока и попросить обработать, как настоящий ДнД мастер, ИИ прекрасно справится. Любой может убедиться в этом, открыв ИИ‑ассистента типа ChatGPT или Gemini и попытавшись поиграть в ДнД.

Моя первая версия бота по сути и представляла собой обёртку вокруг одной‑единственной модели, которой я передавал данные сеттинга, немного истории ходов и запрос игрока. Если довольствоваться малым, это работало, и с запросом игрока ничего делать не надо было.

Потом архитектура немного усложнилась, и я задался рядом вопросов. Например:

  • а если напишут спам?

  • а если я добавлю команды типа «/help» — их же надо отдельно обрабатывать?

  • а зачем простые вопросы про правила прогонять через движок бота?

Так родилась идея LLM‑классификатора, который на вход получает запрос игрока и определяет, что это. Если игровое действие — направляем запрос дальше в движок. Если спам — игнорируем. Если вопрос про правила — отдаём другой небольшой LLM, которая просто ответит про правила. А если игрок напишет «ещё раз» — находим предыдущее сообщение и скармливаем его движку. В общем, классификация стала первым шагом на пути анализа запроса.

Условная схема работы классификатора в N8N

Условная схема работы классификатора в N8N

Наличие одного лишь классификатора какое‑то время меня устраивало. Именно на модели «классификатор + 3 ключевых LLM» я запустил первый закрытый альфа тест с игроками 1 июня, и это тоже работало. Но тест выявил ряд проблем, а именно, невозможность обработки сложных запросов. Вайбом свободы действий в рамках правил ДнД, которую я мечтал реализовать, даже не пахло. Тогда я принял решение полностью переделать архитектуру под «параллельный пайплайн» (чем и продолжаю заниматься спустя 2 месяца).

Идея в том, чтобы разные LLM параллельно обрабатывали запрос игрока, а на выходе некий арбитр склеивал бы разрозненные ответы в единый и непротиворечивый результат. То есть игрок пишет: «Я подбегаю и атакую орка» — а отдельные LLM‑специалисты смотрят: а не сработает ли какая ловушка? а как просчитать атаку? а вдруг игрок бежит по льду, и поэтому надо сделать проверку атлетики? Подробно обо всём этом я писал вот в этой статье; там же объясняется, почему одна модель не справится с такой задачей.

Так вот. Для такой архитектуры нужен роутер, который бы определял, какого специалиста когда вызывать. Конечно, можно не париться и каждый запрос отправлять всем специалистам сразу. Но это будет дорого по токенам. Поэтому — роутер.

Задача роутера очень похожа на задачу классификатора: взять запрос и понять, к каким доменам он относится (то есть каких специалистов вызывать для его обработки). Например, боёвка, социалка или импровизированные действия.

Роутер и параллельный пайплайн в N8N

Роутер и параллельный пайплайн в N8N

Вроде бы всё просто, но дьявол кроется в деталях. Оказалось, что при таком подходе и роутер, и каждый вызываемый специалист должны понять по контексту, а чего вообще хочет игрок и как это относится к их домену. Т.е. одно и то же действие делают, условно, на каждый ход сразу 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/