Перейти к содержимому
Ступень 88.1 Работа с LLM через API

OpenAI-совместимые API

Как устроен запрос к LLM по HTTP, что значат роли, temperature и max_tokens, как работает стриминг и сколько стоит диалог — на примере DeepSeek, Qwen и локального Ollama.

УРОКstage-8/8-1-llm-api/01-api-basics.mdx1 ч 20 минУрок 1 из 2
После урока вы сможете
  • Отправить запрос в Chat Completions API к DeepSeek, Qwen или локальному Ollama и разобрать ответ (choices, finish_reason, usage)
  • Объяснить, почему API не хранит состояние и как из-за этого растёт стоимость длинного диалога
  • Принять ответ по частям (стриминг через SSE) и посчитать стоимость запроса с учётом кэша контекста
  • Обрабатывать ошибки 429 и 5xx повторами с экспоненциальной задержкой

До этой ступени вы строили большая языковая модельбольшая языковая модель (БЯМ, LLM)EN large language model (LLM)В китайском «большая модель» («да мосин») — разговорное общее название для LLM и вообще foundation models. изнутри: токенизатор, внимание, обучение, инференс, вывод моделиинференс, вывод моделиEN inferenceСм. «reasoning»: в китайском одно и то же слово.. В реальных продуктах почти всегда наоборот: модель уже кем-то обучена и запущена, а вы обращаетесь к ней по HTTP. Чат-бот поддержки, RAG по документам компании, агент, который пишет код, — всё это сотни одинаковых запросов «вот сообщения, верни ответ».

Хорошая новость: формат таких запросов почти везде один и тот же. Его придумала OpenAI для Chat Completions, а затем его повторили DeepSeek, Qwen (Alibaba Cloud), Kimi, GLM и локальные серверы — Ollama, vLLM, SGLang, llama.cpp. Выучив один формат, вы переключаетесь между облачной DeepSeek и Qwen на своём ноутбуке, меняя три значения: адрес сервера (base_url), ключ и имя модели.

В этом уроке разберём запрос и ответ по полям, научимся принимать ответ по кусочкам (стриминг) и посчитаем, сколько стоит диалог — там есть неочевидная квадратичная ловушка.

Запрос — это POST на адрес {base_url}/chat/completions с JSON-телом и ключом в заголовке Authorization: Bearer <ключ>. Минимальное тело:

{
"model": "deepseek-flash",
"messages": [
{"role": "system", "content": "Ты — лаконичный помощник. Отвечай по-русски."},
{"role": "user", "content": "Что такое KV-кэш? Одним предложением."}
],
"temperature": 0.7,
"max_tokens": 200,
"thinking": {"type": "disabled"}
}

Последнее поле — расширение DeepSeek, а не часть формата OpenAI. С поколения V4 модели DeepSeek по умолчанию рассуждают перед ответом, а в режиме рассуждений temperature игнорируется; "thinking": {"type": "disabled"} включает обычный режим. У других провайдеров этого поля нет — для них строку уберите.

Ответ (сокращённо):

{
"id": "…",
"model": "deepseek-flash",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "KV-кэш хранит ключи и значения прошлых токенов, чтобы не пересчитывать их на каждом шаге генерации."},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 31, "completion_tokens": 27, "total_tokens": 58}
}

Три поля ответа, которые нужно проверять всегда:

  • choices[0].message.content — сам текст.
  • finish_reason — почему генерация остановилась: stop (модель сама закончила или встретила стоп-строку), length (упёрлась в max_tokens — ответ обрезан!), tool_calls (модель хочет вызвать инструмент — это урок 8.3.1), content_filter (ответ остановил фильтр модерации провайдера).
  • usage — сколько токеновтокенEN tokenВ разговорной речи и статьях китайцы чаще всего пишут просто «token». ушло на вход (prompt_tokens) и выход (completion_tokens). За них вы платите.
flowchart LR
A["messages + параметры"] --> B["POST /chat/completions"]
B --> C["chat template:<br/>сообщения → токены"]
C --> D["prefill<br/>(весь промпт сразу)"]
D --> E["decode<br/>(токен за токеном)"]
E --> F["JSON: choices,<br/>finish_reason, usage"]
Что происходит между вашим запросом и ответом

Каждое сообщение в messages имеет роль:

РольКто «говорит»Для чего
systemразработчикправила игры: кто модель, тон, язык, ограничения, формат ответа
userпользовательвопросы и данные
assistantмодельеё прошлые ответы — история диалога
toolваш кодрезультаты вызова инструментов (урок 8.3.1)

