Prompt Caching в Claude: Как мы снизили затраты на AI в 2 раза

Кейс по оптимизации затрат на Claude API в проекте по автоматизации поиска работы. AI анализировал вакансии и генерировал сопроводительные письма. При 100 пользователях затраты достигали $180/месяц. Решение: Prompt Caching от Anthropic. Экономия 52% ($0.51 → $0.245 за batch из 50 вакансий). Теперь можно делать в 2 раза больше AI-вызовов с тем же бюджетом.

Кому полезно: всем, кто работает с LLM API и хочет оптимизировать затраты.

История: Когда AI начал съедать бюджет

Делал проект по автоматизации поиска работы — платформа анализирует вакансии с помощью AI и сопоставляет их с резюме пользователя. Даёт score от 0 до 100 и объясняет, почему вакансия подходит или нет.

Логика простая:

  1. Пользователь ищет вакансии (например, "Product Manager в Москве")

  2. Система находит 50-100 вакансий через HH API

  3. Claude AI анализирует каждую вакансию относительно резюме пользователя

  4. Возвращаем список с оценками и рекомендациями

Звучит круто, пока не начинаешь считать затраты.

Считаем: Сколько это стоит?

Дано:

  • Модель: Claude Sonnet (умная, но дорогая)

  • Один поиск: 50 вакансий

  • Один пользователь делает ~10 поисков в месяц

Токены на один анализ вакансии:

  • System prompt: 500 токенов (инструкции для AI)

  • Резюме пользователя: 1,500 токенов (опыт, навыки, образование)

  • Описание вакансии: 400 токенов (требования, обязанности)

  • Ответ AI: 200 токенов (оценка + объяснение)

  • Итого: ~2,600 токенов на вакансию

Стоимость Claude Sonnet:

  • Input: $3 за 1M токенов

  • Output: $15 за 1M токенов

Batch из 50 вакансий:

Input: 50 × 2,400 токенов = 120,000 токенов = $0.36
Output: 50 × 200 токенов = 10,000 токенов = $0.15
Итого: $0.51 за один поиск

Стоп. Это много. Очень много.

Если у нас 100 пользователей, и каждый делает 10 поисков в месяц:

100 пользователей × 10 поисков × $0.51 = $510/месяц

Пятьсот долларов только на AI. При том что вся инфраструктура (сервер, база данных, домен) обходится в ~$35/месяц.

Так не пойдёт.

Первая оптимизация: Используем Haiku для простых задач

Первое, что пришло в голову — не все задачи требуют самой умной модели. Для простых вопросов (например, "сколько стоит подписка?") можно использовать Claude Haiku — он в 5 раз дешевле.

Новая стратегия:

  • Haiku (дешёвый) — для FAQ, простых вопросов, базовой фильтрации

  • Sonnet (дорогой) — для сложного анализа резюме и генерации писем

Помогло, но не сильно. Основные затраты всё равно шли на matching вакансий, где без Sonnet никак.

Нужно было что-то другое.

Открытие: Prompt Caching

Копаясь в документации Anthropic, нашёл фичу Prompt Caching. Суть простая: если отправляешь в Claude один и тот же контекст много раз подряд, можно его закешировать на 5 минут. Anthropic берёт плату только за новую часть запроса.

Как это работает:

Обычный запрос выглядит так:

response = client.messages.create(
    model="claude-sonnet-4-20250514",
    system="Ты анализируешь вакансии...",  # 500 токенов
    messages=[
        {
            "role": "user",
            "content": f"Резюме: {resume}\nВакансия: {vacancy}"
        }
    ]
)

Каждый раз Claude читает ВСЁ заново: system prompt, резюме, вакансию. Платим за все токены.

С Prompt Caching:

response = client.messages.create(
    model="claude-sonnet-4-20250514",
    system=[
        {
            "type": "text",
            "text": "Ты анализируешь вакансии...",  # 500 токенов
            "cache_control": {"type": "ephemeral"}  # ← Кешируем!
        },
        {
            "type": "text",
            "text": f"Резюме пользователя: {resume}",  # 1,500 токенов
            "cache_control": {"type": "ephemeral"}  # ← Кешируем!
        }
    ],
    messages=[
        {
            "role": "user",
            "content": f"Вакансия: {vacancy}"  # 400 токенов — НЕ кешируем
        }
    ]
)

