PunchPHOTOS: Osimhen resumes training ahead of Kasimpasa clashBollywood HungamaKiara Advani and Sidharth Malhotra announce second pregnancy with adorable post: “Blessed once again”The Jerusalem PostExtremist Israeli settlers attack Palestinian home in West Bank, set vehicle on fire - reportESPN DeportesMerino rescató a España y le dio la agónica victoria ante Croacia en la Nations LeagueDaily MaverickDRAFT DODGING: Three years, 6,000 pages, no labels: Who’s stalling SA’s food warnings?ESPNMessi marks tearful Argentina farewell with goal: Wish I could play foreverInquirerProsecution seeks to show Dutertes hid P96M via unused manager’s checksThe South AfricanLionel Messi retires from international football in styleSky TG24Lewis Capaldi compie 30 anni, le sue canzoni più famose da Someone you Loved a Forget MeBusiness AMAI-aandelen stuwen S&P 500 en Nasdaq naar nieuwe recordhoogtesZDF heuteAktuelle Pressemitteilungen des ZDFRTP DesportoPortugal perde com Itália no Mundial feminino de hóquei
The Daily Newsstand · Free, Always
Wednesday, October 7, 2026

Почему JSON Schema не делает ответ LLM надёжным

Translate

После того как LLM начинает возвращать structured output, интеграция внезапно становится очень похожей на обычный backend.

Больше не нужно вырезать JSON из Markdown, надеяться, что модель не добавит перед ним «Конечно, вот результат», чинить потерянные кавычки и повторять запрос из-за случайной запятой.

Мы описываем схему, модель возвращает объект, десериализуем его в Go-структуру — и кажется, что самая неприятная часть интеграции закончилась.

Например:

type Invoice struct {
    Number   string `json:"number"`
    Currency string `json:"currency"`
    Total    int64  `json:"total"`
}

На выходе получаем:

{
  "number": "INV-2026-1842",
  "currency": "USD",
  "total": 148000
}

JSON валиден.

Schema соблюдена.

json.Unmarshal ошибок не вернул.

Именно в этот момент очень легко начать относиться к результату LLM как к результату обычного API.

Но структурная корректность ответа ничего не говорит о том, действительно ли в исходном документе был счёт INV-2026-1842 на $1480.

Модель могла вернуть идеальный JSON с неправильными данными.

Для production-систем это различие намного важнее, чем кажется.

Мы проверили форму, а не факт

В обычном backend schema validation действительно даёт довольно сильную гарантию.

Если API принимает:

{
  "quantity": 3,
  "price": 1200
}

мы можем проверить типы, обязательные поля, диапазоны и затем сами посчитать итоговую сумму.

Источник истины при этом находится внутри нашей системы.

С LLM ситуация другая.

Когда модель извлекает данные из договора, PDF, письма или пользовательского сообщения, мы обычно используем её именно потому, что заранее не знаем правильного результата.

Получается интересная конструкция:

неструктурированные данные
        ↓
       LLM
        ↓
structured output
        ↓
JSON Schema
        ↓
Go struct

Последние два этапа проверяют только то, что результат выглядит так, как мы ожидали.

Они не проверяют первый переход:

исходный документ → факты

А именно там и находится большая часть риска.

У ответа на самом деле несколько уровней корректности

Я бы разделял как минимум три разных понятия.

Первое — syntactic validity.

Можно ли вообще разобрать ответ:

if err := json.Unmarshal(data, &result); err != nil {
    return err
}

Второе — structural validity.

Есть ли обязательные поля, допустимые enum, корректные типы и диапазоны:

currency ∈ [USD, EUR, RUB]
total >= 0
invoice_number != ""

И только третье — semantic validity.

Соответствует ли значение реальности.

Например, такой объект может пройти первые две проверки:

{
  "currency": "USD",
  "total": 148000
}

Но в документе на самом деле написано:

Total: $1,840.00

Для JSON Schema всё прекрасно.

Для бизнеса ошибка составляет $360.

Поэтому validation после LLM должна быть многослойной

Я не стал бы пытаться решить эту проблему ещё одним огромным prompt.

Если приложение уже достаточно серьёзное, полезнее относиться к LLM как к недоверенному преобразователю данных.

То есть примерно так же, как мы относимся к внешнему API или пользовательскому вводу.

Модель предлагает результат.

Backend решает, можно ли этот результат принять.

Архитектурно это может выглядеть так:

Document
   ↓
LLM Extraction
   ↓
Schema Validation
   ↓
Domain Validation
   ↓
Evidence Validation
   ↓
Accept / Review / Reject

