Команда AI for Devs подготовила перевод и разбор статьи о Prompt Caching — технологии, которая делает входные токены LLM в разы дешевле и заметно снижает задержКоманда AI for Devs подготовила перевод и разбор статьи о Prompt Caching — технологии, которая делает входные токены LLM в разы дешевле и заметно снижает задерж

[Перевод] Prompt Caching: токены LLM в 10 раз дешевле — но за счёт чего?

Команда AI for Devs подготовила перевод и разбор статьи о Prompt Caching — технологии, которая делает входные токены LLM в разы дешевле и заметно снижает задержки. Внутри — подробное объяснение, что именно кэшируют OpenAI и Anthropic, как KV-кэш связан с attention в трансформерах и почему это не имеет ничего общего с повторным использованием ответов.


На момент, когда я пишу эту статью, закэшированные входные токены стоят в долларах за токен примерно в 10 раз дешевле обычных входных токенов — как в API OpenAI, так и Anthropic.

Anthropic даже заявляет, что кэширование промптов может снизить задержку «до 85% для длинных промптов», и в моих собственных тестах я убедился, что при достаточно длинном промпте это действительно так. Я отправил сотни запросов и в Anthropic, и в OpenAI и заметил существенное сокращение задержки до первого токена для промптов, в которых все входные токены брались из кэша.

fb00105148f3a46b509ea946c39b049c.png

Теперь, когда я зацепил вас красивым текстом и эффектными графиками, задавались ли вы когда-нибудь вопросом…

Что вообще такое закэшированный токен?

Что происходит в этих бескрайних океанах GPU, что позволяет провайдерам давать вам скидку в 10 раз на входные токены? Что именно они сохраняют между запросами? Это не сохранение ответа с последующим повторным использованием, если тот же самый промпт отправляется снова — это легко проверить через API. Напишите промпт, отправьте его десяток раз и обратите внимание: вы получаете разные ответы каждый раз, даже если в разделе usage указано, что входные токены были закэшированы.

Не удовлетворившись ответами в документации вендоров ПО для разработчиков, которые хорошо объясняют, как пользоваться кэшированием промптов, но аккуратно обходят вопрос о том, что именно кэшируется, я решил копнуть глубже. Я нырнул в кроличью нору устройства LLM, пока не понял, какие именно данные провайдеры кэшируют, для чего они используются и как это делает всё быстрее и дешевле для всех.

К концу этой статьи вы:

  • глубже поймёте, как работают LLM

  • сформируете новую интуицию о том, почему LLM устроены именно так

  • разберётесь, какие именно нули и единицы кэшируются и как это снижает стоимость ваших запросов к LLM

Архитектура LLM

В своей основе LLM — это гигантские математические функции. Они принимают на вход последовательность чисел и выдают на выходе число. Внутри LLM находится огромный граф из миллиардов тщательно организованных операций, которые преобразуют входные числа в выходное.

Этот гигантский граф операций можно грубо разделить на четыре части.

2c064ab56e51222c05c888cb706d37e0.png

Каждый узел на этой схеме можно рассматривать как функцию, которая принимает входные данные и возвращает результат. Вход подаётся в LLM в цикле до тех пор, пока специальное выходное значение не скажет модели остановиться. В виде псевдокода это может выглядеть так:

// Copy code prompt = "What is the meaning of life?"; tokens = tokenizer(prompt); while (true) { embeddings = embed(tokens); for ([attention, feedforward] of transformers) { embeddings = attention(embeddings); embeddings = feedforward(embeddings); } output_token = output(embeddings); if (output_token === END_TOKEN) { break; } tokens.push(output_token); } print(decode(tokens));

Место, где происходит кэширование промптов, находится в механизме attention трансформера. Мы шаг за шагом разберём, как работает LLM, пока не доберёмся до него. А значит, начинать этот путь нужно с токенов.

Токенизатор

03ccde919a9ea42f48a8bc752e9ee24b.png

Прежде чем LLM сможет что-то сделать с вашим промптом, его нужно преобразовать в представление, с которым модель умеет работать. Это двухэтапный процесс, разделённый между токенизатором и стадией embedding. Зачем всё это нужно, станет понятно только когда мы дойдём до embedding, так что потерпите, пока мы разберёмся с тем, что делает токенизатор.

Токенизатор берёт ваш промпт, нарезает его на небольшие фрагменты и каждому уникальному фрагменту присваивает целочисленный идентификатор — «токен». Например, вот как GPT-5 токенизирует промпт «Check out ngrok.ai»:

8f28cda400a475eb2a585b4a63fb7d76.png

Промпт был разбит на массив ["Check", " out", " ng", "rok", ".ai"] и преобразован в токены [4383, 842, 1657, 17690, 75584]. Один и тот же промпт всегда даёт один и тот же набор токенов. Токены также чувствительны к регистру, потому что заглавные буквы несут информацию. Например, «Will» с заглавной W с большей вероятностью является именем, чем «will» со строчной.

Токены — это базовая единица ввода и вывода для LLM. Когда вы задаёте ChatGPT вопрос, ответ стримится обратно по одному токену за итерацию работы модели. Провайдеры делают это потому, что генерация полного ответа может занимать десятки секунд, а отправка токенов по мере готовности делает процесс более интерактивным.

Зададим классический вопрос к LLM, чтобы увидеть это в действии.

ff2764978e6b8f92d74a1748694c059e.gif

Токены промпта поступают на вход, ✨ происходит магия ИИ ✨, на выходе появляется токен, и всё повторяется. Этот процесс называется inference. Обратите внимание: каждый выходной токен добавляется к входному промпту перед следующей итерацией. LLM нужен весь контекст, чтобы выдавать качественные ответы. Если бы мы передавали только промпт, модель постоянно пыталась бы сгенерировать первый токен ответа. Если бы передавали только ответ — она сразу забывала бы вопрос. И промпт, и ответ целиком должны подаваться в LLM на каждой итерации.

И последнее про токенизаторы: их много. Токенизатор ChatGPT отличается от токенизатора Claude. Даже разные модели OpenAI используют разные токенизаторы. У каждого свои правила разбиения текста на токены. Если хотите посмотреть, как разные токенизаторы режут текст, загляните в tiktokenizer.

Теперь, когда мы разобрались с токенами, поговорим об embeddings.

Embedding

f1345b38fcea71c53df9d10daeee303e.png

Токены, полученные от токенизатора, теперь поступают на стадию embedding. Чтобы понять, что такое embedding, полезно сначала разобраться, какова цель модели.

Когда люди решают задачу с помощью кода, они пишут функции, которые принимают входные данные и возвращают результат. Например, перевод температуры из Фаренгейтов в Цельсии.

// Copy code function fahrenheitToCelsius(fahrenheit) { return ((fahrenheit - 32) * 5) / 9; }

Мы можем подать в fahrenheitToCelsius любое число и получить правильный ответ. Но что если у нас задача, где формула неизвестна? Что если у нас есть только загадочная таблица входов и выходов?

Input

Output

21

73

2

3

10

29

206

1277

Я не ожидаю, что вы узнаете эту функцию, хотя замечу, что ChatGPT сразу же её определяет, если вставить скриншот в приложение.

Когда мы знаем правильный результат для каждого входа, но не знаем саму функцию, мы можем «обучить» модель восстановить эту функцию. Для этого мы даём модели заготовку — тот самый огромный граф математических операций — и изменяем его, пока модель не сойдётся к нужной функции. После каждого обновления графа мы прогоняем через него входные данные и смотрим, насколько близко он подошёл к правильным выходам. Мы повторяем это, пока результат нас не устроит. Это и есть обучение.

Оказывается, при обучении модели генерировать корректный текст очень важно уметь определять, насколько два предложения похожи. Но похожи в каком смысле? Они могут быть одинаково грустными, смешными или заставляющими задуматься. Похожими по длине, ритму, тону, языку, словарю, структуре. Существует огромное количество измерений, по которым можно сравнивать предложения, и они могут быть похожи по одним измерениям и различаться по другим.

У токенов нет измерений. Это просто целые числа. А вот embeddings — имеют. Имеют много измерений.

