KDnuggets ★ 87 4 min

Reusing the Prompt Prefix with a Key-Value Cache for SLM Optimization

🔗 https://www.kdnuggets.com/reusing-the-prompt-prefix-with-a-key-value-cache-for-slm-optimization

📌 87%的prompt都是重複的,為何還要每次重新計算?

TL;DR:用 KV cache 重複利用靜態 prompt 前綴,可省去 SLM 分類任務中重複的 pre-fill 運算。

如果你的 prompt 裡有 87% 的 token 每次呼叫都長得一模一樣,那這些運算其實不該被重做一遍。這正是 KDnuggets「SLM 最佳化技巧系列」第二篇文章要解決的問題。

🤔 窄領域自動化的 prompt,其實大多是靜態的

作者 Matthew Mayo 延續前一篇「限制輸出空間」的系列,這次聚焦在客服工單分類的場景:一段任務指令、分類定義與幾個範例構成 prompt 的大部分內容,每筆工單只新增二、三十個 token。若指令區塊有數百 token,而每筆資料只帶來少量變動,那麼絕大多數的 prompt 其實是逐位元組相同的重複內容,逐次重新編碼並不划算。

🧩 原理:固定前綴的 key/value 向量不會變

Transformer 在每一層都會為每個 token 計算 key 與 value 向量,而這些向量只取決於左側(即之前)的 token。這意味著,只要前綴固定,這些 key/value 向量在每次呼叫時都完全相同。只要事先計算一次並保留下來,之後每筆資料的 pre-fill 就只需處理真正變動的那一小段 token。

文章以 Qwen2.5-0.5B-Instruct(float16,透過 Hugging Face Transformers,在配備 24GB RAM、16 核心 Neural Engine 的 M2 MacBook Air 上)作為實測環境,延續前一篇文章的「限制式評分」技巧:由於 prompt 在 assistant turn 開頭就結束,只要各分類標籤的第一個 token 彼此不同,比較各標籤第一個 token 的 logits 即可決定分類結果,因此只需一次前向傳播。

📊 基準測試:87%的prompt是靜態前綴

作者手寫 ChatML 格式的 prompt(而非透過 apply_chat_template()),以便在已知的 token 邊界處切分前綴與後綴,並用 assertion 驗證切分後編碼結果與整段編碼完全一致,確保切分是「token-clean」的。測試資料為 600 筆工單(三種範例文字各重複 200 次),系統前綴長度為 145 tokens,完整 prompt 長度為 167 tokens,代表整個 prompt 有 87% 是靜態內容。在「每次都重新編碼完整 prompt」的基準做法下,600 筆工單總耗時 184.85 秒,平均每筆 308.1 毫秒。

💡 導入 DynamicCache:只算一次前綴,重複使用

接下來的程式碼引入 Hugging Face Transformers 的 DynamicCache,將前綴只跑過模型一次、保留下產生的 cache,之後每筆資料只需餵入自己專屬的 token 片段,沿用同一個 model、tokenizer 與 prompt 切分邏輯。這個做法直接對應前面觀察到的問題:87% 的運算原本是重複的,透過快取前綴的 key/value,理論上可以把這部分的計算成本從每筆請求中拿掉,只保留變動的十幾、二十個 token 需要即時編碼。

⚠️ 留意 token 邊界的正確性

值得注意的是,這個技巧的前提是「前綴/後綴切分必須是 token-clean」——也就是分開編碼前綴與後綴,結果必須與整段一起編碼完全一致,否則快取下來的 key/value 會與模型原本會看到的內容對不上,文章中特別用 assertion 檢查了這一點,這是實作時容易忽略但很關鍵的細節。

🎯 實務啟示

如果你的應用場景是窄領域、高重複度的 prompt(例如分類、標籤、路由任務),且前綴佔比高,這類 KV cache 前綴重用的技巧值得優先評估——尤其搭配前一篇文章「限制輸出空間」的單次前向傳播評分法,兩者疊加後理論上能同時省下 decode 與 pre-fill 兩端的運算量。實作時務必先驗證前綴/後綴切分的 token 一致性,避免快取出來的結果與實際 prompt 不符。

🔗 來源

#SLM #KVCache #LLMOptimization #Transformers #HuggingFace #Qwen #Inference #MachineLearning #PromptEngineering #ModelServing

原始資料 KDnuggets · 收集於 2026-09-18
來源原標題
Reusing the Prompt Prefix with a Key-Value Cache for SLM Optimization
作者
Matthew Mayo
原始連結
https://www.kdnuggets.com/reusing-the-prompt-prefix-with-a-key-value-cache-for-slm-optimization

摘要原文

