- BrainTools - https://www.braintools.ru -

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

Каждый, кто пробовал собрать AI-чат по типовой схеме — chat-completions API, OpenAI Memory, один эндпоинт Stable Diffusion — рано или поздно упирается в одни и те же стены. Бот забывает [1] разговор через десять реплик. Иногда сервер бодро отвечает HTTP 200, как будто всё в порядке, а внутри — пустая строка: ни ошибки [2], ни таймаута, модель просто отказалась говорить и сделала это молча. Один и тот же текстовый запрос рисует двух разных персонажей. А одеть нарисованного персонажа в конкретное платье из каталога не получается вообще.

Я три месяца держу в проде AI-компаньона: один и тот же бэкенд обслуживает и Telegram-бот, и веб-приложение. Аудитория — сотни ежедневных пользователей, не сотни тысяч. Конверсия из бесплатного в платный тариф — однозначные проценты, как у любого продукта на ранней стадии. Поэтому в статье не будет цифр про «миллион MAU», но будут цены за тысячу токенов, реальные доли попаданий в кеш, дневные потолки трат и до/после по тонкой настройке прода.

Эта статья — четыре инженерных build-log поста, которые я выкладывал на dev.to (серия «Building HoneyChat»), сведённые в один связный материал на русском. Плюс два раздела, которых в исходниках не было: про деньги (юнит-экономика на третьем месяце) и про операционный тюнинг, который сдвинул потолок DAU больше чем в два раза без переписывания архитектуры.

Оглавление

  1. Память [3]: Redis + ChromaDB

  2. Маршрутизация LLM и кеш промптов

  3. Визуальная консистентность: LoRA и IP-Adapter

  4. Юнит-экономика на третьем месяце

  5. Прод-тюнинг: что подкрутил в инфре на третьем месяце

  6. Что бы переделал, начав сейчас

  7. Где это работает в проде и источники

TL;DR

  • ПамятьRedis под свежий буфер реплик плюс ChromaDB под сжатые пересказы кусков диалога. Три чтения параллельно. Превращать каждое отдельное сообщение в вектор — прямая дорога к индексу на миллионы документов с плохим поиском.

  • Маршрутизация LLM — у пользователя в UI два темпа отношений (slow_burn и instant) плюс legacy-дефолт natural. Под каждый темп, под каждый тариф — своя модель. Плюс цепочка резервных через разных провайдеров. Главная ловушка, на которой все спотыкаются: модель отвечает HTTP 200, а внутри пустая строка и причина «сработал фильтр контента» — не ошибка, не падение, просто тишина.

  • Кеш промптов — на Gemini 3.1 Flash Lite один маркер cache_control: ephemeral поверх системного промпта (это стартовые инструкции с описанием персонажа и правилами поведения [4]) экономит 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()

Три вещи, которые здесь важны:

  1. ltrim на каждой записи. Список ограничен (последние N сообщений), память на пользователя — константная, а не растёт с длиной разговора.

  2. Время жизни ключа продлевается при каждой записи. Неактивные пользователи удаляются сами. Важно: в настройках Redis выставить maxmemory-policy allkeys-lru — дефолтная политика noeviction отказывает в записи при переполнении памяти, и это сюрприз в самый неподходящий момент.

  3. 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 [5] — агрегатор LLM-провайдеров с единым API — это удобно: один ключ, один интерфейс, и при этом видно, чей именно бэкенд стоит за каждой моделью.

Карта моделей на сегодня

Тариф

Темп отношений

Модель

Цена $/1M токенов вход/выход

Кеш

бесплатный / базовый / premium

slow_burn, natural (legacy)

qwen/qwen3-235b-a22b-2507

0.07 / 0.10

нет

бесплатный / базовый / premium

instant + явный запрос

deepseek/deepseek-v4-flash

0.14 / 0.28

неявный, автоматический

VIP / Elite

любой темп

google/gemini-3.1-flash-lite

0.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% бюджета

