Один час без ИИ-агентов: путь от токенов до MCP
Перенесёмся в 2023 год: готовых AI-агентов ещё не существует, есть лишь обычная LLM, способная продолжать текст. Что нужно ей добавить, чтобы она научилась читать файлы, искать информацию, запускать команды, править код и самостоятельно определять, каким должен быть следующий шаг?
С этого вопроса я открыл встречу, на которую записались двадцать человек из моей и смежных команд. Пришли семнадцать. В аудитории сидели бэкендеры, фронтендеры, тестировщики, аналитики и менеджеры, причём опыт работы с LLM у всех был очень разный. Одни к тому времени уже вовсю обсуждали модели и сравнивали их характеристики, другие знали о LLM ровно то, что пишут в новостях.
Задачу я поставил себе непростую: за час разобрать устройство агента снизу вверх. Начать с языковой модели, пройти через tools, agent loop, RAG, MCP и skills, а затем ещё и перейти к практике, чтобы увидеть, как агент справляется с первыми рабочими задачами.
Проще всего было бы открыть OpenCode, продемонстрировать готового агента и запустить пару команд. Выглядело бы эффектно, но коллеги увидели бы всего лишь очередной инструмент. Поэтому я предложил вернуться в самое начало, оставить только обычную LLM и постепенно добавлять всё, чего ей недостаёт.
В финале мы запустили собранную конструкцию вживую. И тут многое пошло не по плану.
Содержание
Почему я вообще решил провести эту встречу
Мне хотелось, чтобы после встречи коллеги понимали не только, как запустить агента, но и почему он устроен именно так, где от него стоит ждать пользы, а где он почти наверняка ошибётся.
Впрочем, предложи мне провести такую встречу несколькими месяцами раньше, я бы, скорее всего, отказался.
За развитием LLM я наблюдал давно. Когда об агентах ещё говорили как о чём-то, что вот-вот появится, я уже пытался понять, как собрать такую систему самостоятельно: экспериментировал с tool calling, RAG и другими подходами, которые тогда были в новинку. Позже появились готовые агентские инструменты, и года полтора назад я начал применять их в личных задачах. Они неплохо справлялись с кодом, поиском информации и ресёрчем. Постепенно я научился различать, что можно доверить агенту, а что быстрее сделать самому.
С рабочими задачами всё складывалось сложнее.
Внутри компании LLM-инструменты только набирали обороты: разворачивались первые модели, разные команды пробовали агентские движки и писали собственные решения. Я время от времени возвращался к этой теме, но долго не находил причин использовать агентов каждый день.
Главной проблемой был контекст. Его могло не хватить даже на один крупный файл проекта, не говоря уже о нескольких связанных файлах и документации. Если файл не помещался целиком, агент просто не мог с ним работать. Экспериментировать со всем этим было увлекательно, а вот рекомендовать коллегам — рановато.
Затем ситуация начала меняться. Из нескольких агентских инструментов постепенно выделился OpenCode. В Ozon появлялись всё более сильные модели, коллеги писали MCP-серверы под рабочие сценарии, а вокруг этого начали собираться инструкции и документация.
В результате сложилось окружение, где агент уже мог работать с файлами проекта, искать нужные сведения и выполнять небольшие задачи. Не полностью самостоятельно и не безошибочно: его по-прежнему приходилось направлять, а результат — обязательно проверять. Но пользоваться им стало заметно удобнее.
Я стал понемногу подключать агентов к рабочим задачам. Около месяца перебирал разные сценарии и смотрел, где они действительно выручают, а где на объяснения и правки уходит больше времени, чем на саму работу. Полезных сценариев становилось всё больше, и в какой-то момент я понял, что агентов можно не только тестировать самому, но и показывать коллегам.
Тогда и родилась идея этой встречи.
Теория LLM. Так кто такие эти ваши агенты?
Первый слайд был почти пустым: на нём значился только вопрос «Кто такие агенты?». Ответ я намеренно не писал — мне хотелось услышать, что скажут коллеги.
Секунд десять стояла тишина, а потом ответы посыпались один за другим.
-
«LLM, которая выполняет конкретную задачу». Неплохо, но чем это отличается от обычного чата в браузере? Там тоже можно поставить конкретную задачу.
-
«Умеет рассуждать». Это тоже не отличие: современные модели в режиме размышления рассуждают и без всякой агентской обвязки.
-
«Видит контекст целиком». Уже ближе. Агент и правда способен держать перед глазами картину всего проекта.
-
«Может сам что-то делать на компьютере». А вот здесь мы почти добрались до нужного ответа.
Если свести ответы воедино, получится рабочее определение. Агент — это система, которая получает информацию, принимает решение на основе узнанного и действует с помощью доступных ей инструментов. Обычная программа так не умеет: она выполняет заранее заданную последовательность операций.
Тут обычно возражают, и вполне справедливо: скрипт с парой условий тоже принимает решения. Но дело не в «уме» агента. Разница в том, что заранее не определено, какие действия окажутся допустимыми. Скрипт нельзя написать под задачу, формулировки которой вы ещё не видели. Агент же соберёт нужную последовательность вызовов на лету — и по той же причине может собрать её неверно. Именно об этом пойдёт речь во второй половине статьи.
Из определения вытекает любопытный вывод: привычный чат с нейросетью в браузере — уже отчасти агентская система. У неё есть поиск, и она сама решает, стоит ли обращаться к интернету. А вот сама по себе языковая модель агентом точно не является.
С неё и начнём.
Ядро: языковая модель
На втором слайде стоял следующий вопрос: что находится внутри агента и с чего начинать его разработку? Коллеги ответили быстро и верно: с LLM.
На этом простые ответы закончились. Языковая модель умеет делать одну-единственную вещь, и понимать её нужно очень чётко, иначе поведение агента будет постоянно вас удивлять.
Модель оперирует токенами. Её единственная задача — взглянуть на уже имеющуюся последовательность и оценить, насколько вероятен каждый токен словаря в роли следующего. Затем из этого распределения выбирается один токен. Обычно не самый вероятный, а случайный с учётом весов, и за это отвечают настройки вроде температуры. Поэтому один и тот же запрос, отправленный дважды, может дать разные ответы: недетерминированность заложена прямо здесь, на уровне выбора одного токена.
Токен — это короткий фрагмент текста, который токенизатор считает отдельной единицей словаря. Частые сочетания превращаются в один токен, редкие распадаются на несколько. Токеном может быть целое слово, его часть, знак препинания или пробел. У каждого токена есть свой номер, поэтому текст для модели — это последовательность чисел: в таком виде она его получает, обрабатывает и начинает генерировать продолжение.
На встрече спросили, зачем вообще нужны токены и почему нельзя работать просто с буквами.
Мне нравится объяснять это через сжатие. Представьте гигантский корпус текстов, который нужно хранить компактно. Вы ищете повторяющиеся фрагменты и заменяете их номерами: частое сочетание получает один номер, редкое приходится собирать из нескольких. Токенизатор — такой же словарь-компрессор, обученный на большом объёме текста. Точные цифры зависят от токенизатора, но порядок примерно такой: в английском на токен приходится около четырёх символов, в русском — два-три. Кириллица почти всегда обходится дороже, потому что словарь набирается не из символов, а из байтов, а кириллическая буква занимает два байта против одного у латиницы.
Механизм прост, но умеет он на удивление много: писать код, составлять планы, объяснять чужие решения. Именно поэтому его легко переоценить, так что сразу назовём три ограничения. Они кажутся очевидными, но почти все проблемы агентов растут отсюда.
-
Модель обучена на устаревших данных. Она знает мир таким, каким он был на момент сбора обучающей выборки, — обычно это полгода-год назад. О том, что произошло вчера, ей неизвестно.
-
Модель не отличает «я знаю» от «я не знаю». В любом случае она генерирует наиболее вероятное продолжение. Когда данных не хватает, продолжение всё равно появляется — просто правдоподобное вместо правильного. И это самое неприятное: со стороны разницы не заметить. Тон, уверенность и связность у верного и у выдуманного ответа одинаковые.
-
Модель ничего не делает. Она лишь пишет текст.
Посмотрим, как эти три пункта складываются в хорошо знакомую ситуацию.
Мы просим модель написать код с использованием недавно вышедшей библиотеки. В обучении она встречала десятки похожих библиотек, поэтому уверенно выдаёт правдоподобное решение. Но нужного метода в новой версии может не оказаться, параметры могут называться иначе, а часть кода модель попросту придумает — тем же ровным тоном, которым писала верную часть.
Тогда вы идёте искать документацию, копируете нужный раздел в запрос и просите попробовать ещё раз. Для одной задачи терпимо. Но таскать информацию в контекст вручную надоедает примерно к третьему разу, и хочется, чтобы модель сама сходила за документацией.
Почему нельзя просто отдать модели весь проект?
Прозвучал вопрос, которого я не ждал: а почему бы не отдать модели вообще всё — весь проект, всю документацию — и не мучиться?
Ответ упирается в ресурсы. Модель — это огромная нейросеть с миллиардами параметров, и чтобы вычисления шли быстро, они выполняются на видеокарте, в её памяти. Туда должны уместиться веса модели, а это фиксированный объём. Сверх того каждый токен контекста занимает свой участок под промежуточные вычисления. Память растёт пропорционально длине текста: вдвое длиннее — вдвое больше.
С вычислениями всё хуже. Прочитывая запрос, модель сопоставляет каждый токен с каждым. Текст вдвое длиннее — работы на этом этапе вчетверо больше. Оптимизации, сглаживающие квадратичный рост, существуют, но саму зависимость они не отменяют.
Отсюда простой вывод: хочешь много контекста — нужна очень мощная видеокарта, а лучше несколько. В качестве примера я назвал серверную карту, которая тогда стоила миллионы рублей за штуку. Из зала справедливо возразили: карты мы не покупаем, к модели ходим по сети. Верно. Но платить за них всё равно приходится — ценой токена, размером доступного окна и очередью, когда карт на всех не хватает.
Поэтому вариант «запихнуть в запрос вообще всё» отпадает. Модель по-прежнему умеет лишь генерировать следующий токен и никуда за документацией не ходит.
Значит, её нужно этому научить.
Tool Calling. Как научить модель вызывать наше API
Задачу я выбрал максимально приземлённую: нужен агент, умеющий узнавать погоду. Пример с документацией отложил намеренно: механизм там тот же, а погода целиком умещается на одном слайде.
Допустим, у нас уже есть готовая функция. Она обращается по API к сервису погоды и возвращает погоду по названию города. Функция написана, протестирована и работает. Вопрос залу: как заставить модель ею воспользоваться?
Первый ответ прозвучал почти мгновенно: «прикрутить API».
По смыслу верно, но это ответ на вопрос «что», а не «как». Модель умеет только генерировать текст. Каким образом текст превращается в вызов функции?
Пауза затянулась, и я продолжил сам. Приём здесь несложный, называется он tool calling.
Всё начинается с описания. У любой функции есть имя, назначение и набор аргументов; нашей достаточно знать город. Всё это оформляется как JSON-схема. По сути, это бланк: вот поля, вот их типы, вот те, что обязательны к заполнению. Что вписать в поле, решает модель, а насколько жёстко её удерживают в рамках бланка, зависит от режима генерации.
{
"name": "get_weather",
"description": "Возвращает текущую погоду в указанном городе",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "Название города на русском языке, например Москва"
}
},
"required": ["city"]
}
}
Обратите внимание на поля description. Это не комментарии для нас с вами: они попадают в контекст модели и работают как часть инструкции (промпта). Плохо написанное описание означает плохо работающий инструмент, а чинится оно переписыванием текста, а не кода.
Теперь самое интересное: как модель вообще понимает, какой инструмент ей нужен.
Никакой отдельной логики выбора, написанной кем-то в виде кода, там нет. Описания всех доступных инструментов подкладываются в контекст вместе с запросом пользователя, и дальше работает всё тот же механизм предсказания следующего токена. Модель видит «какая погода в Москве» и список инструментов, среди которых лежит get_weather с описанием «возвращает текущую погоду». Продолжение, в котором вызывается именно эта функция, оказывается самым вероятным — его модель и генерирует.
Иными словами, инструмент не выбирается, а предсказывается — как и всё остальное. Отсюда практический вывод: описание инструмента важнее его кода, ведь модель ориентируется именно на описание. Если у вас десять инструментов с похожими описаниями, она будет их путать, и виновата в этом не модель.
Далее модель генерирует уже не свободный текст, а JSON по нашей схеме.
В самом строгом варианте, который в документации обычно называется structured outputs, генерация ограничивается прямо на уровне выбора следующего токена: на каждом шаге допустимы только те токены, что не нарушают схему. Дописать лишнее поле или положить число в строковое поле модель в таком режиме физически не может.
Но даже самый жёсткий режим гарантирует форму, а не смысл. Типы будут соблюдены, но формат даты внутри строки модель по-прежнему может перепутать, если он не задан в схеме отдельно.
Соберём цепочку целиком. Пользователь пишет «какая погода в Москве». Модель, видя описания инструментов, генерирует JSON с именем функции и городом. Наш код разбирает этот JSON и вызывает настоящую функцию. Та обращается к сервису погоды и возвращает «четырнадцать градусов и облачно».
Так генератор текста получает возможность вызывать наше API. Инструментов может быть сколько угодно: прочитать файл, открыть браузер, отправить сообщение, запустить тесты — механизм всегда один и тот же.
Перед следующим шагом я показал слайд «Что здесь может пойти не так», потому что эти пункты будут всплывать постоянно. Модель может выбрать неподходящий инструмент, особенно когда их много, а описания похожи. Может ошибиться в аргументах: перепутать формат даты, передать не тот идентификатор, обрезать длинную строку. Может получить неоднозначный ответ и истолковать его по-своему. И наконец, инструменты обращаются к внешним системам, а те падают, тормозят и возвращают ошибки. Последний пункт мне пришлось вспомнить уже во время практики.
Но пока перед нами проблема поинтереснее.
Agent Loop. Кто решает, что делать дальше
Мы научили модель вызывать одну функцию. Реальные задачи так не решаются.
Возьмём что-нибудь повседневное: «посмотри, почему упали тесты, и почини». Нужно запустить тесты, прочитать вывод, по нему найти нужный файл, изменить его и снова запустить тесты. Четыре обращения к инструментам, и каждое следующее зависит от результата предыдущего: пока тесты не запущены, неизвестно, какой файл открывать.
Наша схема так не умеет. Она делает всё за один заход: получили запрос, сгенерировали JSON, вызвали функцию, отдали результат пользователю. Всё. Никто не смотрит, что вернула функция, и никто не решает, нужен ли следующий шаг.
Недостаёт того, что называют циклом выполнения, или agent loop. Устроен он проще некуда, и именно благодаря ему набор функций постепенно превращается в полноценную агентскую систему.
Работает это так. Мы отправляем модели запрос пользователя вместе со списком доступных инструментов. Модель отвечает либо текстом — тогда это финальный ответ, либо запросом на вызов инструмента; вызовов за раз может прийти сразу несколько, и вместе с ними нередко приходит какой-то текст. Если пришёл запрос на вызов, наша программа выполняет функцию, получает результат, дописывает его в контекст и отправляет модели весь накопленный контекст. И так по кругу, пока модель не решит, что задача выполнена.
Всё держится на одном действии: дописать результат в контекст и снова спросить модель.
Каждый следующий шаг модель делает, уже зная, чем закончился предыдущий: запустила тесты, увидела ошибку, поняла, какой файл открыть. Планирования на десять шагов вперёд здесь может и не быть — чаще агент просто делает следующий разумный шаг, глядя на то, что накопилось в контексте.
Теперь о том, как устроен этот накопленный контекст. Это не сплошной текст, а список сообщений, у каждого из которых есть роль.
Ролей обычно четыре.
-
System — инструкции на всю сессию: кто он, что ему разрешено, какие инструменты доступны. Пользователь их не пишет и обычно не видит.
-
User — то, что написали вы.
-
Assistant — то, что сгенерировала модель. Это может быть текст для пользователя, запрос на вызов инструмента или и то и другое сразу.
-
Tool — результат работы инструмента, который модель получает на вход.
Один оборот цикла с погодой выглядит так:
1. system : "Ты помощник. Доступные инструменты: get_weather(...)"
2. user : "Какая погода в Москве?"
3. assistant : tool_calls = [ { id: "call_1",
name: "get_weather",
arguments: {"city": "Москва"} } ]
4. tool : tool_call_id = "call_1"
{ "temp": 14, "condition": "облачно" }
После четвёртого сообщения мы отправляем модели весь список целиком, и она отвечает уже обычным текстом: «В Москве сейчас 14 градусов, облачно».
Идентификатор в третьем и четвёртом сообщениях нужен не просто так. Если за один ход модель запросила три вызова, на каждый мы дописываем отдельное сообщение с ролью tool; выполняться они могут параллельно и завершаться в любом порядке. Связать результат с нужным вызовом можно только по этому идентификатору.
Вернёмся к упавшим тестам.
Сначала в списке три элемента: системная инструкция, ваш запрос и ответ модели с вызовом run_tests. Мы запускаем тесты, четвёртым элементом дописываем их вывод с ошибкой и отправляем всё заново. Модель читает ошибку, замечает в ней имя файла и просит его прочитать. Дописываем содержимое файла, отправляем снова. Модель просит заменить в файле строку. Дописываем результат, отправляем снова. Модель просит ещё раз запустить тесты. Дописываем зелёный вывод, отправляем — и только теперь получаем текстовый ответ: «готово, дело было в неверном типе параметра».
Одиннадцать элементов вместо трёх, пять обращений к модели, и при каждом из них мы отправляли всю историю с самого начала. Запомните это, дальше пригодится.
Стоит проговорить и то, чего в этой схеме нет. Никакой отдельной «агентской» технологии не существует. Весь цикл — это while, внутри которого запрос к модели, одна проверка «пришёл текст или вызов инструмента» и добавление элемента в список. На знакомом языке программирования это пишется за вечер, и я советую это сделать: после этого в разговорах об агентах не остаётся ничего загадочного. Всё остальное, о чём пойдёт речь дальше, — лишь обвязка вокруг этих двадцати строк.
Заодно здесь закрывается реплика про самообучение, прозвучавшая в начале встречи. В рамках одной сессии агент и вправду кажется обучающимся: он накапливает знания о вашем проекте прямо в контексте, и к десятому шагу знает о нём заметно больше, чем на первом. Но веса модели при этом не меняются ни на грамм. Закройте сессию — и он снова видит ваш проект впервые в жизни.
Дальше мы упираемся в стену. Агент умеет действовать, но по-прежнему не знает ничего, чего нет в контексте. Внутренняя документация, вчерашние задачи, схема вашей базы, договорённости команды — для него всего этого не существует. Он не отказывается этим пользоваться, он просто о нём не знает. А если спросить, придумает правдоподобную версию.
RAG/MCP внешние данные. Откуда агент берёт то, чего не знает

Решение называется RAG — retrieval-augmented generation, то есть генерация, дополненная внешними данными. Суть проста: прежде чем отвечать, найди недостающую информацию, помести её в контекст и только потом генерируй ответ.
Здесь стоит развести два похожих подхода, которые часто путают.
В классическом RAG поиск выполняется всегда и до генерации: запрос уходит в индекс, найденное подклеивается к промпту, и решает всё это не модель, а сам конвейер.
В агентском варианте, о котором мы и говорим, решение принимает сама модель. Она видит
Некоторые загадки современной космологии
Как собирают данные для обучения роботов: от телеуправления и симуляций до камер на кепках
Как проверенные временем паровое и водяное отопление работают в современных небоскрёбах?
Старые технологии, которые не прижились: странные изобретения прошлого. Часть 5
Электронный микроскоп: прибор, позволяющий разглядеть атомы
Как распознать психопата по тексту: тёмные черты личности отражаются в нашей речи
Как осмыслить резонанс, если мы привыкли воспринимать мир как неподвижный?