惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

MyScale Blog
MyScale Blog
量子位
宝玉的分享
宝玉的分享
爱范儿
爱范儿
云风的 BLOG
云风的 BLOG
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Recent Announcements
Recent Announcements
Apple Machine Learning Research
Apple Machine Learning Research
N
News and Events Feed by Topic
TaoSecurity Blog
TaoSecurity Blog
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
Simon Willison's Weblog
Simon Willison's Weblog
Google DeepMind News
Google DeepMind News
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
aimingoo的专栏
aimingoo的专栏
Cloudbric
Cloudbric
Blog — PlanetScale
Blog — PlanetScale
Latest news
Latest news
S
Security @ Cisco Blogs
Last Week in AI
Last Week in AI
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Vercel News
Vercel News
W
WeLiveSecurity
M
MIT News - Artificial intelligence
P
Proofpoint News Feed
P
Proofpoint News Feed
P
Palo Alto Networks Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
T
The Blog of Author Tim Ferriss
腾讯CDC
大猫的无限游戏
大猫的无限游戏
Martin Fowler
Martin Fowler
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
V2EX
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Stack Overflow Blog
Stack Overflow Blog
IT之家
IT之家
有赞技术团队
有赞技术团队
Microsoft Security Blog
Microsoft Security Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
美团技术团队
博客园 - 【当耐特】
D
DataBreaches.Net
I
InfoQ
G
GRAHAM CLULEY
S
SegmentFault 最新的问题
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
B
Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
AI-компаньон в проде на третьем месяце — 5 архитектурных решений и инфра-тюнинг
sm1ck · 2026-06-01 · via Все публикации подряд на Хабре

Средний

25 мин

7.8K

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

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

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

Оглавление

  1. Память: 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 поверх системного промпта (это стартовые инструкции с описанием персонажа и правилами поведения) экономит 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 — агрегатор 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% бюджета

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

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

Для сохранения лица персонажа проблема в том, что 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 на практике:

  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 и #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:

Визуал:

Инфра: