Главная / Статьи / От предсказателя текста к программному компоненту: схемы и вызов функций

От предсказателя текста к программному компоненту: схемы и вызов функций

Как JSON Schema, структурированный вывод и ограниченное декодирование превращают свободный текст LLM в типизированные данные, которым может доверять ваш код, и как это ведёт к вызову функций.

1290 слов

Прежде чем говорить об агентах, 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 смогла стать обычным строительным блоком в вашем коде, как любой другой инструмент.