Reusing the Prompt Prefix with a Key-Value Cache for SLM Optimization In this second article in our short series on SLM optimization techniques we focus on the reuse of the prompt prefix with a key-value cache. By Matthew Mayo , KDnuggets Managing Editor on September 18, 2026 in Language Models In a previous article we discussed constraining output space for small language model (SLM) narrow automation optimization. We mentioned at the time that this was the first in a short series of SLM optimization strategy articles. We are now at the second of those. This time we will focus on the reuse of the prompt prefix with a key-value cache. Let's not waste any further time on the niceties and get right to it instead. As in our previous article, all benchmarks below use Qwen2.5-0.5B-Instruct in float16 through Hugging Face Transformers , running on an M2 Macbook Air with 24GB RAM and a 16-core Neural Engine. First, make sure you set up a Python environment and install your requirements: pip install torch transformers accelerate We will continue to use the support ticket framing from our first article . Why Reuse the Prompt Prefix with a Key-Value Cache Narrow automation prompts are relatively static. A task instruction, a taxonomy definition, and a few examples make up the bulk of the tokens, and only a short prompt tail changes from item to item. If your instruction block runs to a couple of hundred tokens and each ticket adds twenty or thirty, then the overwhelming majority of every prompt is byte-for-byte identical to the last one. It's clearly wasteful to be recomputing all of it, for every layer, on every call. Transformers compute a key and a value vector for each token at each layer, and these depend only on the tokens to the left. This means that for a fixed prefix, they are identical on every call. Computing them once and holding onto them shrinks the per-item pre-fill down to just the tokens that actually changed. Re-encoding Every Ticket First, the baseline: a realistic few-shot prompt, re-encoded in full for every ticket. The constrained scoring trick from our earlier article is carried forward here, so the decision is a single forward pass and the only thing left to optimize is the pre-fill. Note that the chat template is written out by hand rather than going through apply_chat_template() , because the next script needs to split the prompt at a known boundary. import os import time import torch from transformers import AutoTokenizer, AutoModelForCausalLM MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" torch.set_num_threads(os.cpu_count() or 1) tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32) model.eval() # our toy data to classify (600 records) LABELS = ["billing", "technical", "account"] tickets = [ "My card was charged twice for the same invoice.", "The mobile app crashes whenever I open the settings page.", "I need to change the email address on my profile.", ] * 200 # constrained scoring, carried over from the previous article: the prompt ends at the # start of the assistant turn, so comparing the logits of each label's FIRST token # is enough to pick a winner, provided those first tokens are distinct label_first_ids = [tokenizer.encode(label, add_special_tokens=False)[0] for label in LABELS] assert len(set(label_first_ids)) == len(LABELS), ( "Labels share a first token; score full label sequences instead." ) label_first_ids = torch.tensor(label_first_ids, device=model.device) SYSTEM_PROMPT = """You classify customer support tickets into exactly one category. Categories: - billing: payments, invoices, refunds, charges, subscription costs - technical: crashes, errors, performance problems, broken features - account: profile changes, login access, permissions, account deletion Examples: Ticket: I was billed twice in March. Category: billing Ticket: The dashboard never finishes loading. Category: technical Ticket: Please remove my old phone number from my profile. Category: account Ticket: My promo code was rejected at checkout. Category: billing Ticket: Exporting to CSV throws a 500 error. Category: technical Ticket: I cannot reset my password. Category: account """ # the chat template is written out by hand so the prompt can be split at a known # token boundary; this is the exact ChatML layout Qwen2.5-Instruct expects prefix_text = f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n" def suffix_text(ticket): return ( f"<|im_start|>user\nTicket: {ticket}\nCategory:<|im_end|>\n" f"<|im_start|>assistant\n" ) def encode(text): return tokenizer(text, add_special_tokens=False)["input_ids"] # the split has to be token-clean: encoding the two halves separately must give # exactly the same ids as encoding the whole prompt in one go, or the cached keys # in the next script will not line up with what the model would otherwise have seen _probe = suffix_text(tickets[0]) assert encode(prefix_text) + encode(_probe) == encode(prefix_text + _probe), ( "Prefix/suffix split is not token-clean; move the boundary." ) prefix_len = len(encode(prefix_text)) full_len = prefix_len + len(encode(_probe)) print(f"Static prefix length: {prefix_len} tokens") print(f"Full prompt length: {full_len} tokens ({100 * prefix_len / full_len:.0f}% of it static)") # time inference baseline_predictions = [] start = time.time() for n, ticket in enumerate(tickets, start=1): # this loop runs for minutes on CPU, so report progress rather than sitting silent if n % 100 == 0: print(f" {n}/{len(tickets)} tickets ({(time.time() - start) / n:.2f}s each)", flush=True) full = tokenizer( prefix_text + suffix_text(ticket), add_special_tokens=False, return_tensors="pt" ).to(model.device) with torch.no_grad(): logits = model(**full).logits[0, -1, :] baseline_predictions.append(LABELS[int(logits[label_first_ids].argmax())]) duration_full = time.time() - start # output task metrics print(f"Recomputing the full prompt every time: {duration_full:.2f} seconds") print(f" ({1000 * duration_full / len(tickets):.1f} ms per ticket)") Output: Loading weights: 100%|████████████████████████████████████████████████████████████████████| 290/290 [00:01<00:00, 156.40it/s] Static prefix length: 145 tokens Full prompt length: 167 tokens (87% of it static) 100/600 tickets (0.31s each) 200/600 tickets (0.30s each) 300/600 tickets (0.30s each) 400/600 tickets (0.31s each) 500/600 tickets (0.31s each) 600/600 tickets (0.31s each) Recomputing the full prompt every time: 184.85 seconds (308.1 ms per ticket) Reusing the Prompt Prefix We now run the prefix through the model exactly once, keep the resulting cache, and feed each item only its own tokens. This continues in the same script, so the model, the tokenizer, the prompt halves, and the baseline timing are all still in scope. import os import time import torch from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" torch.set_num_threads(os.cpu_count() or 1) tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32) model.eval() # our toy data to classify (600 records) LABELS = ["billing", "technical", "account"] tickets = [ "My card was charged twice for the same invoice.", "The mobile app crashes whenever I open the settings page.", "I need to change the email address on my profile.", ] * 200 # constrained scoring, carried over from the previous article: the prompt ends at the # start of the assistant turn, so comparing the logits of each label's FIRST token # is enough to pick a winner, provided those first tokens are distinct label_first_ids = [tokenizer.encode(label, add_special_tokens=False)[0] for label in LABELS] assert len(set(label_first_ids)) == len(LABELS), ( "Labels share a first token; score full label sequences instead." ) label_first_ids = torch.tensor(label_first_ids, device=model.

由 claude-code/sonnet 自動生成