METABYTE
К списку статей

Как встроить AI‑чат в Next.js‑SaaS и не убить маржу

Пошаговая архитектура, контроль стоимости токенов, кэш, RAG, провайдер‑фолбэки, лимитирование и продакшен‑ловушки — чтобы AI‑чат приносил выручку, а не сжигал её.

11 мая 202613 мин чтенияAI-research draft
Как встроить AI‑чат в Next.js‑SaaS и не убить маржу

Если добавить AI‑чат в SaaS «как получится», вы быстро получите счета за токены больше выручки, рассерженных клиентов из‑за таймаутов и утечки данных между арендаторами. Эта статья — про архитектуру и дисциплину, которые позволяют запустить AI‑чат на Next.js и сохранить маржу.

Если коротко: встраивайте чат как потоковый Edge‑роут (Next.js Route Handler), используйте адаптер над несколькими LLM‑провайдерами с деградацией, ограничивайте контекст и кэшируйте RAG, измеряйте токены на каждый запрос и применяйте бюджет/квоты. Добавьте пер‑тенантные лимиты, наблюдаемость затрат и fallback‑модели. Ниже — конкретный стек, код и продакшен‑паттерны.

Базовая архитектура AI‑чата в Next.js

Как выглядит минимально жизнеспособная архитектура, которая не убивает бюджет и не ломается под нагрузкой:

  • UI: Next.js (App Router), React Server Components + клиентский компонент для стриминга ответов (SSE/Web Streams). Tailwind/Chakra — по вкусу.
  • API: app/api/chat/route.ts (Edge runtime) — принимает сообщения, дергает провайдер через адаптер, стримит ответ в UI.
  • Память чата: Redis (например, Upstash) для кратковременного состояния беседы; долгосрочная — периодические саммари и хранение в Postgres.
  • Данные для RAG: Postgres + pgvector или управляемый Qdrant/Weaviate (зависит от объема и SLO). Ингест фоновой джобой (Queue: Cloudflare Queues, Sidekiq, BullMQ).
  • Авторизация/тенантность: JWT/NextAuth, таблицы с tenant_id, желательно Row‑Level Security в Postgres.
  • Наблюдаемость: OpenTelemetry (трейсы), Sentry (ошибки), ClickHouse/BigQuery (сырые логи чатов и затрат), алерты.

Поток данных:

  1. Клиент отправляет prompt → POST /api/chat.
  2. Route Handler валидирует, вынимает контекст (история, RAG), режет по бюджету токенов.
  3. Адаптер выбирает модель (по тарифу/нагрузке), делает запрос как стрим.
  4. Параллельно считаем токены и стоимость, логируем метаданные (но не персональные данные — см. PII‑редакцию).
  5. Отдаем стрим в UI. По завершении — саммари беседы в Redis→Postgres.

Пример минимального Route Handler с потоковой выдачей и грубым контролем токен‑бюджета:

// app/api/chat/route.ts
import { NextRequest } from 'next/server'
export const runtime = 'edge'

// Псевдо‑адаптер над несколькими провайдерами
import { pickModel, streamLLM, countTokens } from '@/lib/ai'
import { getTenant, assertQuota, trackUsage } from '@/lib/billing'
import { buildContext } from '@/lib/context'

export async function POST(req: NextRequest) {
  try {
    const { messages } = await req.json()
    const tenant = await getTenant(req)

    await assertQuota(tenant.id) // hard/soft cap

    // Сбор контекста (история + RAG) под бюджет токенов
    const ctx = await buildContext({ tenantId: tenant.id, messages, maxInputTokens: 6_000 })

    // Выбор модели c fallback по тарифу, нагрузке и SLA
    const model = pickModel({ tier: tenant.tier, prefer: 'fast', maxPricePer1M: tenant.cap })

    // Черновой расчет бюджетов
    const estInput = countTokens(ctx)
    if (estInput > 8_000) {
      ctx.truncate() // отрезаем историю/контекст
    }

    const { readable, usageMeta } = await streamLLM({ model, messages: ctx.toMessages(), temperature: 0.2 })

    // Фоновая фиксация затрат/логов (не блокируем стрим)
    trackUsage({ tenantId: tenant.id, model, ...usageMeta }).catch(() => {})

    return new Response(readable, {
      headers: { 'Content-Type': 'text/event-stream' },
    })
  } catch (e: any) {
    return new Response(JSON.stringify({ error: e.message ?? 'unknown' }), { status: 400 })
  }
}

