Модуль 01 Начинающий ⏱ 25 мин

Temperature и sampling:
управление генерацией LLM

Почему одинаковый промпт дает разные ответы каждый раз? Как сделать LLM предсказуемым для агентов и живым для чата? Разбираем temperature, top-p, top-k и другие параметры на уровне логитов.

Что нужно знать: Как работают токены (предыдущий урок), базовый Python, asyncio

Как LLM предсказывает следующий токен

LLM — это по сути очень сложная функция f(контекст) → вероятности_всех_токенов. Для каждого следующего токена модель выдаёт логит (raw score) для каждого слова в словаре (50 000+ токенов). Затем softmax превращает логиты в вероятности.

💡
Логиты → Softmax → Вероятности → Sampling
Это вся цепочка. Temperature и другие параметры вмешиваются до sampling — они изменяют форму распределения вероятностей.

Представим, что модель генерирует продолжение фразы «Функция возвращает» и получила такое распределение:

Распределение вероятностей для следующего токена
None
62%
62%
True
18%
18%
False
9%
9%
список
5%
5%
строку
3%
3%
…остальные
~3%
3%

При temperature=0 мы всегда выберем None (argmax). При temperature=1 выбираем случайно по этому распределению. При temperature=2 распределение «размажется» — шанс редких токенов вырастет.

1
Модель выдаёт логиты
Массив из ~50к чисел. Большие числа = модель считает токен вероятным. [4.2, 2.1, 1.8, 0.9, ...]
2
Делим на temperature T
logits / T — при T<1 логиты становятся острее (разрыв увеличивается), при T>1 сглаживаются (разрыв уменьшается).
3
Применяем top-k / top-p фильтрацию
Отбрасываем все токены кроме топ-k или тех, чья суммарная вероятность < p. Это предотвращает выбор совсем безумных токенов.
4
Softmax → финальные вероятности
softmax(filtered_logits) нормирует оставшиеся токены в [0, 1] с суммой = 1.
5
Sampling — выбираем токен
Случайно выбираем токен согласно финальному распределению. Это и есть «генерация».

Temperature: от детерминизма до хаоса

Temperature — главный параметр управления «случайностью». Формула проста:

Python — temperature вручную
import numpy as np

def apply_temperature(logits: list[float], temperature: float) -> list[float]:
    """Применяем temperature к логитам перед softmax."""
    if temperature == 0:
        # Детерминированный режим: argmax
        max_idx = np.argmax(logits)
        result = [-1e9] * len(logits)
        result[max_idx] = 0.0
        return result
    return [l / temperature for l in logits]

def softmax(logits: list[float]) -> list[float]:
    e = np.exp(np.array(logits) - np.max(logits))  # стабильный softmax
    return (e / e.sum()).tolist()

# Пример: одни логиты, разные temperature
logits = [4.2, 2.1, 1.8, 0.9, 0.3]

for t in [0.0, 0.3, 0.7, 1.0, 1.5, 2.0]:
    probs = softmax(apply_temperature(logits, t))
    print(f"T={t:.1f}: {[f'{p:.3f}' for p in probs]}")

# T=0.0: ['1.000', '0.000', '0.000', '0.000', '0.000']  ← всегда первый
# T=0.3: ['0.988', '0.009', '0.003', '0.000', '0.000']  ← почти детерминировано
# T=0.7: ['0.854', '0.084', '0.047', '0.012', '0.004']  ← умеренное разнообразие
# T=1.0: ['0.655', '0.142', '0.106', '0.060', '0.037']  ← распределение из модели
# T=1.5: ['0.474', '0.191', '0.163', '0.107', '0.065']  ← заметная случайность
# T=2.0: ['0.374', '0.209', '0.189', '0.143', '0.086']  ← почти равномерно
⚠️
Temperature=0 ≠ temperature=0.0001
API-параметр temperature=0 обычно реализован как argmax (greedy decoding) и действительно детерминирован. temperature=0.0001 всё ещё делает sampling, просто с очень острым распределением. Для воспроизводимости используйте именно 0.

Что происходит при разных значениях

Temperature Поведение Когда использовать
0 Детерминированный, всегда argmax Тесты, отладка, fact extraction
0.1–0.3 Почти детерминированный, минимальная вариативность Генерация кода, SQL, JSON
0.5–0.7 Умеренная случайность Агенты с tool use, анализ данных
0.8–1.0 Стандартное поведение Чат, объяснения, саммари
1.2–1.5 Заметная случайность Brainstorming, вариации идей
> 1.5 Хаотичный, часто несвязный текст Почти никогда (разве что эксперименты)

Top-p (nucleus sampling)

