Чат с историей сессии и изоляция¶
Шаг 1¶
Сейчас наше приложение выполняет одну законченную операцию. Оно читает обращение клиента, собирает сообщения для модели, получает резюме, выводит результат и завершает работу. При следующем запуске весь процесс начинается заново.
Для резюмирования такое поведение подходит, ведь пользователь сразу передает весь текст обращения, а модели не нужно знать, что происходило раньше, то есть каждое обращение можно обработать как самостоятельную задачу.
Но в этом разделе мы хотим создать другой сценарий — чат службы поддержки. Программа больше не должна завершаться после первого ответа, то есть пользователь сможет отправлять новые реплики, уточнять детали и ссылаться на информацию, которую уже сообщил раньше.
Рассмотрим обычное обращение:
В одной строке уже содержится все необходимое. Из нее понятно, что товаром является кофемашина, номер заказа — 8041, а проблема состоит в том, что устройство не включается. Такое сообщение можно отправить модели отдельно, не добавляя никакой истории.
В чате та же информация часто появляется постепенно:
Пользователь:
Кофемашина не включается.
Assistant:
Уточните, пожалуйста, номер заказа.
Пользователь:
8041.
Последняя реплика состоит только из номера. Человек понимает ее, потому что видел предыдущий вопрос. Но если отправить модели только строку 8041, она не узнает, что это номер заказа, какой товар обсуждается и почему пользователь прислал этот номер.
Смысл последней реплики находится не только в ней самой, но и в предыдущих сообщениях. Поэтому при продолжении диалога приложению нужно передать модели прошлый фрагмент разговора вместе с новым текстом.
Что модель видит во время запроса?¶
В нашем проекте метод LLMClient.generate() получает список messages. Этот список является входом конкретного вызова модели. Именно его содержимое определяет, какой контекст доступен во время генерации ответа.
Первый запрос может выглядеть так:
Модель получает эти два сообщения и генерирует ответ. После этого вызов завершается. Когда пользователь пишет снова, приложение выполняет новый вызов и передает новый список.
Если второй запрос выглядит так:
то для модели это отдельная задача. В новом списке нет текста о кофемашине и нет предыдущего ответа assistant. Поэтому модель не может надежно связать число 8041 с прошлой проблемой.
Это взаимодействие можно показать на схеме:
Что именно должно измениться в приложении?¶
Сейчас сервис собирает запрос из developer-инструкции и текущего текста пользователя:
Для диалога этого недостаточно, ведь приложение должно сохранить законченный обмен сообщениями, а при следующем обращении вставить его между developer-инструкцией и новой репликой:
Рассмотрим это подробнее. Пользователь отправляет сообщение 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():
user_text.strip()убирает пробелы по краям.- Пустое сообщение отклоняется до сетевого запроса.
_build_messages(text)создает новый список из developer-инструкции и текущей реплики.LLMClient.generate(messages)передает этот список провайдеру.- Техническая ошибка клиента преобразуется в прикладную
ChatServiceError. - Причина остановки
lengthсчитается неполным ответом. - Любая другая причина, кроме
stop, отклоняется как неожиданный результат. _validate_reply()проверяет, что модель сгенерировала непустой текст.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, для этого первым делом замените импорт сервиса:
Далее замените SupportService:
На сервис чата:
Передайте его в консоль:
Остальной код main.py не меняется. Проверка конфигурации выполняется до создания клиента, а llm_client.close() вызывается в finally.
Понаблюдаем за независимыми запросами. Запустите проект.
Введите две реплики по очереди:
В первом запросе модель получает описание монитора, а во втором она видит только номер заказа и developer-инструкцию. Поэтому модель может попросить уточнить, что произошло с заказом:
Если модель случайно даст правдоподобный ответ, это не доказывает наличие памяти. Она могла предположить типичный контекст. Но самое главное, что мы получили многошаговый интерфейс, и в следующем шаге мы добавим самый простой вариант состояния, когда будет использоваться один список истории для одного разговора.
Два одинаковых пункта "Разберем один вызов reply(): ...
-
Любая другая причина, кроме
stop, отклоняется как неожиданный результат. -
Любая другая причина, кроме
stop, отклоняется как неожиданный результат.
@Дарья_Милорадова, Спасибо!
Шаг 3¶
После прошлого шага проект уже похож на чат: консоль остается открытой и позволяет вводить несколько сообщений. Но метод reply() каждый раз создает только два сообщения:
Предыдущий вопрос и предыдущий ответ нигде не сохраняются, а поэтому все вызовы остаются независимыми.
Теперь добавим в ChatService один список истории. Но при этом мы пока считаем, что приложением пользуется один человек и в нем существует только один разговор.
Рассмотрим первый ход:
После успешного ответа сохраним оба сообщения:
Когда пользователь введет:
новый запрос соберем из четырех сообщений:
developer → правила
user → Кофемашина не включается.
assistant → Уточните, пожалуйста, номер заказа.
user → 8041.
Важно увидеть порядок действий:
- пользователь вводит новую реплику
- приложение берет уже сохраненную историю
- добавляет в конец новую реплику
- отправляет получившийся список модели
- получает ответ
- сохраняет новую пару
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
Главное изменение в этом коде — новое поле:
Сразу после создания ChatService список пуст. После первого успешного ответа в нем будет два элемента, после второго — четыре. Этот список является состоянием сервиса. Он не исчезает после завершения метода reply(), потому что хранится внутри объекта ChatService.
А также у нас изменилась сборка запроса, и теперь сначала текущий текст превращается в сообщение с ролью user:
Затем метод _build_messages() создает новый список:
Запись *self._history раскрывает сохраненные элементы и помещает их между developer-инструкцией и текущей репликой.
То есть на первом ходе история пустая:
На втором ходе в ней уже есть первая пара:
Новое user_message создается до вызова модели, но еще не добавляем его в self._history.
Сначала приложение:
- получает результат от
LLMClient - проверяет
finish_reason - убеждается, что текст ответа не пустой
Только после этих проверок создается assistant_message и сохраняется законченная пара:
Если провайдер не ответил, состояние останется прежним. Пользователь сможет повторить сообщение без скрытой незаконченной реплики в истории.
Теперь давайте добавим вывод размера истории в консоли, для этого в app/console.py после вывода ID ответа добавьте:
А значит теперь, после первого хода консоль покажет:
После второго:
Developer сообщение здесь не считается. Оно не хранится в _history, а заново добавляется в начало каждого запроса.
Теперь наше приложение можно назвать stateful, ведь в stateless-версии результат нового вызова зависел только от нового user_text. Но теперь результат зависит от двух источников:
self._history была создана и заполнена предыдущими вызовами, именно поэтому ChatService стал stateful.
При этом сам вызов LLMClient.generate(messages) не изменился. Он по-прежнему получает обычный готовый список. Историю хранит не модель и не LLMClient, а ChatService.
Давайте запустим проект и по очереди отправим запросы из прошлого шага:
Теперь по ответу и росту счетчика видно, что в запрос была включена сохраненная история:
У этой версии есть важное ограничение: сейчас проект подходит только для одного разговора. В сервисе есть ровно один список self._history, и если через один объект ChatService начнут писать два пользователя, оба попадут в один список. Их сообщения смешаются.
И в следующем шаге мы решим именно эту проблему и заменим одну общую историю несколькими историями. Там же введем идентификатор, по которому приложение сможет выбрать нужный разговор.
Шаг 4¶
После прошлого шага ChatService действительно хранит историю, но она пока одна для всего объекта сервиса.
Для локального разговора в одной консоли этого достаточно. Для будущего backend-приложения — нет, потому что через один объект сервиса могут одновременно обращаться разные пользователи.
Представим два диалога:
Если сервис сохраняет все в self._history, список может получиться таким:
user A → Кофемашина не включается.
assistant A → ...
user B → Клавиатура пришла без кабеля.
assistant B → ...
При следующем сообщении пользователя A модель получит и разговор пользователя B. Это одновременно:
- ошибка логики диалога
- риск раскрытия чужих данных
- причина непредсказуемых ответов
Нам нужен не один список, а несколько независимых списков, поэтому переходим от списка к словарю. Ведь истории удобно хранить в словаре:
Ключ словаря называется Session ID — идентификатор сессии, то есть конкретного разговора. И при новом сообщении сервис сначала получает Session ID, а затем выбирает связанную с ним историю:
Сам 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
То есть раньше сервис хранил один список, а теперь он хранит словарь, в котором может быть много объектов ChatSession.
Следующее важное изменение в том, что reply() получает Session ID
Текст отвечает на вопрос «что написал пользователь», а Session ID — на вопрос «к какому разговору относится сообщение». Далее выбирается сессия:
Если такой идентификатор уже встречался, метод возвращает существующую историю. Если нет — создает новую пустую сессию.
И уже сам запрос строится из messages выбранной сессии:
В прошлой лекции здесь использовался общий self._history. Теперь берется session.messages, поэтому история зависит от Session ID.
И в конце ответ сохраняется в ту же сессию:
Сообщения добавляются не в глобальный список, а в объект, выбранный в начале метода.
Отдельно разберем блокировки. Консоль отправляет сообщения по очереди, но полноценное приложение в будущем сможет получить два запроса почти одновременно. _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 выполняет три действия:
- очищает историю текущей сессии;
- создает новый Session ID;
- продолжает цикл с новым разговором.
Код готов к проверке. В следующем шаге ничего дописывать не будем: запустим проект и последовательно проверим продолжение разговора, новую сессию и потерю истории после перезапуска.
Шаг 5¶
В этом шаге запустим проект и наглядно проверим продолжение разговора, рост истории, границу новой сессии и потерю состояния после перезапуска.
Давайте запустим наше приложение. В начале консоль должна показать:
- окружение
- настроенную модель
- Session ID
- команды
/newи/exit
Session ID будет случайным, поэтому его значение не обязано совпадать с примером:
Сценарий 1. Начинаем новый разговор¶
Введите одной строкой:
Модель получает:
Она может предложить безопасные первичные действия или задать уточняющий вопрос. Формулировка зависит от модели, но ответ должен относиться к заказу 8041 и неработающей кофемашине.
После успешного ответа обратите внимание на строку:
В ChatSession.messages появились:
Developer message в это число не входит: он добавляется отдельно при сборке каждого запроса.
Сценарий 2. Отправляем зависимую реплику¶
Не закрывая приложение, отправьте:
В этой реплике не повторяются слова «заказ 8041» и «кофемашина». Чтобы ответить по существу, модели нужен прошлый ход.
Перед вторым вызовом ChatService собирает:
developer → правила чата
user → сообщение о заказе 8041 и кофемашине
assistant → первый ответ модели
user → сообщение о двух розетках
Модель должна продолжить обсуждение той же неисправности, а не спрашивать, какой товар имеется в виду. Это наглядное проявление stateful-поведения backend.
После ответа история должна увеличиться:
Также обычно увеличится значение prompt_tokens, потому что во второй запрос вошли предыдущая реплика и предыдущий ответ. Точное число зависит от модели, токенизатора и текста первого ответа.
Сценарий 3. Проверяем еще одну ссылку на прошлый контекст¶
Отправьте:
Модель должна понимать, что речь по-прежнему идет о той же кофемашине. Перед третьей генерацией список содержит уже два законченных хода и новую реплику.
Теперь мы получаем:
Обратите внимание: модель не читает историю из словаря самостоятельно. ChatService сначала извлекает нужный ChatSession, затем добавляет его сообщения в новый список и только после этого вызывает LLMClient.
Связный ответ является хорошей демонстрацией, но поведение модели вероятностное. Возможны варианты:
- модель повторно спросит часть уже известных данных
- ответ окажется слишком общим
- модель предложит другую последовательность безопасных действий
- формулировка будет отличаться при повторном запуске
Сценарий 4. Начинаем новый диалог¶
Введите команду:
Консоль должна показать другой Session ID:
Команда не отправляется модели. Консоль вызывает reset_session(), очищает прежнюю историю и создает новый идентификатор.
Теперь отправьте:
Местоимение «его» ссылалось бы на заказ или товар из прошлого разговора. Но новый диалог не содержит ни кофемашину, ни номер 8041. Модель должна попросить уточнить, о каком заказе или товаре идет речь.
После ответа значение снова начинается с двух:
Это важное наблюдение: приложение stateful не глобально, а в пределах выбранной сессии. Новый Session ID обозначает новую историю.
Даже без старых сообщений модель может предположить, что «его» означает заказ. Это обычное продолжение вероятного текста, но уж точно не доказательство скрытой памяти. Но в новой сессии модель не будет знать:
- номер 8041
- что товар является кофемашиной
- что уже были проверены две розетки
- что коробка была целой
В нашем случае модель просит заново назвать заказ или товар, граница работает ожидаемо.
Сценарий 5. Проверяем пустое сообщение¶
Нажмите Enter, не вводя текст. Сервис должен вывести прикладную ошибку:
Сетевой запрос при этом не выполняется, а история не изменяется. Следующий успешный ход должен увеличить прежнее значение только на два.
Сценарий 6. Перезапускаем приложение¶
Завершите чат:
Затем снова запустите проект, и у нас опять появится новый 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()