Пара заметок:

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

Управление стоимостью: токены, бюджеты, кэш

Ключ к марже — не «умная модель», а дисциплина по токенам.

  • Ограничение входа: жёсткий cap на input_tokens per request. Историю саммаризуем в 300–800 токенов, остальное — архив.
  • Ограничение выхода: просим модель отвечать кратко, а UI раскрывать детали по follow‑ups. «Разделяй и отвечай коротко» — это про деньги.
  • Тарифные бюджеты: у каждого тарифа свой дневной/месячный кап. Пер‑тенантные алерты при 80/100%.
  • Кэш RAG: кэшируем с учётом версии документа и эмбеддингов, TTL — по данным (документы — дольше, ответы — короче). Хэш prompt+retrieved_ids.
  • Эмбеддинги пакетом: объединяйте 32–128 чанков в один батч, удаляйте дубликаты по хэшу контента.

Как считать себестоимость разговора:

  • Для каждой модели у провайдера есть цена за 1М входных/выходных токенов. Методология:
    • cost_in = (input_tokens / 1_000_000) * price_in
    • cost_out = (output_tokens / 1_000_000) * price_out
    • cost_total = cost_in + cost_out + cost_embeddings (если был RAG)
  • Маржинальность с учётом прочих COGS: margin = revenue_per_chat - (cost_total + infra + support_share).
  • Не подставляйте «среднюю температуру по больнице» — логируйте фактические токены на каждый запрос.

Пример учёта токенов на ответе (зависит от SDK провайдера; многие возвращают usage):

type Usage = { input_tokens: number; output_tokens: number }

export async function trackUsage({ tenantId, model, usage }: { tenantId: string; model: string; usage: Usage }) {
  // Сохраняем в ClickHouse таблицу ai_usage (tenant_id, model, in, out, ts)
  await clickhouse.insert('ai_usage', [{
    tenant_id: tenantId,
    model,
    input_tokens: usage.input_tokens,
    output_tokens: usage.output_tokens,
    ts: new Date().toISOString(),
  }])
}

Пороговые защиты:

  • Если оценка входных токенов > лимита — синхронно режем контекст и уведомляем UI.
  • Если выход превысил лимит — прерываем стрим (UI должен это уметь) и предлагаем продолжить.
  • При превышении месячного капа тарифа — блокируем генерацию и предлагаем апгрейд/кредиты.

Небольшая инженерная ирония: лучшие «оптимизации LLM» — это обычные if и «не класть в промпт всё, что знаем».

Выбор провайдера и fallback‑стратегия

Не завязывайтесь на одного провайдера — нужны хотя бы два и чёткие правила деградации. Типичный набор: одна быстрая/дешёвая модель для большинства запросов и одна «мозговитая» для сложных кейсов и VIP‑тарифов. Плюс возможность локальной/самостоятельной инференс‑ноды при особых требованиях к данным/латентности.

Сравнение стратегий:

СтратегияКачествоЗадержкаСтоимостьУправление рискамиКогда уместно
Одна быстрая модель для всехСреднееНизкаяНизкаяНизкоеMVP, хелп‑центр, FAQ
Две модели: fast + smartВышеСредняяСредняяВышеПродакшен чат, опции тарифов
Мульти‑провайдер с маршрутизациейВысокоеСредняя/ВысокаяСредняя/ВысокаяВысокоеГео‑SLA, критично к даунтаймам
Локальная LLM (vLLM) + облачный fallbackЗависитНизкая/СредняяКонтролируемаяСреднееНизкая стоимость, приватность

Маршрутизация запросов:

  • По тарифу: base — fast; pro — smart или re‑ask на сложные промпты.
  • По сложности: эвристики (длина, количество инструментов), классификатор сложности (дешёвая модель решает, что отправлять на «умную»).
  • По задержке: если провайдер «тупит», переключаемся на альтернативу.

