Никто не любит ходить по врачам: больницы, очереди, ещё и заразу можно подхватить. Поэтому сейчас развивается телемедицина. Один из вариантов - лаборант с камерой, кардиографом и т.п. катается по людям, а в офисе сидит врач и ведёт “приём” по видеосвязи. Но даже это не вдохновляет людей - пока гром не грянет, как говорится.
А грянувший гром стоит очень дорого как самим людям, так и их страховым компаниям. Поэтому они долбят своих клиентов (особенно тех, у кого есть хронические болезни), чтобы те регулярно показывались врачу. Долбят, разумеется, по телефону. Вот этот-то процесс мне поручили попробовать автоматизировать.
Архитектура
По сути, наш бот - это LLM, который разговаривает и слушает через Twilio.

Когда-то в 2019 я уже работал с Twilio. Тогда нужно было подключить к нему гугловский DialogFlow. Веселье было на полную: SIP-клиент на Python, работа с аудиопотоком через расширение на сях (без плюсов, struct/malloc/free), ручной вызов API для STT/TTS и прочие радости.
Сейчас всё упростилось до нельзя - <ConversationRelay> рулит: Twilio само переводит речь в текст и обратно, мы получаем и отправляем текст через WebSocket. Мало того, можно отправлять ответ частями (см. ниже про стриминг), а также “договаривать” (вроде “Минутку, проверяю”, потом вызываем API и сообщаем результат).
Для работы с этим хозяйством есть классическая связка “API Gateway плюс Lambda”. Лямбд здесь, на самом деле, две. Первая принимает обычные REST-запросы и отвечает за hook-и вроде начала звонка или брошенной трубки. Вторая - это сама “говорилка”. Именно здесь живёт “птичка” LangGraph, которая работает с LLM, checkpoint-ами и tools.
Есть ещё и третья лябмда. Она асинхронно дёргает наши внутренние API для проверки и бронирования времени. Про асинхронность подробнее будет ниже.
Все необходимые данные хранятся в DynamoDB. Данных этих два вида - запросы на асинхронный вызов и checkpoint-ы от LangGraph. Написать свой Saver под “динамку” - это был тот ещё квест.
К LLM мы обращаемся через Bedrock. В качестве модели мы взяли Amazon Nova. Есть модели куда мощнее, но нам хватает и этой.
Это хозяйство описано через стэк CloueFormation на AWS CDK. Немного веселья с Github Actions и всё красиво деплоится на push в master. CI/CD, однако!
Диалог
Задача нашего бота - назначить визит лаборанта, а перед этим уточнить некоторые сведения. План беседы такой:
Проверяем, что на проводе тот, кто нам нужен,
Предлагаем записаться,
Если нужно, немного уговариваем,
Уточняем возраст, состояние здоровья и таблетки,
Обсуждаем удобную дату и время встречи,
Назначаем встречу.
Разумеется, всё что угодно может пойти не так:
Ответил другой человек - тогда просим пригласить нужного нам или сказать, когда перезвонить,
Человек не хочет записываться - уговариваем, приводим простую статистику,
Просит время, чтобы (например) посмотреть, как именно называются таблетки - даём время.
Обсуждение удобного времени - отдельная история. Здесь мы обрабатываем такие случаи:
Человек не знает, что предложить - предлагаем сами ближайшее свободное время, два-три слота
Если предложенное время не подходит, ищем свободное позже.
Люди редко мыслят датами. Обычно это “завтра вечером”, “на той недели кроме вторника” и т.п. - переводим в диапазоны даты-времени,
Если человек уже согласился на какое-то время, но потом вспомнил, что тогда не может - предлагаем дальше.
Промпты
Весь диалог у нас разделён на части: приветствие, уговоры (если надо), сбор сведений и обсуждение времени. Для каждой части есть свой промпт. В состоянии графа есть поле с id текущего промпта. Именно этот промпт мы отправляем в LLM, чтобы получить очередной ответ.
Сами промпты состоят из нескольких частей:
Контекст - что делает агент в этой части диалога,
Шаги - что сказать или спросить,
Переходы - к какой части диалога перейти потом,
Правила - указания по построению фраз, запреты на упоминание AI и т.п.,
Примеры - два-три варианта именно этой части беседы с разными исходами.
Для переходов мы инструктируем модель отвечать текстом вроде goto:booker. Эта команда затем считывается в коде.
Вызовы API и асинхронные задачи

