ИИ в продакшене: латентность, кэширование, роутинг моделей, стоимость, наблюдаемость
Прототип на 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 мс тут не поможет. Приоритеты в порядке эффекта:
- Сократить выход. Строгая JSON-схема вместо свободного текста, запрет на преамбулы,
effort: "low"там, где не нужно рассуждение. Убрать «объясни свой ответ», если объяснение никто не читает. - Включить стриминг. Не уменьшает $L$, но уменьшает воспринимаемую задержку с 16 секунд до TTFT. Для чата это разница между «сломалось» и «работает».
- Сократить вход. Даёт эффект на TTFT (и на деньги), но на общее время — умеренный.
- Взять модель поменьше или быстрый режим. У 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-системе можно на трёх разных уровнях, и они не заменяют друг друга.
hash запроса + версия промпта} L1 -->|hit| R[Ответ, 0 токенов, ~5 мс] L1 -->|miss| L2{Семантический кэш
эмбеддинг + порог} L2 -->|hit, sim > 0.97| R L2 -->|miss| L3[Сборка промпта:
стабильный префикс + переменный хвост] L3 --> P{Prompt cache
провайдера} P -->|prefix hit| G[Генерация: вход 0.1×] P -->|prefix miss| W[Генерация: запись 1.25×] G --> S[Ответ] W --> S S --> WR[Запись в L1/L2 с TTL
и ключом версии промпта] WR --> R style R fill:#3aa8a0,fill-opacity:0.2 style P fill:#6c7ce0,fill-opacity:0.2
Уровень 1: точный кэш ответов
Самый дешёвый и самый недооценённый. Ключ — хэш от нормализованного запроса плюс версия промпта, версия модели и версия корпуса. Последнее критично: без версий в ключе вы будете месяцами отдавать ответы, сгенерированные снятым с прода промптом, и не поймёте, почему метрики качества разошлись с офлайн-оценкой.
Доля попаданий сильно зависит от домена: в поддержке и FAQ-ботах 20–40% запросов повторяются буквально; в кодовых агентах — почти ноль. Померяйте на своих логах перед тем, как строить.
Уровень 2: семантический кэш
Ищем в кэше по близости эмбеддингов: «как отменить подписку» и «хочу отменить подписку» — один и тот же вопрос. Реализации: GPTCache, самописный поверх вашей векторной БД.
Тут стоит быть осторожным. Семантический кэш — это классификатор с ошибками первого и второго рода, и ошибка первого рода означает уверенно выданный неправильный ответ. «Как отменить подписку» и «как отменить отмену подписки» имеют косинусную близость около 0.95. Правила, которые делают его безопасным:
- Порог высокий (0.95–0.98) и откалиброван на размеченной выборке пар, а не взят из головы.
- Не кэшировать ничего, что зависит от идентичности пользователя, времени или состояния (баланс, статус заказа).
- Ключ включает tenant/пользователя, если ответ персонализирован, — иначе это утечка данных между клиентами.
- Отдельная метрика в мониторинге: доля семантических попаданий и её изменение. Внезапный рост — обычно признак сломанного эмбеддера, отдающего вырожденные векторы.
Уровень 3: prompt caching провайдера
Это KV-кэш префикса на стороне провайдера: вы платите 1.25× за запись и 0.1× за чтение уже посчитанной части. Экономика при повторном использовании:
Ключевая механика, из которой следует всё остальное: кэш работает по совпадению префикса. Порядок рендеринга — tools → system → messages. Изменение одного байта в позиции $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 раза. Вопрос только в том, как решать — и не потерять при этом качество.
сложности} CLS -->|тривиально| SMALL["Haiku 4.5
effort: low"] CLS -->|обычно| MID["Sonnet 5
effort: high"] CLS -->|сложно / агентно| BIG["Opus 4.8
effort: xhigh"] SMALL --> V{Валидатор:
схема, цитаты, тесты} MID --> V V -->|ок| OUT[Ответ] V -->|провал| ESC[Эскалация на уровень выше] ESC --> BIG BIG --> OUT style SMALL fill:#3aa8a0,fill-opacity:0.2 style BIG fill:#c9563f,fill-opacity:0.2
Три рабочих механизма выбора:
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), иначе при деградации провайдера вы будете держать тысячи висящих соединений и утилизировать таймауты вместо того, чтобы быстро отдать деградированный ответ.
на окне из 20 запросов Open --> HalfOpen: прошло cooldown (30 с) HalfOpen --> Closed: пробный запрос успешен HalfOpen --> Open: пробный запрос упал
(cooldown удваивается) Closed --> Closed: нормальная работа Open --> Open: запросы отбиваются мгновенно
→ кэш / fallback-модель / 503 note right of Open Здесь решается продуктовый вопрос: что показать пользователю. Молчаливый фолбэк на слабую модель без пометки в трейсе — источник необъяснимых просадок качества. end note
Ещё три вещи, без которых прод не живёт:
- Идемпотентность. Ретрай вызова, который уже отправил письмо или списал деньги, — это баг, а не устойчивость. Побочные эффекты выносятся за 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% трафика работает и связана с версией промпта.
- Есть план на деградацию: что показать пользователю, когда провайдер лежит.
Источники
- Anthropic: pricing и обзор моделей — актуальные цены, контекстные окна, лимиты вывода.
- Anthropic: prompt caching — множители, TTL, минимальные префиксы, правила инвалидации.
- Anthropic: batch processing — 50% скидка, лимиты, жизненный цикл батча.
- Anthropic: rate limits и errors — квоты, коды, заголовки.
- Anthropic: token counting — почему
tiktokenне подходит. - Dean & Barroso, «The Tail at Scale», CACM 2013 — канон про хвосты латентности и хеджирование.
- AWS: Exponential Backoff And Jitter — сравнение стратегий джиттера с симуляциями.
- Kwon et al., «Efficient Memory Management for LLM Serving with PagedAttention», arXiv:2309.06180 — основа vLLM.
- Yu et al., «Orca: A Distributed Serving System for Transformer-Based Generative Models», OSDI ‘22 — continuous batching.
- Chen et al., «FrugalGPT», arXiv:2305.05176 — каскады моделей и экономика.
- Hu et al., «RouterBench», arXiv:2403.12031 — как измерять роутеры; RouteLLM — открытая реализация.
- OpenTelemetry GenAI semantic conventions — стандарт атрибутов трейсов для LLM.
- Langfuse, LangSmith, Arize Phoenix — платформы наблюдаемости и онлайн-оценки.
- GPTCache — семантическое кэширование ответов.
- vLLM docs — если хостите модель сами.
Что дальше
Мы научились делать сервис быстрым, дешёвым и наблюдаемым. Осталась ось, на которой все три предыдущие оптимизации могут обернуться против вас: кэш способен отдать чужие данные, роутер — увести запрос на модель со слабыми фильтрами, а агент с инструментами — выполнить инструкцию, пришедшую вместе с документом. Дальше — про то, как это ломают и как от этого защищаться: Безопасность: prompt injection, утечки данных, ограничение инструментов, red teaming.