системный промптсистемный промптEN system promptСообщение с ролью system — правила для модели (роль, тон, формат). Технически это просто первый блок текста в chat-шаблоне. — это не «волшебная команда», а просто первый блок текста с особой разметкой. Сервер склеивает все сообщения в одну строку по chat-шаблонchat-шаблонEN chat templateПравило (обычно Jinja в tokenizer_config.json), которое превращает список сообщений с ролями в одну строку со спецтокенами. модели — тому самому шаблону, на котором модель учили на этапе SFT (урок 6.1.2). Для Qwen это ChatML:

<|im_start|>system
Ты — лаконичный помощник. Отвечай по-русски.<|im_end|>
<|im_start|>user
Что такое KV-кэш? Одним предложением.<|im_end|>
<|im_start|>assistant

Модель видит эту строку и продолжает её — генерирует ответ ассистента до спецтокена <|im_end|>. Никакой другой магии нет: «роли» — это разметка внутри одного контекстного окнаконтекстное окноEN context window.

Важнейшее свойство: сервер ничего не помнит между запросами. Если вы во втором сообщении спросите «Как меня зовут?», не отправив первое сообщение, модель не узнает. «Память» чат-бота — это список messages, который ваш код хранит и каждый раз отправляет целиком:

history = [{"role": "system", "content": "Ты — краткий помощник."}]
def ask(text):
history.append({"role": "user", "content": text})
reply = chat(history) # отправляем ВСЮ историю
history.append({"role": "assistant", "content": reply})
return reply

Отсюда два следствия: история упирается в контекстное окно (что выкидывать — тема следующего урока про context engineering), а каждый следующий запрос дороже предыдущего. Ко второму вернёмся в разделе про деньги.

ПараметрЧто делаетТипичные значения
modelкакую модель вызватьимя у провайдера или в Ollama
temperature«острота» распределения перед сэмплированием0–0.3 для извлечения фактов и кода, 0.6–1.0 для текста
top_pсэмплировать только из «ядра» вероятности p0.8–0.95
max_tokensпотолок длины ответа (у новых моделей OpenAI поле называется max_completion_tokens)под задачу; помните о finish_reason
stopстроки, на которых остановиться["\n\n"], ["</answer>"]
streamотдавать ответ по частямtrue для чатов
response_formatJSON-режимследующий урок

Температуру и top-p вы уже реализовывали в уроке 4.1.3 о сэмплировании: логиты делятся на TT, затем softmax. При T→0T \to 0 генерация почти жадная, но не обязательно детерминированная: на сервере ваш запрос обрабатывается в батче с чужими, а порядок операций с плавающей точкой может меняться (подробно — там же, в разделе «Почему „температура 0“ не всегда детерминирована»).

max_tokens ограничивает только ответ. Промпт и ответ вместе должны поместиться в контекстное окно модели. У reasoning-моделей (R1, Qwen3 в режиме размышлений) в выходные токены входят и рассуждения — их тоже оплачиваете вы. В API DeepSeek рассуждения приходят отдельным полем reasoning_content рядом с content.

Проще всего обращаться к API через официальный SDK (software development kit) — готовую библиотеку-обёртку, которая сама собирает HTTP-запрос и разбирает JSON-ответ. Ставится она командой pip install openai (в вашем виртуальном окружении, урок 0.1.2). Код с ней выглядит одинаково для всех провайдеров:

import os
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:11434/v1", # Ollama на вашем компьютере
api_key="ollama", # Ollama ключ не проверяет, но поле обязательно
)
# DeepSeek: OpenAI(base_url="https://api.deepseek.com", api_key=os.environ["DEEPSEEK_API_KEY"])
resp = client.chat.completions.create(
model="qwen3:0.6b",
messages=[{"role": "user", "content": "Назови три китайские LLM."}],
temperature=0.7,
max_tokens=300,
)
print(resp.choices[0].message.content)
print(resp.choices[0].finish_reason, resp.usage)

Для запуска нужны установленный Ollama с моделью qwen3:0.6b и пакет openai. Ответ зависит от модели, поэтому вывод здесь не приводим. Полностью воспроизводимый вариант без сети и SDK вы найдёте в практике.

Чтобы перейти в облако, меняются только base_url, ключ и имя модели:

