ИИ-агенты и prompt engineering ИИ в продакшене: латентность, кэширование, роутинг моделей, стоимость, наблюдаемость
0%

ИИ в продакшене: латентность, кэширование, роутинг моделей, стоимость, наблюдаемость

ИИ в продакшене: латентность, кэширование, роутинг моделей, стоимость, наблюдаемость

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

Главная особенность LLM-сервиса по сравнению с обычным бэкендом: переменная стоимость запроса не пренебрежимо мала. У классического CRUD-сервиса стоимость одного запроса — доли цента амортизированного железа, и оптимизируют там инфраструктуру целиком. У LLM-сервиса один агентный прогон легко стоит 30–80 центов, и это доминирующая статья расходов. Значит, юнит-экономика запроса становится инженерной метрикой первого класса — такой же, как p99 латентности.

Из чего складывается латентность

Разберём один вызов модели на составляющие. Генерация авторегрессивна и делится на две фазы с принципиально разной характеристикой (подробнее — в статье про устройство LLM):

Prefill — обработка входного промпта. Все токены входа считаются параллельно, фаза упирается в вычисления (compute-bound). Стоимость по времени примерно линейна по длине входа, но с очень маленьким коэффициентом: 20 000 входных токенов обрабатываются за сотни миллисекунд, а не за секунды.

Decode — генерация выходных токенов по одному. Каждый токен требует прохода через всю модель, читая веса из памяти; фаза упирается в пропускную способность памяти (memory-bound). Тут коэффициент огромный: один выходной токен стоит по времени примерно как сотни входных.

Отсюда основная формула латентности:

$$L = \mathrm{TTFT} + (N_{out} - 1) \cdot \mathrm{TPOT}$$

где TTFT — time to first token (сеть + очередь + prefill), TPOT — time per output token, $N_{out}$ — число сгенерированных токенов.

Практический вывод, который экономит больше времени, чем все остальные оптимизации вместе: латентность определяется длиной ответа, а не длиной запроса. Ответ на 800 токенов при 50 ток/с — это 16 секунд, и никакая оптимизация ретривера на 40 мс тут не поможет. Приоритеты в порядке эффекта:

  1. Сократить выход. Строгая JSON-схема вместо свободного текста, запрет на преамбулы, effort: "low" там, где не нужно рассуждение. Убрать «объясни свой ответ», если объяснение никто не читает.
  2. Включить стриминг. Не уменьшает $L$, но уменьшает воспринимаемую задержку с 16 секунд до TTFT. Для чата это разница между «сломалось» и «работает».
  3. Сократить вход. Даёт эффект на TTFT (и на деньги), но на общее время — умеренный.
  4. Взять модель поменьше или быстрый режим. У Anthropic это claude-haiku-4-5 для простых задач и research-preview fast mode на Opus 4.8 — до 2.5× выше output tok/s по премиальной цене (Anthropic: pricing).

Хвосты важнее среднего

Средняя латентность в LLM-системе почти бесполезна как метрика. Распределение времени ответа тяжелохвостое: очередь у провайдера, ретрай под капотом SDK, необычно длинный ответ, pause_turn в серверном инструменте — всё это даёт p99, отстоящий от p50 в 5–10 раз.

Дальше это усиливается агентной архитектурой. Если один вызов попадает в хвост с вероятностью $p$, то цепочка из $k$ вызовов содержит хотя бы один медленный вызов с вероятностью $1 - (1-p)^k$.

Усиление хвоста латентности в цепочке вызовов

Для восьмишагового агента «редкие» 5% превращаются в 34% прогонов. Это ровно та классика, что описана в Dean & Barroso, «The Tail at Scale» (CACM, 2013), только с амплитудой на порядок больше, потому что базовая латентность вызова — секунды, а не миллисекунды.

Что с этим делают:

  • SLO ставят на прогон, а не на вызов. «p95 полного ответа пользователю ≤ 12 с», а не «p95 вызова модели ≤ 4 с».
  • Бюджет шагов — жёсткий инвариант в коде. Не «модель сама остановится», а счётчик и дедлайн (см. task_budget ниже).
  • Хеджирование для коротких критичных вызовов: если ответ не пришёл за p90, послать дубль и взять первый пришедший. Работает только для идемпотентных операций без побочных эффектов и стоит примерно +10% к деньгам.
  • Дедлайн распространяется по цепочке. Каждый следующий вызов получает timeout = deadline - now, а не свой независимый таймаут. Иначе восемь таймаутов по 60 с дают потенциальные 8 минут ожидания.