Top-p — более умный способ ограничить пространство токенов. Вместо «взять top-k токенов» берём минимальное подмножество токенов, чья суммарная вероятность ≥ p.

Python — nucleus sampling
def top_p_filter(probs: list[float], p: float) -> list[float]:
    """Оставляем только токены, суммарная вероятность которых <= p."""
    # Сортируем по убыванию вероятности
    indexed = sorted(enumerate(probs), key=lambda x: x[1], reverse=True)

    cumulative = 0.0
    keep_indices = set()

    for idx, prob in indexed:
        cumulative += prob
        keep_indices.add(idx)
        if cumulative >= p:
            break  # достаточно токенов набрано

    # Обнуляем невыбранные токены
    filtered = [prob if i in keep_indices else 0.0
                for i, prob in enumerate(probs)]

    # Ренормализуем
    total = sum(filtered)
    return [p / total for p in filtered]

# Пример
probs = [0.62, 0.18, 0.09, 0.05, 0.03, 0.02, 0.01]
tokens = ["None", "True", "False", "список", "строку", "dict", "int"]

for p_val in [0.7, 0.9, 0.95, 1.0]:
    filtered = top_p_filter(probs, p_val)
    kept = [t for t, fp in zip(tokens, filtered) if fp > 0]
    print(f"top_p={p_val}: keeping {kept}")

# top_p=0.70: keeping ['None', 'True']
# top_p=0.90: keeping ['None', 'True', 'False', 'список']
# top_p=0.95: keeping ['None', 'True', 'False', 'список', 'строку']
# top_p=1.00: keeping ['None', 'True', 'False', 'список', 'строку', 'dict', 'int']
💡
Top-p умнее top-k
При чёткой уверенности модели (None=62%) top-p=0.9 возьмёт 4 токена. При неуверенности (10 токенов по ~10%) top-p=0.9 возьмёт все 10. Top-k всегда берёт фиксированное число и не адаптируется к ситуации.

Рекомендации по top-p

top_pЭффектПрименение
0.7Очень ограниченное nucleusСтрогий код, JSON
0.9Стандартный nucleusАгенты, чат
0.95Широкий nucleusCreative writing
1.0Нет фильтрации (только temperature)По умолчанию в большинстве API

Top-k sampling

Top-k оставляет только k наиболее вероятных токенов, остальные обнуляет. Менее гибкий, чем top-p, но всё ещё используется (особенно в Google/Gemini API).

Python — top-k фильтр
def top_k_filter(probs: list[float], k: int) -> list[float]:
    """Оставляем только top-k токенов."""
    if k >= len(probs):
        return probs

    # Находим k-й по величине порог
    threshold = sorted(probs, reverse=True)[k - 1]
    filtered = [p if p >= threshold else 0.0 for p in probs]

    # Ренормализуем
    total = sum(filtered)
    return [p / total for p in filtered]
ℹ️
OpenAI и Anthropic не поддерживают top_k напрямую (через API). Gemini API поддерживает top_k как параметр. Для OpenAI/Anthropic используйте top_p.

Repetition penalty и frequency penalty

Без штрафов модель может зациклиться: «Привет, привет, привет, привет…». Два основных параметра борются с этим:

Концептуально — как работают штрафы
# OpenAI параметры:
# frequency_penalty: [-2.0, 2.0] — штраф пропорционален частоте токена в тексте
# presence_penalty:  [-2.0, 2.0] — штраф за сам факт появления токена (бинарный)

# При генерации следующего токена:
# adjusted_logit[token] = logit[token]
#   - frequency_penalty * count(token_in_generated_text)
#   - presence_penalty  * (1 if token_appeared else 0)

# Пример — если "функция" встретилась 3 раза:
# frequency_penalty=0.5: logit -= 0.5 * 3 = -1.5
# presence_penalty=0.5:  logit -= 0.5 * 1 = -0.5

# Anthropic НЕ имеет этих параметров — борьба с повторами встроена в обучение
Параметр Диапазон Эффект > 0 Эффект < 0
frequency_penalty -2 … 2 Меньше повторов слов Больше повторов (редко нужно)
presence_penalty -2 … 2 Больше новых тем Фокус на уже упомянутом
⚠️
Высокий frequency_penalty ломает код и JSON
Если установить frequency_penalty=1.5, модель будет избегать повтора переменных, ключевых слов, скобок — и начнёт генерировать синтаксически некорректный код. Для кода держите frequency_penalty <= 0.3.

Seed: воспроизводимые результаты

Параметр seed позволяет получать одинаковые ответы при одинаковом промпте. Полезно для тестирования и отладки.

openai — seed для воспроизводимости
import openai

