Маленький LLM-чат на Python с Ollama и LiteLLM. Часть 3: добавляем историю сообщений и контекст. ai.. ai. large language model.. ai. large language model. LiteLLM.. ai. large language model. LiteLLM. llm.. ai. large language model. LiteLLM. llm. Natural Language Processing.. ai. large language model. LiteLLM. llm. Natural Language Processing. npl.. ai. large language model. LiteLLM. llm. Natural Language Processing. npl. ollama.. ai. large language model. LiteLLM. llm. Natural Language Processing. npl. ollama. python.. ai. large language model. LiteLLM. llm. Natural Language Processing. npl. ollama. python. искусственный интеллект.. ai. large language model. LiteLLM. llm. Natural Language Processing. npl. ollama. python. искусственный интеллект. искуственный интеллект.. ai. large language model. LiteLLM. llm. Natural Language Processing. npl. ollama. python. искусственный интеллект. искуственный интеллект. локальные модели.. ai. large language model. LiteLLM. llm. Natural Language Processing. npl. ollama. python. искусственный интеллект. искуственный интеллект. локальные модели. Проектирование API.. ai. large language model. LiteLLM. llm. Natural Language Processing. npl. ollama. python. искусственный интеллект. искуственный интеллект. локальные модели. Проектирование API. чатбот.

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

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

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

Составь простой план изучения 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-инструмент — логика останется той же.


Вывод

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


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

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


<- Назад

Автор: kuz1

Источник