🎁 新用戶 免費註冊,送 10 次呼叫,最高 $1,免綁卡。
LLM Prompt 快取 #5:真正命中的 LangChain 設定

LLM Prompt 快取 #5:真正命中的 LangChain 設定

目錄
  1. 先搞清楚:你要找的是哪一種「快取」?
  2. 修正方式:用 content block,而不是字串
  3. 樣板變數放在哪,決定了你的命中率
  4. 工具定義也會被快取
  5. 多輪對話:把 marker 移到最後一則訊息
  6. 讀懂計量欄位,也要記住它們的名字
  7. 隱式快取:順序放錯會無聲失敗,這裡最該盯緊
  8. 檢查清單
  9. 免責聲明
  10. 來源

以下這段 LangChain system prompt 看起來完全合理,但快取命中率是零:

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", BIG_STABLE_SYSTEM_PROMPT),   # the syntax every tutorial uses
    ("human", "{question}"),
])

我們拿一段固定的 1,800-token system prompt,對 claude-sonnet-5 連跑兩次,再把 usage 欄位讀回來。兩次呼叫的結果都一樣:cache write 0、cache read 0。不是部分命中,也不是被拆散的快取,就是完全沒有。原因在於 Anthropic 只會快取你用 cache_control 標記的部分,而 ("system", ...) tuple 裡的純字串根本沒地方放這個標記。LangChain 裡最方便的語法,剛好也是把整筆折扣拱手讓出的那個,而且沒有任何錯誤訊息會提醒你。

TL;DR

  • LangChain 的 ("system", "string") 元組無法攜帶 cache_control,所以 Claude 完全不會快取:一段相同的 1,800 個權杖系統提示在 claude-sonnet-5 上實測為 0 次快取寫入與 0 次讀取。
  • 修正方式是使用帶有內容區塊 cache_controlSystemMessage;系統區塊上的一個標記也能涵蓋透過 bind_tools 綁定的工具。
  • langchain-anthropic 1.4.8 中,即使有實際寫入,input_token_details.cache_creation 仍維持為 0;真正的數量在 ephemeral_5m_input_tokens 中。
  • 順序錯誤的 RAG 提示(變動的內容放在穩定的規則之前)會在每次呼叫時付出快取寫入的額外費用,約 1.25 倍:比完全不使用快取還要昂貴。

這是快取系列的第 5 篇。第 1 篇講 prefix caching 的運作原理,第 3 篇是原生 SDK 的教學。這篇要談的是:當 LangChain 幫你組裝 prompt 時,會發生哪些變化。以下所有數字都是 2026-07-04 透過 Synthorai 閘道實測,版本為 langchain-core 1.4.8、langchain-anthropic 1.4.8、langchain-openai 1.3.3。

先搞清楚:你要找的是哪一種「快取」?

有兩個毫不相干的功能共用了「快取」這個詞,而你搜尋後點進去的那個 LangChain 文件頁面,通常不是你要的那個。

