Перейти к содержанию

Чат с историей сессии и изоляция

Шаг 1

Сейчас наше приложение выполняет одну законченную операцию. Оно читает обращение клиента, собирает сообщения для модели, получает резюме, выводит результат и завершает работу. При следующем запуске весь процесс начинается заново.

Для резюмирования такое поведение подходит, ведь пользователь сразу передает весь текст обращения, а модели не нужно знать, что происходило раньше, то есть каждое обращение можно обработать как самостоятельную задачу.

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

Рассмотрим обычное обращение:

Заказ 8041 с кофемашиной не включается.

В одной строке уже содержится все необходимое. Из нее понятно, что товаром является кофемашина, номер заказа — 8041, а проблема состоит в том, что устройство не включается. Такое сообщение можно отправить модели отдельно, не добавляя никакой истории.

В чате та же информация часто появляется постепенно:

Пользователь:
Кофемашина не включается.

Assistant:
Уточните, пожалуйста, номер заказа.

Пользователь:
8041.

Последняя реплика состоит только из номера. Человек понимает ее, потому что видел предыдущий вопрос. Но если отправить модели только строку 8041, она не узнает, что это номер заказа, какой товар обсуждается и почему пользователь прислал этот номер.

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

Что модель видит во время запроса?

В нашем проекте метод LLMClient.generate() получает список messages. Этот список является входом конкретного вызова модели. Именно его содержимое определяет, какой контекст доступен во время генерации ответа.

Первый запрос может выглядеть так:

developer → правила работы
user      → Кофемашина не включается.

Модель получает эти два сообщения и генерирует ответ. После этого вызов завершается. Когда пользователь пишет снова, приложение выполняет новый вызов и передает новый список.

Если второй запрос выглядит так:

developer → правила работы
user      → 8041.

то для модели это отдельная задача. В новом списке нет текста о кофемашине и нет предыдущего ответа assistant. Поэтому модель не может надежно связать число 8041 с прошлой проблемой.

Это взаимодействие можно показать на схеме:

Что именно должно измениться в приложении?

Сейчас сервис собирает запрос из developer-инструкции и текущего текста пользователя:

Было:
developer + новое сообщение user

Для диалога этого недостаточно, ведь приложение должно сохранить законченный обмен сообщениями, а при следующем обращении вставить его между developer-инструкцией и новой репликой:

Станет:
developer
+ предыдущее сообщение user
+ предыдущий ответ assistant
+ новое сообщение user

Рассмотрим это подробнее. Пользователь отправляет сообщение U1. Приложение создает список [developer, U1] и передает его модели. Модель генерирует ответ A1. После успешного ответа приложение сохраняет оба сообщения — U1 и A1.

Затем пользователь отправляет новую реплику U2. Перед обращением к модели приложение берет сохраненные U1 и A1, добавляет к ним новую реплику и получает список [developer, U1, A1, U2]. Теперь модель видит не только последнее сообщение, но и разговор, к которому оно относится.

И это уже можно отобразить на схеме вот так:

То есть после генерации приложение сохраняет второй ответ A2. История становится длиннее и может использоваться в третьем ходе. Этот процесс повторяется после каждого успешного обмена сообщениями.

Кто в этой схеме хранит историю разговора? Разговор будет помнить наше Python-приложение. Оно сохранит предыдущие сообщения в своей структуре данных, а перед каждым новым вызовом заново соберет полный список для модели. При этом сама модель не открывает хранилище приложения и не ищет там прошлую переписку. LLMClient тоже не решает, какие сообщения относятся к конкретному разговору. Его задача только получить готовый список и отправить его провайдеру. Поэтому фраза «модель запомнила номер заказа» технически неточна. Правильнее сказать: «приложение сохранило сообщение с номером заказа и снова передало его модели в следующем запросе».

Такое поведение иногда описывают терминами stateless и stateful. Запоминать их как отдельную теорию не нужно: они лишь коротко называют уже понятное различие.

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

А наше приложение станет stateful, потому что будет сохранять историю между вызовами и использовать ее при сборке следующего запроса. Для пользователя чат тоже будет выглядеть stateful: короткое продолжение вроде «8041» будет обрабатываться с учетом предыдущего разговора, ведь отдельный вызов модели не хранит историю, а приложение хранит ее и каждый раз передает модели заново.

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

Шаг 2

Мы уже обсудили, что сейчас наш проект читает одно обращение, вызывает SupportService.summarize() и завершает работу. В этом шаге мы изменим только сценарий использования, и вместо одного резюме приложение сможет принимать несколько сообщений подряд. Саму историю мы добавим позже, то есть в конце этого шага программа будет выглядеть как чат, но каждое введенное сообщение по-прежнему станет отдельным запросом.

До изменения сценарий выглядел так:

одно обращение → одно резюме → завершение

А вот в конце этой лекции станет:

сообщение → ответ
сообщение → ответ
сообщение → ответ

Но сами запросы пока не связаны между собой.

Перейдем к практике и первым делом создадим отдельный промпт чата. Резюмирование обращения и ответ клиенту — разные функции продукта. Поэтому SUPPORT_SUMMARY_PROMPT продолжит описывать резюмирование, а для нового сценария мы создадим отдельный промпт.

В каталоге app/prompts создайте новый каталог support_chat:

app/
└── prompts/
    ├── support_summary/
    ├── template.py
    └── support_chat/
        ├── __init__.py
        ├── registry.py
        └── v1_0_0.py

v1_0_0.py будет содержать неизменяемую сохраненную версию промпта. registry.py будет знать обо всех доступных версиях и выбирать активную. __init__.py предоставит остальному приложению простое публичное имя SUPPORT_CHAT_PROMPT.

Создайте app/prompts/support_chat/v1_0_0.py:

from app.prompts.template import PromptTemplate

SUPPORT_CHAT_PROMPT_V1_0_0 = PromptTemplate(
    prompt_id="support_chat",
    version="1.0.0",
    body="""
Ты помогаешь клиенту в чате службы поддержки.

Правила:
- Используй только сведения из текущего диалога.
- Не придумывай данные о заказе, товаре и действиях компании.
- Не обещай возврат, замену или сроки от имени компании.
- Если данных недостаточно, задай один уточняющий вопрос.
- Отвечай кратко и по-русски.
""".strip(),
    required_variables=frozenset(),
)

После появления PromptTemplate все промпты и их метаданные описываются одинаково. Значение prompt_id="support_chat" отличает сценарий чата от support_summary. Версия 1.0.0 позволит позднее понять, с какими правилами был создан конкретный ответ. У шаблона нет переменных, поэтому required_variables содержит пустое множество. Обратите внимание: фраза «используй сведения из текущего диалога» сама по себе не создает историю. В этом шаге пока в запросе будет только текущая реплика, поэтому фактически доступного прошлого диалога у модели пока нет.

Создайте app/prompts/support_chat/registry.py:

from app.prompts.template import (
    PromptTemplate,
    PromptTemplateError,
)
from app.prompts.support_chat.v1_0_0 import (
    SUPPORT_CHAT_PROMPT_V1_0_0,
)

ACTIVE_SUPPORT_CHAT_VERSION = "1.0.0"

_SUPPORT_CHAT_PROMPTS: dict[str, PromptTemplate] = {
    SUPPORT_CHAT_PROMPT_V1_0_0.version: SUPPORT_CHAT_PROMPT_V1_0_0,
}

def get_support_chat_prompt(
    version: str | None = None,
) -> PromptTemplate:
    selected_version = (
        ACTIVE_SUPPORT_CHAT_VERSION
        if version is None
        else version.strip()
    )

    try:
        return _SUPPORT_CHAT_PROMPTS[selected_version]
    except KeyError as error:
        available = ", ".join(_SUPPORT_CHAT_PROMPTS)
        raise PromptTemplateError(
            f"Неизвестная версия support_chat: "
            f"{selected_version}. "
            f"Доступны: {available}."
        ) from error

def list_support_chat_versions() -> tuple[str, ...]:
    return tuple(_SUPPORT_CHAT_PROMPTS)

ACTIVE_SUPPORT_CHAT_VERSION указывает, какую версию приложение использует по умолчанию. Сейчас в реестре одна запись. Когда появится v1_1_0.py, старый файл останется на месте, а новый объект будет добавлен в словарь. А get_support_chat_prompt() умеет вернуть активную версию или конкретную версию, переданную аргументом. Если номера нет в реестре, приложение получит понятную ошибку со списком доступных значений.

Теперь создайте app/prompts/support_chat/__init__.py и добавьте:

from app.prompts.support_chat.registry import (
    ACTIVE_SUPPORT_CHAT_VERSION,
    get_support_chat_prompt,
    list_support_chat_versions,
)

SUPPORT_CHAT_PROMPT = get_support_chat_prompt()

__all__ = [
    "ACTIVE_SUPPORT_CHAT_VERSION",
    "SUPPORT_CHAT_PROMPT",
    "get_support_chat_prompt",
    "list_support_chat_versions",
]

Строка SUPPORT_CHAT_PROMPT = get_support_chat_prompt() выбирает активную запись из реестра. Остальному коду не нужно знать имя файла v1_0_0.py или самостоятельно обращаться к словарю, а в сервисе сохраняется короткий импорт.

Теперь в каталоге app создайте файл chat_service.py:

from dataclasses import dataclass
from time import perf_counter

from app.llm.client import LLMClient, Message
from app.llm.errors import LLMClientError
from app.prompts.support_chat import SUPPORT_CHAT_PROMPT

CHAT_INSTRUCTION = SUPPORT_CHAT_PROMPT.render()

class ChatServiceError(RuntimeError):
    pass

@dataclass(frozen=True)
class ChatReply:
    text: str
    model: str
    prompt_id: str
    prompt_version: str
    elapsed_seconds: float
    prompt_tokens: int | None
    completion_tokens: int | None
    total_tokens: int | None
    response_id: str

class ChatService:
    def __init__(self, llm_client: LLMClient) -> None:
        self._llm_client = llm_client

    def reply(self, user_text: str) -> ChatReply:
        text = user_text.strip()
        if not text:
            raise ChatServiceError("Сообщение не должно быть пустым.")

        messages = self._build_messages(text)

        started_at = perf_counter()
        try:
            llm_result = self._llm_client.generate(messages)
        except LLMClientError as error:
            raise ChatServiceError(
                f"Не удалось получить ответ модели: {error}"
            ) from error
        elapsed_seconds = perf_counter() - started_at

        if llm_result.finish_reason == "length":
            raise ChatServiceError("Ответ остановлен из-за ограничения длины.")
        if llm_result.finish_reason != "stop":
            raise ChatServiceError(
                "Модель не вернула готовый ответ. "
                f"Причина завершения: {llm_result.finish_reason}."
            )

        reply_text = self._validate_reply(llm_result.text)

        return ChatReply(
            text=reply_text,
            model=llm_result.model,
            prompt_id=SUPPORT_CHAT_PROMPT.prompt_id,
            prompt_version=SUPPORT_CHAT_PROMPT.version,
            elapsed_seconds=elapsed_seconds,
            prompt_tokens=llm_result.prompt_tokens,
            completion_tokens=llm_result.completion_tokens,
            total_tokens=llm_result.total_tokens,
            response_id=llm_result.response_id,
        )

    @staticmethod
    def _build_messages(user_text: str) -> list[Message]:
        return [
            {
                "role": "developer",
                "content": CHAT_INSTRUCTION,
            },
            {
                "role": "user",
                "content": (
                    "<customer_message>\n"
                    f"{user_text}\n"
                    "</customer_message>"
                ),
            },
        ]

    @staticmethod
    def _validate_reply(reply: str | None) -> str:
        if reply is None:
            raise ChatServiceError("Модель не вернула текст ответа.")

        cleaned_reply = reply.strip()
        if not cleaned_reply:
            raise ChatServiceError("Модель вернула пустой ответ.")

        return cleaned_reply

Теперь ChatService отвечает за сценарий продукта, а LLMClient — за интеграцию с SDK и провайдером. Такое разделение уже использовалось в SupportService.

Разберем один вызов reply():

  1. user_text.strip() убирает пробелы по краям.
  2. Пустое сообщение отклоняется до сетевого запроса.
  3. _build_messages(text) создает новый список из developer-инструкции и текущей реплики.
  4. LLMClient.generate(messages) передает этот список провайдеру.
  5. Техническая ошибка клиента преобразуется в прикладную ChatServiceError.
  6. Причина остановки length считается неполным ответом.
  7. Любая другая причина, кроме stop, отклоняется как неожиданный результат.
  8. _validate_reply() проверяет, что модель сгенерировала непустой текст.
  9. ChatReply возвращает текст и метаданные консоли.

Вызов по-прежнему проходит через созданный в первом модуле LLMClient. Поэтому ChatService не знает адрес ProxyAPI и не работает с API-ключом напрямую. Ошибки подключения, таймаута, лимитов и недостаточного баланса сначала преобразуются в ошибки LLMClient, а здесь становятся ChatServiceError.

По структуре этот код почти не отличается от уже знакомого support_service.py, и это сделано намеренно. Оба сервиса проверяют входной текст, собирают список сообщений, вызывают один и тот же LLMClient, преобразуют техническую ошибку в прикладную, проверяют результат модели и возвращают объект с текстом и метаданными. Отличается не механизм обращения к модели, а сценарий продукта. SupportService.summarize() использует SUPPORT_SUMMARY_PROMPT, принимает законченное обращение и возвращает SupportSummary с резюме. ChatService.reply() использует SUPPORT_CHAT_PROMPT, принимает отдельную реплику клиента и возвращает ChatReply с ответом для продолжения разговора. Также пользовательский текст теперь помещается в границы <customer_message>, а не <customer_request>. На этом этапе ChatService еще не хранит историю, поэтому его главное отличие пока заключается в назначении промпта и результата, а состояние диалога мы добавим позже.

Но теперь нам нужно переделать консоль в интерфейс чата, для этого откройте app/console.py и замените его содержимое:

from app.chat_service import (
    ChatReply,
    ChatService,
    ChatServiceError,
)

def print_usage(result: ChatReply) -> None:
    if result.total_tokens is None:
        print("Провайдер не вернул статистику токенов.")
        return

    print(f"Входные токены: {result.prompt_tokens}")
    print(f"Выходные токены: {result.completion_tokens}")
    print(f"Всего токенов: {result.total_tokens}")

def run_console(
        service: ChatService,
        app_env: str,
        configured_model: str,
) -> None:
    print(f"Окружение: {app_env}")
    print(f"Настроенная модель: {configured_model}")
    print("Для выхода введите /exit.")

    while True:
        user_text = input("\nВы: ")
        if user_text.strip() == "/exit":
            return

        try:
            result = service.reply(user_text)
        except ChatServiceError as error:
            print(f"Ошибка чата: {error}")
            continue

        print(f"Assistant: {result.text}")
        print(f"Промпт: {result.prompt_id}@{result.prompt_version}")
        print(f"Время ответа: {result.elapsed_seconds:.2f} с")
        print_usage(result)
        print(f"ID ответа: {result.response_id}")

Обратите внимание: цикл while True сохраняет работу Python-процесса и позволяет вызывать сервис несколько раз. А уже команда /exit обрабатывается самим интерфейсом и не отправляется модели.

Теперь консоль выглядит как чат, но пока это только многошаговый интерфейс: она повторно вызывает метод reply() и не передает ему прошлые сообщения.

Теперь подключим ChatService в main.py, для этого первым делом замените импорт сервиса:

from app.chat_service import ChatService

Далее замените SupportService:

llm_client = LLMClient(settings)
support_service = SupportService(llm_client)

На сервис чата:

llm_client = LLMClient(settings)
chat_service = ChatService(llm_client)

Передайте его в консоль:

run_console(
    chat_service,
    app_env=settings.app_env,
    configured_model=settings.model,
)

Остальной код main.py не меняется. Проверка конфигурации выполняется до создания клиента, а llm_client.close() вызывается в finally.

Понаблюдаем за независимыми запросами. Запустите проект.

Введите две реплики по очереди:

Монитор пришел с треснутой подставкой.
Номер заказа 7718.

В первом запросе модель получает описание монитора, а во втором она видит только номер заказа и developer-инструкцию. Поэтому модель может попросить уточнить, что произошло с заказом:

Если модель случайно даст правдоподобный ответ, это не доказывает наличие памяти. Она могла предположить типичный контекст. Но самое главное, что мы получили многошаговый интерфейс, и в следующем шаге мы добавим самый простой вариант состояния, когда будет использоваться один список истории для одного разговора.

Два одинаковых пункта "Разберем один вызов reply(): ...

  1. Любая другая причина, кроме stop, отклоняется как неожиданный результат.

  2. Любая другая причина, кроме stop, отклоняется как неожиданный результат.

@Дарья_Милорадова, Спасибо!

Шаг 3

После прошлого шага проект уже похож на чат: консоль остается открытой и позволяет вводить несколько сообщений. Но метод reply() каждый раз создает только два сообщения:

developer → правила
user      → текущая реплика

Предыдущий вопрос и предыдущий ответ нигде не сохраняются, а поэтому все вызовы остаются независимыми.

Теперь добавим в ChatService один список истории. Но при этом мы пока считаем, что приложением пользуется один человек и в нем существует только один разговор.

Рассмотрим первый ход:

user:
Кофемашина не включается.

assistant:
Уточните, пожалуйста, номер заказа.

После успешного ответа сохраним оба сообщения:

history = [
    user: Кофемашина не включается.
    assistant: Уточните, пожалуйста, номер заказа.
]

Когда пользователь введет:

8041.

новый запрос соберем из четырех сообщений:

developer → правила
user      → Кофемашина не включается.
assistant → Уточните, пожалуйста, номер заказа.
user      → 8041.

Важно увидеть порядок действий:

  1. пользователь вводит новую реплику
  2. приложение берет уже сохраненную историю
  3. добавляет в конец новую реплику
  4. отправляет получившийся список модели
  5. получает ответ
  6. сохраняет новую пару user → assistant

Историю нужно изменять только после успешного ответа. Если запрос завершился ошибкой, незаконченный ход не попадет в список.

Давайте реализуем эту логику в виде кода. Для этого откройте app/chat_service.py и замените его содержимое:

from dataclasses import dataclass
from time import perf_counter

from app.llm.client import LLMClient, Message
from app.llm.errors import LLMClientError
from app.prompts.support_chat import SUPPORT_CHAT_PROMPT

CHAT_INSTRUCTION = SUPPORT_CHAT_PROMPT.render()

class ChatServiceError(RuntimeError):
    pass

@dataclass(frozen=True)
class ChatReply:
    text: str
    history_messages: int
    model: str
    prompt_id: str
    prompt_version: str
    elapsed_seconds: float
    prompt_tokens: int | None
    completion_tokens: int | None
    total_tokens: int | None
    response_id: str

class ChatService:
    def __init__(self, llm_client: LLMClient) -> None:
        self._llm_client = llm_client
        self._history: list[Message] = []

    def reply(self, user_text: str) -> ChatReply:
        text = user_text.strip()
        if not text:
            raise ChatServiceError("Сообщение не должно быть пустым.")

        user_message = self._build_user_message(text)
        messages = self._build_messages(user_message)

        started_at = perf_counter()
        try:
            llm_result = self._llm_client.generate(messages)
        except LLMClientError as error:
            raise ChatServiceError(
                f"Не удалось получить ответ модели: {error}"
            ) from error
        elapsed_seconds = perf_counter() - started_at

        if llm_result.finish_reason == "length":
            raise ChatServiceError("Ответ остановлен из-за ограничения длины.")
        if llm_result.finish_reason != "stop":
            raise ChatServiceError(
                "Модель не вернула готовый ответ. "
                f"Причина завершения: {llm_result.finish_reason}."
            )

        reply_text = self._validate_reply(llm_result.text)

        assistant_message: Message = {
            "role": "assistant",
            "content": reply_text,
        }
        self._history.extend([user_message, assistant_message])

        return ChatReply(
            text=reply_text,
            history_messages=len(self._history),
            model=llm_result.model,
            prompt_id=SUPPORT_CHAT_PROMPT.prompt_id,
            prompt_version=SUPPORT_CHAT_PROMPT.version,
            elapsed_seconds=elapsed_seconds,
            prompt_tokens=llm_result.prompt_tokens,
            completion_tokens=llm_result.completion_tokens,
            total_tokens=llm_result.total_tokens,
            response_id=llm_result.response_id,
        )

    def _build_messages(
            self,
            user_message: Message,
    ) -> list[Message]:
        return [
            {
                "role": "developer",
                "content": CHAT_INSTRUCTION,
            },
            *self._history,
            user_message,
        ]

    @staticmethod
    def _build_user_message(user_text: str) -> Message:
        return {
            "role": "user",
            "content": (
                "<customer_message>\n"
                f"{user_text}\n"
                "</customer_message>"
            ),
        }

    @staticmethod
    def _validate_reply(reply: str | None) -> str:
        if reply is None:
            raise ChatServiceError("Модель не вернула текст ответа.")

        cleaned_reply = reply.strip()
        if not cleaned_reply:
            raise ChatServiceError("Модель вернула пустой ответ.")

        return cleaned_reply

Главное изменение в этом коде — новое поле:

self._history: list[Message] = []

Сразу после создания ChatService список пуст. После первого успешного ответа в нем будет два элемента, после второго — четыре. Этот список является состоянием сервиса. Он не исчезает после завершения метода reply(), потому что хранится внутри объекта ChatService.

А также у нас изменилась сборка запроса, и теперь сначала текущий текст превращается в сообщение с ролью user:

user_message = self._build_user_message(text)

Затем метод _build_messages() создает новый список:

return [
    developer_message,
    *self._history,
    user_message,
]

Запись *self._history раскрывает сохраненные элементы и помещает их между developer-инструкцией и текущей репликой.

То есть на первом ходе история пустая:

developer, current user

На втором ходе в ней уже есть первая пара:

developer, old user, old assistant, current user

Новое user_message создается до вызова модели, но еще не добавляем его в self._history.

Сначала приложение:

  • получает результат от LLMClient
  • проверяет finish_reason
  • убеждается, что текст ответа не пустой

Только после этих проверок создается assistant_message и сохраняется законченная пара:

self._history.extend([user_message, assistant_message])

Если провайдер не ответил, состояние останется прежним. Пользователь сможет повторить сообщение без скрытой незаконченной реплики в истории.

Теперь давайте добавим вывод размера истории в консоли, для этого в app/console.py после вывода ID ответа добавьте:

print(
    f"Сообщений в истории: "
    f"{result.history_messages}"
)

А значит теперь, после первого хода консоль покажет:

Сообщений в истории: 2

После второго:

Сообщений в истории: 4

Developer сообщение здесь не считается. Оно не хранится в _history, а заново добавляется в начало каждого запроса.

Теперь наше приложение можно назвать stateful, ведь в stateless-версии результат нового вызова зависел только от нового user_text. Но теперь результат зависит от двух источников:

текущий user_text
        +
содержимое self._history

self._history была создана и заполнена предыдущими вызовами, именно поэтому ChatService стал stateful.

При этом сам вызов LLMClient.generate(messages) не изменился. Он по-прежнему получает обычный готовый список. Историю хранит не модель и не LLMClient, а ChatService.

Давайте запустим проект и по очереди отправим запросы из прошлого шага:

Монитор пришел с треснутой подставкой.
Номер заказа 7718.

Теперь по ответу и росту счетчика видно, что в запрос была включена сохраненная история:

У этой версии есть важное ограничение: сейчас проект подходит только для одного разговора. В сервисе есть ровно один список self._history, и если через один объект ChatService начнут писать два пользователя, оба попадут в один список. Их сообщения смешаются.

И в следующем шаге мы решим именно эту проблему и заменим одну общую историю несколькими историями. Там же введем идентификатор, по которому приложение сможет выбрать нужный разговор.

Шаг 4

После прошлого шага ChatService действительно хранит историю, но она пока одна для всего объекта сервиса.

Для локального разговора в одной консоли этого достаточно. Для будущего backend-приложения — нет, потому что через один объект сервиса могут одновременно обращаться разные пользователи.

Представим два диалога:

Пользователь A:
Кофемашина не включается.

Пользователь B:
Клавиатура пришла без кабеля.

Если сервис сохраняет все в self._history, список может получиться таким:

user A      → Кофемашина не включается.
assistant A → ...
user B      → Клавиатура пришла без кабеля.
assistant B → ...

При следующем сообщении пользователя A модель получит и разговор пользователя B. Это одновременно:

  • ошибка логики диалога
  • риск раскрытия чужих данных
  • причина непредсказуемых ответов

Нам нужен не один список, а несколько независимых списков, поэтому переходим от списка к словарю. Ведь истории удобно хранить в словаре:

session-a → история разговора A
session-b → история разговора B
session-c → история разговора C

Ключ словаря называется Session ID — идентификатор сессии, то есть конкретного разговора. И при новом сообщении сервис сначала получает Session ID, а затем выбирает связанную с ним историю:

reply("session-a", "Номер заказа 8041")
               ↓
выбирается история session-a

Сам Session ID не нужно отправлять модели. Он нужен только Python-приложению, чтобы выбрать правильный список.

Пока мы работаем в консоли, у нас всего один локальный пользователь, поэтому мы просто создадим случайный идентификатор. В веб-приложении одного идентификатора недостаточно, ведь именно бэкенд также должен проверить, что текущему пользователю разрешено читать выбранный диалог.

Перейдем к практике, откройте app/chat_service.py и замените его содержимое:

from dataclasses import dataclass, field
from threading import Lock
from time import perf_counter

from app.llm.client import LLMClient, Message
from app.llm.errors import LLMClientError
from app.prompts.support_chat import SUPPORT_CHAT_PROMPT

CHAT_INSTRUCTION = SUPPORT_CHAT_PROMPT.render()

class ChatServiceError(RuntimeError):
    pass

@dataclass(frozen=True)
class ChatReply:
    text: str
    session_id: str
    history_messages: int
    model: str
    prompt_id: str
    prompt_version: str
    elapsed_seconds: float
    prompt_tokens: int | None
    completion_tokens: int | None
    total_tokens: int | None
    response_id: str

@dataclass
class ChatSession:
    messages: list[Message] = field(default_factory=list)
    lock: Lock = field(default_factory=Lock)

class ChatService:
    def __init__(self, llm_client: LLMClient) -> None:
        self._llm_client = llm_client
        self._sessions: dict[str, ChatSession] = {}
        self._sessions_lock = Lock()

    def reply(
        self,
        session_id: str,
        user_text: str,
    ) -> ChatReply:
        session_key = self._normalize_session_id(session_id)

        text = user_text.strip()
        if not text:
            raise ChatServiceError("Сообщение не должно быть пустым.")

        session = self._get_or_create_session(session_key)

        with session.lock:
            user_message = self._build_user_message(text)
            messages = self._build_messages(
                history=session.messages,
                user_message=user_message,
            )

            started_at = perf_counter()
            try:
                llm_result = self._llm_client.generate(messages)
            except LLMClientError as error:
                raise ChatServiceError(
                    f"Не удалось получить ответ модели: {error}"
                ) from error
            elapsed_seconds = perf_counter() - started_at

            if llm_result.finish_reason == "length":
                raise ChatServiceError("Ответ остановлен из-за ограничения длины.")
            if llm_result.finish_reason != "stop":
                raise ChatServiceError(
                    "Модель не вернула готовый ответ. "
                    f"Причина завершения: {llm_result.finish_reason}."
                )

            reply_text = self._validate_reply(llm_result.text)

            assistant_message: Message = {
                "role": "assistant",
                "content": reply_text,
            }
            session.messages.extend([user_message, assistant_message])
            history_messages = len(session.messages)

        return ChatReply(
            text=reply_text,
            session_id=session_key,
            history_messages=history_messages,
            model=llm_result.model,
            prompt_id=SUPPORT_CHAT_PROMPT.prompt_id,
            prompt_version=SUPPORT_CHAT_PROMPT.version,
            elapsed_seconds=elapsed_seconds,
            prompt_tokens=llm_result.prompt_tokens,
            completion_tokens=llm_result.completion_tokens,
            total_tokens=llm_result.total_tokens,
            response_id=llm_result.response_id,
        )

    def reset_session(self, session_id: str) -> None:
        session_key = self._normalize_session_id(session_id)

        with self._sessions_lock:
            session = self._sessions.get(session_key)

        if session is None:
            return

        with session.lock:
            session.messages.clear()

    def _get_or_create_session(
        self,
        session_id: str,
    ) -> ChatSession:
        with self._sessions_lock:
            session = self._sessions.get(session_id)
            if session is None:
                session = ChatSession()
                self._sessions[session_id] = session
            return session

    @staticmethod
    def _normalize_session_id(session_id: str) -> str:
        session_key = session_id.strip()
        if not session_key:
            raise ChatServiceError("Session ID не должен быть пустым.")
        return session_key

    @staticmethod
    def _build_user_message(user_text: str) -> Message:
        return {
            "role": "user",
            "content": (
                "<customer_message>\n"
                f"{user_text}\n"
                "</customer_message>"
            ),
        }

    @staticmethod
    def _build_messages(
        history: list[Message],
        user_message: Message,
    ) -> list[Message]:
        return [
            {
                "role": "developer",
                "content": CHAT_INSTRUCTION,
            },
            *history,
            user_message,
        ]

    @staticmethod
    def _validate_reply(reply: str | None) -> str:
        if reply is None:
            raise ChatServiceError("Модель не вернула текст ответа.")

        cleaned_reply = reply.strip()
        if not cleaned_reply:
            raise ChatServiceError("Модель вернула пустой ответ.")

        return cleaned_reply

Обратите внимание: мы создали отдельный объект ChatSession:

@dataclass
class ChatSession:
    messages: list[Message] = field(default_factory=list)
    lock: Lock = field(default_factory=Lock)

В нем находятся:

  • messages — история только этого диалога;
  • lock — блокировка, сохраняющая порядок одновременных сообщений.

default_factory=list создает отдельный пустой список для каждого объекта. Благодаря этому две сессии не разделяют одну и ту же историю.

Состояние пока хранится только в памяти процесса. Для учебной консоли этого достаточно, но backend-приложению позднее понадобятся постоянное хранилище, срок жизни сессий и удаление неактивных записей. Сам Session ID также не заменяет проверку прав пользователя.

А также теперь вместо _history появился _sessions

self._sessions: dict[str, ChatSession] = {}

То есть раньше сервис хранил один список, а теперь он хранит словарь, в котором может быть много объектов ChatSession.

Следующее важное изменение в том, что reply() получает Session ID

def reply(
    self,
    session_id: str,
    user_text: str,
) -> ChatReply:

Текст отвечает на вопрос «что написал пользователь», а Session ID — на вопрос «к какому разговору относится сообщение». Далее выбирается сессия:

session = self._get_or_create_session(session_key)

Если такой идентификатор уже встречался, метод возвращает существующую историю. Если нет — создает новую пустую сессию.

И уже сам запрос строится из messages выбранной сессии:

messages = self._build_messages(
    history=session.messages,
    user_message=user_message,
)

В прошлой лекции здесь использовался общий self._history. Теперь берется session.messages, поэтому история зависит от Session ID.

И в конце ответ сохраняется в ту же сессию:

session.messages.extend([user_message, assistant_message])

Сообщения добавляются не в глобальный список, а в объект, выбранный в начале метода.

Отдельно разберем блокировки. Консоль отправляет сообщения по очереди, но полноценное приложение в будущем сможет получить два запроса почти одновременно. _sessions_lock защищает общий словарь сессий. Без него два запроса могут одновременно решить, что нужной сессии еще нет, и создать два объекта с одним идентификатором. А уже session.lock защищает порядок внутри одного диалога. Пока сервис получает ответ на одну реплику, следующая реплика той же сессии ждет.

Для одной сессии получается последовательная логика:

первый запрос: прочитал историю → получил ответ → сохранил ход
второй запрос: прочитал обновленную историю → получил ответ → сохранил ход

У разных сессий разные блокировки. Поэтому разговор A не обязан ждать завершения разговора B.

Также у нас появился новый метод reset_session(session_id), который находит историю по идентификатору и очищает только ее.

Теперь обновим код консоли: именно она должна создать идентификатор и передавать его в reply(). Откройте app/console.py и замените его содержимое:

from uuid import uuid4

from app.chat_service import (
    ChatReply,
    ChatService,
    ChatServiceError,
)

def print_usage(result: ChatReply) -> None:
    if result.total_tokens is None:
        print("Провайдер не вернул статистику токенов.")
        return

    print(f"Входные токены: {result.prompt_tokens}")
    print(f"Выходные токены: {result.completion_tokens}")
    print(f"Всего токенов: {result.total_tokens}")

def run_console(
    service: ChatService,
    app_env: str,
    configured_model: str,
) -> None:
    print(f"Окружение: {app_env}")
    print(f"Настроенная модель: {configured_model}")

    session_id = uuid4().hex
    print(f"Session ID: {session_id}")
    print("Команды: /new — новый диалог, /exit — выход.")

    while True:
        user_text = input("\nВы: ")
        command = user_text.strip()

        if command == "/exit":
            return

        if command == "/new":
            service.reset_session(session_id)
            session_id = uuid4().hex
            print(f"Новый Session ID: {session_id}")
            continue

        try:
            result = service.reply(session_id, user_text)
        except ChatServiceError as error:
            print(f"Ошибка чата: {error}")
            continue

        print(f"Assistant: {result.text}")
        print(f"Session ID ответа: {result.session_id}")
        print(f"Сообщений в истории: {result.history_messages}")
        print(f"Промпт: {result.prompt_id}@{result.prompt_version}")
        print(f"Время ответа: {result.elapsed_seconds:.2f} с")
        print_usage(result)
        print(f"ID ответа: {result.response_id}")

Давайте сразу обсудим команды и их действия:

/exit завершает приложение. Команда не отправляется модели и не сохраняется в истории.

/new выполняет три действия:

  1. очищает историю текущей сессии;
  2. создает новый Session ID;
  3. продолжает цикл с новым разговором.

Код готов к проверке. В следующем шаге ничего дописывать не будем: запустим проект и последовательно проверим продолжение разговора, новую сессию и потерю истории после перезапуска.

Шаг 5

В этом шаге запустим проект и наглядно проверим продолжение разговора, рост истории, границу новой сессии и потерю состояния после перезапуска.

Давайте запустим наше приложение. В начале консоль должна показать:

  • окружение
  • настроенную модель
  • Session ID
  • команды /new и /exit

Session ID будет случайным, поэтому его значение не обязано совпадать с примером:

Сценарий 1. Начинаем новый разговор

Введите одной строкой:

Здравствуйте. Получил заказ 8041 с кофемашиной, но она не включается.

Модель получает:

developer → правила чата
user      → первое сообщение клиента

Она может предложить безопасные первичные действия или задать уточняющий вопрос. Формулировка зависит от модели, но ответ должен относиться к заказу 8041 и неработающей кофемашине.

После успешного ответа обратите внимание на строку:

Сообщений в истории: 2

В ChatSession.messages появились:

user      → сообщение о кофемашине
assistant → сгенерированный ответ

Developer message в это число не входит: он добавляется отдельно при сборке каждого запроса.

Сценарий 2. Отправляем зависимую реплику

Не закрывая приложение, отправьте:

Я уже проверил две розетки. Что делать дальше?

В этой реплике не повторяются слова «заказ 8041» и «кофемашина». Чтобы ответить по существу, модели нужен прошлый ход.

Перед вторым вызовом ChatService собирает:

developer → правила чата
user      → сообщение о заказе 8041 и кофемашине
assistant → первый ответ модели
user      → сообщение о двух розетках

Модель должна продолжить обсуждение той же неисправности, а не спрашивать, какой товар имеется в виду. Это наглядное проявление stateful-поведения backend.

После ответа история должна увеличиться:

Сообщений в истории: 4

Также обычно увеличится значение prompt_tokens, потому что во второй запрос вошли предыдущая реплика и предыдущий ответ. Точное число зависит от модели, токенизатора и текста первого ответа.

Сценарий 3. Проверяем еще одну ссылку на прошлый контекст

Отправьте:

Коробка была целой. Это что-то меняет?

Модель должна понимать, что речь по-прежнему идет о той же кофемашине. Перед третьей генерацией список содержит уже два законченных хода и новую реплику.

Теперь мы получаем:

Сообщений в истории: 6

Обратите внимание: модель не читает историю из словаря самостоятельно. ChatService сначала извлекает нужный ChatSession, затем добавляет его сообщения в новый список и только после этого вызывает LLMClient.

Связный ответ является хорошей демонстрацией, но поведение модели вероятностное. Возможны варианты:

  • модель повторно спросит часть уже известных данных
  • ответ окажется слишком общим
  • модель предложит другую последовательность безопасных действий
  • формулировка будет отличаться при повторном запуске

Сценарий 4. Начинаем новый диалог

Введите команду:

/new

Консоль должна показать другой Session ID:

Новый Session ID: a29c...

Команда не отправляется модели. Консоль вызывает reset_session(), очищает прежнюю историю и создает новый идентификатор.

Теперь отправьте:

А когда его доставили?

Местоимение «его» ссылалось бы на заказ или товар из прошлого разговора. Но новый диалог не содержит ни кофемашину, ни номер 8041. Модель должна попросить уточнить, о каком заказе или товаре идет речь.

После ответа значение снова начинается с двух:

Сообщений в истории: 2

Это важное наблюдение: приложение stateful не глобально, а в пределах выбранной сессии. Новый Session ID обозначает новую историю.

Даже без старых сообщений модель может предположить, что «его» означает заказ. Это обычное продолжение вероятного текста, но уж точно не доказательство скрытой памяти. Но в новой сессии модель не будет знать:

  • номер 8041
  • что товар является кофемашиной
  • что уже были проверены две розетки
  • что коробка была целой

В нашем случае модель просит заново назвать заказ или товар, граница работает ожидаемо.

Сценарий 5. Проверяем пустое сообщение

Нажмите Enter, не вводя текст. Сервис должен вывести прикладную ошибку:

Ошибка чата: Сообщение не должно быть пустым.

Сетевой запрос при этом не выполняется, а история не изменяется. Следующий успешный ход должен увеличить прежнее значение только на два.

Сценарий 6. Перезапускаем приложение

Завершите чат:

/exit

Затем снова запустите проект, и у нас опять появится новый Session ID. Отправьте:

Что делать с той кофемашиной?

Новый объект ChatService только что создан в main.py, поэтому его словарь _sessions пуст. Модель не должна знать детали прежнего разговора. Так мы видим ограничение хранения в памяти, а значит приложение было stateful во время работы процесса, но не сохранило состояние между запусками.

То есть текущий ChatSession хранит точные реплики одного разговора. Эта история:

  • привязана к одному Session ID
  • используется только в последующих ходах этой сессии
  • очищается командой /new
  • исчезает после завершения процесса
  • не выбирает важные факты для будущих диалогов

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

Шаг 6

Пользователь сначала написал «Кофемашина не включается», а затем отправил только «Номер заказа 8041». Почему модель не сможет связать эти реплики, если приложение отправит второй запрос без истории?

Правильный ответ
  • ✅ Модель видит только сообщения текущего API-запроса и не хранит историю приложения сама

Шаг 7

Какие правила формирования запроса с историей верны?

Правильный ответ
  • ✅ Новое user-сообщение должно находиться после истории
  • ✅ В историю следует добавлять законченный ход user/assistant
  • ✅ Сохранённые сообщения user и assistant нужно передавать в исходном порядке
  • ✅ Developer-инструкция должна стоять первой

Шаг 8

Когда безопасно добавлять новый ход user/assistant в историю сессии?

Правильный ответ
  • ✅ После успешного ответа, проверки finish_reason и валидации непустого текста

Шаг 9

Какие меры нужны, чтобы диалоги разных пользователей не смешивались?

Правильный ответ
  • ✅ Команда нового диалога должна очищать только выбранную сессию
  • ✅ Выбирать историю по нормализованному session_id перед запросом
  • ✅ Операции одного диалога следует защищать отдельной блокировкой при конкурентных запросах
  • ✅ Хранить отдельную историю для каждого session_id