И вот последние три этапа я бы по возможности держал вне модели.

Domain validation лучше оставить обычному Go-коду

Предположим, модель извлекает данные счёта:

type Invoice struct {
    Number       string `json:"number"`
    Currency     string `json:"currency"`
    Subtotal     int64  `json:"subtotal"`
    Tax          int64  `json:"tax"`
    Total        int64  `json:"total"`
}

LLM возвращает:

{
  "number": "INV-1842",
  "currency": "USD",
  "subtotal": 100000,
  "tax": 20000,
  "total": 130000
}

Все поля корректны.

Но:

100000 + 20000 != 130000

Нет никакого смысла спрашивать другую LLM:

Проверь, правильно ли посчитана сумма.

Это детерминированная задача.

В Go она выглядит значительно надёжнее:

func validateInvoice(inv Invoice) error {
    if inv.Subtotal+inv.Tax != inv.Total {
        return errors.New("invoice total mismatch")
    }

    return nil
}

Чем больше таких проверок можно вынести из вероятностного слоя в детерминированный, тем лучше.

LLM стоит использовать там, где действительно нужна интерпретация.

Сложение двух чисел интерпретации не требует.

Cross-field validation особенно легко забыть

JSON Schema хорошо проверяет отдельные поля, но многие реальные ограничения возникают между ними.

Например:

start_date <= end_date

или:

discounted_price <= original_price

или:

subtotal + tax = total

или:

если payment_status = paid,
то payment_date не должна быть пустой

LLM вполне способна вернуть каждое поле в допустимом диапазоне и при этом создать невозможную комбинацию.

Поэтому я бы не воспринимал generated struct как готовый domain object.

У меня это скорее:

LLM DTO
   ↓
validation
   ↓
Domain Object

То есть граница доверия проходит уже после модели.

Но арифметикой проблема не заканчивается

Самая неприятная ситуация возникает, когда поле невозможно проверить обычным кодом.

Например, модель извлекла из договора:

{
  "termination_notice_days": 30,
  "auto_renewal": true
}

Типы правильные.

Значения реалистичные.

Между полями нет противоречий.

Но откуда взялись 30 дней?

Вот здесь становится полезен provenance не только на уровне модели, о котором я писал в контексте fallback, но и на уровне конкретного значения.

Вместо:

type ContractTerms struct {
    NoticeDays  int  `json:"notice_days"`
    AutoRenewal bool `json:"auto_renewal"`
}

для некоторых задач имеет смысл запросить ещё и evidence:

type ExtractedField[T any] struct {
    Value    T      `json:"value"`
    Evidence string `json:"evidence"`
}

type ContractTerms struct {
    NoticeDays  ExtractedField[int]  `json:"notice_days"`
    AutoRenewal ExtractedField[bool] `json:"auto_renewal"`
}

Ответ становится больше:

{
  "notice_days": {
    "value": 30,
    "evidence": "Either party may terminate with thirty days written notice"
  },
  "auto_renewal": {
    "value": true,
    "evidence": "The agreement shall automatically renew for successive one-year terms"
  }
}

Это всё ещё не доказательство корректности.

LLM может ошибиться и в evidence.

Но теперь у backend появляется возможность хотя бы проверить, существует ли указанный фрагмент в исходном документе.

Я бы предпочёл offsets цитатам

Есть ещё более удобный вариант.

Если preprocessing позволяет стабильно работать с исходным текстом, вместо произвольной цитаты можно просить вернуть диапазон:

type Evidence struct {
    Start int `json:"start"`
    End   int `json:"end"`
}

Тогда модель говорит не:

Поверь мне, в документе была такая фраза.

А:

ответ основан на символах 1842–1927

Backend сам достаёт этот участок из оригинала.

Это принципиально лучше с точки зрения provenance: модель уже не является источником одновременно и значения, и доказательства этого значения.

А если проверить результат второй моделью?

Очевидное решение:

Model A → answer
Model B → verify answer

Иногда оно действительно полезно.

Но я бы не превращал verifier LLM в универсальную гарантию.

Две модели могут совершить одну и ту же ошибку.

Особенно если обе получают одинаковый контекст, похожий prompt и имеют схожие слабые места.

Кроме того, появляется неприятный вопрос: если verifier не согласился с extractor, кому верить?

Extractor: 30 days
Verifier: incorrect

Дальше нужен либо третий вызов, либо arbitration, либо ручная проверка.

Мы довольно быстро можем построить дорогую систему, которая использует несколько вероятностных компонентов, чтобы решить проблему недоверия к вероятностному компоненту.

Поэтому verifier я бы использовал как один из сигналов, а не как замену детерминированной validation.

