OpenAI-совместимые API
Как устроен запрос к LLM по HTTP, что значат роли, temperature и max_tokens, как работает стриминг и сколько стоит диалог — на примере DeepSeek, Qwen и локального Ollama.
Зачем это нужно
Заголовок раздела «Зачем это нужно»До этой ступени вы строили большая языковая модельбольшая языковая модель (БЯМ, 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"]
Роли: system, user, assistant
Заголовок раздела «Роли: system, user, assistant»Каждое сообщение в 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.
API не хранит состояние
Заголовок раздела «API не хранит состояние»Важнейшее свойство: сервер ничего не помнит между запросами. Если вы во втором сообщении спросите
«Как меня зовут?», не отправив первое сообщение, модель не узнает. «Память» чат-бота — это список 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 | сэмплировать только из «ядра» вероятности p | 0.8–0.95 |
max_tokens | потолок длины ответа (у новых моделей OpenAI поле называется max_completion_tokens) | под задачу; помните о finish_reason |
stop | строки, на которых остановиться | ["\n\n"], ["</answer>"] |
stream | отдавать ответ по частям | true для чатов |
response_format | JSON-режим | следующий урок |
Температуру и top-p вы уже реализовывали в уроке 4.1.3 о сэмплировании: логиты делятся на , затем softmax. При генерация почти жадная, но не обязательно детерминированная: на сервере ваш запрос обрабатывается в батче с чужими, а порядок операций с плавающей точкой может меняться (подробно — там же, в разделе «Почему „температура 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 osfrom 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 | любая строка | имя, с которым запущен сервер |
| DeepSeek | https://api.deepseek.com | DEEPSEEK_API_KEY | на октябрь 2026 — deepseek-flash (V4.1-Flash) и deepseek-v4-pro |
| Qwen (Alibaba Cloud, платформа Model Studio / Bailian) | https://dashscope.aliyuncs.com/compatible-mode/v1 | DASHSCOPE_API_KEY | qwen-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 не хранит состояние, поэтому на ходе вы заново отправляете системный промпт и все прошлые реплики. Пусть системный промпт — токенов, реплика пользователя — , ответ — . Тогда вход -го запроса и суммарный вход за ходов:
Что здесь написано: в -м запросе лежат системный промпт, реплик пользователя (включая новую) и прошлых ответов модели. При сложении по всем ходам реплики дают сумму , ответы — , и в обеих стоит .
Значит, рост квадратичный по числу ходов. При , , диалог из 20 ходов отправит на вход токенов, а из 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 млн токенов):
Надёжность: ошибки, повторы, ключи
Заголовок раздела «Надёжность: ошибки, повторы, ключи»Сеть и сервер иногда отказывают. Что делать с типичными кодами ответа:
| Код | Значение | Что делать |
|---|---|---|
| 400 | неверный запрос (лишнее поле, слишком длинный контекст) | исправить запрос; повтор не поможет |
| 401 | неверный ключ | проверить переменную окружения |
| 402 | закончились деньги на балансе (так у DeepSeek) | пополнить баланс |
| 429 | превышен лимит запросов | подождать и повторить |
| 500, 502, 503, 504 | сбой или перегрузка сервера | подождать и повторить |
Повторять нужно с экспоненциальной задержкой (1 с, 2 с, 4 с…) и случайным разбросом («джиттером»),
чтобы тысяча клиентов, получивших ошибку одновременно, не ударила по серверу снова в одну и ту же секунду.
Если сервер прислал заголовок Retry-After, он сам говорит, сколько секунд ждать (как такой заголовок отдаёт
ваш собственный сервис — в уроке 8.4.3). Обрыв связи и таймаут
тоже стоит повторить:
import randomimport timeimport 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 jsonimport threadingfrom 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 jsonimport urllib.request
import mock_server
BASE_URL = "http://127.0.0.1:8765/v1" # Ollama: http://localhost:11434/v1API_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) и сравните.
Проверь себя
Заголовок раздела «Проверь себя»- Почти все LLM-провайдеры и локальные серверы говорят на формате Chat Completions: смена модели — это
смена
base_url, ключа и имени. - Роли
system/user/assistant— разметка внутри одного контекстного окна; API не хранит состояние, историю отправляете вы. - Всегда проверяйте
finish_reasonиusage. Стриминг (SSE) не экономит токены, но сокращает ожидание. - Стоимость диалога растёт квадратично по числу ходов; кэш контекста работает по префиксу, поэтому статичное — в начало промпта, изменчивое — в конец.
- Повторяйте 429 и 5xx с экспоненциальной задержкой; ключи держите вне репозитория.
Дальше: промпт-инжиниринг и context engineering — как писать инструкции, давать примеры, получать JSON и решать, что вообще класть в контекстное окно.