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 — эмбеддинги для смыслового поиска.
Автор: sm1ck


