LangGraph: как вернуть из графа только публичные поля

Разработчик подключает LangGraph к HTTP-обработчику и возвращает клиенту результат graph.invoke целиком. В браузере вместе с answer оказываются найденные документы, поисковый запрос, промежуточные оценки и служебные признаки. Пока схема маленькая, проблема незаметна. Через несколько изменений фронтенд начинает зависеть от внутреннего устройства графа.

Для узлов подробное состояние полезно: поиск передаёт кандидатов, следующий шаг выбирает материалы, а последний строит ответ. Пользователю обычно нужен другой набор: текст и идентификаторы источников. Эти два контракта можно разделить прямо при сборке StateGraph, чтобы очередное внутреннее поле не появлялось в обычном результате вызова автоматически.

Разделите три структуры данных

Вход описывает, что вызывающая сторона передаёт графу. Внутреннее состояние хранит данные между узлами. Выход задаёт поля, которые возвращает invoke. В конструкторе StateGraph для этого есть input_schema и output_schema. Общая схема состояния при этом сохраняется: узлы продолжают обмениваться найденными документами и другими промежуточными значениями.

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

Сразу определите, какие идентификаторы нужны пользователю. Если оставить только текст, интерфейс потеряет возможность открыть источник или принять обратную связь о конкретном документе. Поэтому в примере вместе с answer остаётся source_ids. Это публичные ссылки на выбранные материалы, тогда как полный список кандидатов остаётся рабочими данными графа.

Рабочий пример на StateGraph

Установите пакет командой python -m pip install langgraph и запустите следующий файл. Маленький каталог и поиск по словам позволяют проверить весь путь без ключа модели. Ответ формируется из реально найденного материала. В LLM-приложении узел respond можно заменить вызовом модели, сохранив выходной контракт.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class Input(TypedDict):
    question: str

class Output(TypedDict):
    answer: str
    source_ids: list[str]

class State(Input, Output):
    candidates: list[dict]
    query_terms: list[str]

CATALOG = [
    {"id": "refund-01", "keywords": ["возврат"],
     "text": "Заявление на возврат принимается через личный кабинет."},
    {"id": "delivery-02", "keywords": ["доставка"],
     "text": "Статус доставки доступен в карточке заказа."}
]

def retrieve(state):
    terms = state["question"].lower().split()
    found = [doc for doc in CATALOG
             if set(terms) & set(doc["keywords"])]
    return {"query_terms": terms, "candidates": found}

def respond(state):
    docs = state["candidates"]
    return {
        "answer": " ".join(doc["text"] for doc in docs)
                  if docs else "Уточните тему обращения.",
        "source_ids": [doc["id"] for doc in docs]
    }

b = StateGraph(State, input_schema=Input, output_schema=Output)
b.add_node("retrieve", retrieve)
b.add_node("respond", respond)
b.add_edge(START, "retrieve")
b.add_edge("retrieve", "respond")
b.add_edge("respond", END)
graph = b.compile()

result = graph.invoke({"question": "Как оформить возврат"})
assert set(result) == {"answer", "source_ids"}
assert result["source_ids"] == ["refund-01"]
assert "личный кабинет" in result["answer"]
empty = graph.invoke({"question": "Гарантия"})
assert empty["source_ids"] == []
print(result)
print(empty)

На вопрос о возврате граф выбирает refund-01 и возвращает соответствующий текст. Assert проверяет точный состав ключей: candidates и query_terms отсутствуют в результате. Второй вход проверяет пустую выдачу. Благодаря этому тест не ограничивается счастливым случаем, где все поля заполнились автоматически.

Здесь output_schema передана именно конструктору графа. Отдельный класс Output, который нигде не используется, ничего не фильтрует. Поэтому при переносе в приложение проверьте место создания StateGraph, а затем посмотрите фактический result, который возвращает invoke. Один тест на уровне HTTP-ответа дополнительно покажет, не дописывает ли обработчик служебные поля после графа.

Где заканчивается действие выходной схемы

output_schema фильтрует поля обычного результата, но не редактирует содержимое answer. Если модель включила во фразу сведения из внутреннего документа, удаление ключа candidates их не уберёт. Правила доступа к материалам применяйте до поиска и формирования контекста; состав публичного ответа проверяйте уже после генерации.

Похожая граница касается потоковой передачи. Обработчик, который пересылает наружу каждое обновление узлов, может сам отправить промежуточные поля. Не считайте проверку invoke доказательством безопасности всех stream-режимов. Для пользовательского потока явно выбирайте события и поля, которые интерфейс должен видеть, и проверьте реальный сетевой ответ этого endpoint.

Трассировки, сохранённое состояние и журналы тоже имеют собственных потребителей. Выходная схема не служит механизмом удаления данных из этих хранилищ. Если документы содержат персональные сведения, определите состав трассировки и правила хранения отдельно. Практические приёмы для журналов разобраны в статье об удалении персональных данных из LLM-логов.

Как закрепить контракт при изменении графа

Добавьте в внутреннее состояние новое тестовое поле и убедитесь, что набор ключей результата остался прежним. Затем проверьте, что source_ids соответствуют именно тем документам, из которых построен ответ. Это разные свойства: фильтрация ключей сохраняет форму API, а проверка источников сохраняет полезность ответа.

TypedDict помогает описать структуру для кода, но не заменяет полноценную проверку внешнего HTTP-ввода. На границе API задайте проверяемую модель запроса и ответа, а внутри графа используйте отдельные схемы для управления обменом данными. Такой разделённый контракт проще изменять, когда появятся новые шаги поиска, ранжирования и генерации.

В платном курсе LangChain 1.0.x вы соберёте Shoply Support: ответы по PDF с цитатами, инструменты, графы LangGraph и веб-приложение с API. Контракт результата становится частью приложения, которым действительно пользуется интерфейс. Откройте программу курса и изучите блоки LangGraph и деплоя, чтобы продолжить от этого примера к целому сервису.