Как устроена LLM с точки зрения инженера: токены, контекст, температура, стоимость
Есть два способа понимать большие языковые модели. Первый — «как исследователь»: трансформер, механизм внимания, градиентный спуск, лосс. Второй — «как инженер»: чёрный ящик с известным контрактом, у которого есть единица измерения (токен), ограничение (контекстное окно), рычаги управления (параметры сэмплирования и усилия), цена (доллары за миллион токенов) и профиль латентности (TTFT + TPOT).
Эта статья — про второй способ. Но не в стиле «вам не нужно знать, как оно работает». Наоборот: почти каждый практический баг в LLM-приложении — от «модель не может сложить два числа» до «мой кэш никогда не срабатывает» — объясняется одной-двумя деталями внутреннего устройства. Разберём ровно те детали, которые окупаются.
Формально о самой архитектуре: Attention Is All You Need (Vaswani et al., 2017). Мы будем ссылаться на неё, но пересказывать не будем — нас интересуют следствия.
1. Токен — единица всего
Модель не видит символы и не видит слова. Она видит последовательность целых чисел — идентификаторов токенов из фиксированного словаря (обычно 50–200 тысяч записей). Токенизатор — детерминированная программа, которая переводит байты в эти числа и обратно.
Доминирующий алгоритм — BPE (byte-pair encoding), перенесённый в NLP из сжатия данных в Neural Machine Translation of Rare Words with Subword Units (Sennrich et al., 2015). Идея простая до неприличия:
1. Начать со словаря из всех отдельных байтов (256 записей).
2. Пройти по обучающему корпусу, найти самую частую пару соседних токенов.
3. Слить эту пару в один новый токен, добавить в словарь.
4. Повторять, пока словарь не достигнет заданного размера.
Сложность обучения токенизатора — O(N · V) наивно, O(N log N) с приоритетной очередью, где N — размер корпуса, V — целевой размер словаря. Кодирование строки — O(L log L) или O(L) в зависимости от реализации. Для нас важно другое: границы токенов определяются статистикой обучающего корпуса, а не смыслом.
Три следствия, которые бьют по проду
Следствие 1: неанглийский текст дороже. Английский на типичных токенизаторах даёт ~4 символа на токен. Русский — 2–3.5. Китайский — 1–1.5. Один и тот же документ в русском переводе стоит в полтора-два раза дороже. Это не наценка за язык, это прямое следствие того, что в обучающем корпусе английского было больше и BPE выучил для него более длинные слияния.
Практический эффект: если у вас RAG-система на русских документах, ваш реальный бюджет контекста в символах — примерно вдвое меньше, чем вы посчитали по английским бенчмаркам.
Следствие 2: модель плохо считает. Число 1234567 может разбиться на 123|456|7. Модель не «видит» разряды — она видит три произвольных куска. Отсюда классические ошибки в умножении многозначных чисел и в сравнении версий (3.9 против 3.11). Лечение — не промптинг, а инструменты: вынесите арифметику в code execution или в function calling. Об этом подробно в статье про структурированный вывод.
Следствие 3: подсчёт токенов «на глаз» всегда неверен. Правило «4 символа = 1 токен» промахивается на 15–30% на коде, на 50%+ на русском, и катастрофически на JSON с длинными ключами. У каждого провайдера свой токенизатор, и он меняется между поколениями моделей.
Как считать токены правильно
Единственный корректный способ — спросить у провайдера.
# pip install anthropic
from anthropic import Anthropic
client = Anthropic()
def count(text: str, model: str = "claude-opus-4-8") -> int:
"""Точный подсчёт токенов для конкретной модели.
Эндпоинт /v1/messages/count_tokens бесплатен и не тратит квоту вывода."""
resp = client.messages.count_tokens(
model=model,
messages=[{"role": "user", "content": text}],
)
return resp.input_tokens
en = "Unbelievable performance improvements across the board."
ru = "Невероятные улучшения производительности по всем направлениям."
print(f"EN: {len(en):3d} симв. → {count(en):3d} ток. ({len(en)/count(en):.1f} симв/ток)")
print(f"RU: {len(ru):3d} симв. → {count(ru):3d} ток. ({len(ru)/count(ru):.1f} симв/ток)")
Важно: не используйте tiktoken для не-OpenAI моделей. Это токенизатор OpenAI; на текстах для Claude он систематически занижает счёт на 15–20%, а на коде и не-английском — сильно больше. Ошибка в оценке бюджета контекста означает 400-е ошибки в проде.
Токенизаторы, которые полезно посмотреть вживую:
openai/tiktoken— быстрая реализация BPE на Rust с биндингами в Python.huggingface/tokenizers— универсальная библиотека, поддерживает BPE, WordPiece, Unigram.- Tiktokenizer — интерактивная визуализация разбиения.
2. Что происходит между запросом и ответом
Инженерно генерация делится на две принципиально разные фазы. Их путают чаще всего, а от их различия зависит и латентность, и стоимость, и вся стратегия оптимизации.
Prefill (предзаполнение). Модель обрабатывает весь промпт за один проход. Все n токенов идут через сеть параллельно — это большие матричные умножения, GPU загружен под завязку. Фаза compute-bound. Стоимость внимания здесь квадратична по длине: O(n²·d) по времени, где d — размерность модели. Именно поэтому промпт в 100k токенов обрабатывается заметно дольше, чем в 10 раз дольше промпта в 10k.
Decode (декодирование). Модель генерирует по одному токену за проход. На каждом шаге прогоняется ровно один новый токен, но приходится читать из памяти все веса модели. Фаза memory-bandwidth-bound: GPU в основном ждёт память. Здесь вычислений мало, но шагов много, и они строго последовательны — распараллелить генерацию собственного ответа нельзя.
Между шагами decode переиспользуется KV-кэш: ключи и значения внимания для уже обработанных токенов не пересчитываются. Без него каждый шаг стоил бы O(n²), с ним — O(n). Цена — память: KV-кэш растёт линейно с длиной последовательности и в длинном контексте занимает десятки гигабайт. Управление этой памятью — предмет отдельной инженерии; см. PagedAttention / vLLM (Kwon et al., 2023).
Формулы латентности, которыми можно пользоваться
TTFT ≈ сеть + очередь + (n_input − n_cached) / скорость_prefill
TPOT ≈ 1 / скорость_decode (токенов в секунду)
Общее ≈ TTFT + n_output · TPOT
Из этого следует главное правило оптимизации латентности:
| Что сокращаем | На что влияет | Насколько эффективно |
|---|---|---|
| Длину промпта | TTFT | Средне — квадратичная зависимость помогает, но убрать много обычно нельзя |
| Кэшируемый префикс (prompt caching) | TTFT | Очень сильно — кэш-хит убирает prefill почти целиком |
| Длину ответа | Общую латентность | Очень сильно — линейно, и это обычно единственный настоящий рычаг |
| Выбор модели | TPOT и TTFT | Сильно — младшие модели декодируют кратно быстрее |
| Стриминг | Воспринимаемую латентность | Сильно — пользователь видит первый токен через TTFT, а не через полное время |
Ответ на 2000 токенов при TPOT = 20 мс — это 40 секунд, что бы вы ни делали с промптом. Если вам нужен быстрый отклик, единственный работающий приём — заставить модель отвечать короче.
3. Контекстное окно: что это и почему «влезает» ≠ «работает»
Контекстное окно — максимальное число токенов, которые модель может держать «в поле зрения» за один запрос: система, определения инструментов, вся история диалога, документы и генерируемый ответ. У современных моделей Anthropic это 1M токенов (у Haiku 4.5 — 200K), с потолком вывода 128K (64K у Haiku).
Три ловушки.
Ловушка 1: окно общее для входа и выхода. max_tokens — это лимит на вывод, и он вычитается из общего бюджета. Если промпт занял почти всё окно, места на ответ не осталось. При этом max_tokens — не подсказка модели, а жёсткий обрыв: превышение даёт stop_reason: "max_tokens" и обрезанный посередине текст. Проверять stop_reason нужно всегда.
Ловушка 2: заявленное окно ≠ эффективное. Модель, формально принимающая 1M токенов, не обязательно одинаково хорошо использует всю длину. Классический результат — Lost in the Middle: How Language Models Use Long Contexts (Liu et al., 2023): точность извлечения факта заметно проседает, когда факт лежит в середине длинного контекста, и максимальна на краях. Кривая U-образная.
Практика: важное — в начало и в конец. Инструкции сверху, ключевой вопрос снизу, «наполнитель» в середине. Для RAG это означает, что порядок чанков после реранжирования — не косметика; см. RAG.
Ловушка 3: длинный контекст дорог и медленен. Внимание квадратично. Даже с FlashAttention (Dao et al., 2022), который убирает материализацию матрицы внимания в HBM и даёт линейную по памяти реализацию, вычислительная сложность остаётся O(n²). Забить окно «на всякий случай» — самый дорогой способ ничего не улучшить.
Код, который обрабатывает response.content[0].text без проверки stop_reason, — это код с латентным багом. При refusal массив content может быть пустым, при max_tokens — обрезанным, при tool_use — вообще не содержать текста.
4. Температура и сэмплирование: чем на самом деле управляют
На каждом шаге decode модель выдаёт вектор логитов размером со словарь — по числу на каждый возможный следующий токен. Дальше нужно выбрать один. Способ выбора и есть сэмплирование.
Softmax с температурой превращает логиты в вероятности: логиты делятся на T, затем нормализуются. Эффект:
T → 0: распределение схлопывается в дельта-функцию, всегда выбирается самый вероятный токен (жадное декодирование).T = 1: распределение ровно такое, каким его выдала модель.T > 1: распределение сглаживается, хвост становится вероятнее, текст — «креативнее» и бессвязнее.
Top-k оставляет только k самых вероятных кандидатов и ренормализует. Грубо, потому что k фиксировано: в уверенном контексте отсекает слишком мало, в неуверенном — слишком много.
Top-p (nucleus sampling) оставляет минимальное множество токенов, суммарная вероятность которых ≥ p. Адаптивно: там, где модель уверена, ядро из 2–3 токенов; там, где нет, — из сотен. Введено в The Curious Case of Neural Text Degeneration (Holtzman et al., 2019) — там же показано, почему чисто жадное декодирование даёт вырожденный, зацикливающийся текст.
import numpy as np
def sample(logits: np.ndarray, temperature: float = 1.0, top_p: float = 1.0) -> int:
"""Референсная реализация temperature + nucleus sampling.
Сложность: O(V log V) из-за сортировки, где V — размер словаря."""
if temperature <= 0: # жадное декодирование
return int(np.argmax(logits))
scaled = logits / temperature
scaled = scaled - scaled.max() # стабилизация: избегаем переполнения exp
probs = np.exp(scaled)
probs /= probs.sum()
if top_p < 1.0:
order = np.argsort(-probs) # по убыванию вероятности
cum = np.cumsum(probs[order])
# оставляем минимальное ядро, покрывающее top_p вероятности
keep = order[: int(np.searchsorted(cum, top_p) + 1)]
mask = np.zeros_like(probs, dtype=bool)
mask[keep] = True
probs = np.where(mask, probs, 0.0)
probs /= probs.sum()
return int(np.random.choice(len(probs), p=probs))
Важно: temperature=0 никогда не давала детерминизма
Это самое живучее заблуждение в отрасли. Даже при жадном декодировании результат не воспроизводится побитово, потому что:
- сложение чисел с плавающей точкой на GPU не ассоциативно, а порядок редукции зависит от того, как ядро разбило работу;
- батчинг на сервере меняется от запроса к запросу, а вместе с ним и порядок операций;
- MoE-модели маршрутизируют токены по экспертам в зависимости от состава батча;
- при равных логитах у двух токенов
argmaxвыбирает произвольный.
Если вам нужна воспроизводимость — её обеспечивает не температура, а валидация схемой и кэширование ответов, а не надежда на детерминизм модели.
Отдельно: в современных API температуры может просто не быть
Это важное изменение контракта, о котором стоит знать заранее. На Claude Opus 4.7/4.8, Sonnet 5 и Fable 5 параметры temperature, top_p и top_k удалены — запрос с ними возвращает 400. Модели этого поколения управляются иначе:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
thinking={"type": "adaptive"}, # модель сама решает, сколько «думать»
output_config={"effort": "high"}, # low | medium | high | xhigh | max
messages=[{"role": "user", "content": "..."}],
)
# temperature / top_p / top_k здесь дали бы 400 invalid_request_error
Логика смены рычага: temperature — это управление на уровне отдельного токена, вслепую. effort — управление на уровне задачи: сколько токенов рассуждения и сколько шагов инструментов модель имеет право потратить. Для инженера это более осмысленная ручка, потому что она напрямую отображается в стоимость и латентность.
Если раньше вы использовали temperature=0 для «предсказуемости» — замена не в другой температуре, а в effort: "low" плюс более жёсткий промпт и схема вывода. Если использовали temperature=1.2 для разнообразия — замена в явной инструкции («предложи четыре разных варианта, затем выбери») либо в нескольких независимых вызовах.
Сравнительная сводка по рычагам:
| Рычаг | Что делает | Когда применять | Побочный эффект |
|---|---|---|---|
temperature (наследие) |
Сглаживает распределение токенов | Устаревшие модели, творческая генерация | Ниже связность на высоких значениях |
top_p (наследие) |
Отсекает хвост распределения | Вместе с temperature, не вместо | На малых p — шаблонность |
effort |
Глубина рассуждения и число шагов | Современные модели, все задачи | Прямо влияет на цену и латентность |
thinking: adaptive |
Модель сама решает глубину | По умолчанию для нетривиальных задач | Расход токенов рассуждения |
max_tokens |
Жёсткий потолок вывода | Всегда | Обрыв посередине, если мало |
stop_sequences |
Досрочная остановка | Парсеры с явным разделителем | Пропущенный разделитель = полный вывод |
5. Экономика: как считать деньги
Тарификация посимвольно-токенная и асимметричная: выходные токены стоят в 4–5 раз дороже входных. Причина видна из раздела 2 — decode последовательный и не батчится так же хорошо, как prefill.
Актуальные ставки Anthropic (за миллион токенов):
| Модель | Контекст | Вход | Выход | Когда брать |
|---|---|---|---|---|
claude-fable-5 |
1M | 10.00 $ | 50.00 $ | Самые сложные длинногоризонтные задачи |
claude-opus-4-8 |
1M | 5.00 $ | 25.00 $ | Дефолт для сложных агентных и кодовых задач |
claude-sonnet-5 |
1M | 3.00 $ | 15.00 $ | Высоконагруженный прод, качество близко к Opus |
claude-haiku-4-5 |
200K | 1.00 $ | 5.00 $ | Классификация, роутинг, простые извлечения |
Соотношение вход/выход в реальных нагрузках сильно разное, и от него зависит, какой рычаг оптимизации работает:
| Тип нагрузки | Типичный вход | Типичный выход | Где деньги |
|---|---|---|---|
| Классификация тикетов | 500 | 5 | Целиком во входе → кэшируйте system |
| RAG-ответ | 8 000 | 400 | Во входе → кэшируйте документы, сжимайте чанки |
| Генерация кода | 3 000 | 2 500 | Пополам → важны обе стороны |
| Длинный отчёт | 2 000 | 8 000 | В выводе → единственный рычаг — короче писать |
| Агентный цикл (20 шагов) | 20 × растущий | 20 × 300 | История растёт квадратично → кэш обязателен |
Кэширование промптов — главный рычаг
Кэш работает по совпадению префикса. Порядок рендеринга запроса: tools → system → messages. Любое изменение байта в префиксе инвалидирует всё после него.
Экономика на примере Opus 4.8 (5 $ за 1M входных):
Запись в кэш (TTL 5 мин): 1.25× базовой цены → $6.25 / 1M
Запись в кэш (TTL 1 час): 2.00× базовой цены → $10.00 / 1M
Чтение из кэша: 0.10× базовой цены → $0.50 / 1M
Обычный вход: 1.00× → $5.00 / 1M
Точка безубыточности для 5-минутного TTL — два запроса: 1.25 + 0.1 = 1.35 против 2.0 без кэша. Для часового TTL нужно минимум три.
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
system=[{
"type": "text",
"text": BIG_STABLE_SYSTEM_PROMPT, # неизменный между запросами
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": user_question}], # меняется каждый раз
)
# Проверка, что кэш реально работает:
u = response.usage
print(f"записано в кэш: {u.cache_creation_input_tokens}")
print(f"прочитано из кэша: {u.cache_read_input_tokens}")
print(f"обработано без кэша: {u.input_tokens}")
Если cache_read_input_tokens стабильно ноль при одинаковых промптах — где-то тихий инвалидатор. Типичные виновники:
| Паттерн | Почему ломает |
|---|---|
datetime.now() в system-промпте |
Префикс уникален на каждый запрос |
uuid4() / request_id в начале контента |
То же самое |
json.dumps(d) без sort_keys=True |
Недетерминированная сериализация |
Итерация по set при сборке промпта |
Порядок не гарантирован |
| Список инструментов, зависящий от пользователя | tools рендерятся первыми — ломается всё |
| Смена модели посреди диалога | Кэш привязан к модели |
Условные блоки if flag: system += ... |
Каждая комбинация флагов — свой префикс |
Ещё одна тонкость: минимальная кэшируемая длина префикса зависит от модели. Для Opus 4.8 — 4096 токенов, для Sonnet 4.6 и Fable 5 — 2048, для Sonnet 4.5 — 1024. Промпт короче порога не закэшируется молча, без ошибки: просто cache_creation_input_tokens: 0. Промпт на 3000 токенов кэшируется на Sonnet и не кэшируется на Opus.
Максимум — 4 точки разрыва (cache_control) на запрос.
Batch API
Если задача не интерактивная — пакетная обработка даёт 50% скидки на все токены. Ограничения: до 100 000 запросов или 256 МБ на пакет, большинство завершается в течение часа, максимум — 24 часа. Батч отлично комбинируется с кэшированием: общий системный промпт в тысячах запросов кэшируется один раз.
Калькулятор стоимости
from dataclasses import dataclass
PRICING = { # ($ за 1M вход, $ за 1M выход)
"claude-fable-5": (10.0, 50.0),
"claude-opus-4-8": ( 5.0, 25.0),
"claude-sonnet-5": ( 3.0, 15.0),
"claude-haiku-4-5": ( 1.0, 5.0),
}
@dataclass
class Workload:
model: str
requests_per_day: int
input_tokens: int
output_tokens: int
cached_prefix: int = 0 # сколько входных токенов кэшируются
cache_hit_rate: float = 0.0 # доля запросов с попаданием в кэш
batch: bool = False
def daily_cost(self) -> float:
p_in, p_out = PRICING[self.model]
fresh = self.input_tokens - self.cached_prefix
# Попадание: свежие токены по полной + кэш по 0.1x
hit = fresh * p_in + self.cached_prefix * p_in * 0.10
# Промах: свежие по полной + запись кэша по 1.25x
miss = fresh * p_in + self.cached_prefix * p_in * 1.25
cost_in = self.cache_hit_rate * hit + (1 - self.cache_hit_rate) * miss
cost_out = self.output_tokens * p_out
per_req = (cost_in + cost_out) / 1_000_000
if self.batch:
per_req *= 0.5
return per_req * self.requests_per_day
rag = Workload("claude-sonnet-5", 50_000, 8_000, 400,
cached_prefix=6_500, cache_hit_rate=0.9)
print(f"RAG в день: ${rag.daily_cost():,.2f} → в месяц ${rag.daily_cost()*30:,.2f}")
naive = Workload("claude-sonnet-5", 50_000, 8_000, 400)
print(f"Без кэша: ${naive.daily_cost():,.2f} → в месяц ${naive.daily_cost()*30:,.2f}")
При 90% попаданий кэш срезает входную часть счёта примерно в четыре раза. На нагрузке с преобладанием входа это разница между «дорого» и «нормально».
6. Дерево решений: какую модель брать
многошагового рассуждения?"} B -- Нет --> C{"Объём > 10k запросов в день?"} C -- Да --> D["Haiku 4.5
effort: low"] C -- Нет --> E["Sonnet 5
effort: low или medium"] B -- Да --> F{"Агентный цикл
или длинный горизонт?"} F -- Нет --> G["Sonnet 5
adaptive + high"] F -- Да --> H{"Цена ошибки высока?"} H -- Нет --> G H -- Да --> I["Opus 4.8
adaptive + xhigh"] D --> J{"Качество достаточное
на своём eval-наборе?"} E --> J G --> J I --> J J -- Да --> K["Зафиксировать,
включить кэш и мониторинг"] J -- Нет --> L["Поднять effort на ступень
или модель на тир выше"] L --> J
Ключевое здесь — узел «на своём eval-наборе». Выбор модели по чужим бенчмаркам — это гадание; про построение собственных наборов и метрик см. оценку и бенчмарки.
Важное правило миграции: не понижайте модель ради экономии по умолчанию. Сначала измерьте, где реально уходят деньги. В 8 случаях из 10 это не тир модели, а невключённый кэш и раздутый вывод.
7. Типичные ошибки
Оценка токенов по длине строки. «4 символа = 1 токен» ломается на русском, коде и JSON. Считайте через count_tokens. Стоимость вызова — ноль.
tiktoken для не-OpenAI моделей. Систематическое занижение. Планировали 180k токенов, получили 400 «context length exceeded».
Игнорирование stop_reason. Обрезанный по max_tokens JSON парсится с исключением где-то ниже по стеку, и вы будете отлаживать парсер вместо того, чтобы поднять лимит.
max_tokens «с запасом» в 200. Модель начинает писать нормальный ответ и обрывается. Для нестримовых запросов разумный дефолт — около 16 000; для стримовых — до 64 000. Занижать стоит только по конкретной причине (классификация — 256, жёсткий кост-кап).
Ожидание детерминизма от temperature=0. Не бывает. Стройте систему на валидации, а не на воспроизводимости.
Динамический system-промпт. Дата, имя пользователя, режим — всё это в system-промпте убивает кэш для всех. Выносите изменяемое в конец messages.
Недетерминированная сериализация инструментов. tools рендерятся первыми. Список, собранный итерацией по словарю без сортировки, ломает кэш целиком.
Забивание контекста «на всякий случай». Больше контекста ≠ лучше ответ. Работает lost-in-the-middle, платите вы квадратично, а качество падает.
Смена набора инструментов посреди диалога. Добавили один инструмент на пятом ходу — инвалидировали весь кэш разговора. Если нужен динамический набор — используйте tool search, который дописывает схемы, а не подменяет их.
Оптимизация не того конца. Если 80% счёта — выходные токены, сжатие промпта на 20% не даст ничего. Сначала посмотрите на разбивку usage, потом оптимизируйте.
8. Практика продакшена
Считайте usage на каждом запросе и складывайте в метрики. Минимальный набор: input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, stop_reason, латентность, модель. Без этого разговор об оптимизации беспредметен.
import time, logging
def instrumented_call(client, **kwargs):
t0 = time.monotonic()
resp = client.messages.create(**kwargs)
elapsed = time.monotonic() - t0
u = resp.usage
total_in = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
hit_rate = u.cache_read_input_tokens / total_in if total_in else 0.0
logging.info(
"llm_call model=%s in=%d cached=%d out=%d hit_rate=%.2f "
"stop=%s latency_ms=%d req_id=%s",
resp.model, u.input_tokens, u.cache_read_input_tokens,
u.output_tokens, hit_rate, resp.stop_reason,
int(elapsed * 1000), resp._request_id,
)
return resp
Обратите внимание: input_tokens — это только незакэшированный остаток. Полный размер промпта — сумма трёх полей. Агент, который проработал час и показывает input_tokens: 4000, не «маленький» — остальное пришло из кэша.
Всегда обрабатывайте stop_reason явно. Отдельная ветка на max_tokens (повторить с большим лимитом или перейти на стриминг), на refusal (не ретраить тот же промпт), на tool_use (выполнить инструмент), на pause_turn (переотправить историю).
Стримьте всё, что длиннее ~16k max_tokens. SDK отказывается делать нестримовые запросы, которые он оценивает дольше десяти минут, — простой обрыв соединения по таймауту вы получите и без этой защиты.
Проектируйте промпт под кэш с первого дня. Стабильное — вперёд (замороженный system, детерминированно отсортированные инструменты), изменчивое — назад, после последней точки разрыва. Переделывать это потом дороже, чем сделать сразу.
Ставьте потолки. Пер-запросный лимит max_tokens, дневной бюджет на пользователя, алерт на аномальный рост output_tokens. Агентный цикл без потолка — это открытый счёт.
Прогревайте кэш, если первая задержка видна пользователю. Запрос с max_tokens: 0 прогоняет prefill и записывает кэш, возвращаясь мгновенно с пустым content. Имеет смысл при старте воркера или после деплоя — но не когда трафик и так плотнее, чем TTL кэша.
Подробнее про роутинг моделей, наблюдаемость и бюджеты — в статье про ИИ в продакшене.
Мини-итог
- Токен — единица счёта, стоимости и лимитов. Границы токенов задаёт статистика корпуса, а не смысл, — отсюда дорогой русский текст и сломанная арифметика. Считайте токены API-методом, а не эвристикой.
- Генерация делится на prefill и decode. Prefill параллелен, quadratic по длине и кэшируется. Decode последователен, линеен по числу выходных токенов и не кэшируется никак. Отсюда: длинный промпт лечится кэшем, длинный ответ — только сокращением ответа.
- Контекстное окно — общий бюджет входа и выхода. Заявленный объём не равен эффективному: работает lost-in-the-middle, важное держите на краях.
- Температура управляет распределением на уровне токена и никогда не давала детерминизма. В современных API Anthropic её нет вовсе — рычаг сместился на
effortи adaptive thinking, что для инженера удобнее: эта ручка прямо отображается в цену и латентность. - Экономика асимметрична: выход дороже входа в 4–5 раз. Главные рычаги в порядке эффективности — кэширование префикса, сокращение вывода, Batch API, выбор модели.
- Наблюдаемость
usage— предусловие любой оптимизации. Оптимизировать нужно тот конец, где реально уходят деньги, а не тот, который проще сжать.
Дальше начинается собственно работа с моделью: как формулировать задачу так, чтобы модель её понимала, и как получать ответ в нужной форме.
Источники
- Vaswani et al. — Attention Is All You Need (2017), исходная архитектура трансформера.
- Sennrich et al. — Neural Machine Translation of Rare Words with Subword Units (2015), BPE в NLP.
- Holtzman et al. — The Curious Case of Neural Text Degeneration (2019), nucleus sampling.
- Liu et al. — Lost in the Middle: How Language Models Use Long Contexts (2023).
- Dao et al. — FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness (2022).
- Kwon et al. — Efficient Memory Management for Large Language Model Serving with PagedAttention (2023), vLLM.
- Anthropic: Prompt caching — точки разрыва, TTL, пороги.
- Anthropic: Token counting — эндпоинт
count_tokens. - Anthropic: Context windows и Pricing.
- Anthropic: Batch processing — 50% скидка на пакетную обработку.
openai/tiktokenиhuggingface/tokenizers— реализации токенизаторов.- Tiktokenizer — интерактивная визуализация разбиения на токены.
Что дальше
Основы промптинга: zero-shot, few-shot, роли, структура и формат ответа — как из понимания токенов и контекста вырастает практика формулирования задач: почему few-shot работает, куда класть инструкции, как задавать формат ответа и какие структуры промпта устойчиво выигрывают на замерах.