Dostępny na nowe rolePiotr Czerwiński

Blog · 20 czerwca 2026 · 9 min czytania

Tool use zamiast parsowania: ustrukturyzowane dane z LLM na produkcji

AI · LLM · tool use · architektura

TL;DR: Jeśli na produkcji potrzebujesz pól wyciągniętych z nieuporządkowanego tekstu, przestań prosić model o "zwrócenie JSON-a" i parsować to, co wróci. Zadeklaruj wynik jako JSON Schema i pozwól, żeby pilnował go dostawca, przez tool use (function calling) albo ścisły tryb structured outputs. Wtedy wadliwy kształt w ogóle nie może zostać wygenerowany. Schemat traktuj jednak jako minimum: gwarantuje kształt, a nie prawdziwość, więc cienka warstwa deterministycznego kodu nadal normalizuje wartości, przycina zakresy i nie pozwala modelowi nadpisać danych, które już znasz. Dostawcę schowaj za jednym małym interfejsem, żeby zmiana dostawcy albo przejście na endpoint w chmurze oznaczały jeden nowy adapter, a nie refaktor.

Problem: na wejściu chaotyczny tekst, na wyjściu kolumny w bazie

Spora część AI w produktach, które prowadzę, to mało efektowna ekstrakcja. Na wejściu jest długi, niespójny dokument: ogłoszenie o pracę, profil, strona internetowa. Na wyjściu wiersz z typowanymi kolumnami: kategoria ze stałej listy, poziom doświadczenia, lista technologii, widełki wynagrodzenia, model pracy i garść pól, które mogą być puste. Nikt nie czyta wyniku modelu bezpośrednio. Trafia on do bazy, zasila filtry i dopasowania, a potem renderuje się na publicznych stronach.

Ta ostatnia część sprawia, że ekstrakcja jest problemem produkcyjnym, a nie problemem promptu. Lekko chybiona odpowiedź w czacie to po prostu trochę gorsza odpowiedź. Lekko chybiona ekstrakcja to zepsuty filtr, zła plakietka na publicznym ogłoszeniu albo wiersz, którego nie da się zapisać o trzeciej w nocy w środku zadania wsadowego.

Jakie są sposoby na ustrukturyzowany wynik z LLM?

Jest ich mniej więcej pięć i większości w którymś momencie używałem.

PodejścieCo gwarantujeGdzie się sypie
Wolny tekst plus regexNicKażde nowe sformułowanie to nowy błąd
Prompt mówi "zwróć JSON", kod parsujeNic, ale zwykle działaTekst dookoła JSON-a, przecinki na końcu, wynik ucięty na limicie tokenów
JSON modeSkładniowo poprawny JSONDowolny kształt: brakujące pola, złe typy, zmyślone klucze
Tool use albo ścisłe structured outputsWynik przechodzi walidację względem Twojego JSON SchemaWartości nadal mogą być błędne, zakresy nie są pilnowane
Biblioteka opakowująca powyższeTo, co gwarantuje tryb pod spodemKolejna zależność między Tobą a API

Od dwóch pierwszych zaczyna większość prototypów i nie powinny na nich zostawać. JSON mode to realna poprawa, a pułapka polega na tym, że wygląda jak meta: parsowanie nigdy nie rzuca wyjątku, więc przestajesz patrzeć. Moja główna ścieżka ekstrakcji działa na JSON mode, a błąd, który nauczył mnie tej różnicy, był drobny i bardzo widoczny. Nullowalne pole typu enum, poziom doświadczenia, czasem wracało jako napis "null" zamiast JSON-owego null. JSON był poprawny. Kształt był poprawny. Na publicznym ogłoszeniu w miejscu plakietki z poziomem doświadczenia widniało "Null". Doraźną poprawką był sanitizer, który przed przypisaniem zamienia "null", "None", "undefined" i pusty napis na prawdziwy null. Prawdziwa poprawka polega na tym, żeby w ogóle nie pozwalać modelowi wybierać reprezentacji. Ta starsza ścieżka nadal ma sanitizer na wejściu, a przeniesienie jej na ścisły schemat jest na mojej liście.

