11.2 · Prompt caching, бюджеты, фолбэки
Неделя 11 · Продакшен · ⏱ ~4 ч · Практика
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»В главе 11.1 ассистент поддержки стал подниматься одной командой; теперь он должен ещё и не разорять владельца и не падать вместе с провайдером. Счёт за LLM растёт линейно с трафиком, а провайдеры регулярно отвечают 429 и 529 (перегрузка у Anthropic). Сервис, который в эти минуты просто падает или за ночь сжигает месячный бюджет из-за зациклившегося агента, в продакшен не пускают. На собеседованиях спрашивают именно так: «как вы снизили стоимость?», «что будет, если Anthropic недоступен?», «как не допустить счёта на $5000?» — и ждут цифр и механизмов, а не «ну, мы следили».
Откуда берётся стоимость запроса
Заголовок раздела «Откуда берётся стоимость запроса»В главе 1.7 · Скрипт: temperature, токены, стоимость мы считали стоимость как input_tokens × цена_input + output_tokens × цена_output (функция estimate_cost). Для ассистента поддержки «Полки» из проекта 2 разложение одного ответа обычно такое:
запрос к модели ≈ 8k токенов input ├─ system prompt + правила цитирования ~1.5k одинаковый всегда ├─ few-shot примеры ~1k одинаковый всегда ├─ постоянные разделы kb/ (по желанию) ~2.5k одинаковый, пока не поменяли kb/ ├─ найденные куски (top-5) ~3k свой у каждого вопроса └─ вопрос клиента ~50 свойответ ≈ 300 токенов output (дороже за токен в несколько раз)Отсюда три рычага: не платить заново за одинаковое (prompt caching), не делать запрос вообще (кэш ответов), платить меньше за токен (модель дешевле там, где хватает, — это решается evals, как в главе 8.3). Плюс два механизма защиты: фолбэки — переключение на запасного провайдера, когда основной лежит, — и бюджет — лимит расходов на случай, когда что-то пошло не так. Глава идёт в этом порядке: сначала два вида кэша, потом фолбэки, потом бюджет.
Prompt caching: повторение из главы 2.7 и применение к RAG
Заголовок раздела «Prompt caching: повторение из главы 2.7 и применение к RAG»Первый рычаг — prompt caching: провайдер запоминает обработанное начало промпта и при повторе берёт за него меньше. Механику мы разобрали в главе 2.7 · Стриминг, ретраи, rate limits, prompt caching; коротко главное:
- Кэшируется префикс запроса. У Anthropic порядок префикса:
tools→system→messages. Изменение на любом уровне инвалидирует его и всё после. - У Anthropic кэш включается явно:
cache_control: {"type": "ephemeral"}на блоке (до 4 точек разметки) или автоматический режим с одним полем верхнего уровня. Запись в кэш стоит дороже обычного input (для 5-минутного TTL — 1.25×, для часового — 2×), чтение — в разы дешевле (стандартно 0.1×, у части новых моделей ещё меньше). Есть минимальная длина кэшируемого префикса, разная для разных моделей — от сотен до нескольких тысяч токенов. Короче — кэш молча не включится, без ошибки. Точные множители и пороги стоит сверить с документацией на день работы. - У OpenAI кэширование префиксов автоматическое для достаточно длинных промптов; сколько попало в кэш — в
usage.prompt_tokens_details.cached_tokens. - Проверять факт попадания — только по
usage:cache_creation_input_tokensиcache_read_input_tokensу Anthropic. Если оба нуля — кэша не было.
Главная мысль для RAG: кэш работает только на одинаковом префиксе, а куски у каждого вопроса свои. Значит, промпт надо упорядочить так, чтобы всё неизменное шло первым, а изменяемое — последним:
[tools] ─┐[system: роль, правила] │ стабильный префикс → cache_control здесь[few-shot примеры] │[постоянные разделы kb/] ─┘[найденные куски <documents>] ─┐ меняется каждый раз[вопрос клиента] ─┘Типичная ошибка, которая обнуляет кэш: в system prompt подставлена текущая дата-время («Сейчас 2026-11-26 14:03:17»), id запроса или имя клиента. Префикс меняется каждую секунду — кэш никогда не читается, зато каждый раз пишется по повышенной цене. Динамику — в конец.
Если стабильный префикс короче минимального порога, кэш в RAG ничего не даст. Это нормальный результат эксперимента, его надо так и записать. Где кэш даёт много: длинные инструкции, большая «шпаргалка», которая нужна почти в каждом ответе, многоходовые диалоги в Telegram-боте поддержки (вся история — префикс следующего хода), агентский цикл из главы 9.3 (каждая итерация повторяет всю историю инструментов).
У ассистента поддержки такая «шпаргалка» естественна: про доставку и возвраты спрашивают чаще всего, поэтому актуальные kb/delivery.md и kb/returns.md можно класть в префикс целиком. Условия: только текущие страницы (архивная kb/returns-2025.md — никогда); префикс пересобирается при переиндексации (иначе ассистент цитирует старые правила); постоянные разделы оформлены такими же <document> с kb/<file>.md#<anchor> и всегда получают номера [1]…[m], найденные куски нумеруются дальше, дубли из выдачи убираем.
Иллюстративный пример (Anthropic SDK; модель и детали параметров сверяем с актуальной документацией):
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
STABLE_KB = ["kb/delivery.md", "kb/returns.md"] # current pages only, never archived# rules + few-shot + stable kb sections as numbered <document> blocks; rebuilt on re-ingestSTATIC_SYSTEM = build_static_system("prompts/answer_v2.txt", STABLE_KB)
def answer(question: str, docs_xml: str, model: str): resp = client.messages.create( model=model, max_tokens=600, system=[ {"type": "text", "text": STATIC_SYSTEM, "cache_control": {"type": "ephemeral"}}, # breakpoint after stable prefix ], messages=[{"role": "user", "content": f"<documents>\n{docs_xml}\n</documents>\n\nQuestion: {question}"}], ) u = resp.usage # cache_creation_input_tokens: written now; cache_read_input_tokens: served from cache return resp, u.input_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokensФормула стоимости с кэшем (множители — из документации для выбранной модели):
cost = (input × P_in + cache_write × P_in × k_write + cache_read × P_in × k_read + output × P_out) / 1_000_000Именно её надо зашить в common/llm_utils.py (параметры cache_read_tokens и cache_write_tokens у estimate_cost), иначе кэшированные токены посчитаются по полной цене и экономии не будет видно.
Кэш ответов и семантический кэш
Заголовок раздела «Кэш ответов и семантический кэш»Второй рычаг — другой уровень: не «дешевле обработать промпт», а «не звонить модели вообще».
Точный кэш — ключ = хэш от (модель, промпт, параметры). Безопасен, но в чате почти не попадает: люди формулируют по-разному. Полезен в evals и при разработке — повторный прогон того же набора стоит ноль (так, кстати, работают кэши в promptfoo).
Семантический кэш — ключ = эмбеддинг вопроса. Если новый вопрос близок к сохранённому (косинусная близость выше порога, глава 4.1), возвращаем сохранённый ответ.
вопрос → embed → поиск ближайшего в кэше ├─ sim ≥ 0.95 → вернуть сохранённый ответ (0 токенов LLM) └─ иначе → полный RAG → сохранить (вопрос, ответ, ts)Когда уместен: FAQ-подобный трафик, где много одинаковых вопросов, ответы не зависят от времени и от клиента (например, «сколько дней на возврат?»).
Когда опасен — и это любимая тема на собеседованиях:
| Риск | Пример из ассистента «Полки» |
|---|---|
| Близкие по эмбеддингу, разные по смыслу | «можно ли вернуть вскрытый чай?» vs «можно ли вернуть невскрытый чай?»; «гарантия на лампу „Свет“» (12 месяцев) vs «гарантия на „Свет мини“» (6 месяцев) — косинус высокий, ответы разные |
| Устаревание | «действует ли TEA15 вместе со скидкой?», «сколько стоит доставка» — после правки kb/promo.md или тарифов ответ из кэша верен только вчера |
| Утечка между клиентами | ответ локальной модели на вопрос с личными данными («где мой заказ 1042, телефон …») выдан другому клиенту с похожей формулировкой |
| Отравление кэша | prompt injection (глава 9.4) через отравленную страницу kb/ заставил модель выдать мусор или «промокод на 100%», и это теперь отдаётся всем похожим вопросам |
| Потеря трассируемости | ответ без свежих ссылок на источники ломает контракт «каждое утверждение — со ссылкой» из главы 5.3 |
Отсюда правила: высокий порог, TTL, ключ включает версию индекса (хэши страниц kb/); вопросы, на которых сработал contains_personal_data, не кэшируются вообще; отказы и ошибки тоже не кэшируются; и кэш проверяется evals-набором: перефразы должны попадать, а «ловушки» с противоположным смыслом — нет.
Фолбэки: что, когда, на что
Заголовок раздела «Фолбэки: что, когда, на что»Кэши снижают цену, но не помогают, когда провайдер не отвечает. Для этого нужен фолбэк — переход на запасную модель. Фолбэк — не «поймать любое исключение и позвать другую модель». Правила:
- Какие ошибки: 429 (после ретраев), 5xx, 529 overloaded, таймаут, сетевые ошибки. Не 400/401/403/404 — это ошибка в нашем запросе или ключе, на другом провайдере она либо повторится, либо замаскирует баг.
- Порядок: сначала ретраи с экспоненциальной паузой и джиттером (глава 2.7), потом фолбэк. Иначе каждый случайный 529 переключает провайдера.
- Circuit breaker («автомат-предохранитель»): если основной провайдер упал 5 раз за минуту, не пытаться ещё N секунд, идти сразу в запасной. Иначе каждый запрос сначала ждёт таймаут.
- Куда переключаться, решают данные. Вопрос о правилах без личных данных может уйти в другую облачную модель; сообщение, где сработал
contains_personal_data, — только в локальную модель или оператору (handoff). - Совместимость: у запасной модели другой токенизатор, другое поведение в tool calling, другой стиль цитирования. Промпт может потребовать версию под каждую модель. Evals на запасной модели обязательны — фолбэк, который отвечает плохо, хуже честной ошибки.
- Кэш теряется: prompt cache привязан к провайдеру и модели; после переключения первые запросы идут по полной цене.
- Наблюдаемость: в лог/трейс пишем, какая модель фактически ответила. Иначе в главе 11.3 не понять, почему подскочила стоимость.
- Запасной может быть локальным — Ollama (подробно — глава 11.4); тогда стоимость ноль, но качество надо измерить.
LiteLLM: один интерфейс ко всем провайдерам
Заголовок раздела «LiteLLM: один интерфейс ко всем провайдерам»Ретраи, фолбэки, circuit breaker, учёт стоимости — всё это можно написать самим, а можно взять готовым. LiteLLM — библиотека (и отдельный прокси-сервер), которая даёт OpenAI-подобный интерфейс completion(model="anthropic/...", messages=...) к десяткам провайдеров, плюс подсчёт стоимости, ретраи, фолбэки, кэширование и бюджеты. По смыслу это наш call_llm() из главы 2.7 (или Vercel AI SDK в мире JavaScript), доведённый до промышленного вида.
Два режима:
- SDK (
from litellm import completion, Router) — внутри нашего Python-кода. - Proxy — отдельный сервис с OpenAI-совместимым API: приложение ходит в него, а он — к провайдерам. Там удобны виртуальные ключи, бюджеты на ключ, общая статистика. Для одного учебного проекта избыточно, но на собеседовании полезно знать, что «LLM-гейтвей» — стандартный элемент архитектуры.
Иллюстративный пример Router с фолбэком «облако → другое облако» (точные имена моделей и формат стоит проверить в docs):
import osfrom litellm import Router
router = Router( model_list=[ {"model_name": "primary", "litellm_params": {"model": "anthropic/<claude-model-id>", "api_key": os.environ["ANTHROPIC_API_KEY"]}}, {"model_name": "backup", "litellm_params": {"model": "openai/<gpt-model-id>", "api_key": os.environ["OPENAI_API_KEY"]}}, ], fallbacks=[{"primary": ["backup"]}], num_retries=2, timeout=30,)
resp = router.completion(model="primary", messages=[{"role": "user", "content": "ping"}])Безопасность зависимости. LiteLLM — пакет, который видит все ключи провайдеров. Такие пакеты — привлекательная цель для атак на цепочку поставок (подменённые релизы на PyPI), поэтому обращаемся с ним как с кодом с доступом к деньгам. Перед установкой: GitHub Security Advisories проекта, версия не первого дня, закреплённая в uv.lock, список того, что пакет тянет. Альтернатива, вполне достойная для учебного проекта: свой фолбэк на 40 строк поверх common/llm_utils.py из главы 2.7 (такой каркас — в разделе про DeepSeek ниже). Решение и его причину записываем в README.
Бюджет: на ответ и на день
Заголовок раздела «Бюджет: на ответ и на день»Последний механизм ловит то, что пропустили все предыдущие. Бюджет — это предохранитель, а не отчёт. У ассистента поддержки два естественных масштаба: стоимость одного ответа и расход за день. Посчитаем на примере разложения выше: deepseek-flash в пиковые часы (цены — в разделе про DeepSeek ниже), 8 050 токенов input (1 500 + 1 000 + 2 500 + 3 000 + 50 из схемы; первые три части, 5 000 токенов, — стабильный префикс), 300 output.
| input, $ | output, $ | ответ, $ | 100 ответов в день, $ | |
|---|---|---|---|---|
| без кэша (всё — промах) | 8 050 × 0.30 / 1M = 0.002415 | 300 × 1.20 / 1M = 0.00036 | 0.002775 | 0.28 |
| префикс 5 000 токенов в кэше | 5 000 × 0.006 / 1M + 3 050 × 0.30 / 1M = 0.000945 | 0.00036 | 0.001305 | 0.13 |
Экономия — около 53%. Суммы маленькие: лимиты защищают не от обычного трафика, а от аварии — цикла ретраев, бота-спамера, бага, из-за которого в промпт уходит вся kb/. Дневной лимит ставят в несколько раз выше нормы (например, $1), лимит на ответ — выше худшего нормального случая: всё промах и полный max_tokens=600 дают 8 050 × 0.30 / 1M + 600 × 1.20 / 1M ≈ $0.0031, так что $0.01 ловит раздутый контекст или рассуждение, включённое по ошибке.
Проверяется до запроса:
перед вызовом: estimated = стоимость ответа по известному input и max_tokens if estimated > ANSWER_LIMIT: не вызывать модель → записать в лог, разобраться (это баг, а не трафик) spent_today = SUM(cost) WHERE day = today (из своей таблицы логов) if spent_today + estimated > DAILY_LIMIT: уведомить владельца (один раз в день) → вежливый ответ клиенту и handoffпосле вызова: записать фактическую стоимость if spent_today пересёк 80% → предупреждениеДетали, которые отличают рабочий предохранитель от декоративного:
- Хранить расход в персистентном месте (SQLite, как журнал стоимости в главе 3.4), а не в переменной процесса — рестарт контейнера не должен обнулять счётчик.
- Оценивать стоимость запроса заранее (input известен, output ограничен
max_tokens), иначе последний запрос может сильно превысить лимит. Для агентов — ещё и лимит на число шагов. - Клиент при остановке видит не стектрейс, а «сейчас ответит оператор»:
handoff(question, "unavailable"). - Уведомление — владельцу в Telegram (chat id — в
.env, какOWNER_TELEGRAM_IDв проекте 1), один раз на событие, иначе при остановке придёт 500 одинаковых сообщений. - Второй рубеж — у провайдера: лимиты расходов в консоли Anthropic/OpenAI, лимит трат на ключ OpenRouter. В нашем коде может быть баг; лимит у провайдера от него не зависит.
- Часовой пояс «дня» — явный (UTC), иначе ограничение «скачет».
DeepSeek и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»Всё, что выше, делаем на DeepSeek (облако, OpenAI-совместимый API) и Ollama (локально); облачный запасной — модель другого вендора через OpenRouter (глава 2.4, гл. 2.4). Факты ниже проверены по официальной документации в ноябре 2026 — цены и имена моделей у DeepSeek меняются, перед работой стоит открыть Models & Pricing.
Кэш у DeepSeek: включать нечего, можно только не мешать
Заголовок раздела «Кэш у DeepSeek: включать нечего, можно только не мешать»У Anthropic кэш — явная операция: ставим cache_control, платим за запись и получаем дешёвое чтение. У DeepSeek context caching включён по умолчанию у всех: каждый запрос сам строит кэш на диске, и следующий запрос с тем же префиксом получает «cache hit». Отличия, которые меняют работу:
- Нечего включать и нечего настраивать — нет
cache_control, нет TTL, нет точек разметки. Единственный рычаг тот же, что в теории выше: порядок промпта. Правила, few-shot и постоянные разделыkb/— в начало, найденные куски и вопрос — в конец, никаких timestamp в system. Это работает у всех провайдеров одинаково. - Нет доплаты за запись. Промах стоит обычную цену input (cache miss), попадание — в десятки раз дешевле. Поэтому «первый запрос с кэшем дороже» (вопрос 5 теста) у DeepSeek неверно.
- Best-effort: документация прямо говорит, что 100% попаданий не гарантируется. Кэш строится «за секунды» и удаляется, когда не используется, — «обычно в пределах от нескольких часов до нескольких дней». Фиксированного TTL и минимальной длины в документации нет; кэш совпадает по «единицам префикса» (границы — конец каждого сообщения пользователя и ответа модели, общий префикс нескольких запросов, плюс фиксированные интервалы на длинных текстах).
- Как проверить попадание: в
usageполяprompt_cache_hit_tokensиprompt_cache_miss_tokens. В примере ответа они лежат на верхнем уровнеusage, а в схеме API — внутриprompt_tokens_details; читаем оба места. - Anthropic-совместимый эндпоинт
https://api.deepseek.com/anthropic: код наanthropicSDK из теории запустится против DeepSeek, ноcache_controlтам игнорируется, попадания придут вcache_read_input_tokens, аcache_creation_input_tokens— нулём (Anthropic API). Одинаковый интерфейс — не одинаковая семантика.
Цены (за 1M токенов, USD, на ноябрь 2026, пиковые / непиковые):
| Модель | input, cache hit | input, cache miss | output |
|---|---|---|---|
deepseek-flash |
$0.006 / $0.003 | $0.30 / $0.15 | $1.20 / $0.60 |
deepseek-v4-pro |
$0.044 / $0.022 | $1.32 / $0.66 | $3.96 / $1.98 |
У DeepSeek есть то, чего нет у Anthropic: время суток в цене. Пиковые часы — 01:00–04:00 и 06:00–10:00 UTC с понедельника по пятницу; в остальное время, в выходные и в китайские праздники — половина цены. Значит, формула стоимости зависит от момента запроса, а прогон 100 вопросов в 07:00 UTC стоит вдвое дороже того же прогона в 12:00 UTC. Сравнивая «до/после», сравниваем прогоны из одного ценового окна (или считаем обе стоимости по одному тарифу). Дневной расход ассистента тоже зависит от того, когда пишут клиенты.
# common/llm_utils.py (illustrative) - DeepSeek cost with cache hit/miss and peak hoursfrom datetime import datetime, timezone
# USD per 1M tokens at PEAK; off-peak is half. Checked Nov 2026 - re-check the pricing page.DEEPSEEK_PEAK = { "deepseek-flash": {"hit": 0.006, "miss": 0.30, "out": 1.20}, "deepseek-v4-pro": {"hit": 0.044, "miss": 1.32, "out": 3.96},}
def is_peak(ts: datetime) -> bool: # Chinese public holidays are off-peak too; ignoring them overestimates cost slightly ts = ts.astimezone(timezone.utc) return ts.weekday() < 5 and (1 <= ts.hour < 4 or 6 <= ts.hour < 10)
def ds_cache_tokens(usage) -> tuple[int, int]: # the docs show these fields both at the top level and under prompt_tokens_details u = usage.model_dump() det = u.get("prompt_tokens_details") or {} hit = u.get("prompt_cache_hit_tokens", det.get("prompt_cache_hit_tokens")) or 0 miss = u.get("prompt_cache_miss_tokens", det.get("prompt_cache_miss_tokens")) or 0 return hit, miss
def deepseek_cost(model: str, usage, ts: datetime) -> float: p = DEEPSEEK_PEAK[model] k = 1.0 if is_peak(ts) else 0.5 hit, miss = ds_cache_tokens(usage) return k * (hit * p["hit"] + miss * p["miss"] + usage.completion_tokens * p["out"]) / 1_000_000Ещё один рычаг стоимости, специфичный для DeepSeek: режим размышления включён по умолчанию (effort high, Thinking mode). Рассуждение — это output-токены (видны в completion_tokens_details.reasoning_tokens), то есть самая дорогая часть. Для ответа поддержки по найденным кускам размышление обычно не нужно — выключаем его (как в 5.3) и проверяем evals:
resp = deepseek.chat.completions.create( model="deepseek-flash", messages=messages, max_tokens=600, extra_body={"thinking": {"type": "disabled"}}, # OpenAI SDK: thinking goes in extra_body)В режиме размышления temperature молча игнорируется (без ошибки) — temperature=0 из наших прогонов там ничего не делает.
Фолбэк: DeepSeek → другое облако → Ollama, а для личных данных — только локально
Заголовок раздела «Фолбэк: DeepSeek → другое облако → Ollama, а для личных данных — только локально»Проще всего — своя обёртка: все три провайдера говорят на одном протоколе, отличаются только base_url, ключом и именем модели. Ретраи с паузой делает сам OpenAI SDK (max_retries), а цикл ниже — это фолбэк и circuit breaker.
import os, openaifrom openai import OpenAI
deepseek = OpenAI(api_key=os.environ["DEEPSEEK_API_KEY"], base_url="https://api.deepseek.com", timeout=60, max_retries=2) # explicit timeout: see the keep-alive note belowopenrouter = OpenAI(api_key=os.environ["OPENROUTER_API_KEY"], base_url="https://openrouter.ai/api/v1", timeout=60, max_retries=2)ollama = OpenAI(api_key="ollama", base_url=os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1"), timeout=120, max_retries=0) # key is ignored by Ollama
CLOUD = [("deepseek", deepseek, os.environ.get("ANSWER_MODEL", "deepseek-flash")), ("openrouter", openrouter, os.environ["ANSWER_MODEL_FALLBACK"])] # another vendor's modelLOCAL = [("ollama", ollama, os.environ.get("ANSWER_MODEL_LOCAL", "qwen3:4b"))]
FALLBACK_ON = (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError, openai.InternalServerError) # 429, timeouts, network, 5xx
class NoProviderError(RuntimeError): pass
def chat(messages, *, private: bool, **kw): # private=True: the message has personal data -> local model only, never any cloud chain = LOCAL if private else CLOUD + LOCAL last_err = None for name, client, model in chain: if breaker.is_open(name): # our circuit breaker: skip a provider that keeps failing continue try: resp = client.chat.completions.create(model=model, messages=messages, **kw) breaker.success(name) return name, model, resp # log which provider actually answered except FALLBACK_ON as e: breaker.failure(name) last_err = e raise NoProviderError("no provider available") from last_errВ ask() флаг даёт проверка из 5.3, а последнее звено цепочки — человек:
private = contains_personal_data(question) # runs before any cloud calltry: provider, model, resp = chat(messages, private=private, max_tokens=600)except NoProviderError: handoff(question, "personal_data" if private else "unavailable") return Answer(text=OPERATOR_MESSAGE, sources=[], refused=True, latency_ms=...)Что здесь специфично для наших провайдеров:
- Направление фолбэка — вопрос безопасности, а не только надёжности. Цепочка «DeepSeek → другая облачная модель → Ollama» годится для вопросов о правилах: в промпт идут публичная
kb/и вопрос без личных данных. Для сообщения, гдеcontains_personal_dataнашёл телефон, e-mail или номер заказа, цепочка только локальная: Ollama, а если она недоступна — оператор черезhandoff. Фолбэк «Ollama упала → отправим в облако» — это утечка данных клиента на чужие серверы (у DeepSeek — в Китае), которую мы сами и написали. Поэтому флагprivateобязательный, без значения по умолчанию. - Облачный запасной — от другого вендора (тот же DeepSeek через OpenRouter упадёт вместе с основным), с
data_collection: "deny"(см. ниже).extra_bodyсthinkingпередаём только DeepSeek. - Ошибки DeepSeek (Error Codes): 429 — превышен лимит одновременных запросов (лимиты на аккаунт, а не на ключ), 500 — ошибка сервера, 503 Server Overloaded — аналог 529 у Anthropic. Это поводы для фолбэка. 400/401/422 — нет. 402 Insufficient Balance — особый случай: это не сбой, а кончились деньги на предоплаченном балансе; фолбэк на запасного допустим, но обязательно с уведомлением владельцу, иначе ассистент неделю будет работать на запасной модели и никто не заметит.
- Keep-alive вместо отказа. Под нагрузкой DeepSeek не отвечает ошибкой сразу: держит соединение, отправляя пустые строки (без стриминга) или
: keep-alive(SSE), и закрывает его, только если инференс не начался за 10 минут (Rate Limit). Без явногоtimeoutв клиенте фолбэк сработает через 10 минут — клиент в Telegram-боте поддержки столько ждать не будет. - У Ollama нет 429 и 5xx от перегрузки провайдера — у неё очередь: по умолчанию одна модель обрабатывает один запрос за раз (
OLLAMA_NUM_PARALLEL, по умолчанию 1, FAQ). При переключении всего трафика на Ollama латентность растёт, поэтому у неё таймаут больше. - Кэш теряется при переключении и здесь: у Ollama свой KV-кэш префикса в памяти (поле
prompt_eval_cached_countв родном API), живёт, пока модель загружена (keep_alive, по умолчанию 5 минут). - Промпт и формат. Локальная модель на 4B (или 7–8B на машине с 16 GB) хуже держит правила цитирования, отказ и tool calling (глава 11.4). Прогон evals на запасных моделях обязателен — это тот же пункт теории, только теперь разница качества будет большой.
Вариант через OpenRouter (глава 2.4 · OpenRouter: один API ко многим моделям, гл. 2.4): массив models в запросе — если первая модель вернула ошибку, OpenRouter пробует следующую (Model Fallbacks). Платим по цене модели, которая фактически ответила (видно в поле model ответа), а стоимость приходит в usage.cost автоматически (Usage Accounting). По умолчанию фолбэк срабатывает на любую ошибку, включая превышение контекста и модерацию — это шире, чем правило «только 429/5xx/таймаут» выше. Из важного для приватности: в объекте provider есть data_collection: "deny" (не маршрутизировать к провайдерам, которые хранят данные и могут на них обучаться) и zdr: true (только endpoints с нулевым хранением) — Provider Routing. Но OpenRouter — облако: локальную Ollama он в цепочку не поставит, а сообщения с личными данными клиентов туда тоже нельзя. И кэш DeepSeek через OpenRouter зависит от того, к какому хосту модели ушёл запрос.
Вариант через LiteLLM: префиксы deepseek/<model> (ключ из DEEPSEEK_API_KEY), openrouter/<model> и ollama_chat/<model> с api_base="http://localhost:11434" (LiteLLM рекомендует ollama_chat/, он ходит в /api/chat). Страница провайдера DeepSeek в docs LiteLLM на момент проверки показывала примеры со старыми именами моделей — стоит проверить, что встроенная таблица цен LiteLLM знает deepseek-flash/deepseek-v4-pro и учитывает пиковые часы; если нет — считаем стоимость своей функцией выше.
Бюджет: у DeepSeek предоплата
Заголовок раздела «Бюджет: у DeepSeek предоплата»У Anthropic и OpenAI второй рубеж — лимиты расходов в консоли, у OpenRouter — лимит трат на ключ. У DeepSeek модель другая: предоплаченный баланс. Когда он кончается, API отвечает 402 — это и есть жёсткий потолок, который не зависит от багов нашего кода. Практический вывод: учебный аккаунт пополняем маленькими суммами. Баланс можно читать через GET https://api.deepseek.com/user/balance (поля is_available, balance_infos[].total_balance, granted_balance, topped_up_balance, Get User Balance) — удобно для ежедневной сводки владельцу в Telegram. Свой check_budget при этом всё равно нужен: баланс — потолок на всё, лимиты на ответ и на день — защита от того, чтобы один баг или зациклившийся агент не съел его за ночь. Отдельной настройки лимита расходов в консоли DeepSeek в документации API на момент проверки нет — стоит посмотреть в личном кабинете.
У Ollama денежного бюджета нет, но предохранитель всё равно нужен: лимит шагов агента и max_tokens, иначе зациклившийся агент займёт машину, на которой работают остальные сервисы магазина.
Сравнение провайдеров по функциям этой главы
Заголовок раздела «Сравнение провайдеров по функциям этой главы»| Что | Claude | OpenAI | DeepSeek | Ollama |
|---|---|---|---|---|
| Включение prompt caching | явно: cache_control на блоках (до 4 точек) или автоматический режим одним полем верхнего уровня |
автоматически для достаточно длинных промптов | всегда автоматически, отключить/настроить нечего; best-effort | нет платного кэша; KV-кэш префикса в памяти, пока модель загружена |
| Цена записи в кэш | 1.25× (5 мин) / 2× (1 ч) от input | без отдельной доплаты | без доплаты (промах = обычный input) | — |
| Цена чтения | 0.1× у большинства моделей (у части новых меньше) | со скидкой, см. docs | ≈2% цены промаха (flash), ≈3% (pro) |
$0 |
| Время жизни | 5 мин по умолчанию, 1 ч опционально | см. docs | без фиксированного TTL, часы–дни | пока модель в памяти (keep_alive, 5 мин) |
| Минимальная длина | есть, 512–4096 токенов в зависимости от модели | есть, см. docs | в документации не указана | — |
| Где смотреть попадания | cache_creation_input_tokens, cache_read_input_tokens |
prompt_tokens_details.cached_tokens |
prompt_cache_hit_tokens, prompt_cache_miss_tokens |
prompt_eval_cached_count (родной API) |
| Цена от времени суток | нет | нет | да: вне пиковых часов UTC — 50% | — |
| «Перегружен» | 529 | 5xx | 503; плюс keep-alive до 10 мин до начала инференса | очередь запросов, не ошибка |
| Жёсткий потолок расходов | лимиты в консоли | лимиты в консоли | предоплаченный баланс → 402 | нет денег, есть железо |
| Эмбеддинги для семантического кэша | своего эндпоинта нет | есть | нет эндпоинта эмбеддингов | есть (/api/embed, /v1/embeddings) |
| Запасной для сообщений с личными данными | нельзя | нельзя | нельзя | да (или оператор) |
Что брать для практики
Заголовок раздела «Что брать для практики»- Бенчмарк стоимости и кэша — на DeepSeek (
deepseek-flash): дешёво, и кэш работает «сам», так что измеряется эффект порядка промпта, а не правильность разметки. Вопросы — изeval/golden.jsonlпо публичнойkb/. - Запасные модели. Для вопросов о правилах: DeepSeek → модель другого вендора через OpenRouter → Ollama. Для сообщений с личными данными: только Ollama, дальше —
handoff. - Эмбеддинги для семантического кэша — Ollama (у DeepSeek их нет), той же моделью, что индекс (
bge-m3). - Судья для evals запасных моделей — модель сильнее проверяемой:
deepseek-v4-pro(на публичных данных) против локальной 4B; Claude — если хочется сравнить судей. - Эксперимент с явным
cache_controlна Anthropic — опционально, «для сравнения».
Маршрут изучения
Заголовок раздела «Маршрут изучения»- DeepSeek: Context Caching → вся страница (≈15 мин). Выписываем: как кэш строится, какие поля в
usage, что значит best-effort и сколько живёт кэш. - DeepSeek: Models & Pricing → цены hit/miss/output для обеих моделей и пиковые часы (≈10 мин). Переносим актуальные цифры в
common/llm_utils.py. - DeepSeek: Error Codes и Rate Limit & Isolation → какие коды значат «повтори», какие «ошибка в запросе», что такое 402, как ведёт себя сервер под нагрузкой (keep-alive, 10 минут) (≈15 мин).
- DeepSeek: Thinking Mode → как выключить размышление и какие параметры в этом режиме игнорируются (≈10 мин).
- Ollama FAQ →
keep_alive,OLLAMA_NUM_PARALLEL, контекст по умолчанию (≈10 мин) — это свойства нашего локального запасного провайдера. - (Для сравнения) Anthropic: Prompt caching (ссылка перенаправит на актуальный адрес docs) → разделы про автоматический и явный кэш, цены, минимальную длину, порядок префикса и про то, что инвалидирует кэш (≈30 мин). Выписываем для своей модели: минимальную длину, множители записи и чтения.
- (Для сравнения) OpenAI: Prompt caching → как работает автоматический кэш и где смотреть
cached_tokens(≈10 мин). - LiteLLM → «Getting started» (вызов
completionдля двух провайдеров), затем Routing и Fallbacks / reliability (≈40 мин). Обращаем внимание, как тестировать фолбэк без реального падения провайдера (mock_testing_fallbacksдля Router) и как получить стоимость ответа. Затем страницы провайдеров DeepSeek и Ollama (≈10 мин). - Альтернатива LiteLLM — OpenRouter: Model Fallbacks и Provider Routing → массив
models, какие ошибки вызывают фолбэк,data_collectionиzdr(≈15 мин). - В docs LiteLLM — разделы про caching (включая semantic cache) и budgets: только прочитать, чтобы знать, что готово «из коробки» (≈20 мин).
- GitHub Security Advisories репозитория
BerriAI/litellm— перед установкой, затем выбор версии (≈10 мин). - Личный кабинет DeepSeek → баланс и пополнение; учебный аккаунт пополняем небольшой суммой — это жёсткий потолок. Плюс Get User Balance (≈10 мин). Лимит трат на ключ OpenRouter (глава 2.4) проверяем там же, где создавали ключ. (Для сравнения) В консолях Anthropic и OpenAI находим лимиты расходов.
На что обратить внимание
Заголовок раздела «На что обратить внимание»- Кэш «включён», но не работает. Префикс короче минимума, в system есть timestamp, порядок tools меняется от запроса к запросу (например, их собирают из
set), или паузы между запросами больше TTL. Диагноз — только поusage. - Экономия считается неправильно, если формула стоимости не знает про
cache_read/cache_creation. У Anthropic первый запрос с кэшем дороже, чем без (запись стоит 1.25–2× input). - Сравнение «до/после» на разных вопросах ничего не значит. Один и тот же набор, одинаковый порядок, одинаковые параметры.
- Последовательный прогон vs параллельный. Если 100 запросов отправлены одновременно, кэш ещё не записан — все платят за запись. Сначала один «прогревающий» запрос.
- Постоянные разделы
kb/в префиксе устарели.kb/переиндексировали, а префикс собран при старте процесса — ассистент цитирует старые тарифы. Префикс пересобирается вместе с индексом, архивные страницы в него не попадают. - Постоянный раздел скрывает конфликт.
kb/delivery.mdвсегда в префиксе, аkb/promo.md— только если его нашёл retrieval: про порог бесплатной доставки ответ может назвать одно число вместо двух. Проверяем вопросы с тегомconflict. - Фолбэк маскирует инцидент. Если 30% запросов идёт на запасную модель, а об этом никто не знает — качество упало молча. Метрика «доля фолбэков» должна быть на дашборде из главы 11.3.
- Ретрай не-идемпотентного. Для чистого вопроса-ответа повтор безопасен; для агента с инструментами повтор шага может выполнить инструмент дважды. У ассистента и агента инструменты только на чтение — и это одна из причин, почему так и должно оставаться.
- Семантический кэш без evals — бомба: проблема всплывёт, когда клиент получит ответ про «Свет» вместо «Свет мини».
- Бюджет в памяти процесса обнуляется при рестарте.
- DeepSeek: сравнение прогонов из разных ценовых окон. Прогон в пиковые часы UTC стоит вдвое дороже — «экономия» может оказаться сменой времени суток.
- DeepSeek: нет таймаута в клиенте. Сервер держит соединение keep-alive до 10 минут; фолбэк без
timeoutсрабатывает слишком поздно. - DeepSeek: размышление включено по умолчанию. Output-токены рассуждения дорогие и не нужны для ответа по готовым кускам;
temperatureв этом режиме игнорируется молча. - Фолбэк в облако для личных данных. Ollama → DeepSeek или Ollama → «другое облако» на сообщении с телефоном или номером заказа — утечка. Для них цепочка только локальная, дальше оператор.
Практика
Заголовок раздела «Практика»Работаем в projects/02-support-kb-rag/. Набор — 100 вопросов из eval/golden.jsonl (глава 6.3, гл. 6.3); если их меньше, добираем перефразами. Вопросы, на которых срабатывает contains_personal_data, в облачный прогон не попадают. Провайдеры — как в «Что брать для практики»; данные клиентов, логи с настоящими вопросами и ключи в облако не уходят.
- Базовая линия. Скрипт
bench_cost.py: прогоняет 100 вопросов последовательно черезask()на DeepSeek, для каждого пишет вbench/<run_name>.jsonl: вопрос, провайдер, модель,prompt_tokens,cache_hit,cache_miss,completion_tokens,reasoning_tokens, время запроса (UTC), пик/не пик, стоимость, латентность. Запускаемrun_name=baselineс намеренно «плохим» промптом: куски перед инструкциями, текущее время в начале system. Итог: суммарная стоимость, доляcache_hitот input, средняя стоимость ответа, p50/p95 латентности. - Обновить формулу стоимости в
common/llm_utils.py: hit/miss по ценам DeepSeek и пиковые часы (функцияdeepseek_costиз раздела про DeepSeek). (Для сравнения) Ветка для Anthropic с множителями записи и чтения. - Размышление. 20 вопросов с режимом размышления по умолчанию и с
thinking: disabled. Сравниваемreasoning_tokens, стоимость, латентность и correctness (судья — из главы 6.4). Выбираем режим и записываем почему. - Переставить промпт: всё стабильное (роль, правила цитирования, few-shot) — в начало, куски и вопрос — в конец. Из system убираем всё динамическое. Считаем токены стабильного префикса (у DeepSeek — по
usageодного запроса или их офлайн-токенизатором со страницы Token & Token Usage; у Anthropic —client.messages.count_tokens). - Постоянные разделы
kb/. Добавляем в префиксkb/delivery.mdиkb/returns.md(как в теории) и прогоняем evals: correctness и faithfulness не падают, вопросы с тегамиconflictиarchivedсмотрим отдельно; стало хуже — откатываем. (Для сравнения, Anthropic) Если префикс короче порога модели — осознанно решаем: полезное стабильное содержимое (проверив evals) или вывод, что кэш здесь не даёт эффекта. Мусором ради кэша промпт не раздуваем. - Прогон с правильным порядком,
run_name=cached, в том же ценовом окне, что baseline. На DeepSeek включать нечего: проверяем в jsonl, что у первого запроса почти весь input —cache_miss, а у следующих стабильный префикс уходит вcache_hit. Считаем экономию в процентах, долю попаданий и стоимость одного ответа; сверяем с расчётом из теории. Повторяем прогон через сутки — сколько попаданий осталось (кэш best-effort, без фиксированного TTL)? - (Для сравнения) Тот же прогон на Claude с
cache_control:cache_creation > 0у первого запроса,cache_read > 0у остальных. Сравниваем экономию с DeepSeek и записываем, почему у Anthropic первый запрос дороже, а у DeepSeek нет. - Фолбэк. Своя обёртка (каркас
chat(..., private=...)из раздела про DeepSeek), LiteLLM Router или OpenRouter с массивомmodels: DeepSeek → модель другого вендора через OpenRouter (ANSWER_MODEL_FALLBACK,data_collection: "deny") → Ollama. Фолбэк только на 429/5xx/503/таймауты;timeoutв клиенте явный. 402 — уведомление владельцу в Telegram. - Тест приватного пути. Вопрос с телефоном или номером заказа ни при каком сбое не уходит ни в DeepSeek, ни в OpenRouter: останавливаем Ollama (
ollama stop <model>и выключаем сервер) и убеждаемся, что облачные клиенты не вызваны ни разу, а вoutputs/handoff.jsonlпоявилась строка с причинойpersonal_data. Это тест безопасности, его оставляем вtests/(облачные клиенты — фейки, как в тестах 5.3). - Фолбэк без реального сбоя: заведомо неверный
base_urlдля DeepSeek в тестовом конфиге (илиmock_testing_fallbacksв LiteLLM). В логе записано, какая модель ответила. - 400 (например,
max_tokens=-1) не вызывает фолбэк. - Evals на обеих запасных моделях (судья —
deepseek-v4-proили другая модель сильнее проверяемых, одна для всех строк); цифры — в таблицу экспериментов (глава 8.5, гл. 8.5). - Бюджет. Таблица
spend(day TEXT, ts, model, cost)вspend.dbпроекта (не в git; в Docker — в томе, а не в образе). Функцияcheck_budget(...)перед каждым вызовом;ANSWER_LIMIT_USD(на один ответ) иDAILY_LIMIT_USD(на день) из.env. Превышен лимит на ответ — модель не вызывается, событие в лог. Превышен дневной — клиенту вежливое сообщение иhandoff(question, "unavailable"), владельцу одно сообщение в Telegram за день. Каркас:def check_budget(db, estimated: float, answer_limit: float, daily_limit: float) -> str | None:# return "answer_limit" or "daily_limit" if the call must not run, None if it may;# today's spend is summed in UTC...def notify_once(db, event: str) -> None:# insert (day, event) with a UNIQUE constraint; send to Telegram only if inserted... - Проверка бюджета: дневной лимит $0.01, несколько запросов — остановка и ровно одно уведомление. Перезапуск контейнера — лимит всё ещё действует. Отдельно: искусственно раздутый контекст (например, k=50) упирается в лимит на ответ.
- Ежедневная сводка владельцу в Telegram: баланс DeepSeek (
GET /user/balance), расход за день и средняя стоимость ответа. - (Опционально) Семантический кэш на эмбеддингах из Ollama (у DeepSeek эмбеддингов нет): порог, TTL, ключ с версией индекса, 10 пар-перефраз (должны попадать) и 10 пар-ловушек «вскрытый/невскрытый чай», «„Свет“/„Свет мини“», «TEA10/TEA15» (не должны). Записываем, при каком пороге ловушки начинают пробивать.
- На балансе DeepSeek и на ключе OpenRouter — небольшие суммы и лимиты: это потолок. (Для сравнения) Лимит расходов в консолях Anthropic/OpenAI, если они используются.
Готово, когда
Заголовок раздела «Готово, когда»- Есть цифры: стоимость 100 ответов до и после оптимизаций, экономия в процентах и стоимость одного ответа (или обоснованный вывод, почему кэш здесь не помогает).
- Фолбэк срабатывает на сбой основного провайдера и не срабатывает на 400; в логах видно, кто ответил.
- Лимиты на ответ и на день останавливают запросы, переживают рестарт, дневной шлёт одно уведомление.
- Цифры внесены в README/таблицу экспериментов; стоимость DeepSeek посчитана с hit/miss и пиковыми часами.
- Приватный путь проверен тестом: при падении Ollama сообщение с личными данными не уходит ни в одно облако, а передаётся оператору.
Тест для самопроверки
Заголовок раздела «Тест для самопроверки»Вопрос 1
Почему в RAG-промпте найденные куски ставят после system prompt и few-shot, а не перед?
Кэшируется префикс, а любое изменение инвалидирует всё после себя. Куски меняются от вопроса к вопросу, поэтому всё стабильное должно идти до них.
Вопрос 2
cache_control включён, но в usage и cache_creation_input_tokens, и cache_read_input_tokens равны нулю. Какие две возможные причины?
Стабильный префикс короче минимальной длины для модели; в префиксе есть меняющиеся данные (timestamp, id); порядок tools меняется; запросы реже TTL; неправильное место cache_control.
Вопрос 3
Что не так в этом system prompt с точки зрения кэширования?
system = f"""Ты ассистент поддержки магазина «Полка». Сейчас {datetime.now().isoformat()}.Отвечай со ссылками [1], [2]. ... (ещё 3000 токенов правил)"""Текущее время в начале system делает префикс уникальным для каждого запроса — кэш всегда пишется (дороже) и никогда не читается. Дату, если нужна, — в конец, в сообщение пользователя.
Вопрос 4
Какие ошибки должны вызывать фолбэк на другого провайдера?
b. 4xx (кроме 429) — ошибка запроса или ключа; на другом провайдере она повторится или спрячет баг.
Вопрос 5
Почему первый запрос с включённым кэшем дороже, чем без кэша?
Запись в кэш стоит дороже обычного input (1.25× для 5-минутного TTL у Anthropic). Экономия появляется с повторными чтениями. У DeepSeek доплаты за запись нет: промах стоит обычную цену, поэтому первый запрос не дороже.
Вопрос 6
Пример из ассистента поддержки, где семантический кэш выдаст опасно неверный ответ?
«Можно ли вернуть вскрытый чай?» и «можно ли вернуть невскрытый чай?», или гарантия на «Свет» (12 месяцев) и на «Свет мини» (6 месяцев) — эмбеддинги близки, ответы разные. Ещё «действует ли TEA15 со скидкой» после правки kb/promo.md — ответ устаревает; и ответ на вопрос с личными данными, выданный другому клиенту.
Вопрос 7
Почему дневной бюджет нельзя хранить в переменной Python-процесса?
Рестарт или пересоздание контейнера обнулит счётчик, и лимит перестанет действовать. Расход хранится персистентно (SQLite).
Вопрос 8
Что такое circuit breaker и зачем он нужен вместе с фолбэком?
После серии сбоев основного провайдера он временно «выключается», и запросы сразу идут в запасной, не тратя время на таймауты. Без него каждый запрос во время инцидента ждёт таймаут перед фолбэком.
Вопрос 9
Зачем прогонять evals на запасной модели?
Запасная модель может отвечать хуже или ломать формат (цитаты, отказ, tool calling). Фолбэк, качество которого неизвестно, может быть хуже честной ошибки.
Вопрос 10
100 запросов отправлены одновременно через asyncio.gather, и экономии от кэша почти нет. Почему?
Все запросы ушли до того, как первый записал кэш, поэтому каждый заплатил за запись. Нужен один прогревающий запрос, затем параллельные.
Вопрос 11
Что даёт LLM-гейтвей (LiteLLM Proxy) по сравнению с вызовом SDK из кода, и почему для одного учебного проекта он может быть лишним?
Единая точка для ключей, бюджетов на ключ/команду, логов, фолбэков и смены провайдера без правок приложения. Для одного проекта это лишний сервис в compose и ещё одна зависимость, которой доверены все ключи.
Вопрос 12
Код перенесли с Anthropic на DeepSeek и оставили cache_control. Что произойдёт с кэшем и как это увидеть в usage?
Через Anthropic-совместимый эндпоинт DeepSeek cache_control игнорируется, но кэш работает автоматически: попадания видны в cache_read_input_tokens, cache_creation_input_tokens равен нулю. Через OpenAI-совместимый — смотрим prompt_cache_hit_tokens/prompt_cache_miss_tokens.
Вопрос 13
Два прогона одного и того же набора на DeepSeek дали стоимость $0.42 и $0.21, хотя промпт и вопросы те же, а доля cache hit одинаковая. Самое вероятное объяснение?
Один прогон был в пиковые часы (01–04 и 06–10 UTC по будням), другой — вне их: вне пика цены у DeepSeek вдвое ниже. Сравнивать нужно прогоны из одного окна или пересчитывать по одному тарифу.
Вопрос 14
Что не так в цепочке chain = ["ollama", "deepseek", "openrouter"] для сообщений, где contains_personal_data вернул True, и почему для вопроса «можно ли вернуть вскрытый чай?» цепочка «DeepSeek → другая облачная модель» допустима?
При сбое Ollama телефон, e-mail или номер заказа уйдут на чужие серверы (у DeepSeek — в Китае); другой провайдер — всё равно облако. Для личных данных цепочка только локальная: Ollama, затем оператор через handoff(question, "personal_data"). Вопрос о правилах без личных данных отвечается по публичной kb/, его можно отдать любой облачной модели — с evals на запасной.
Вопрос 15
DeepSeek под нагрузкой не возвращает 503, а запрос «висит» несколько минут. Почему, и что должно быть в клиенте?
При ожидании DeepSeek держит соединение keep-alive (пустые строки или : keep-alive в SSE) и закрывает его, только если инференс не начался за 10 минут. Нужен явный timeout в клиенте, чтобы фолбэк сработал вовремя.
Что дальше
Заголовок раздела «Что дальше»Цифры экономии сейчас в jsonl-файлах — в главе 11.3 · Трейсинг и стоимость одного ответа они переедут в Langfuse, и появится дашборд по всем четырём проектам, включая долю фолбэков и стоимость одного ответа поддержки. Локальная модель как бесплатный запасной вариант и единственный путь для личных данных — в главе 11.4 · Локальная модель через Ollama + свои evals.