import time
import anthropic

client = anthropic.Anthropic()

def call_with_deadline(deadline_ts: float, **kwargs):
    """Вызов с остатком общего дедлайна вместо независимого таймаута."""
    remaining = deadline_ts - time.monotonic()
    if remaining <= 1.0:                      # меньше секунды — смысла нет
        raise TimeoutError("бюджет прогона исчерпан")
    # with_options не мутирует клиент — таймаут действует только на этот вызов
    return client.with_options(timeout=remaining).messages.create(**kwargs)

deadline = time.monotonic() + 25.0            # общий бюджет прогона агента
for step in range(8):
    response = call_with_deadline(
        deadline,
        model="claude-opus-4-8",
        max_tokens=4096,
        messages=messages,
    )

Стоимость запроса: считаем честно

Базовая формула — просто сумма по типам токенов, но важно, что типов четыре, а не два:

$$C = \frac{T^{\mathrm{miss}} \cdot p_{in} + T^{\mathrm{write}} \cdot 1.25, p_{in} + T^{\mathrm{read}} \cdot 0.1, p_{in} + T_{out} \cdot p_{out}}{10^6}$$

Здесь $T^{\mathrm{miss}}$ — некэшированный вход, $T^{\mathrm{write}}$ — запись в кэш, $T^{\mathrm{read}}$ — чтение из кэша. Множители 1.25 и 0.1 — это Anthropic prompt caching при пятиминутном TTL; при часовом TTL запись стоит 2×.

Актуальные цены на момент написания (за миллион токенов, прайс Anthropic):

Модель ID Контекст Вход Выход
Claude Fable 5 claude-fable-5 1M 10.00 USD 50.00 USD
Claude Opus 4.8 claude-opus-4-8 1M 5.00 USD 25.00 USD
Claude Sonnet 5 claude-sonnet-5 1M 3.00 USD 15.00 USD
Claude Haiku 4.5 claude-haiku-4-5 200K 1.00 USD 5.00 USD

Обратите внимание на два соотношения, которые формируют всю дальнейшую архитектуру: выход дороже входа в 5 раз и Haiku дешевле Opus в 5 раз. Первое означает, что многословность — прямой финансовый убыток. Второе задаёт потолок экономии от роутинга.

Цены соседних провайдеров смотрите в их прайсах напрямую (OpenAI, Google Gemini) — они меняются чаще, чем обновляются статьи, и любое зашитое в текст число тут врёт через полгода.

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

Единственный корректный способ узнать длину промпта — эндпоинт подсчёта токенов у самого провайдера. tiktoken — токенизатор OpenAI; на Claude он недосчитывает 15–20% на обычном тексте и заметно больше на коде и кириллице. Эвристики вида «4 символа = токен» на русском языке ошибаются в полтора-два раза.

# бесплатный вызов, точный ответ для конкретной модели
count = client.messages.count_tokens(
    model="claude-opus-4-8",
    system=SYSTEM_PROMPT,
    tools=TOOLS,
    messages=messages,
)
print(count.input_tokens)

Отдельная ловушка при миграции: токенизатор меняется между поколениями моделей. Тот же текст на Opus 4.7/4.8 даёт больше токенов, чем на Opus 4.6 (примерно 1×–1.35× в зависимости от контента), а Sonnet 5 против Sonnet 4.6 — около +30%. Цена за токен при этом другая, так что старая оценка стоимости после смены модели неверна в обе стороны. Перемеряйте count_tokens на своей выборке, а не применяйте множитель.

Правильная метрика — стоимость успеха, а не вызова

Считать «сколько стоит один вызов API» бессмысленно, потому что часть вызовов уходит в ретраи, часть прогонов заканчивается неудачей, а часть работы делает верификатор. Реальная метрика:

$$C_{\mathrm{success}} = \frac{C_{\mathrm{run}}}{s}, \qquad C_{\mathrm{run}} = \sum_{i=1}^{k} C_i$$

где $s$ — доля прогонов, дошедших до приемлемого результата (по вашей метрике из статьи про оценку). Система, которая стоит 0.20 USD за прогон при $s = 0.6 USD, дороже системы за 0.35 USD при $s = 0.95 USD: 33 цента против 37 — почти паритет, а если добавить стоимость ручного разбора провалов, вторая выигрывает с большим запасом.