Теперь:

  • Первый запрос: платим за всё (500 + 1,500 + 400 токенов)

  • Следующие запросы (в течение 5 минут): платим только за новую вакансию (400 токенов)

System prompt и резюме читаются из кеша — стоимость снижается в 10 раз для cached токенов!

Реализация: Что мы закешировали

1. System Prompt (всегда одинаковый)

SYSTEM_PROMPT = """Ты — AI-ассистент для анализа вакансий.

ЗАДАЧА:
Оценить соответствие вакансии резюме кандидата по шкале 0-100.

КРИТЕРИИ ОЦЕНКИ:
- Совпадение навыков (40%)
- Опыт работы (30%)
- Образование (15%)
- Зарплатные ожидания (15%)

ВАЖНО:
- Учитывай уровень позиции (Junior/Middle/Senior/Lead)
- Если кандидат overqualified (например Senior на Middle позицию), снижай оценку
- Если underqualified, тоже снижай оценку

ФОРМАТ ОТВЕТА:
{
  "score": 85,
  "reasoning": "Объяснение оценки",
  "pros": ["Плюс 1", "Плюс 2"],
  "cons": ["Минус 1"]
}
"""

Этот промпт не меняется между вызовами → кешируем целиком.

2. Резюме пользователя (меняется редко)

У каждого пользователя есть резюме. Оно не меняется в рамках одной сессии поиска. Более того — пользователь может делать 5-10 поисков подряд с одним и тем же резюме.

Кешируем его тоже:

resume_text = f"""
ИМЯ: {user.name}
ДОЛЖНОСТЬ: {user.title}
ОПЫТ:
{format_experience(user.experience)}

НАВЫКИ:
{', '.join(user.skills)}

ОБРАЗОВАНИЕ:
{user.education}
"""

3. Описание вакансии (всегда новое)

Это единственная часть, которая меняется для каждого вызова. Её НЕ кешируем — она всегда уникальна.

Код: Как это выглядит в FastAPI

Вот реальный код из нашего проекта (упрощённая версия):

from anthropic import AsyncAnthropic

class VacancyMatcher:
    def __init__(self):
        self.client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
    
    async def match_vacancy(
        self,
        vacancy: dict,
        resume: dict,
        use_cache: bool = True
    ) -> dict:
        """
        Анализирует одну вакансию относительно резюме
        
        Args:
            vacancy: Данные вакансии из HH API
            resume: Резюме пользователя
            use_cache: Использовать ли Prompt Caching
        """
        
        # 1. Форматируем резюме в текст
        resume_text = self._format_resume(resume)
        
        # 2. Форматируем вакансию
        vacancy_text = self._format_vacancy(vacancy)
        
        # 3. Формируем system prompt с кешированием
        if use_cache:
            system = [
                {
                    "type": "text",
                    "text": SYSTEM_PROMPT,
                    "cache_control": {"type": "ephemeral"}
                },
                {
                    "type": "text",
                    "text": f"РЕЗЮМЕ КАНДИДАТА:\n{resume_text}",
                    "cache_control": {"type": "ephemeral"}
                }
            ]
        else:
            # Без кеширования (для сравнения)
            system = f"{SYSTEM_PROMPT}\n\nРЕЗЮМЕ КАНДИДАТА:\n{resume_text}"
        
        # 4. Вызываем Claude
        response = await self.client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=1024,
            system=system,
            messages=[
                {
                    "role": "user",
                    "content": f"ВАКАНСИЯ:\n{vacancy_text}\n\nОцени соответствие."
                }
            ]
        )
        
        # 5. Парсим ответ
        result = json.loads(response.content[0].text)
        
        return {
            "vacancy_id": vacancy["id"],
            "score": result["score"],
            "reasoning": result["reasoning"],
            "pros": result["pros"],
            "cons": result["cons"],
            "tokens_used": {
                "input": response.usage.input_tokens,
                "cache_read": getattr(response.usage, "cache_read_input_tokens", 0),
                "output": response.usage.output_tokens
            }
        }

