📱 Подписаться
IT и цифровая трансформация

AI-компаньон в проде на третьем месяце — 5 архитектурных решений и инфра-тюнинг

📰 Habr 👁️ 1 просмотров

sm1ck12 минут назад

AI-компаньон в проде на третьем месяце — 5 архитектурных решений и инфра-тюнинг

Уровень сложностиСреднийВремя на прочтение25 минОхват и читатели375Искусственный интеллектPython*Машинное обучение*КейсИз песочницыКаждый, кто пробовал собрать 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 токенов вход/выходКешбесплатный / базовый / premiumslow_burn, natural (legacy)qwen/qwen3-235b-a22b-25070.07 / 0.10нетбесплатный / базовый / premiuminstant + явный запросdeepseek/deepseek-v4-flash0.14 / 0.28неявный, автоматическийVIP / Eliteлюбой темпgoogle/gemini-3.1-flash-lite0.25 / 1.50явный, через маркеррезервные при отказе/пустоте—x-ai/grok-4.20, затем minimax/minimax-m2-herпо факту использования—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. Грабли визуального стека

• Лицо поплыло на превью товаров в магазине. Я выкрутил weight IP-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-форках. По этой связке очень мало публичных материалов вне аниме-комьюнити.

Источники

Память:

• ChromaDB docs
• ChromaDB issue #3336 — segment cache growth
• ChromaDB issue #5843 — LRU eviction
• Redis LTRIMLLM:

• OpenRouter model list
• OpenRouter prompt caching docs
• Chat Completions finish_reason semanticsВизуал:

• LoRA paper — Hu et al., 2021
• Kohya_ss SDXL training
• IP-Adapter (tencent-ailab)
• ComfyUI IPAdapter Plus
• SDXL base modelИнфра:

• uvicorn deployment
• Docker Compose stop_grace_periodТеги:• ИИ
• LLM
• OpenRouter
• Stable Diffusion
• LoRA
• ComfyUI
• Redis
• ChromaDB
• Python
• чатботыХабы:• Искусственный интеллект
• Python
• Машинное обучение

Получайте больше инсайтов о систематизации бизнеса

Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал