Чат с ИИ-персонажем на Python: роутинг моделей, три слоя памяти и борьба с отказами
Как собрать чат с ИИ-персонажем на Python за выходные: OpenRouter, три слоя памяти, обработка отказов модели и синтез речи. Рабочий каркас с кодом.

Яндекс запустил 30+ ИИ-персонажей в Алисе — и это хороший повод разобраться, как такое устроено изнутри. Собрать болванку за вечер несложно, но заставить бота помнить вчерашний разговор, не отказывать на ровном месте и не съедать бюджет на API — вот где настоящая работа.
Зачем писать своё, если есть Character.AI и Replika
Готовые платформы дают персонажей из коробки, но забирают контроль. Вы не выбираете модель, не управляете памятью, не можете добавить синтез речи на свой вкус и не знаете, сколько токенов тратится на каждый ответ. Если вы строите продукт или просто хотите понять, как это работает, — нужен собственный каркас.
Ниже — рабочая архитектура, которая обкатана в проде. Четыре места, где всё ломается, и как с ними справляться.
Базовый цикл: один запрос к модели
Любой чат с персонажем — это один и тот же цикл: системный промпт + история диалога + новая реплика = ответ модели. Системный промпт удобно собирать слоями: кто персонаж, кто собеседник, в каком формате отвечать.
Для запросов к моделям используем OpenAI-совместимый клиент с OpenRouter в качестве прокси — он даёт единый интерфейс к десяткам моделей, и переключиться между ними можно одной строкой. Ключ заводите на openrouter.ai/keys, кладёте в переменную окружения OPENROUTER_API_KEY.
```python from openai import AsyncOpenAI
client = AsyncOpenAI( base_url="https://openrouter.ai/api/v1", api_key=API_KEY )
def build_system_prompt(char, user): return "\n".join([ f"Ты — {char['name']}, {char['persona']}.", f"Собеседник: {user['name']}.", "Отвечай 2–4 предложениями. Действия — в звёздочках, мысли — в ~тильдах~.", ])
async def reply(char, user, history, user_msg, model): messages = [{"role": "system", "content": build_system_prompt(char, user)}] messages += history messages.append({"role": "user", "content": user_msg}) resp = await client.chat.completions.create(model=model, messages=messages) return resp.choices[0].message.content ```
Это уже работающий чат. Всё интересное начинается дальше.
Роутинг моделей: кому какую и за сколько
Умные модели стоят в разы дороже дешёвых. Гонять GPT-4-класс на каждого бесплатного пользователя — прямой путь к убыткам. Решение: выбирать модель по двум признакам — тариф пользователя и характер сцены.
Вместо дерева if/elif — простой словарь:
```python MODEL_BY_ROUTE = { ("free", "обычный"): "дешёвая-базовая-модель", ("free", "горячий"): "модель-без-жёсткой-цензуры", ("paid", "обычный"): "качественная-модель", ("paid", "горячий"): "качественная-модель", }
def select_model(tier: str, mode: str = "обычный") -> str: return MODEL_BY_ROUTE.get((tier, mode), MODEL_BY_ROUTE[("free", "обычный")])
Потолок длины ответа — тоже по тарифу
MAX_TOKENS = {"free": 1500, "paid": 3500} ```
Добавить новый тариф или режим — одна строка в словаре, а не новая ветка в коде. Лимит токенов на ответ напрямую бьёт по кошельку: не ставьте его слишком щедро для бесплатных пользователей.
Где ломается: модель отказывается отвечать
Фильтр безопасности рано или поздно сработает на безобидной фразе, и пользователь упрётся в стену «Извините, я не могу...». На реальном трафике ложные отказы составляют 2–8% ответов — цифра зависит от модели и тематики.
Логика спасения — от бесплатного к дорогому. Сначала пытаемся вытащить текст, который модель успела написать до фразы-отказа. Если не получилось — зовём запасную модель, но только для платных пользователей.
```python REFUSAL_MARKERS = ("я не могу", "i can't", "as an ai", "申し訳")
def salvage(text: str) -> str | None: # Перед фразой-отказом часто уже есть полезный текст — отрезаем хвост low = text.lower() for m in REFUSAL_MARKERS: i = low.rfind(m) if i > 150: text = text[:i].rstrip() break return text if len(text) >= 150 else None
async def reply_with_rescue(tier, messages): raw = await call_model(PRIMARY_MODEL, messages) if not is_refusal(raw): return raw if good := salvage(raw): # 0 лишних запросов return good if tier == "paid": # запасная модель — только платным return await call_model(BACKUP_MODEL, messages) return in_character_refusal() # бесплатным — мягкий отказ в роли ```
Тонкость, которую понимаешь не сразу: запасную модель имеет смысл вызывать только для платных. Каждый отказ бесплатного пользователя, превращённый в дополнительный запрос, — заметная статья расходов на объёме.
Три слоя памяти: почему одного не хватает
Самая частая ошибка — держать историю просто как список последних сообщений. Это работает в демо, но ломается в проде: либо контекст переполняется, либо бот «забывает» что-то важное из прошлых сессий.
Рабочая схема — три слоя, каждый закрывает свою задачу:
Слой 1 — горячий буфер в Redis. Последние 20 реплик, живут миллисекунды, нужны для связности текущего диалога.
``python async def remember_turn(r, key, text): await r.rpush(key, text) await r.ltrim(key, -20, -1) # держим только хвост ``
Слой 2 — векторный поиск в ChromaDB. Чтобы «вспомнить, что было сто сообщений назад», нужен поиск по смыслу, а не по словам. ChromaDB хранит эмбеддинги реплик и возвращает семантически близкие к текущему запросу.
``python def recall(collection, query, k=3): res = collection.query(query_texts=[query], n_results=k) docs, dists = res["documents"][0], res["distances"][0] return [d for d, dist in zip(docs, dists) if dist <= 0.55] # порог близости ``
Слой 3 — накопительное саммари. Старая история периодически сжимается в краткое резюме отдельным запросом к модели. Это не даёт промпту раздуваться до бесконечности — и стоит дёшево, потому что делается редко.
По отдельности ни один слой не вывозит. Redis без векторного поиска не помнит прошлые сессии. ChromaDB без горячего буфера теряет связность текущего разговора. Саммари без обоих — просто сжатый текст без деталей.
Подводные камни
Порог векторного поиска. Значение 0.55 для dist в ChromaDB — не универсальное. При другой модели эмбеддингов или другом языке порог нужно калибровать вручную, иначе будете получать нерелевантные воспоминания или не получать вообще ничего.
Саммари накапливает ошибки. Если модель однажды неточно сжала историю, эта неточность войдёт в следующее саммари и будет жить вечно. Стоит хранить сырые реплики отдельно и иметь возможность пересобрать саммари.
Бесплатные модели на OpenRouter. Модели с пометкой free имеют жёсткие rate limits и могут пропадать без предупреждения. Не стройте на них критический путь — используйте как fallback.
Синтез речи и задержки. TTS-запрос добавляет 0.5–2 секунды к ответу. Если голос нужен, запускайте синтез параллельно с отображением текста, а не после.
Что попробовать дальше
Добавить streaming — тогда первые слова появляются сразу, не дожидаясь полного ответа модели, и задержка перестаёт ощущаться. Попробовать pgvector вместо ChromaDB, если у вас уже есть PostgreSQL — меньше инфраструктуры. И посмотреть на function calling для структурированных действий персонажа: смена настроения, триггеры событий, обновление профиля пользователя — всё это чище ложится через инструменты, чем через парсинг текста.