Biblioteki opakowujące, które walidują wynik i pytają model ponownie, to rozsądny wybór, a zanim dostawcy zaczęli natywnie pilnować schematów, były najlepszą opcją. Dziś pilnowanie schematu siedzi w API, więc u mnie biblioteka głównie dołożyłaby zależność do audytu i aktualizacji. Wolę mieć na własność te trzydzieści linijek kleju.

Czym jest tool use i dlaczego wygrywa z parsowaniem?

Tool use, nazywany też function calling, to funkcja API, w której opisujesz funkcje, jakie model może wywołać, każdą z JSON Schema dla argumentów, a model zamiast prozy odpowiada ustrukturyzowanym wywołaniem. Powstało z myślą o agentach, którzy wykonują akcje, ale to także najczystszy interfejs do ekstrakcji, jaki istnieje. Definiujesz jedno narzędzie, którego argumenty to dokładnie te pola, których potrzebujesz, każesz modelowi zapisać przez nie swoje ustalenia i argumenty stają się Twoim wierszem.

Structured outputs to ten sam pomysł bez otoczki narzędzia: przekazujesz schemat jako format odpowiedzi w trybie ścisłym, a dostawca ogranicza generowanie tak, że da się wyprodukować wyłącznie wynik zgodny ze schematem. Pod spodem oba podejścia opierają się na dekodowaniu z ograniczeniami (constrained decoding): tokeny, które złamałyby schemat, po prostu nie są dla modelu dostępne. To, którego użyjesz, zależy od dostawcy i od tego, czy wywołanie potrzebuje też prawdziwych narzędzi. Przy czystej ekstrakcji korzystam z wariantu z formatem odpowiedzi tam, gdzie istnieje, a z jednego ścisłego narzędzia tam, gdzie go nie ma.

// illustrative shape - a strict extraction schema
{
  name: "record_listing",
  strict: true,
  schema: {
    type: "object",
    properties: {
      category:  { type: "string", enum: ["engineering", "design", "data", "other"] },
      seniority: { type: ["string", "null"], enum: ["junior", "mid", "senior", null] },
      skills:    { type: "array", items: { type: "string" } },
      notes:     { type: "string" }
    },
    required: ["category", "seniority", "skills", "notes"],
    additionalProperties: false
  }
}

Kilka elementów tego kształtu ma znaczenie. Tryb ścisły zwykle chce, żeby każda właściwość była na liście wymaganych, więc opcjonalne staje się nullowalne: pole musi być obecne, a brak wartości wyraża się jako null w typie unii. Enumy jawnie zawierają null, co zamyka drogę napisowi "null". A additionalProperties: false oznacza, że model nie może wymyślić pola, z którym potem trzeba by coś zrobić. Ekstraktory odkrywania ofert w moim produkcie do dopasowywania pracy od początku powstawały w ten sposób i żadnego takiego pola nie potrzebowały.

Jedno zastrzeżenie zależne od dostawcy: niektóre API pozwalają zmusić model do wywołania konkretnego narzędzia, inne ograniczają to na części modeli. Nie projektuję pod wymuszanie. Ścisły schemat plus jasna instrukcja, żeby użyć narzędzia, są przenośne. Wymuszony wybór to pokrętło, którego może nie być w następnym modelu, na który przejdziesz.

Czego schemat nie gwarantuje

