Агенты сейчас обсуждают все подряд. Меняются фреймворки, появляются MCP-серверы, облака обещают «агента из коробки». Но в центре любой такой схемы по-прежнему одна и та же вещь — LLM: она читает контекст и формулирует следующий шаг. А между запросом к агенту и самой моделью — ещё несколько слоёв.
Сама модель всего лишь предсказывает вероятности следующего токена; снаружи нарастает луковица обёрток — о них и речь. Если собирать агента не из готового конструктора, понимание этой логики помогает быстрее найти источник проблемы: поломки могут быть не в модели, а на каком-то уровне рядом. Я пришёл к агентам из ML: для меня каждый новый слой появлялся поверх уже знакомого — поэтому дальше рассказываю от модели наружу. Идея статьи пришла после очередного совместного поиска бага с технической командой: захотелось собрать заметки в один обзор, чтобы в следующий раз было проще объяснять.
Как LLM принимает текст запроса и выдает текст ответа
Large Language Model — большая языковая модель. Современные чат-модели почти всегда опираются на архитектуру Transformer (статья Attention Is All You Need, Google, 2017) в варианте decoder-only: на каждом шаге сеть смотрит на уже имеющуюся последовательность и оценивает, какой элемент должен быть следующим.
На этом уровне для модели нет JSON и нет «сообщений». Нет даже текста как строк: есть токены — целочисленные идентификаторы из словаря токенов конкретной модели. Словарь фиксируют на раннем этапе обучения; у каждой модели он свой. Менять словарь у уже обученной модели можно только ценой дообучения и пересборки токенизатора — для этой статьи это за скобками.
Чтобы модель вообще увидела ваш запрос, текст сначала проходит через токенизатор: строка → последовательность id (просто целые числа) по словарю этой модели. На вход модели попадают эти числа. Одно применение модели (forward) по текущей последовательности даёт распределение вероятностей следующего id из того же словаря. Параметр temperature влияет на то, насколько «плоско» или «остро» это распределение перед выбором. Из распределения выбирают один следующий token id, дописывают его к последовательности и снова применяют модель — и так до спецтокена конца (EOS) или лимита длины. Так набирается полная последовательность новых token id ответа. Дальше применяется детокенизатор, который уже из предсказанных токенов собирает текст ответа:

Без оптимизаций такой цикл дорогой: на каждом шаге наивно снова прогонять всю уже накопленную последовательность. Поэтому современные runtime почти обязательно держат KV-кэш. В attention у позиций есть проекции Q, K, V; ключи и значения уже виденных токенов между шагами decode не меняются — их сохраняют. На шаге считают в основном новый токен, а к прошлым обращаются из кэша. Главное назначение — ускорить генерацию одного ответа; некоторые серверы ещё умеют переиспользовать KV общего префикса между ходами диалога — это уже отдельная опция, не сам факт «есть KV-кэш».
Если модель в облаке, обычно платят за токены на входе и на выходе, а не за размер текста. Считают при этом токены так, как их видит модель после chat template — не «красоту» JSON в API. Один и тот же смысл на русском и английском часто даёт разное число токенов: у моделей с преимущественно английским словарём кириллица дробится мельче — больше токенов на слово. На локальной модели даже без оплаты за токены это всё равно съедает лимит контекста и скорость. Иногда длинный промпт выгоднее написать по-английски и получить экономию в 2–3 раза по числу токенов.
Все эти шаги — токенизация, сэмплинг, KV-кэш, цикл с остановкой по EOS — обычно не пишут вручную в каждом сервисе: готовые библиотеки и runtime берут логику на себя и дают границу текст на вход → текст на выход. Но тут же возникает другая проблема: этот входной текст разные модели ждут в разном формате.
Разные модели ждут разный текст
Изначально LLM предобучают предсказывать следующий токен, но дальше идут этапы дообучения: диалоги, рассуждения (reasoning), вызовы внешних систем и т.д. Для чата модель дообучают на диалогах с ролями; чтобы она правильно увидела system / user / assistant, последовательность оборачивают в служебную разметку. Разметка разная у разных семейств: один и тот же смысл «system + вопрос пользователя» превращается в разные строки.
Вот, например, ChatML в духе Qwen (и ряда OpenAI-подобных шаблонов). Сначала — полный диалог, как мы его читаем глазами: system, вопрос и уже готовый ответ ассистента.
<|im_start|>system Ты помощник инженера. Отвечай кратко.<|im_end|> <|im_start|>user Что означает cron `0 9 * * 1-5`?<|im_end|> <|im_start|>assistant Каждый будний день в 09:00.<|im_end|>
На этапе предсказания модель Runtime собирает prompt — всё до точки, с которой ассистент только начинает говорить — и просит модель достроить продолжение токен за токеном, пока не встретится конец последовательности (или лимит длины).
Ввод (prompt):
<|im_start|>system Ты помощник инженера. Отвечай кратко.<|im_end|> <|im_start|>user Что означает cron `0 9 * * 1-5`?<|im_end|> <|im_start|>assistant
Дальше ожидается что модель сможет ответить следующее:
Каждый будний день в 09:00.<|im_end|>
Наглядно то же разделение цветом (синий — ввод, оранжевый — предсказание):