В адаптере держите нейтральный интерфейс: complete({ model, messages, tools }), а конфигурации цен/лимитов — отдельно. Это позволит при изменении цен провайдера просто подкрутить правила.

RAG без лишних счетов: ingest, хранение, запрос

Чат без доменных знаний — игрушка. Но RAG легко превращается в «пылесос токенов».

Ингест:

  • Чанкуйте документы 400–800 токенов с overlap 50–100.
  • Очистка: убирайте boilerplate, сжимайте таблицы до Markdown‑представления.
  • Эмбеддинги: используйте одну компактную модель эмбеддингов для всего проекта; меняя модель, версионируйте в схеме.
  • Дедупликация: хэш текста чанка, не индексируйте одинаковые куски.

Хранилище:

  • До ~5–10 млн чанков хорошо живёт Postgres + pgvector (удобно, транзакции, RLS). Дальше — Qdrant/Weaviate.
  • Метаданные: tenant_id, doc_id, version, tags, updated_at.

Запрос:

  • Hybrid search: BM25/FTS + ANN по эмбеддингам; интерсектируйте по tenant_id и фильтрам.
  • Re‑rank: дешёвая доранжировка 10–20 кандидатов локальной моделью/правилами.
  • Ограничение контекста: 3–6 чанков, сверху — саммари чанков (ультра‑дёшево).

Пример upsert/query в Postgres + pgvector (TypeScript + SQL):

// псевдокод: генерация эмбеддингов и апсерта
import { sql } from '@vercel/postgres'
import { embedMany } from '@/lib/embeddings'

export async function upsertChunks(tenantId: string, docId: string, chunks: string[]) {
  const vectors = await embedMany(chunks) // батч 64–128
  await sql.begin(async (tx) => {
    await tx`delete from rag_chunks where tenant_id=${tenantId} and doc_id=${docId}`
    for (let i = 0; i < chunks.length; i++) {
      await tx`insert into rag_chunks (tenant_id, doc_id, content, embedding) values (${tenantId}, ${docId}, ${chunks[i]}, ${vectors[i]})`
    }
  })
}

export async function search(tenantId: string, query: string) {
  const [qv] = await embedMany([query])
  // ANN + простая фильтрация
  const { rows } = await sql`
    select content, doc_id
    from rag_chunks
    where tenant_id=${tenantId}
    order by embedding <-> ${qv}
    limit 5
  `
  return rows.map(r => r.content)
}

В продакшене добавьте:

  • Взвешивание источников (политика доверия).
  • «Citation mode»: LLM обязан сослаться на doc_id.
  • TTL на кэш ответов с ключом tenant_id:hash(prompt+sources).

Безопасность, мультиарендность и лимитирование

Мультиарендность — главный риск утечек. Мы видим типовые ошибки:

  • Запрос к векторному хранилищу без фильтра tenant_id.
  • Тестовые ключи в проде, общий кэш на всех.
  • Неизолированная история чатов.

Практики из коробки:

  • Postgres: включите RLS и проверяйте политику на каждую таблицу (чаты, RAG, usage). Похожую проблему разбирали в нашем разборе утечек: исследование утечек Supabase.
  • Ключи: храните провайдерские ключи в KMS/Secrets Manager, не в ENV статично, если нужен ротационный доступ пер‑тенант.
  • Redis‑ключи с префиксом tenant_id и строгими TTL.
  • Rate limit: скользящее окно в Redis, ключ — tenant_id:route, санкции при аномалиях.

Пример лимитера:

import { kv } from '@vercel/kv'

export async function rateLimit(tenantId: string, limit = 60, windowSec = 60) {
  const key = `rl:${tenantId}:${Math.floor(Date.now()/1000/windowSec)}`
  const n = await kv.incr(key)
  if (n === 1) await kv.expire(key, windowSec)
  if (n > limit) throw new Error('rate_limited')
}

