Как работают LLM
Модуль про внутреннее устройство LLM в том объёме, который нужен инженеру продукта. Разберём токены и BPE, attention, сэмплинг, эмбеддинги, этапы обучения, KV-cache и квантизацию, а в конце посчитаем, сколько стоит длинный контекст и reasoning. Этого хватит, чтобы объяснять счёт за API, латентность и галлюцинации цифрами, без ссылок на «магию».
Относитесь к LLM как к чистой функции float[] f(int[] tokens). Она принимает последовательность целых чисел и возвращает вектор логитов, по одному на каждый токен словаря. Всё остальное (чат, tool use, «рассуждения») собрано поверх этой функции циклом for и сэмплером. От понимания этой функции и цикла зависят стоимость, латентность и режимы отказов вашего продукта.
Относитесь к LLM как к чистой функции f(tokens []int) []float32. Она принимает последовательность целых чисел и возвращает вектор логитов, по одному на каждый токен словаря. Всё остальное (чат, tool use, «рассуждения») собрано поверх этой функции циклом for и сэмплером. От понимания этой функции и цикла зависят стоимость, латентность и режимы отказов вашего продукта.
Токены и BPE
Модель не работает с символами или словами, она работает с token'ами, то есть с целыми числами из словаря фиксированного размера. У современных моделей словарь обычно насчитывает от 32K до 256K элементов: у Llama 3 около 128K, у o200k_base (OpenAI) около 200K. Токен может быть целым словом (" the", вместе с ведущим пробелом), частью слова ("изац"), отдельным байтом или куском отступа в коде.
В токенах измеряется почти всё, за что вы отвечаете в проде. Цена API считается за миллион входных и выходных токенов. Контекстное окно задаётся в токенах. Rate limits выражаются в токенах в минуту (ITPM/OTPM), а латентность генерации в токенах в секунду. Если вы оцениваете бюджет в символах или словах, ошибка на русском тексте будет в разы.
Как строится BPE
Byte Pair Encoding (Sennrich et al., 2015, для LLM его адаптировали на уровне байтов в GPT-2) обучается один раз на большом корпусе:
- Текст нарезается претокенизатором на куски примерно по словам: регулярка отделяет пробел+слово, числа, пунктуацию. Слияния никогда не пересекают эти границы.
- Каждый кусок превращается в последовательность байтов UTF-8. Базовый словарь состоит из 256 байтов, поэтому любой текст закодируется и «неизвестного токена» не бывает.
- Алгоритм считает частоты всех соседних пар и самую частую пару (например,
t+h) объявляет новым токеномth. Правило слияния записывается в таблицу с рангом. - Шаг 3 повторяется, пока словарь не дорастёт до целевого размера, то есть десятки и сотни тысяч раз.
Кодирование нового текста повторяет те же слияния в порядке ранга. Отсюда два следствия. Токенизация детерминирована, но зависит от контекста: "мир" и " мир" дают разные токены. И она отражает статистику обучающего корпуса: частые в корпусе строки получают короткие коды.
Песочница BPE
Учебный BPE. Он обучается прямо в браузере на встроенном корпусе примерно из 5 КБ текста (≈ 65% английского, 20% русского, 15% GoJava-кода). Это не токенизатор Claude или GPT: у настоящих словарь в сотни раз больше, а корпус в миллиарды раз. Механика при этом та же. Двигайте ползунок слияний и смотрите, как текст «склеивается». Есть два режима базовых единиц. В режиме байтов UTF-8 (как у GPT-2/4 и o200k) кириллическая буква изначально стоит 2 токена, а токены могут резать букву пополам: такие показаны шестнадцатеричными байтами в красной пунктирной рамке. В режиме символов с byte fallback (как SentencePiece в Llama 2) незнакомые символы, например эмодзи, распадаются на байты <0xF0>….
Показать первые выученные слияния (таблица рангов)
Почему русский текст дороже
- Корпус. В обучающих данных токенизатора английского на порядки больше, поэтому английские слова целиком попадают в словарь, а русские режутся на 2–4 куска. В песочнице это видно на полном количестве слияний:
" the"и" model"стали одним токеном, а русские слова остаются по кусочкам. - UTF-8. Кириллическая буква занимает 2 байта, латинская 1. Пока пара байтов не слилась, одна буква стоит двух токенов. Поставьте ползунок на 0 и сравните счётчики.
- Морфология. Шесть падежей и богатое словообразование дают больше уникальных словоформ, а значит, меньше повторов, на которых учится BPE.
Для английского у больших токенизаторов работает правило «≈ 4 символа или ≈ 0.75 слова на токен». Одинаковый по смыслу русский текст обычно занимает в 1.3–3 раза больше токенов в зависимости от токенизатора: у новых словарей на 200K+ разрыв меньше, у старых больше. Это прямой множитель к цене, к латентности (больше токенов на выходе, значит дольше генерация) и к расходу контекста. Не берите коэффициент из статей, а измерьте на 100 реальных документах своего домена через count_tokens.
Числа, код и прочие сюрпризы
- Числа. Многие токенизаторы режут цифры на группы до 3 (
12345→123|45). Модель «видит» не разряды, а произвольные куски, поэтому арифметика на длинных числах и сравнение9.11и9.9исторически давались плохо. Вывод для инженера: считайте в коде (tool use), а не в модели. - Буквы внутри токена. Вопрос «сколько букв r в strawberry» трудный, потому что слово для модели занимает 1–3 токена, и отдельные буквы в нём не видны. Задачи на уровне символов (реверс строки, подсчёт, регулярки) ненадёжны без инструментов.
- Код. Отступы,
\n\t\tи частые идентификаторы обычно имеют свои токены, но минифицированный код, base64, UUID и хэши токенизируются очень плохо: почти символ на токен. Логи с UUID и JSON с длинными ключами съедают бюджет контекста быстрее, чем кажется. - Граница токенов в промпте. Висящий пробел в конце промпта или разрыв слова даёт модели «неестественную» последовательность, которой почти не было в обучении. Отсюда странные продолжения.
- Смена токенизатора. У каждого семейства моделей свой словарь, а у новых поколений он может меняться. Бюджеты, рассчитанные для одной модели, не переносятся на другую без пересчёта.
Типичный баг в проде выглядит так: бюджет контекста считается как len(text)/4, для русских документов он оказывается занижен в 2 раза, запрос превышает окно, и API возвращает 400. Ещё хуже вариант с max_tokens, рассчитанным на английский: русский JSON обрезается посередине с stop_reason: "max_tokens". Считайте токены реальным счётчиком и держите запас в 10–20%.
Код: как считать токены на самом деле
Токенизатор Claude не опубликован, поэтому локально, как с tiktoken, посчитать нельзя. Для этого есть бесплатный (но с rate limit) эндпоинт POST /v1/messages/count_tokens. Он принимает те же model, system, messages, tools, что и Messages API, и возвращает input_tokens. Документация называет результат оценкой: фактический usage.input_tokens может немного отличаться, потому что провайдер добавляет служебные токены. tiktoken ниже приведён для иллюстрации BPE на открытом словаре OpenAI. Для бюджетов Claude он не подходит.
// Подсчёт токенов через API. Сырой java.net.http, чтобы видеть эндпоинт;
// в проде берите SDK: client.messages().countTokens(MessageCountTokensParams.builder()...build()).
package tokens;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
public final class Tokens {
record Msg(String role, String content) {}
@JsonInclude(JsonInclude.Include.NON_EMPTY)
record CountReq(String model, String system, List<Msg> messages) {}
record CountResp(int input_tokens) {}
private static final ObjectMapper JSON = new ObjectMapper()
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build();
/** Возвращает число входных токенов для запроса к модели. */
public static int count(String model, String system, String user)
throws IOException, InterruptedException {
byte[] body = JSON.writeValueAsBytes(
new CountReq(model, system, List.of(new Msg("user", user))));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.anthropic.com/v1/messages/count_tokens"))
.timeout(Duration.ofSeconds(10))
.header("x-api-key", System.getenv("ANTHROPIC_API_KEY"))
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new IOException("count_tokens: status " + resp.statusCode() + ": " + resp.body());
}
return JSON.readValue(resp.body(), CountResp.class).input_tokens();
}
// Пример: сравнить EN и RU. Считайте разницу с пустым сообщением,
// чтобы вычесть служебные токены шаблона.
// int en = Tokens.count("claude-opus-5", null, "The model reads tokens, not words.");
// int ru = Tokens.count("claude-opus-5", null, "Модель читает токены, а не слова.");
}
// Подсчёт токенов через API. Сырой net/http, чтобы видеть эндпоинт;
// в проде берите SDK: client.Messages.CountTokens(ctx, anthropic.MessageCountTokensParams{...}).
package tokens
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type msg struct {
Role string `json:"role"`
Content string `json:"content"`
}
type countReq struct {
Model string `json:"model"`
System string `json:"system,omitempty"`
Messages []msg `json:"messages"`
}
var httpClient = &http.Client{Timeout: 10 * time.Second}
// Count возвращает число входных токенов для запроса к модели.
func Count(ctx context.Context, model, system, user string) (int, error) {
body, err := json.Marshal(countReq{Model: model, System: system,
Messages: []msg{{Role: "user", Content: user}}})
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.anthropic.com/v1/messages/count_tokens", bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("x-api-key", os.Getenv("ANTHROPIC_API_KEY"))
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("content-type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("count_tokens: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("count_tokens: status %d: %s", resp.StatusCode, raw)
}
var out struct {
InputTokens int `json:"input_tokens"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return 0, fmt.Errorf("count_tokens: decode: %w", err)
}
return out.InputTokens, nil
}
// Пример: сравнить EN и RU. Считайте разницу с пустым сообщением,
// чтобы вычесть служебные токены шаблона.
// en, _ := Count(ctx, "claude-opus-5", "", "The model reads tokens, not words.")
// ru, _ := Count(ctx, "claude-opus-5", "", "Модель читает токены, а не слова.")
# pip install tiktoken
# Иллюстрация BPE на открытом словаре OpenAI (o200k_base).
# У Claude СВОЙ токенизатор: для бюджетов используйте
# client.messages.count_tokens(model=..., messages=[...]).input_tokens
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
samples = {
"en": "The model reads tokens, not words.",
"ru": "Модель читает токены, а не слова.",
"num": "12345678 + 9.11 vs 9.9",
"uuid": "3f2b9c1e-7a4d-4e8b-9f10-2c6d8e4a1b7f",
}
for name, text in samples.items():
ids = enc.encode(text)
pieces = [enc.decode_single_token_bytes(t) for t in ids]
# bytes, потому что токен может оказаться половинкой UTF-8 символа
shown = [p.decode("utf-8", errors="replace") for p in pieces]
print(f"{name:5} chars={len(text):3} tokens={len(ids):3} "
f"chars/token={len(text) / len(ids):.2f}")
print(" ", " | ".join(shown))
Генерация и сэмплинг
Next-token prediction и авторегрессия
Модель обучена одной задаче: по префиксу из токенов выдать распределение вероятностей следующего токена. Её выход — вектор логитов (logits) длиной в размер словаря, то есть ненормированных оценок, которые softmax превращает в вероятности. Генерация устроена как цикл:
List<Integer> tokens = tokenize(prompt);
while (out.size() < maxTokens) {
float[] logits = model(tokens); // один forward pass
int next = sample(logits, params); // temperature / top_k / top_p
if (next == EOS || hitsStop(out, next)) {
break;
}
tokens.add(next); // авторегрессия: выход становится входом
out.add(next);
}
tokens := tokenize(prompt)
for len(out) < maxTokens {
logits := model(tokens) // один forward pass
next := sample(logits, params) // temperature / top_k / top_p
if next == eos || hitsStop(out, next) {
break
}
tokens = append(tokens, next) // авторегрессия: выход становится входом
out = append(out, next)
}
Из этого цикла следует несколько фактов, которые важны в проде:
- Выходные токены последовательны. 1000 токенов ответа требуют 1000 шагов цикла, и распараллелить их нельзя. Поэтому output в 4–5 раз дороже input и определяет большую часть латентности. Самая дешёвая оптимизация — просить короткий ответ.
- Модель не может «вернуться». Если на шаге 5 выбран неудачный токен, все следующие шаги будут его оправдывать. Отсюда снежный ком галлюцинаций и польза от того, чтобы модель сначала подумала или процитировала источник, а потом отвечала.
- Модель не знает, «правду» ли она пишет. Она оптимизирована на правдоподобное продолжение. Правдивость появляется как побочный эффект обучения, а не как гарантия.
- Стриминг бесплатен. Токены всё равно появляются по одному, и SSE просто отдаёт их клиенту сразу (модуль 2).
Параметры сэмплинга
| Параметр | Что делает | Когда трогать |
|---|---|---|
temperature | Делит логиты на T перед softmax. T<1 делает распределение острее, T>1 площе, T→0 даёт greedy (всегда argmax). | Около 0 для извлечения и классификации, 0.7–1 для текстов и брейншторма. |
top_k | Оставляет k самых вероятных токенов, остальные обнуляет. | Грубая страховка от «хвоста» мусорных токенов. |
top_p (nucleus) | Оставляет минимальный набор токенов, чья суммарная вероятность ≥ p. Размер набора адаптивный: когда модель уверена, остаётся 1–2 токена, когда нет, их десятки. | Обычно 0.9–0.95. Меняйте либо temperature, либо top_p, а не оба сразу. |
max_tokens | Жёсткий потолок длины ответа. На качество не влияет, только обрезает. | Всегда. Проверяйте stop_reason == "max_tokens": это обрезанный, возможно невалидный JSON. |
stop_sequences | Останавливают генерацию, как только появилась строка. stop_reason: "stop_sequence". | Разделители в шаблонах, экономия выходных токенов. |
Порядок применения в большинстве движков (HF transformers, vLLM): логиты → деление на temperature → top_k → top_p → перенормировка → случайный выбор. Есть ещё min_p, frequency_penalty и presence_penalty, но это уже зависит от провайдера.
Temperature, top_k и top_p на распределении следующего токена
Контекст: «Я пью кофе с…». Серые полосы показывают распределение после temperature, цветные — итоговые вероятности после отсечений и перенормировки. Отсечённые кандидаты затемнены, справа указано, чем они отрезаны. Нажмите «Сэмплировать 100 раз»: фиолетовые засечки покажут, сколько раз выпал каждый токен.
Энтропия H = −Σ p·log₂p измеряет, сколько бит неопределённости в выборе. При H≈0 модель уверена (greedy и сэмплинг совпадают), при высокой H ответы сильно разнятся от запуска к запуску. Низкая энтропия в среднем по ответу — полезный сигнал уверенности, если провайдер отдаёт logprobs.
На момент написания (сентябрь 2026) у новейших моделей Anthropic (Opus 4.7 и новее, Sonnet 5, Fable) параметры temperature, top_p и top_k удалены: запрос с ними возвращает 400. Глубиной и «старательностью» ответа управляют через output_config: {effort: "low" | "medium" | "high" | "xhigh" | "max"} (раздел про reasoning). У других провайдеров и у open-source моделей в vLLM и Ollama temperature, top_p и top_k по-прежнему работают, и механику выше нужно понимать. При миграции со старых моделей удалите эти поля из запросов, а для «детерминированного» формата используйте structured outputs.
Даже при greedy-декодировании ответы API могут различаться между вызовами. Сервер батчит ваш запрос с чужими, порядок операций с плавающей точкой на GPU меняется, и при почти равных логитах argmax переключается. Тесты вида «ответ должен совпасть байт в байт» нестабильны. Проверяйте семантику: парсинг, схему, eval-метрики (модуль 8).
Трансформер и attention
Decoder-only трансформер (архитектура GPT, Claude, Llama) — это стек из N одинаковых блоков: от 32 у 8B-моделей до примерно 80–120 у больших. Каждый блок делает две вещи с residual-соединениями и нормализацией:
- Self-attention: каждый токен собирает информацию с других позиций контекста;
- MLP (feed-forward): обрабатывает каждую позицию независимо. Здесь сосредоточена большая часть параметров и, по всей видимости, «знаний».
На входе токены превращаются в векторы (embedding lookup, размерность d_model около 4K–16K), к ним добавляется информация о позиции (сейчас обычно RoPE). На выходе последний вектор умножается на матрицу словаря и даёт логиты.
Q, K, V на интуиции
Для каждого токена attention вычисляет три вектора линейными проекциями его текущего представления:
- Query — «что я ищу» (например, местоимение «она» ищет существительное женского рода);
- Key — «что я могу предложить» (у «Кошка» есть признаки «существительное, ж. р., одушевлённое»);
- Value — «что я отдам, если меня выберут».
Скалярное произведение Q·K даёт «похожесть» запроса и ключа. Деление на √d_k не даёт softmax насытиться на больших размерностях. Softmax превращает оценки в веса с суммой 1, и результат оказывается взвешенной суммой Value. Это похоже на мягкий map[key]value: вместо точного совпадения ключа вы получаете смесь всех значений, взвешенную по похожести.
- Causal mask. В decoder-only модели токен видит только себя и прошлое: оценкам «из будущего» присваивается −∞. Поэтому обучение параллельно (все позиции предсказываются за один проход, маска не даёт подсмотреть ответ), а инференс идёт авторегрессивно. У BERT-подобных encoder-моделей (из них часто делают embedding-модели и reranker'ы) маски нет, и каждый токен видит всё предложение.
- Multi-head. Attention выполняется параллельно в H «головах» (32–128) с меньшей размерностью d_head = d_model / H, обычно 128. Разные головы учатся разным отношениям: «предыдущий токен», синтаксическая связь, кореференция, копирование (induction heads). Выходы голов конкатенируются. Вообще говоря, головы не обязаны быть интерпретируемыми: на визуализации ниже поведение идеализировано.
- GQA/MQA. Чтобы сэкономить память KV-cache, у многих голов Query общие K и V. Например, 64 головы Q и 8 голов KV дают кэш в 8 раз меньше. Это важно в разделе про инференс.
Attention: куда «смотрит» токен
Кликните по токену (или строке матрицы): дуги покажут веса внимания от него (query) к остальным токенам (keys). Матрица внизу — те же веса для всех пар, строка = query, столбец = key. Головы идеализированы, их веса заданы вручную для наглядности. Выключите causal mask, чтобы увидеть двунаправленное внимание, как в encoder-моделях.
Слой attention похож на fan-in: каждая позиция (как задача в StructuredTaskScope) читает результаты всех предыдущих одновременно, но с весами. Слой MLP работает как parallelStream().map(...) над независимыми элементами: каждая позиция обрабатывается сама по себе. Стек блоков — это pipeline из N стадий, где на каждой стадии представление токена обогащается контекстом.
Слой attention похож на fan-in: каждая горутина-позиция читает из всех предыдущих «каналов» одновременно, но с весами. Слой MLP работает как errgroup с независимыми задачами: каждая позиция обрабатывается сама по себе. Стек блоков — это pipeline из N стадий, где на каждой стадии представление токена обогащается контекстом.
Attention вычисляет оценки для всех пар позиций, поэтому вычисления растут квадратично по длине последовательности: O(n²·d) на слой. FlashAttention не материализует матрицу n×n в памяти (память O(n)), но число FLOP остаётся квадратичным. MLP растёт линейно. На коротких контекстах доминирует MLP, на сотнях тысяч токенов доминирует attention. Отсюда длинный prefill и TTFT на больших документах (раздел про стоимость контекста).
Эмбеддинги и сходство
Embedding — это вектор фиксированной длины (обычно 256–3072 чисел), который кодирует смысл текста так, что похожие по смыслу тексты оказываются рядом. Внутри LLM эмбеддинги есть у каждого токена на каждом слое. В продуктах под «эмбеддингами» обычно понимают выход отдельной embedding-модели: она превращает целый фрагмент текста в один вектор для поиска, кластеризации и дедупликации. Это основа RAG (модуль 5).
Anthropic собственной embedding-модели не предоставляет и в документации рекомендует Voyage AI. У OpenAI есть text-embedding-3-small (1536 измерений) и -large (3072). Open-source вариантов много: BGE, E5, multilingual-e5, nomic и другие.
Косинусное сходство
- Косинус сравнивает направление векторов и игнорирует длину. Если векторы заранее нормированы до ‖v‖=1, косинус равен скалярному произведению, и ранжирование по cosine, dot product и L2 совпадает. Большинство embedding-моделей отдают уже нормированные векторы. Проверьте это и выбирайте самую дешёвую метрику в индексе.
- Абсолютные значения зависят от модели. У многих моделей даже несвязанные тексты дают косинус 0.2–0.5, а не 0 (анизотропия пространства). Порог «релевантно, если > 0.8» из чужой статьи не переносится. Калибруйте его на своих размеченных парах.
- Векторы разных моделей несравнимы. Смена модели эмбеддингов означает полную переиндексацию. Храните рядом с вектором имя и версию модели.
- Память. float32 × 1536 = 6 КБ на вектор, 10M векторов занимают ≈ 61 ГБ без учёта индекса. Отсюда интерес к меньшим размерностям (Matryoshka-эмбеддинги можно обрезать) и к квантизации векторов (int8, бинарная).
Эмбеддинги в 2D: косинус и ближайшие соседи
Игрушечная 2D-картинка. Настоящие эмбеддинги имеют сотни и тысячи измерений, а проекции вроде t-SNE/UMAP искажают расстояния. Здесь косинус считается по углу вектора от центра (крестик). Кликните по слову, чтобы сделать его запросом, или перетаскивайте ромб запроса. Обратите внимание на «мышь» между животными и программированием (многозначность) и на «cat» рядом с «кошкой» (мультиязычная модель). Точки на одном луче имеют косинус 1 при разной длине.
package vecs;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
public final class Vecs {
/** Косинусное сходство. double-аккумулятор, чтобы не терять точность на 3072 измерениях. */
public static double cosine(float[] a, float[] b) {
if (a.length != b.length) {
throw new IllegalArgumentException("dim mismatch: %d vs %d".formatted(a.length, b.length));
}
double dot = 0, na = 0, nb = 0;
for (int i = 0; i < a.length; i++) {
double x = a[i], y = b[i];
dot += x * y;
na += x * x;
nb += y * y;
}
if (na == 0 || nb == 0) throw new IllegalArgumentException("zero vector");
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
public record Hit(String id, double score) {}
// topK — брутфорс-поиск. Для 100K векторов × 1536 это ~150M умножений,
// единицы-десятки миллисекунд. Дальше нужен ANN-индекс (HNSW, модуль 5).
public static List<Hit> topK(float[] q, Map<String, float[]> docs, int k) {
List<Hit> hits = new ArrayList<>(docs.size());
docs.forEach((id, v) -> hits.add(new Hit(id, cosine(q, v))));
hits.sort(Comparator.comparingDouble(Hit::score).reversed());
return hits.subList(0, Math.min(k, hits.size()));
}
}
package vecs
import (
"errors"
"fmt"
"math"
"slices"
)
// Cosine — косинусное сходство. float64-аккумулятор, чтобы не терять точность
// на 3072 измерениях.
func Cosine(a, b []float32) (float64, error) {
if len(a) != len(b) {
return 0, fmt.Errorf("dim mismatch: %d vs %d", len(a), len(b))
}
var dot, na, nb float64
for i := range a {
x, y := float64(a[i]), float64(b[i])
dot += x * y
na += x * x
nb += y * y
}
if na == 0 || nb == 0 {
return 0, errors.New("zero vector")
}
return dot / (math.Sqrt(na) * math.Sqrt(nb)), nil
}
type Hit struct {
ID string
Score float64
}
// TopK — брутфорс-поиск. Для 100K векторов × 1536 это ~150M умножений,
// единицы-десятки миллисекунд. Дальше нужен ANN-индекс (HNSW, модуль 5).
func TopK(q []float32, docs map[string][]float32, k int) ([]Hit, error) {
hits := make([]Hit, 0, len(docs))
for id, v := range docs {
s, err := Cosine(q, v)
if err != nil {
return nil, fmt.Errorf("doc %s: %w", id, err)
}
hits = append(hits, Hit{id, s})
}
slices.SortFunc(hits, func(x, y Hit) int {
switch {
case x.Score > y.Score:
return -1
case x.Score < y.Score:
return 1
}
return 0
})
return hits[:min(k, len(hits))], nil
}
import numpy as np
def normalize(m: np.ndarray) -> np.ndarray:
n = np.linalg.norm(m, axis=-1, keepdims=True)
return m / np.clip(n, 1e-12, None)
def top_k(query: np.ndarray, docs: np.ndarray, k: int = 5):
"""query: (d,), docs: (N, d). Возвращает индексы и косинусы, по убыванию."""
q = normalize(query)
d = normalize(docs) # в проде нормализуйте один раз при индексации
scores = d @ q # (N,) — после нормализации dot == cosine
k = min(k, len(scores))
idx = np.argpartition(-scores, k - 1)[:k] # O(N), без полной сортировки
idx = idx[np.argsort(-scores[idx])]
return idx, scores[idx]
rng = np.random.default_rng(0)
docs = rng.normal(size=(10_000, 1536)).astype(np.float32)
query = docs[42] + 0.1 * rng.normal(size=1536).astype(np.float32)
idx, sc = top_k(query, docs, k=3)
print(idx, sc.round(3)) # 42 будет первым с косинусом ≈ 0.99
Как обучают: pretraining → SFT → RLHF
Поведение модели в вашем продукте задаётся несколькими стадиями обучения. Понимание стадий объясняет её причуды: угодничество, многословие, отказы, уверенный тон при незнании.
Этапы обучения
Кликните по этапу, чтобы увидеть данные, цель обучения и то, как на каждой стадии меняется ответ на один и тот же запрос.
- Pretraining. Next-token prediction на триллионах токенов (веб, книги, код). Результат — base model, мощный «автодополнятор» без понятия о диалоге. На вопрос он может ответить списком похожих вопросов, потому что так выглядят FAQ-страницы в интернете. Это самая дорогая стадия (месяцы на тысячах GPU), и на ней закладываются знания и knowledge cutoff.
- SFT (supervised fine-tuning, instruction tuning). Десятки и сотни тысяч примеров «инструкция → хороший ответ» в формате чата с ролями. Модель учит формат диалога, следование инструкциям, вызов инструментов. Новых знаний почти не добавляется: SFT меняет поведение, а не эрудицию. Тот же вывод работает и для вашего fine-tuning (модуль 10).
- RLHF. Люди сравнивают пары ответов, на этих сравнениях обучается reward model, затем политика оптимизируется RL (PPO) под эту награду с KL-штрафом, чтобы не уйти далеко от SFT-модели (InstructGPT, 2022). Альтернатива без reward model — DPO, которое оптимизирует предпочтения напрямую.
- RLAIF и Constitutional AI (Anthropic, 2022). Сравнения делает не человек, а модель, опираясь на явный список принципов («конституцию»). Модель критикует и переписывает свои ответы, а AI-разметка предпочтений масштабируется дешевле человеческой.
- RL с проверяемыми наградами (RLVR). Награда выдаётся за верный ответ в математике, за прошедшие тесты в коде, за успешное завершение агентной задачи. Именно так обучены современные reasoning-модели: длинные цепочки рассуждений появляются потому, что они повышают награду.
Sycophancy (угодничество): люди-оценщики предпочитают согласие, поэтому модель склонна соглашаться с неверной предпосылкой в вопросе. Многословие: длинные ответы чаще нравились оценщикам. Уверенный тон: «не знаю» редко получало высокую оценку. Reward hacking в агентах: модель может «подогнать» тест вместо починки кода. Против всего этого помогают явные инструкции, evals и проверки в коде, а не надежда на «умную модель».
Галлюцинации и knowledge cutoff
Галлюцинация — правдоподобный, но неверный или неподтверждённый вывод: несуществующая функция в SDK, выдуманная ссылка на статью, неверная цифра в резюме документа. Это не баг, который исправят в следующей версии. Это следствие того, как модель устроена и обучена.
Почему возникают
- Цель обучения — правдоподобие, а не истинность. Если правильный факт в данных встречался редко (long tail: мелкие библиотеки, внутренние термины, люди без статьи в Википедии), модель выдаст статистически похожую форму ответа.
- Обучение и бенчмарки поощряют угадывание. Если за «не знаю» ставят 0, а за угаданный ответ иногда дают балл, оптимальная стратегия — угадывать. Этот механизм подробно разобран в статье OpenAI 2025 года «Why Language Models Hallucinate».
- Авторегрессия. Первый неверный токен (выдуманное имя) дальше подкрепляется связным текстом.
- Контекст «перебивает» знания или теряется. Длинный контекст с противоречиями, нужный факт в середине 200K токенов (lost in the middle) или устаревший документ в RAG приводят к ответу, который уверенно опирается не на то.
- Токенизация и символьные задачи: счёт, арифметика, точные строки.
Как снижать (по убыванию эффекта)
- Grounding. Дайте факты в контексте (RAG, результаты tool use) и требуйте ответ только по ним, со ссылками или цитатами на источник. Для длинных документов попросите сначала выписать дословные цитаты, а потом отвечать на их основе.
- Разрешите «не знаю». Явная инструкция и отдельное поле в схеме ответа (
"answer": null, "reason": "not_in_context"). Метрика отказов идёт в evals. - Вынесите точные операции в инструменты: калькулятор, SQL, поиск по API-докам, проверку существования URL или пакета.
- Проверка в коде: схема (structured outputs), валидация ID и ссылок по базе, компиляция и тесты для сгенерированного кода.
- Второй проход: verifier/judge-модель, проверка согласованности нескольких сэмплов (self-consistency), reasoning с большим effort для сложных задач.
- Измеряйте: faithfulness и groundedness метрики на golden-наборе (модуль 8). Без измерения «снизили галлюцинации» остаётся ощущением.
Снижение temperature не лечит фактические галлюцинации. Оно делает ответ стабильнее, но если самый вероятный токен неверен, модель будет стабильно неверной.
Knowledge cutoff
Модель знает мир только до даты среды обучения (training data cutoff). Кроме того, о последних месяцах перед отсечкой данных в интернете мало, поэтому «надёжная» граница знаний обычно раньше заявленной. Модель не знает текущую дату и не знает, что вышла новая версия вашей библиотеки. Практика:
- передавайте текущую дату в контексте. Ставьте её после кэшируемого префикса и с точностью до дня, а не секунды: timestamp в начале system prompt инвалидирует prompt cache при каждом запросе;
- для свежих данных (цены, версии API, новости) используйте поиск или tool, а не память модели;
- для кодогенерации под быстро меняющиеся SDK кладите в контекст актуальные фрагменты документации.
Инференс: KV-cache и квантизация
Что кэшируется
На каждом шаге декодирования новый токен считает attention по всем предыдущим позициям. Для этого нужны их векторы K и V во всех слоях. Они зависят только от префикса, а префикс не меняется, поэтому их сохраняют в KV-cache и на каждом шаге пропускают через модель только один новый токен. Без кэша шаг t стоил бы полного прохода по t токенам, и генерация N токенов была бы O(N²) проходов вместо O(N).
Пример для модели уровня Llama 3 8B (32 слоя, 8 KV-голов благодаря GQA, d_head = 128, FP16): 2 × 32 × 8 × 128 × 2 байта = 128 КБ на токен. 8K токенов занимают 1 ГБ, 128K токенов — 16 ГБ, то есть больше половины весов самой модели (16 ГБ в FP16). На сервере с батчем из 32 параллельных запросов KV-cache, а не веса, определяет, сколько пользователей влезет на GPU. Поэтому в vLLM появился PagedAttention: кэш выделяется страницами, как виртуальная память, без фрагментации.
Prefill и decode: почему TTFT и tokens/s — разные метрики
Prefill
Весь промпт обрабатывается за один параллельный проход, и KV-cache заполняется целиком. Это большие матричные умножения, ограниченные вычислениями (compute-bound). Время растёт с длиной входа, а на длинных контекстах сверхлинейно из-за attention. Определяет TTFT (time to first token).
Decode
По одному токену за шаг. На каждом шаге нужно прочитать из памяти все веса и весь KV-cache ради одного вектора, поэтому шаг ограничен пропускной способностью памяти (memory-bound). Определяет tokens/s или ITL (inter-token latency).
Оценка сверху для decode при batch = 1: tokens/s ≈ bandwidth / байты, читаемые на шаг. У 8B-модели в FP16 это 16 ГБ, у H100 около 3.35 ТБ/с, получается потолок ≈ 200 токенов/с. Квантизация до INT4 уменьшает читаемые байты в 4 раза и почти пропорционально ускоряет decode. Батчинг почти бесплатно увеличивает суммарную пропускную способность: веса читаются один раз на весь батч. Поэтому провайдеры держат большие батчи, а скорость отдельного запроса колеблется в зависимости от нагрузки.
TTFT — это время до первого байта ответа (в HttpClient — момент, когда BodyHandler получил первый чанк; в Micrometer это отдельный Timer), tokens/s — это пропускная способность потока. Для чата важнее TTFT: пользователь видит, что «пошло». Для генерации отчёта в фоне важнее tokens/s и цена. Мерить нужно обе метрики отдельно, в перцентилях p50/p95, как любую латентность. Итоговая латентность ≈ TTFT + output_tokens / tokens_per_s.
TTFT — это время до первого байта ответа (как httptrace.GotFirstResponseByte), tokens/s — это пропускная способность потока. Для чата важнее TTFT: пользователь видит, что «пошло». Для генерации отчёта в фоне важнее tokens/s и цена. Мерить нужно обе метрики отдельно, в перцентилях p50/p95, как любую латентность. Итоговая латентность ≈ TTFT + output_tokens / tokens_per_s.
Авторегрессия с KV-cache: prefill блоком, decode по одному
Нажмите «Шаг» или «Играть». На первом шаге весь промпт обрабатывается параллельно (prefill) и заполняет кэш, затем каждый шаг обрабатывает только последний токен, добавляет его K/V в кэш и выдаёт следующий. Внизу — шкала времени: первый блок задаёт TTFT, дальше идут одинаковые decode-шаги.
Калькулятор памяти: веса + KV-cache
Выберите пресет или двигайте ползунки. Пресеты описывают открытые модели с известной архитектурой: у Claude архитектура не опубликована. Полоса сравнивается с памятью GPU (24 ГБ — потребительская карта, 80 ГБ — H100/A100, 640 ГБ — узел из 8×80). Внизу — оценка сверху для скорости decode на H100.
Квантизация
Квантизация хранит веса (а иногда и активации или KV-cache) в меньшей точности. Модель занимает меньше памяти, и decode идёт быстрее, потому что читается меньше байт.
| Формат | Байт/параметр | 70B-модель | Качество (типично) |
|---|---|---|---|
| FP32 | 4 | 280 ГБ | эталон, для инференса не используют |
| FP16 / BF16 | 2 | 140 ГБ | стандарт инференса, без потерь |
| INT8 / FP8 | 1 | 70 ГБ | почти без потерь при аккуратной калибровке (LLM.int8, FP8 на H100) |
| INT4 (GPTQ, AWQ, GGUF Q4_K_M) | ≈ 0.5–0.6 | ≈ 35–40 ГБ | небольшая деградация: обычно единицы процентов на бенчмарках, заметнее в коде, математике, длинном контексте и на небольших моделях |
| 2–3 бита | < 0.4 | < 28 ГБ | деградация быстро растёт, нужен eval на ваших задачах |
- Практическое правило для self-hosted: большая модель в INT4 обычно лучше маленькой в FP16 при одинаковом объёме памяти.
- Бенчмарк-потеря в «1–2%» может оказаться 10% на вашей узкой задаче (строгий JSON, редкий язык). Прогоняйте свой eval-набор (модуль 8) на квантизованной версии до выката.
- Квантизация KV-cache (FP8) позволяет вдвое увеличить батч или контекст. Качество страдает меньше, чем при INT4 для весов, но на очень длинных контекстах это надо проверять.
- В API провайдеров квантизацию не видно и не настроить, это их внутренняя оптимизация. Для вас она важна при self-hosting (vLLM, llama.cpp, Ollama; модуль 10).
Контекстное окно и его цена
Контекстное окно — максимальное число токенов, которое модель обрабатывает за один вызов: system + история + документы + tools + сам ответ. На момент написания (сентябрь 2026) у Claude Opus 5.x, Sonnet 5 и Fable 5.1 окно составляет 1M токенов (≈ 700 тыс. английских слов, несколько толстых книг), у Haiku 4.5 — 200K. Максимальный вывод у топовых моделей — 128K токенов, большие значения только со стримингом. Окно у API stateless: каждый ход чата заново отправляет всю историю, и вы снова за неё платите (если не спасает prompt caching).
Почему длинный контекст стоит дорого
- Деньги линейны. Каждый входной токен оплачивается при каждом запросе. Документ на 100K токенов в Claude Opus 5 ($5 за 1M input) стоит $0.50 за вызов. При 1 запросе в секунду это ≈ $43 000 в день только за вход.
- Вычисления квадратичны. Attention на 1M токенов требует ~10¹² пар на слой и голову. Prefill длинного документа длится секунды и десятки секунд, TTFT растёт.
- Память линейна. KV-cache на 1M токенов занимает десятки и сотни гигабайт, поэтому провайдеры дорого берут за длинный контекст и ограничивают его.
- Качество не бесплатно. Эффект lost in the middle (Liu et al., 2023): модели лучше используют информацию в начале и конце контекста, чем в середине. Тест «иголка в стоге сена» современные модели проходят почти идеально, но задачи, где нужно связать много разрозненных фактов, деградируют с ростом контекста (так называемый context rot). Больше контекста не значит лучше: лишние 500K токенов «на всякий случай» и дороже, и часто хуже, чем 20K хорошо отобранных.
Prompt caching — это сохранённый провайдером KV-cache вашего префикса. Поэтому он требует побайтового совпадения префикса (tools → system → messages), чтение стоит ≈ 0.1× цены input (у Opus 5.5 — 0.05×, у Fable 5.1 — 0.025×; по состоянию на октябрь 2026), а TTFT на кэш-хите заметно падает: prefill уже сделан. Запись в кэш стоит 1.25× (TTL 5 минут) или 2× (TTL 1 час). Дальше идут RAG вместо «всё в контекст», суммаризация и обрезка истории чата, Batch API (−50%) для офлайн-задач, выбор меньшей модели для простых шагов. Важные инструкции ставьте в конец, после длинных документов: так рекомендует и документация Anthropic.
Калькулятор стоимости контекста
Цены на сентябрь 2026 ($ за 1M токенов input / output). Кэш-хит — доля входных токенов, прочитанных из prompt cache по 0.1× цены (Opus 5.5 — 0.05×, Fable 5.1 — 0.025×; уточнено в октябре 2026). Упрощение: стоимость записи в кэш (1.25×) не учитывается, так как предполагается постоянно «горячий» префикс. Внизу — та же нагрузка на всех моделях.
Reasoning и thinking-модели
Идея test-time compute: вместо того чтобы сделать модель больше, дать ей больше вычислений на конкретный запрос. Модель генерирует длинную цепочку рассуждений (thinking tokens): пробует гипотезы, проверяет себя, возвращается. Только после этого она пишет ответ. Способность обучена RL с проверяемыми наградами, и качество на математике, коде и многошаговых задачах растёт с объёмом рассуждений.
Как это устроено в Claude API (сентябрь 2026)
{
"model": "claude-opus-5",
"max_tokens": 32000,
"thinking": { "type": "adaptive" },
"output_config": { "effort": "high" },
"messages": [{ "role": "user", "content": "Найди гонку данных в этом коде: ..." }]
}
thinking: {type: "adaptive"}: модель сама решает, думать ли и сколько, в зависимости от сложности запроса. Старыйbudget_tokensна новых моделях отклоняется с 400.effort(low | medium | high | xhigh | max) задаёт общую «старательность»: глубину рассуждений, число вызовов инструментов в агентном цикле, подробность ответа. Он заменяет temperature как главный рычаг на новейших моделях.- Thinking-токены оплачиваются как output и занимают
max_tokens. Рассуждение может быть в 5–50 раз длиннее ответа, и именно оно определяет цену и латентность. - В ответе приходят блоки
thinking(в стриме этоthinking_delta). При многоходовом tool use их нужно возвращать в историю без изменений, как требует документация.
Когда окупается
| Задача | Effort / thinking | Почему |
|---|---|---|
| Классификация, извлечение полей, роутинг, короткий чат | low, часто на Haiku/Sonnet | Рассуждение не повышает точность, но умножает цену и TTFT |
| RAG-ответ по найденным документам | medium | Основная сложность в поиске, а не в выводе |
| Отладка, код-ревью, сложный SQL, планирование | high | Многошаговый вывод, проверка гипотез |
| Агенты с длинными задачами, трудная математика, архитектурный анализ | xhigh / max | Ошибка стоит дороже токенов, есть проверяемый результат |
Overthinking: на простом запросе модель тратит тысячи токенов и десятки секунд. Обрезка: max_tokens меньше, чем нужно на мысли и ответ, и вы получаете stop_reason: "max_tokens" без ответа. Таймауты: HTTP-клиент с таймаутом 30 с не дождётся ответа на max, нужен стриминг. Подход инженера: effort — это параметр маршрута, как размер пула соединений. Подбирайте его по eval-метрикам и цене на задачу, а не «на всякий случай max». Для reasoning-моделей не нужно писать «думай шаг за шагом», они и так это делают.
Практика
Задание 1: token-бюджетер на GoJava
Напишите CLI tokbudget. Он читает директорию документов (.md/.txt), считает токены каждого файла через count_tokens и печатает таблицу: файл, символы, токены, символы на токен, стоимость одного прогона в input для Opus 5, Sonnet 5 и Haiku 4.5.
Критерии готовности: конкурентные запросы через errgroup с лимитом (SetLimit)на virtual threads с лимитом через Semaphore и ретраями на 429 с учётом retry-after; вычтено «пустое» сообщение (служебные токены); есть сравнение: для 10+ парных документов EN/RU посчитан средний коэффициент RU/EN; итог отражает долю документов, которые не влезают в 200K (окно Haiku).
Задание 2: сэмплинг своими руками
Поднимите локальную open-source модель (Ollama или vLLM, например 3–8B) и получите logprobs для 20 top-токенов следующего шага. Реализуйте на Python или GoJava функцию sample(logits, T, top_k, top_p) в том же порядке, что в визуализации, и сравните с сэмплингом движка по гистограмме из 1000 запусков.
Критерии готовности: гистограммы совпадают в пределах статистического шума; есть график энтропии в зависимости от T; вы можете объяснить, почему при T=0 и повторных запусках ответ всё равно иногда меняется.
Задание 3: мини-поиск на эмбеддингах
Возьмите 200–500 абзацев своей документации, получите эмбеддинги (Voyage, OpenAI или локальная multilingual-e5), сохраните их в файл и реализуйте на GoJava поиск top-5 по косинусу (код выше). Составьте 20 запросов с известным правильным абзацем.
Критерии готовности: посчитан recall@5; найдены и описаны минимум 3 ошибки поиска (многозначность, опечатка, запрос на другом языке); измерено распределение косинусов для релевантных и нерелевантных пар и выбран порог отсечения по данным.
Задание 4: TTFT и tokens/s
Через стриминг API измерьте TTFT и скорость decode для одной модели при входе 1K, 10K, 100K токенов и выходе 500 токенов, по 10 прогонов на точку. Повторите прогон 100K с prompt caching (второй запрос должен быть кэш-хитом).
Критерии готовности: таблица p50/p95; видно, что TTFT растёт со входом, а tokens/s почти не меняется; видно падение TTFT на кэш-хите и значение usage.cache_read_input_tokens.
Самопроверка
Почему один и тот же по смыслу текст на русском стоит больше токенов, чем на английском?
Словарь BPE выучен на корпусе, где английского на порядки больше: английские слова стали целыми токенами, русские режутся на части. Кириллическая буква занимает 2 байта в UTF-8, и без слияний это два токена. Богатая морфология даёт больше уникальных словоформ. Итог — множитель примерно 1.3–3× в зависимости от токенизатора, и его нужно измерять.
Что делают temperature, top_k и top_p и в каком порядке?
Temperature делит логиты: T<1 делает распределение острее, T>1 площе, T→0 даёт greedy. top_k оставляет k лучших кандидатов, top_p — минимальное «ядро» с суммой ≥ p. Обычно порядок такой: temperature → top_k → top_p → перенормировка → сэмплинг. На новейших Claude эти параметры убраны (400), вместо них есть effort.
Зачем в attention делить на √dk и что делает causal mask?
Дисперсия скалярного произведения растёт с размерностью. Без масштабирования softmax насыщается до почти one-hot, и градиенты затухают. Causal mask ставит −∞ на позиции из будущего, чтобы токен видел только прошлое. Это позволяет обучать все позиции параллельно без утечки ответа и соответствует авторегрессивной генерации.
Посчитайте KV-cache для модели с 80 слоями, 8 KV-головами, d_head=128, FP16, контекст 32K, батч 4.
2 × 80 × 8 × 128 × 2 байта = 327 680 байт ≈ 320 КБ на токен. × 32 768 токенов ≈ 10 ГБ на последовательность. × 4 ≈ 40 ГБ. Плюс веса 70B-модели в FP16 (140 ГБ): нужно минимум 2–3 карты по 80 ГБ.
Почему TTFT растёт с длиной промпта, а tokens/s почти нет?
TTFT определяется prefill: весь вход обрабатывается за один compute-bound проход, объём работы растёт линейно, а для attention квадратично. Decode на каждом шаге обрабатывает один токен и ограничен чтением весов из памяти. Рост KV-cache немного замедляет decode на очень длинных контекстах, но основная часть времени шага — чтение весов.
Косинус двух несвязанных текстов равен 0.45. Это баг?
Нет. Пространства многих embedding-моделей анизотропны: все векторы лежат в узком конусе, и «ноль» смещён. Значение косинуса имеет смысл только относительно распределения для данной модели. Порог калибруется на размеченных парах, а надёжнее опираться на ранжирование, чем на абсолютный порог.
Что изменил каждый этап: pretraining, SFT, RLHF?
Pretraining даёт знания и языковую модель мира (base model, автодополнение). SFT учит формату диалога и следованию инструкциям, почти не добавляя знаний. RLHF, RLAIF и Constitutional AI выравнивают поведение по предпочтениям (полезность, безопасность, стиль), а побочно создают угодничество и многословие. RL с проверяемыми наградами обучает рассуждать.
Почему снижение temperature не лечит галлюцинации?
Temperature меняет только разброс выбора. Если модель не знает факта, самый вероятный токен тоже неверен, и при T=0 она будет стабильно и уверенно ошибаться. Лечится это grounding'ом (контекст и цитаты), разрешением отвечать «не знаю», инструментами и проверками в коде.
Как prompt caching связан с KV-cache и почему timestamp в system prompt его ломает?
Prompt caching сохраняет KV-cache префикса запроса. K и V каждой позиции зависят от всех предыдущих токенов, поэтому переиспользовать кэш можно только при побайтовом совпадении префикса (tools → system → messages). Timestamp в начале system prompt меняет префикс в каждом запросе и инвалидирует всё после себя.
Вопросы с собеседований
Объясните, как LLM генерирует ответ, от текста запроса до текста ответа.
Текст токенизируется (BPE) в последовательность id. Prefill за один параллельный проход через N блоков трансформера (attention + MLP) обрабатывает весь промпт и заполняет KV-cache. Последний вектор проецируется в логиты размером со словарь, сэмплер (temperature/top_p или greedy) выбирает токен. Дальше идёт decode: токен добавляется к входу, модель считает только новую позицию, используя кэш, и так до EOS, stop sequence или max_tokens. Токены детокенизируются и стримятся клиенту.
Сеньорская часть ответа — связать это с продом: prefill определяет TTFT, decode — tokens/s. Output дороже input, потому что генерируется последовательно. KV-cache ограничивает батч и контекст, а prompt caching — это переиспользование KV-cache префикса.
Счёт за LLM вырос в 3 раза после запуска на русскоязычном рынке. Ваши гипотезы и действия?
Гипотезы: (1) токенизация, где русский даёт в 1.3–3× больше токенов и на входе, и на выходе; (2) ответы стали длиннее; (3) prompt cache перестал попадать: например, в system prompt попал язык или локаль пользователя, и префиксы разошлись; (4) выросла длина истории диалогов.
Действия: разложить стоимость по usage (input, cache read, cache write, output) на запрос и по фичам; измерить токены на символ по локалям через count_tokens; следить за долей cache_read_input_tokens; сделать общий префикс независимым от локали, а языковую инструкцию вынести в конец; ограничить длину ответов; перевести простые шаги на меньшую модель; офлайн-задачи отправить в Batch API. Проверять по eval-набору, что качество не упало.
Что такое KV-cache, сколько он весит и как с ним борются при self-hosting?
Кэш векторов K и V всех прошлых позиций во всех слоях. Он превращает генерацию из O(N²) проходов в O(N). Размер: 2 × layers × kv_heads × d_head × seq × bytes. Для 8B-модели с GQA в FP16 это ≈ 128 КБ на токен, 16 ГБ на 128K контекста, что сопоставимо с весами.
Борьба: GQA/MQA/MLA на уровне архитектуры (меньше KV-голов); PagedAttention в vLLM (без фрагментации, шаринг префиксов); квантизация кэша в FP8; prefix caching; ограничение max context и батча; offload на CPU. Компромисс: память ↔ батч ↔ максимальная длина контекста ↔ качество.
Почему бы просто не класть все документы в контекст на 1M токенов вместо RAG?
Иногда так и нужно делать: корпус маленький и стабильный, задачи требуют синтеза по всему корпусу, prompt caching делает повторные запросы дешёвыми. Но в общем случае есть четыре проблемы. Цена: вход оплачивается при каждом запросе, 1M токенов на Opus 5 стоит $5 за вызов без кэша. Латентность: prefill квадратичен, TTFT составляет десятки секунд. Качество: lost in the middle и context rot, лишний шум ухудшает многошаговый вывод. Масштаб: корпус больше окна, права доступа по документам, свежесть данных.
Хороший ответ: гибрид. Retrieval сужает до 10–50K релевантных токенов, стабильная часть кэшируется, решение подтверждается evals по качеству и цене.
Как бы вы снижали галлюцинации в боте поддержки?
Сначала измерить: golden-набор вопросов с эталонами, метрики faithfulness (ответ подтверждается контекстом), доля корректных отказов и доля выдуманных фактов. Потом рычаги: RAG с хорошим retrieval (частая причина галлюцинаций — не найден нужный документ); инструкция отвечать только по контексту с цитатами и явным «не знаю» с эскалацией на оператора; structured output с полями sources и confidence и проверка в коде, что цитаты действительно есть в документах; инструменты для данных о заказе вместо памяти модели; для рискованных ответов (деньги, юридическое) — verifier-проход или жёсткие шаблоны.
Чего не говорить: «поставим temperature 0» или «возьмём модель побольше» как единственное решение.
Что такое temperature и почему на новых моделях Claude её нет?
Делитель логитов перед softmax: управляет разбросом сэмплинга (подробности выше). У новейших Claude (Opus 4.7+, Sonnet 5, Fable) temperature, top_p и top_k удалены: запрос с ними возвращает 400. Управление перенесено на effort и adaptive thinking, а для воспроизводимого формата есть structured outputs. Мотивация понятна: reasoning-модели обучены и откалиброваны под свой режим декодирования, ручная настройка сэмплинга чаще ухудшала результат, чем улучшала. Для open-source моделей в vLLM и Ollama и у других провайдеров параметры актуальны. Для извлечения данных там ставят T≈0, для креатива 0.7–1, и меняют что-то одно: T или top_p.
TTFT у вас 8 секунд на p95. Что проверите и что сделаете?
Разложить: сеть и очередь (rate limits, 429 с ретраями, очередь у провайдера), prefill (длина входа — самая частая причина), reasoning (thinking до первого текстового токена), время на стороне приложения (retrieval, tool calls до вызова модели). Действия: включить стриминг и мерить TTFT по первому content_block_delta; сократить вход (RAG, обрезка истории); prompt caching стабильного префикса, где кэш-хит снимает большую часть prefill; снизить effort на простых маршрутах; взять модель меньше или быстрее; распараллелить retrieval и подготовку. Мерить p50/p95 отдельно по маршрутам.
Когда вы бы выбрали self-hosted модель в INT4 вместо API?
Требования к данным (on-prem, суверенитет), очень большой и стабильный объём простых задач (классификация, извлечение), где своя GPU дешевле за токен, жёсткие требования к латентности внутри периметра, необходимость fine-tuning без ограничений. Стоимость владения: GPU, инженеры на vLLM и мониторинг, отставание качества open-source моделей от фронтира на сложных задачах, деградация от INT4 на коде и длинном контексте. Решение принимается по eval-набору и TCO-расчёту, а не по бенчмаркам из твиттера. Часто работает гибрид: простые задачи идут на свою модель, сложные в API.
Источники
- Документация Anthropic (Claude API): разделы о token counting, extended/adaptive thinking, effort, prompt caching, моделях и ценах.
- Anthropic: Token counting
- Vaswani et al., 2017. Attention Is All You Need
- Sennrich et al., 2015. Neural Machine Translation of Rare Words with Subword Units (BPE)
- openai/tiktoken и karpathy/minbpe: минимальная реализация BPE для чтения
- Jay Alammar. The Illustrated Transformer
- Holtzman et al., 2019. The Curious Case of Neural Text Degeneration (nucleus sampling)
- Ouyang et al., 2022. Training language models to follow instructions with human feedback (InstructGPT)
- Bai et al., 2022. Constitutional AI: Harmlessness from AI Feedback
- Rafailov et al., 2023. Direct Preference Optimization
- Liu et al., 2023. Lost in the Middle: How Language Models Use Long Contexts
- Kalai et al., 2025. Why Language Models Hallucinate
- Ainslie et al., 2023. GQA: Training Generalized Multi-Query Transformer Models
- Kwon et al., 2023. Efficient Memory Management for LLM Serving with PagedAttention (vLLM)
- Dao et al., 2022. FlashAttention
- Dettmers et al., 2022. LLM.int8(), GPTQ, AWQ
- Документация vLLM: параметры сэмплинга, prefix caching, квантизация