Именно такой исходный текстовый запрос токенизатор режет в токены, и именно по ней считают prompt_tokens / лимит контекста. Дальше вокруг этого может быть любой другой JSON формат, но биллинг и длина контекста относятся не к «красоте JSON», а к тому тексту (и спецтокенам), который получится после chat template. Две модели с разными правилами внутренней разметки на один и тот же JSON-запрос могут съесть разное число токенов.
У шаблона бывают и дополнительные блоки. Если модель поддерживает reasoning (часть семейств Qwen-Thinking и похожие), то в prompt вшивается блок внутренних рассуждений: после <|im_start|>assistant сразу дописывают открывающий <think>. Модель тогда не стартует «с пустого места», а продолжает уже внутри thinking. В completion обычно идут сами рассуждения, закрывающий </think> и только потом финальный ответ:

Thinking / reasoning на этом уровне — тоже текст (токены). Модель в одном авторегрессивном прогоне сначала генерирует токены «черновика», потом токены ответа. Это просто другая разметка в той же completion-строке. Разделить thinking и финальный ответ, спрятать мысли от пользователя или оставить их в сыром виде — уже задача того, кто обрабатывает выход модели дальше; сама сеть отдаёт одну последовательность токенов.
Отсюда типичная проблема на небольших локальных reasoning-моделях. Лимит длины генерации (max_tokens и аналоги) обычно общий на весь выход: и на блок внутри <think>…</think>, и на финальный ответ. Модель может долго «думать», потратить весь бюджет на thinking (иногда ещё и зациклиться в повторах) и оборваться по длине, так и не дописав текст после </think>. Снаружи это выглядит странно: генерация шла долго, токенов вышло много, а “формального” ответа после thinking нет или он обрезан. Что делать: смотреть ответ целиком; поднять общий лимит с запасом под thinking; для простых задач отключить reasoning (instruct-вариант или флаг вроде enable_thinking=false у шаблона); либо, если runtime умеет, задать отдельный бюджет на thinking — это часто вообще просто функционал runtime, а не модели, по исчерпании лимита на reasoning runtime жестко может прервать рассуждения, дописать </think> и дать модели место на финальный ответ.
Для контраста — абсолютно другая разметка того же смысла: Llama 3 (упрощённо). Снова синий — prompt, оранжевый — completion:

Запоминать разметку каждой модели вручную неприятно: один и тот же диалог для Qwen, Llama и reasoning-варианта выглядит по-разному. Нужен единый способ описать «system / user / assistant», а превращение в текст конкретной модели — отдать правилу при этой модели.
Единый JSON диалога и Jinja chat template
Так и сложилось. Диалог описывают структурированно — список сообщений с ролями и текстом. Массово эту форму закрепил Chat Completions OpenAI (начало 2023; до него чаще была одна сырая строка prompt). Снаружи для приложения это привычный JSON вроде:
{ "messages": [ {"role": "system", "content": "Ты помощник инженера. Отвечай кратко."}, {"role": "user", "content": "Что означает cron `0 9 * * 1-5`?"} ] }
Модель по-прежнему ест не этот JSON, а prompt-строку со своими спецтокенами — ту, что мы разбирали выше. Связка между ними — chat template: почти всегда шаблон на Jinja, лежащий рядом с моделью (в конфиге токенизатора, в metadata GGUF и т.п.). На вход — messages[], на выход — текст ровно в разметке этой модели (ChatML, Llama 3, с <think> или без).
Давайте посмотрим простой шаблон на простой модели в уже знакомом формате ChatML (с im_start). Например, OpenHermes-2.5-Mistral-7B: отдельного .jinja в репозитории нет, поле chat_template лежит внутри tokenizer_config.json. По сути это короткий цикл по сообщениям (\n в кавычках — перевод строки в итоговой prompt-строке):
{% for message in messages %} {{ '<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n' }} {% endfor %} {% if add_generation_prompt %} {{ '<|im_start|>assistant\n' }} {% endif %}
Применим его к JSON выше и попросим шаблон дописать открытие хода ассистента (нижний if в Jinja) — получим ту же ChatML-строку, что раньше разбирали как prompt:
<|im_start|>system Ты помощник инженера. Отвечай кратко.<|im_end|> <|im_start|>user Что означает cron `0 9 * * 1-5`?<|im_end|> <|im_start|>assistant
У современных моделей Jinja уже заметно больше: reasoning, картинки и другие модальности, разметка tools — отдельные ветки и параметры. Шаблон может занимать страницы и часто выносится в отдельный файл, но идея та же: messages[] → текст этой модели. У Qwen/Qwen3.5-9B это, например, chat_template.jinja примерно на 7–8 КБ.
Ещё нюанс, который легко упустить, если с LLM работали только через высокоуровневый JSON API. В учебных примерах обычно в диалоге копится всё, что сказал пользователь, и всё, что ответила модель. На практике часть истории может не дойти до модели — её выкинет chat template (тот самый Jinja). У Qwen, например, из прошлых ходов часто убирают блоки reasoning (<think>…</think>), оставляя видимый ответ; у других моделей своя политика (только последний thinking, обрезка с какой-то глубины и т.п.). Коллеги как-то искали «баг»: при новых репликах в рамках одного диалога число входных токенов иногда падало, а не росло. Много времени ушло на логи — а ответ оказался простым: на первых шагах модель генерировала огромные рассуждения, позже Jinja их в prompt не подставил, и на следующих ходах в модель уходило меньше данных, чем на первых.
Обратный путь — из текста ответа снова собрать JSON — уже проще: фиксированная обёртка, куда кладут разобранный ответ модели (поля вроде сообщения ассистента и часто ещё полезную статистику от runtime: сколько токенов на входе и на выходе и т.п.).
Обновим схему цикла: между приложением и пайплайном токенов появляется слой JSON ↔ текст (chat template туда, упаковка ответа обратно).

В большинстве случаев руками это не вызывают: библиотеки и runtime делают и шаблон, и упаковку ответа одним высокоуровневым проходом. Сменить модель — подхватится другой шаблон; JSON с ролями снаружи можно оставить тем же.
Историческая ремарка на потом: когда тот же JSON начнут гонять по HTTP, его часто назовут «OpenAI-compatible» — обычно имеется в виду как раз эта форма messages[], а не «весь продукт OpenAI». К сети и эндпоинтам вернёмся отдельно.
Теорию пути — токены, цикл, разметка, messages[], Jinja — мы разобрали. Дальше посмотрим, как это обычно лежит на диске: веса, токенизатор, chat template и спецтокены; затем — привычная упаковка в GGUF и runtime вроде llama.cpp (и форков), чтобы не писать весь пайплайн самому.
Как модель лежит на диске: SafeTensors и соседние JSON
Открытые веса на Hugging Face обычно раздают папкой:
веса в SafeTensors (
.safetensors, иногда шарды и*.index.json);config.json— архитектура и гиперпараметры;файлы токенизатора (
tokenizer.json, vocab/merges и т.п.);часто шаблон чата (Jinja
chat_templateв tokenizer config / отдельном файле);спецтокены вроде EOS.
Именно этот набор позволяет закрыть путь выше: взять структурированные messages[] → применить Jinja chat template → получить prompt-строку для этой модели → токенизировать словарём модели → прогнать inference с KV-кэшем → detokenize → при необходимости разобрать сырой выход (например, отделить thinking) и снова представить ответ как сообщение ассистента.
Например, Qwen/Qwen3.5-9B — на странице Files как раз такая папка: шарды SafeTensors, индекс, токенизатор и отдельный chat_template.jinja:

Пример раскладки (схематично, по скрину выше):
Qwen3.5-9B/ config.json ← архитектура и гиперпараметры модели chat_template.jinja ← Jinja: messages[] → prompt-строка model-00001-of-00004.safetensors ← шарды весов (SafeTensors) model-00002-of-00004.safetensors model-00003-of-00004.safetensors model-00004-of-00004.safetensors model.safetensors.index.json ← карта: какой тензор в каком шарде tokenizer.json ← токенизатор (основной файл) tokenizer_config.json ← настройки токенизатора, спецтокены vocab.json ← словарь токенов merges.txt ← правила склейки BPE preprocessor_config.json ← препроцессинг картинок (у этой модели multimodal) video_preprocessor_config.json ← препроцессинг видео
Полные веса в FP16/BF16 для ноутбука часто тяжелы. Поэтому для offline-запуска почти всегда делают квантизацию (меньше бит на веса → меньше файл и память, небольшое ухудшение качества) и упаковывают в формат, удобный конкретному движку.
GGUF: квант и всё нужное в одном (или нескольких) файлах
Сейчас один из самых популярных форматов для локального inference — GGUF. Идея: взять веса (часто уже квантованные, например Q4_K_M), словарь токенизатора, chat template и метаданные — и сложить в компактный контейнер. Иногда это один .gguf, иногда несколько шардов, но для практики «скачал файл — скормил рантайму» этого достаточно.
Пример того же семейства в GGUF — lmstudio-community/Qwen3.5-9B-GGUF: вместо россыпи SafeTensors — готовые квантованные .gguf (часто по одному файлу на уровень кванта):

Qwen3.5-9B-GGUF/ Qwen3.5-9B-Q4_K_M.gguf ← квант Q4_K_M: баланс размер / качество (часто берут для ноутбука) Qwen3.5-9B-Q6_K.gguf ← квант Q6_K: тяжелее и обычно чуть точнее Qwen3.5-9B-Q8_0.gguf ← квант Q8_0: ещё больше и ближе к полным весам mmproj-Qwen3.5-9B-BF16.gguf ← multimodal projector (картинки/видео); к текстовому GGUF подключают отдельно
В metadata такого .gguf как раз лежит то, что раньше было размазано по JSON рядом с SafeTensors: как токенизировать и как из messages[] собрать prompt. Движок читает файл — и знает, какой текст ждать эта модель.
Кроме GGUF есть и другие локальные форматы — например MLX под Apple Silicon: раскладка ближе к папке SafeTensors, чем к одному контейнеру, и свой стек библиотек. Логика та же: удобно хранить (часто квантованные) веса и гонять их своим runtime. В этой статье дальше опираемся на GGUF + llama.cpp как на самый распространённый связный пример; в MLX и аналоги углубляться не будем.
llama.cpp: читать GGUF и не писать цикл вручную
llama.cpp — в первую очередь C/C++ библиотека inference (libllama): загрузка GGUF, токены, decode, KV-кэш, размещение слоёв на CPU / GPU / Metal (Apple Silicon). В том же мире вокруг неё:
Компонент |
Зачем |
|---|---|
C++ / |
Встроить inference прямо в своё приложение |
|
Быстро прогнать модель из терминала, без своего кода и без HTTP |
|
Тот же движок из Python in-process |
|
HTTP-фасад: другой процесс шлёт JSON, как на облачный API |
UI вроде LM Studio / Ollama |
Часто тот же класс идей: файл модели + совместимый API, чтобы быть универсальным для разных моделей |