Где модельbase_urlКлючПример модели
Ollama (локально)http://localhost:11434/v1любая строкаqwen3:0.6b, qwen3:8b
llama.cpp / vLLM / SGLang (локально)http://localhost:8080/v1 / :8000/v1 / :30000/v1любая строкаимя, с которым запущен сервер
DeepSeekhttps://api.deepseek.comDEEPSEEK_API_KEYна октябрь 2026 — deepseek-flash (V4.1-Flash) и deepseek-v4-pro
Qwen (Alibaba Cloud, платформа Model Studio / Bailian)https://dashscope.aliyuncs.com/compatible-mode/v1DASHSCOPE_API_KEYqwen-plus и др.

Имена моделей меняются с каждым поколением: актуальный список отдаёт client.models.list() или страница документации провайдера. Пример — DeepSeek: имена deepseek-chat и deepseek-reasoner, которые встречаются в коде 2025 года, отключены 24.07.2026, а режим рассуждений теперь включается полем thinking (журнал изменений DeepSeek API). У Alibaba Cloud общий адрес dashscope.aliyuncs.com по-прежнему работает, но документация рекомендует адрес своего рабочего пространства, а у международного региона адрес другой (Model Studio Base URL) — сверяйтесь с консолью.

Генерация идёт в две фазы (урок 7.1.1): prefill обрабатывает весь промпт параллельно, затем decode выдаёт токены по одному. Без стриминга пользователь ждёт оба этапа целиком — для ответа в 500 токенов при 30 токенах/с это больше 16 секунд тишины. Со стримингомпотоковая выдача (стриминг)EN streamingОтвет приходит по кусочкам через Server-Sent Events; уменьшает время до первого токена, но не число токенов. первые слова появляются сразу после prefill, а остальные «печатаются» по мере генерации. время до первого токенавремя до первого токена (TTFT)EN time to first token (TTFT)Сколько пользователь ждёт первое слово ответа: очередь + prefill всего промпта. Растёт с длиной промпта. становится главной метрикой отзывчивости.

С "stream": true сервер отвечает не JSON-документом, а потоком Server-Sent Events — текстовыми строками:

data: {"choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}
data: {"choices":[{"index":0,"delta":{"content":"KV-"}}]}
data: {"choices":[{"index":0,"delta":{"content":"кэш"}}]}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]

Вместо message в каждом чанке — delta с новым кусочком текста. Ваш код склеивает кусочки. Блок usage по умолчанию в поток не попадает; чтобы получить его в последнем чанке, добавьте stream_options={"include_usage": True} (OpenAI и Ollama это поддерживают).