Дальше эта величина переводится в бизнес-метрику: стоимость закрытого тикета, стоимость проверенного документа, стоимость смёрдженного PR. Именно она сравнивается с человеческой альтернативой и именно её показывают финансовому директору.

Кэширование: три независимых уровня

Кэшировать в LLM-системе можно на трёх разных уровнях, и они не заменяют друг друга.

Уровень 1: точный кэш ответов

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

Доля попаданий сильно зависит от домена: в поддержке и FAQ-ботах 20–40% запросов повторяются буквально; в кодовых агентах — почти ноль. Померяйте на своих логах перед тем, как строить.

Уровень 2: семантический кэш

Ищем в кэше по близости эмбеддингов: «как отменить подписку» и «хочу отменить подписку» — один и тот же вопрос. Реализации: GPTCache, самописный поверх вашей векторной БД.

Тут стоит быть осторожным. Семантический кэш — это классификатор с ошибками первого и второго рода, и ошибка первого рода означает уверенно выданный неправильный ответ. «Как отменить подписку» и «как отменить отмену подписки» имеют косинусную близость около 0.95. Правила, которые делают его безопасным:

  • Порог высокий (0.95–0.98) и откалиброван на размеченной выборке пар, а не взят из головы.
  • Не кэшировать ничего, что зависит от идентичности пользователя, времени или состояния (баланс, статус заказа).
  • Ключ включает tenant/пользователя, если ответ персонализирован, — иначе это утечка данных между клиентами.
  • Отдельная метрика в мониторинге: доля семантических попаданий и её изменение. Внезапный рост — обычно признак сломанного эмбеддера, отдающего вырожденные векторы.

Уровень 3: prompt caching провайдера

Это KV-кэш префикса на стороне провайдера: вы платите 1.25× за запись и 0.1× за чтение уже посчитанной части. Экономика при повторном использовании:

Экономика prompt caching при разных TTL

Ключевая механика, из которой следует всё остальное: кэш работает по совпадению префикса. Порядок рендеринга — toolssystemmessages. Изменение одного байта в позиции $N$ инвалидирует всё, что идёт после $N$. Отсюда единственное архитектурное правило:

Стабильное — в начало, изменчивое — в конец. Всё остальное — детали.

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=4096,
    system=[
        {
            "type": "text",
            "text": FROZEN_SYSTEM_PROMPT,      # ни дат, ни UUID, ни имени юзера
            "cache_control": {"type": "ephemeral"},   # TTL по умолчанию 5 минут
        },
    ],
    messages=[{"role": "user", "content": question}],  # изменчивая часть — после точки
)

# Единственный способ узнать, что кэш реально работает
u = response.usage
print(u.cache_creation_input_tokens,   # записали (1.25×)
      u.cache_read_input_tokens,       # прочитали (0.1×)
      u.input_tokens)                  # заплатили полную цену

Типовые «тихие инвалидаторы», которые обнуляют hit rate и не выдают никакой ошибки:

Паттерн Почему ломает
datetime.now() в системном промпте префикс меняется каждый запрос
uuid4() / request-id в начале контента то же самое
json.dumps(d) без sort_keys=True недетерминированная сериализация схем инструментов
Имя/ID пользователя в системном промпте кэш не переиспользуется между пользователями
Условные секции if flag: system += ... каждая комбинация флагов — отдельный префикс
Набор инструментов, зависящий от пользователя инструменты рендерятся первыми, ломают всё
Смена модели посреди сессии кэши привязаны к модели

Ещё несколько неочевидных ограничений, каждое из которых стоило кому-то дня отладки:

  • Минимальная длина кэшируемого префикса зависит от модели: 4096 токенов для Opus 4.8/4.7/4.6 и Haiku 4.5, 2048 для Fable 5 и Sonnet 4.6, 1024 для Sonnet 4.5 и старше. Более короткий префикс молча не кэшируется — ошибки нет, просто cache_creation_input_tokens: 0.
  • Максимум 4 точки кэширования на запрос.
  • Окно поиска — 20 блоков контента назад. В агентном цикле, где один ход добавляет 30 блоков tool_use/tool_result, следующая точка не найдёт предыдущую запись. Лечится промежуточными брейкпойнтами каждые ~15 блоков.
  • Параллельные запросы не читают кэш друг друга. Запись становится доступной только после начала стриминга первого ответа. Для fan-out: отправить один запрос, дождаться первого токена, потом остальные N−1.
  • Изменение системного промпта посреди сессии инвалидирует весь кэш диалога. Если инструкция появилась в середине — добавляйте её как сообщение с ролью system в конец messages (поддерживается на Opus 4.8), а не редактируйте верхнеуровневый system.

