模块 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. 你做了一次测试,模型在长文档里准确找到了你藏的一句话。能不能说明这个模型的长上下文能力没问题?

不能。"找一句话"是最简单的长上下文任务。需要综合多处信息、在大量相似内容里分辨细节时,模型更容易出错。要判断能不能满足你的需求,得用接近真实任务的测试来验证。