Головна / Статті / Від передбачувача тексту до програмного компонента: схеми й виклик функцій

Від передбачувача тексту до програмного компонента: схеми й виклик функцій

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