Шаг 10

Подготовка запроса с историей диалога

Для новой реплики модель должна получить постоянную инструкцию, сохранённую историю и текущее сообщение пользователя. Так вы закрепите правильный порядок сообщений и изоляцию историй разных диалогов.

Задача

Реализуйте build_chat_messages(instruction, history, user_text).

Требования

  • instruction и user_text должны быть строками. Для другого типа выбросьте TypeError, а для пустого значения после strip() — ValueError.
  • Первым добавьте сообщение роли developer с очищенной инструкцией.
  • После него добавьте все сообщения history без изменения порядка.
  • Последним добавьте новое сообщение роли user.
  • Поместите очищенный пользовательский текст внутрь тегов <customer_message>.
  • Не изменяйте исходный список history.

Пример результата

history = [
    {"role": "user", "content": "Кофемашина не включается."},
    {"role": "assistant", "content": "Укажите номер заказа."},
]

build_chat_messages("Правила чата", history, "8041")
# developer, старый user, старый assistant, новый user

Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.

Правильный ответ
def build_chat_messages(
    instruction: str,
    history: list[dict[str, str]],
    user_text: str,
) -> list[dict[str, str]]:
    if not isinstance(instruction, str) or not isinstance(user_text, str):
        raise TypeError

    instruction = instruction.strip()
    user_text = user_text.strip()

    if not instruction or not user_text:
        raise ValueError

    messages = [
        {
            "role": "developer",
            "content": instruction,
        }
    ]
    messages.extend(history)
    messages.append(
        {
            "role": "user",
            "content": f"<customer_message>\n{user_text}\n</customer_message>",
        }
    )
    return messages

Шаг 11

Сохранение завершённого хода

После успешного ответа в историю нужно добавить сообщение пользователя и проверенный ответ модели. Оба сообщения должны появиться вместе и в правильном порядке.

Задача

Реализуйте commit_turn(history, user_text, assistant_text). Функция изменяет переданный список и возвращает новое количество сообщений.

Требования

  • user_text и assistant_text должны быть строками. Для другого типа выбросьте TypeError и не изменяйте history.
  • Очистите оба текста через strip().
  • Если один из текстов пуст, выбросьте ValueError и не изменяйте историю.
  • Добавьте сначала сообщение user, затем сообщение assistant.
  • Верните len(history) после добавления.

Пример результата

history = []
commit_turn(history, "  Номер заказа 8041. ", "  Спасибо, номер сохранён. ")
# 2

history
# [
#   {"role": "user", "content": "Номер заказа 8041."},
#   {"role": "assistant", "content": "Спасибо, номер сохранён."},
# ]

Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.

Правильный ответ
def commit_turn(
    history: list[dict[str, str]],
    user_text: str,
    assistant_text: str,
) -> int:
    if not isinstance(user_text, str) or not isinstance(assistant_text, str):
        raise TypeError

    user_text = user_text.strip()
    assistant_text = assistant_text.strip()

    if not user_text or not assistant_text:
        raise ValueError

    history.append({
        "role": "user",
        "content": user_text,
    })

    history.append({
        "role": "assistant",
        "content": assistant_text,
    })

    return len(history)

Шаг 12

Изолированное хранилище историй

Один сервис обслуживает несколько диалогов. История каждой сессии должна храниться отдельно и не должна изменяться через список, полученный вызывающим кодом.

Задача

Реализуйте класс SessionHistory с методами add_turn(), get_messages() и reset().

Требования

  • В __init__ создайте пустой словарь историй.
  • session_id должен быть строкой. Для другого типа выбросьте TypeError, а для пустого значения после strip() — ValueError.
  • add_turn(session_id, user_text, assistant_text) принимает строковые тексты, очищает их через strip() и добавляет в выбранную сессию сначала сообщение user, затем assistant.
  • Для нестрокового текста выбросьте TypeError, а для пустого после очистки — ValueError.
  • get_messages(session_id) возвращает новый список с копиями словарей. Для неизвестной сессии верните пустой список.
  • reset(session_id) очищает только выбранную сессию. Для неизвестной сессии ничего не делает.
  • Данные разных session_id не должны смешиваться.

Пример результата

store = SessionHistory()
store.add_turn("session-a", "Заказ 8041.", "Уточните проблему.")
store.add_turn("session-b", "Заказ 991.", "Уточните проблему.")

store.get_messages("session-a")
# содержит только сообщения session-a

store.reset("session-a")
store.get_messages("session-a")
# []

Заготовка кода уже находится в редакторе. Дополнительные сторонние библиотеки для решения не нужны.

Правильный ответ
class SessionHistory:
    def __init__(self) -> None:
        self.histories: dict[str, list[dict[str, str]]] = {}

    def _validate_session_id(self, session_id: str) -> str:
        if not isinstance(session_id, str):
            raise TypeError

        session_id = session_id.strip()

        if not session_id:
            raise ValueError

        return session_id

    def add_turn(
        self,
        session_id: str,
        user_text: str,
        assistant_text: str,
    ) -> None:
        session_id = self._validate_session_id(session_id)

        if not isinstance(user_text, str) or not isinstance(assistant_text, str):
            raise TypeError

        user_text = user_text.strip()
        assistant_text = assistant_text.strip()

        if not user_text or not assistant_text:
            raise ValueError

        history = self.histories.setdefault(session_id, [])

        history.append({
            "role": "user",
            "content": user_text,
        })

        history.append({
            "role": "assistant",
            "content": assistant_text,
        })

    def get_messages(
        self,
        session_id: str,
    ) -> list[dict[str, str]]:
        session_id = self._validate_session_id(session_id)

        history = self.histories.get(session_id, [])

        return [message.copy() for message in history]

    def reset(self, session_id: str) -> None:
        session_id = self._validate_session_id(session_id)

        if session_id in self.histories:
            self.histories[session_id].clear()