Принцип кеша обидно очевиден, когда узнаёшь его постфактум. Каждый запрос к модели начинается с одного и того же большого куска — системного промпта с описанием персонажа, правилами поведения [6], инструкциями по тону. Эта часть идентична от запроса к запросу в рамках одного диалога. Провайдер может закешировать её после первого вызова и в следующих запросах брать оттуда же по цене в несколько раз ниже обычного входа. Платите только за уникальную часть — за свежее сообщение пользователя.

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

Четыре детали, в которых легко промахнуться:

  1. Маркер только на те семейства, что его требуют. Остальные провайдеры игнорируют поле тихо, но некоторые клиентские библиотеки OpenAI SDK могут отвергнуть его на валидации.

  2. Маркер ставится только на системный промпт. История сообщений у каждой реплики разная, кешировать её бессмысленно. Системный промпт — самая большая и при этом самая стабильная часть запроса, она и даёт 90% экономии.

  3. Порог 1024 символа. Ниже OpenRouter не кеширует, маркер ничего не делает.

  4. 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 [7] — это техника, которая позволяет прокинуть референс-картинку в дополнение к текстовому промпту; модель учитывает её визуальные признаки во время генерации. Для рендера товаров — отлично.

Для сохранения лица персонажа проблема в том, что 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 ГБ это всё равно компактно: можно хранить десятки тысяч персонажей без боли [8] по дискам.

Запуск картинок (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 подбирается отдельно. Низкое значение — модель недоучивается, лицо получается размытое; высокое — переобучается, лицо становится «деревянным» и плохо гнётся под изменения позы или эмоции [9] в промпте.

3.5. IP-Adapter поверх LoRA для каталога: балансировка двух параметров

Теперь задача: у нас есть Анна (стабильный LoRA), пользователь покупает конкретное платье. Нужно одновременно: лицо Анны не дрейфует, именно это платье отрендерено узнаваемо.

Промпт-инжиниринг это не вытянет: «Anna wearing a red silk dress with a white collar» сгенерит какое-то красное шёлковое платье, не именно это. SKU-точность требует референса в пайплайне.

Конфликт [10] 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 модифицирует только промежуточные слои внимания [11] (cross-attention) во время самой генерации. Когда IP-Adapter отключается на шаге end_at, оставшиеся шаги работают по LoRA-модифицированной модели без влияния IP-Adapter — именно это и позволяет лицу персонажа вернуться к норме.

Как подбирать weight и end_at на практике:

  1. Возьмите референс с чистым фоном и персонажа с уже стабильным LoRA.

  2. Начните с weight=0.4, end_at=0.8 — это значения, на которых у меня в проде получается стабильный баланс лица и одежды. Сгенерируйте, посмотрите на результат.

  3. Лицо уплыло → опустите weight или end_at на 0.05.

  4. Товар не похож на референс → поднимите weight на 0.05.

  5. Шаг — 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 [12] и #5843 [13], оба ещё не закрыты в ветке 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. Что бы переделал, начав сейчас

Если бы я мог отмотать три месяца назад и собрать всё это заново с уже накопленным опытом [14], что бы поменял.

Память:

  • Не лез бы в pgvector для такой формы нагрузки. На коротких запросах по пересказам качество поиска было хуже, чем у ChromaDB. На других нагрузках pgvector может выиграть.

  • Не превращал бы каждое сообщение в вектор. Индекс пухнет, качество поиска не растёт.

  • Суммаризировал бы фиксированными окнами по числу реплик, а не по времени. Дневной пересказ бесполезен для пользователя, который написал 500 раз за один день.

  • С первого дня закладывал бы паттерн отмены фоновых задач и поле metadata.type на каждом документе ChromaDB.

Маршрутизация LLM:

  • Маршрутизировал бы по темпу отношений с первого дня и сразу заложил бы возможность переопределить модель из стиля ответа. Переписывать существующий обработчик чата под маршрутизацию задним числом — больно.

  • Включил бы маркер cache_control на Gemini сразу — полтора месяца потерянного бюджета.

  • Завёл бы отдельную метрику тихих отказов модели (HTTP 200 с пустым телом). Он редкий, но без специальной метрики его не увидишь.

  • Не использовал бы один и тот же ключ OpenRouter в dev и prod. Лимит запросов общий, шум разработки ест продакшен-квоту.

Картинки:

  • Запускал бы пайплайн с LoRA с первого дня, даже на трёх персонажах. Неконсистентные картинки на бесплатном тарифе убивают первое впечатление [15] до того, как пользователь увидит сильные части продукта.

  • Делал бы датасеты руками, не скрейпил. 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 [16]) и как веб-приложение на honeychat.bot [17]. То есть один и тот же чат, память, персонажи и LoRA — доступны и из Telegram, и из браузера, с синхронизацией истории между ними.