Модерация и защита промптов:

  • Модерация входных/выходных сообщений дешёвой моделью‑классификатором; при срабатывании — нейтрализация/блокировка.
  • Против prompt‑injection: чёткие системные инструкции, whitelisting функций, JSON‑схемы для tool calling, валидация по схеме Zod.
  • PII‑редакция журналов: заменяйте e‑mail/телефон/номера на токены перед отправкой в логи.

Наблюдаемость и финансы: что и как мерить

Без чёткой телеметрии по токенам маржу вы не увидите. Схема наблюдаемости:

  • Трейсы: один trace_id на чат‑запрос, спаны на: RAG поиск, LLM вызов, модерация, пост‑процессинг.
  • Метрики: гистограммы задержек, доля успешных стримов, частота фолбэков, средние input/output_tokens по тарифам.
  • Логи: промпты/ответы с редакцией PII, usage‑события с ценовыми коэффициентами провайдера.
  • Дашборды маржи: ARPU, COGS LLM, Infra, Gross Margin, «стоимость на диалог», «стоимость на функцию».
  • Алерты: 95‑й перцентиль задержки, рост output_tokens, аномальный всплеск RAG‑запросов, «провайдер 5xx».

Про инфраструктурные счета и почему они вас удивят — см. наш разбор: ваш счёт AWS врёт вам. В AI‑функциях добавьте ещё один слой контроля: «блокировка при x3 от медианы затрат на диалог за сутки».

Что ломается в продакшене

  • Таймауты провайдера и обрыв стриминга через корпоративные прокси. Лечится retry с backoff, фолбэк на альтернативного провайдера, опция «продолжить ответ» в UI.
  • Бесконечные циклы tool calling. Ограничивайте количество шагов, валидируйте намерение, кэшируйте результаты инструментов.
  • Вспухание памяти чата (история растёт, токены — тоже). Периодический autosummarize и строгий бюджет истории.
  • Непредсказуемый формат JSON‑ответов. Требуйте strict JSON в system prompt и валидируйте Zod‑схемой перед выполнением.
  • Векторное хранилище: рассинхронизация версий эмбеддингов. Версионируйте поле embedding_version, делайте миграции пакетами.
  • Утечки между арендаторами в кэше. Всегда включайте tenant_id в ключи, изолируйте namespace.
  • Перекос расходов на эмбеддинги при большом ingest. Дедупликация, батчи, ночные окна, контроль версий.

Бизнес‑контекст: стоимость и маржинальность

Как не утонуть в затратах и не испортить опыт пользователя:

Ценообразование и квоты:

  • Кредиты/квоты вместо «безлимита»: пользователю прозрачно, вам — предсказуемость COGS.
  • Тарифы отличайте не только лимитами, но и моделью/скоростью: base — fast, pro — smart + приоритетная очередь.
  • Нестандартные клиенты: выделенный budget‑пул и договорённости о провайдерах (вплоть до on‑prem vLLM).

Финансовые правила:

  • Жёсткий бюджет на разговор и на день: отсекайте хвосты, предлагайте «продолжить» за кредит.
  • Дифференциация RAG: сложные/дорогие контексты — только для pro/enterprise.
  • Мониторинг токенов по функциям: какие промпты дорогие, какие инструменты «жрут» больше всего.

Как считать экономику без гаданий:

  • Смоделируйте 3 корзины использования: «короткий ответ», «с контекстом», «длинная сессия». Для каждой — диапазон in/out tokens и долю трафика. Умножьте на актуальные расценки провайдера — получите себестоимость, а затем заложите целевую валовую маржу. Наша рекомендация по дисциплине: держать долю LLM‑COGS в выручке функции в планке, зафиксированной в вашей финансовой модели, и реагировать автоматикой, а не вручную.

UX и маржа дружат:

  • Стриминг коротких ответов ощущается «быстро» и экономит токены.
  • Последующие уточнения дешевле «одного гигантского ответа».
  • FAQ/готовые команды в UI снижают токены и повышают конверсию в решённую задачу.

