От предсказателя текста к программному компоненту: схемы и вызов функций
Как JSON Schema, структурированный вывод и ограниченное декодирование превращают свободный текст LLM в типизированные данные, которым может доверять ваш код, и как это ведёт к вызову функций.
Прежде чем говорить об агентах, RAG и MCP, стоит ответить на более базовый вопрос: что такое LLM и что она на самом деле делает? Большая языковая модель делает ровно одно. Она предсказывает следующий токен. Модель читает уже имеющийся текст, выбирает самый вероятный следующий токен, дописывает его и предсказывает снова — и так по кругу, пока не закончит.
Этот цикл и есть весь механизм. Всё остальное, что строят вокруг LLM, надстраивается поверх него. Из этого следуют две вещи. Во-первых, LLM ничего не знает о типах, числах, объектах или базах данных. Она выдаёт текст и только текст. Во-вторых, её предсказания опираются на обучающие данные, а это снимок прошлого, так что ответы могут устареть, если вы сами не положите свежую информацию в промпт.
То, что модель — просто генератор текста, становится настоящей проблемой, как только вы начинаете строить на ней софт. Софт детерминирован и типизирован: объекты, методы, числа. Голая LLM отдаёт вам строку.
Проблема на конкретном примере
Допустим, вы спрашиваете у модели погоду. В ответ может прийти любое из этого:
- «Сейчас 22°C и солнечно»
- «Около 22 градусов, ясно»
- «Я бы сказала, примерно 22°»
Все три ответа верны, и все три бесполезны для вашего кода. Коду нужно, чтобы response.temp_c каждый раз было числом, которое можно сохранить, посчитать с ним что-то или вывести на график. С предложением ничего из этого не сделать, а JSON.parse() к русскому языку не применишь. Регулярка, может, и справится сегодня, но сломается на завтрашней формулировке.
Обратите внимание, как именно здесь всё ломается. Ничего не сломано, и никто не ошибся: модель сделала свою работу, просто результатом этой работы оказалась проза, а программе было нужно значение. Так что беда не в том, что модель неправа. У вывода нет фиксированной формы. Чтобы на нём строить, нужен предсказуемый вывод, а не просто правильный. Решение потребовало нескольких идей, которые надстраиваются одна над другой.
JSON Schema
Начнём с JSON Schema. Это рецепт того, как должен выглядеть JSON-объект после заполнения. Данных в ней нет. Она описывает только форму: какие поля существуют, какого типа каждое и какие из них обязательны.
{
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name, e.g. Istanbul" },
"temp_c": { "type": "number", "description": "Temperature in Celsius" },
"is_sunny": { "type": "boolean" }
},
"required": ["city", "temp_c"]
}
Каждое поле получает type, например string, number или boolean. description даёт модели больше контекста о поле, а список required говорит, какие поля должны присутствовать. В этом примере is_sunny необязательно.
Именно такое и нужно для запроса погоды: город в виде строки, температура в виде числа и флаг, который может быть, а может и не быть. Сама по себе схема ничего не делает. Считайте её контрактом, который говорит: «корректные данные выглядят вот так». Следующий шаг — передать этот контракт модели.
Структурированный вывод
В 2024 году у LLM появилась функция под названием структурированный вывод. Вы даёте модели JSON Schema, и она обязана её заполнить — ни больше, ни меньше. (Более ранняя и слабая версия гарантировала только валидный JSON, без каких-либо обещаний насчёт полей и типов. Структурированный вывод закрыл этот пробел.)
Работает это за счёт двух вещей одновременно, и ни одной из них по отдельности недостаточно.
Первая — обучение. Начиная с 2024 года новые модели учили понимать схемы и выдавать соответствующий им вывод. Но модель всё равно угадывает по одному токену, поэтому даже хорошо обученная следовала схеме примерно в 90% случаев. Для настоящего софта 90% — это баг, который ждёт своего часа.
Вторая — обычный код в движке инференса, который называется ограниченным декодированием. Модель выдаёт токены по одному, и на каждом шаге этот код сверяется со схемой и убирает любой токен, который бы её нарушил, выставляя ему нулевую вероятность. Схема превращается в грамматику — набор правил о том, какие токены могут идти дальше, — и модель может выбирать только из оставшихся.
Основную работу делает обученная модель, а ограниченное декодирование превращает её в почти стопроцентную гарантию. Разделение ролей можно представить так: обучение делает правильный ответ вероятным, а шаг декодирования делает неправильную форму невозможной. Модель по-прежнему выдаёт строку, но такую, которая всегда соответствует рецепту. Дальше вы разбираете её в настоящий объект и доверяете ему.
// the recipe, the same kind of JSON Schema we saw above
const schema = {
type: "object",
properties: {
city: { type: "string" },
temp_c: { type: "number" }
},
required: ["city", "temp_c"]
};
// ask the model, and pass the schema to force the shape
const report = await askModel({
prompt: "Weather in Istanbul, sunny, 22°",
schema: schema,
});
report.temp_c; // 22, always a number. no parsing, no regex.
Теперь типизированный код и модель наконец могут работать вместе, потому что у вывода есть форма, на которую можно положиться.
Есть и более крупное следствие. Параметры функции — это просто поля с типами, то есть ровно то, чем является структурированный вывод. Если модель умеет заполнить любую схему, она может заполнить и аргументы функции. А раз она может выдать правильные аргументы, то по сути может сказать вашему коду, какую функцию запустить и с какими значениями. Это даёт больше, чем чистые данные. Это даёт вызовы методов, и у этого есть название.
Вызов функций
При вызове функций вы отправляете промпт вместе со списком именованных функций. У каждой есть имя, description с описанием того, что она делает, и JSON Schema для её параметров.
{
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
description здесь очень важно, потому что именно по нему модель решает, что нужна эта функция. Модель читает ваш промпт, приходит к выводу, что ей нужна погода, и отвечает вызовом, который хочет выполнить:
{ "name": "get_weather", "input": { "city": "Istanbul" } }
Этот ответ — не что иное, как структурированный вывод. В input лежат аргументы, а name указывает функцию. Модель ничего не запускала и не может. На вашей стороне вы разбираете JSON, находите подходящую функцию по name, передаёте ей input в качестве аргументов, выполняете её и отправляете результат обратно модели. Проще говоря, вызов функций — это структурированный вывод с аргументами плюс выбор моделью подходящей функции.
Именно это и открывает возможности. Всё, что можно обернуть в функцию, становится доступным: API погоды, платёжная система, поисковик, база данных, ваши собственные сервисы. Любую возможность, описанную как функция со схемой, можно подключить к LLM. Заметьте, как мало для этого нужно модели. Она никогда не трогает ваши системы напрямую: она лишь предлагает вызов, а ваш код решает, выполнять ли его и как. Так текстовое поле превращается в то, что делает что-то в реальном мире — через ваш код.
Сложность никогда не была в том, чтобы описать желаемое. Сложно было заставить генератор текста следовать правилам. Когда это решили, LLM смогла стать обычным строительным блоком в вашем коде, как любой другой инструмент.