Как устроена языковая модель | Курс LangChain урок 1
Цель урока: понимать, что происходит с вашей строкой между вызовом invoke и полученным ответом. И уметь это измерить: сколько токенов ушло, сколько влезет в окно, откуда берётся разброс ответов и во сколько обходится один запрос.
Необходимые знания:
1) урок 0: окружение собрано, ключ работает, проверочный скрипт доходит до конца
2) Python на уровне джуниора, включая работу со словарями и списками
3) представление о том, что вызов модели, это запрос по сети к чужому серверу
Ключевые концепции:
1) токен как единица ввода, вывода и оплаты
2) предсказание следующего токена и сэмплирование
3) контекстное окно и то, что в него входит
4) вызов модели не хранит состояние
5) temperature, top_p и режим рассуждения
6) выдумки модели и отсутствие калькулятора внутри
7) стоимость запроса и кеширование префикса
Зачем разбираться в устройстве модели, если её код вы не пишете
Дальше в курсе будет два десятка решений, которые без этого урока выглядят набором правил, взятых с потолка. Почему историю диалога приходится обрезать или пересказывать. Почему системный промпт кладут в начало и стараются не трогать. Почему арифметику отдают инструменту, а не модели. Почему тест агента нельзя писать через сравнение строк.
Все четыре ответа лежат не в LangChain, а в самой модели, которую он вызывает. Её устройство разберу ровно настолько, насколько это нужно для решений в коде. Обучения моделей, матриц внутри трансформера и истории вопроса здесь не будет.
Цены и размеры окна стареют быстрее всего в этой области, быстрее, чем выходят версии фреймворка. Поэтому запоминать их не нужно, нужно уметь их достать: где посмотреть, чем измерить, как посчитать. Механика не меняется годами, конкретная цифра верна до следующего объявления провайдера.
Что модель делает с вашей строкой
Соблазнительно думать, что модель понимает вопрос и ищет ответ. Устроено это иначе.
Модель работает с токенами, а не со словами и буквами. Токен это базовая единица, которую модель читает и порождает. У разных провайдеров она определена по-разному, но обычно это целое слово или часть слова.
Дальше происходит одно действие, повторённое много раз. Модель смотрит на всё, что уже есть в тексте, и строит распределение вероятностей по тому, каким будет следующий токен. Выбирает один. Дописывает его к тексту. Смотрит на получившееся и повторяет.
Распределение можно попросить напрямую у провайдера. В API DeepSeek за это отвечают два параметра. logprobs включает возврат логарифмов вероятностей для каждого выданного токена. top_logprobs задаёт, сколько ближайших конкурентов вернуть на каждой позиции, до двадцати штук. В LangChain та же возможность включается через bind, а результат лежит в response_metadata["logprobs"].
Посмотрите на распределение своими глазами. Здесь и дальше листинг в тексте полный: скопируйте его в файл с указанным именем и запускайте, дописывать в него ничего не нужно.
Сначала общий модуль, его импортируют все примеры урока. В уроке 0 сборка модели заняла несколько строк: прочитать .env, взять имя модели и адрес из переменных курса, выбрать одну из двух веток. Повторять их в каждом файле незачем, поэтому положите сборку рядом с примерами в course_model.py, и дальше каждый пример вызывает её.
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
load_dotenv()
def build_model(**kwargs):
"""Собирает модель курса.
Все именованные аргументы уходят в init_chat_model как есть: temperature,
max_tokens, model_kwargs и прочее из раздела Parameters.
"""
model_name = os.environ["MODEL_NAME"]
base_url = os.getenv("MODEL_BASE_URL")
if base_url:
# Путь для любого адреса, совместимого с OpenAI Chat Completions API.
return init_chat_model(
model=model_name,
model_provider="openai",
base_url=base_url,
api_key=os.environ["OPENAI_API_KEY"],
**kwargs,
)
# Путь напрямую к провайдеру.
return init_chat_model(model_name, **kwargs)
Функция принимает те же именованные аргументы, что и init_chat_model, и передаёт их дальше без изменений. Поэтому build_model(temperature=0) в примерах ниже, это тот же вызов, который вы писали в уроке 0, только короче. Файл .env берётся из урока 0. load_dotenv() ищет его начиная с папки модуля и поднимается вверх по дереву, так что модуль можно держать рядом с примерами, а ключ выше.
Пример 01_next_token.py
import math
from course_model import build_model
# temperature=0 здесь не ради воспроизводимости, а чтобы модель брала самого
# вероятного кандидата и картинка распределения читалась однозначно.
model = build_model(temperature=0).bind(logprobs=True, top_logprobs=5)
response = model.invoke("Продолжите фразу тремя словами: столица Франции, это")
print("ОТВЕТ:", response.text)
print()
logprobs = response.response_metadata.get("logprobs")
if not logprobs:
print("Провайдер не вернул logprobs.")
print("Это не ошибка примера: поле необязательное, и часть шлюзов его режет.")
print("Механика от этого не меняется, но увидеть её на своём ключе не выйдет.")
raise SystemExit(0)
print("Первые пять позиций и кандидаты на каждую:")
for position in logprobs["content"][:5]:
print(f"
выбран: {position['token']!r}")
for candidate in position.get("top_logprobs", []):
# Провайдер отдаёт натуральный логарифм вероятности, exp возвращает
# обратно долю от единицы.
probability = math.exp(candidate["logprob"])
print(f" {candidate['token']!r:<20} {probability:.4f}")
# Вывод:
# ОТВЕТ: прекрасный город Париж
#
# Провайдер не вернул logprobs.
# Это не ошибка примера: поле необязательное, и часть шлюзов его режет.
# Механика от этого не меняется, но увидеть её на своём ключе не выйдет.
Поле logprobs необязательное: возвращать его настроены не все модели, а часть шлюзов режет его. На модели курса сработала ветка if not logprobs, поле не пришло. Механика от этого не меняется, но увидеть её на своём ключе выйдет не у каждого. Ветка на отсутствие поля нужна и в вашем коде: без неё вместо ответа приходит TypeError.
Из механики следуют три вещи, которыми вы будете пользоваться весь курс.
1) стриминг возможен именно потому, что текст рождается по кусочку. stream отдаёт объекты AIMessageChunk, которые складываются в целое сообщение сложением. Урок 7 целиком про это
2) ответ не выбирается целиком и не проверяется на осмысленность перед отправкой. Модель не может вернуться и переписать первое предложение, узнав, что четвёртое ей не понравилось
3) длина ответа, это деньги и время. Каждый следующий токен, это ещё один проход по всему тексту
Токен это единица счёта и денег
За токены платят, в токенах измеряется окно, в токенах считаются лимиты. Значит, надо уметь их считать, а не прикидывать по длине строки.
Соотношение символов и токенов не постоянно. DeepSeek даёт ориентир: один английский символ, это примерно 0.3 токена, один китайский иероглиф, примерно 0.6 токена. И тут же оговаривает, что разные модели режут текст по-разному, поэтому источником правды считается то, что вернул API.
В LangChain есть два разных способа узнать число токенов, и путать их дорого.
| Способ | Что меряет | Когда брать |
|---|---|---|
len(text) |
символы | никогда, если речь идёт про токены |
count_tokens_approximately |
оценку, локально и бесплатно | планирование: влезет ли история, пора ли обрезать |
usage_metadata в ответе |
то, за что списали деньги | учёт расхода, отчёты, лимиты по деньгам |
Функция count_tokens_approximately лежит в langchain_core.messages.utils и работает в паре с trim_messages. Как именно она считает, документация не раскрывает. Порядок задан в исходном коде langchain-core 1.6.3, и он важен для понимания первой строки вывода ниже.
1) для каждого сообщения складывается длина содержимого в символах и длина имени роли в формате OpenAI. Роль берётся по типу сообщения: user у HumanMessage, assistant у AIMessage, system у SystemMessage, tool у ToolMessage. Если у сообщения задано поле name, его длина тоже прибавляется, это включает аргумент count_name со значением True по умолчанию
2) сумма делится на chars_per_token, по умолчанию 4.0, и округляется вверх
3) сверху добавляется extra_tokens_per_message, по умолчанию 3.0
4) результат по всем сообщениям складывается и ещё раз округляется вверх
Отдельные виды содержимого учитываются по-разному. У ToolMessage прибавляется длина tool_call_id, у AIMessage с вызовами инструментов, длина их строкового представления. Картинка идёт по фиксированной цене tokens_per_image, по умолчанию 85 токенов, вместо подсчёта символов кодировки base64. Схемы инструментов в счёт попадают, только если вы передали их аргументом tools.
Пересчитаю на самом дешёвом варианте, на одной точке. Содержимое, один символ, роль user, четыре символа. Пять символов на четыре и вверх, это 2, плюс три служебных, итого 5. Вот откуда пятёрка в первой строке таблицы ниже, а не тройка: имя роли попадает в счёт наравне с вашим текстом. Пустой HumanMessage стоит 4 токена оценки, пустой AIMessage уже 6, потому что слово assistant длиннее слова user. Для английского вся эта арифметика близка к правде, на русском она расходится.
Пример 02_tokens.py
from langchain.messages import HumanMessage
from langchain_core.messages.utils import count_tokens_approximately
from course_model import build_model
SAMPLES = [
".",
"cat",
"кот",
"The quick brown fox jumps over the lazy dog",
"Быстрая бурая лиса прыгает через ленивого пса",
"1234567890",
"достопримечательность",
]
# max_tokens режет ответ: платить за длинную генерацию здесь не за что,
# нас интересует только вход.
model = build_model(temperature=0, max_tokens=16)
print(f"{'текст':<48}{'симв.':>7}{'оценка':>8}{'реально':>9}")
print("-" * 72)
for text in SAMPLES:
response = model.invoke(text)
approximate = count_tokens_approximately([HumanMessage(text)])
real = response.usage_metadata["input_tokens"]
shown = text if len(text) <= 45 else text[:42] + "..."
print(f"{shown:<48}{len(text):>7}{approximate:>8}{real:>9}")
# Вывод:
# текст симв. оценка реально
# ------------------------------------------------------------------------
# . 1 5 5
# cat 3 5 5
# кот 3 5 6
# The quick brown fox jumps over the lazy dog 43 15 13
# Быстрая бурая лиса прыгает через ленивого пса 45 16 22
# 1234567890 10 7 8
# достопримечательность 21 10 9
Обратите внимание на первую строку вывода, это одна точка. Она показывает накладную плату: даже почти пустой запрос стоит несколько токенов, потому что вокруг вашего текста добавляется служебная обёртка сообщения. Накладных плат здесь на самом деле две, и совпали они случайно. В колонке оценки пятёрка сложилась из формулы, разобранной выше: символ содержимого, четыре символа роли user, деление на четыре вверх и три служебных токена. В колонке реального счёта пятёрка пришла от провайдера, и из чего она у него сложилась, он не объясняет. Все остальные строки читайте как "столько сверх этой обёртки".
Оценка по символам врёт, и врёт в обе стороны. На русской фразе она занижена больше чем на четверть, на слове "достопримечательность" и на английской фразе, наоборот, завышена. Постоянного коэффициента, на который можно было бы умножить длину строки, здесь нет: направление ошибки зависит от текста.
Отсюда правило. Для планирования берите оценку: она не ходит в сеть, не стоит денег, и ошибки в четверть достаточно, чтобы решить "обрезать или нет", если оставить запас. Для денег, отчётов и жёстких лимитов берите usage_metadata. Прикидывать по длине строки нельзя ни для того, ни для другого.
Контекстное окно и его предел
Контекстное окно, это максимальное число токенов, которое можно передать модели за один раз.
Дальше три следствия, которые в определение не входят, а в работу входят.
Первое. В окно входит и входные, и выходные токены. Суммарная длина входных и сгенерированных токенов ограничена длиной контекста модели. То есть окно, это не "сколько можно прислать", а "сколько всего поместится вместе с ответом".
Второе. Переполнение выглядит как обрезанный ответ, а не как ошибка. Содержимое сообщения приходит частично обрезанным, а finish_reason равен "length". Значит, генерация превысила max_tokens либо диалог превысил максимальную длину контекста. Ваш код при этом получит нормальный ответ, только оборванный на середине фразы.
Третье, и оно неприятнее первых двух. Даже если модель поддерживает полную длину контекста, большинство моделей работают по длинному контексту плохо. Их отвлекает устаревшее и не относящееся к делу содержимое, ответы приходят медленнее и стоят дороже. То есть "влезло" и "сработает хорошо", это разные вещи.
Размер окна не нужно помнить. LangChain отдаёт характеристики модели через атрибут profile, данные для него приходят из проекта models.dev и лежат внутри пакета интеграции, так что запроса в сеть не будет.
Пример 03_context_window.py
from langchain_openai import ChatOpenAI
from course_model import build_model
model = build_model()
profile = model.profile
print("ПРОФИЛЬ МОДЕЛИ КУРСА")
if profile is None:
print("Профиль не найден: для этого имени модели данных в пакете нет.")
print("Так бывает у шлюзов, где имя модели выглядит как provider/model.")
print("Размер окна тогда берётся со страницы вашего провайдера,")
print("а в код его кладут параметром profile при создании модели.")
else:
print(f" max_input_tokens: {profile.get('max_input_tokens')}")
print(f" max_output_tokens: {profile.get('max_output_tokens')}")
print(f" tool_calling: {profile.get('tool_calling')}")
print(f" reasoning_output: {profile.get('reasoning_output')}")
print()
print("ТРИ ИМЕНИ, У КОТОРЫХ ПРОФИЛЬ ЕСТЬ ВСЕГДА")
print("Ключ здесь не используется: profile читается из данных пакета,")
print("запрос к провайдеру не уходит.")
for name in ("gpt-4o-mini", "gpt-5-nano", "gpt-5.5"):
known = ChatOpenAI(model=name, api_key="not-used-no-request-is-made")
known_profile = known.profile or {}
print(
f" {name:<12} вход до {known_profile.get('max_input_tokens')} токенов, "
f"выход до {known_profile.get('max_output_tokens')}"
)
# Вывод:
# ПРОФИЛЬ МОДЕЛИ КУРСА
# Профиль не найден: для этого имени модели данных в пакете нет.
# Так бывает у шлюзов, где имя модели выглядит как provider/model.
# Размер окна тогда берётся со страницы вашего провайдера,
# а в код его кладут параметром profile при создании модели.
#
# ТРИ ИМЕНИ, У КОТОРЫХ ПРОФИЛЬ ЕСТЬ ВСЕГДА
# Ключ здесь не используется: profile читается из данных пакета,
# запрос к провайдеру не уходит.
# gpt-4o-mini вход до 128000 токенов, выход до 16384
# gpt-5-nano вход до 272000 токенов, выход до 128000
# gpt-5.5 вход до 1050000 токенов, выход до 128000
Профиль может оказаться пустым, и это ожидаемый случай, а не ошибка: у шлюзов имя модели выглядит как provider/model, и данных по такому имени в пакете нет. Тогда размер окна берётся со страницы вашего провайдера, а в код кладётся руками, параметром profile при создании модели.
Профиль в текущей версии помечен авторами как бета, и формат может измениться. Читайте его, но не стройте на нём то, что потом придётся переписывать.
Что с этим делать в коде, разберу в уроках 12, 14 и 15. Это обрезка истории, суммаризация и средний слой, который включает её по размеру окна из того самого профиля.
Модель не помнит, что было минуту назад
Это самое частое недоразумение в начале.
API чата состояния не хранит: сервер провайдера не записывает контекст ваших запросов. Поэтому вы обязаны склеить всю прежнюю историю и прислать её заново с каждым запросом. В LangChain то же самое: взаимодействия с моделью обычно не хранят состояние, а диалог выглядит как вызов модели со списком сообщений, который растёт.
Иначе говоря, память, это не свойство модели. Память, это ваш код, который каждый раз кладёт в запрос всё, что было раньше.
Пример 04_no_memory.py
from langchain.messages import AIMessage, HumanMessage
from course_model import build_model
model = build_model(temperature=0)
print("ДВА ОТДЕЛЬНЫХ ВЫЗОВА")
first = model.invoke("Меня зовут Михаил. Запомните это.")
print(" вопрос 1: Меня зовут Михаил. Запомните это.")
print(f" ответ 1: {first.text}")
print(f" вход: {first.usage_metadata['input_tokens']} токенов")
second = model.invoke("Как меня зовут? Ответьте одним словом.")
print(" вопрос 2: Как меня зовут? Ответьте одним словом.")
print(f" ответ 2: {second.text}")
print(f" вход: {second.usage_metadata['input_tokens']} токенов")
print()
print("ТОТ ЖЕ ВТОРОЙ ВОПРОС, НО СО СПИСКОМ СООБЩЕНИЙ")
history = [
HumanMessage("Меня зовут Михаил. Запомните это."),
AIMessage(first.text),
HumanMessage("Как меня зовут? Ответьте одним словом."),
]
third = model.invoke(history)
print(f" ответ: {third.text}")
print(f" вход: {third.usage_metadata['input_tokens']} токенов")
# Вывод:
# ДВА ОТДЕЛЬНЫХ ВЫЗОВА
# вопрос 1: Меня зовут Михаил. Запомните это.
# ответ 1: Приятно познакомиться, Михаил. Я запомнил ваше имя. Чем могу быть полезен?
# вход: 18 токенов
# вопрос 2: Как меня зовут? Ответьте одним словом.
# ответ 2: Неизвестно
# вход: 16 токенов
#
# ТОТ ЖЕ ВТОРОЙ ВОПРОС, НО СО СПИСКОМ СООБЩЕНИЙ
# ответ: Михаил.
# вход: 61 токенов
Смотрите не только на текст ответов, но и на числа. Список сообщений дал правильный ответ ценой выросшего входа, и это ровно та цена, которую вы платите за память в любом приложении с моделью.
Каждый новый ход диалога тянет за собой всю прежнюю историю. Расход на входе растёт не линейно от числа ходов, а быстрее: на десятом ходу вы платите за первый вопрос десятый раз. Урок 12 про то, как этим управлять, а урок 13 про то, что имеет смысл помнить между сессиями.
Температура и сэмплирование
Это параметр, который крутят все, и он же чаще всего оказывается не тем, чем его считают.
Вы уже знаете, что на каждой позиции у модели есть распределение вероятностей по следующему токену. Сэмплирование, это правило, по которому из распределения выбирают один токен. Температура управляет этим правилом.
temperature управляет случайностью вывода: выше число, ответы разнообразнее, ниже число, ответы предсказуемее. Границы задаёт уже провайдер. У DeepSeek это число от 0 до 2, по умолчанию 1. Значения вроде 0.8 делают вывод случайнее, значения вроде 0.2 делают его сфокусированнее и детерминированнее.
Рядом стоит второй параметр, top_p, он же nucleus sampling. При нём модель рассматривает только те токены, которые набирают заданную долю вероятностной массы. top_p=0.1 означает, что в рассмотрение попадут кандидаты, составляющие верхние десять процентов массы. И рекомендация провайдера, которую стоит соблюдать: меняйте либо одно, либо другое, но не оба сразу.
Температура, это параметр, а не гарантия. Провайдер вправе её не применять, и вы об этом можете не узнать.
1) DeepSeek: режим рассуждения включён по умолчанию, а в нём параметры temperature, top_p, presence_penalty и frequency_penalty не поддерживаются. Дословно: для совместимости с существующим софтом установка этих параметров не вызовет ошибки, но и не даст эффекта
2) Anthropic: на текущих моделях, включая Claude Opus 5 и Claude Sonnet 5, нестандартные значения temperature, top_p и top_k возвращают ошибку 400 на каждом запросе, независимо от того, используется рассуждение или нет
Две противоположные реакции на одно и то же действие: один провайдер молча игнорирует, другой отказывает с ошибкой.
Проверяется это одним прогоном. Один и тот же вопрос задаётся четыре раза: дважды при нулевой температуре и дважды при 1.5. В конце контрольный замер.
Пример 05_temperature.py
from course_model import build_model
PROMPT = "Придумайте название для кофейни рядом с университетом. Ответьте только названием."
def show(title, model):
response = model.invoke(PROMPT)
# Поле output_token_details заполняет провайдер. Если он не разделяет
# выход на рассуждение и ответ, словарь придёт пустым, и это тоже факт.
details = response.usage_metadata.get("output_token_details") or {}
reasoning = details.get("reasoning", "провайдер не разделил")
print(f"{title:<24} {response.text.strip()!r}")
print(f"{'':<24} выход {response.usage_metadata['output_tokens']} токенов, "
f"из них на рассуждение: {reasoning}")
return response.text.strip()
cold = build_model(temperature=0)
hot = build_model(temperature=1.5)
print("ТЕМПЕРАТУРА 0")
cold_1 = show("прогон 1", cold)
cold_2 = show("прогон 2", cold)
print()
print("ТЕМПЕРАТУРА 1.5")
hot_1 = show("прогон 1", hot)
hot_2 = show("прогон 2", hot)
print()
print(f"пара при 0 совпала: {cold_1 == cold_2}")
print(f"пара при 1.5 совпала: {hot_1 == hot_2}")
print()
print("КОНТРОЛЬ: ОДНОСЛОВНЫЙ ОТВЕТ И СЧЁТЧИК ВЫХОДА")
control = cold.invoke("Столица Франции? Ответьте одним словом.")
print(f" видимый текст: {control.text.strip()!r}")
print(f" счётчик выхода: {control.usage_metadata['output_tokens']} токенов")
# Вывод:
# ТЕМПЕРАТУРА 0
# прогон 1 'КофеСтудент'
# выход 192 токенов, из них на рассуждение: провайдер не разделил
# прогон 2 'Кофейная пауза'
# выход 160 токенов, из них на рассуждение: провайдер не разделил
#
# ТЕМПЕРАТУРА 1.5
# прогон 1 'Переменка'
# выход 230 токенов, из них на рассуждение: провайдер не разделил
# прогон 2 'Кофе-Сессия'
# выход 161 токенов, из них на рассуждение: провайдер не разделил
#
# пара при 0 совпала: False
# пара при 1.5 совпала: False
#
# КОНТРОЛЬ: ОДНОСЛОВНЫЙ ОТВЕТ И СЧЁТЧИК ВЫХОДА
# видимый текст: 'Париж'
# счётчик выхода: 67 токенов
В выводе разошлись обе пары, и при нуле, и при 1.5. Нулевая температура повторяемости ответа не дала. Если у вас пара при нуле совпадёт, это не опровержение: два одинаковых однословных ответа выпадают и без всякой детерминированности. Опора здесь на сам факт расхождения, а не на конкретные названия кофейни.
Второе, что видно в выводе. Поле output_token_details пришло пустым: провайдер выход на рассуждение и ответ не разделил, в сыром ответе соответствующее поле равно null. Отдельного счётчика рассуждения нет, приходит одно число на весь выход. Судить по нему, сколько модель потратила на внутреннюю работу, нельзя. Контрольный запрос в конце про это же: ответ ровно словом "Париж", а счётчик выхода показывает 67 токенов. Объяснить разрыв нечем, отдельного поля с рассуждением в ответе нет. Размер разрыва от прогона к прогону разный. В уроке 0 тот же однословный ответ обошёлся в 4 токена на одном прогоне и в 5 на двух других.
Вот теперь возвращаюсь к странности из урока 0. Там три прогона одного скрипта с temperature=0 разошлись и в тексте, и в счётчике: 4 токена на выходе в одном прогоне и 5 в двух других. Замер выше эту странность и объясняет. Температура на этой модели не управляет ничем, ответ каждый раз собирается заново, а совпадение однословного текста, это совпадение, а не воспроизводимость. Счётчик выхода гуляет вместе с текстом, и в выводе выше это видно на четырёх вызовах одного вопроса.
Раз рычага нет, разумно поискать другой. Здесь есть деталь, которой не видно из документации фреймворка.
Собственные поля провайдера можно отправить двумя способами, и они ведут себя по-разному.
1) model_kwargs кладёт ключи прямо в вызов клиента провайдера. Неизвестный ключ роняет вызов локально, до отправки, с TypeError: Completions.create() got an unexpected keyword argument 'thinking'. Запрос в сеть при этом не уходит вовсе, и никакого "отказа провайдера" вы не видели, вы видели отказ пакета у себя на машине
2) extra_body кладёт содержимое в тело запроса и до клиента как ключ не доходит. Выглядит это так: chat.invoke("...", extra_body={...}). DeepSeek требует ровно того же: при работе через SDK OpenAI поле thinking передаётся внутри extra_body
Второй рычаг стандартный, он есть в самом LangChain. Параметр reasoning_effort задаётся при создании модели или на вызов, как температура, и каждый провайдер переводит его в свой формат. DeepSeek принимает уровни low, high и max, причём medium и xhigh он отображает в high.
Пример 06_thinking_off.py
from course_model import build_model
PROMPT = "Придумайте название для кофейни рядом с университетом. Ответьте только названием."
model = build_model(temperature=0)
print("БЕЗ РЫЧАГОВ, ДЛЯ СРАВНЕНИЯ")
base = model.invoke(PROMPT)
print(f" {base.text.strip()!r}, выход {base.usage_metadata['output_tokens']} токенов")
print()
print("РЫЧАГ 1: ВЫКЛЮЧИТЬ РЕЖИМ РАССУЖДЕНИЯ")
try:
off = model.invoke(PROMPT, extra_body={"thinking": {"type": "disabled"}})
print(f" {off.text.strip()!r}, выход {off.usage_metadata['output_tokens']} токенов")
except Exception as error: # noqa: BLE001
print(f" отказ: {type(error).__name__}")
print(f" текст: {error}")
print()
print("РЫЧАГ 2: СНИЗИТЬ УСИЛИЕ НА РАССУЖДЕНИЕ")
try:
low = model.invoke(PROMPT, reasoning_effort="low")
print(f" {low.text.strip()!r}, выход {low.usage_metadata['output_tokens']} токенов")
except Exception as error: # noqa: BLE001
print(f" отказ: {type(error).__name__}")
print(f" текст: {error}")
# Вывод:
# БЕЗ РЫЧАГОВ, ДЛЯ СРАВНЕНИЯ
# 'СтудКофейня', выход 237 токенов
#
# РЫЧАГ 1: ВЫКЛЮЧИТЬ РЕЖИМ РАССУЖДЕНИЯ
# 'Кофе-Конспект', выход 357 токенов
#
# РЫЧАГ 2: СНИЗИТЬ УСИЛИЕ НА РАССУЖДЕНИЕ
# 'Кофе-конспект', выход 434 токенов
Смотреть надо на счётчик выходных токенов рядом с каждым ответом. Сработавший рычаг виден сразу: вместе с рассуждением из выхода уходят его токены, и счётчик падает в разы, а не на проценты.
Ни один рычаг не сработал. Счётчик выхода с ними и без них остался в тех же сотнях токенов, а сработавший рычаг уронил бы его в разы. Что это не эффект, видно по разбросу. Счётчик выхода на одном и том же вопросе гуляет сам по себе. В примере выше он разошёлся от 160 до 230 токенов на четырёх вызовах. Разброс от 237 до 434 на этом фоне, это расстояние между соседними вызовами, а не результат управления. Рычаг, который работает, дал бы падение в одну сторону, а здесь счётчик с рычагами вырос.
Оба поля шлюз принял молча: ни ошибки, ни эффекта. Это третий тихий отказ в одном разделе, до этого так же молча не подействовала температура. И вот это, а не сработавший рычаг, и есть вывод раздела.
Между вашим кодом и моделью стоит цепочка: пакет интеграции, шлюз, провайдер. Любое её звено вправе принять параметр и ничего с ним не сделать, причём именно принять, а не отказать. Отсюда рабочее правило: единственный способ узнать, работает ли рычаг, это замер до и после, а не строка в документации и не отсутствие ошибки.
Вывод: не стройте проверки на том, что при нулевой температуре ответ повторится дословно. В уроке 22 (выйдет позже) проверяется поведение агента, а не равенство строк, и причина этого решения находится здесь.
Почему модель уверенно врёт и не считает
Это не сбой и не недоделка конкретного провайдера.
Работа "Why Language Models Hallucinate" формулирует причину так. Модели, как студенты на трудном экзамене, угадывают при неуверенности и выдают правдоподобные, но неверные утверждения вместо признания незнания. Ошибки такого рода возникают из статистики обучения, а держатся потому, что принятые способы оценки поощряют угадывание. Модель, которая пишет "не знаю", на тестах выглядит хуже модели, которая назвала наугад.
С арифметикой то же самое, только заметнее. Калькулятора внутри нет. Число, это последовательность токенов, и произведение двух больших чисел модель продолжает так же, как продолжает фразу. Иногда попадает, и попадает чаще, чем ожидаешь. Проблема не в том, что модель ошибается, а в том, что по виду ответа вы не отличите попадание от промаха.
Проверьте обе вещи.
Пример 07_facts_and_math.py
import re
from course_model import build_model
A = 972_345_618_407
B = 851_209_366_773
model = build_model(temperature=0)
print("ПРОВЕРКА ПЕРВАЯ: АРИФМЕТИКА")
question = f"Посчитайте {A} * {B}. В ответе дайте только число, без пояснений."
answer = model.invoke(question)
truth = A * B
digits = re.sub(r"\D", "", answer.text)
print(f" вопрос: {A} * {B}")
print(f" ответ модели: {answer.text.strip()}")
print(f" ответ Python: {truth}")
print(f" совпало: {digits == str(truth)}")
print()
print("ПРОВЕРКА ВТОРАЯ: НЕСУЩЕСТВУЮЩАЯ ФУНКЦИЯ")
fake = model.invoke(
"Опишите параметры функции create_agent_pool из LangChain "
"и приведите пример вызова."
)
print(f" {fake.text.strip()}")
print()
print("ТОТ ЖЕ ВОПРОС, НО С РАЗРЕШЕНИЕМ НЕ ЗНАТЬ")
honest = model.invoke(
"Опишите параметры функции create_agent_pool из LangChain "
"и приведите пример вызова. Если такой функции нет или вы не уверены, "
"ответьте ровно одной фразой: не знаю."
)
print(f" {honest.text.strip()}")
# Вывод:
# ПРОВЕРКА ПЕРВАЯ: АРИФМЕТИКА
# вопрос: 972345618407 * 851209366773
# ответ модели: 827669698128723562990611
# ответ Python: 827669698128723562990611
# совпало: True
#
# ПРОВЕРКА ВТОРАЯ: НЕСУЩЕСТВУЮЩАЯ ФУНКЦИЯ
# Функция `create_agent_pool` является **экспериментальной** и находится
# в пакете `langchain_experimental.agent_pool`. Она предназначена для
# создания пула агентов, которые могут обрабатывать запросы параллельно
# или целенаправленно маршрутизировать их к конкретным агентам.
#
# [ПОДРЕЗАНО. Дальше в ответе шли: таблица "Основные параметры" из шести
# строк (agents, routing_strategy, reducer, max_concurrency, verbose,
# kwargs) с типами и значениями по умолчанию, раздел про возвращаемый
# объект AgentPool с интерфейсом Runnable, пример вызова на сорок строк
# с импортом from langchain_experimental.agent_pool import create_agent_pool
# и раздел "Примечания" из трёх пунктов, где названа версия пакета,
# с которой функция доступна, и дана ссылка на страницу документации]
#
# ТОТ ЖЕ ВОПРОС, НО С РАЗРЕШЕНИЕМ НЕ ЗНАТЬ
# не знаю
Функции create_agent_pool в LangChain нет, я её придумал.
Произведение двух двенадцатизначных чисел модель посчитала верно, до последнего разряда. На другом прогоне того же вопроса модель ошиблась уже в старших разрядах. Вывода это не меняет: верный счёт от неверного по виду ответа не отличается. В том же прогоне модель уверенно описала несуществующую функцию. Отнесла её к пакету langchain_experimental.agent_pool, расписала шесть параметров с типами, привела пример вызова на сорок строк и сослалась на страницу документации, которой нет. С разрешением не знать та же модель на тот же вопрос ответила "не знаю".
Читать это надо не как "повезло с арифметикой". Разойдись произведение, проблема была бы та же самая. Ответ с ошибкой в счёте и ответ с выдуманной функцией выглядят одинаково уверенно: та же интонация, та же структура, та же готовность привести детали. Отличить их по виду ответа нельзя, отличил их Python в первом случае и знание документации во втором. Ровно поэтому дальше в курсе проверка выносится из модели наружу.
Четыре приёма, и все четыре разбираются дальше в курсе.
1) разрешите модели сказать "не знаю". Anthropic ставит это первым пунктом в своём руководстве по снижению выдумок и называет эффект существенным. Фраза в системном промпте, урок 5
2) отдайте вычисление и доступ к данным инструменту. Модель не считает, а функция считает, и это ровно то, зачем инструменты нужны, урок 9
3) требуйте структуру вместо свободного текста, тогда часть выдумок превращается в ошибку разбора схемы, которую видно в коде, урок 6
4) проверяйте результат отдельно: ограждения на входе и выходе агента, урок 17, и набор примеров с оценкой качества, урок 22 (выйдет позже)
И оговорка из того же руководства Anthropic: перечисленные приёмы значительно снижают долю выдумок, но не устраняют их полностью, поэтому всегда проверяйте критичную информацию.
Сколько стоит запрос
Механика оплаты у всех провайдеров одна, а числа разные. Разберу механику.
1) ставка назначается за миллион токенов
2) вход и выход тарифицируются по разным ставкам, выход дороже, обычно в разы
3) вход, попавший в кеш, стоит дешевле входа, который считается заново
4) у отдельных провайдеров ставка ещё зависит от времени суток
Вот три модели трёх разных ценовых уровней, доллары за миллион токенов. Ставки DeepSeek даны на 22.09.2026, ставки Anthropic на 31.08.2026. Числа приведены не для запоминания, а чтобы показать размах.
| Модель | Вход | Вход из кеша | Выход |
|---|---|---|---|
| DeepSeek deepseek-flash, ночной тариф | $0.15 | $0.003 | $0.60 |
| Anthropic Claude Sonnet 5 | $2 | $0.20 | $10 |
| Anthropic Claude Fable 5 | $10 | $1 | $50 |
В первой строке стоит имя со страницы цен провайдера. Имя, которое принимает шлюз курса, другое, deepseek/deepseek-v4-flash, и в выводе примера ниже напечатано именно оно.
Смотреть здесь надо на отношения, они устойчивее абсолютных значений. Выход дороже входа в четыре раза в первой строке таблицы и в пять раз в двух остальных. Вход из кеша дешевле обычного входа в пятьдесят раз у первого провайдера и в десять у второго. Между самой дешёвой и самой дорогой строкой таблицы разница в десятки раз, а с точки зрения вашего кода эти модели вызываются одинаково.
Одна оговорка про эту таблицу и про пример ниже. Здесь ставки самих провайдеров, первая строка взята по ночному тарифу, а дневной у того же провайдера ровно вдвое выше. Если вы, как и я, ходите к модели через шлюз, платите вы шлюзу по его прайсу, а не по этому. Проверить расхождение можно на выводе примера 1 урока 2. Шлюз вернул своё поле cost, и с расчётом по ставкам DeepSeek оно не сходится: это разные прайсы. Единицу измерения этого поля шлюз нигде не объявляет. Правило короткое в формулировке и обязательное по последствиям: считайте по ставкам того, кому платите, а итог раз в месяц сверяйте со счётом в личном кабинете.
Кеширование префикса у DeepSeek включено по умолчанию и не требует правок в коде. Попадание в кеш требует полного совпадения префикса запроса с ранее сохранённым. Ключевое слово здесь "префикс": совпадать должно начало. В LangChain попадание в кеш видно в метаданных расхода на ответе.
Отсюда правило: всё стабильное кладите в начало, всё меняющееся в конец. Соблюсти его при сборке промпта ничего не стоит, а узнать о нём поздно значит переписывать промпт. Системный промпт, описания инструментов, справочные данные, это начало. Текущая дата, идентификатор запроса, имя пользователя, это конец. Дата в первой строке системного промпта ломает попадание в кеш для всего, что за ней идёт.
Теперь деньги на диалоге из трёх ходов.
Пример 08_cost.py
from langchain.messages import AIMessage, HumanMessage
from langchain_core.callbacks import UsageMetadataCallbackHandler
from course_model import build_model
# ПОДСТАВЬТЕ СВОИ СТАВКИ. Здесь цены модели курса на 22.09.2026, доллары за
# миллион токенов, ночной тариф. У вашего провайдера они другие и меняются
# часто.
PRICE_INPUT_MISS = 0.15
PRICE_INPUT_HIT = 0.003
PRICE_OUTPUT = 0.60
QUESTIONS = [
"Назовите три задачи, где агент с инструментами выигрывает у одного запроса к модели.",
"Возьмите первую из них и опишите, какие инструменты понадобятся.",
"А теперь оцените, сколько вызовов модели уйдёт на один прогон такой задачи.",
]
callback = UsageMetadataCallbackHandler()
model = build_model(temperature=0)
history = []
for number, question in enumerate(QUESTIONS, start=1):
history.append(HumanMessage(question))
response = model.invoke(history, config={"callbacks": [callback]})
history.append(AIMessage(response.text))
usage = response.usage_metadata
cached = (usage.get("input_token_details") or {}).get("cache_read", 0)
print(
f"ход {number}: вход {usage['input_tokens']} "
f"(из кеша {cached}), выход {usage['output_tokens']}"
)
print()
print("ИТОГ ПО ВСЕМ ВЫЗОВАМ")
for model_name, usage in callback.usage_metadata.items():
cached = (usage.get("input_token_details") or {}).get("cache_read", 0)
fresh = usage["input_tokens"] - cached
money = (
fresh * PRICE_INPUT_MISS
+ cached * PRICE_INPUT_HIT
+ usage["output_tokens"] * PRICE_OUTPUT
) / 1_000_000
print(f" модель: {model_name}")
print(f" вход: {usage['input_tokens']} токенов, из них из кеша {cached}")
print(f" выход: {usage['output_tokens']} токенов")
print(f" всего: {usage['total_tokens']} токенов")
print(f" деньги: ${money:.6f}")
# Вывод:
# ход 1: вход 29 (из кеша 0), выход 1273
# ход 2: вход 899 (из кеша 0), выход 1587
# ход 3: вход 2129 (из кеша 0), выход 2395
#
# ИТОГ ПО ВСЕМ ВЫЗОВАМ
# модель: deepseek/deepseek-v4-flash
# вход: 3057 токенов, из них из кеша 0
# выход: 5255 токенов
# всего: 8312 токенов
# деньги: $0.003612
Обработчик UsageMetadataCallbackHandler берётся из langchain_core.callbacks и передаётся в вызов через config={"callbacks": [callback]}. Он складывает расход по всем вызовам и раскладывает его по именам моделей, что пригодится, когда в приложении их станет две и больше. Тот же приём есть в виде менеджера контекста, get_usage_metadata_callback.
Смотрите на две вещи в выводе. Первая, как растёт вход от хода к ходу: это цена памяти. Вторая, попал ли хоть один токен в кеш: если поле cache_read осталось нулевым, значит либо ваш провайдер кеш не отдаёт в метаданных, либо префикс не совпал.
Одна цифра в конце ничего не значит сама по себе. Значение появляется, когда вы умножаете её на число пользователей и на число ходов в диалоге. Урок 24 (выйдет позже) про то, как считать это на реальном объёме.
Распространённые ошибки
Ошибка 1: считать нулевую температуру гарантией повторяемости
# Неправильно: тест, который развалится в неожиданный день
model = build_model(temperature=0)
assert model.invoke("Столица Франции?").text == "Париж"
Что происходит: тест зелёный неделю, потом красный, и в коде ничего не менялось.
Почему так: нулевая температура убирает случайность выбора токена там, где провайдер этот параметр применяет. Неизменными она не делает ни версию модели у провайдера, ни счётчики токенов, ни режим рассуждения. А если у вас модель с рассуждением, температура может вообще не применяться, и об этом вам никто не скажет.
# Правильно: проверяем поведение, а не строку
response = model.invoke("Столица Франции?")
assert "Париж" in response.text
Полноценный ответ на этот вопрос, это набор примеров и оценка качества, урок 22 (выйдет позже). Пока достаточно правила: равенство строк, это не проверка модели.
Ошибка 2: считать токены по длине строки
# Неправильно: оценка "четыре символа на токен" на русском тексте
if len(text) // 4 > 3000:
text = text[:12000]
Что происходит: обрезка срабатывает не там, где вы рассчитывали, а счёт от провайдера оказывается выше ожидаемого.
Почему так: четыре символа на токен, это ориентир для английского, он и зашит в count_tokens_approximately аргументом chars_per_token со значением 4.0 по умолчанию. Но даже сама функция считает не только это. У неё сверху идут имя роли и три служебных токена на сообщение, а len(text) // 4 в примере выше не считает и их. Разные языки и разные модели режут текст по-разному, и провайдер прямо говорит, что источником правды считается то, что вернул API.
# Правильно: оценка для планирования, usage_metadata для денег
from langchain_core.messages.utils import count_tokens_approximately
if count_tokens_approximately(history) > 3000:
...
spent = response.usage_metadata["input_tokens"]
Ошибка 3: ждать от модели памяти
# Неправильно: два независимых вызова, второй ничего не знает о первом
model.invoke("Меня зовут Михаил.")
answer = model.invoke("Как меня зовут?")
Что происходит: модель отвечает, что не знает вашего имени, либо выдумывает имя.
Почему так: сервер провайдера не хранит контекст ваших запросов. Всё, что модель "помнит", это то, что вы прислали в этом самом запросе.
# Правильно: историю собирает ваш код
history = [
HumanMessage("Меня зовут Михаил."),
AIMessage(first_response.text),
HumanMessage("Как меня зовут?"),
]
answer = model.invoke(history)
Ошибка 4: ставить меняющееся в начало системного промпта
# Неправильно: дата в первой строке
system = f"Сегодня {datetime.now():%d.%m.%Y %H:%M:%S}. Вы помощник по документации..."
Что происходит: расход растёт, а в метаданных у поля cache_read всегда нулевое значение, хотя провайдер кеш поддерживает.
Почему так: кеш работает по префиксу и требует полного совпадения его начала. Время в первой строке меняет префикс на каждом запросе и обесценивает всё, что идёт за ним, включая длинные описания инструментов.
# Правильно: стабильное вперёд, меняющееся назад
system = "Вы помощник по документации..."
messages = [
SystemMessage(system),
HumanMessage(f"Сегодня {datetime.now():%d.%m.%Y}. {question}"),
]
Практическое задание
Соберите скрипт token_budget.py, который показывает бюджет диалога до того, как вы упрётесь в окно или в счёт.
Требования:
1) на вход скрипт берёт список сообщений диалога, задайте его прямо в файле, минимум четыре хода
2) печатает для каждого хода: номер, длину в символах, оценку count_tokens_approximately по всей истории до этого хода включительно и реальный input_tokens из ответа модели
3) печатает расхождение между оценкой и реальностью в процентах, отдельной колонкой
4) берёт размер окна из model.profile, а если профиль пустой, из константы в начале файла с комментарием, откуда взято число
5) печатает предупреждение, когда история занимает больше 20 процентов окна
6) в конце считает стоимость всего диалога по трём ставкам из констант, как в примере 8
Как проверить результат:
1) на коротком диалоге предупреждения нет, а расхождение оценки и реальности вы видите числом. На английском тексте оно держится около десяти процентов, на русском уходит за четверть, и это нормально
2) добавьте в один из ходов длинный кусок русского текста, страницу или две. Расхождение должно заметно вырасти, и это ответ на вопрос, можно ли планировать бюджет по символам
3) уменьшите константу окна до нескольких сотен токенов. Должно появиться предупреждение
Подсказка: count_tokens_approximately принимает список сообщений целиком, а не по одному, поэтому срез истории делается обычным history[:n].
Итоги урока
Теперь вы знаете, что происходит между вашим invoke и ответом. Модель работает с токенами, на каждом шаге выбирает следующий токен из распределения и дописывает его к тексту. Она не хранит состояние между вызовами, поэтому память, это ваш код. У неё есть предел на суммарную длину входа и выхода, и работа у предела хуже работы вдали от него. У неё нет калькулятора, а угадывать при неуверенности её приучили условия обучения и оценки.
И вы умеете это измерить. usage_metadata показывает расход, count_tokens_approximately даёт оценку до запроса, model.profile отдаёт размер окна. UsageMetadataCallbackHandler складывает расход по всему приложению, а logprobs показывает то самое распределение, если провайдер его отдаёт.
Отдельно о том, что видно по примерам урока: часть полей и рычагов ваш провайдер не поддержит. Профиль модели может прийти пустым, logprobs могут не вернуться, output_token_details может оказаться пустым словарём, а температура может не действовать. Пишите код так, чтобы отсутствие поля было веткой, а не падением, и проверяйте рычаг замером, а не верой в документацию.
Чего в этом уроке не хватило. Вы всё это время напрямую звали модель. Отсюда справедливый вопрос: зачем нужен фреймворк, если запрос к провайдеру уходит по обычному HTTP и без него. Вопрос острый ещё и потому, что фреймворк за последний год изменился. Цепочки уехали в отдельный пакет, LCEL ушёл из ядра, а половина статей в интернете написана про то, чего в текущей версии больше нет.
В уроке 2, "Зачем нужен LangChain и что случилось с v0", разберу, что даёт слой над API и когда он избыточен. Там же, куда делись LLMChain и цепочки. И один раз покажу готового агента, чтобы было видно, куда идёт курс.
Код урока
Примеры этого урока лежат в репозитории курса, папка lesson_01. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.
Предыдущий урок: Настройка окружения
Следующий урок: Зачем нужен LangChain и что случилось с v0
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru