Ошибка парсинга LLM-ответа в LangChain: когда повторять запрос

В письме клиент пишет: «Договор AB-2041 закрыли в мае. По CD-1020 прошу перенести срок оплаты». Приложение должно выбрать договор, к которому относится просьба, и передать заявку сотруднику. Поиск регулярным выражением найдёт оба номера, но не объяснит, какой из них клиент хочет изменить. Модель помогает разобрать смысл письма. Затем приходит проблема: её первый ответ нарушает формат, а повтор может сделать JSON аккуратным, сохранив ошибочный выбор.

За что отвечает повтор

Пусть мы просим вернуть contract_id и короткую дословную цитату из письма в поле evidence. Ответ {"contract_id":1020,"evidence":"По CD-1020 прошу перенести срок оплаты"} содержит число там, где ожидается строка. Заодно он потерял префикс CD-. Простым приведением типа это не исправить: строка "1020" всё ещё не номер договора. Можно один раз снова попросить модель извлечь точное написание из письма.

Другая ситуация: модель вернула правильный JSON с AB-2041. Номер действительно есть в письме, поэтому проверка по списку найденных номеров его пропустит. Но просьба относится к CD-1020. Совпадение с исходным текстом подтверждает только происхождение значения, а не правильность выбора. Если письмо допускает несколько кандидатов, автоматическое действие останавливается, а предложенный моделью выбор и цитата идут человеку на проверку. Для продукта с чёткими правилами можно добавить отдельную предметную проверку смысла.

Небольшая исполнимая политика

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

import json
import re

PATTERN = re.compile(r"\b[A-Z]{2}-\d{4}\b")


def extract(letter, ask_model, max_attempts=2):
    numbers = set(PATTERN.findall(letter))
    if not numbers:
        return {"status": "no_id_found", "contract_id": None, "attempts": 0}

    feedback = "Return JSON with exact contract_id and a verbatim evidence quote."
    for attempt in range(1, max_attempts + 1):
        raw = ask_model(letter, feedback)
        try:
            data = json.loads(raw)
            if not isinstance(data, dict) or set(data) != {"contract_id", "evidence"}:
                raise ValueError("expected contract_id and evidence only")
            contract_id, evidence = data["contract_id"], data["evidence"]
            if contract_id is None and evidence is None:
                return {"status": "no_choice", "contract_id": None,
                        "evidence": None, "attempts": attempt}
            if not isinstance(contract_id, str) or not isinstance(evidence, str):
                raise ValueError("both fields must be strings, or both null")
        except (json.JSONDecodeError, ValueError) as exc:
            feedback = f"Fix the format: {exc}. Copy the complete ID from the letter."
            continue

        if (contract_id not in numbers or evidence not in letter or
                contract_id not in evidence):
            return {"status": "unsupported", "contract_id": contract_id,
                    "evidence": evidence, "attempts": attempt}
        if len(numbers) > 1:
            return {"status": "review_choice", "contract_id": contract_id,
                    "evidence": evidence, "attempts": attempt}
        return {"status": "ok", "contract_id": contract_id,
                "evidence": evidence, "attempts": attempt}

    return {"status": "format_failed", "contract_id": None,
            "attempts": max_attempts}


def fake_model(replies):
    replies = iter(replies)
    return lambda letter, feedback: next(replies)


single = "По CD-1020 прошу перенести срок оплаты."
mixed = "AB-2041 закрыли в мае. По CD-1020 прошу перенести срок оплаты."
print(extract(single, fake_model([
    '{"contract_id":1020,"evidence":"По CD-1020 прошу перенести срок оплаты"}',
    '{"contract_id":"CD-1020","evidence":"По CD-1020 прошу перенести срок оплаты"}'
])))
print(extract(mixed, fake_model([
    '{"contract_id":"CD-1020","evidence":"По CD-1020 прошу перенести срок оплаты"}'
])))
print(extract(mixed, fake_model([
    '{"contract_id":"AB-2041","evidence":"По CD-1020 прошу перенести срок оплаты"}'
])))

Первый вызов вернёт ok за две попытки: модель исправила ответ, а номер и цитата совпали с письмом. Во втором письме два номера, поэтому выбранный CD-1020 попадёт в review_choice. Третий ответ связывает AB-2041 с цитатой о другом договоре и получит unsupported без нового запроса к модели.

Поля могут быть null, если модель не может обосновать выбор. Тогда статус no_choice направляет письмо на уточнение без новых попыток. Так схема не заставляет модель придумывать номер ради заполнения обязательной строки. Статус no_id_found означает другое: известный приложению формат номера вообще не встретился в письме. Его можно передать человеку или обработать отдельным способом, если договоры иногда пишут без префикса.

После двух ответов с нарушенной структурой функция вернёт format_failed. Сохраните этот статус и число попыток рядом с письмом, чтобы отличать сбой формата от спорного выбора договора.

Где подключить это в LangChain

В LangChain 1.x агент может получать структурированный результат через response_format. У ToolStrategy параметр handle_errors=False отключает встроенный повтор при ошибке структуры и передаёт исключение приложению. Тогда приложение само ограничивает число новых вызовов. Короткий фрагмент ниже показывает это место; chat_model — настроенная модель вашего провайдера. После возврата ContractChoice примените проверку цитаты и кандидатов из примера выше до записи в CRM.

from pydantic import BaseModel, ConfigDict
from langchain.agents import create_agent
from langchain.agents.structured_output import (
    MultipleStructuredOutputsError,
    StructuredOutputValidationError,
    ToolStrategy,
)


class ContractChoice(BaseModel):
    model_config = ConfigDict(extra="forbid")
    contract_id: str | None
    evidence: str | None


def choose_with_langchain(chat_model, letter):
    agent = create_agent(
        model=chat_model,
        tools=[],
        response_format=ToolStrategy(ContractChoice, handle_errors=False),
    )
    request = "Choose the contract the customer wants to change. " \
              "Return its exact ID and a verbatim quote. " \
              "If no choice is justified, set both fields to null. Letter: " + letter
    for attempt in range(2):
        try:
            result = agent.invoke({"messages": [{"role": "user", "content": request}]})
            return result["structured_response"]
        except (StructuredOutputValidationError, MultipleStructuredOutputsError):
            if attempt == 1:
                raise
            request += " Check field types and copy the complete ID exactly."

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

В платном курсе LangChain 1.0.x: с нуля до продакшн LLM-приложений есть открытый урок о JSON, Pydantic и валидации. Затем студент собирает бота, который отвечает по PDF с цитатами, подключает инструменты и API, измеряет качество и стоимость ответов, добавляет повторы и таймауты и связывает всё с интерфейсом. Это продолжение задачи из статьи на уровне целого приложения. Открыть программу курса.

Рядом с этой темой — отсутствующие поля и null, проверка завершённого потокового JSON и другие практические разборы LangChain.