Embedding — это массив длины n, представляющий точку в n-мерном пространстве. Если n равно 3, embedding может выглядеть как [10, 4, 2], то есть точка с координатами x=10, y=4, z=2 в трёхмерном пространстве. При обучении LLM каждому токену сначала назначается случайное положение в этом пространстве, а затем процесс обучения постепенно сдвигает все токены, пока не будет найдено такое расположение, которое даёт наилучшие результаты.

Стадия embedding начинается с того, что для каждого токена извлекается его embedding. В псевдокоде это может выглядеть так:

// Copy code // Created during training, never changes during inference. const EMBEDDINGS = [...]; function embed(tokens) { return tokens.map(token => { return EMBEDDINGS[token]; }); }

Мы берём токены — массив целых чисел — и превращаем их в массив embeddings. Массив массивов, или матрицу.

e2ec55426f8aac296d46d6e349866a98.gif

Токены [75, 305, 284, 887] преобразуются в матрицу трёхмерных embeddings.

Чем больше измерений у embeddings, тем по большему числу осей модель может сравнивать предложения. Мы говорили о embeddings с тремя измерениями, но современные модели используют embeddings с тысячами измерений. У самых больших моделей их больше 10 000.

Чтобы показать ценность дополнительных измерений, ниже я привёл восемь групп цветных фигур, которые сначала находятся в одномерном пространстве. Они лежат на линии и представляют собой хаотичную мешанину. Но по мере добавления измерений становится видно, что на самом деле это восемь отдельных, связанных между собой групп.

a6b217e4e0dc8fcecf2325277a4c832e.gif

Три измерений — это максимум, который я могу показать визуально, дальше вам придётся подключить воображение, чтобы представить, что можно сделать с тысячами измерений.

Есть ещё одна важная вещь, которую делает стадия embedding. После получения embeddings токена в них кодируется позиция токена внутри промпта. Я не углублялся в детали реализации — лишь настолько, чтобы понимать, что это почти не влияет на кэширование промптов, — но без этого LLM не смогла бы определить порядок токенов в тексте.

Обновим наш псевдокод, предполагая, что существует функция encodePosition. Она принимает embeddings и позицию и возвращает новые embeddings с закодированной позицией.

// Copy code const EMBEDDINGS = [...]; // Input: array of tokens (integers) function embed(tokens) { // Output: array of n-dimensional embedding arrays return tokens.map((token, i) => { const embeddings = EMBEDDINGS[token]; return encodePosition(embeddings, i); }); }

Итого: embeddings — это точки в n-мерном пространстве, которые можно рассматривать как семантический смысл представляемого текста. Во время обучения каждый токен перемещается в этом пространстве так, чтобы находиться рядом с другими, похожими токенами. Чем больше измерений, тем более сложным и тонким может быть представление каждого токена у LLM.

Всё, что мы сделали на стадиях токенизации и embedding, было нужно, чтобы превратить текст в форму, с которой LLM может работать. Теперь давайте посмотрим, как выглядит эта работа на стадии трансформера.

Трансформер

ed7466e74ecc7678921716b8e5fcb23c.png

Стадия трансформера — это про то, чтобы взять embeddings на вход и «переставить» их внутри n-мерного пространства. Делается это двумя способами, и мы сосредоточимся только на первом: attention. Про «Feedforward» и стадию вывода мы говорить не будем (в этой статье 👀).

Задача механизма attention — помочь LLM понять взаимосвязи между токенами в промпте, позволяя токенам влиять на положения друг друга в n-мерном пространстве. Для этого embeddings токенов промпта комбинируются взвешенным образом. На вход подаются embeddings всего промпта, а на выходе получается один новый embedding — взвешенная комбинация всех входных embeddings.

Например, если бы у нас был промпт «Mary had a little», и он превращался бы в 4 токена: Mary, had, a и little, механизм attention мог бы решить, что для генерации следующего токена нужно взять:

  • 63% embeddings слова Mary

  • 16% embeddings слова had

  • 12% embeddings слова a

  • 9% embeddings слова little

А затем он бы объединил их, масштабировав embeddings по весам и сложив. Так LLM «понимает», насколько ей стоит учитывать, то есть «внимательно смотреть» (attend) на каждый токен в промпте.

Это самая сложная и абстрактная часть процесса на данный момент. Сначала я покажу её в виде псевдокода, а затем посмотрим, как embeddings меняются при прохождении через неё. Я хотел сделать этот раздел менее «математичным», но тут трудно полностью избежать математики. Вы справитесь, я в вас верю.

Большинство вычислений в attention — это умножение матриц. Единственное, что вам нужно знать об умножении матриц для понимания этой статьи: размер (shape) выходной матрицы определяется размерами входных. У результата всегда столько же строк, сколько у первой матрицы, и столько же столбцов, сколько у второй.

1da669b9bcbd0a68ffe90d44b231e71e.png

Имея это в виду, вот как упрощённый механизм attention вычисляет веса, которые нужно назначить каждому токену. В коде ниже я использую * как обозначение умножения матриц.

// Copy code // Similar to EMBEDDINGS from the pseudocode // earlier, WQ and WK are learned during // training and do not change during inference. // // These are both n*n matrices, where n is the // number of embedding dimensions. In our example // above, n = 3. const WQ = [[...], [...], [...]]; const WK = [[...], [...], [...]]; // The input embeddings look like this: // [ // [-0.1, 0.1, -0.3], // Mary // [1.0, -0.5, -0.6], // had // [0.0, 0.8, 0.6], // a // [0.5, -0.7, 1.0] // little // ] function attentionWeights(embeddings) { const Q = embeddings * WQ; const K = embeddings * WK; const scores = Q * transpose(K); const masked = mask(scores); return softmax(masked); }

Давайте посмотрим, как embeddings «протекают» через эту функцию.

Чтобы получить Q и K, мы берём embeddings и умножаем их на WQ и WK соответственно. У WQ и WK число строк и столбцов всегда равно числу измерений embeddings — в нашем случае это 3. Я выбрал здесь случайные значения для WQ и WK, а числа округлил до двух знаков после запятой для удобства чтения.

1abca2a0de2ad43776ad4171e6d2be0d.png

Итоговая матрица Q имеет 4 строки и 3 столбца. 4 строки — потому что у матрицы embeddings было 4 строки (по одной на токен), а 3 столбца — потому что у WQ было 3 столбца (по одному на измерение embeddings).

Вычисление K полностью такое же, только вместо WQ используется WK.

55e851dd61849d144ce61eea575ebadb.png

Q и K — это «проекции» входных embeddings в новые n-мерные пространства. Это не исходные embeddings, но они вычислены на их основе.

Затем мы берём Q и K и перемножаем их. Мы берём транспонированную K — то есть «переворачиваем» её по диагонали — чтобы итоговая матрица стала квадратной: число строк и столбцов в ней равно числу токенов входного промпта.

1485eb888f672c8ad52927902d91ebec.png

Эти scores — представление того, насколько важен каждый токен для генерации следующего токена. Число в левом верхнем углу, -0.08, — это «насколько важна Mary для had». Затем строкой ниже, -0.10, — это «насколько важна Mary для a». Я покажу это визуально после матричной части. Всё, что будет дальше, — это превращение scores в веса, которые можно использовать для смешивания embeddings.

Первая проблема этой матрицы scores в том, что она позволяет будущим токенам влиять на прошлое. В верхней строке мы знаем только «Mary», значит, именно она и должна быть единственным словом, которое влияет на генерацию «had». То же самое во второй строке: мы знаем «Mary» и «had», значит только они двое должны влиять на генерацию «a», и так далее.

Чтобы исправить это, мы применяем треугольную маску к матрице, чтобы «обнулить» будущие токены. Но вместо нулей мы ставим минус бесконечность. Почему — объясню чуть позже.

562fbc398f6e3c7affc882c4142bda4e.png

Вторая проблема в том, что эти scores — произвольные числа. Нам было бы намного удобнее, если бы это было распределение, которое по каждой строке суммируется в 1. Именно это делает функция softmax. Детали работы softmax тут не важны: она немного сложнее, чем «разделить каждое число на сумму строки», но результат тот же — сумма по каждой строке равна 1, и каждое число находится между 0 и 1.

8fbf09e36d5d7f0ef1c1a6f189fa9366.png

Чтобы пояснить, зачем здесь минус бесконечности, вот реализация softmax в коде:

// Copy code function softmax(matrix) { return matrix.map(row => { const exps = row.map(x => Math.exp(x)); const sumExps = exps.reduce((a, b) => a + b, 0); return exps.map(exp => exp / sumExps); }); }

Это не совсем «сложить числа и поделить каждое на сумму». Сначала берётся Math.exp от каждого числа, то есть вычисляется e^x. Если бы вместо -∞ мы подставляли нули, то Math.exp(0) === 1, и эти нули всё равно давали бы вес. А Math.exp(-Infinity) равно 0 — именно то, что нам нужно.

Сетка ниже показывает пример attention-весов для промпта «Mary had a little». Эти веса не совпадают с вычислениями выше, потому что я взял их из версии GPT-2, которая работает на отличном сайте Transformer Explained. То есть это реальные веса реальной, хоть и старой, модели.

b5ba2b23fdbe2a5098a7091b817a577d.gif

В первой строке у нас есть только «Mary», поэтому Mary даёт 100% вклада в «had». Во второй строке Mary даёт 79%, а «had» — 21% при генерации «a», и так далее. Вероятно, неудивительно, что слово, которое LLM считает самым важным в этом предложении, — это «Mary»: у неё максимальный вес в каждой строке. Если бы я попросил вас продолжить фразу «Jessica had a little», вряд ли вы выбрали бы «lamb».

Осталось только смешать embeddings токенов — к счастью, это заметно проще, чем вычислять веса.

// Copy code // Learned during training, doesn't change // during inference. This is also an n*n matrix, // where n is the number of embedding dimensions. const WV = [[...], [...], ...]; function attention(embeddings) { const V = embeddings * WV; // This is the `attentionWeights` function from // the section above. We're wrapping it in // this `attention` function. const weights = attentionWeights(embeddings); return weights * V; }

Как и раньше, у нас есть матрица WV, заданная на этапе обучения. С её помощью мы получаем матрицу V из embeddings токенов.

44cc3ced8fbe66b54698b7eed61240d2.png

Дальше мы перемножаем V на веса, которые вычислили, и на выходе получаем новый набор embeddings:

9a478ba2483c211ca5bba68230aaed00.png

Итоговый результат механизма attention — последняя строка этой матрицы output. Вся контекстная информация из предыдущих токенов «вмешалась» в эту последнюю строку через attention, но чтобы получить её, пришлось вычислить и все предыдущие строки.

В итоге: embeddings заходят на вход, а на выходе получается новый embedding. Механизм attention проделал много тонкой математики, чтобы смешать токены в пропорции их важности, опираясь на матрицы WQ, WK и WV, которые он выучил во время обучения. Именно этот механизм позволяет LLM понимать, что важно в её контекстном окне, и почему.

И вот теперь мы наконец знаем всё, что нужно, чтобы говорить о кэшировании.

Кэширование промптов

Давайте снова посмотрим на сетку выше — но теперь увидим, как она заполняется по мере генерации каждого нового токена в цикле inference.

cfcefff9d4038c04bb3d140062d62b88.gif

Каждый новый токен добавляется ко входу и весь ввод заново перерабатывается целиком. Но приглядитесь: ни один из предыдущих весов не меняется. Вторая строка всегда 0.79 и 0.21. Третья строка всегда 0.81, 0.13, 0.06. Мы повторяем массу вычислений, которые повторять не нужно. Большинство умножений матриц для «Mary had a little» не требуется, если вы только что закончили обрабатывать «Mary had a» — а именно так и устроен цикл inference в LLM.

Избежать этих повторных вычислений можно, если сделать два изменения в цикле inference:

  1. Кэшировать матрицы K и V на каждой итерации.

  2. Передавать в модель только самый новый токен, а не весь промпт целиком.

Пройдёмся по матричным умножениям ещё раз, но теперь у нас закэшированы K и V для первых 4 токенов, а на вход мы подаём embeddings только одного токена. Да, это снова математика, простите, но в основном всё то же самое, что выше, и мы пройдёмся быстро.

Вычисление нового Q даёт на выходе всего одну строку. WQ не изменился по сравнению с прошлым разом.

e9bb4c8794f01e8b9f8feffbc4abbb32.png

Затем вычисление нового K тоже даёт на выходе одну строку, и WK также тот же.

6fe85a75e08573c718e1705599e62bab.png

Но дальше мы берём эту новую строку и дописываем её к 4 строкам K, которые были закэшированы с предыдущей итерации:

91e76c93f58a443f6f8f1ee3d30fe102.png

Итак, теперь у нас есть матрица K для всех токенов в промпте, но мы посчитали только её последнюю строку.

Дальше в том же духе получаем новые scores:

d4ab44b13d40de647185246f2df0cdd7.png

И новые веса:

b11b5d3b419f72fc85c7f45dafcaab95.png

Всё это время мы считаем только то, что действительно нужно. Старые значения вообще не пересчитываются. Далее получаем новую строку V:

affe1e74c36391d746d9e5f10355a255.png

И дописываем её к V, который был в кэше:

266454397e56e1f2868ddda64425ead6.png

И наконец перемножаем новые веса с новым V, получая итоговые новые embeddings:

02e3fd048c8a37f826aabf0b10e9c7ed.png

Эта единственная новая строка embeddings — всё, что нам было нужно. Вся контекстная информация из предыдущих токенов уже «запечена» в неё благодаря закэшированным K и V.

Данные, которые кэшируются, — это результат embeddings WK и embeddings WV, то есть K и V. Поэтому кэширование промптов обычно называют «KV-кэшированием» (KV caching).

06f98dccf6f34ca086278098df3dcb3f.png

Вот и всё: эти матрицы K и V выше — и есть те самые нули и единицы, которые провайдеры сохраняют в своих гигантских дата-центрах, чтобы давать нам токены в 10 раз дешевле и заметно ускорять ответы.

Провайдеры держат эти матрицы для каждого промпта 5–10 минут после запроса. Если вы отправляете новый запрос, который начинается с того же промпта, они переиспользуют закэшированные K и V вместо того, чтобы пересчитывать их заново. Самое классное — можно частично совпасть с записью в кэше и всё равно использовать совпавшую часть, а не только «полное совпадение целиком».

Визуализация ниже по кругу перебирает несколько промптов с похожими префиксами, чтобы показать, как могут использоваться записи кэша. Время от времени кэш очищается, чтобы было видно, как он снова заполняется.

5a24e02221bfaad39b877a137493448e.gif

OpenAI и Anthropic делают кэширование очень по-разному. OpenAI делает всё автоматически, пытаясь по возможности направлять запросы на закэшированные записи. В моих экспериментах, если отправить запрос и сразу же отправить его повторно, мне удавалось получить hit rate около 50%. Учитывая, насколько большим может быть время до первого байта при длинных контекстных окнах, это может приводить к непредсказуемой производительности.

Anthropic дают больше контроля: вы сами решаете, когда кэшировать и на какой срок. За эту возможность вы платите, но в моих экспериментах Anthropic направляли на закэшированные записи в 100% случаев, когда я явно просил кэшировать промпт. Это может сделать их более подходящим выбором для приложений, где вы работаете с длинными контекстными окнами и вам нужна предсказуемая задержка.

Русскоязычное сообщество про AI в разработке

d066a81482f4fe77b245ab293d3beffc.png

Друзья! Эту статью подготовила команда ТГК «AI for Devs» — канала, где мы рассказываем про AI-ассистентов, плагины для IDE, делимся практическими кейсами и свежими новостями из мира ИИ. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Источник

Возможности рынка
Логотип Prompt
Prompt Курс (PROMPT)
$0,05065
$0,05065$0,05065
+%1,40
USD
График цены Prompt (PROMPT) в реальном времени
Отказ от ответственности: Статьи, размещенные на этом веб-сайте, взяты из общедоступных источников и предоставляются исключительно в информационных целях. Они не обязательно отражают точку зрения MEXC. Все права принадлежат первоисточникам. Если вы считаете, что какой-либо контент нарушает права третьих лиц, пожалуйста, обратитесь по адресу [email protected] для его удаления. MEXC не дает никаких гарантий в отношении точности, полноты или своевременности контента и не несет ответственности за любые действия, предпринятые на основе предоставленной информации. Контент не является финансовой, юридической или иной профессиональной консультацией и не должен рассматриваться как рекомендация или одобрение со стороны MEXC.