Від передбачувача тексту до програмного компонента: схеми й виклик функцій
Як 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 змогла стати звичайним будівельним блоком у вашому коді, як будь-який інший інструмент.