Response caching(LangChain 的 InMemoryCachePrompt caching(本系列)
存的是什麼整段 completion,存在你的 app 裡prompt 前綴的 KV 狀態,存在供應商那邊
何時省到錢完全相同的請求重複出現不同請求共用同一段前綴
存在哪set_llm_cache(InMemoryCache())、SQLite、Rediscache_control 標記,或自動前綴比對
Agent 迴圈、RAG、chat幾乎沒用(每個請求都不一樣)主要的施力點,因為 system + tools 每一輪都會重複

「完全相同的請求」就是字面上的完全相同:內建快取的 key 是(序列化後的 prompt、model-config 字串)這組配對。實測結果:完全相同的重複請求在 0 ms 內回傳,沒有發出 API 呼叫;在 prompt 裡多加一個空格就 miss;同一段 prompt 只把 max_tokens 改動 1 也會 miss。(快取重播還會回傳原始呼叫的 usage 數字,所以天真地做 token 計算會重複計數。)語意快取有第三方整合可用;內建的只有完全比對這一種。

所以 set_llm_cache 拿來在測試裡去除重複的相同呼叫沒問題;但你每一輪 agent 都要重送的那段 2,000-token system prompt,是 prompt caching 的工作,而且需要用正確的方式組裝 prompt 才行。

修正方式:用 content block,而不是字串

cache_control 是掛在 content block 裡的,所以 system message 必須是帶有 block 內容的 SystemMessage,不能只給一個純字串:

from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langchain_core.prompts import ChatPromptTemplate

llm = ChatAnthropic(
    model="claude-sonnet-5",
    base_url="https://synthorai.io",   # any Anthropic-compatible endpoint
)

prompt = ChatPromptTemplate.from_messages([
    SystemMessage(content=[{
        "type": "text",
        "text": BIG_STABLE_SYSTEM_PROMPT,
        "cache_control": {"type": "ephemeral"},   # a bare string has nowhere to put this
    }]),
    ("human", "{question}"),
])
chain = prompt | llm

同一份 1,800 token 的 system prompt,透過同一個閘道實測:

呼叫字串 tuple 寫法content block 寫法
第 1 次(冷啟動)write 0 / read 0write 1,875 / read 0
第 2 次,換一個問題write 0 / read 0write 0 / read 1,875

熱讀取(warm read)的計費大約是 input 價格的 10%,所以在 Claude 上,這個結構調整決定了你是永遠付全價,還是在每次呼叫的固定部分拿到九折優惠。成本細節在 part 1;marker 的運作方式與原生 SDK 的用法一致,可參考 LangChain Anthropic 整合文件Anthropic 的 prompt caching 指南

樣板變數放在哪,決定了你的命中率

LangChain 樣板讓你可以在任何位置輕鬆插入變數,而這正是風險所在。快取的 key 是完全一致的位元組前綴。我們把一個日期放進被快取的 block 裡,實測結果如下:

SystemMessage(content=[{
    "type": "text",
    "text": f"Today is {today}. " + BIG_STABLE_SYSTEM_PROMPT,   # variable INSIDE the block
    "cache_control": {"type": "ephemeral"},
}])
呼叫結果
第 A 天,問題 1write 1,865(這個值冷啟動)
第 A 天,問題 2read 1,865(同一個值,命中)
第 B 天,問題 1write 1,865(新的值,再次冷啟動)

快取沒有壞,它是被這個變數綁定了 key。像日期這種會重複的值,每個值付一次 cache write,之後就會命中。像 timestamp 或 request ID 這種每次呼叫都不同的值,會讓每次呼叫都變成冷寫入,命中率剛好是零。

這個錯誤在真實世界最花錢的版本是 RAG。很多 chain 慣用的樣板會把檢索到的 context 放在 system prompt 最前面,擺在靜態指令之前。我們用一份每次查詢都會變、800 token 的檢索 context,搭配一個標記過的 system block,實測了兩種順序:

prompt 內的順序第 1 次呼叫第 2 次呼叫(新查詢、新 context)
context 在前,規則在後write 3,133又是 write 3,133,read 0
規則在前(標記),context 放進 human turnwrite 1,852read 1,852

錯誤那一列不只是「沒有折扣」而已:每次呼叫都要在完整的 3,133 個 token 上付 cache write 溢價,大約是正常 input 價格的 1.25 倍,而且完全不會被讀回來。**開了快取卻把 RAG prompt 順序搞錯,比根本不快取還要貴。**固定內容排在變動內容後面,等於形同不存在。

從這些實測結果得出的規則:

  • 靜態文字放最前面,放進標記過的 block 裡。 system 規則、tool 定義、few-shot 範例。
  • 任何會變動的東西放在 marker 後面,最好放進 human turn:檢索到的 context、日期、使用者問題。
  • 只有當某個變數重複的頻率高到足以攤平自己的 cache write,才可以把它放進 block 裡。

工具定義也會被快取

Agent 每次呼叫時都會重送它的 tool schema,而在 Anthropic 的請求結構裡,tools 排在 system prompt 的前面。既然一個 marker 代表「快取從請求開頭到這裡為止的所有內容」,就衍生出兩個實際問題:放在 system 區塊上的 marker,會不會連帶把它前面的 tools 也涵蓋進去?還有 LangChain 的 bind_tools 是否每次呼叫都會把你的 tools 序列化成一模一樣的位元組?因為只要序列化稍有變動,prefix 就變了,每次呼叫都會 cache miss。

兩個問題都有實測答案。在同樣加上 marker 的 system prompt 下,暖快取的讀取量在沒有 tools 時是 1,861 個 token,綁定兩個 tool 後是 2,389 個 token:多出來的 528 個 token 就是從快取讀回來的 tool schema。而且這個 2,389 在連續三次呼叫中完全一致,代表 bind_tools 每次都用相同方式序列化,框架不會把雜訊摻進 prefix 裡。講明白一點:只要 system 區塊帶著 marker,tools 本身就不需要 cache_control,後面那一個 marker 就把整件事處理掉了。

反過來的做法只適用於一種特定情況:tools 是你送出的內容裡最大且最穩定的部分,而 system prompt 很薄或根本沒有。這時請求仍然需要在某處放一個 marker,而它可以掛在某個 tool 上。這只在原始的 Anthropic 格式 dict 上才行得通,因為用 @tool 裝飾的函式沒有欄位可以承載它;bind_tools 會把 dict 原封不動地傳過去:

# variant: NO marked system block anywhere; the tool carries the request's only marker
llm.bind_tools([{
    "name": "get_weather",
    "description": LONG_TOOL_DESCRIPTION,
    "input_schema": {...},
    "cache_control": {"type": "ephemeral"},   # passes through bind_tools verbatim
}])

實測:冷快取寫入 3,002,暖快取讀取 3,002,且請求中沒有任何加上 marker 的 system message。

多輪對話:把 marker 移到最後一則訊息

對話看起來像是另一個排序問題,其實正好相反:順序本來就是完美的,因為歷史只會不斷往後追加,所以整份對話紀錄就是穩定的 prefix。這裡的問題是涵蓋範圍。放在 system 區塊上的 marker 只會快取 system 區塊,之後的都不快取:隨著歷史增長,暖快取的讀取量會一直停在 system 的大小,而每一輪累積下來的內容都以一般 input 計費。

解決這個問題的模式,跟原始 SDK 用的一樣:把 marker 放在最新的那則訊息上,讓斷點往前滑動,於是目前為止的整段對話都成為被快取的 prefix:

def marked(text):
    return HumanMessage(content=[{
        "type": "text", "text": text,
        "cache_control": {"type": "ephemeral"},
    }])

# each turn: history stays plain, only the newest human message carries the marker
llm.invoke([system, *history, marked(new_question)])

跨兩輪實測:第 1 輪寫入 1,864;第 2 輪讀取 1,864,只寫入 15 個 token 的差量(上一則回答加上新問題),先前的 prefix 以約 10% 的讀取費率計費。這正是 agent 迴圈想要的形態,而 LangChain 用一個普通的 message list 就能表達出來。Anthropic 每個請求最多允許四個 marker,所以這個滑動的 marker 可以和固定掛在 system 區塊或 tools 上的 marker 搭配使用。

讀懂計量欄位,也要記住它們的名字

LangChain 把用量統一整理到 usage_metadata 裡,但這裡有個坑我們踩到了:在 langchain-anthropic 1.4.8 上,我們每一輪的每個回應中,標準的 input_token_details.cache_creation 欄位即使發生了快取寫入也一直是 0。真正的寫入數量落在一個非標準的 key 上:

r = chain.invoke({"question": "..."})
det = r.usage_metadata["input_token_details"]
det["cache_read"]                  # correct on hits (1875 above)
det["cache_creation"]              # 0 even on a cold write; do not alert on this
det["ephemeral_5m_input_tokens"]   # the actual write count (1875)

供應商本身回報的寫入是正確的(原始回應裡是 cache_creation_input_tokens: 1875,可以透過 r.response_metadata["usage"] 看到);只是這個標準化的對應把它歸到了 TTL 桶的 key 底下。一個盯著 cache_creation 的成本儀表板會告訴你快取是免費的,而寫入的溢價卻在悄悄累積。要嘛相信原始的 usage 物件,要嘛就得清楚這些桶的 key。這跟閘道錯報快取欄位是同一類問題,我們在 Does Your LLM Gateway Lie About Cache? 裡有做稽核。

隱式快取:順序放錯會無聲失敗,這裡最該盯緊

Claude 的快取是顯式的。GPT 和大多數 open-weight 供應商則是在前綴命中時自動快取,不需要任何標記,透過 LangChain 只要改一行 constructor,同一條 chain 就能用:

llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")

純字串的 system prompt,沒有任何標記:GLM 5.2 第二次呼叫讀到了約 1,850 個 token 前綴中的 1,088 個 token。(不是全部:自動快取是以粗略的區塊為單位命中,而不是逐 byte 對到結尾;例如 OpenAI 文件寫的粒度是 128 個 token。)到這裡都還是白賺的。但前面 RAG 表格提到的順序放錯風險,在這裡照樣成立,而且失敗模式更難察覺。我們在自動快取這條路上重跑了同樣的順序實驗,每次呼叫都帶入新的檢索 context:

順序(無標記,自動快取)第 1 次呼叫第 2 次呼叫(新 query、新 context)
context 在前,rules 在後read 0read 0
rules 在前,context 放在 human turnread 0read 1,088

放錯順序就是無條件的零命中:會變的 context 放在最前面,任兩次呼叫都不會共用前綴,折扣永遠等不到。在顯式那條路上,同樣的錯誤至少會以每次呼叫的快取寫入溢價出現在帳單上;但在隱式這條路上,沒有溢價、沒有錯誤、也沒有任何訊號。prompt 就是悄悄地永遠不符合條件,而你還以為「自動」就等於「有在運作」。而且既然沒有標記可放,prompt 的順序就是隱式路徑唯一給你的旋鈕。

所以要從計量欄位去驗證,而且要在 production 裡持續驗證,別只在測試時驗一次:input_token_details.cache_read(LangChain)或 prompt_tokens_details.cached_tokens(原始)。OpenAI 的自動快取另外還規定前綴至少要 1,024 個 token,各供應商的 TTL 與適用條件也不一樣,那是第 2 篇的範圍。

檢查清單

  • 在 Claude 上,("system", "...") 這種字串 tuple 無處可放 cache_control:什麼都不會被快取,也不會有任何警告。要快取的 system prompt 必須放進帶有 content block 和標記的 SystemMessage
  • 快取鍵是逐位元組完全一致的前綴:靜態內容在前,變數放在標記之後或人類回合裡。把 RAG context 放在規則之前不只是無法命中,而是每次呼叫都要付寫入的額外費用。
  • 快取區塊內的變數,每個值都會建立一筆快取項目:重複的值可以攤提成本,但每次呼叫都不同的值(時間戳、request ID)永遠不會命中。
  • 工具在前綴裡排在 system prompt 之前,所以 system 標記也會一併快取綁定的工具(bind_tools 的序列化是確定性的)。如果工具是你最大的穩定區塊,也可以把標記放在 Anthropic 格式的 tool dict 上。
  • 在對話中,如果標記固定在 system 區塊上,不斷成長的歷史紀錄還是要付全額;把標記放在最新一則訊息上,這樣每個回合都能讀取先前的前綴,只寫入增量部分。
  • 不要監控 input_token_details.cache_creation:即使發生寫入它也維持在 0,於是儀表板會誤判快取是免費的,實際上寫入費用一直在累積。真正的數字在 ephemeral_5m_input_tokens,或者直接讀原始的 response_metadata["usage"]
  • 在自動快取的模型上(GPT、GLM、DeepSeek),prompt 順序是唯一能調的旋鈕,而順序放錯是無聲的失敗:沒有額外費用,也沒有錯誤,只是折扣永遠不會出現。要從 usage 欄位確認是否命中。
  • set_llm_cache 會用完整的 prompt 和模型設定當鍵,存下整份回應;只有在完全相同的請求重複時才有回報,用在 agent 迴圈上永遠划不來。

這些習慣都很小:用 content block 取代字串、靜態在變數之前、讓標記隨對話移動、正確讀取一個 usage 欄位。實測差異是每個穩定 token 拿到 90% 折扣,對比什麼都沒有;而在 RAG 順序放錯的情況下,對比的是還要多付錢。LangChain 不會妨礙 prompt caching,只是它讓你寫出錯誤的 prompt 形狀,跟寫出正確的一樣容易。


免責聲明

測試於 2026-07-04 進行,對象是 https://synthorai.io/,使用 langchain-core 1.4.8、langchain-anthropic 1.4.8、langchain-openai 1.3.3,模型為 claude-sonnet-5glm-5.2,一段約 1,800 token 的英文 system 前綴,樣本數不多,並在連續呼叫之間留了 1–2 秒的間隔,讓快取寫入有時間落地。每個實驗都用一段全新隨機化的前綴,以確保快取是冷的,這也是為什麼各表之間的基準 token 數會略有差異(1,852 到 1,875)。函式庫的欄位對應和供應商的快取行為會隨版本改變;依賴這些數字之前,請先針對你自己的技術棧重新測量。

來源

← 返回部落格