Ниже — один и тот же смысл запроса через Python, CLI и HTTP.
Python-обёртка
from llama_cpp import Llama # Python-обёртка над libllama llm = Llama( model_path="Qwen3-8B-Q4_K_M.gguf", # путь к GGUF (веса + template + токенизатор) n_ctx=8192, # размер контекста в токенах (prompt + ответ) n_gpu_layers=-1, # сколько слоёв на GPU/Metal; -1 = все, если сборка умеет ) out = llm.create_chat_completion( messages=[ # тот же messages[], что и в универсальном JSON {"role": "system", "content": "Ты помощник инженера. Отвечай кратко."}, {"role": "user", "content": "Что означает cron `0 9 * * 1-5`?"}, ], temperature=0.2, # насколько «случайно» сэмплировать следующий токен max_tokens=512, # лимит длины completion (включая thinking, если он есть) ) print(out["choices"][0]["message"]["content"]) # текст ответа ассистента
Снаружи — привычный JSON (Chat Completions). Внутри — template из GGUF, токены, цикл, KV-кэш.
CLI
Для проверки модели без приложения достаточно CLI (флаги зависят от версии llama-cli; смысл тот же — файл + prompt или простой чат):
llama-cli \ -m Qwen3-8B-Q4_K_M.gguf \ -sys "Ты помощник инженера. Отвечай кратко." \ -p 'Что означает cron `0 9 * * 1-5`?' \ -n 256
Так удобно убедиться, что квант, шаблон и спецтокены вообще живые, до интеграции в бота.
HTTP (llama-server)
Поднимаете сервер и бьёте в OpenAI-совместимый endpoint — тем же JSON, что выше:
llama-server -m Qwen3-8B-Q4_K_M.gguf --port 8080
curl http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "local", "messages": [ {"role": "system", "content": "Ты помощник инженера. Отвечай кратко."}, {"role": "user", "content": "Что означает cron `0 9 * * 1-5`?"} ] }'
Приложение на любом языке теперь говорит с локальной моделью так же, как с облаком: сменили base_url — сменили backend. Облачные LLM снаружи тоже HTTP; вы просто не видите GGUF и KV-кэш.
Схема пути целиком — тот же цикл, что с JSON на границе, плюс явно: приложение уже ходит по HTTP с JSON-запросами (локальный llama-server или облако — снаружи один контракт). Между приложением и JSON↔текст на схеме — слой веб-сервера; идея у всех одна, меняется только удобство и масштаб.

