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, аудио-теги бывают, так что сверяйтесь со своим.

Эмоцию приходится передавать тем, что движок действительно понимает:

Приём

Что делает

[laugh] [sigh] [breathe]

не работают — тишина или текст вслух

*слово*

ударение на слове

... (многоточие)

пауза с падением интонации

SSML <break time="0.4s"/>

точная пауза

звукоподражание (ahh, mmm, ha-ha)

реальный звук — естественнее синтезированного [laugh]

temperature / темп речи

общая живость и эмоциональность

Поэтому перед отправкой текст прогоняется через препроцессор: выкидываем нерабочие теги, многоточия превращаем в 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. Все числа и подводные камни выше — из реальной эксплуатации, а не из туториалов.

Если делаете похожий чат с ии девушкой и упёрлись в те же места — заходите в комментарии, сверим цифры.

Источники