模組 01 · 第 4 課

上下文視窗和成本

把整份 httpx 文件塞進一次請求,看它花多少錢、找不找得到藏在中間的一句話,再看快取怎麼讓第二次呼叫便宜 30 多倍,最後寫一個按 usage 計費的函式。

  • 約 40 分鐘
  • 難度:入門
  • 實測:2026-09-14 deepseek-flash

程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。

現在的模型上下文視窗動輒幾十萬、上百萬詞元。很多人的第一反應是:那還做什麼 RAG,把所有資料一股腦塞進去不就行了?

有時候確實可以。但你得先知道這樣做每次要花多少錢、會慢多少、模型能不能在一大堆文字裡找到你要的那一句。這一課用 httpx 的全部文件做實驗,把這幾個問題都量一量。

上下文視窗是什麼

上下文視窗(context window)是模型一次能處理的詞元總數上限,輸入和輸出加在一起不能超過它。截至 2026 年 9 月,deepseek-flashdeepseek-v4-pro 的上下文視窗都是 100 萬詞元,單次輸出最多 38.4 萬詞元。

一次請求裡,下面這些東西都佔用上下文:

┌──────────────────────────────────────────────┐
│ system 消息:规则、人设                         │
│ 之前的对话记录(user 和 assistant 轮流)          │
│ 你塞进去的资料:文档、检索结果、工具返回的内容      │  输入
│ 这一轮用户的问题                                │
├──────────────────────────────────────────────┤
│ 模型的思考过程(如果开了思考模式)                  │  输出
│ 模型的正式回答                                  │
└──────────────────────────────────────────────┘

做簡單的問答時,這些加起來可能只有幾百個詞元。但做多輪對話、帶資料的問答、讓模型呼叫工具時,它們會迅速增長。上下文用完了,請求會直接報錯。

實驗:把整份文件塞進去

httpx 的官方文件一共 20 多個 Markdown 檔案,合起來 116,933 個字元,放在課程倉庫的 data/httpx-docs/ 目錄下。下面的指令碼做兩個實驗:先把全部文件放進 system 訊息,同一個問題連問兩次;再在文件裡藏一句話,看模型能不能找到。

import os
import pathlib

from openai import OpenAI

client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)
MODEL = os.environ.get("LLM_MODEL", "deepseek-flash")
NO_THINKING = {"thinking": {"type": "disabled"}}

docs = pathlib.Path("data/httpx-docs")
text = "\n\n".join(p.read_text() for p in sorted(docs.rglob("*.md")) if p.name != "LICENSE.md")
print(f"文档共 {len(text)} 个字符")

# 实验一:同样的请求连发两次
system = "你是 httpx 的答疑助手。下面是 httpx 的全部文档:\n\n" + text
for i in range(2):
    r = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": "httpx 默认的超时时间是多少秒?一句话回答。"},
        ],
        extra_body=NO_THINKING,
    )
    u = r.usage
    print(f"第 {i + 1} 次:输入 {u.prompt_tokens},其中缓存命中 {u.prompt_cache_hit_tokens},"
          f"输出 {u.completion_tokens} | {r.choices[0].message.content}")

# 实验二:把一句无关的话插到文档的不同位置
needle = "(备注:RepoBot 项目的内部口令是“蓝鲸四十二”。)"
paragraphs = text.split("\n\n")
for fraction in [0, 0.25, 0.5, 0.75, 1.0]:
    k = int(len(paragraphs) * fraction)
    haystack = "\n\n".join(paragraphs[:k] + [needle] + paragraphs[k:])
    r = client.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": haystack + "\n\n问题:上面的文字里提到的 RepoBot 内部口令是什么?只回答口令本身。"}],
        extra_body=NO_THINKING,
    )
    print(f"口令放在 {fraction:>4.0%} 处:输入 {r.usage.prompt_tokens} 词元 → {r.choices[0].message.content}")

在課程目錄下執行,我得到的結果(回答措辭你會不一樣,詞元數應該接近):

文档共 116933 个字符
第 1 次:输入 29397,其中缓存命中 0,输出 12 | httpx 默认超时是 5 秒。
第 2 次:输入 29397,其中缓存命中 29184,输出 19 | httpx 默认的超时时间是 5 秒(指网络不活动的超时)。
口令放在   0% 处:输入 29404 词元 → 蓝鲸四十二
口令放在  25% 处:输入 29404 词元 → 蓝鲸四十二
口令放在  50% 处:输入 29404 词元 → 蓝鲸四十二
口令放在  75% 处:输入 29404 词元 → 蓝鲸四十二
口令放在 100% 处:输入 29404 词元 → 蓝鲸四十二

先看實驗一。回答是對的,httpx 文件原文寫著 "The default behavior is to raise a TimeoutException after 5 seconds of network inactivity"。整份文件大約 2.9 萬個詞元,對 100 萬的視窗來說只佔 3%。同樣的問題連問兩次,第二次的 29,397 個輸入詞元裡,有 29,184 個命中了快取。下面會講它有多省錢。

它能找到藏在中間的一句話嗎

實驗二是一個簡單的"大海撈針"測試。有一篇常被引用的論文,Lost in the Middle(Liu 等,2023),發現當時的模型在長輸入裡找資訊時,放在開頭和結尾的資訊找得最好,放在中間的最容易被忽略。

我在文件裡插入一句和 httpx 毫無關係的話,分別放在全文的開頭、25%、50%、75% 和結尾處,結果五個位置全部答對。在三萬詞元這個長度、找一句話這種簡單任務上,deepseek-flash 沒有表現出"中間遺忘"。那篇論文測的是 2023 年的模型,這幾年長上下文能力進步很大。

但別就此認為長上下文沒有問題。"找一句明顯的話"是最簡單的長上下文任務。需要把散落在文件各處的幾條資訊綜合起來、需要在幾十個相似的段落裡分辨細微差別時,模型還是更容易出錯,RULER(Hsieh 等,2024)這類評測專門測這些更難的情況。你的任務屬於哪一種,最好用自己的資料測一測,練習 3 就是這樣一個測試。

算錢:為什麼不總是全塞進去

能找到,不代表划算。寫一個根據 usage 算錢的函式:

PRICES = {
    # 模型: (输入-缓存命中, 输入-缓存未命中, 输出),美元 / 每一百万词元,高峰价
    "deepseek-flash": (0.006, 0.30, 1.20),
    "deepseek-v4-pro": (0.044, 1.32, 3.96),
}


def cost_usd(usage, model, off_peak=False):
    hit_price, miss_price, out_price = PRICES[model]
    # DeepSeek 的 usage 里有 prompt_cache_hit_tokens,别家不一定有,没有就当全部未命中
    hit = getattr(usage, "prompt_cache_hit_tokens", 0) or 0
    miss = usage.prompt_tokens - hit
    total = (hit * hit_price + miss * miss_price + usage.completion_tokens * out_price) / 1_000_000
    return total / 2 if off_peak else total  # 低谷时段五折

價格是截至 2026 年 9 月的 DeepSeek 官方價格,用之前請到價格頁面核對。把實驗一的兩組數字代進去,再估算一下每天 1000 次、一個月的費用(完整程式碼在 code/01-llm-basics/cost.py):

第一次:0.008833 美元
第二次:0.000262 美元
每月 3 万次,全部命中缓存:7.85 美元;全部不命中:265.00 美元

第 00 模組第 3 課那次簡單問答花了 0.00019 美元,這次把文件全塞進去的第一次呼叫花了 0.0088 美元,貴了 46 倍,而使用者看到的回答只有一句話。按每天 1000 次、一個月算,沒有快取要 265 美元。

另外還有速度。輸入越長,模型讀完它的時間越長,使用者等待第一個字出現的時間也越長。三萬詞元的差別還不明顯,文件一旦到了幾十萬詞元,就能明顯感覺到了。

所以,全部塞進去適合這些情況:資料本身不大,呼叫次數不多,或者能穩定命中快取。資料很大、呼叫頻繁、每次只需要其中一小部分時,先把相關的部分找出來再交給模型更划算。這就是 04 模組 RAG 要做的事。

快取:讓重複的開頭幾乎免費