Помимо llama-server есть и другие: часть стоит на llama.cpp (или форке), часть гоняет похожую логику своим движком. Из популярных:
LM Studio — приложение с UI: можно легко управлять списком локальных моделей, запускать с ними интерактивный чат, смотреть логи. Для GGUF обычно опирается на llama.cpp; на Apple Silicon умеет и MLX. Тот же UI умеет поднять и остановить локальный OpenAI/Antropic совместимый HTTP-сервер — снаружи для вашего кода это снова
base_url.Ollama — ближе к «демону в фоне»: модель скачали / указали — и уже есть HTTP API в духе Chat Completions, часто без отдельной возни с
llama-server.vLLM — уже про нагрузку и прод: высокий throughput, батчинг запросов, обычно веса в духе Hugging Face / SafeTensors, а не «один GGUF на ноутбук». Снаружи тоже часто отдают OpenAI-совместимый HTTP.
Что-то удобно поднять на своём ноутбуке, что-то рассчитано на ферму GPU; снаружи привычный JSON-контракт, и он не обязан совпадать с тем текстом, который в итоге уйдёт в модель.
От LLM к агенту: сначала tools, потом цикл
До сих пор у нас был простой чат: вопрос → ответ → следующий вопрос в продолжение диалога. Ответ опирается не только на последнюю реплику, но и на всю историю (контекст). Это всё ещё обычный чат с LLM: до агента часто не хватает одного шага — и он не на стороне модели, а в runtime вокруг неё. Модель по-прежнему отвечает текстом; меняется только то, что в этом тексте появляется дополнительный формат, которому её учили.
Если модель должна что-то делать во внешнем мире, ей нужно перечислить, что именно можно делать. Тогда в ответе она явно попросит нужное действие — когда сочтёт это нужным. Сама она по-прежнему только пишет текст; исполнять запрос или нет решаете вы, своим кодом или чужой обёрткой.
На пальцах договорённость с моделью такая: в начале диалога говорим ей, что когда нужно действие А — напиши в ответе «действие А»; когда Б — «действие Б». Модель как принимала запрос и отвечала текстом, так и продолжает; меняется только формат. Для этого у неё должна быть способность вызывать инструменты (tool use / function calling) — этому её учили при обучении или дообучении.
Реальный пример. Обычная Python-функция: у модели нет «часов», у runtime — есть:
from datetime import datetime, timezone def get_current_time() -> str: """Текущее время в UTC, ISO-8601.""" return datetime.now(timezone.utc).isoformat()
В запросе к модели (Chat Completions и аналоги) мы не «вшиваем» код функции в веса — мы описываем tool в JSON рядом с messages: имя, зачем нужен, какие аргументы (JSON Schema). Для нашего случая аргументов нет:
Запрос к модели, которая при необходимости может попросить runtime вызвать get_current_time:
{ "model": "local", "messages": [ {"role": "user", "content": "Который сейчас час по UTC?"} ], "tools": [ { "type": "function", "function": { "name": "get_current_time", "description": "Возвращает текущее время в UTC в формате ISO-8601.", "parameters": { "type": "object", "properties": {}, "additionalProperties": false } } } ] }
Модель может ответить не готовой фразой человеку, а запросом на вызов — в том же JSON-контракте это выглядит примерно так (tool_calls):
{ "role": "assistant", "content": null, "tool_calls": [ { "id": "call_1", "type": "function", "function": { "name": "get_current_time", "arguments": "{}" } } ] }
Runtime между пользователем и LLM видит, что это не ответ человеку, а tool_calls: имя get_current_time и аргументы (если есть). Он сам вызывает get_current_time(), получает строку вроде 2026-07-20T11:32:00+00:00 и кладёт результат в историю сообщением с role=tool. Следующий ход модели уже видит факт времени и может ответить обычным текстом — и именно этот ответ runtime отдаёт пользователю. Tools при этом не «включаются на каждый чих»: спросите 1+1 — модель обычно ответит сама, без get_current_time, потому что в описании сказано, зачем tool нужен. А если спросить, какой завтра день, она может сначала запросить текущее время, прибавить день уже «в уме» и только потом отдать финальный ответ человеку.
В качестве tools можно объявить модели что угодно: узнать время, поискать в интернете, создать заявку в CRM или хоть сценарий из «Терминатора». Главное — чтобы на стороне runtime была реализация. LLM всё равно, как это устроено внутри: сказали, что так можно — значит, когда она попросит, кто-то снаружи это выполнит.
Один класс tools закрывает слабости модели на задачах, где детерминированный алгоритм прост, а работа с текстом легко ошибается: арифметика, конвертация дат и таймзон, парсинг cron, нормализация единиц. Для кода это пара строк; для LLM — «примерно» и галлюцинации. Чем слабее модель, тем чаще нужны именно такие tools: сильная ещё может «вытянуть» устно, маленькая локальная — уже нет.
Но большинство tools — доступ к внешнему миру, куда модель сама никак не может обратиться: текущее время, поиск по базе, Jira, браузер, HTTP к вашему API. Без tool здесь не обойтись никакой размерностью весов.
При этом модель только просит вызов: пишет имя tool и аргументы. Сама никуда не ходит — весам некуда открыть сокет и нечем дернуть Jira. Вокруг LLM нужна обёртка (свой runtime или фреймворк): объявить доступные tools, послать запрос, при tool_calls исполнить их (или проксировать), дописать результат в историю и снова вызвать модель; без вызова — отдать ответ пользователю.
Когда цикл замыкается в «подумал → tool → результат → снова подумал», это уже не одна генерация, а базовый агент. Классическое имя схемы — ReAct (Reason + Act).

Писать каждый tool с нуля в каждом проекте далеко не всегда нужно: многие уже готовы, ими можно пользоваться. Чтобы проще переиспользовать tools, публиковать их, добавлять авторизацию и т.п., есть MCP (Model Context Protocol). Это способ опубликовать набор tools на сервере в понятном протоколе: список инструментов, схемы аргументов, вызов. Модели в конечном счёте важны сами tools; MCP — упаковка и транспорт, удобный для подключения к разным клиентам и облакам.