Batch Processing: Обрабатываем 50 вакансий параллельно

Чтобы не ждать 50 секунд (по 1 секунде на вакансию), делаем batch processing:

async def match_batch(
    self,
    vacancies: list[dict],
    resume: dict,
    batch_size: int = 5
) -> list[dict]:
    """
    Обрабатываем вакансии батчами параллельно
    
    Args:
        vacancies: Список вакансий
        resume: Резюме пользователя
        batch_size: Сколько вакансий обрабатывать одновременно
    """
    results = []
    
    # Разбиваем на батчи по 5 вакансий
    for i in range(0, len(vacancies), batch_size):
        batch = vacancies[i:i + batch_size]
        
        # Запускаем параллельно
        tasks = [
            self.match_vacancy(vacancy, resume, use_cache=True)
            for vacancy in batch
        ]
        
        batch_results = await asyncio.gather(*tasks)
        results.extend(batch_results)
        
        # Небольшая пауза между батчами (чтобы не упереться в rate limit)
        if i + batch_size < len(vacancies):
            await asyncio.sleep(0.5)
    
    return results

Результат:

  • 50 вакансий обрабатываются за ~20 секунд (вместо 50)

  • Prompt Cache hit rate: 60-80% (зависит от того, как быстро пользователь делает запросы)


Результаты: Что получили

До Prompt Caching

Один batch (50 вакансий):

Input: 50 × 2,400 токенов = 120,000 токенов × $3/1M = $0.36
Output: 50 × 200 токенов = 10,000 токенов × $15/1M = $0.15
Итого: $0.51 за batch

100 пользователей × 10 поисков/месяц:

$0.51 × 1,000 = $510/месяц

После Prompt Caching

Первый запрос (cache miss):

Input: 2,400 токенов × $3/1M = $0.0072
Output: 200 токенов × $15/1M = $0.003
Итого: $0.0102

Следующие 49 запросов (cache hit):

Cached tokens (system + resume): 2,000 токенов × $0.30/1M = $0.0006
New tokens (vacancy): 400 токенов × $3/1M = $0.0012
Output: 200 токенов × $15/1M = $0.003
Итого за вакансию: $0.0048

Batch из 50 вакансий:

Первая: $0.0102
Остальные 49: 49 × $0.0048 = $0.235
Итого: $0.245 за batch (вместо $0.51)

Экономия: 52% 

100 пользователей × 10 поисков/месяц:

$0.245 × 1,000 = $245/месяц (вместо $510)

Сэкономили $265 в месяц. Или $3,180 в год.

Когда Prompt Caching работает лучше всего

Идеальные сценарии:

  1. Batch processing — обрабатываете много похожих запросов подряд

    • Пример: анализ вакансий, модерация контента, генерация описаний товаров

  2. Длинный контекст — system prompt или reference материалы занимают много токенов

    • Пример: база знаний компании, документация, техническая спецификация

  3. Повторяющиеся запросы — пользователь делает несколько запросов за короткое время

    • Пример: поиск с разными фильтрами, итеративное редактирование

Когда кеширование не поможет:

  1. Уникальные запросы — каждый раз новый контекст

    • Пример: генерация креативов для разных клиентов

  2. Редкие запросы — между вызовами проходит >5 минут

    • Пример: чат-бот с низкой активностью

  3. Короткие промпты — кешировать нечего (меньше 1024 токенов)

Подводные камни и нюансы

1. Кеш живёт только 5 минут

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

Решение: Группируем запросы. Например, в нашем случае пользователь сначала ищет все вакансии, а потом мы анализируем их batch'ами. Вероятность cache hit высокая.

2. Минимальный размер для кеширования: 1024 токена

Нельзя кешировать маленькие промпты. Если ваш system prompt короткий, кеширование не сработает.

Решение: Объединяйте несколько блоков в один cached блок. Например, мы кешируем system prompt + резюме вместе.

3. Порядок имеет значение

Кешировать можно только последние блоки в system prompt. Нельзя сделать так:

#  Не сработает
system = [
    {"text": "Часть 1", "cache_control": {"type": "ephemeral"}},
    {"text": "Часть 2"},  # НЕ cached
    {"text": "Часть 3", "cache_control": {"type": "ephemeral"}}
]

