Цикл чата без состояния: ручное вызов API OpenAI на Python
Создайте многократный чат с помощью Python SDK от OpenAI, самостоятельно управляя историей сообщений, и поймите, почему тот же цикл лежит в основе механизмов памяти и агентов LangChain.
Фреймворки вроде LangChain создают иллюзию того, что чат-модели обладают памятью, но сам API не запоминает ничего. Каждый запрос является независимым, а «разговор» представляет собой список сообщений, который ваш код пересоздаёт и пересылает каждый раз. Если написать этот цикл вручную с помощью Python SDK от OpenAI, станет совершенно ясно, что автоматизируют фреймворки агентов, почему стоимость токенов растёт в ходе разговора и какие ошибки следует ожидать.
Что на самом деле представляет собой конечная точка хостинговой модели
API от OpenAI представляет собой простую структуру: провайдер запускает модель на своих GPU и предоставляет возможность выполнения запросов через HTTPS. Вы отправляете текст, модель генерирует токены в своём обычном цикле выбора следующего токена, и вы платите за каждый токен в обоих направлениях. Из этого следуют три последствия:
- Она не имеет состояния. Ничего из предыдущих запросов не сохраняется, поэтому каждый запрос должен содержать всю информацию, которую должна знать модель.
Никогда не размещайте ключ 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, поскольку требует реальных денег и настоящего ключа. Если вы хотите написать тесты для такого кода, симулируйте клиент.
Проблемы, которые следует учесть с самого начала
- Отсутствие ключа: возникает ошибка
AuthenticationErrorс кодом HTTP 401, обычно из-за того, что переменная не задана в этой среде, написана неправильно или содержит лишние пробелы. Проверяйте это при запуске и сразу сообщайте об ошибках. - Ограничения по частоте запросов: ошибка
RateLimitErrorс кодом HTTP 429 означает слишком большое количество запросов или пустой баланс предоплаты. Агенты, работающие в цикле, столкнутся с этой проблемой, поэтому сразу же добавьте возможность повторных попыток с задержками.
Одинаковая структура у всех поставщиков
Claude принимает список сообщений от пользователя и ассистента, при этом системное указание перемещается в отдельный параметр верхнего уровня. Gemini использует ту же модель обработки диалога в виде списка, где роли обозначены как user и model. Ollama предоставляет конечную точку, совместимую с OpenAI, поэтому этот код может использовать локальную модель путем изменения базового URL и имени модели; см. использование Claude, GPT и Gemini через конечные точки, совместимые с OpenAI. Именно это сходство позволяет LangChain предоставлять единый интерфейс для работы с множеством поставщиков.
Основные выводы
- API чата не имеет состояния; ваш код сам хранит и пересылает информацию о диалоге.
- Список сообщений с метками ролей фактически является стандартом, совместимым с разными поставщиками.
- Фиксируйте информацию об
использованиипри каждом вызове, поскольку количество токенов входных данных увеличивается с каждым разом.