Chat Agent for Patients

Как работает мультиагентная система

Здесь объясняется, что такое ветка (intent), как реплика пациента доходит до ответа, и какие защитные механизмы стоят на пути «петель» и потерянного контекста. Английские термины оставлены в скобках — на них завязан код и логи.

Зачем это всё

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

Мы перешли на графовую архитектуру (graph-based routing): реплика пациента сначала идёт в маршрутизатор (router), который определяет тему, а потом — в ветку (intent), отвечающую только за свой узкий сценарий. У сложных веток внутри есть собственный пошаговый сценарий (state machine).

Главные термины

Ветка (intent)
Изолированный «под-агент» с собственным системным промптом. Отвечает за одну тему: запись, перенос, цены, медицинский вопрос, общая справка, перевод на оператора. У каждой ветки — свой код (new_booking, price_question и т. п.) и активная версия настроек.
Маршрутизатор (router)
Системная ветка _router: отдельный, дешёвый вызов модели, который по последней реплике пациента возвращает один из кодов веток. Не отвечает пациенту напрямую — только классифицирует. Вызывается на КАЖДОЙ реплике, не один раз при входе.
Пошаговый сценарий (state machine)
Внутренний граф шагов внутри ветки. Сейчас есть только у new_booking: 6 шагов от приветствия до подтверждения записи. Модель на каждой реплике видит, на каком шаге сейчас, и какие слоты уже собраны.
Шаг (step)
Узел пошагового сценария. У каждого шага свой код (intro, qualify, present, offer_time, book, close), свой кусок промпта и список допустимых переходов.
Допустимые переходы (allowed_next)
Список кодов шагов, в которые можно перейти с текущего. Например, с qualify разрешено в qualify (остаться) или present (двигаться вперёд), но не в close. Если модель попытается перепрыгнуть через шаг — валидатор переходов (transition validator) отклонит запрос, мы останемся на шаге.
Слоты (slots)
JSON-словарь данных, которые ветка собирает по ходу разговора. Для записи это name, reason, specialist, preferred_time, confirmed. Модель на каждой реплике их видит и обновляет — старые не переспрашиваются.
Условия выхода (exit conditions)
Список ситуаций, когда ветка должна вместо обычного ответа выдать служебный сигнал [INTENT_CHANGE: <код_ветки>] и передать диалог другой ветке. Например, если пациент в записи упомянул хирургию — ветка new_booking сама вернёт [INTENT_CHANGE: escalate_human].
Переключение ветки (hard handoff)
Полная смена ветки внутри одного диалога с обнулением шага и слотов. Бывает в двух случаях: ветка сама выдала [INTENT_CHANGE] или маршрутизатор предложил другую ветку, которая не имеет пошагового сценария.
Удержание в ветке (sticky state machine)
Защита от ложных переключений: если диалог идёт по пошаговому сценарию (например, new_booking · qualify) и маршрутизатор на короткой реплике («Алексей», «болит ухо») предлагает другую ветку — мы НЕ сбрасываем состояние, а передаём модели подсказку «маршрутизатор думает X, но ты в Y». Модель сама решает: остаться в сценарии (заполнить слот) или явно выйти через [INTENT_CHANGE].
Структурированный ответ ветки (structured output)
Каждая sm-ветка возвращает не только текст пациенту, но и служебный JSON-блок в хвосте ответа:
STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Алексей"}}
Парсер вырезает этот блок (пациент его не видит), валидатор проверяет легальность state_after, обновляет состояние диалога.
Отложенный сценарий (suspended intent / resume)
Если из пошаговой ветки произошёл переход в другую (например, посреди записи спросили про цены), её состояние (текущий шаг и собранные слоты) запоминается в полях suspended_intent, resumable_step_code, resumable_slots. Когда маршрутизатор увидит, что пациент возвращается к исходной теме («ладно, продолжаем запись»), мы автоматически восстановим шаг и слоты.
Защита от петли (routing loop guard)
Счётчик handoff_count в состоянии диалога считает все переключения ветки. При превышении 3 переключений за диалог следующее переключение блокируется: диалог автоматически уходит в escalate_human с шаблонным ответом «Уточню детали с администратором клиники, свяжемся с вами в течение ближайшего часа». Это страховка от циклов вроде «запись ↔ цены ↔ запись ↔ цены».
Состояние диалога (thread state)
Запись в БД (одна на диалог), хранящая текущую ветку, текущий шаг (если есть), собранные слоты, handoff_count и поля отложенного сценария. Видно в Песочнице справа, в блоке «Состояние диалога».

Что происходит на каждой реплике

1
Маршрутизатор классифицирует реплику.
Отдельный вызов модели с короткой системой и историей. Возвращает один код ветки.
2
Проверяем отложенный сценарий.
Если в состоянии диалога есть suspended_intent и маршрутизатор вернул именно его — восстанавливаем шаг и слоты, очищаем поля, обнуляем счётчик переключений.
3
Применяем удержание в ветке.
Если диалог уже идёт по sm-ветке и маршрутизатор предлагает другую — состояние не сбрасываем, в системный промпт ветки добавляется блок [ПОДСКАЗКА РОУТЕРА].
4
Если переключение всё-таки происходит — инкрементим счётчик.
При превышении 3 — авто-перевод на оператора, без вызова модели.
5
Собираем системный промпт ветки.
Базовый промпт + промпт текущего шага (если есть) + блок текущего состояния (шаг, слоты, подсказка). Зовём модель.
6
Парсим ответ.
Если есть [INTENT_CHANGE] — переключаемся в новую ветку (если из sm-ветки — запоминаем в отложенный сценарий) и зовём модель ещё раз. Если есть STATE_JSON: — валидируем переход, обновляем шаг и сливаем слоты.
7
Сохраняем сообщение пациента, ответ модели и обновлённое состояние диалога.
Всё одной транзакцией. Если что-то упадёт — откатываем целиком, «диалог-призрак» в списке не появится.

Защитные механизмы

Где что настраивается

Документ описывает текущее состояние после Спринта 6a. Перевод на оператора с указанием причины (acute_pain / surgery / routing_loop / …), сводка для оператора и умный маршрутизатор, видящий состояние диалога — в Спринте 6b.