client = openai.AsyncOpenAI()

async def ask_with_seed(question: str, seed: int = 42) -> str:
    response = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": question}],
        temperature=1.0,   # даже при temperature=1 seed даёт стабильность
        seed=seed,
    )
    # Проверяем, был ли ответ действительно детерминированным
    # (API гарантирует это только при system_fingerprint совпадении)
    print(f"System fingerprint: {response.system_fingerprint}")
    return response.choices[0].message.content

# Несколько запусков с одинаковым seed должны давать одинаковый ответ
# (при одинаковом system_fingerprint — версии модели на сервере)
ℹ️
Anthropic поддерживает seed начиная с Claude 3.
Однако полная воспроизводимость не гарантируется из-за флоатинговой арифметики на GPU. На практике при seed + temperature=0 почти всегда получаете одинаковый ответ.

Параметры в OpenAI и Anthropic API

openai — полный набор sampling параметров
import openai

client = openai.AsyncOpenAI()

response = await client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Напиши функцию сортировки на Python"},
    ],

    # ── Основные sampling параметры ──
    temperature=0.2,        # 0-2, по умолчанию 1. Низкое для кода
    top_p=0.95,             # 0-1, по умолчанию 1. Nucleus sampling

    # ── Штрафы ──
    frequency_penalty=0.0,  # -2 до 2, по умолчанию 0
    presence_penalty=0.0,   # -2 до 2, по умолчанию 0

    # ── Управление длиной ──
    max_tokens=1024,        # максимум новых токенов

    # ── Воспроизводимость ──
    seed=42,

    # ── Стоп-последовательности ──
    stop=["```", "END"],    # остановиться на этих строках

    # ── Количество вариантов ──
    n=1,                    # сколько вариантов генерировать
)

text = response.choices[0].message.content
usage = response.usage  # prompt_tokens, completion_tokens, total_tokens
anthropic — sampling параметры
import anthropic

client = anthropic.AsyncAnthropic()

response = await client.messages.create(
    model="claude-opus-4-6",

    # ── Основные sampling параметры ──
    temperature=0.2,      # 0-1 у Anthropic (не 0-2!), по умолчанию 1
    top_p=0.95,           # nucleus sampling
    top_k=50,             # Anthropic поддерживает top_k

    # ── Управление длиной ──
    max_tokens=1024,      # ОБЯЗАТЕЛЬНЫЙ параметр у Anthropic

    # ── Стоп-последовательности ──
    stop_sequences=["```", "END"],

    messages=[
        {"role": "user", "content": "Напиши функцию сортировки на Python"}
    ],
    system="You are a helpful coding assistant.",
)

text = response.content[0].text
usage = response.usage  # input_tokens, output_tokens
⚠️
Важное отличие: диапазон temperature
OpenAI: temperature от 0 до 2.
Anthropic: temperature от 0 до 1.
При портировании кода между API не забывайте это нормировать.

Сравнительная таблица параметров

Параметр OpenAI Anthropic Gemini
temperature 0–2, default 1 0–1, default 1 0–2, default 1
top_p ✅ 0–1 ✅ 0–1 ✅ 0–1
top_k
frequency_penalty ✅ -2…2
presence_penalty ✅ -2…2
seed ✅ (beta)
max_tokens optional required optional
stop ✅ до 4 строк ✅ stop_sequences ✅ stop_sequences

Готовые пресеты для разных задач

В production обычно не подбирают параметры вручную — используют проверенные пресеты:

Чат / FAQ
Сбалансированный
temperature=0.7
top_p=0.9
Хорошо для объяснений, Q&A, технической поддержки.
Creative writing
Творческий
temperature=1.0
top_p=0.95
presence_penalty=0.5
Разнообразные ответы, меньше повторяющихся тем.
Python — пресеты как Enum + dataclass
from dataclasses import dataclass
from enum import Enum
from typing import Optional

@dataclass
class SamplingConfig:
    temperature: float = 1.0
    top_p: float = 1.0
    top_k: Optional[int] = None
    frequency_penalty: float = 0.0
    presence_penalty: float = 0.0
    seed: Optional[int] = None
    max_tokens: int = 1024

class SamplingPreset(Enum):
    CODE        = SamplingConfig(temperature=0.0, seed=42)
    AGENT       = SamplingConfig(temperature=0.3, top_p=0.9)
    CHAT        = SamplingConfig(temperature=0.7, top_p=0.9)
    ANALYSIS    = SamplingConfig(temperature=0.5, top_p=0.9)
    CREATIVE    = SamplingConfig(temperature=1.0, top_p=0.95, presence_penalty=0.5)
    BRAINSTORM  = SamplingConfig(temperature=1.3, top_p=0.95)

