Несколько месяцев назад мне понадобился чат-бот, который держит контекст разговора и не начинает галлюцинировать на третьем сообщении. Начал с OpenAI, застрял на странных ответах, и в итоге переехал на Anthropic. Вот что узнал в процессе.
Как устроен Claude API
Документация Anthropic читается проще, чем у большинства конкурентов. Основная точка входа — /v1/messages. Ты отправляешь массив сообщений с ролями (user и assistant), модель возвращает ответ. Никакой магии.
Выглядит это так:
import anthropic
client = anthropic.Anthropic(api_key="sk-ant-...")
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
messages=[
{"role": "user", "content": "Привет, как дела?"}
]
)
print(response.content[0].text)
Минут двадцать у меня ушло, пока не понял: response.content — это список объектов, а не строка. Звучит очевидно, но я полез распечатывать весь объект и завис над структурой ответа.
Память разговора: где я напортачил
Главное в чат-боте — контекст. Claude не хранит историю сам по себе, каждый запрос начинается с чистого листа. Значит, историю нужно носить с собой и передавать при каждом вызове.
Первые полчаса я делал так:
history = []
def chat(user_message):
history.append({"role": "user", "content": user_message})
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
messages=history
)
assistant_message = response.content[0].text
history.append({"role": "assistant", "content": assistant_message})
return assistant_message
Работает. Но история растёт бесконечно — после пятидесяти сообщений ты передаёшь огромный контекст, платишь за все входящие токены и рискуешь упереться в лимит. Решил обрезанием: оставлял последние N сообщений плюс системный промпт:
MAX_HISTORY = 20
def chat(user_message, system_prompt="Ты полезный ассистент."):
history.append({"role": "user", "content": user_message})
trimmed = history[-MAX_HISTORY:]
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
system=system_prompt,
messages=trimmed
)
assistant_message = response.content[0].text
history.append({"role": "assistant", "content": assistant_message})
return assistant_message
Обратите внимание на параметр system — он идёт отдельно, не внутри массива сообщений. Это не очевидно, если переехал с OpenAI, где системное сообщение пихается первым элементом в тот же список.
Стриминг — когда важно не ждать
Первый прототип отвечал целиком: пишешь вопрос, пауза секунд пять, потом весь текст разом. Для демо сойдёт. Для реального использования — нет.
Включить стриминг просто:
with client.messages.stream(
model="claude-opus-4-5",
max_tokens=1024,
messages=history
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
В веб-приложении это обычно делается через Server-Sent Events или WebSocket. Я делал через SSE на FastAPI — пользователь видит текст по мере генерации, ощущение живого диалога появляется сразу.
Системный промпт решает больше, чем кажется
Поначалу я писал промпт на три строчки: "Ты помощник. Отвечай по делу. Будь вежлив." Технически работает, но плохо.
Дело в том, что модель не знает контекст твоего продукта — она знает только то, что ты ей написал. Начал объяснять подробнее: ограничения, формат ответов, что делать в нестандартных ситуациях. Разница ощутимая. Вот пример для саппорт-бота:
Ты саппорт-агент компании [X]. Твоя задача — помогать пользователям с вопросами
по продукту. Если не знаешь ответа — говори об этом прямо и предлагай обратиться
к живому специалисту. Не придумывай информацию о продукте. Отвечай на том языке,
на котором пишет пользователь.
"Не придумывай информацию" — строчка, без которой не обойтись. Без неё модель иногда генерирует правдоподобные, но несуществующие детали о твоём продукте, особенно если вопрос пользователя выходит за рамки контекста.
Что я бы сделал иначе с самого начала
Сразу настроил бы нормальное логирование запросов и ответов. Когда бот начинает вести себя странно, ты хочешь видеть, что именно передавалось в каждом вызове. Без логов это детектив без улик.
Обработку ошибок тоже добавил бы с первого коммита, а не через неделю. Claude API возвращает разные типы: rate limit, перегрузка серверов, проблемы с контентом. Без try/except бот просто падает вместо того, чтобы нормально сообщить пользователю о проблеме:
from anthropic import RateLimitError, APIStatusError
try:
response = client.messages.create(...)
except RateLimitError:
return "Слишком много запросов, попробуй через минуту."
except APIStatusError as e:
return f"Что-то пошло не так: {e.status_code}"
И не тянул бы с переходом на асинхронный клиент. Синхронная версия хороша для прототипа, но в приложении с несколькими одновременными пользователями нужен AsyncAnthropic — это не сложно, просто лучше сделать сразу.
Claude API достаточно предсказуем, и это, как ни странно, его главное достоинство. После нескольких проектов у меня появилось ощущение, что я примерно знаю, что получу. Галлюцинаций меньше, чем ожидал, инструкции в системном промпте выполняются честнее. Но контекст всё равно нужно держать руками — никто за тебя это не сделает.