Правильно:

#  Работает
system = [
    {"text": "Часть 1"},  # НЕ cached
    {"text": "Часть 2", "cache_control": {"type": "ephemeral"}},
    {"text": "Часть 3", "cache_control": {"type": "ephemeral"}}
]

4. Cache hit не гарантирован

Даже если делаете запросы быстро, cache hit rate может быть 60-80%, а не 100%. Зависит от нагрузки на серверах Anthropic.

Решение: Считайте среднюю экономию, а не максимальную.

Мониторинг: Как считать реальную экономию

Anthropic возвращает детальную статистику по токенам:

response.usage:
{
  "input_tokens": 400,           # Новые токены
  "cache_creation_input_tokens": 2000,  # Записали в кеш
  "cache_read_input_tokens": 2000,      # Прочитали из кеша
  "output_tokens": 200
}

Считаем стоимость:

def calculate_cost(usage) -> float:
    """
    Считает стоимость запроса с учётом кеширования
    
    Цены Claude Sonnet (на февраль 2025):
    - Input: $3.00 / 1M tokens
    - Output: $15.00 / 1M tokens
    - Cache write: $3.75 / 1M tokens (на 25% дороже обычного input)
    - Cache read: $0.30 / 1M tokens (в 10 раз дешевле!)
    """
    
    input_cost = usage.input_tokens * 3.00 / 1_000_000
    cache_write_cost = usage.cache_creation_input_tokens * 3.75 / 1_000_000
    cache_read_cost = usage.cache_read_input_tokens * 0.30 / 1_000_000
    output_cost = usage.output_tokens * 15.00 / 1_000_000
    
    return input_cost + cache_write_cost + cache_read_cost + output_cost

Логируем каждый запрос:

logger.info(
    "Claude API call",
    extra={
        "vacancy_id": vacancy["id"],
        "input_tokens": usage.input_tokens,
        "cache_read_tokens": usage.cache_read_input_tokens,
        "output_tokens": usage.output_tokens,
        "cost": cost,
        "cache_hit": usage.cache_read_input_tokens > 0
    }
)

Дальше можно строить дашборды в Grafana или анализировать логи:

-- Средний cache hit rate за последний день
SELECT
  DATE(created_at) as date,
  COUNT(*) as total_requests,
  SUM(CASE WHEN cache_read_tokens > 0 THEN 1 ELSE 0 END) as cache_hits,
  ROUND(
    100.0 * SUM(CASE WHEN cache_read_tokens > 0 THEN 1 ELSE 0 END) / COUNT(*),
    2
  ) as hit_rate_percent
FROM ai_calls
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY DATE(created_at)
ORDER BY date DESC;

Наши результаты за неделю:

date       | total_requests | cache_hits | hit_rate_percent
-----------|----------------|------------|------------------
2025-02-08 |     1,247      |    982     |      78.75
2025-02-07 |     1,893      |   1,456    |      76.93
2025-02-06 |     2,104      |   1,687    |      80.18

Cache hit rate ~78%. Значит экономим не ровно 52%, а ~46% (с учётом cache miss). Всё равно отлично!


Выводы: Стоит ли оно того?

Да, если:

  1. У вас batch processing — обрабатываете десятки запросов подряд

  2. Длинный контекст — system prompt + reference документы >2000 токенов

  3. Частые запросы — пользователи делают много действий за короткое время

Возможно стоит, если:

  1. Средний контекст — 1000-2000 токенов

  2. Умеренная нагрузка — пользователи делают запросы, но не очень часто

Не стоит, если:

  1. Короткие промпты — меньше 1024 токенов

  2. Редкие запросы — между вызовами проходит много времени

  3. Всегда уникальный контекст — нечего кешировать

Наш случай:

  • Batch processing

  • Длинный контекст (system prompt + резюме = 2000+ токенов)

  • Частые запросы (пользователь делает 5-10 поисков подряд)

Итог: Сэкономили $265/месяц при текущей нагрузке. При росте до 1000 пользователей — это будет $2,650/месяц экономии. Вполне достаточно, чтобы покрыть всю инфраструктуру.

Полезные ссылки

Комментарии (0)