В нашем боте есть два вызова внешних API: проверка доступного времени и назначение встречи. Второй работает мгновенно, а вот первый может думать несколько секунд. Раньше запрос был только из веб-морды, там было вполне допустимо покрутить спиннер. В телефоне такие лаги - ещё не катастрофа, но уже намёк. И мало того, что человек долго ждёт - он ещё и не может ничего спросить, даже “Ну, что там?”.
Чтобы решить эту проблему, мы сделали асинхронный вызов API через механизм задач (jobs). Работает он так:
Когда бот хочет проверить расписание, он говорит в трубку “Сейчас посмотрю” и тут же вызывает tool, который создаёт в DynamoDB задачу с нужными параметрами,
DynamoDB через Stream вызывает лямбду, которая дёргает внутренний API, ждёт ответ и сохраняет результат,
Если в это время человек спросит, что да как, бот вызовет tool проверки состояния задач и ответит, что ещё проверяет,
Когда ответ API пришёл, лямбда с ботом получает событие из DynamoDB Stream и сообщает результат. Здесь как раз полезны веб-сокеты: бот может начать говорить, не дожидаясь очередной реплики пользователя.
Пока API думает, бот говорит в трубку “Я тут, проверяю” примерно раз в десять секунд. Сделано это через отправку вопроса “нучёкак” по таймеру.
Стрим токенов
Модели сейчас быстрые, но не мгновенные. Чтобы пользователь не ждал, пока модель родит ответ целиком, мы используем поток токенов. Идея тут простая: как только модель выдала хоть что-то, мы отправляем это в сокет Twilio с пометкой “будет ещё”. Когда модель закончила свою тираду, сообщаем об этом в сокет.
Вроде всё здорово, и даже из коробки, но есть два момента:
у нас есть команды смены промптов, которые не надо зачитывать,
LLM в режиме стриминга не убирает размышления,а обрамляет их в тег
<thinking>.
Поэтому мы добавили фильтр, который не пропускает в сокет мысли модели и наши команды. Интересно, что LLM выдаёт текст с почти непредсказуемым разбиением. Например, ‘try!<’, ‘think’, ‘ing’, ‘>Now’. Получилась неплохая задачка в духе LeetCode на buffer/flush и регулярки.
Генерация ответа

Работа с LLM и tools проводится через LangGraph. Сначала мы проверяем, есть ли у нас незавершённые асинхронные вызовы. Напомню, что в LangGraph чат хранит не только реплики, но и вызовы и результаты работы tools. Поэтому эти сведения можно узнать прямо из сообщений, которые у нас уже есть. Если незавершённые вызовы есть, проверяем их статус.
Далее выбираем нужный промпт, подставляем туда некоторые сведения вроде имени пользователя, и отправляем в LLM. Ответ модели в большинстве случаев отправляем в стрим Twilio. Исключение составляют команды перехода к другой части диалога. Например, после приветствия человек согласился обсудить приём. Тогда в качестве ответа модель вернёт команду перехода к сбору сведений. В этом случае мы берём соответствующий промпт и вызываем LLM ещё раз. Результат уже точно будет сообщением (в нашем примере - просьба назвать возраст), отправляем его в стрим Twilio.
К тому моменту, как дело дойдёт до выбора времени, диалог может быть достаточно длинным. Поэтому перед началом мы сжимаем все предыдущие сообщения в один абзац. Он сообщает модели нечто вроде “Ты говоришь с женщиной по имени Гюльчатай, ей 55 лет, у неё…” ну и т.д. Этот кусок мы вставляем в промпт в качестве вводных.
Заключение
Кроме описанного выше есть ещё веб-морда для звонков по WebRTC. Вместе с ней получился вполне приличный прототип. Направлений развития здесь пруд пруди:
Автоматическое определение языка - если человек вместо “здравствуйте” предложил что-то там опустить в чай, то предложить ему перейти на китайский,
Интеграция с внешними сервисами - например, некто ходит к врачам только в определённую фазу луны. Можно вызвать API лунного календаря и узнать ближайшие даты,
Разные цели диалога и набор шагов - естественный шаг к multi-tenancy
Но это уже в следующей жизни…
















