Разработка ИИ-агентов · Модуль 2 · Урок 2.4

Структурированный вывод и его парсинг

Зачем нужен структурированный вывод

Часто от модели нужен не свободный текст, а данные, которые программа сможет дальше обработать: категория, список полей, решение «да/нет» с обоснованием. Если просто попросить «ответь в JSON», модель иногда добавит пояснения до или после, вставит markdown-обёртку или слегка нарушит схему — и парсинг упадёт. Нужна надёжность.

Структурированный вывод — это получение ответа в заранее заданной форме, пригодной для машинного разбора. Это мост между «модель порассуждала» и «программа дальше действует по данным».

Способы получить структуру надёжно

  • Через tool calling. Самый надёжный приём: описать нужную структуру как инструмент (схему) и заставить модель «вызвать» его. Тогда аргументы вызова и есть ваш типизированный объект, провалидированный по JSON Schema. Часто это удобнее, чем парсить текст.
  • Режим строгого JSON / structured outputs. Некоторые провайдеры гарантируют валидный JSON по схеме (имена параметров версионно-зависимы — сверяйтесь с доками).
  • Промпт + строгий парсинг. Если первых двух нет: чётко задать схему в промпте, попросить «только JSON без пояснений», а в коде — извлечь JSON, распарсить и провалидировать.

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

Парсинг и восстановление

Хороший разбор структурированного ответа в Go: определить целевой тип, распарсить json.Unmarshal, затем провалидировать значения (обязательные поля, диапазоны, enum). Если разбор не удался — не падать, а попросить модель исправиться: вернуть ей текст ошибки и попросить прислать корректный JSON по схеме. Один-два таких ретрая обычно решают проблему.

Полезные мелочи: срезать markdown-ограждения (), не доверять порядку полей, задавать значения по умолчанию для необязательных, логировать сырой ответ при ошибке — это бесценно при отладке.

Целевой тип, разбор и валидация структурированного ответа
// Ожидаемая структура решения модели.
type Triage struct {
    Category string `json:"category"`
    Urgent   bool   `json:"urgent"`
    Reason   string `json:"reason"`
}

var allowed = map[string]bool{"billing": true, "tech": true, "other": true}

func parseTriage(raw string) (Triage, error) {
    raw = stripFences(raw) // убрать ``` если модель их добавила

    var t Triage
    if err := json.Unmarshal([]byte(raw), &t); err != nil {
        return t, fmt.Errorf("невалидный JSON: %w", err)
    }
    if !allowed[t.Category] {
        return t, fmt.Errorf("недопустимая категория: %q", t.Category)
    }
    if t.Reason == "" {
        return t, fmt.Errorf("пустое поле reason")
    }
    return t, nil
}
Ретрай при невалидном ответе: вернуть модели ошибку и попросить исправить
func triageWithRetry(input string) (Triage, error) {
    schema := "Верни ТОЛЬКО JSON: {\"category\":\"billing|tech|other\"," +
        "\"urgent\":true|false,\"reason\":\"...\"}"

    msgs := []Message{
        {Role: "system", Content: schema},
        {Role: "user", Content: input},
    }
    for attempt := 0; attempt < 3; attempt++ {
        choice, err := callLLM(msgs, nil)
        if err != nil {
            return Triage{}, err
        }
        t, perr := parseTriage(choice.Content)
        if perr == nil {
            return t, nil
        }
        // подкладываем ответ модели и текст ошибки — просим исправиться
        msgs = append(msgs,
            Message{Role: "assistant", Content: choice.Content},
            Message{Role: "user", Content: "Ошибка разбора: " + perr.Error() + ". Пришли корректный JSON."})
    }
    return Triage{}, fmt.Errorf("не удалось получить валидный JSON за 3 попытки")
}

Anti-patterns

Анти-паттернПочему плохоКак правильно
Парсить свободный текст регэкспамиХрупко: формат «плывёт» от запроса к запросуTool calling / строгий JSON-режим со схемой
Доверять JSON без валидацииПропущенные/чужие значения ломают логику дальшеПроверять обязательные поля, enum, диапазоны
Падать при первом невалидном ответеСлучайный сбой роняет весь запрос1–2 ретрая: вернуть ошибку модели и попросить исправить
Не срезать markdown-огражденияjson ... ломает json.UnmarshalСнимать ограждения/префиксы перед разбором

Практическое задание

  • Определите Go-тип для нужного вам структурированного ответа (например, классификацию с полями).
  • Получите структуру через tool calling (схему как инструмент) ИЛИ через строгий JSON-промпт.
  • Реализуйте parse, который делает json.Unmarshal и валидирует поля (обязательные, enum).
  • Добавьте ретрай: при ошибке верните модели текст ошибки и попросите прислать корректный JSON.
  • Залогируйте сырой ответ при сбое разбора — пригодится при отладке.

Проверка знаний

Какой приём обычно даёт самый надёжный структурированный вывод?

  • A Попросить «ответь в JSON» и парсить регэкспом
  • B Описать нужную структуру как инструмент (схему) и получить её из аргументов tool calling
  • C Поднять температуру
  • D Просить ответ в свободной форме и угадывать поля

Что делать, если модель прислала невалидный JSON?

  • A Сразу аварийно завершить запрос
  • B Вернуть модели текст ошибки и попросить прислать корректный JSON (1–2 ретрая), затем сдаться с понятной ошибкой
  • C Принять как есть и надеяться
  • D Удалить все скобки и парсить снова