Минимальный план внедрения (2 недели)

  • День 1–2: каркас Next.js Route Handler (Edge), клиент для стриминга, адаптер провайдеров.
  • День 3–4: Redis‑память чата, autosummary, бюджет истории, модерация.
  • День 5–6: RAG MVP: ingest, pgvector, поиск, ограничение контекста.
  • День 7: учёт токенов, логирование usage, первичные дашборды.
  • День 8: квоты/кредиты, rate limit, алерты.
  • День 9–10: fallback‑маршрутизация, ретраи, «продолжить ответ» в UI.
  • День 11–12: PII‑редакция, RLS, нагрузочное тестирование, хаос‑тест фолбэков.
  • День 13–14: прайсинг и A/B: fast vs smart, caps, онбординг с лимитами.

Пример клиентского компонента для стриминга

'use client'
import { useState } from 'react'

export default function ChatBox() {
  const [msg, setMsg] = useState('')
  const [lines, setLines] = useState<string[]>([])

  async function send() {
    const res = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ messages: [{ role: 'user', content: msg }] }) })
    const reader = res.body!.getReader()
    const dec = new TextDecoder()
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const chunk = dec.decode(value)
      // сервер шлёт SSE; упрощённо воспринимаем как текст
      setLines(prev => [...prev, chunk])
    }
  }

  return (
    <div>
      <textarea value={msg} onChange={e => setMsg(e.target.value)} />
      <button onClick={send}>Send</button>
      <pre>{lines.join('')}</pre>
    </div>
  )
}

FAQ

Можно ли обойтись без RAG и всё равно быть полезными?

Да, если сценарии узкие (FAQ, короткие команды) и вы тщательно пишете системные инструкции. Но без RAG модель будет «галлюцинировать» про ваш домен. Для продуктивных сценариев добавьте хотя бы минимальный поиск по базе знаний с 3–5 чанками контекста.

Как измерять токены, если провайдер не даёт usage?

Используйте локальные токенайзеры (например, совместимые с выбранной моделью), чтобы оценить вход/выход. Это не 100% идеально, но достаточно для бюджетов и алертов. Логи фиксируйте на завершении стрима, чтобы знать output_tokens фактически.

Где хранить историю чата?

Оперативно — в Redis (быстро для стриминга), долговременно — саммари в Postgres, полную историю — по согласию пользователя/политике приватности. Не забудьте RLS и PII‑редакцию в логах.

Что выбрать для векторного поиска: pgvector или отдельную базу?

До миллионов чанков и умеренных SLO подойдёт pgvector (транзакции, простота, один бэкап). При росте и необходимости тонкой настройки ANN/шардирования — Qdrant/Weaviate. Ключевой фактор — требования к латентности и рост индекса.

Как избежать дорогих «простыней» ответов?

Управляйте форматом: просите модель отвечать кратко, выносите примеры/таблицы в follow‑up. Делите сложную задачу на шаги. Ограничивайте max_tokens на ответ и прерывайте стрим по лимиту с предложением продолжить.

Как тестировать fallback‑маршрутизацию?

Проведите хаос‑тест: искусственно возвращайте ошибки/таймауты от провайдера и проверяйте переключение, сохранение контекста и корректность биллинга/логов. Имейте ручной «переключатель» провайдера на уровне конфигурации.

Key takeaways

  • Архитектура: Edge‑стриминг + адаптер провайдеров + строгий токен‑бюджет и кэш RAG — база для маржинального чата.
  • Маржа рождается из ограничений: режьте контекст, саммаризуйте историю, используйте дешёвые модели там, где это допустимо.
  • Мульти‑провайдер и fallback обязательны: даунтаймы и всплески задержек в LLM — не редкость.
  • Наблюдаемость затрат — часть продукта: логируйте токены и стройте алерты по экономике, а не только по ошибкам.
  • Мультиарендность требует дисциплины: RLS, tenant‑scoped кэш и строгие фильтры в RAG.
  • План внедрения на 2 недели реалистичен, если не пытаться решить всё одним промптом.

Если вы строите AI‑чат в Next.js‑SaaS и хотите сохранить маржу, MTBYTE поможет спроектировать архитектуру, внедрить адаптеры, телеметрию и квоты. Напишите нам: /contact.

СЛЕДУЮЩИЙ ШАГ

Понравилось как мыслим?

Применяем те же принципы в клиентских проектах: AI, автоматизации, продукты, которые не умирают после релиза.