Если хочется потрогать ту самую архитектуру, про которую статья:

  • Открыть в Telegram: @HoneyChatAIBot [16]/start, free-тариф даёт 20 сообщений в день без регистрации.

  • Открыть в браузере: honeychat.bot [17] — тот же бэкенд, полный чат-интерфейс, картинки и голос.

  • Посмотреть код: публичные tutorial-папки с runnable примерами по каждой из четырёх инженерных частей лежат в github.com/sm1ck/honeychat/tree/main/tutorial [18] — клонируется и поднимается через docker compose.

Если строите что-то похожее и упёрлись в одно из мест выше — пишите в комментариях, особенно интересуют чужие решения по гонкам вокруг пользовательских действий (clear history, switch character) и по тюнингу weight/end_at на свежих SDXL-форках. По этой связке очень мало публичных материалов вне аниме-комьюнити.

Источники

Память:

LLM:

Визуал:

Инфра:

Автор: sm1ck

Источник [30]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/31110

URLs in this post:

[1] забывает: http://www.braintools.ru/article/333

[2] ошибки: http://www.braintools.ru/article/4192

[3] Память: http://www.braintools.ru/article/4140

[4] поведения: http://www.braintools.ru/article/9372

[5] OpenRouter: https://openrouter.ai

[6] поведения: http://www.braintools.ru/article/5593

[7] IP-Adapter: https://github.com/tencent-ailab/IP-Adapter

[8] боли: http://www.braintools.ru/article/9901

[9] эмоции: http://www.braintools.ru/article/9540

[10] Конфликт: http://www.braintools.ru/article/7708

[11] внимания: http://www.braintools.ru/article/7595

[12] issue #3336: https://github.com/chroma-core/chroma/issues/3336

[13] #5843: https://github.com/chroma-core/chroma/issues/5843

[14] опытом: http://www.braintools.ru/article/6952

[15] впечатление: http://www.braintools.ru/article/2012

[16] @HoneyChatAIBot: https://t.me/HoneyChatAIBot

[17] honeychat.bot: https://honeychat.bot

[18] github.com/sm1ck/honeychat/tree/main/tutorial: https://github.com/sm1ck/honeychat/tree/main/tutorial

[19] ChromaDB docs: https://docs.trychroma.com/

[20] Redis LTRIM: https://redis.io/commands/ltrim/

[21] OpenRouter model list: https://openrouter.ai/models

[22] OpenRouter prompt caching docs: https://openrouter.ai/docs/features/prompt-caching

[23] Chat Completions finish_reason semantics: https://platform.openai.com/docs/api-reference/chat/object

[24] LoRA paper — Hu et al., 2021: https://arxiv.org/abs/2106.09685

[25] Kohya_ss SDXL training: https://github.com/bmaltais/kohya_ss

[26] ComfyUI IPAdapter Plus: https://github.com/cubiq/ComfyUI_IPAdapter_plus

[27] SDXL base model: https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0

[28] uvicorn deployment: https://www.uvicorn.org/deployment/

[29] Docker Compose stop_grace_period: https://docs.docker.com/compose/compose-file/compose-file-v3/#stop_grace_period

[30] Источник: https://habr.com/ru/articles/1042280/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1042280

www.BrainTools.ru

Rambler's Top100