Ошибка парсинга 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.