Tę sekcję chciałbym kiedyś przeczytać u kogoś innego. Wymuszony kształt usuwa jedną klasę błędów, a pozostałe stają się lepiej widoczne. Te pozostałe są prawdziwe.

  • Zakresy. Liczba całkowita to liczba całkowita. Tryb ścisły nie powstrzyma oceny 140 na skali od 0 do 100. Przycinanie zostaje w kodzie.
  • Prawdziwość. Idealnie otypowane pole nadal może być błędne. Model potrafi wybrać wiarygodnie wyglądającą kategorię, której tekst źródłowy nie uzasadnia. Tam, gdzie źródło już niesie jednoznaczną etykietę, deterministyczne mapowanie wygrywa z modelem, a każdą rozbieżność loguję, żeby widzieć, jak często model zostałby przegłosowany.
  • Dane, które już masz. Błąd, który kosztował mnie w tym obszarze najwięcej sprzątania, polegał na tym, że pozwoliłem modelowi zwracać pole istniejące też w źródle i zapisywałem wersję modelu. Prompt prosił o oczyszczenie tytułów stanowisk z wyrazów dookreślających. Model usuwał jednak także prawowite słowa z prawdziwych tytułów i na 39 wierszach na produkcji skrócił wielowyrazową nazwę roli do ostatniego słowa. Reguła od tamtej pory: ekstrakcja nigdy nie nadpisuje autorytatywnych pól źródłowych. Model nadal może je wyliczać na własny użytek przy klasyfikacji, ale baza zachowuje oryginał.
  • Poprawki ludzi. Jeśli admin poprawi pole ręcznie, późniejsze ponowne przetwarzanie nie może po cichu przywrócić odpowiedzi modelu. Ponowne przetwarzanie wymaga jawnej flagi, która zachowuje ręczne poprawki.

Ponowienia, idempotencja i stany błędów

Ustrukturyzowany wynik zmienia to, co ponawiasz. Błędy transportu (limity zapytań, przekroczone czasy, błędy serwera) to zadanie SDK. Oficjalne SDK już ponawiają je z wycofaniem (backoff). Liczy się dobór timeoutu dla każdej ścieżki. Nocne zadanie wsadowe przeżyje długie wartości domyślne. Interaktywne żądanie, przy którym ktoś patrzy na kręcące się kółko, potrzebuje ciasnego limitu i najwyżej jednego ponowienia, żeby szybko i czysto się wyłożyć, zamiast wisieć przez kilka minut.

Błędy walidacji to co innego. Gdy kształtu pilnuje dostawca, do walidacji zostaje znaczenie: sprawdzenie zakresu, reguła między polami, zabezpieczenie sprawdzające, czy wynik nie zawiera czegoś, czego zawierać nie może. Moja reguła dla nich: jedno ponowienie z dopisanymi do żądania konkretnymi ustaleniami, potem twardy błąd. Nigdy drugie ciche ponowienie i nigdy zapisywanie wyniku, który nie przeszedł sprawdzenia.

Wokół każdego wywołania ekstrakcji stoją trzy tanie zabezpieczenia, które oszczędzają więcej pieniędzy niż jakikolwiek wybór modelu:

  • Hash treści. Jeśli wejście nie zmieniło się od ostatniej udanej ekstrakcji, pomiń wywołanie.
  • Stan przetwarzania. Rekordu oznaczonego jako przetwarzany nie podejmie drugi worker, a błąd zapisuje się jako stan nieudany, zamiast zostawiać rekord zapisany do połowy.
  • Limit rozmiaru i tani pierwszy przebieg. Za duże wejście jest pomijane, zanim wygeneruje jakikolwiek koszt, a krótkie wywołanie wstępne decyduje, czy rekord w ogóle jest wart pełnej ekstrakcji.

Czy warto abstrahować dostawcę LLM?

Tak, i to cienko. Wzorzec to porty i adaptery (nazywane też architekturą heksagonalną): aplikacja zależy od małego interfejsu, który sama posiada, a każdy dostawca to adapter, który go implementuje. W prowadzonym przeze mnie produkcie do mierzenia widoczności w AI, który odpytuje kilka silników AI, interfejs ma dwie metody: czy adapter jest skonfigurowany oraz ask. Każdy adapter zwraca ten sam znormalizowany wynik: odpowiedź, cytowania i zużycie tokenów do rozliczania kosztów. Kod wywołujący nigdy nie importuje SDK dostawcy.

// illustrative shape - the port the app depends on
interface Engine {
  id: string;
  enabled(env: Env): boolean;          // has its key/config
  ask(input: string, env: Env): Promise<{
    text: string;
    usage?: { inputTokens: number; outputTokens: number };
  }>;
}