def config_to_openai(cfg: SamplingConfig) -> dict:
    """Конвертируем конфиг в kwargs для OpenAI API."""
    params = {
        "temperature": cfg.temperature,
        "top_p": cfg.top_p,
        "max_tokens": cfg.max_tokens,
    }
    if cfg.frequency_penalty != 0:
        params["frequency_penalty"] = cfg.frequency_penalty
    if cfg.presence_penalty != 0:
        params["presence_penalty"] = cfg.presence_penalty
    if cfg.seed is not None:
        params["seed"] = cfg.seed
    return params

def config_to_anthropic(cfg: SamplingConfig) -> dict:
    """Конвертируем конфиг в kwargs для Anthropic API.

    Важно: temperature нормируем в диапазон 0-1.
    """
    params = {
        "temperature": min(cfg.temperature, 1.0),  # Anthropic max = 1.0
        "max_tokens": cfg.max_tokens,
    }
    if cfg.top_p < 1.0:
        params["top_p"] = cfg.top_p
    if cfg.top_k is not None:
        params["top_k"] = cfg.top_k
    return params

# Использование
import openai
import anthropic

async def generate_code(prompt: str) -> str:
    client = openai.AsyncOpenAI()
    preset = SamplingPreset.CODE.value

    response = await client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        **config_to_openai(preset),
    )
    return response.choices[0].message.content

async def analyze_document(text: str) -> str:
    client = anthropic.AsyncAnthropic()
    preset = SamplingPreset.ANALYSIS.value

    response = await client.messages.create(
        model="claude-opus-4-6",
        messages=[{"role": "user", "content": text}],
        **config_to_anthropic(preset),
    )
    return response.content[0].text

Sampling в агентах: особые случаи

Проблема: агент циклится

При низком temperature агент может зациклиться — постоянно выбирать один и тот же инструмент или генерировать одинаковые мысли. Решение: динамически повышать temperature при обнаружении петли.

Python — автоматическое повышение temperature при цикле
import hashlib
from collections import deque

class AdaptiveAgent:
    """Агент с автоматической адаптацией sampling параметров."""

    def __init__(self, base_temperature: float = 0.3):
        self.base_temperature = base_temperature
        self.recent_responses: deque[str] = deque(maxlen=5)
        self.consecutive_repeats: int = 0

    def _response_hash(self, text: str) -> str:
        """Хэш первых 100 символов для обнаружения повторов."""
        return hashlib.md5(text[:100].encode()).hexdigest()[:8]

    def _detect_loop(self, response: str) -> bool:
        """Обнаруживаем, если агент генерирует похожие ответы."""
        h = self._response_hash(response)
        if h in [self._response_hash(r) for r in self.recent_responses]:
            self.consecutive_repeats += 1
            return self.consecutive_repeats >= 2
        self.consecutive_repeats = 0
        return False

    def _current_temperature(self) -> float:
        """Динамически повышаем temperature при обнаружении цикла."""
        bump = min(self.consecutive_repeats * 0.2, 0.8)
        return min(self.base_temperature + bump, 1.0)

    async def step(self, messages: list[dict], client) -> str:
        temp = self._current_temperature()

        if temp > self.base_temperature:
            print(f"⚠️ Обнаружен цикл, повышаем temperature: {temp:.1f}")

        response = await client.messages.create(
            model="claude-opus-4-6",
            messages=messages,
            max_tokens=512,
            temperature=temp,
        )

        text = response.content[0].text
        self.recent_responses.append(text)

        if self._detect_loop(text):
            print("🔄 Агент застрял, сбрасываем контекст...")
            # В реальном агенте здесь можно сжать историю или добавить
            # системный промпт "Try a different approach"

        return text

Проблема: недетерминированные тесты агента

Python — детерминированный режим для тестов
import os
from contextlib import contextmanager

@contextmanager
def deterministic_mode():
    """Контекст-менеджер для детерминированного поведения агента."""
    original = os.environ.get("AGENT_TEMPERATURE")
    os.environ["AGENT_TEMPERATURE"] = "0"
    os.environ["AGENT_SEED"] = "42"
    try:
        yield
    finally:
        if original is None:
            del os.environ["AGENT_TEMPERATURE"]
        else:
            os.environ["AGENT_TEMPERATURE"] = original
        os.environ.pop("AGENT_SEED", None)

# В тестах:
async def test_agent_tool_selection():
    with deterministic_mode():
        result = await my_agent.run("Какая погода в Москве?")
        assert "weather_tool" in result.tool_calls

Проблема: sampling для structured output