第二次呼叫便宜了 30 多倍(0.000262 對 0.008833),靠的是 DeepSeek 的上下文快取:如果這次請求的開頭部分,和之前某次請求的開頭一模一樣,伺服器就可以複用之前算好的結果,這部分按"快取命中"的價格收費。deepseek-flash 快取命中的價格是未命中的五十分之一(0.006 對 0.30)。

根據 DeepSeek 的快取文件(截至 2026 年 9 月):

  • 快取預設開啟,不需要改程式碼。
  • 快取按"字首單元"儲存。伺服器會在每次請求的邊界、多個請求共有的開頭部分,以及長輸入中每隔固定的詞元數,建立這樣的單元。只有完整匹配了一個單元,這部分才算命中。
  • 不再使用的快取,一般幾個小時到幾天內會被清除。
  • 命中和沒命中的詞元數,分別在 usageprompt_cache_hit_tokensprompt_cache_miss_tokens 裡。

實驗裡第二次還有 213 個詞元沒命中,就是因為快取是按單元匹配的:使用者問題所在的最後一段,以及不足以構成一個完整單元的部分,都按未命中計價。

這些規則決定了你應該怎麼安排訊息的順序:

  • 不變的放前面:system 訊息、固定的資料、示例放在最前面。
  • 變化的放後面:使用者的問題、每次不一樣的內容放在最後。

如果你把使用者的問題放在文件前面,每次問題不同,開頭就不同,後面的文件也就都命中不了。

輸出比輸入貴

回到價格表:deepseek-flash 的輸出是 1.20 美元每百萬詞元,未命中快取的輸入是 0.30,命中快取的輸入是 0.006。輸出比輸入貴 4 倍,比命中快取的輸入貴 200 倍。

幾個直接的推論:

  • 讓模型別說廢話是在省錢。在提示詞裡要求"一句話回答"、"只輸出 JSON",效果立竿見影。
  • 思考模式下,思考過程按輸出計費。簡單任務關掉思考,能省下大部分輸出費用。
  • 長篇資料放在輸入裡,如果能命中快取,其實並不貴。真正花錢的,往往是沒命中快取的長輸入,以及冗長的輸出。

常見問題

報錯說上下文超長:先檢查是不是對話歷史越積越多。03 模組第 1 課會講怎麼截斷和壓縮歷史。

快取命中數一直是 0:檢查每次請求的開頭是不是真的完全一樣。常見的問題是在 system 訊息裡放了當前時間、隨機的 ID,或者每次調整了資料的順序。

別的服務商有快取嗎:大多數都有,但計價方式和規則不一樣,有的需要手動標記哪部分要快取。用之前查對方的文件。

練習

  1. cost_usd 算一算:同樣每月 3 萬次、不命中快取,全部放在低谷時段呼叫(傳 off_peak=True),能省多少錢?
  2. 把實驗一里的 system 訊息和 user 訊息順序換一下(問題放前面,文件放後面),連問兩次,看看第二次的快取命中數有什麼變化。
  3. 改一改實驗二:插入三句分散在不同位置的話,比如"口令的第一部分是藍鯨"、"第二部分是四十"、"第三部分是二",然後問完整的口令。關掉思考和開著思考各測幾次,結果和只找一句話時一樣嗎?

自測

1. 上下文視窗是 100 萬詞元,是不是意味著我可以輸入 100 萬詞元?

不是。上下文視窗是輸入和輸出加起來的上限。輸入用掉 99 萬,輸出最多就只剩 1 萬,而且開著思考時思考過程也算輸出。另外,能放進去不代表划算,長輸入又慢又貴。

2. 你的應用每次把一份固定的產品手冊和使用者問題一起發給模型。怎麼安排訊息順序最省錢?

把產品手冊放在最前面(比如放進 system 訊息),使用者問題放在最後。這樣每次請求的開頭都一樣,能命中快取,手冊部分按快取命中的價格收費,便宜幾十倍。如果問題放在前面,開頭每次都不同,快取永遠命中不了。

3. 你做了一次測試,模型在長文件裡準確找到了你藏的一句話。能不能說明這個模型的長上下文能力沒問題?

不能。"找一句話"是最簡單的長上下文任務。需要綜合多處資訊、在大量相似內容裡分辨細節時,模型更容易出錯。要判斷能不能滿足你的需求,得用接近真實任務的測試來驗證。