Осталось определиться, где это запускать
Цикл можно крутить у себя (свой код или LangChain / LangGraph и аналоги) либо отдать на сторону провайдера, у которого есть фреймворк для AI-агентов: в консоли выбираете модель, пишете системный prompt, подключаете MCP — а снаружи остаётся только точка входа, которую дергаете из своего кода (хуки Telegram, CRM, любой ваш сервис). Шлёте сообщения агенту, забираете его ответ — для него модель уже могла дернуть нужные tools через MCP. Это базовый AI-агент; дальше — ветки, цепочки, разные модели на разных шагах.
У такого агента часто свой API: в консоли настроили модель и tools, а снаружи вызываете уже не «голую» LLM, а агента по API провайдера. На рынке это делают по-разному — ниже два подхода не как реклама вендоров, а как разные модели размещения цикла.
Подход 1. Платформа поднимает runtime агента у себя (часто контейнеры), tools/MCP «пришиты» к агенту, снаружи — свой контракт вызова. Пример — Evolution AI Agents у Cloud.ru.
Базовый сценарий — простой агент (создать агента): в UI — модель, системный prompt, один или несколько MCP. Код цикла писать не нужно — платформа поднимает runtime на Docker по умолчанию (контейнеры, при желании serverless). Tools к агенту уже «пришиты»; после запуска появляется URL вида https://…-agent.ai-agent.inference.cloud.ru.
Из своего кода (бот, CRM, сервис) ходите в этот URL по A2A — JSON-RPC 2.0, метод message/send (описание A2A). Шлёте сообщение пользователя и забираете готовый ответ; ReAct крутится на стороне Cloud.ru. Токен — Authorization: Bearer … (аутентификация); нужна роль ai-agents.agents.invoker. Сниппеты — кнопка «Использовать» в карточке агента; примеры клиентов — evo-ai-agents-labs.
Пример запроса к такому агенту:
{ "jsonrpc": "2.0", "id": "1", "method": "message/send", "params": { "message": { "messageId": "550e8400-e29b-41d4-a716-446655440000", "role": "user", "parts": [ {"kind": "text", "text": "Какая ставка по вкладу на 12 месяцев?"} ] } } }
Ответ — JSON-RPC с result: обычно задача (kind: "task") со статусом и текстом в artifacts[].parts (иногда сразу в message.parts):
{ "jsonrpc": "2.0", "id": "1", "result": { "kind": "task", "id": "task-…", "status": {"state": "completed"}, "artifacts": [ { "artifactId": "…", "parts": [ {"kind": "text", "text": "По вкладу на 12 месяцев ставка …"} ] } ] } }
На схеме слоёв это выглядит так: приложение ходит уже не в «голый» HTTP модели, а в HTTP агента; агент внутри ходит в HTTP модели (и при необходимости крутит цикл tools/MCP), дальше — знакомые JSON↔текст и токены:

Сложнее того же простого агента: свой Docker-образ (LangChain, Google ADK и т.п.), несколько MCP, связанные агенты / A2A между ними, агентные системы, триггеры (например Telegram без своего HTTP-клиента). Идея та же — runtime вокруг модели; меняется только то, сколько оркестрации выносите в свой код. Важно по деньгам: кроме вызовов модели на Cloud.ru отдельно тарифицируются вычислительные ресурсы агента и MCP (vCPU/RAM контейнеров, пока экземпляры живы; при min = 0 в простое не платите) — см. тарификацию AI Agents.
Подход 2. Цикл и часть tools живут ближе к managed API модели (часто Responses): вы по-прежнему бьёте в HTTP, но server-side tools / remote MCP может отработать сервис, без отдельного «агентного» Docker у вас. Так, например, устроен путь OpenAI и Yandex Cloud (AI Studio).
Для новых интеграций OpenAI продвигает Responses API; его же подхватывают часть провайдеров и некоторые локальные серверы. Tools объявляете в запросе, но hosted / built-in сервис может исполнить сам (кусок ReAct + следы вызовов). Свой function по-прежнему исполняете вы. Контракт ещё умеет ссылаться на id предыдущего ответа вместо полной истории по HTTP — трафик меньше, свою копию диалога часто всё равно хранят.
Пример у Yandex (AI Studio): встроенный web_search и публичный MCP (web_tool, mcp_always_approve):
client = openai.OpenAI( api_key=YANDEX_CLOUD_API_KEY, project=YANDEX_CLOUD_FOLDER, base_url="https://ai.api.cloud.yandex.net/v1", ) response = client.responses.create( model=f"gpt://{YANDEX_CLOUD_FOLDER}/{YANDEX_CLOUD_MODEL}", tools=[ { "type": "web_search", # встроенный tool на стороне сервиса }, { "type": "mcp", # публичный MCP; цикл вызовов тоже на backend "server_label": "kontur", "server_description": "Возвращает информацию по ИНН", "server_url": "...", # URL MCP-сервера "require_approval": "never", }, ], input="Чей ИНН … и что о компании пишут в свежих новостях?", ) print(response.output_text)
Про MCP Hub — в документации AI Studio; как Responses и MCP складываются в агентный цикл — в обзоре Yandex Cloud.
Идея на пальцах: в tools указываете встроенные возможности (из документации) и/или MCP — и простой ReAct-цикл для этой части переезжает на сервер провайдера. Вы получаете уже не «голую LLM», а базового ReAct-агента. Нужен новый навык — оформите tool, положите в MCP, подключите к модели; агент начинает им пользоваться без переписывания всей оркестрации.
Из интересного — в Responses можно объявить и серверные (built-in / MCP), и свои tools (type: function и т.п.): серверные крутит провайдер, свои по-прежнему обрабатываете вы в runtime. Насколько свободно их сочетают в одном запросе — зависит от конкретной реализации (OpenAI-совместимость ≠ одинаковые гарантии у всех). Практически: то, что готовы отдать провайдеру (встроенный RAG / File Search, Web Search, remote MCP…), оставляете ему; остальное — своим tool’ом или своим MCP. За встроенные tools часто платите отдельно (или через токены инструментов) — смотрите прайс конкретного tool.
Дальше уже другие этажи: субагенты, фиксированные или динамические workflow, общие ресурсы, human-in-the-loop. Но ReAct-агент с tools — база. Более сложные схемы обычно надстраиваются поверх: граф, политики, память, общие сервисы — а в узлах по-прежнему «модель + инструменты».
Что запомнить
Всё, о чём шла речь, — одна цепочка с разными обёртками. На дне — токены и цикл «применить модель → выбрать следующий id токена → снова», плюс KV-кэш, чтобы не пересчитывать прошлое зря. Снаружи модели нет JSON и нет «сообщений»: есть id токенов из её словаря. Текст, за который платите, и который упирается в контекст, — тот, что получится после chat template, а не красивый messages[] в curl.
Дальше слои наслаиваются, но ни один не отменяет нижний:
Разметка — разные семейства ждут разный текст (ChatML, Llama…); thinking — те же токены только другой кусок строки.
Контракт — Chat Completions и JSON удобны людям и клиентам; Jinja (или аналог) и parser связывают их с конкретной моделью.
Диск и runtime — SafeTensors + конфиги или GGUF; llama.cpp и обёртки прячут tokenize/decode и отдают Python / CLI / HTTP. Сменили
base_url— сменили backend, путь данных тот же.Агент начинается не с «умной модели», а с цикла вокруг неё: объявили tools → модель попросила вызов → runtime исполнил → результат в историю → снова. MCP — упаковка tools, не новый вид интеллекта.
-
Где крутится цикл — вариантов несколько:
у вас в коде;
в контейнерах, которые поднимает платформа (как простой агент Cloud.ru: Docker по умолчанию или свой образ; снаружи часто API в духе A2A);
в managed runtime провайдера, до которого вы почти не дотрагиваетесь, через Responses API (OpenAI / Yandex Cloud).
Меняются интерфейс, гибкость и то, чем надо управлять самим; физика токенов — нет.