Python — structured output с OpenAI
from pydantic import BaseModel
import openai

client = openai.AsyncOpenAI()

class TaskAnalysis(BaseModel):
    priority: int        # 1-5
    category: str
    estimated_hours: float
    requires_human: bool

async def analyze_task(description: str) -> TaskAnalysis:
    # Для structured output рекомендуется temperature=0 или очень низкая
    # Модель и так ограничена схемой, случайность не нужна
    response = await client.beta.chat.completions.parse(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Analyze the task and return structured data."},
            {"role": "user", "content": description},
        ],
        response_format=TaskAnalysis,
        temperature=0,   # детерминированно для парсинга
    )
    return response.choices[0].message.parsed

Лучшие практики

Золотые правила sampling для агентов:
  1. Код, JSON, SQL → temperature=0, seed=42
  2. Tool use в агентах → temperature=0.1–0.3
  3. Не используйте frequency_penalty > 0.3 для структурированных ответов
  4. В тестах всегда фиксируйте seed и используйте temperature=0
  5. При A/B тестировании промптов — зафиксируйте seed для честного сравнения
  6. Anthropic temperature нормируйте в 0–1 при переносе из OpenAI

Как подбирать параметры методично

Python — grid search по sampling параметрам
import asyncio
import statistics
from itertools import product

async def evaluate_sampling(
    prompt: str,
    expected_keywords: list[str],
    temperatures: list[float],
    top_ps: list[float],
    runs_per_config: int = 5,
) -> list[dict]:
    """Подбираем лучшие sampling параметры для задачи."""

    client = openai.AsyncOpenAI()
    results = []

    for temperature, top_p in product(temperatures, top_ps):
        scores = []

        for _ in range(runs_per_config):
            response = await client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[{"role": "user", "content": prompt}],
                temperature=temperature,
                top_p=top_p,
                max_tokens=200,
            )
            text = response.choices[0].message.content.lower()

            # Считаем, сколько ключевых слов нашли
            score = sum(1 for kw in expected_keywords if kw.lower() in text)
            scores.append(score / len(expected_keywords))

        results.append({
            "temperature": temperature,
            "top_p": top_p,
            "avg_score": statistics.mean(scores),
            "std_score": statistics.stdev(scores) if len(scores) > 1 else 0,
        })

    return sorted(results, key=lambda x: x["avg_score"], reverse=True)

# Запуск:
# results = await evaluate_sampling(
#     prompt="Объясни что такое gradient descent",
#     expected_keywords=["градиент", "оптимизация", "функция потерь"],
#     temperatures=[0.0, 0.3, 0.7, 1.0],
#     top_ps=[0.9, 0.95, 1.0],
# )
# print(results[:3])  # топ-3 конфигурации

Проверь себя

Вопросы для самопроверки

  1. Что происходит с распределением вероятностей при temperature=0.1 vs temperature=2.0?
  2. Почему top-p считается лучше top-k для большинства задач?
  3. Какие параметры sampling НЕ поддерживает Anthropic API?
  4. При каком значении temperature рекомендуется генерировать JSON для агента?
  5. Чем отличается frequency_penalty от presence_penalty?
Показать ответы
  1. При 0.1 логиты делятся на 0.1 (×10) — распределение становится очень острым, топ-токен получает почти 100%. При 2.0 логиты делятся на 2 — распределение сглаживается, менее вероятные токены получают больший шанс.
  2. Top-k всегда берёт фиксированное число токенов. Top-p адаптируется: при уверенной модели берёт мало токенов, при неуверенной — больше. Это предотвращает выбор случайных редких токенов в ситуациях высокой уверенности.
  3. Anthropic не поддерживает frequency_penalty и presence_penalty.
  4. Рекомендуется temperature=0 (deterministic) для JSON — нужна максимальная предсказуемость структуры.
  5. frequency_penalty штрафует пропорционально числу повторений токена (накапливается). presence_penalty штрафует бинарно — за сам факт появления токена хотя бы раз.

Итог урока

  • Temperature делит логиты: <1 → острее, >1 → равномернее. 0 = greedy (argmax)
  • Top-p (nucleus) оставляет минимальный набор токенов с суммой вероятности ≥ p — адаптивный подход
  • Top-k — фиксированный набор; Anthropic его поддерживает, OpenAI — нет
  • Frequency/presence penalty борются с повторами; осторожно с кодом — лучше ≤ 0.3
  • OpenAI temperature 0–2, Anthropic temperature 0–1 — разные диапазоны!
  • Агент + tool use → temperature=0.1–0.3, код/JSON → temperature=0
  • Используйте seed для воспроизводимых тестов