Confidence тоже не является вероятностью истинности

Ещё одна ловушка — попросить модель вернуть:

{
  "value": 30,
  "confidence": 0.97
}

Число выглядит очень удобно.

Можно даже написать:

if result.Confidence < 0.8 {
    sendToManualReview()
}

Проблема в том, что 0.97 не обязательно означает:

вероятность правильного ответа — 97%.

Без отдельной калибровки это просто ещё одно значение, сгенерированное моделью.

Оно может быть полезным сигналом. Но относиться к нему как к статистически калиброванной вероятности я бы не стал.

Если confidence влияет на автоматические решения, его нужно проверять на собственном eval dataset.

Например, среди результатов с:

confidence >= 0.9

действительно должно быть около ожидаемого уровня правильных ответов.

Если этого нет, threshold существует только для красоты.

В итоге у AI-ответа появляется жизненный цикл

В обычном API часто достаточно:

request → response

Для production LLM мне всё больше нравится другая модель:

request
   ↓
candidate
   ↓
validated
   ↓
accepted

LLM возвращает не истину.

Она возвращает candidate result.

После этого backend может применить детерминированные правила, проверить evidence, посмотреть provenance и только потом повысить результат до состояния accepted.

Например:

type ResultStatus string

const (
    StatusCandidate ResultStatus = "candidate"
    StatusAccepted  ResultStatus = "accepted"
    StatusReview    ResultStatus = "review"
    StatusRejected  ResultStatus = "rejected"
)

Мне нравится эта модель ещё и потому, что она заставляет явно ответить на вопрос:

В какой момент наша система начинает доверять результату модели?

Если ответа нет, скорее всего доверие сейчас появляется случайно — где-нибудь сразу после json.Unmarshal.

Что логировать

Здесь observability тоже немного отличается от обычного API.

Мне недостаточно знать:

status=200
latency=840ms
tokens=3200

Для extraction pipeline я бы хотел видеть ещё хотя бы:

schema_valid=true
domain_valid=true
evidence_valid=false
result_status=review

Тогда можно обнаружить гораздо более интересные проблемы.

Например:

после смены модели
schema errors: без изменений
evidence failures: +18%

Если смотреть только на HTTP errors и JSON parsing errors, новая модель выглядит абсолютно исправной.

Но качество системы уже ухудшилось.

Это очень похоже на проблему fallback из предыдущей статьи: технический success не обязательно означает product success.

И здесь снова появляются evals

Чтобы понять, работает ли validation pipeline, нужен набор примеров с известным правильным результатом.

Не обязательно сразу строить огромную платформу.

На старте вполне может хватить сотни реальных обезличенных примеров, которые покрывают нормальные случаи и известные edge cases.

После изменения prompt, модели или preprocessing мы прогоняем их снова.

Тогда вместо:

новая модель вроде отвечает лучше

можно получить:

schema validity:       99.8%
domain validity:       98.4%
evidence validity:     94.1%
accepted automatically: 91.7%
manual review:          7.6%
rejected:               0.7%

И вот это уже намного ближе к тому, что мне хотелось бы видеть перед production rollout.

JSON Schema всё равно очень полезна

После всего написанного может показаться, что structured output почти ничего не даёт.

Наоборот.

Это одна из самых полезных возможностей современных LLM API.

Она убирает огромный класс совершенно ненужных ошибок: сломанный JSON, отсутствующие поля, неожиданный формат и часть проблем с типами.

Просто гарантия у неё другая.

JSON Schema отвечает на вопрос:

Результат имеет форму, которую понимает моя программа?

Но не отвечает:

Результат правильный?

И чем важнее ответ LLM для бизнес-логики, тем опаснее смешивать эти два вопроса.

Что я в итоге считаю хорошей границей

Для production AI я бы относился к structured output примерно так же, как к входящему DTO от внешней системы.

Мы рады, что контракт соблюдён.

Но это только начало.

После него всё ещё могут понадобиться domain validation, cross-field checks, evidence, provenance, evals и иногда manual review.

Самое полезное изменение мышления здесь довольно простое:

LLM output != trusted domain object

Скорее:

LLM output
     ↓
candidate
     ↓
validation
     ↓
trusted domain object

Structured output сделал интеграцию LLM с backend намного приятнее.

Но если json.Unmarshal прошёл без ошибки, это означает только одно:

JSON удалось разобрать.

Всё остальное системе ещё предстоит доказать.

Интересно, как вы проводите эту границу в production: принимаете structured output сразу или держите отдельный validation/evidence слой между LLM и бизнес-логикой?

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.