Полная механика — в документации по prompt caching.

Что даёт кэш в цифрах

Возьмём реалистичный сценарий: агент с системным промптом и определениями инструментов на 20 000 токенов, Opus 4.8, 1000 вызовов в час.

  • Без кэша: $20000 \cdot 5 / 10^6 = $0.10 USD за вызов → 100 USD/час только за префикс.
  • С 5-минутным TTL и равномерным трафиком: 12 записей в час (по одной на окно TTL) + 988 чтений: $12 \cdot 0.125 + 988 \cdot 0.01 = $11.4 USD/час.

Экономия — 8.8×, и это самая дешёвая оптимизация в списке: она требует перестановки строк, а не переписывания логики.

Роутинг моделей

Идея простая: большинство запросов не требуют самой сильной модели. Если 70% трафика уходит на Haiku за 1 USD/5 USD вместо Opus за 5 USD/$25, средний чек падает в 2.8 раза. Вопрос только в том, как решать — и не потерять при этом качество.

Три рабочих механизма выбора:

1. Детерминированный роутинг по типу задачи. Самый надёжный и самый недооценённый. Классификация, извлечение полей, форматирование, короткие суммаризации — жёстко на маленькую модель, без всякого «умного» решения. Генерация кода, многошаговые агенты, юридический анализ — на большую. Никакого ML, просто таблица маршрутов, и она покрывает большую часть выигрыша.

2. Каскад с проверкой. Сначала дешёвая модель, потом верификация; при провале — эскалация. Стоимость каскада:

$$C = C_{\mathrm{small}} + (1 - a) \cdot C_{\mathrm{big}}$$

где $a$ — доля запросов, принятых на первом уровне. При $a = 0.75 USD, $C_{\mathrm{small}} = 0.2 C_{\mathrm{big}}$ получаем 0.2 USD + 0.25 = 0.45 USD от стоимости «всё на большой» — экономия 55% при латентности, которая на 25% запросов выросла (два вызова вместо одного). Это главный трейд-офф каскада, и в интерактивных сценариях он часто неприемлем.

Критично: верификатор должен быть дешевле и надёжнее генератора. Проверка JSON-схемой, наличием цитат из источника, прохождением тестов — стоит околоноль и не ошибается. LLM-судья в роли верификатора съедает половину экономии и добавляет свои ошибки. Подход из FrugalGPT (Bhattacharyya et al., arXiv:2305.05176) — каскад с обученным скорером — даёт заявленную экономию до 98% при сопоставимом качестве на их бенчмарках; воспроизводимость на вашем домене надо проверять отдельно.

3. Обучаемый роутер. Отдельная маленькая модель предсказывает, справится ли дешёвая. Сравнение подходов и метрика «качество на доллар» — в RouterBench (Hu et al., arXiv:2403.12031); открытые реализации — RouteLLM. Начинать с этого не стоит: сначала пункты 1 и 2, они дают 80% эффекта за 5% работы.

from dataclasses import dataclass
from pydantic import BaseModel, ValidationError
import json, anthropic

client = anthropic.Anthropic()

class Extraction(BaseModel):
    company: str
    amount_eur: float
    signed_on: str

@dataclass
class Tier:
    model: str
    effort: str

CASCADE = [Tier("claude-haiku-4-5", "low"), Tier("claude-opus-4-8", "high")]

def extract(document: str) -> tuple[Extraction, str, int]:
    """Каскад: дешёвая модель + инвариантная проверка, эскалация при провале."""
    last_error = None
    for level, tier in enumerate(CASCADE):
        resp = client.messages.create(
            model=tier.model,
            max_tokens=1024,
            output_config={
                "effort": tier.effort,
                "format": {"type": "json_schema", "schema": Extraction.model_json_schema()},
            },
            system=[{"type": "text", "text": EXTRACTION_PROMPT,
                     "cache_control": {"type": "ephemeral"}}],
            messages=[{"role": "user", "content": document}],
        )
        if resp.stop_reason == "max_tokens":     # обрезано — верить нельзя
            last_error = "truncated"
            continue
        text = next(b.text for b in resp.content if b.type == "text")
        try:
            parsed = Extraction.model_validate_json(text)
        except ValidationError as e:
            last_error = str(e)
            continue
        # инвариант домена: проверка стоит ноль токенов и не ошибается
        if parsed.amount_eur <= 0 or parsed.company not in document:
            last_error = "не прошёл доменную проверку"
            continue
        return parsed, tier.model, level
    raise ValueError(f"каскад исчерпан: {last_error}")

Три вещи, которые ломают роутинг на практике:

  • Роутер сам стоит денег и времени. LLM-классификатор перед каждым запросом — это +TTFT и +цена; если он на Haiku и промпт короткий, ок, но считайте его в юнит-экономике честно.
  • Смена модели инвалидирует prompt cache. Каскад с эскалацией платит холодную запись на втором уровне. Иногда дешевле сразу идти на большую модель с горячим кэшем, чем делать два холодных вызова.
  • Метрики надо собирать по уровням. Средняя точность 92% может означать 98% на Opus (30% трафика) и 89% на Haiku (70%) — и второе число решает, жив ли сервис.

Батч, асинхронность и пропускная способность

Онлайн-запрос оптимизирует латентность, а батч — стоимость. Batch API у Anthropic даёт 50% скидку на все токены ценой асинхронности: до 100 000 запросов или 256 МБ в батче, большинство завершается в течение часа, гарантированный потолок — 24 часа (документация).

Что уходит в батч почти всегда: переиндексация корпуса, офлайн-разметка, регулярные отчёты, бэкфилл эмбеддингов, прогон эвал-датасета, генерация синтетики для дообучения. Это половина токенов типичной ИИ-платформы, и скидка в 50% на неё — самая простая экономия после кэша.

from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request

shared_system = [
    {"type": "text", "text": CLASSIFIER_PROMPT,
     "cache_control": {"type": "ephemeral"}},   # кэш работает и внутри батча
]

batch = client.messages.batches.create(
    requests=[
        Request(
            custom_id=f"doc-{doc.id}",          # результаты приходят в ЛЮБОМ порядке
            params=MessageCreateParamsNonStreaming(
                model="claude-haiku-4-5",
                max_tokens=256,
                system=shared_system,
                messages=[{"role": "user", "content": doc.text}],
            ),
        )
        for doc in documents
    ]
)

# ... позже, после processing_status == "ended"
for result in client.messages.batches.results(batch.id):
    if result.result.type == "succeeded":
        store(result.custom_id, result.result.message)   # ключ по custom_id, не по позиции

Две ошибки, которые встречаются в каждом втором коде с батчами: сопоставление результатов по индексу вместо custom_id (порядок не гарантирован) и отсутствие обработки статусов errored / expired — они не бросают исключение, а тихо возвращаются в общем потоке результатов.

Если вы хостите модель сами

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

  • Continuous batching — планировщик добавляет новые запросы в батч на каждой итерации декодирования, вместо ожидания завершения всей группы. Идея из Orca (Yu et al., OSDI ‘22), реализована в vLLM и TGI; на реальных нагрузках даёт кратный рост throughput.
  • PagedAttention — страничное управление KV-кэшем, снимающее фрагментацию памяти и позволяющее держать больше одновременных последовательностей (Kwon et al., arXiv:2309.06180, это ядро vLLM).
  • Разделение prefill и decode. Фазы упираются в разные ресурсы, поэтому их всё чаще разносят по разным пулам GPU: длинный prefill одного запроса иначе стопорит декодирование всех остальных.

Здесь throughput и латентность — прямые антагонисты: рост размера батча повышает токены/сек на кластер и одновременно ухудшает TPOT каждого отдельного пользователя. Выбирается это по SLO, а не «по максимуму».

Надёжность: 429, 529 и всё остальное

Провайдерский API — это внешняя зависимость с квотами и переменной доступностью. Коды, которые надо различать:

Код Тип Ретраить Что делать
400 invalid_request_error нет чинить запрос (схема, параметры модели)
401 / 403 auth / permission нет ключ, права, workspace
404 not_found_error нет опечатка в ID модели
413 request_too_large нет резать вход
429 rate_limit_error да читать retry-after, снижать конкурентность
500 api_error да бэкофф
529 overloaded_error да бэкофф, рассмотреть fallback-модель

Первое, что нужно знать: SDK уже ретраит 408/409/429/5xx с экспоненциальным бэкоффом, по умолчанию max_retries=2. Собственный цикл ретраев поверх этого даёт произведение попыток и незаметно умножает и латентность, и счёт. Если пишете свой — сначала выключите встроенный (max_retries=0).

Экспоненциальный бэкофф обязательно с джиттером: без него все клиенты, получившие 429 одновременно, синхронно вернутся в ту же секунду и получат его снова. Классический разбор вариантов джиттера — AWS Architecture Blog, «Exponential Backoff And Jitter».

Дальше нужен предохранитель (circuit breaker), иначе при деградации провайдера вы будете держать тысячи висящих соединений и утилизировать таймауты вместо того, чтобы быстро отдать деградированный ответ.

Ещё три вещи, без которых прод не живёт:

  • Идемпотентность. Ретрай вызова, который уже отправил письмо или списал деньги, — это баг, а не устойчивость. Побочные эффекты выносятся за LLM-вызов и защищаются ключом идемпотентности.
  • Backpressure. Ограничивайте конкурентность на своей стороне семафором, привязанным к вашей квоте RPM/ITPM. Читать 429 как единственный сигнал регулирования — значит постоянно работать в режиме отказа.
  • Бюджет как инвариант. Для агентных прогонов у Anthropic есть task_budget (бета task-budgets-2026-03-13, минимум 20 000 токенов): модель видит обратный отсчёт и завершает работу аккуратно, а не обрывается. Это дополняет, а не заменяет max_tokens — тот является жёстким потолком, о котором модель не знает.

Наблюдаемость

Обычный APM здесь не работает: код может отработать за 200 мс с кодом 200 и вернуть полную чушь. Наблюдаемость LLM-системы — это одновременно про производительность и про качество, и второе не выводится из первого.

Что писать в трейс

Минимальный набор атрибутов на каждый вызов модели. Формат стоит брать не свой, а OpenTelemetry GenAI semantic conventions — тогда любой совместимый бэкенд (Langfuse, Arize Phoenix, Grafana) прочитает ваши спаны без адаптера.

Атрибут Зачем
gen_ai.request.model, response.model они различаются при фолбэке и роутинге — это первое, что смотрят при разборе
usage.input_tokens, output_tokens деньги
usage.cache_read_input_tokens, cache_creation_input_tokens здоровье кэша; нулевые чтения = кэш сломан
stop_reason max_tokens = обрезано, refusal = отказ, tool_use = цикл продолжается
TTFT и полное время латентность отдельно для prefill и decode
prompt_version, tools_version иначе невозможно связать просадку метрик с релизом
request_id из заголовка ответа без него провайдер не сможет вам помочь
tenant_id, user_id (хэш) атрибуция расходов и поиск «дорогих» клиентов
trace_id прогона склейка всех шагов агента в один прогон

Полезно логировать сам промпт и ответ — но с осознанным решением по PII, ретеншену и семплированию: 100% логирования тела запросов на объёме — это отдельная статья расходов и отдельный риск утечки (о котором — в следующей статье про безопасность).

Метрики, на которые ставят алерты

Разделите три группы — они деградируют независимо и лечатся разными способами:

Производительность: p50/p95/p99 TTFT, p95 полного ответа, доля прогонов, упёршихся в дедлайн, глубина очереди, доля stop_reason == "max_tokens" (рост — признак того, что ответы стали длиннее и скоро вырастет счёт).

Стоимость: доллары в час по моделям и по тенантам, средняя стоимость успешного прогона, hit rate prompt-кэша (его падение — самый частый источник внезапного роста счёта втрое за ночь), доля трафика по уровням роутера.

Качество: доля ответов, прошедших инвариантную валидацию (схема, наличие цитат, компиляция кода), доля отказов и refusal, оценка LLM-судьи на семпле продакшн-трафика (1–5% запросов достаточно), доля эскалаций каскада, доля попаданий семантического кэша.

