LangChainMiddleCoding

Как реализовать историю диалога с помощью MessagesPlaceholder в LangChain?

MessagesPlaceholder — специальный слот в ChatPromptTemplate, который вставляет список объектов BaseMessage (историю диалога) напрямую в промпт, сохраняя тип каждого сообщения (HumanMessage, AIMessage, SystemMessage).

Зачем нужен MessagesPlaceholder

MessagesPlaceholder решает проблему встраивания динамического списка сообщений в шаблон промпта. В отличие от обычного {variable}, который подставляет строку, MessagesPlaceholder принимает список объектов BaseMessage и вставляет их «как есть», сохраняя роли (human, ai, system). Это критично для истории диалога: модель должна видеть чередование реплик пользователя и ассистента, а не их конкатенацию в строку.

Базовый пример с RunnableWithMessageHistory

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = ChatPromptTemplate.from_messages([
    ("system", "Ты эксперт по {domain}. Отвечай кратко."),
    MessagesPlaceholder(variable_name="chat_history"),  # история диалога
    ("human", "{input}"),                               # текущий вопрос
])

chain = prompt | llm

store: dict[str, ChatMessageHistory] = {}

def get_session_history(session_id: str) -> ChatMessageHistory:
    if session_id not in store:
        store[session_id] = ChatMessageHistory()
    return store[session_id]

chain_with_history = RunnableWithMessageHistory(
    chain,
    get_session_history,
    input_messages_key="input",
    history_messages_key="chat_history",
)

cfg = {"configurable": {"session_id": "alice"}}

reply1 = chain_with_history.invoke(
    {"input": "Что такое контекстное окно?", "domain": "LLM"},
    config=cfg,
)
print(reply1.content)

reply2 = chain_with_history.invoke(
    {"input": "А как его увеличить?", "domain": "LLM"},
    config=cfg,
)
print(reply2.content)  # Бот понимает «его» из контекста

Что происходит внутри промпта

После заполнения шаблон превращается в список сообщений:

from langchain_core.messages import HumanMessage, AIMessage

# MessagesPlaceholder подставляет реальные объекты сообщений
formatted = prompt.format_messages(
    domain="LLM",
    chat_history=[
        HumanMessage(content="Что такое контекстное окно?"),
        AIMessage(content="Контекстное окно — максимальное количество токенов..."),
    ],
    input="А как его увеличить?",
)
# Результат: [SystemMessage, HumanMessage, AIMessage, HumanMessage]
for msg in formatted:
    print(f"{msg.type}: {msg.content[:50]}")

optional=True для пустой истории

# При первом сообщении история пустая — без optional=True
# вылетит KeyError или ValidationError
prompt = ChatPromptTemplate.from_messages([
    ("system", "Ты ассистент."),
    MessagesPlaceholder(variable_name="chat_history", optional=True),
    ("human", "{input}"),
])

# Работает даже без ключа chat_history во входных данных
result = (prompt | llm).invoke({"input": "Привет!"})

Ограничение размера истории через trim_messages

from langchain_core.messages import trim_messages
from langchain_core.runnables import RunnableLambda

trimmer = trim_messages(
    max_tokens=1000,
    strategy="last",          # оставляем последние сообщения
    token_counter=llm,        # используем токенизатор модели
    include_system=True,      # системное сообщение не удалять
    allow_partial=False,
    start_on="human",         # начинаем с реплики пользователя
)

chain = (
    RunnableLambda(lambda x: {**x, "chat_history": trimmer.invoke(x["chat_history"])})
    | prompt
    | llm
)

Подводные камни

  • Имя переменной в MessagesPlaceholder(variable_name="chat_history") должно точно совпадать с history_messages_key в RunnableWithMessageHistory — несовпадение даёт KeyError в runtime
  • Без optional=True промпт падает при пустом или отсутствующем ключе истории — всегда добавляйте этот параметр
  • История хранится как объекты BaseMessage, но Redis-сериализатор по умолчанию хранит JSON — при десериализации тип сообщения (HumanMessage vs AIMessage) должен восстанавливаться корректно
  • При использовании нескольких MessagesPlaceholder в одном промпте (например, системный контекст + диалог) порядок имеет значение — LLM интерпретирует чередование ролей строго по порядку в списке
  • Без обрезки истории (trim_messages) длинные сессии превышают context limit модели — ошибка context_length_exceeded от API, часто без понятного сообщения
  • Изменение variable_name в MessagesPlaceholder требует синхронного изменения в RunnableWithMessageHistory — это не проверяется статически
  • В многопользовательском приложении при конкурентных запросах с одним session_id история может записываться в неправильном порядке — нужна блокировка на уровне store
  • Старый API ConversationChain автоматически управлял промптом с историей, но в LCEL это ответственность разработчика — легко забыть MessagesPlaceholder и получить бота без памяти

Common mistakes

  • Объяснять messagesplaceholder history только синтаксисом без shape, dtype, состояния или режима выполнения.
  • Игнорировать leakage, воспроизводимость, пустые входы и скрытые копии данных.
  • Не проверять production-симптомы: latency, память, ретраи, дрейф качества и несовпадение версий.

What the interviewer is testing

  • Может ли связать messagesplaceholder history с реальным контрактом входов и выходов.
  • Упоминает ли тесты, метрики, reproducibility и диагностику ошибок.
  • Видит ли различие между demo-кодом в ноутбуке и production-пайплайном.

Sources

Related topics