Главная / Статьи / Цикл чата без состояния: ручное вызов API OpenAI на Python

Цикл чата без состояния: ручное вызов API OpenAI на Python

Создайте многократный чат с помощью Python SDK от OpenAI, самостоятельно управляя историей сообщений, и поймите, почему тот же цикл лежит в основе механизмов памяти и агентов LangChain.

1167 слов

Фреймворки вроде LangChain создают иллюзию того, что чат-модели обладают памятью, но сам API не запоминает ничего. Каждый запрос является независимым, а «разговор» представляет собой список сообщений, который ваш код пересоздаёт и пересылает каждый раз. Если написать этот цикл вручную с помощью Python SDK от OpenAI, станет совершенно ясно, что автоматизируют фреймворки агентов, почему стоимость токенов растёт в ходе разговора и какие ошибки следует ожидать.

Что на самом деле представляет собой конечная точка хостинговой модели

API от OpenAI представляет собой простую структуру: провайдер запускает модель на своих GPU и предоставляет возможность выполнения запросов через HTTPS. Вы отправляете текст, модель генерирует токены в своём обычном цикле выбора следующего токена, и вы платите за каждый токен в обоих направлениях. Из этого следуют три последствия:

  1. Она не имеет состояния. Ничего из предыдущих запросов не сохраняется, поэтому каждый запрос должен содержать всю информацию, которую должна знать модель.
  • Это обычный HTTP. SDK оборачивает запрос POST, поэтому при возникновении ошибок вы можете просмотреть исходный трафик.
  • Вы покупаете токены, а не ответы. Длинные запросы стоят денег при каждом вызове, в котором они используются.
  • Никогда не размещайте ключ API в исходном коде. Ключи могут утекнуть через историю Git, скриншоты и общие ноутбуки, а утечка ключа означает, что кто-то другой будет тратить средства с вашего аккаунта. SDK автоматически читает значение OPENAI_API_KEY из окружения, поэтому в вашем скрипте совсем не требуется код для обработки ключей. Использование API оплачивается отдельно от подписки ChatGPT, и новым аккаунтам обычно требуется небольшой предоплаченный баланс.

    Роли: формат общих сообщений

    Запрос содержит список сообщений, каждое из которых имеет свою роль:

    • system содержит ваши инструкции, которые модель учитывает с большим весом.
    • user содержит то, что написал пользователь.
  • assistant хранит предыдущие ответы модели, а в агентах — вызовы инструментов.
  • Этот формат используется во всей экосистеме. Claude и Gemini применяют ту же концепцию с небольшими отличиями, Ollama имитирует её, а SystemMessage, HumanMessage и AIMessage в LangChain представляют собой классы, соответствующие этим ролям. Провайдер преобразует список в одну последовательность токенов перед генерацией, поэтому роли на самом деле являются частью структурированного проектирования промптов.

    Одна заявка

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

    from openai import OpenAI
    
    client = OpenAI()  # key from env
    
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "You are a concise "
                           "Python assistant.",
            },
            {
                "role": "user",
                "content": "Why resend the whole "
                           "chat history each call?",
            },
        ],
        temperature=0,
    )
    print(resp.choices[0].message.content)
    u = resp.usage
    print(u.prompt_tokens, u.completion_tokens)
    

    gpt-4o-mini — это недорогая модель, подходящая для обучения; перейти на более крупную модель достаточно просто, хотя названия и цены моделей могут меняться, поэтому проверяйте актуальный список. Значение temperature=0 сводит к минимуму случайность выбора ответов, что является оптимальным значением по умолчанию для ответов на вопросы и позже для агентов, выполняющих запросы к инструментам. Фиксируйте usage при каждом вызове — это ваш показатель затрат.

    Ведение разговора самостоятельно

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

    msgs = [{
        "role": "system",
        "content": "You are a concise assistant.",
    }]
    
    def ask(text: str) -> str:
        msgs.append(
            {"role": "user", "content": text}
        )
        resp = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=msgs,  # full history
            temperature=0,
        )
        reply = resp.choices[0].message.content
        msgs.append({
            "role": "assistant",
            "content": reply,
        })
        return reply
    
    print(ask("Define a context window."))
    print(ask("Now for a five-year-old."))
    print(ask("Which answer was shorter?"))
    

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

    Эта функция ask() встречается во многих формах. Веб-приложение ChatGPT по сути является её версией с пользовательским интерфейсом. Класс RunnableWithMessageHistory из LangChain представляет собой управляемую версию процесса добавления и пересылки данных. Внутренний цикл агента следует тому же принципу, но с добавлением вызовов инструментов и результатов. Обратите внимание на издержки: три пересылки превращаются в одну или две, поэтому количество токенов входных данных увеличивается с каждым обменом.

    Запуск примера

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

    pip install -r requirements.txt
    export OPENAI_API_KEY="sk-..."
    python examples/part02_chat.py
    

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

    Проблемы, которые следует учесть с самого начала

    1. Отсутствие ключа: возникает ошибка AuthenticationError с кодом HTTP 401, обычно из-за того, что переменная не задана в этой среде, написана неправильно или содержит лишние пробелы. Проверяйте это при запуске и сразу сообщайте об ошибках.
    2. Ограничения по частоте запросов: ошибка RateLimitError с кодом HTTP 429 означает слишком большое количество запросов или пустой баланс предоплаты. Агенты, работающие в цикле, столкнутся с этой проблемой, поэтому сразу же добавьте возможность повторных попыток с задержками.
  • История данных бесполезна в обоих случаях. Если забыть добавить новые данные, модель забудет всё; если продолжать добавлять их бесконечно, расход токенов будет расти квадратично до возникновения ошибки длины контекста. В реальных системах используются методы сокращения, краткого изложения или извлечения только значимых данных из истории, и именно эта идея лежит в основе технологии RAG.
  • Значение Temperature 0 обеспечивает стабильность вывода, но не его корректность. Необходимо проверять всё, что имеет значение.
  • Одинаковая структура у всех поставщиков

    Claude принимает список сообщений от пользователя и ассистента, при этом системное указание перемещается в отдельный параметр верхнего уровня. Gemini использует ту же модель обработки диалога в виде списка, где роли обозначены как user и model. Ollama предоставляет конечную точку, совместимую с OpenAI, поэтому этот код может использовать локальную модель путем изменения базового URL и имени модели; см. использование Claude, GPT и Gemini через конечные точки, совместимые с OpenAI. Именно это сходство позволяет LangChain предоставлять единый интерфейс для работы с множеством поставщиков.

    Основные выводы

    • API чата не имеет состояния; ваш код сам хранит и пересылает информацию о диалоге.
    • Список сообщений с метками ролей фактически является стандартом, совместимым с разными поставщиками.
    • Фиксируйте информацию об использовании при каждом вызове, поскольку количество токенов входных данных увеличивается с каждым разом.
  • Храните ключи в окружении и исключайте вызовы живых API из процесса CI.
  • Обрабатывайте ошибки 401, 429 и проблемы с неограниченной историей перед созданием агентов, поскольку циклы работы агентов усугубляют все три проблемы.