Ключевая практика — онлайн-оценка на семпле. Офлайн-эвал ловит регрессии до релиза, но не ловит дрейф распределения запросов: пользователи через месяц спрашивают не то же, что было в вашем датасете. Постоянный семпл продакшн-трафика, прогоняемый через судью и периодически размечаемый людьми, — единственный способ увидеть медленную деградацию. Инструменты: Langfuse (open source), LangSmith, Arize Phoenix.

Отдельно: связывайте метрики качества с версией промпта. Без этого поле разбора инцидента выглядит как «в четверг стало хуже», а с этим — как «стало хуже после релиза промпта v37, откатываем».

Типичные ошибки

Оптимизация не того конца. Неделя на ускорение ретривера с 120 до 40 мс при генерации в 9 секунд. Сначала профилируйте, потом оптимизируйте — как и везде.

Кэш «включён», но не работает. cache_control расставлен, а cache_read_input_tokens стабильно ноль. Причина почти всегда в списке тихих инвалидаторов выше. Метрика hit rate обязана быть в дашборде с первого дня, иначе вы этого просто не увидите.

Свой цикл ретраев поверх SDK-шного. Два уровня по 3 попытки — это 9 запросов, 9× стоимости и минуты латентности вместо секунд.

Ретрай без дедлайна. Повтор добавляется именно там, где вызов уже медленный, — и хвост умножается. Ретрай должен проверять остаток общего бюджета прогона.

Молчаливый фолбэк. При 529 система переключилась на слабую модель и не отметила это в трейсе. Через неделю приходит жалоба на качество, и никто не может воспроизвести.

max_tokens подобран впритык. Ответ обрывается на stop_reason: "max_tokens", парсер JSON падает, происходит ретрай — и вы платите за обрезанную генерацию плюс полную. Для нестримингового вызова разумный дефолт — около 16 000; для стриминга можно 64 000 и выше.

Стоимость меряется по вызовам, а не по успехам. Дешёвая модель с 60% успеха дороже дорогой с 95%, но в дашборде «средняя цена вызова» этого не видно.

Нет атрибуции по тенантам. Счёт вырос на 40%, и невозможно понять, это органический рост, один клиент с циклом или баг в ретраях.

Оценка «на глаз» вместо семпла. Без непрерывной онлайн-оценки деградация обнаруживается по жалобам, то есть с задержкой в недели.

Prefill-«оптимизация» ломает кэш. Умное динамическое сокращение контекста, которое каждый раз собирает чуть-чуть другой префикс, экономит 10% входных токенов и теряет 90% скидки от кэша.

Чеклист вывода в прод

  • SLO сформулирован на полный прогон (p95 полного ответа), а не на отдельный вызов.
  • Стриминг включён везде, где ответ видит человек.
  • Стабильный префикс заморожен: ни дат, ни UUID, ни имён пользователей; инструменты сериализуются детерминированно.
  • cache_read_input_tokens и hit rate — на дашборде, с алертом на падение.
  • count_tokens используется для оценки объёма; после смены модели всё перемерено.
  • Есть таблица маршрутов «тип задачи → модель + effort», а не одна модель на всё.
  • Каскад (если есть) верифицируется инвариантами на коде, а не LLM-судьёй.
  • Всё, что не требует онлайна, переведено в Batch API (−50%).
  • Ретраи — одного уровня, с джиттером, с проверкой остатка дедлайна.
  • Конкурентность ограничена семафором под вашу квоту; есть circuit breaker.
  • Побочные эффекты идемпотентны и вынесены за пределы LLM-вызова.
  • Каждый фолбэк и каждая эскалация видны в трейсе.
  • Трейсы в формате OTel GenAI, с prompt_version и request_id.
  • Расходы атрибутированы по тенантам; есть бюджетные лимиты на тенанта.
  • Онлайн-оценка на 1–5% трафика работает и связана с версией промпта.
  • Есть план на деградацию: что показать пользователю, когда провайдер лежит.

Источники

Что дальше

Мы научились делать сервис быстрым, дешёвым и наблюдаемым. Осталась ось, на которой все три предыдущие оптимизации могут обернуться против вас: кэш способен отдать чужие данные, роутер — увести запрос на модель со слабыми фильтрами, а агент с инструментами — выполнить инструкцию, пришедшую вместе с документом. Дальше — про то, как это ломают и как от этого защищаться: Безопасность: prompt injection, утечки данных, ограничение инструментов, red teaming.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов