Каждый, кто пробовал собрать AI-чат по типовой схеме — chat-completions API, OpenAI Memory, один эндпоинт Stable Diffusion — рано или поздно упирается в одни и те же стены. Бот забывает разговор через десять реплик. Иногда сервер бодро отвечает HTTP 200, как будто всё в порядке, а внутри — пустая строка: ни ошибки, ни таймаута, модель просто отказалась говорить и сделала это молча. Один и тот же текстовый запрос рисует двух разных персонажей. А одеть нарисованного персонажа в конкретное платье из каталога не получается вообще.
Я три месяца держу в проде AI-компаньона: один и тот же бэкенд обслуживает и Telegram-бот, и веб-приложение. Аудитория — сотни ежедневных пользователей, не сотни тысяч. Конверсия из бесплатного в платный тариф — однозначные проценты, как у любого продукта на ранней стадии. Поэтому в статье не будет цифр про «миллион MAU», но будут цены за тысячу токенов, реальные доли попаданий в кеш, дневные потолки трат и до/после по тонкой настройке прода.
Эта статья — четыре инженерных build-log поста, которые я выкладывал на dev.to (серия «Building HoneyChat»), сведённые в один связный материал на русском. Плюс два раздела, которых в исходниках не было: про деньги (юнит-экономика на третьем месяце) и про операционный тюнинг, который сдвинул потолок DAU больше чем в два раза без переписывания архитектуры.
Оглавление
-
Память: Redis + ChromaDB
-
Маршрутизация LLM и кеш промптов
-
Визуальная консистентность: LoRA и IP-Adapter
-
Юнит-экономика на третьем месяце
-
Прод-тюнинг: что подкрутил в инфре на третьем месяце
-
Что бы переделал, начав сейчас
-
Где это работает в проде и источники
TL;DR
-
Память —
Redisпод свежий буфер реплик плюсChromaDBпод сжатые пересказы кусков диалога. Три чтения параллельно. Превращать каждое отдельное сообщение в вектор — прямая дорога к индексу на миллионы документов с плохим поиском. -
Маршрутизация LLM — у пользователя в UI два темпа отношений (
slow_burnиinstant) плюс legacy-дефолтnatural. Под каждый темп, под каждый тариф — своя модель. Плюс цепочка резервных через разных провайдеров. Главная ловушка, на которой все спотыкаются: модель отвечает HTTP 200, а внутри пустая строка и причина «сработал фильтр контента» — не ошибка, не падение, просто тишина. -
Кеш промптов — на Gemini 3.1 Flash Lite один маркер
cache_control: ephemeralповерх системного промпта (это стартовые инструкции с описанием персонажа и правилами поведения) экономит 75% на закешированной части запроса. У меня этот один маркер закрывает четверть всего LLM-бюджета. -
Картинки — LoRA, небольшая надстройка над базовой моделью, которую вы дообучаете под каждого персонажа отдельно. Она «учит» Stable Diffusion узнавать конкретное лицо. Поверх неё IP-Adapter (техника подмешивания референс-картинки) с умеренной силой и ранним отключением рисует конкретный товар из каталога так, чтобы лицо не уехало.
-
Тюнинг прода — LRU-вытеснение в ChromaDB, перезапуск uvicorn-воркеров по числу запросов, 90 секунд на мягкое выключение, поднятый дневной потолок трат. В сумме потолок API по памяти переехал с ~500 до ~1200 DAU, потолок ChromaDB — с ~800 до 2000+ DAU. Архитектуру при этом не трогал.
Дальше — каждый блок развёрнуто, с кодом, реальными цифрами и тем, что бы переделал.
1. Память: Redis + ChromaDB
Почему скользящего пересказа недостаточно
Стандартный путь у начинающих — отдать в контекст последние N сообщений и забыть. Через 10-20 реплик контекст вылетает, бот забывает имя пользователя и предыдущие договорённости. Очевидное решение «просто увеличим окно контекста» упирается в две вещи: цену в токенах (которая на длинном диалоге быстро становится больно ощутимой) и в то, что модель на длинном контексте всё равно начинает терять детали из середины.
Второй очевидный шаг — скользящий пересказ: каждые N сообщений генерировать сжатую версию. Дёшево, но теряет нюанс при повторной суммаризации. Прогоните руками:
Реплика 1: «Она сказала, что ненавидит начальника, потому что он
присваивает её работу»
Summary 1: «Пользователь упомянул напряжение с руководителем на работе»
Summary 2: «У пользователя стресс из-за работы»
Summary 3: «У пользователя есть работа»
К четвёртой итерации причина исчезла. Бот начинает звучать как заезженная пластинка. Лечится разделением слоёв: свежие сообщения хранятся дословно, сжимаются только реально устаревшие куски, при этом сохраняется возможность по смыслу достать любой пересказ из истории.
Архитектура: два независимых слоя
-
Redis— горячий буфер по ключу(user_id, character_id, session_id), ограниченная длина, короткое время жизни ключа (TTL), запись синхронно на каждом сообщении. Это краткосрочная память «последние 20-30 реплик». -
ChromaDB— векторная база, хранит сжатые пересказы кусков диалога, не отдельные сообщения. Запись асинхронно, батчами. Поиск по смыслу через векторное сходство (embedding similarity).
Главный приём — превращать в векторы пересказы, а не каждое сообщение. Десять недель активного чата — это 30-50 документов на коллекцию, а не десятки тысяч. Индекс остаётся компактным, качество поиска на коротких репликах вроде «угу» не загрязняется (короткое сообщение даёт слабый вектор и тянет за собой шумные совпадения).
Про session_id отдельно. Веб-чат у меня поддерживает «сцены»: пользователь начинает новый разговор с тем же персонажем в новом сеттинге, и память не должна протекать из предыдущей сцены. Поэтому ключи Redis и коллекции ChromaDB включают session_id, когда он есть. Бот в Telegram пока работает в режиме без session-разделения — это слой совместимости с прошлой версией.
Структура summary-документа в ChromaDB:
{
"id": "summary:uid42:char_anna:sess_kn3a:turn_120",
"document": "Анна и пользователь обсудили его проблемы на работе...",
"metadata": {
"type": "summary", # отличает от event-документов
"turn_range": "100-120", # какой кусок диалога сжат
"ts": "2026-05-20T14:32:00Z",
"lang": "ru",
},
}
Поле type: "summary" появилось не сразу — изначально документы лежали без метаданных, и при добавлении нового типа («event», «fact») пришлось писать слой обратной совместимости. Совет: с первого дня кладите type в metadata, даже если у вас один тип.
Запись в Redis: bounded list + TTL в одном пайплайне
async def save_message(user_id: int, char_id: str, role: str, content: str) -> None:
r = get_redis()
key = f"chat:{user_id}:{char_id}:messages"
msg = json.dumps({
"role": role,
"content": content,
"ts": datetime.now(timezone.utc).isoformat(),
})
pipe = r.pipeline()
pipe.rpush(key, msg)
pipe.ltrim(key, -HOT_BUFFER_SIZE, -1)
pipe.expire(key, 86400 * HOT_BUFFER_TTL_DAYS)
await pipe.execute()
Три вещи, которые здесь важны:
-
ltrimна каждой записи. Список ограничен (последние N сообщений), память на пользователя — константная, а не растёт с длиной разговора. -
Время жизни ключа продлевается при каждой записи. Неактивные пользователи удаляются сами. Важно: в настройках Redis выставить
maxmemory-policy allkeys-lru— дефолтная политикаnoevictionотказывает в записи при переполнении памяти, и это сюрприз в самый неподходящий момент. -
Pipelined запись.
rpush+ltrim+expireуходят одним сетевым запросом в Redis, а не тремя.
Чтение — три источника параллельно
async def build_prompt_context(user_id: int, char_id: str, user_query: str) -> dict:
recent, summary, memories = await asyncio.gather(
get_recent(user_id, char_id),
get_latest_summary(user_id, char_id),
get_relevant_memories(user_id, char_id, user_query),
)
return {"recent": recent, "summary": summary, "memories": memories}
Кэш свежего summary живёт в Redis с коротким TTL; если протух — ChromaDB-запрос и write-back в Redis, чтобы следующий вызов снова был горячим.
Грабли, которые выловил в проде
-
Состояние гонки между двумя суммаризациями. Два сообщения от одного пользователя приходят почти одновременно, оба запускают суммаризацию, в коллекцию падают пересекающиеся документы. У меня в проде живёт глобальный словарь
_SUMMARIZE_TASKS: dict[str, asyncio.Task]с ключомf"{user_id}:{char_id}:{session_id}"; при появлении новой задачи предыдущая отменяется черезtask.cancel(). -
Пользователь очистил историю прямо во время суммаризации. Жмёт «сбросить чат», пока фоновая задача всё ещё работает. Пересказ прилетает в коллекцию, которой уже нет. Лечится проверкой существования ключа Redis перед записью; если ключ исчез — задача тихо завершается.
-
Пустые пересказы кешируются с длинным TTL. LLM ответил пустотой из-за превышения частоты запросов (rate limit), а я закешировал пустую строку на 3 дня. Лечится тривиально —
if summary:перед записью в кеш. -
Коллекции нет у новых пользователей. Запрос к несуществующей коллекции ChromaDB бросает исключение. Оборачиваем в
try/exceptи возвращаем пустоту — нормально для первых сообщений нового пользователя.
2. Маршрутизация LLM и кеш промптов
Почему одна модель на всё не работает
У меня была мысль взять одну хорошую модель и не заморачиваться. Через пару недель в проде стало понятно, почему так не работает. Три причины.
Бесплатный и платный тарифы тянут экономику в разные стороны. На бесплатном тарифе пользователь шлёт 20 сообщений в день. Если каждое уходит на флагманскую модель — этот бесплатник стоит вам дороже, чем сам платит. А платит он, на минуточку, ноль. На верхнем платном тарифе человек ждёт качества за свои деньги: будь добр, дай ему флагман. Одна модель на всех — это либо тихая дотация бесплатных за счёт платящих, либо платящий получает ровно то же, что бесплатный, и обижается. Среднее арифметическое тут никого не устраивает.
Модели по-разному относятся к контенту. GPT-4 и Claude по умолчанию отказывают на сценах, которые для компаньон-продукта на платном тарифе совершенно нормальны и разрешены законом для совершеннолетней аудитории. Менее зарегулированные модели соглашаются, но хуже держат связность на длинном контексте — забывают, кто что сказал десять реплик назад. Чем-то всегда приходится жертвовать.
У пользователя есть собственный выбор темпа. В UI он выбирает между двумя режимами отношений: slow_burn («давайте сначала познакомимся, без 18+, медленное развитие») и instant («сразу к интересному, без долгих прелюдий»). Плюс в БД ещё живёт legacy-значение natural — дефолт для тех, кто настройку темпа никогда не открывал. Эти темпы влияют не только на сценарий разговора, но и на ожидание от модели: на instant пользователь обычно шлёт более короткие реплики и ждёт ответа за 3 секунды; на slow_burn или natural — длинные описательные сцены, тут терпит и 10. Жёстко зашитая одна модель проигрывает либо первому, либо второму: либо медленно отвечает в коротком чате, либо штампует сухие фразы там, где ждали сцену.
Поверх темпа есть ещё стиль ответа (response_style) — отдельное измерение: standard (дефолт), cinematic (длинная сцена с разметкой действий через ✦action✦), brief (1-2 коротких предложения), slang (SMS-стиль), conversational (живой разговор без action markers). Стиль и темп независимы: на любом темпе пользователь может выбрать любой стиль. На маршрутизацию модели стиль почти не влияет, но влияет на формат финального промпта — brief/slang/conversational вырезают cinematic-разметку и режут длину ответа на стороне промпта.
Отсюда схема маршрутизации в коде: (тариф) × (темп) → конкретная модель, плюс цепочка резервных моделей на случай отказа. Через OpenRouter — агрегатор LLM-провайдеров с единым API — это удобно: один ключ, один интерфейс, и при этом видно, чей именно бэкенд стоит за каждой моделью.
Карта моделей на сегодня
|
Тариф |
Темп отношений |
Модель |
Цена $/1M токенов вход/выход |
Кеш |
|---|---|---|---|---|
|
бесплатный / базовый / premium |
|
|
0.07 / 0.10 |
нет |
|
бесплатный / базовый / premium |
|
|
0.14 / 0.28 |
неявный, автоматический |
|
VIP / Elite |
любой темп |
|
0.25 / 1.50 |
явный, через маркер |
|
резервные при отказе/пустоте |
— |
|
по факту использования |
— |
Qwen3-235B-A22B-2507 — самая дешёвая «приличная» модель из тех, что я пробовал. 235 миллиардов параметров, архитектура MoE (Mixture of Experts — внутри модели сидят несколько специализированных «экспертных» подсетей, на каждый запрос активируется только часть из них, поэтому она работает быстрее и дешевле плотной модели того же размера). Окно контекста 131 тыс. токенов, $0.10 за миллион токенов на выходе. Для бесплатного тарифа этого хватает с запасом.
Gemini 3.1 Flash Lite на платных тарифах даёт миллион токенов контекста и плотную связность длинных сцен, но без кеша она дороже Qwen в 3.5 раза по входу и в 15 раз по выходу. Поэтому в следующем блоке — про кеш.
Кеш промптов на Gemini 3.1: где спрятались 25% бюджета
Принцип кеша обидно очевиден, когда узнаёшь его постфактум. Каждый запрос к модели начинается с одного и того же большого куска — системного промпта с описанием персонажа, правилами поведения, инструкциями по тону. Эта часть идентична от запроса к запросу в рамках одного диалога. Провайдер может закешировать её после первого вызова и в следующих запросах брать оттуда же по цене в несколько раз ниже обычного входа. Платите только за уникальную часть — за свежее сообщение пользователя.
OpenRouter поддерживает кеш, но как именно — очень неровно по моделям. Я полтора месяца платил полную цену за Gemini, пока не дочитал доки до конца и не вкрутил один маркер на одну строку. Эффект — около 25% всего LLM-бюджета по проекту. Один из тех случаев, когда смотришь на свой коммит и не знаешь, гордиться или ругаться.
Эмпирическая картина:
-
DeepSeek V4 Flash — кеширует сам, без настройки. Тест показал 1.5-1.8 тыс. токенов кешируются на каждый ход.
-
DeepSeek Chat v3.1 — кеша не наблюдал. Пропускаем.
-
Qwen3-235B — нет в списке поддерживаемых OpenRouter-ом. Пропускаем.
-
Gemini 3.1 Flash Lite — требует явный маркер
cache_controlв запросе (для ветки 3.x, в отличие от 2.5 — там автоматически). На тесте 3772 из 3779 токенов промпта попали в кеш. Цена чтения — примерно 25% от обычного ввода, то есть экономия 75% на закешированной части.
Срок жизни кеша короткий — 5 минут (ephemeral), но OpenRouter использует sticky-роутинг (направляет последующие запросы того же диалога к тому же бэкенд-провайдеру), поэтому кеш остаётся горячим, пока пользователь продолжает разговор. Минимальный объём для маркера — около 1024 символов; системный промпт почти всегда в это окно попадает.
Код, который у меня в проде:
_EXPLICIT_CACHE_PREFIXES = ("google/gemini-",)
def _apply_prompt_caching(messages: list[dict], model: str) -> list[dict]:
"""Завернуть первое system-сообщение в blocks-форму с маркером
cache_control: ephemeral — для провайдеров, которым нужен явный
маркер (семейство Gemini). Остальные провайдеры либо кешируют
неявно (DeepSeek V4), либо игнорируют маркер тихо."""
if not model.startswith(_EXPLICIT_CACHE_PREFIXES):
return messages
out: list[dict] = []
cached_one = False
for m in messages:
if not cached_one and m.get("role") == "system":
content = m.get("content")
if isinstance(content, str) and len(content) >= 1024:
new_msg = dict(m)
new_msg["content"] = [{
"type": "text",
"text": content,
"cache_control": {"type": "ephemeral"},
}]
out.append(new_msg)
cached_one = True
continue
out.append(m)
return out
Четыре детали, в которых легко промахнуться:
-
Маркер только на те семейства, что его требуют. Остальные провайдеры игнорируют поле тихо, но некоторые клиентские библиотеки OpenAI SDK могут отвергнуть его на валидации.
-
Маркер ставится только на системный промпт. История сообщений у каждой реплики разная, кешировать её бессмысленно. Системный промпт — самая большая и при этом самая стабильная часть запроса, она и даёт 90% экономии.
-
Порог 1024 символа. Ниже OpenRouter не кеширует, маркер ничего не делает.
-
cache_controlтребует другого формата поляcontent. Обычныйcontent: "строка"маркер не примет — нужно превратить в массив блоков[{type, text, cache_control}]. В доках OpenRouter это упомянуто между строк, но обязательно.
Ловушка пустого ответа с HTTP 200
Современные модели-«рассуждатели» у части провайдеров делают проверку контента до того, как вернуть финальный ответ. На пограничном запросе они не возвращают HTTP-ошибку. Они возвращают HTTP 200 с таким телом:
{
"choices": [{
"finish_reason": "content_filter",
"message": { "content": "" }
}]
}
Пустая строка. Без исключения. Без статуса, на который можно повесить повторный запрос. Если ваша retry-логика срабатывает только на httpx.HTTPStatusError — пустой ответ улетает прямо в чат пользователю.
Лечится одной функцией, которая проверяет ответ перед тем, как отдать его дальше:
def _is_silent_refusal(choice: dict) -> bool:
"""Reasoning-модели иногда возвращают HTTP 200 + finish_reason=content_filter
+ content="". Если смотреть только на статус — юзер получает пустую реплику."""
reason = choice.get("finish_reason")
content = choice.get("message", {}).get("content") or ""
return reason in ("content_filter", "length") and not content.strip()
content.strip() проверяется отдельно от finish_reason — некоторые модели возвращают finish_reason=stop с пустым content, когда отказывают мягко.
Цепочка резервных моделей
async def complete(messages, *, primary=None, chain=None) -> CompletionResult:
models = list(chain) if chain is not None else _build_chain(primary)
async with httpx.AsyncClient() as client:
for attempt, model in enumerate(models):
try:
data = await _call(client, model, messages)
except httpx.HTTPStatusError as e:
if e.response.status_code in TRANSIENT_CODES:
continue
raise
except (httpx.ReadTimeout, httpx.ConnectError):
continue
choice = (data.get("choices") or [{}])[0]
if _is_silent_refusal(choice):
continue
content = choice.get("message", {}).get("content") or ""
if not content.strip():
continue
return CompletionResult(content=content, model=model, attempt=attempt)
raise AllModelsFailedError(f"no model returned usable content; tried {models}")
Правило шага по цепочке — разные провайдеры. Если основная модель хостится у провайдера A и упала, резервная должна идти через провайдера B. Резерв у того же провайдера падает на том же контенте, потому что фильтр модерации часто стоит до самой модели, на API-шлюзе. У OpenRouter это видно в метаданных каждой модели.
Что логировать:
-
Какая модель в итоге ответила (основная или резервная, и её порядковый номер в цепочке). Если основная отказывает на 10% запросов какого-то класса — это не проблема повторов, это проблема маршрутизации: подвиньте этот класс на другую основную модель.
-
Время до первого токена vs общее время ответа — подсказывает, где затык: на стороне сервера модели или у вашего сетевого стека.
-
Стоимость в токенах (вход + выход) с разбивкой по тарифу.
3. Визуальная консистентность: LoRA и IP-Adapter
Две задачи в одном разделе, потому что вторая решается поверх первой:
-
Лицо персонажа стабильно от генерации к генерации — это LoRA.
-
На этом персонаже надо отрендерить конкретный товар из каталога, и при этом не сломать ему лицо — это IP-Adapter, накрученный поверх LoRA.
3.1. Почему «тот же промпт = то же лицо» не работает
Интуитивно кажется:
"anime girl, long silver hair, green eyes, Arknights operator outfit"
+ seed=12345
→ Анна. Всегда.
На практике — нет. Три причины:
-
Размер батча меняет вывод. В большинстве конфигов Stable Diffusion одна картинка с
batch_size=1и первая картинка из батчаbatch_size=4при одинаковом seed получаются разными. Состояние генератора случайных чисел зависит от размерности батча, и это не баг — это особенность реализации сэмплеров. -
Сдвиг сэмплера на чужих API. Если вы дёргаете внешние сервисы (
fal.ai,Replicate,Together), провайдер обновляет модель, меняет параметры по умолчанию, переключает сэмплер — и ваш «закреплённый» персонаж дрейфует за недели. У меня один из персонажей за месяц «постарел лет на пять», потому что провайдер откатил минорную версию модели и никому не сказал. -
Подробный промпт насыщается. После определённого количества тегов добавление новых перестаёт помогать. Модель оперирует приблизительным шаблоном персонажа и интерполирует внутри него (то есть подставляет «среднее» из чего-то похожего, что видела на тренировке).
3.2. IP-Adapter в одиночку даёт слабый результат
IP-Adapter — это техника, которая позволяет прокинуть референс-картинку в дополнение к текстовому промпту; модель учитывает её визуальные признаки во время генерации. Для рендера товаров — отлично.
Для сохранения лица персонажа проблема в том, что IP-Adapter тянет за собой всё с референс-картинки — освещение, позу, фон, иногда даже саму одежду. Снизишь силу его влияния — лицо ослабевает; поднимешь — референс начинает доминировать над генерацией.
IP-Adapter правильно работает там, где референс — это то, что вы хотите сохранить (конкретный товар или объект). Когда вы хотите сохранить только лицо — это плохой инструмент.
3.3. LoRA на персонажа: масштаб и стоимость
LoRA (Low-Rank Adaptation) — небольшой набор дополнительных весов, который накладывается поверх базовой модели Stable Diffusion. По размеру файл LoRA — 100-300 МБ против 6-7 ГБ у базовой SDXL, то есть в 20-30 раз компактнее. Натренированный на 20-30 чистых картинках одного персонажа в разных позах, ракурсах и освещении, он кодирует внешность лица прямо в весах нейросети, а не в текстовом промпте. Получается, что модель «выучила», как выглядит конкретно этот персонаж, и стабильно его генерирует от запроса к запросу.
Реальные цифры на моей нагрузке:
-
В каталоге 80+ персонажей, у каждого свой LoRA. Каталог растёт по мере того, как мы добавляем новых, а не разово.
-
Тренировка — на арендованной GPU через Vast.ai, RTX 3090/4090 с 24 GB видеопамяти. GPU взята помесячно, поэтому marginal cost самой тренировки и последующего инференса близок к нулю: мы платим фиксированную аренду, а сколько LoRA на ней натренируем за месяц — столько и натренируем. Деньги начинают капать, только если GPU offline и мы вынужденно валимся на платный fallback (OpenRouter Flux/Riverflow — $0.03-0.07 за картинку).
-
По времени один LoRA для SDXL — 15-25 минут GPU-работы в зависимости от размера датасета и числа шагов.
-
Чекпоинты лежат в Storj S3, один safetensors-файл LoRA — 100-300 МБ (зависит от rank, у нас обычно 16-32). На фоне базовой SDXL в 6-7 ГБ это всё равно компактно: можно хранить десятки тысяч персонажей без боли по дискам.
Запуск картинок (inference) — отдельная GPU с предзагруженной базовой моделью SDXL и горячей сменой LoRA под каждый запрос. Базовый файл модели грузится в видеопамять один раз и сидит там; смена LoRA на конкретного персонажа — десятки миллисекунд.
Pipeline:
workflow = [
"Checkpoint", # базовая SDXL
f"LoRA: {char.lora}", # кастомный LoRA персонажа
"FreeU", # noise rebalance, +качество без compute
"KSampler",
]
Для проектов с одним-двумя персонажами LoRA-пайплайн избыточен. Для 50+ персонажей это единственная разумная архитектура.
3.4. Что важно в тренировке
Каркас конфига для тренера Kohya_ss — с пустыми местами под подбор параметров под вашу задачу:
[model_arguments]
pretrained_model_name_or_path = "PATH_TO_SDXL_BASE.safetensors"
[dataset_arguments]
train_data_dir = "./dataset/train"
resolution = "1024,1024"
caption_extension = ".txt"
[training_arguments]
output_dir = "./output"
output_name = "YOUR_CHARACTER_V1"
learning_rate = "TUNE_ME"
max_train_steps = "TUNE_ME"
train_batch_size = "TUNE_ME"
[network_arguments]
network_module = "networks.lora"
network_dim = "TUNE_ME"
network_alpha = "TUNE_ME"
Параметры, которые реально решают (learning rate, число шагов, rank, alpha, размер датасета), зависят от того, кого вы учите. Аниме-лица сходятся не так, как реалистичные. Универсально лучшего набора параметров не существует — сделайте 3-4 прогона с разными значениями и сравните сетку результатов.
Правила, которые работают:
-
Качество датасета важнее размера. 20 чистых, разнообразных картинок с подписями бьют 100 грязных.
-
Разная поза и освещение, одно лицо. 30 раз один ракурс учит «этот ракурс», а не «этого персонажа».
-
Подпись (caption) описывает сцену, а не персонажа. «Девушка в саду» лучше, чем «Анна в саду» — вы хотите, чтобы модель учила лицо из визуального контекста, а не привязывала его к слову «Анна».
-
Параметр rank подбирается отдельно. Низкое значение — модель недоучивается, лицо получается размытое; высокое — переобучается, лицо становится «деревянным» и плохо гнётся под изменения позы или эмоции в промпте.
3.5. IP-Adapter поверх LoRA для каталога: балансировка двух параметров
Теперь задача: у нас есть Анна (стабильный LoRA), пользователь покупает конкретное платье. Нужно одновременно: лицо Анны не дрейфует, именно это платье отрендерено узнаваемо.
Промпт-инжиниринг это не вытянет: «Anna wearing a red silk dress with a white collar» сгенерит какое-то красное шёлковое платье, не именно это. SKU-точность требует референса в пайплайне.
Конфликт LoRA и IP-Adapter: LoRA фиксирует лицо, IP-Adapter тянет на себя всё (включая лицо, если оно есть на референсе). На высокой силе IP-Adapter Анна начинает походить на референс; на низкой — платье «примерно того цвета», а не именно оно.
Решение — две настройки:
-
weight— насколько сильно IP-Adapter влияет на генерацию. Это число от 0 до 1. Ниже середины диапазона референс становится «настроением». Выше — доминирует над всем. Нижняя половина (0.2-0.5) обычно даёт лучший результат. -
end_at— на какой доле шагов генерации IP-Adapter отключается. Stable Diffusion рисует картинку не за один шаг, а постепенно «убирает шум» (denoising) за 20-50 шагов. Если IP-Adapter работает все шаги — он влияет на финальные детали лица. Если отключается на 70-90% пути — последние шаги уже работают только под весами LoRA, и лицо «перетягивается» обратно к нашему персонажу.
Грубо говоря: товар получает свою форму в середине процесса генерации, а лицо доводится уже в самом конце.
Порядок нод в ComfyUI:
[Checkpoint Loader]
→ [LoRA Loader: character_lora]
→ [FreeU: quality touch-up]
→ [IPAdapter Advanced: reference, weight=W, end_at=E]
→ [KSampler]
→ [VAE Decode]
LoRA идёт до IP-Adapter в цепочке. LoRA модифицирует сами веса базовой модели; IP-Adapter модифицирует только промежуточные слои внимания (cross-attention) во время самой генерации. Когда IP-Adapter отключается на шаге end_at, оставшиеся шаги работают по LoRA-модифицированной модели без влияния IP-Adapter — именно это и позволяет лицу персонажа вернуться к норме.
Как подбирать weight и end_at на практике:
-
Возьмите референс с чистым фоном и персонажа с уже стабильным LoRA.
-
Начните с
weight=0.4, end_at=0.8— это значения, на которых у меня в проде получается стабильный баланс лица и одежды. Сгенерируйте, посмотрите на результат. -
Лицо уплыло → опустите
weightилиend_atна 0.05. -
Товар не похож на референс → поднимите
weightна 0.05. -
Шаг — 0.05, не 0.1. Рабочий диапазон уже, чем кажется на глаз.
При смене базовой модели обе цифры скорее всего сдвинутся — не закладывайте их как константы в коде.
3.6. Как это собрано в продукте
-
Каталог как набор референсов. Каждый товар хранит ссылку на свою референс-картинку в S3.
-
Превью генерим заранее. Когда пользователь открывает магазин, он видит превью каждого товара на своём активном персонаже. Эти превью рендерятся не в момент открытия страницы, а заранее, фоновой задачей (Celery), и потом просто отдаются из кеша в S3.
-
Те же значения
weightиend_atидут в стартовый кадр видео. Сначала откатайте параметры на статичных картинках, потом аккуратно прокидывайте в видео-пайплайн. -
Не у каждого товара есть визуал. Часть позиций — бонус к характеристикам, разблокировка диалогов, флаг отношений. У них нет картинки, и пытаться отрендерить их через ComfyUI бесполезно. На каталоге есть явный флаг
visual: true|false, и API режет невизуальные товары на входе, до постановки задачи в очередь GPU.
3.7. Грабли визуального стека
-
Лицо поплыло на превью товаров в магазине. Я выкрутил
weightIP-Adapter слишком высоко — мне хотелось «получше передать одежду». Откатился в нижнюю половину диапазона после всплеска жалоб. Урок банальный: подбирай одну переменную за раз, даже когда кажется, что медленно. -
Подписанные ссылки на референсы истекали прямо на лету. Каталог в S3 раздавался через presigned URL с коротким сроком жизни. Фоновая задача подхватывала ссылку в очереди, а ComfyUI скачивал её позже — ссылка к этому моменту уже мёртвая. Лечится тем, что задача сама скачивает картинку, а в ComfyUI отправляет уже локальное имя файла.
-
Несовпадение версии IP-Adapter с базовой SDXL.
IP-Adapter Plusпоставляется в нескольких файлах, привязанных к конкретным версиям SDXL. Микс не падает с ошибкой — он просто молча даёт более тусклый результат. Закрепите версию IP-Adapter в конфиге деплоя рядом с базовой моделью, как часть пары. -
Невизуальный товар ронял пайплайн. Описал выше — API пытался прогнать товар без картинки через image-pipeline. Лечится флагом
visualна каталоге и проверкой на границе API ещё до постановки задачи в очередь.
4. Юнит-экономика на третьем месяце
Контекст
Проект живёт три месяца. Сотни ежедневных пользователей, не сотни тысяч. Конверсия в платный тариф — однозначные проценты, типичная цифра для ранней стадии. Это к чему: бесплатная аудитория должна обходиться очень дёшево в обслуживании, иначе бесплатные пользователи съедят бюджет до того, как вы поймёте, нашли ли вы своих платящих. Каждый процент, который вы скашиваете со стоимости одного бесплатного пользователя, — это лишние недели взлётной полосы.
Реальные потолки лежат в одном файле:
daily_cost_alert_usd: float = 30.0 # шлёт алёрт в Telegram админу, но не блокирует
daily_cost_hard_stop_usd: float = 50.0 # is_generation_allowed() возвращает False, новые генерации режутся
Месячный бюджет без вмешательства — порядка $900-1500. В типичный день тратим в разы меньше: десятки долларов. Пиковые сценарии (массовая генерация картинок/видео) могут подъезжать к уровню алёрта.
Счётчики и принцип «блокируй при сбое»
Счётчики живут в Redis со сроком жизни 7 дней:
costs:daily:{YYYY-MM-DD} — общая сумма $ за день
costs:user:{uid}:{YYYY-MM-DD} — сумма $ на конкретного пользователя за день
Запись — атомарный INCRBYFLOAT: одна команда Redis инкрементит число с плавающей точкой, никаких гонок при параллельных вызовах. Чтение в функции «можно ли запускать новую генерацию» — простой GET и сравнение с потолком.
Ключевая деталь: если Redis недоступен — функция возвращает «не разрешено». Это называется fail-closed подход. Звучит параноидально, пока однажды утром не обнаруживаешь, что ночью где-то крутилась лавинообразная генерация и за несколько часов выжгла дневной бюджет — никто этого не видел, потому что счётчик молчал. С тех пор правило железобетонное: если не можем измерить — блокируем. Цена ошибки в обратную сторону (пользователь не получил ответ) низкая; цена бесконтрольного цикла на API за $1.50 за миллион токенов на выходе — высокая.
Где деньги уходят
-
LLM Gemini 3.1 FL (тарифы VIP и Elite) — около 40-50% бюджета. Дорогой выход (1.50 USD за миллион токенов), частично закрыт кешем.
-
LLM Qwen3-235B (бесплатный, базовый, premium тарифы) — около 15-20%. Модель дешёвая, но запросов много.
-
LLM резервные (Grok, MiniMax) — менее 5%. Срабатывают только при отказе основной модели.
-
Голос Inworld TTS-1.5 Max — около 15-20%. 10 USD за миллион символов, примерно 0.005-0.01 USD на одно голосовое сообщение.
-
Картинки (Vast.ai GPU плюс резерв) — около 15-25%. GPU оплачивается фиксированным месячным платежом; резерв идёт через OpenRouter, 0.03-0.07 USD за картинку.
-
Видео (WaveSpeed и fal Pixverse) — пиковые расходы. 0.16-0.25 USD за ролик, доступно от тарифа Premium.
Что приносит кеш промптов
Грубая прикидка для одной VIP-реплики:
Без кеша:
системный промпт ~3800 токенов × $0.25/1M = $0.00095 на вход
+ output ~300 токенов × $1.50/1M = $0.00045
≈ $0.0014 на реплику
С кешем (cache hit на системном промпте, реплики 2+ в сессии):
системный промпт ~3800 токенов × $0.0625/1M = $0.000238 (75% off)
+ output ~300 токенов × $1.50/1M = $0.00045
≈ $0.00069 на реплику
Экономия около 50% на репликах 2+, и почти все реплики в живой сессии — это «реплики 2+». На моём миксе это закрывает около четверти всего LLM-бюджета.
DeepSeek V4 кеширует неявно — ещё 10-15% экономии на instant-pace репликах сверху. Qwen3 в OpenRouter-кеш не входит.
5. Прод-тюнинг: что подкрутил в инфре на третьем месяце
Архитектурой потолок DAU не сдвинешь, пока не разберёшься с операционным слоем — лимитами памяти, таймаутами, перезапусками процессов, потолками трат. Этой части почти нет в туториалах «как собрать чат-бота». Зато она напрямую решает, упрётесь вы в потолок при 500 ежедневных пользователях или при 1500. Дальше — четыре конкретные настройки, которые я менял за последние недели, с до/после в цифрах.
5.1. ChromaDB: LRU-вытеснение и поднятый лимит памяти
Эту проблему я ловил постепенно. Сначала контейнер ChromaDB начал съедать 2 ГБ. Поднял до 4. Через две недели снова OOMKill — поднял до 6. Через ещё неделю — кажется, что я просто оплачиваю гонку с растущим аппетитом базы.
Полез смотреть. Виновник нашёлся в открытых тикетах ChromaDB: issue #3336 и #5843, оба ещё не закрыты в ветке 0.5.x. Внутренний кеш индексов держит загруженные сегменты «вечно» и не выгружает их сам, даже когда коллекция давно никому не нужна. У меня 2233 коллекции (по одной на пару user × character × session), и каждая из них постепенно подтягивает свой индекс в память — никогда оттуда не уходя. Память контейнера росла стабильно: ~250 МБ в неделю.
Решение нашлось в той же документации: включить LRU-вытеснение. LRU расшифровывается как Least Recently Used — давно неиспользуемые коллекции выгружаются первыми, активные остаются в памяти. Включается двумя переменными окружения, плюс поднимаю жёсткий лимит контейнера до 10 ГБ:
chromadb:
environment:
CHROMA_SEGMENT_CACHE_POLICY: LRU
CHROMA_MEMORY_LIMIT_BYTES: "8589934592" # 8 ГБ — при превышении срабатывает LRU
deploy:
resources:
limits:
memory: 10G
После этого активный набор данных в памяти схлопнулся до 50-200 МБ — только сессии за последние пять минут. Холодные коллекции выгружаются автоматически, линейный рост остановился. Без LRU убийство по памяти пришло бы через 6-8 недель, с симпатичным риском бонусом: если ChromaDB упадёт ровно во время записи, тихо потеряются эмбеддинги последней сессии — без алёрта, без явных следов.
Дополнительные 2 ГБ поверх лимита LRU — запас не на работу базы, а под чтение во время бэкапа. tar в 3.3 ГБ во время снапшота создавал конкуренцию за IO с самим процессом ChromaDB; в Sentry мерцали сбои бэкапа, которые невозможно было воспроизвести руками. После увеличения лимита — пропало само собой.
Архитектурное ограничение, которое LRU не решает. 2233 коллекций — последствие моей же архитектуры памяти на уровне отдельных сессий (см. часть 1). Когда DAU вырастет до 5000+, коллекций станет тысяч 30, и LRU начнёт работать слишком агрессивно: постоянное «выгрузил-загрузил», задержка на запросах поползёт вверх. Тогда придётся переезжать на одну общую коллекцию с фильтром по session_id в метаданных запроса. Это рефакторинг на пару недель — отложил, пока не стало нужно.
5.2. Перезапуск воркеров uvicorn по числу запросов
Что было. В рабочем процессе FastAPI/uvicorn утечка памяти: стартовые 480 МБ → ~800 МБ за 8 часов работы. На 4 процесса по 1.5 ГБ устойчиво = 6 ГБ — это лимит контейнера, прогноз убийства по памяти при DAU ~500.
Что сделал. Добавил --limit-max-requests 5000 — uvicorn перезапускает воркер после N обработанных запросов (то есть утечка не успевает накопиться):
api:
command: >
uvicorn api.main:app
--workers 4
--limit-max-requests 5000
--timeout-graceful-shutdown 90
Что получил. При DAU 300 и ~30 тысячах запросов в день: 4 воркера → 1-2 перезапуска на каждый воркер в сутки → память каждого возвращается к старту 480 МБ. На 4 × 480 МБ устойчиво = 1.9 ГБ, в 6 ГБ лимит влезает с трёхкратным запасом. Потолок API по DAU сдвинулся с ~500 до ~1200.
5.3. Мягкое выключение: 90 секунд на завершение запросов LLM
Решение из предыдущего пункта родило неожиданный побочный эффект. Воркер теперь регулярно перезапускается. И каждый раз, когда он стартует заново, он обрывает запросы, которые были в работе — а LLM-вызов длится 10-60 секунд, его так просто не закончишь. Пользователь видит в чате «Network error», ошибку 502 или пустой ответ. Прикинул на бумажке: 6 перезапусков в сутки × ~3 запроса в работе на каждый = ~18 ошибок в день, которые видят живые люди. И это только из-за моей же оптимизации.
Дал воркеру 90 секунд на корректное завершение запросов в работе. Важно — в двух слоях:
api:
command: uvicorn ... --timeout-graceful-shutdown 90
stop_grace_period: 90s # docker ждёт 90s, прежде чем послать SIGKILL
Что получил. P99 (99-й процентиль времени) LLM-вызова — 60 секунд, запас 50%. На той же частоте перезапусков — 0 ошибок у пользователей. При росте до DAU 1000 без этой настройки было бы 60+ ошибок в день.
Деталь, в которой легко промахнуться: одно изменение в uvicorn без увеличения stop_grace_period в docker-compose ничего не даёт — Docker всё равно прибьёт контейнер через свой дефолт (10 секунд). Нужны обе настройки.
5.4. Дневной потолок трат: $30 → $50
В один из дней мая дневной расход подскочил до $21.70. Это близко к старому потолку $30, и неприятно близко: если такой же расход придётся на первую половину дня, к вечеру мы наверняка упрёмся в жёсткий стоп, и пользователи будут получать «генерация недоступна» вместо ответов. Поднял жёсткий потолок до $50, алёрт оставил на $30 (60% от крыши) — это даёт 5-6 часов запаса на то, чтобы заметить и среагировать до блока.
При текущем профиле пользователей (~$0.06 на одного DAU в день) жёсткий потолок $50 не сработает примерно до DAU 800. Дальше — либо рост платящей доли через апсейл, либо ещё один подъём планки и переоценка экономики.
Сводная: где сдвинулись потолки
|
Узкое место |
Было |
Стало |
Прирост |
|---|---|---|---|
|
Память API, убийство по нехватке |
~500 DAU |
~1200 DAU |
+140% |
|
Память ChromaDB |
~800 DAU |
~2000+ DAU |
+150% |
|
Жёсткий потолок трат |
~480 DAU |
~830 DAU |
+73% |
|
502 при перезапуске воркера |
~18/день |
0 |
устранено |
|
Конкуренция за IO во время бэкапа |
мерцающие сбои |
устранено |
устранено |
Цена изменений
-
+4 ГБ на хост-машине под лимит ChromaDB. На хосте свободно 18 ГБ, не давит.
-
0 нагрузки на CPU от LRU — это просто хеш-таблица с порядком обращений.
-
~0.5 секунды простоя на пересоздание контейнера: пересоздание синхронное, пользователи в худшем случае увидели один повтор запроса.
-
Риск низкий — все настройки из официальных доков ChromaDB и uvicorn, никакого хака.
Итог. Тюнинг купил порядка 6-12 месяцев без необходимости трогать архитектуру ChromaDB или железо. Следующие узкие места — Vast.ai GPU как единая точка отказа и единственный 32 ГБ хост, на котором всё крутится: упадёт машина — упадёт продукт целиком. После DAU 1500 это становится критично.
6. Что бы переделал, начав сейчас
Если бы я мог отмотать три месяца назад и собрать всё это заново с уже накопленным опытом, что бы поменял.
Память:
-
Не лез бы в
pgvectorдля такой формы нагрузки. На коротких запросах по пересказам качество поиска было хуже, чем уChromaDB. На других нагрузках pgvector может выиграть. -
Не превращал бы каждое сообщение в вектор. Индекс пухнет, качество поиска не растёт.
-
Суммаризировал бы фиксированными окнами по числу реплик, а не по времени. Дневной пересказ бесполезен для пользователя, который написал 500 раз за один день.
-
С первого дня закладывал бы паттерн отмены фоновых задач и поле
metadata.typeна каждом документе ChromaDB.
Маршрутизация LLM:
-
Маршрутизировал бы по темпу отношений с первого дня и сразу заложил бы возможность переопределить модель из стиля ответа. Переписывать существующий обработчик чата под маршрутизацию задним числом — больно.
-
Включил бы маркер
cache_controlна Gemini сразу — полтора месяца потерянного бюджета. -
Завёл бы отдельную метрику тихих отказов модели (HTTP 200 с пустым телом). Он редкий, но без специальной метрики его не увидишь.
-
Не использовал бы один и тот же ключ OpenRouter в dev и prod. Лимит запросов общий, шум разработки ест продакшен-квоту.
Картинки:
-
Запускал бы пайплайн с LoRA с первого дня, даже на трёх персонажах. Неконсистентные картинки на бесплатном тарифе убивают первое впечатление до того, как пользователь увидит сильные части продукта.
-
Делал бы датасеты руками, не скрейпил. 5 итераций по 20 ручных картинок бьют скрейп на 200.
-
Версионировал бы LoRA:
char_v1,char_v2живут параллельно; при регрессии откат отдельно по персонажу, а не всего пайплайна. -
Хранил бы значения настроек IP-Adapter (
weight,end_at) как часть параметров деплоя, а не как константы в коде. При смене базовой модели эти числа сдвигаются.
Юнит-экономика и прод-тюнинг:
-
Влил бы маркер
cache_controlв первый день. Однострочный helper, неделя на отладку — а я полтора месяца жил без него. -
Унифицировал бы счётчик трат на все типы генераций. Сейчас приходится обновлять словарь маппинга при каждом новом типе.
-
Сделал бы дневной потолок на каждого пользователя наравне с глобальным. Сейчас только глобальный жёсткий стоп — один особо активный пользователь может теоретически выбрать большую долю дневного бюджета до того, как сработает rate-лимит.
-
Завёл бы дашборд по доле попаданий в кеш. OpenRouter возвращает
prompt_tokens_details.cached_tokensв каждом ответе, но без агрегации в дашборд вы не заметите, если кеш «отвалится» из-за изменения формата промпта (например, динамическая дата в начале — и кеш не попадает).
Что НЕ закрыто и всё ещё узкие места:
-
Vast.ai GPU как единая точка отказа. Решение — второй GPU в горячем резерве. Окупится при DAU 1500+.
-
Один 32 ГБ хост. Если умирает машина — умирает весь продукт. На нашем масштабе пока не критично.
-
2233 коллекции в ChromaDB — архитектурное ограничение. LRU маскирует, не решает.
Где это работает в проде
Всё описанное выше живёт под одним бэкендом проекта HoneyChat — это AI-компаньон, который работает одновременно как Telegram-бот (@HoneyChatAIBot) и как веб-приложение на honeychat.bot. То есть один и тот же чат, память, персонажи и LoRA — доступны и из Telegram, и из браузера, с синхронизацией истории между ними.
Если хочется потрогать ту самую архитектуру, про которую статья:
-
Открыть в Telegram: @HoneyChatAIBot —
/start, free-тариф даёт 20 сообщений в день без регистрации. -
Открыть в браузере: honeychat.bot — тот же бэкенд, полный чат-интерфейс, картинки и голос.
-
Посмотреть код: публичные tutorial-папки с runnable примерами по каждой из четырёх инженерных частей лежат в github.com/sm1ck/honeychat/tree/main/tutorial — клонируется и поднимается через
docker compose.
Если строите что-то похожее и упёрлись в одно из мест выше — пишите в комментариях, особенно интересуют чужие решения по гонкам вокруг пользовательских действий (clear history, switch character) и по тюнингу weight/end_at на свежих SDXL-форках. По этой связке очень мало публичных материалов вне аниме-комьюнити.
Источники
Память:
LLM:
Визуал:
Инфра:
Автор: sm1ck


