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

Но пока у этого чата есть важное ограничение: каждый новый запрос для модели почти независим.

Если сначала спросить:

Составь простой план изучения Python на 2 недели.

а потом написать:

Сделай его короче и оставь только самое важное.

модель может ответить нормально. А может и не понять, к чему относится слово «его». Потому что для неё второй запрос — это просто новый отдельный вызов.

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


Серия статей


Что сделаем в этой части

  • разберём, почему текущий чат не помнит контекст;

  • добавим список сообщений conversation_history;

  • научим программу передавать модели не только новый вопрос, но и предыдущие реплики;

  • начнём сохранять в память пары user + assistant;

  • добавим простое ограничение на размер истории;

  • посмотрим, как меняется поведение чата после этого.


Почему чат без истории ещё не настоящий чат

Во второй части мы собирали запрос так:

messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": user_message},
]

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

Для модели это выглядит так, будто каждый раз с ней разговаривают с нуля.

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


Как на самом деле работает память у LLM

У модели нет «памяти» между вызовами в человеческом смысле. Она не хранит ваш прошлый диалог где-то внутри себя между запросами.

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

То есть память нашего чата — это не магия и не особый режим Ollama. Это обычный Python-список:

conversation_history = [
    {"role": "user",      "content": "Составь план изучения Python"},
    {"role": "assistant", "content": "Вот простой план на 2 недели..."},
]

Когда пользователь задаёт новый вопрос, мы не отправляем только его одного. Мы отправляем system + историю прошлых сообщений + новый user. И тогда модель уже видит контекст разговора.


Какие роли сообщений нам нужны

В запросе три роли: system, user, assistant. Для истории нужны только user и assistant — они и есть переписка. Роль system в историю не идёт: это постоянная инструкция, которую добавляем в каждый запрос отдельно.

Порядок на каждом шаге цикла:

  1. пользователь вводит вопрос;

  2. программа собирает messages: system + история + новый user;

  3. модель отвечает;

  4. программа сохраняет в историю вопрос и ответ.


Пишем новый main.py

Откройте main.py из второй части и замените содержимое целиком:

# -*- coding: utf-8 -*-
import time
from typing import Optional
from litellm import completion

MODEL = "ollama_chat/qwen2.5:3b"
API_BASE = "http://localhost:11434"
SYSTEM_PROMPT = "Ты полезный ассистент. Отвечай кратко и по делу."

MAX_HISTORY_MESSAGES = 6


def trim_history(history: list, limit: int) -> list:
    if len(history) <= limit:
        return history
    return history[-limit:]


def send_request_to_llm(user_message: str, history: list) -> Optional[str]:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        *history,
        {"role": "user", "content": user_message},
    ]
    try:
        start_time = time.time()
        response = completion(
            model=MODEL,
            messages=messages,
            api_base=API_BASE,
            request_timeout=120,
        )
        duration = time.time() - start_time
        print(f"\nВремя генерации: {duration:.2f} сек")
        return response.choices[0].message.content
    except Exception as e:
        print(f"\nОшибка при запросе: {e}")
        return None


def main() -> None:
    print("Локальный ИИ-ассистент с памятью запущен.")
    print("Введите вопрос или 'выход' для завершения.\n")

    conversation_history = []

    while True:
        user_input = input("Вы: ").strip()

        if user_input.lower() in ("выход", "exit", "quit"):
            print("До свидания!")
            break

        if not user_input:
            print("Введите вопрос.")
            continue

        print("\nМодель думает...")
        answer = send_request_to_llm(user_input, conversation_history)

        if answer is not None:
            print(f"\nИИ: {answer}\n")
            conversation_history.append({"role": "user",      "content": user_input})
            conversation_history.append({"role": "assistant", "content": answer})
            conversation_history = trim_history(conversation_history, MAX_HISTORY_MESSAGES)
        else:
            print("\nНе удалось получить ответ. Проверьте, запущена ли Ollama.\n")


if __name__ == "__main__":
    main()

Запустите:

python main.py

Проверьте на вопросах, где нужен контекст:

Вы: Составь план изучения Python на 2 недели.
ИИ: ...

Вы: Сделай его короче.
ИИ: ...

Вы: Добавь в него практику по 20 минут в день.
ИИ: ...

Теперь модель держит нить разговора.


Разберём код

Константы вверху файла

MAX_HISTORY_MESSAGES = 6

Сколько сообщений максимум хранить в истории. Почему это нужно — объясним ниже при разборе trim_history.

conversation_history = []

В начале main() создаём пустой список. В нём живёт память текущего диалога. Пока программа работает — список растёт. Закрыли скрипт — память пропала. Для этой статьи этого достаточно.

send_request_to_llm(user_message, history) — теперь принимает историю

Раньше функция принимала только строку. Теперь принимает ещё и список прошлых сообщений.

messages собирается из трёх частей:

messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    *history,
    {"role": "user", "content": user_message},
]

Порядок принципиален: system → история → новый вопрос. Оператор *history разворачивает список сообщений внутрь нового списка. Если история пустая — всё работает нормально.

Сохраняем обе реплики

После успешного ответа записываем в историю сразу две записи:

conversation_history.append({"role": "user",      "content": user_input})
conversation_history.append({"role": "assistant", "content": answer})

Это важно. Если сохранить только вопрос пользователя и не сохранить ответ модели, на следующем шаге контекст сломается: модель увидит что пользователь что-то спрашивал, но не увидит что сама отвечала.

trim_history — ограничиваем размер истории

def trim_history(history: list, limit: int) -> list:
    if len(history) <= limit:
        return history
    return history[-limit:]

Без ограничения история растёт бесконечно: запросы становятся тяжелее, модель получает слишком много старых сообщений. MAX_HISTORY_MESSAGES = 6 означает, что в памяти хранятся последние 6 сообщений — три последних обмена репликами. Для стартовой версии достаточно.

Главная идея всей структуры: send_request_to_llm по-прежнему отвечает только за запрос к модели. Управление историей — снаружи, в main(). Логика не смешана.


Что изменилось в поведении чата

После запуска попробуйте такую цепочку:

Вы: Назови три фреймворка для Python.
ИИ: Django, Flask, FastAPI.

Вы: Расскажи подробнее про второй.
ИИ: Flask — минималистичный веб-фреймворк...

Без истории на второй вопрос модель не знала бы, что такое «второй». Теперь знает — потому что видит предыдущую реплику.


Ограничение этой версии

История живёт только в памяти текущего запуска. Закрыли скрипт — пропала. Для учебного примера это нормально, для реального приложения нужно сохранение на диск или в базу.


Что у нас получилось за три части

За эту серию мы собрали работающую основу LLM-приложения:

  • подняли локальную модель через Ollama без API-ключей и без интернета;

  • подключили её к Python через LiteLLM;

  • сделали консольный чат с system prompt и обработкой ошибок;

  • добавили историю сообщений и ограничение её размера.

Этот main.py можно взять как основу и встроить в Telegram-бота, веб-сервис или CLI-инструмент — логика останется той же.


Вывод

Если смотреть на код трезво, мы сделали немного: добавили список сообщений и стали передавать его в запрос. Но именно это превращает скрипт в чат — модель начинает видеть разговор, а не набор одиночных запросов.


Если хотите пойти дальше

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


<- Назад