stream = client.chat.completions.create(
model="qwen3:0.6b",
messages=[{"role": "user", "content": "Расскажи про DeepSeek-V3 в трёх фразах."}],
stream=True,
stream_options={"include_usage": True},
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
if chunk.usage:
print("\n", chunk.usage)

Облачные API берут деньги за токены, обычно в пересчёте на миллион. Цены на выход заметно выше, чем на вход: prefill обрабатывает тысячи токенов промпта одним проходом и упирается в вычисления, а decode делает по проходу на каждый токен и упирается в пропускную способность памяти (урок 7.1.1). Поэтому провайдеру токен ответа обходится дороже.

Платите вы за токены, а не за символы. Помните из урока 4.2: русский текст обычно режется на большее число токенов, чем тот же смысл по-английски, и у разных моделей по-разному. Точное число всегда в usage.

API не хранит состояние, поэтому на ходе tt вы заново отправляете системный промпт и все прошлые реплики. Пусть системный промпт — SS токенов, реплика пользователя — uu, ответ — aa. Тогда вход tt-го запроса и суммарный вход за TT ходов:

promptt=S+t u+(t−1) a,∑t=1Tpromptt=TS+u T(T+1)2+a T(T−1)2\begin{gathered} \text{prompt}_t = S + t\,u + (t-1)\,a, \\ \sum_{t=1}^{T} \text{prompt}_t = T S + u\,\frac{T(T+1)}{2} + a\,\frac{T(T-1)}{2} \end{gathered}

Что здесь написано: в tt-м запросе лежат системный промпт, tt реплик пользователя (включая новую) и t−1t-1 прошлых ответов модели. При сложении по всем ходам реплики дают сумму u (1+2+⋯+T)=u T(T+1)2u\,(1 + 2 + \dots + T) = u\,\frac{T(T+1)}{2}, ответы — a (0+1+⋯+(T−1))=a T(T−1)2a\,(0 + 1 + \dots + (T-1)) = a\,\frac{T(T-1)}{2}, и в обеих стоит T2T^2.

Значит, рост квадратичный по числу ходов. При S=800S = 800, u=60u = 60, a=250a = 250 диалог из 20 ходов отправит на вход 20⋅800+60⋅210+250⋅190=76 10020 \cdot 800 + 60 \cdot 210 + 250 \cdot 190 = 76\,100 токенов, а из 40 ходов — 276 200: вдвое больше ходов, в 3.6 раза больше токенов.

Заметьте: у запросов одного диалога одинаковое начало — системный промпт и вся предыдущая история. Провайдеры этим пользуются. кэширование контекстакэширование контекста (кэш промпта)EN context caching / prompt cachingПровайдер хранит KV-кэш общего префикса запросов и не пересчитывает его; такие «попадания» стоят в разы дешевле. У DeepSeek — «кэш контекста на жёстком диске». хранит KV-кэшKV-кэшEN KV cache уже обработанного префикса: если новый запрос начинается так же, prefill для этой части не нужен. Такие токены называются попаданием в кэш (cache hit) и стоят в разы дешевле промаха.

Как это видно в ответе API:

  • DeepSeek включает кэш на диске по умолчанию и возвращает в usage поля prompt_cache_hit_tokens и prompt_cache_miss_tokens.
  • OpenAI-формат показывает попадания в usage.prompt_tokens_details.cached_tokens.

Кэш работает только по префиксу — совпадение должно идти с самого первого токена. Отсюда главное правило промпт-дизайна для денег: статичное — в начало, изменчивое — в конец. Если вставить текущее время с секундами первой строкой системного промпта, кэш не сработает ни разу: каждый запрос будет отличаться с первых токенов.

Формула стоимости одного запроса (цены за 1 млн токенов):

cost=nhit phit+nmiss pmiss+nout pout106\text{cost} = \frac{n_\text{hit}\, p_\text{hit} + n_\text{miss}\, p_\text{miss} + n_\text{out}\, p_\text{out}}{10^6}

Сеть и сервер иногда отказывают. Что делать с типичными кодами ответа:

КодЗначениеЧто делать
400неверный запрос (лишнее поле, слишком длинный контекст)исправить запрос; повтор не поможет
401неверный ключпроверить переменную окружения
402закончились деньги на балансе (так у DeepSeek)пополнить баланс
429превышен лимит запросовподождать и повторить
500, 502, 503, 504сбой или перегрузка сервераподождать и повторить

Повторять нужно с экспоненциальной задержкой (1 с, 2 с, 4 с…) и случайным разбросом («джиттером»), чтобы тысяча клиентов, получивших ошибку одновременно, не ударила по серверу снова в одну и ту же секунду. Если сервер прислал заголовок Retry-After, он сам говорит, сколько секунд ждать (как такой заголовок отдаёт ваш собственный сервис — в уроке 8.4.3). Обрыв связи и таймаут тоже стоит повторить:

import random
import time
import urllib.error
RETRYABLE = {429, 500, 502, 503, 504}
def with_retries(call, max_attempts=5, base_delay=1.0):
"""Повторяет call() при 429/5xx и сетевых сбоях с экспоненциальной задержкой и «джиттером»."""
for attempt in range(max_attempts):
last = attempt == max_attempts - 1
retry_after = None
try:
return call()
except urllib.error.HTTPError as e: # сервер ответил кодом ошибки
if e.code not in RETRYABLE or last:
raise # 400/401/402 повторять бессмысленно
retry_after = e.headers.get("Retry-After") # сервер может сам сказать, сколько ждать
except (urllib.error.URLError, TimeoutError): # обрыв связи, таймаут
if last:
raise
if retry_after and retry_after.isdigit():
delay = float(retry_after)
else:
delay = base_delay * 2 ** attempt * random.uniform(0.5, 1.5)
print(f"попытка {attempt + 1} не удалась, ждём {delay:.2f} с")
time.sleep(delay)
# Проверка на «капризной» функции: два раза 429, затем успех
calls = 0
def flaky():
global calls # global: менять переменную calls, объявленную вне функции, а не создавать локальную
calls += 1
if calls < 3:
raise urllib.error.HTTPError("http://demo", 429, "Too Many Requests", {}, None)
return "ответ получен"
random.seed(0)
print(with_retries(flaky, base_delay=0.1), "| вызовов:", calls)
попытка 1 не удалась, ждём 0.13 с
попытка 2 не удалась, ждём 0.25 с
ответ получен | вызовов: 3

Порядок except важен: HTTPError — подкласс URLError, поэтому коды ответа сервера разбираем первыми. Официальный SDK openai уже умеет повторы (параметр max_retries у клиента) и таймауты (timeout).

Ключи храните в переменных окружения или в файле .env, который добавлен в .gitignore. Ключ, однажды попавший в публичный репозиторий, считайте украденным: боты сканируют GitHub постоянно.

Переменная окружения — это именованная строка, которую терминал передаёт всем запущенным из него программам. Задать её на один сеанс терминала (Linux/macOS): export DEEPSEEK_API_KEY="sk-...". В Python её читают так: os.environ["DEEPSEEK_API_KEY"] (если переменной нет — KeyError). Чтобы не вводить ключ каждый раз, допишите строку export ... в файл ~/.bashrc (или ~/.zshrc на macOS) и откройте новый терминал.

Задание 1 — клиент без SDK. Без ключа и интернета разберём протокол «руками». Ниже — фейковый OpenAI-совместимый сервер на стандартной библиотеке Python и клиент без SDK. Сохраните оба файла в одну папку и запустите python client.py.

mock_server.py — фейковый сервер (раскройте и скопируйте)
# mock_server.py — фейковый OpenAI-совместимый сервер: тренируемся без ключа и интернета
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
n_msgs = len(body["messages"])
answer = f"Принято. В истории {n_msgs} сообщ., последнее — {len(body['messages'][-1]['content'])} симв."
words = answer.split(" ")
# «Токены» здесь фейковые: просто слова. Настоящий сервер считает их токенизатором модели.
usage = {
"prompt_tokens": sum(len(m["content"].split()) for m in body["messages"]),
"completion_tokens": len(words),
}
usage["total_tokens"] = usage["prompt_tokens"] + usage["completion_tokens"]
if not body.get("stream"):
resp = {
"id": "chatcmpl-demo",
"object": "chat.completion",
"model": body["model"],
"choices": [{"index": 0, "message": {"role": "assistant", "content": answer},
"finish_reason": "stop"}],
"usage": usage,
}
data = json.dumps(resp, ensure_ascii=False).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
def send(obj):
self.wfile.write(f"data: {json.dumps(obj, ensure_ascii=False)}\n\n".encode())
for i, w in enumerate(words):
piece = w if i == 0 else " " + w
send({"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}]})
send({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
if body.get("stream_options", {}).get("include_usage"):
send({"choices": [], "usage": usage})
self.wfile.write(b"data: [DONE]\n\n")
def log_message(self, *args): # не засоряем консоль логами
pass
def start(port=8765):
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
# client.py — клиент Chat Completions на одной стандартной библиотеке
import json
import urllib.request
import mock_server
BASE_URL = "http://127.0.0.1:8765/v1" # Ollama: http://localhost:11434/v1
API_KEY = "sk-local" # облако: os.environ["DEEPSEEK_API_KEY"] и т. п.
MODEL = "demo" # Ollama: "qwen3:0.6b"
def chat(messages, stream=False, **params):
payload = {"model": MODEL, "messages": messages, "stream": stream, **params}
req = urllib.request.Request(
BASE_URL + "/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"},
)
resp = urllib.request.urlopen(req, timeout=60)
return iter_sse(resp) if stream else json.loads(resp.read())
def iter_sse(resp):
"""Разбирает поток Server-Sent Events: строки «data: {...}», конец — «data: [DONE]»."""
for raw in resp:
line = raw.decode("utf-8").strip()
if not line.startswith("data: "):
continue # пустые строки-разделители
data = line[len("data: "):]
if data == "[DONE]":
return
yield json.loads(data)
mock_server.start()
history = [
{"role": "system", "content": "Ты — краткий помощник. Отвечай по-русски."},
{"role": "user", "content": "Привет! Меня зовут Аня."},
]
r = chat(history, temperature=0.7, max_tokens=200)
answer = r["choices"][0]["message"]["content"]
print("ответ:", answer)
print("finish_reason:", r["choices"][0]["finish_reason"], "| usage:", r["usage"])
# API без состояния: чтобы модель «помнила» диалог, присылаем всю историю заново
history += [{"role": "assistant", "content": answer},
{"role": "user", "content": "Как меня зовут?"}]
print("стрим: ", end="")
for chunk in chat(history, stream=True, stream_options={"include_usage": True}):
if chunk["choices"]:
print(chunk["choices"][0]["delta"].get("content", ""), end="", flush=True)
elif "usage" in chunk:
print("\nusage из последнего чанка:", chunk["usage"])

Ожидаемый вывод:

ответ: Принято. В истории 2 сообщ., последнее — 23 симв.
finish_reason: stop | usage: {'prompt_tokens': 10, 'completion_tokens': 9, 'total_tokens': 19}
стрим: Принято. В истории 4 сообщ., последнее — 15 симв.
usage из последнего чанка: {'prompt_tokens': 22, 'completion_tokens': 9, 'total_tokens': 31}

Теперь установите Ollama, скачайте модель (ollama pull qwen3:0.6b), уберите строку mock_server.start() и поменяйте BASE_URL и MODEL. Тот же клиент заговорит с настоящей Qwen.

Задание 2 — посчитайте деньги. Скрипт ниже считает стоимость диалога без кэша и с кэшем при условных ценах. Он предполагает, что в кэш попадает весь вход предыдущего запроса. У реальных провайдеров кэш хранится блоками и со временем вытесняется, так что это оценка выгоды сверху.

P_MISS, P_HIT, P_OUT = 0.30, 0.03, 1.20 # долл. за 1 млн токенов — УСЛОВНЫЙ тариф
SYSTEM, USER, ANSWER = 800, 60, 250 # токенов: системный промпт, реплика, ответ
def dialog_cost(turns):
total_in = hit = prev_prompt = 0
for t in range(1, turns + 1):
prompt = SYSTEM + t * USER + (t - 1) * ANSWER # вся история + новая реплика
total_in += prompt
hit += prev_prompt # префикс прошлого запроса уже в кэше
prev_prompt = prompt
out = turns * ANSWER
miss = total_in - hit
no_cache = (total_in * P_MISS + out * P_OUT) / 1e6
cached = (miss * P_MISS + hit * P_HIT + out * P_OUT) / 1e6
return total_in, hit, no_cache, cached
for turns in (10, 20, 40):
total_in, hit, no_cache, cached = dialog_cost(turns)
print(f"{turns:>2} ходов: входных токенов {total_in:>7,} (из них в кэше {hit:>7,}) | "
f"без кэша ${no_cache:.4f}, с кэшем ${cached:.4f} (в {no_cache / cached:.1f} раза дешевле)")
10 ходов: входных токенов 22,550 (из них в кэше 18,900) | без кэша $0.0098, с кэшем $0.0047 (в 2.1 раза дешевле)
20 ходов: входных токенов 76,100 (из них в кэше 69,350) | без кэша $0.0288, с кэшем $0.0101 (в 2.9 раза дешевле)
40 ходов: входных токенов 276,200 (из них в кэше 263,250) | без кэша $0.0949, с кэшем $0.0238 (в 4.0 раза дешевле)

Чем длиннее диалог, тем большую долю входа покрывает кэш и тем больше выгода. Попробуйте поставить в начало системного промпта «текущее время» (то есть обнулить hit) и сравните.

Проверь себя6 вопросов
1 Пользователь в 5-м сообщении спрашивает «Как меня зовут?», а модель не знает, хотя имя было в 1-м сообщении. Самая вероятная причина?
2 Системный промпт — 1000 токенов, каждая реплика пользователя — 50, каждый ответ модели — 200. Сколько входных токенов (prompt_tokens) будет у третьего запроса диалога?
3 Ответ пришёл с finish_reason: "length". Что это значит?
4 Что верно про стриминг?Можно выбрать несколько ответов
5 Запрос: 10 000 входных токенов, из них 8 000 — попадание в кэш; 500 выходных. Цены за 1 млн: промах 0.30 долл., попадание 0.03 долл., выход 1.20 долл. Сколько долларов стоят 1000 таких запросов?
6 Какие ошибки имеет смысл автоматически повторять с экспоненциальной задержкой?Можно выбрать несколько ответов
  • Почти все LLM-провайдеры и локальные серверы говорят на формате Chat Completions: смена модели — это смена base_url, ключа и имени.
  • Роли system/user/assistant — разметка внутри одного контекстного окна; API не хранит состояние, историю отправляете вы.
  • Всегда проверяйте finish_reason и usage. Стриминг (SSE) не экономит токены, но сокращает ожидание.
  • Стоимость диалога растёт квадратично по числу ходов; кэш контекста работает по префиксу, поэтому статичное — в начало промпта, изменчивое — в конец.
  • Повторяйте 429 и 5xx с экспоненциальной задержкой; ключи держите вне репозитория.

Дальше: промпт-инжиниринг и context engineering — как писать инструкции, давать примеры, получать JSON и решать, что вообще класть в контекстное окно.