Модуль 1

Как работают LLM

Модуль про внутреннее устройство LLM в том объёме, который нужен инженеру продукта. Разберём токены и BPE, attention, сэмплинг, эмбеддинги, этапы обучения, KV-cache и квантизацию, а в конце посчитаем, сколько стоит длинный контекст и reasoning. Этого хватит, чтобы объяснять счёт за API, латентность и галлюцинации цифрами, без ссылок на «магию».

≈ 10 часовGoJava + Python8 интерактивных визуализацийцены на сентябрь 2026
Как читать этот модуль Java-разработчику

Относитесь к LLM как к чистой функции float[] f(int[] tokens). Она принимает последовательность целых чисел и возвращает вектор логитов, по одному на каждый токен словаря. Всё остальное (чат, tool use, «рассуждения») собрано поверх этой функции циклом for и сэмплером. От понимания этой функции и цикла зависят стоимость, латентность и режимы отказов вашего продукта.

Как читать этот модуль Go-разработчику

Относитесь к 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) обучается один раз на большом корпусе:

  1. Текст нарезается претокенизатором на куски примерно по словам: регулярка отделяет пробел+слово, числа, пунктуацию. Слияния никогда не пересекают эти границы.
  2. Каждый кусок превращается в последовательность байтов UTF-8. Базовый словарь состоит из 256 байтов, поэтому любой текст закодируется и «неизвестного токена» не бывает.
  3. Алгоритм считает частоты всех соседних пар и самую частую пару (например, t + h) объявляет новым токеном th. Правило слияния записывается в таблицу с рангом.
  4. Шаг 3 повторяется, пока словарь не дорастёт до целевого размера, то есть десятки и сотни тысяч раз.

Кодирование нового текста повторяет те же слияния в порядке ранга. Отсюда два следствия. Токенизация детерминирована, но зависит от контекста: "мир" и " мир" дают разные токены. И она отражает статистику обучающего корпуса: частые в корпусе строки получают короткие коды.

Песочница BPE

Учебный BPE. Он обучается прямо в браузере на встроенном корпусе примерно из 5 КБ текста (≈ 65% английского, 20% русского, 15% GoJava-кода). Это не токенизатор Claude или GPT: у настоящих словарь в сотни раз больше, а корпус в миллиарды раз. Механика при этом та же. Двигайте ползунок слияний и смотрите, как текст «склеивается». Есть два режима базовых единиц. В режиме байтов UTF-8 (как у GPT-2/4 и o200k) кириллическая буква изначально стоит 2 токена, а токены могут резать букву пополам: такие показаны шестнадцатеричными байтами в красной пунктирной рамке. В режиме символов с byte fallback (как SentencePiece в Llama 2) незнакомые символы, например эмодзи, распадаются на байты <0xF0>….

Показать первые выученные слияния (таблица рангов)

Почему русский текст дороже

Цифры

Для английского у больших токенизаторов работает правило «≈ 4 символа или ≈ 0.75 слова на токен». Одинаковый по смыслу русский текст обычно занимает в 1.3–3 раза больше токенов в зависимости от токенизатора: у новых словарей на 200K+ разрыв меньше, у старых больше. Это прямой множитель к цене, к латентности (больше токенов на выходе, значит дольше генерация) и к расходу контекста. Не берите коэффициент из статей, а измерьте на 100 реальных документах своего домена через count_tokens.

Числа, код и прочие сюрпризы

Режим отказа: оценка токенов «на глаз»

Типичный баг в проде выглядит так: бюджет контекста считается как 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)
}

Из этого цикла следует несколько фактов, которые важны в проде:

Параметры сэмплинга

ПараметрЧто делаетКогда трогать
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 раз»: фиолетовые засечки покажут, сколько раз выпал каждый токен.

после temperatureитоговая вероятностьвыпало из 100

Энтропия H = −Σ p·log₂p измеряет, сколько бит неопределённости в выборе. При H≈0 модель уверена (greedy и сэмплинг совпадают), при высокой H ответы сильно разнятся от запуска к запуску. Низкая энтропия в среднем по ответу — полезный сигнал уверенности, если провайдер отдаёт logprobs.