Zysk jest konkretny. Zmiana modelu dla jednego zadania, próba tańszego dostawcy albo przejście na endpoint w chmurze, taki jak Bedrock czy Azure, ze względu na lokalizację danych to nowy adapter i zmiana konfiguracji. Rejestr rozpoznaje skonfigurowane adaptery w czasie działania i pomija te bez kluczy. Dzięki temu lokalny development działa na atrapie silnika i bez żadnych wydatków. Wielu dostawców udostępnia dziś endpoint zgodny z OpenAI, przez co niektóre zmiany sprowadzają się do podmiany bazowego URL-a, ale nie polegałbym tylko na tym: obsługa ścisłych schematów różni się między dostawcami, a adapter to miejsce, w którym tę różnicę się obsługuje.

Granica tego podejścia: adapter ukrywa sygnatury wywołań, a nie zachowanie. Dwa modele za tym samym interfejsem mogą różnie wyciągać dane z tego samego dokumentu. Dlatego zmiana dostawcy to w równym stopniu pomiar, co zmiana w kodzie. Wiersze wytworzone przez model oznaczam nazwą modelu, który je wytworzył, żeby dało się porównać stan przed i po na prawdziwych danych.

Co powiedziałbym komuś, kto zaczyna to jutro

  • Całkiem odpuść "zwróć JSON". Zacznij od ścisłego schematu przez tool use albo structured outputs.
  • Pola opcjonalne rób nullowalnymi, a do enumów dodawaj null. Ten jeden nawyk zapobiegłby mojemu najbardziej widocznemu błędowi ekstrakcji.
  • Zostaw deterministyczną warstwę za modelem. Przycinaj zakresy, mapuj znane etykiety i loguj miejsca, w których kod przegłosowuje model.
  • Nigdy nie pozwól, żeby ekstrakcja nadpisała autorytatywne dane. Ani pól źródłowych, ani poprawek ludzi.
  • Ponowienia transportu zostaw SDK. Walidacja dostaje jedno ponowienie z ustaleniami, a potem zapisany błąd.
  • Hash, blokada i wstępne sito przed wywołaniem. Najtańsze wywołanie modelu to to, które pominiesz.
  • Interfejs miej na własność, dostawcę wynajmuj. Jeden port, jeden adapter na dostawcę i nazwa modelu zapisana w każdym wierszu.

Ten sam nawyk mierzenia, zanim zaufasz, dotyczy strony retrievalu w pipeline'ie. O kalibrowaniu jej pisałem w tekście o strojeniu progów podobieństwa w pgvector.

Pytania, na które odpowiada ten wpis

Czym różni się JSON mode od structured outputs?
JSON mode gwarantuje tylko składniowo poprawny JSON, więc pól może brakować, mogą mieć zły typ albo być zmyślone. Ścisłe structured outputs, czyli tool use ze ścisłym schematem, ograniczają generowanie tak, że wynik przechodzi walidację względem Twojego JSON Schema.
Czy tool use (function calling) to dobry sposób na wyciąganie danych z LLM?
Tak. Zdefiniuj jedno narzędzie, którego argumenty to dokładnie te pola, których potrzebujesz, pola opcjonalne zrób nullowalnymi, a do enumów dodaj jawny null. Wtedy argumenty narzędzia stają się Twoim typowanym rekordem. Ścisły schemat jest bardziej przenośny niż wymuszanie konkretnego narzędzia, którego część modeli nie obsługuje.
Czy nadal potrzebuję walidacji, jeśli wynik LLM pasuje do schematu?
Tak. Schemat gwarantuje kształt, a nie prawdziwość: zakresy liczbowe nie są pilnowane, a wartości nadal mogą być błędne. Zostaw deterministyczną warstwę, która przycina zakresy, mapuje znane etykiety i nigdy nie pozwala, żeby ekstrakcja nadpisała autorytatywne dane źródłowe albo poprawki ludzi.