Хватит относиться к промпту как к строке: инженерная практика кэширования промптов в ИИ-приложениях
Автор предлагает не кэшировать промпт как единый результат запроса, а разделить его на базовый слой стратегии, слой бизнес-знаний и слой контекста запроса, кэшируя только стабильные фрагменты.
Превращение кэша промптов из кэша результатов запроса в многоуровневую, версионируемую и наблюдаемую инфраструктуру — с конкретными подходами к протоколу ключей кэша и управлению инвалидацией.
Многие команды, делая ИИ-приложения, в первой версии пишут промпт одной длинной строкой. Системная роль, бизнес-правила, описание инструментов, JSON-схема, few-shot примеры, ввод пользователя — всё склеивается вместе и отправляется модели. На этапе демо такой подход работает, но как только приходит реальный трафик, проблемы дают о себе знать очень быстро.
Самое очевидное — стоимость. Каждый запрос заново отправляет большой кусок стабильного контекста: даже если пользователь поменял одну фразу, система всё равно обрабатывает весь промпт целиком. Дальше — задержка. Чем длиннее контекст, тем медленнее приходит первый токен, чем длиннее цепочка вызовов инструментов, тем чаще пользователю кажется, что продукт тормозит. Но ещё незаметнее проблемы управления. Поментяли одну строку в промпте — какие запросы это затронуло? Кэш всё ещё валиден? После обновления правил доступа для конкретного арендатора старый кэш не продолжит ли срабатывать? Если не заложить это в инженерную архитектуру, всё в итоге превращается в инциденты на проде.
В этой статье я хочу разобрать вполне конкретную практику: не стоит относиться к Prompt Cache как к простому «кэшу результатов запроса» — это один из слоёв инфраструктуры ИИ-приложения. Он кэширует не финальный ответ модели, а те стабильные фрагменты промпта, которые можно переиспользовать, версионировать и наблюдать.
I. Почему «хэшировать весь промпт» обычно недостаточно
Самое очевидное решение — один раз хэшировать весь промпт и хранить ответ модели в Redis. Для FAQ, фиксированных вопросов-ответов и офлайн-саммари это работает, но в интерактивных ИИ-приложениях быстро перестаёт.
Причина простая: в реальных запросах много мелких изменений:
- Ввод пользователя каждый раз разный
- Текущее время, регион, языковые предпочтения меняются
- Результаты поиска меняются
- Состояние инструментов меняется
- Права арендатора и бизнес-конфигурация меняются
- Формат вывода может подстраиваться под сценарий
Достаточно измениться любому из этих пунктов — и хэш всего промпта становится совсем другим. Процент попаданий низкий, и кэш фактически превращается в декорацию. Хуже того, чтобы поднять процент попаданий, команда может выкинуть из хэша поля, которые игнорировать нельзя, — и получить пересечение прав или грязные данные.
Поэтому суть Prompt Cache не в том, чтобы «кэшировать весь промпт», а в том, чтобы выделить в промпте стабильные и изменяемые слои.
II. Разбиваем промпт на три слоя
Я предпочитаю делить промпт в ИИ-приложении на три слоя.
Первый слой — базовые политики. Сюда входят системная роль, границы ответа, политики безопасности, язык вывода, ограничения формата. Этот слой обычно привязан к версии приложения и меняется реже всего.
Второй слой — бизнес-знания. Сюда входят описание продукта, список инструментов, схема полей, few-shot примеры, типовые способы обработки ошибок. Этот слой меняется вместе с развитием бизнеса, но не в каждом запросе.
Третий слой — контекст запроса. Сюда входят текущий ввод пользователя, найденные фрагменты, саммари диалога, временные переменные, результаты работы инструментов. Этот слой меняется быстрее всего и обычно не годится для долгосрочного кэширования.
Инженерная цель — сделать первый и второй слои как можно более стабильными и легко кэшируемыми, а третий слой пусть участвует только в финальном вызове и не загрязняет стабильный кэш.
Это можно представить как chunk во фронтенд-сборке. Системные правила — как vendor chunk, бизнес-схема — как feature chunk, ввод пользователя — как runtime data. Вы же не пересобираете весь фронтенд-проект из-за того, что пользователь нажал кнопку; так и не стоит заставлять модель заново обрабатывать весь стабильный контекст из-за одной изменённой фразы.
III. Ключ кэша — не конкатенация строк, а протокол версий
Самое проблемное место в Prompt Cache — ключ кэша. Безопасный ключ должен включать как минимум такие измерения:
- appId — какое приложение
- tenantId — какой арендатор
- promptVersion — версия шаблона промпта
- policyVersion — версия политик доступа и безопасности
- schemaVersion — версия структуры вывода
- locale — язык и регион
- modelFamily — семейство моделей или класс возможностей
- segmentHash — хэш содержимого стабильного фрагмента
Заметьте: здесь userId нет во всех ключах. Нужен ли userId, зависит от того, содержит ли кэшируемый фрагмент приватную информацию пользователя. Системные правила и общие схемы не должны разбиваться по пользователям, иначе процент попаданий будет очень плохим. Фрагменты, содержащие права пользователя, его профиль или саммари приватных документов, обязательно нужно изолировать по пользователю или по границе прав.
Частая ошибка — использовать только хэш текста промпта. Выглядит чисто, но в нём нет семантики версий. Например, политика доступа не записана в тексте промпта, а выполняется на уровне серверных инструментов; когда политика обновляется, старый кэш всё равно может сработать. Такой баг трудно искать, потому что по тексту промпта вроде бы ничего не изменилось.
IV. Минимальная реализация
Ниже — упрощённая реализация в стиле TypeScript. Это не полноценный фреймворк, но её достаточно, чтобы показать основную идею.
import crypto from "node:crypto";
type Segment = {
name: string;
content: string;
scope: "global" | "tenant" | "user";
version: string;
};
type CacheContext = {
appId: string;
tenantId: string;
userId?: string;
modelFamily: string;
policyVersion: string;
locale: string;
};
function sha256(input: string) {
return crypto.createHash("sha256").update(input).digest("hex").slice(0, 16);
}
function segmentKey(ctx: CacheContext, seg: Segment) {
const parts = [
"prompt-segment",
ctx.appId,
ctx.modelFamily,
ctx.locale,
`policy:${ctx.policyVersion}`,
`segment:${seg.name}`,
`v:${seg.version}`,
`h:${sha256(seg.content)}`,
];
if (seg.scope === "tenant") parts.push(`tenant:${ctx.tenantId}`);
if (seg.scope === "user") parts.push(`tenant:${ctx.tenantId}`, `user:${ctx.userId ?? "anonymous"}`);
return parts.join(":");
}
В этом коде два важных момента. Первый: хэш содержимого и явная версия существуют одновременно. Хэш гарантирует, что изменение содержимого сбросит кэш, а версия позволяет активно сбрасывать его при смысловых изменениях. Второй: scope явно задаёт границу изоляции — так мы не разбиваем весь кэш грубо по пользователям и не пускаем приватные данные пользователя в глобальный кэш.
В реальной системе значением кэша могут быть предобработанные фрагменты промпта, статистика по токенам, сжатые саммари, результат компиляции схемы инструментов или ссылка на prefix cache, которую поддерживает тот или иной провайдер модели. Возможности у моделей разные, но идея разбиения на слои, версионирования и наблюдаемости на верхнем уровне универсальна.
V. Не смотрите только на процент попаданий
Многие команды после запуска кэша смотрят только на hit rate — этого мало. Для Prompt Cache нужно как минимум пять категорий метрик.
Первая — segment hit rate. Низкий процент попаданий по всему запросу ещё не значит, что кэш бесполезен; важен высокий процент попаданий по стабильным фрагментам.
Вторая — saved tokens. Сколько входных токенов сэкономил кэш — это ближе к реальной выгоде по стоимости, чем число попаданий.
Третья — first token latency. Пользователь ощущает время до первого токена, а не время выполнения серверной функции.
Четвёртая — stale prevention. Число активных сбросов из-за изменений версии, прав или схемы должно фиксироваться.
Пятая — fallback rate. Сколько запросов при сбое кэша уходит на путь без кэша. Если fallback слишком высокий, значит кэш-слой нестабилен; если fallback нет вообще, значит кэш-слой может стать единой точкой отказа.
Каждый вызов можно записывать в такой структуре.
type PromptCacheTrace = {
requestId: string;
segments: Array<{
name: string;
scope: string;
hit: boolean;
inputTokens: number;
savedTokens: number;
version: string;
}>;
latencyMs: number;
firstTokenMs?: number;
fallback: boolean;
invalidationReason?: string;
};
Эти трейсы не нужно хранить целиком навсегда. Наоборот, в проде лучше не писать открытый промпт пользователя в логи. Обычно достаточно записать имя сегмента, версию, хэш, число токенов и статус попадания.
VI. Воспроизводимый эксперимент
Чтобы не ограничиваться одними концепциями, я написал небольшой эксперимент. Он не вызывает реальную модель, а моделирует стоимость и задержку трёх стратегий:
- no-cache — каждый раз обрабатывается весь промпт
- full-hash-cache — попадание только при полностью одинаковом промпте
- segment-cache — стабильные фрагменты могут попадать в кэш по отдельности
const requests = Array.from({ length: 1000 }, (_, i) => ({
tenant: `t${i % 10}`,
schemaVersion: i < 700 ? "v1" : "v2",
userInput: `question-${i}`,
retrievedDoc: `doc-${i % 80}`,
}));
const baseTokens = 1800;
const schemaTokens = 900;
const dynamicTokens = 500;
function simulate(strategy) {
const cache = new Set();
let costTokens = 0;
let hits = 0;
for (const r of requests) {
if (strategy === "no-cache") {
costTokens += baseTokens + schemaTokens + dynamicTokens;
continue;
}
if (strategy === "full-hash-cache") {
const key = `${r.tenant}:${r.schemaVersion}:${r.userInput}:${r.retrievedDoc}`;
if (cache.has(key)) hits++;
else {
cache.add(key);
costTokens += baseTokens + schemaTokens + dynamicTokens;
}
continue;
}
if (strategy === "segment-cache") {
const baseKey = `base:${r.tenant}`;
const schemaKey = `schema:${r.schemaVersion}`;
if (cache.has(baseKey)) hits++; else { cache.add(baseKey); costTokens += baseTokens; }
if (cache.has(schemaKey)) hits++; else { cache.add(schemaKey); costTokens += schemaTokens; }
costTokens += dynamicTokens;
}
}
return { strategy, hits, costTokens };
}
console.table([
simulate("no-cache"),
simulate("full-hash-cache"),
simulate("segment-cache"),
]);
В этой симуляции full-hash-cache почти не даёт выигрыша, потому что ввод пользователя каждый раз разный. segment-cache выигрывает заметно, потому что базовые правила арендатора и версии схемы активно переиспользуются. Реальные результаты на проде зависят от модели, структуры запросов и распределения бизнеса, но направление обычно одно: кэшировать стабильные фрагменты надёжнее, чем надеяться на повторение всего запроса.
Здесь стоит подчеркнуть: это не бенчмарк цен у вендоров и не оценка производительности моделей. Это лишь способ помочь команде проверить, есть ли у её структуры промпта потенциал для переиспользования. Перед реальным запуском стоит на теневом трафике записать токены, задержки и причины сброса кэша, а потом решать, какие фрагменты действительно стоит кэшировать.
VII. Сбросом кэша нужно уметь управлять
Prompt Cache не заканчивается на написании ключа. На проде важнее стратегия сброса.
Рекомендую предусмотреть как минимум три способа сброса.
Первый — сброс по версии. При выпуске нового промпта, новой схемы или новых правил доступа просто поднимайте номер версии. Это самый управляемый способ.
Второй — сброс по области. Когда меняется конфигурация конкретного арендатора, очищайте только кэш в его tenant scope, не трогая глобальный кэш.
Третий — аварийное отключение. Если обнаружилось загрязнение кэша или аномалии в выводе, можно быстро отключить кэш по app, tenant, segment, modelFamily.
Не полагайтесь только на TTL. TTL может подстраховать, но не выражает бизнес-семантику. Изменение прав должно вступать в силу немедленно, ждать естественного истечения кэша нельзя. И выпуск схемы тоже не должен упираться в «подождём десять минут».
Практичная панель управления может выглядеть так:
{
"appId": "support-agent",
"cacheEnabled": true,
"disabledSegments": ["user-private-profile"],
"minPromptVersion": "2026-10-12.3",
"tenantOverrides": {
"tenant-a": {
"cacheEnabled": false,
"reason": "permission policy migration"
}
}
}
Такие настройки лучше читать на сервере, а не хардкодить в коде приложения. ИИ-приложения меняются быстро, и если для каждого отключения кэша нужно выпускать новую версию, реагировать на инциденты будет очень тяжело.
VIII. Частые грабли
Первые грабли — надолго кэшировать результаты поиска. В RAG-сценариях результаты поиска часто зависят от версии индекса, прав и времени. Можно кэшировать нормализованный результат поискового запроса или общие фрагменты знаний, но нельзя игнорировать права на документы и версию индекса.
Вторые грабли — подмешивать результаты работы инструментов в стабильный слой. Результаты инструментов обычно отражают текущее состояние: заказы, остатки, процесс согласования, баланс счёта. Они должны быть контекстом запроса, а не глобальным активом промпта.
Третьи грабли — кэшировать неправильный формат. Если модель не смогла выдать JSON и результат ошибки закэширован, следующие запросы будут продолжать наступать на те же грабли. При кэшировании финального ответа нужно сохранять только результаты, прошедшие проверку по схеме.
Четвёртые грабли — не различать «экономию входных токенов» и «экономию сквозного времени». Если узкое место — внешние вызовы инструментов или поиск, Prompt Cache слабо помогает сквозной задержке, но всё ещё может снижать стоимость. Метрики нужно смотреть отдельно.
Пятая ловушка — утечка через логи. Чтобы разобраться с попаданиями, вы пишете полный текст промпта в логи — кажется, что так удобно, а на деле это очень опасно. Лучше записывать хеш, версию, число токенов и обезличенный фрагмент после сэмплирования.
IX. Чек-лист внедрения
Если вы собираетесь внедрить Prompt Cache в команде, двигайтесь в таком порядке.
- Сначала разбейте промпт на сегменты, не спешите с кэшем.
- Пометьте каждый сегмент: scope, version, owner.
- Заведите протокол ключей: тенант, права, схема, семейство моделей.
- На теневом трафике фиксируйте токены, задержку, кандидатов на попадание и причины промахов.
- Кэшируйте только стабильные сегменты с высоким потенциалом попадания и чёткими границами прав.
- Добавьте fallback: сбой кэша не должен влиять на основной путь.
- Добавьте управляющий слой с возможностью отключения по app, tenant, segment.
- После релиза смотрите saved tokens, first token, fallback rate, а не только hit rate.
Эта работа звучит сложнее, чем «добавить Redis», но взамен вы получаете объяснимость, откат и управляемость. Когда AI-приложение выходит в продакшен, промпт всё больше становится смесью кода и конфигурации. Раз код требует версий, тестов, канареечного релиза и отката, промпт заслуживает такого же инженерного отношения.
Заключение
Суть Prompt Cache не в том, чтобы сэкономить немного токенов, а в том, чтобы признать: промпт стал ключевым активом AI-приложения. Если продолжать склеивать его из строк, команда будет раз за разом терять на стоимости, задержке и управляемости. Разбейте его на стабильные сегменты, заведите протокол версий, границы изоляции и метрики — только тогда AI-приложение сможет пройти путь от демо до надёжного продукта.
Если запомнить только одну фразу, пусть будет эта: кэшируйте не «запросы, которые выглядят одинаково», а «промпт-активы, семантически стабильные, безопасные по границам и управляемые по версиям».
Источник: Juejin · Популярное об ИИ за неделю · juejin.cn