Важно: на новейших Claude сэмплинг-параметров нет

На момент написания (сентябрь 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.

Режим отказа: «temperature 0, значит детерминированно»

Даже при greedy-декодировании ответы API могут различаться между вызовами. Сервер батчит ваш запрос с чужими, порядок операций с плавающей точкой на GPU меняется, и при почти равных логитах argmax переключается. Тесты вида «ответ должен совпасть байт в байт» нестабильны. Проверяйте семантику: парсинг, схему, eval-метрики (модуль 8).

Трансформер и attention

Decoder-only трансформер (архитектура GPT, Claude, Llama) — это стек из N одинаковых блоков: от 32 у 8B-моделей до примерно 80–120 у больших. Каждый блок делает две вещи с residual-соединениями и нормализацией:

  1. Self-attention: каждый токен собирает информацию с других позиций контекста;
  2. MLP (feed-forward): обрабатывает каждую позицию независимо. Здесь сосредоточена большая часть параметров и, по всей видимости, «знаний».

На входе токены превращаются в векторы (embedding lookup, размерность d_model около 4K–16K), к ним добавляется информация о позиции (сейчас обычно RoPE). На выходе последний вектор умножается на матрицу словаря и даёт логиты.

Q, K, V на интуиции

Для каждого токена attention вычисляет три вектора линейными проекциями его текущего представления:

Attention(Q, K, V) = softmax( Q·Kᵀ / √dk + mask ) · V

Скалярное произведение Q·K даёт «похожесть» запроса и ключа. Деление на √d_k не даёт softmax насытиться на больших размерностях. Softmax превращает оценки в веса с суммой 1, и результат оказывается взвешенной суммой Value. Это похоже на мягкий map[key]value: вместо точного совпадения ключа вы получаете смесь всех значений, взвешенную по похожести.

Attention: куда «смотрит» токен

Кликните по токену (или строке матрицы): дуги покажут веса внимания от него (query) к остальным токенам (keys). Матрица внизу — те же веса для всех пар, строка = query, столбец = key. Головы идеализированы, их веса заданы вручную для наглядности. Выключите causal mask, чтобы увидеть двунаправленное внимание, как в encoder-моделях.

Аналогия из Java

Слой attention похож на fan-in: каждая позиция (как задача в StructuredTaskScope) читает результаты всех предыдущих одновременно, но с весами. Слой MLP работает как parallelStream().map(...) над независимыми элементами: каждая позиция обрабатывается сама по себе. Стек блоков — это pipeline из N стадий, где на каждой стадии представление токена обогащается контекстом.

Аналогия из Go

Слой 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 и другие.

Косинусное сходство

cos(a, b) = (a · b) / (‖a‖ · ‖b‖) ∈ [−1, 1]

Эмбеддинги в 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

Поведение модели в вашем продукте задаётся несколькими стадиями обучения. Понимание стадий объясняет её причуды: угодничество, многословие, отказы, уверенный тон при незнании.

Этапы обучения

Кликните по этапу, чтобы увидеть данные, цель обучения и то, как на каждой стадии меняется ответ на один и тот же запрос.

Побочные эффекты, которые вы увидите в проде

Sycophancy (угодничество): люди-оценщики предпочитают согласие, поэтому модель склонна соглашаться с неверной предпосылкой в вопросе. Многословие: длинные ответы чаще нравились оценщикам. Уверенный тон: «не знаю» редко получало высокую оценку. Reward hacking в агентах: модель может «подогнать» тест вместо починки кода. Против всего этого помогают явные инструкции, evals и проверки в коде, а не надежда на «умную модель».

Галлюцинации и knowledge cutoff

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

Почему возникают

Как снижать (по убыванию эффекта)

  1. Grounding. Дайте факты в контексте (RAG, результаты tool use) и требуйте ответ только по ним, со ссылками или цитатами на источник. Для длинных документов попросите сначала выписать дословные цитаты, а потом отвечать на их основе.
  2. Разрешите «не знаю». Явная инструкция и отдельное поле в схеме ответа ("answer": null, "reason": "not_in_context"). Метрика отказов идёт в evals.
  3. Вынесите точные операции в инструменты: калькулятор, SQL, поиск по API-докам, проверку существования URL или пакета.
  4. Проверка в коде: схема (structured outputs), валидация ID и ссылок по базе, компиляция и тесты для сгенерированного кода.
  5. Второй проход: verifier/judge-модель, проверка согласованности нескольких сэмплов (self-consistency), reasoning с большим effort для сложных задач.
  6. Измеряйте: faithfulness и groundedness метрики на golden-наборе (модуль 8). Без измерения «снизили галлюцинации» остаётся ощущением.

Снижение temperature не лечит фактические галлюцинации. Оно делает ответ стабильнее, но если самый вероятный токен неверен, модель будет стабильно неверной.

Knowledge cutoff

Модель знает мир только до даты среды обучения (training data cutoff). Кроме того, о последних месяцах перед отсечкой данных в интернете мало, поэтому «надёжная» граница знаний обычно раньше заявленной. Модель не знает текущую дату и не знает, что вышла новая версия вашей библиотеки. Практика:

Инференс: KV-cache и квантизация

Что кэшируется

На каждом шаге декодирования новый токен считает attention по всем предыдущим позициям. Для этого нужны их векторы K и V во всех слоях. Они зависят только от префикса, а префикс не меняется, поэтому их сохраняют в KV-cache и на каждом шаге пропускают через модель только один новый токен. Без кэша шаг t стоил бы полного прохода по t токенам, и генерация N токенов была бы O(N²) проходов вместо O(N).

KV-память на последовательность = 2 (K и V) × layers × kv_heads × d_head × seq_len × bytes_per_value

Пример для модели уровня 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. Батчинг почти бесплатно увеличивает суммарную пропускную способность: веса читаются один раз на весь батч. Поэтому провайдеры держат большие батчи, а скорость отдельного запроса колеблется в зависимости от нагрузки.

Аналогия из Java и SLO

TTFT — это время до первого байта ответа (в HttpClient — момент, когда BodyHandler получил первый чанк; в Micrometer это отдельный Timer), tokens/s — это пропускная способность потока. Для чата важнее TTFT: пользователь видит, что «пошло». Для генерации отчёта в фоне важнее tokens/s и цена. Мерить нужно обе метрики отдельно, в перцентилях p50/p95, как любую латентность. Итоговая латентность ≈ TTFT + output_tokens / tokens_per_s.

Аналогия из Go и SLO

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.

d_head = 128

Квантизация

Квантизация хранит веса (а иногда и активации или KV-cache) в меньшей точности. Модель занимает меньше памяти, и decode идёт быстрее, потому что читается меньше байт.

ФорматБайт/параметр70B-модельКачество (типично)
FP324280 ГБэталон, для инференса не используют
FP16 / BF162140 ГБстандарт инференса, без потерь
INT8 / FP8170 ГБпочти без потерь при аккуратной калибровке (LLM.int8, FP8 на H100)
INT4 (GPTQ, AWQ, GGUF Q4_K_M)≈ 0.5–0.6≈ 35–40 ГБнебольшая деградация: обычно единицы процентов на бенчмарках, заметнее в коде, математике, длинном контексте и на небольших моделях
2–3 бита< 0.4< 28 ГБдеградация быстро растёт, нужен eval на ваших задачах

Контекстное окно и его цена

Контекстное окно — максимальное число токенов, которое модель обрабатывает за один вызов: system + история + документы + tools + сам ответ. На момент написания (сентябрь 2026) у Claude Opus 5.x, Sonnet 5 и Fable 5.1 окно составляет 1M токенов (≈ 700 тыс. английских слов, несколько толстых книг), у Haiku 4.5 — 200K. Максимальный вывод у топовых моделей — 128K токенов, большие значения только со стримингом. Окно у API stateless: каждый ход чата заново отправляет всю историю, и вы снова за неё платите (если не спасает prompt caching).

Почему длинный контекст стоит дорого

Инженерные рычаги

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": "Найди гонку данных в этом коде: ..." }]
}

Когда окупается

Задача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.