19 июня 2026 Яндекс запустил ИИ-персонажей в Алисе — больше 30 собеседников с характером, от блогеров до аниме-героинь; они запоминают контекст, а голоса и обсуждение новостей обещают позже. Жанр не новый: «чат с ИИ-персонажем» уже пару лет тянут Character.AI, Replika и десятки Telegram-ботов. Удивляет другое — насколько мало нужно, чтобы собрать такое самому. Под капотом всего три кубика: языковая модель, память и синтез речи.
Сделать чат с ИИ девушкой (или любым другим персонажем) поверх готовой модели — это грубо говоря вечер работы. А вот заставить его не забывать вчерашний разговор, не отказывать на ровном месте, говорить голосом и при этом не разорить вас на счетах за API — на это уходит гораздо больше времени. Я полгода вожусь с таким ботом в проде; ниже — рабочий каркас на Python и четыре места, где мы спотыкались.
Каркас: один запрос к модели
Любой чат с персонажем — это один и тот же цикл: системный промпт, история диалога, новая реплика — и ответ модели. Системный промпт удобно собирать слоями: кто персонаж, с кем говорит, в каком формате отвечает. Ходить к моделям я предпочитаю через OpenAI-совместимый клиент — тот же OpenRouter даёт единый интерфейс к десяткам моделей, и менять их можно одной строкой.
Ключ для OpenRouter. Заведите на openrouter.ai/keys (вход через Google/GitHub → Create Key), положите пару долларов на баланс или возьмите модель с пометкой free. Ключ держим не в коде, а в переменной окружения OPENROUTER_API_KEY.
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
Это уже работающий чат. Всё интересное начинается дальше.
Какую модель выбрать?
Под каждую реплику модель выбирается по двум признакам — тариф пользователя и характер сцены. Бесплатным — дешёвую, платным — поумнее, для откровенных сцен — отдельную, без жёсткой цензуры. Я держу это обычным словарём, без дерева из if:
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}
Умные модели стоят в разы дороже дешёвых, а лимит на длину ответа напрямую бьет по кошельку. Добавить новый тариф или режим — одна строка в словаре, а не новая ветка в коде.
Подводный камень 1: модель отказывается отвечать
Рано или поздно фильтр безопасности сработает на безобидной фразе, и пользователь упрётся в стену «Извините, я не могу…». На нашем трафике такие ложные отказы — это 2–8% ответов (у вас цифра будет своя). Совсем их не убрать, но большую часть можно вернуть, причём дёшево. Логика — от бесплатного к дорогому: сначала пытаемся спасти то, что модель уже написала до отказа, и только если не вышло — зовём запасную модель.
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() # бесплатным — мягкий отказ в роли
Тонкость, которую мы поняли не сразу: запасную модель имеет смысл звать только платным. Иначе каждый отказ бесплатного пользователя превращается в лишний запрос, а на объёме это заметная статья расходов. Бесплатным отдаём мягкий отказ «в роли» — он всё равно приятнее сырой стены. Цепочку можно усложнить: первым шагом, например, приспускать настройки безопасности у провайдера, если он это позволяет.
Как дать чат-боту долгосрочную память?
Память собирается из трёх слоёв, и каждый закрывает свою задачу. Последние реплики держим в Redis — это быстро. Чтобы «вспомнить, что было сто сообщений назад», нужен поиск по смыслу, а не по словам — его даёт векторная база вроде ChromaDB. А чтобы длинная история не раздувала промпт до бесконечности, её периодически сжимают в накопительное саммари. По отдельности ни один слой не вывозит.
# слой 1 — горячий: последние N реплик в Redis (живёт миллисекунды) async def remember_turn(r, key, text): await r.rpush(key, text) await r.ltrim(key, -20, -1) # держим только хвост # слой 2 — смысловой: ищем релевантное прошлое по смыслу, а не по словам 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 — накопительное саммари: сжимаем старое + дописываем новое async def update_summary(prev_summary, recent_msgs): prompt = f"Дополни summary новыми событиями.\nБыло: {prev_summary}\nДиалог: {recent_msgs}" summary = await llm_summarize(prompt) return summary[:900] # держим в рамках, чтобы не раздувать промпт
Порог близости 0.55 и потолок саммари в 900 символов — это наши значения, под свой эмбеддер подбирайте свои. И про изоляцию: имя коллекции включает id сессии (mem_{user}_{char}_{session}), поэтому факты из одной сцены не протекают в другую — в одной сцене пользователь студентка, в другой пилот, смешивать их нельзя.
Подводный камень 2: ChromaDB и память сервера
Когда коллекций много — а у нас их за четыре тысячи, по одной на сцену, — ChromaDB способен незаметно съесть всю память хоста. На ветке 0.5.x кэш сегментов по умолчанию неограничен: при каждом обращении к свежей коллекции RSS подрастает и обратно не отдаётся. У нас контейнер выедал свой бюджет за пару-тройку дней и уходил в OOM по ночам. Если вы застряли на 0.5.x, лечится двумя переменными окружения — и помните, что применяются они только при пересоздании контейнера, restart их не подхватит:
# docker-compose.yml — обходной путь, если сидите на 0.5.x chromadb: image: chromadb/chroma:0.5.18 environment: CHROMA_SEGMENT_CACHE_POLICY: "LRU" CHROMA_MEMORY_LIMIT_BYTES: "10737418240" # 10 ГиБ
Мой совет - не пытаться править 0.5.x, а обновиться на ветку 1.x: её переписали на Rust, и это совсем другой разговор. После миграции RSS у нас упал с ~14 ГБ до ~300 МиБ, своп на хосте — со 100% до 2%, а результаты поиска и дистанции остались прежними (те же 4,5 тысячи коллекций на месте). Только у апгрейда есть три проблемы, на которые мы напоролись:
Миграция данных односторонняя. На первом старте 1.x необратимо правит схему sqlite — откатиться на 0.5.x поверх тронутого тома уже не выйдет. Сначала бэкап (у нас — tar в S3), потом обновление.
Клиент и сервер обновляются одним деплоем. Старый клиент против нового сервера падает на каждом обращении к коллекции. У нас версии однажды разъехались в проде — это стоило двенадцати часов тихой потери памяти, пока часть воркеров писала в пустоту.
Лимит файловых дескрипторов. Rust-версия держит по sqlite-файлу на сегмент; на тысячах коллекций она пробивает дефолтный
nofile=1024, сервер встаёт намертво — а клиент 1.x вдобавок зашивает бесконечный таймаут, и тогда виснет уже весь бот, у всех сразу. Лечится поднятием лимита (тоже только через пересоздание):
chromadb: image: chromadb/chroma:1.5.9 ulimits: nofile: 262144
Как озвучить ответ?
Голос добавляется примерно за вечер: текст уходит в TTS-сервис, обратно приходит mp3. Я использую Inworld TTS, на ошибке откатываюсь на бесплатный gTTS — чтобы пользователь хотя бы что-то услышал.
Ключ для Inworld. Создаётся в консоли platform.inworld.ai (раздел API Keys) — это строка в Base64, она идёт в заголовке Authorization: Basic. Кладём в INWORLD_API_KEY, а voiceId берём там же из каталога голосов.
import base64, httpx async def text_to_speech(text, voice_id, lang="ru"): text = enrich_for_tts(text, lang) # готовим текст (см. ниже) body = { "text": text, "voiceId": voice_id, "modelId": TTS_MODEL, "audioConfig": {"encoding": "MP3", "sampleRateHertz": 24000}, } async with httpx.AsyncClient(timeout=30) as c: r = await c.post(TTS_URL, json=body, headers={"Authorization": f"Basic {TTS_KEY}"}) if r.status_code == 200: return base64.b64decode(r.json()["audioContent"]) return None # дальше — бесплатный запасной gTTS
Подводный камень 3: теги эмоций [laugh]/[sigh] не работают
Почти каждый гайд по TTS советует расставлять теги вроде [laugh], [sigh], [breathe]. Мы так и сделали — и какое-то время недоумевали, почему голос звучит ровно так же, как без них. Конкретно у Inworld TTS-1.5 Max этих тегов попросту нет: тег либо проглатывается, либо — особенно на русском — зачитывается вслух как текст. У других движков, например ElevenLabs, аудио-теги бывают, так что сверяйтесь со своим.
Эмоцию приходится передавать тем, что движок действительно понимает:
Приём | Что делает |
|---|---|
| не работают — тишина или текст вслух |
| ударение на слове |
| пауза с падением интонации |
SSML | точная пауза |
звукоподражание ( | реальный звук — естественнее синтезированного |
| общая живость и эмоциональность |
Поэтому перед отправкой текст прогоняется через препроцессор: выкидываем нерабочие теги, многоточия превращаем в SSML-паузы.
import re FAKE_TAGS = re.compile(r"\[(?:laugh|sigh|breathe|moan)\]") def enrich_for_tts(text, lang="ru"): text = FAKE_TAGS.sub("", text) # эти теги озвучка игнорит/читает вслух text = text.replace("...", '<break time="0.3s"/>') # паузу задаём через SSML if "<break" in text: text = f"<speak>{text}</speak>" return text
Проверять это лучше на слух: тег, который выдаёт тишину, в логах не отличить от рабочего — нужно реально слушать аудио.
Почему счёт за LLM такой большой?
Потому что длинный системный промпт — у нас это персона, правила и память, суммарно около 5K токенов — оплачивается на каждом ходу диалога заново. Кэширование промпта снимает с этого процентов сорок и по деньгам, и по задержке. Но дешёвым переключателем оно не включается, и тут есть несколько нюансов.
Первый: некоторым провайдерам нужен явный маркер кэша. Без него можно неделю гонять модель и видеть ноль попаданий, решив, что «она не умеет кэшировать». У нас один провайдер с этим маркером прыгнул с 0% до ~90% попаданий; другой кэшировал и так, без всякого маркера, около 96%.
messages = [ { "role": "system", "content": [{ "type": "text", "text": SYSTEM_PROMPT, # длинный стабильный префикс "cache_control": {"type": "ephemeral"} # без него у части провайдеров 0% попаданий }], }, {"role": "user", "content": user_msg}, # меняется только это ]
Второй нюанс. Если вы ходите через агрегатор вроде OpenRouter, он балансирует запросы между нодами, и неявный префиксный кэш ломается — лечится не маркером, а закреплением конкретного провайдера. И сразу подвох по экономике: провайдер с кэшем иногда дороже по входным токенам, так что на распределённом трафике выигрыш может уйти в минус. Считать надо по реальному биллингу за несколько минут живого трафика, а не по десяти одинаковым запросам в цикле.
Подводный камень 4: короткий тестовый промпт врёт про кэш
Первый раз мы мерили кэш на коротком промпте — вышло 0% попаданий по всем провайдерам, и мы записали их в категорию «не умеют». Оказалось, у кэша есть минимальная длина префикса: ниже неё он просто не включается. На промпте боевого размера (≥5K токенов) тот же провайдер с тем же маркером дал уже десятки процентов попаданий. И второе: цикл из одинаковых запросов завышает цифру — на реальном трафике с холодными стартами и редкими персонажами она ниже. Мерьте по usage-полям ответа (cache_read_input_tokens) и по биллингу, а не по задержке — задержка шумит. Числа из 2026 года и зависят от провайдера; провайдеры иногда меняют поведение, так что перепроверяйте под себя.
Запустить за две минуты, без Docker
Всё выше собирается в один файл — рабочий чат в терминале, ключи через переменные окружения. Голос подключается опционально: если заданы ключ и voiceId Inworld, ответ дополнительно сохраняется в reply.mp3.
#!/usr/bin/env python3 """Мини-чат с ИИ-девушкой в терминале. Без Docker, ключи через env. pip install openai export OPENROUTER_API_KEY=sk-or-... # ключ: https://openrouter.ai/keys python mini_waifu.py Голос (опционально) — допишет reply.mp3: export INWORLD_API_KEY=... # ключ: https://platform.inworld.ai/ export INWORLD_VOICE_ID=... # id голоса из консоли Inworld """ import os, sys, json, base64, urllib.request OR_KEY = os.environ.get("OPENROUTER_API_KEY") if not OR_KEY: sys.exit("Нет OPENROUTER_API_KEY. Создайте ключ на https://openrouter.ai/keys " "и выполните: export OPENROUTER_API_KEY=sk-or-...") from openai import OpenAI client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=OR_KEY) MODEL = os.environ.get("MODEL", "qwen/qwen3-235b-a22b-2507") # любая модель OpenRouter PERSONA = "Мия, 23 года, художница — тёплая и немного дерзкая" SYSTEM = (f"Ты — {PERSONA}. Общаешься тепло и по-человечески. " "Отвечай 2–4 предложениями. Действия — в *звёздочках*.") NAME = PERSONA.split(",")[0] def synth_voice(text): """Опциональная озвучка через Inworld -> reply.mp3 (если заданы env).""" key, voice = os.environ.get("INWORLD_API_KEY"), os.environ.get("INWORLD_VOICE_ID") if not (key and voice): return body = json.dumps({ "text": text, "voiceId": voice, "modelId": os.environ.get("INWORLD_MODEL", "inworld-tts-1.5-max"), "audioConfig": {"encoding": "MP3", "sampleRateHertz": 24000}, }).encode() req = urllib.request.Request( "https://api.inworld.ai/tts/v1/voice", data=body, headers={"Authorization": f"Basic {key}", "Content-Type": "application/json"}) try: resp = json.load(urllib.request.urlopen(req, timeout=30)) with open("reply.mp3", "wb") as f: f.write(base64.b64decode(resp["audioContent"])) print(" (голос сохранён в reply.mp3)") except Exception as e: print(f" (озвучка не удалась: {e})") def main(): history = [] print(f"Чат с {NAME}. Пустая строка — выход.\n") while True: try: user = input("Вы: ").strip() except (EOFError, KeyboardInterrupt): break if not user: break messages = [{"role": "system", "content": SYSTEM}, *history, {"role": "user", "content": user}] reply = client.chat.completions.create( model=MODEL, messages=messages, max_tokens=400).choices[0].message.content print(f"{NAME}: {reply}\n") history += [{"role": "user", "content": user}, {"role": "assistant", "content": reply}] history = history[-12:] # помним только хвост диалога synth_voice(reply) if __name__ == "__main__": main()
Запуск:
pip install openai export OPENROUTER_API_KEY=sk-or-... # ваш ключ с openrouter.ai/keys python mini_waifu.py
Тридцать строк, и это уже персонаж с памятью; добавьте ключ Inworld, и он заговорит голосом. Дальше навешивается всё из статьи: роутинг моделей, работа с отказами, векторная память и кэш.
Что в итоге работает, а что нет
Работает:
роутинг моделей словарём
(тариф, режим) → модель;спасение отказов: сперва вытащить полезное из ответа, запасную модель — только платным;
память тремя слоями: Redis, векторная база, накопительное резюме;
эмоции в TTS через
*ударение*,...,<break>и звукоподражание;кэш промпта на длинном префиксе с явным маркером.
Не работает:
запасная модель на каждый отказ бесплатным пользователям;
ChromaDB 0.5.x на тысячах коллекций без лимита кэша (правильнее — сразу на 1.x);
вера в теги
[laugh]/[sigh](только касается inworld TTS 1.5 max, в других сервисах по типу ElevenLabs нужно тестировать отдельно, но эти сервисы часто и сильно дороже) и в короткие тестовые промпты.
Каркас и правда собирается за вечер-другой. А вот эти четыре места съедают потом большую часть времени — так что пусть они будут закрыты заранее.
Разбор основан на продакшене HoneyChat — Telegram бота и сайта в браузере с ИИ персонажами: 500–700 активных пользователей в день, 20 языков. Стек:
aiogram+FastAPI(uvicorn) + Celery-воркеры (очереди под текст, картинки, голос), хранилище — PostgreSQL, Redis и ChromaDB. Все числа и подводные камни выше — из реальной эксплуатации, а не из туториалов.Если делаете похожий чат с ии девушкой и упёрлись в те же места — заходите в комментарии, сверим цифры.
Источники
Inworld TTS — документация — поддерживаемые параметры (
temperature,speakingRate), подмножество SSML.W3C — Speech Synthesis Markup Language (SSML) 1.1 —
<break>,<speak>, просодия.ChromaDB — документация — конфигурация кэша сегментов; ишью #3336 и #5843 (утечка памяти, open).
Anthropic — prompt caching —
cache_control, ephemeral-кэш, тарификация.OpenAI — prompt caching — авто-кэш, минимальная длина префикса,
cached_tokens.Google — Gemini safety settings — категории фильтра и
BLOCK_NONE.Redis — LTRIM — паттерн «хвост списка».
sentence-transformers — эмбеддинги для смыслового поиска.
