03 · Context Window#

每次调用 LLM,你能放进去的内容是有上限的——这个上限就是 Context Window(上下文窗口)

理解它对实际开发至关重要:

  • 为什么模型会「忘记」对话开头说的内容?

  • 为什么处理长文档需要特殊策略?

  • 为什么更大的窗口意味着更高的成本?

本章从原理讲起,然后动手实验,最后给出实用的长文本处理方案。

1. Context Window 是什么#

Transformer 模型在生成每个 token 时,会对所有已有 token 做 Attention 运算:

Attention 计算量 ∝ n²  (n = token 数量)

这意味着:

  • 窗口翻倍 → 计算量翻 4 倍、显存翻 4 倍

  • 存在物理上限,超出则无法计算

Context Window 包含什么:

┌─────────────────────────────────────────┐
│  System Prompt                          │
│  ─────────────────────────────────────  │
│  对话历史 (User + Assistant turns)       │
│  ─────────────────────────────────────  │
│  当前 User 输入                          │
│  ─────────────────────────────────────  │
│  [待生成的 Assistant 输出]               │
└─────────────────────────────────────────┘
         全部加起来不能超过窗口上限

Input tokens + Output tokens ≤ Context Window

2. 各模型的窗口大小#

# 主流模型的 context window(2025 年数据,请以官网为准)
models = [
    # (模型名,               上下文窗口,  最大输出,   发布时间)
    ("gpt-3.5-turbo",        4_096,       4_096,      "2022"),
    ("gpt-4",                8_192,       4_096,      "2023"),
    ("gpt-4o",             128_000,      16_384,      "2024"),
    ("gpt-4o-mini",        128_000,      16_384,      "2024"),
    ("claude-3-haiku",     200_000,       4_096,      "2024"),
    ("claude-sonnet-4-6",  200_000,      64_000,      "2025"),
    ("gemini-1.5-pro",   1_000_000,       8_192,      "2024"),
    ("gemini-2.0-flash", 1_000_000,       8_192,      "2025"),
]

# 换算成「能装多少内容」
CHARS_PER_TOKEN_EN = 4   # 英文
CHARS_PER_TOKEN_ZH = 1.5 # 中文
WORDS_PER_PAGE = 500     # 英文,每页约 500 词

print(f"{'模型':<22} {'窗口(K)':>8} {'最大输出(K)':>12} {'约等于(英文页数)':>16}")
print("-" * 65)
for name, ctx, out, year in models:
    pages = (ctx * CHARS_PER_TOKEN_EN) / (WORDS_PER_PAGE * 5)  # ~5 chars/word
    print(f"{name:<22} {ctx//1000:>7}K {out//1000:>11}K {pages:>14.0f} 页")
模型                        窗口(K)      最大输出(K)        约等于(英文页数)
-----------------------------------------------------------------
gpt-3.5-turbo                4K           4K              7 页
gpt-4                        8K           4K             13 页
gpt-4o                     128K          16K            205 页
gpt-4o-mini                128K          16K            205 页
claude-3-haiku             200K           4K            320 页
claude-sonnet-4-6          200K          64K            320 页
gemini-1.5-pro            1000K           8K           1600 页
gemini-2.0-flash          1000K           8K           1600 页
import tiktoken

enc = tiktoken.get_encoding("cl100k_base")

# 实际可用的 input token = 窗口上限 - 预留给输出的 token
def available_input_tokens(
    context_limit: int,
    reserved_for_output: int = 1000,
    system_prompt: str = "",
) -> int:
    system_tokens = len(enc.encode(system_prompt))
    return context_limit - reserved_for_output - system_tokens

system = "You are a helpful assistant that answers questions concisely."

print("实际可用 Input Token(扣除 system prompt + 预留 1K 输出):")
print(f"{'模型':<22} {'窗口':>10} {'可用 Input':>12}")
print("-" * 48)
for name, ctx, out, year in models:
    avail = available_input_tokens(ctx, reserved_for_output=1000, system_prompt=system)
    print(f"{name:<22} {ctx:>10,} {avail:>12,}")
实际可用 Input Token(扣除 system prompt + 预留 1K 输出):
模型                             窗口     可用 Input
------------------------------------------------
gpt-3.5-turbo               4,096        3,084
gpt-4                       8,192        7,180
gpt-4o                    128,000      126,988
gpt-4o-mini               128,000      126,988
claude-3-haiku            200,000      198,988
claude-sonnet-4-6         200,000      198,988
gemini-1.5-pro          1,000,000      998,988
gemini-2.0-flash        1,000,000      998,988

3. 超出窗口时会发生什么?#

超出限制时,API 会直接报错(不会静默截断)。 我们来构造一个接近边界的场景,用真实 API 感受一下。

import os
from dotenv import load_dotenv
load_dotenv()

# 当前配置的模型
current_model = os.getenv("LLM_MODEL", "未设置")
print(f"当前模型: {current_model}")

# 模拟一个很长的对话历史
def make_long_text(target_tokens: int) -> str:
    """生成大约 target_tokens 个 token 的文本。"""
    unit = "The quick brown fox jumps over the lazy dog. "  # ~10 tokens
    unit_tokens = len(enc.encode(unit))
    repeats = target_tokens // unit_tokens
    text = unit * repeats
    actual = len(enc.encode(text))
    return text, actual

# 生成不同长度的文本
for target in [100, 1_000, 10_000, 50_000]:
    text, actual = make_long_text(target)
    print(f"目标 {target:>6} tokens → 实际 {actual:>6} tokens  ({len(text):,} 字符)")
当前模型: openai/gpt-5-mini
目标    100 tokens → 实际     91 tokens  (405 字符)
目标   1000 tokens → 实际    901 tokens  (4,050 字符)
目标  10000 tokens → 实际   9091 tokens  (40,905 字符)
目标  50000 tokens → 实际  45451 tokens  (204,525 字符)
from utils.llm_client import chat

# 发送一个正常大小的请求,观察 token 使用情况
long_text, token_count = make_long_text(500)
prompt = f"""{long_text}

请用一句话总结上面这段文字的主要内容。"""

print(f"Prompt token 数: {len(enc.encode(prompt)):,}")
response = chat(prompt)
print(f"Response: {response}")
Prompt token 数: 469
Response: 这段文字多次重复了英文示例句 “The quick brown fox jumps over the lazy dog。”
# 验证「遗忘效应」:把关键信息放在很长文本的开头,看模型还记不记得
secret = "SECRET_CODE_42XZ"
filler, _ = make_long_text(3000)  # 3K tokens 的填充内容

prompt = f"""记住这个暗号:{secret}

{filler}

请告诉我之前给你的暗号是什么?"""

print(f"Prompt token 数: {len(enc.encode(prompt)):,}")
response = chat(prompt)
print(f"\n模型回答: {response}")
print(f"\n暗号是否在回答中: {secret in response}")
Prompt token 数: 2,755
模型回答: 暗号是:SECRET_CODE_42XZ

暗号是否在回答中: True

4. 长文本处理的三种策略#

当文本超出窗口,有三种基本策略:

策略

适用场景

缺点

截断

只关心开头或结尾

丢失信息

滑动窗口

需要连续处理整个文本

边界处信息割裂

分块摘要

需要理解全文

多次 API 调用,摘要有损

# ── 策略 1:截断 ──────────────────────────────────────────

def truncate(
    text: str,
    max_tokens: int,
    from_end: bool = False,
) -> str:
    """
    截断文本到 max_tokens。
    from_end=True 保留末尾(适合对话历史:保留最近的内容)。
    """
    ids = enc.encode(text)
    if len(ids) <= max_tokens:
        return text
    if from_end:
        ids = ids[-max_tokens:]
    else:
        ids = ids[:max_tokens]
    return enc.decode(ids)

sample = "这是一段很长的文本。" * 200
print(f"原始: {len(enc.encode(sample))} tokens")

t1 = truncate(sample, 100, from_end=False)
print(f"保留开头 100 tokens: '{t1[:50]}...'")

t2 = truncate(sample, 100, from_end=True)
print(f"保留末尾 100 tokens: '...{t2[-50:]}'")
原始: 2200 tokens
保留开头 100 tokens: '这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。...'
保留末尾 100 tokens: '...这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。这是一段很长的文本。'
# ── 策略 2:滑动窗口分块 ──────────────────────────────────

def sliding_window_chunks(
    text: str,
    chunk_size: int = 500,
    overlap: int = 50,
) -> list[str]:
    """
    把文本切成有重叠的块,避免边界信息丢失。
    overlap: 相邻块之间共享的 token 数。
    """
    ids = enc.encode(text)
    chunks = []
    start = 0
    while start < len(ids):
        end = min(start + chunk_size, len(ids))
        chunks.append(enc.decode(ids[start:end]))
        if end == len(ids):
            break
        start += chunk_size - overlap
    return chunks

long_doc = "人工智能的发展历史悠久。" * 100
chunks = sliding_window_chunks(long_doc, chunk_size=100, overlap=20)

print(f"原始文本: {len(enc.encode(long_doc))} tokens")
print(f"分块结果: {len(chunks)} 块")
for i, c in enumerate(chunks):
    print(f"  Chunk {i+1}: {len(enc.encode(c))} tokens")
原始文本: 1700 tokens
分块结果: 21 块
  Chunk 1: 100 tokens
  Chunk 2: 99 tokens
  Chunk 3: 100 tokens
  Chunk 4: 100 tokens
  Chunk 5: 100 tokens
  Chunk 6: 100 tokens
  Chunk 7: 100 tokens
  Chunk 8: 100 tokens
  Chunk 9: 100 tokens
  Chunk 10: 100 tokens
  Chunk 11: 100 tokens
  Chunk 12: 100 tokens
  Chunk 13: 100 tokens
  Chunk 14: 100 tokens
  Chunk 15: 99 tokens
  Chunk 16: 100 tokens
  Chunk 17: 100 tokens
  Chunk 18: 100 tokens
  Chunk 19: 99 tokens
  Chunk 20: 100 tokens
  Chunk 21: 100 tokens
# ── 策略 3:分块摘要(Map-Reduce 模式)─────────────────────

def map_reduce_summarize(
    text: str,
    chunk_size: int = 800,
    overlap: int = 50,
) -> str:
    """
    Map:    每个 chunk 单独摘要
    Reduce: 把所有摘要合并,再做一次最终摘要
    """
    chunks = sliding_window_chunks(text, chunk_size=chunk_size, overlap=overlap)
    print(f"文档分成 {len(chunks)} 块,开始 Map 阶段...")

    # Map:每块单独摘要
    summaries = []
    for i, chunk in enumerate(chunks):
        summary = chat(
            f"请用 2-3 句话概括以下内容的要点:\n\n{chunk}",
            max_tokens=500,
        )
        summaries.append(summary)
        print(f"  Chunk {i+1}/{len(chunks)} 摘要完成")

    # Reduce:合并所有摘要,生成最终摘要
    print("\nReduce 阶段...")
    combined = "\n\n".join(f"[部分 {i+1}]\n{s}" for i, s in enumerate(summaries))
    final = chat(
        f"以下是一篇文档各部分的摘要,请整合成一个连贯的总体摘要(200字以内):\n\n{combined}",
        max_tokens=1000,
    )
    return final


# 用一篇「假长文档」测试(实际场景替换成真实内容)
fake_long_doc = """
人工智能(AI)的发展可以追溯到 20 世纪 50 年代。1956 年,达特茅斯会议标志着 AI 作为一个学科正式诞生。
早期 AI 研究主要集中在符号推理和专家系统。这一阶段的代表成果包括 LISP 语言和第一批专家系统。
然而,受限于计算能力和数据量,AI 在 80 年代末进入了第一次「寒冬」。

进入 90 年代,机器学习的兴起带来了新的希望。统计方法取代了符号推理,SVM、随机森林等算法相继出现。
1997 年,IBM 的深蓝击败国际象棋世界冠军卡斯帕罗夫,成为 AI 史上的里程碑事件。

2012 年,深度学习迎来爆发。AlexNet 在 ImageNet 竞赛中的惊人表现,宣告了深度学习时代的到来。
此后,卷积神经网络(CNN)在图像识别领域全面超越人类水平。

2017 年,Google 发表了划时代的论文《Attention is All You Need》,提出了 Transformer 架构。
这一架构彻底改变了自然语言处理(NLP)领域,为后来的大型语言模型奠定了基础。

2022 年,ChatGPT 的发布让 AI 真正走入大众视野。其流畅的对话能力和广泛的知识令全球用户震惊。
随后,各大科技公司纷纷推出自己的大语言模型:Google 的 Gemini、Anthropic 的 Claude、Meta 的 LLaMA 等。

如今,AI 已经渗透到医疗、教育、金融、创作等各个领域。
如何确保 AI 的安全性、可解释性和公平性,成为当下最重要的研究课题。
""" * 3  # 重复 3 次模拟更长的文档

print(f"文档长度: {len(enc.encode(fake_long_doc))} tokens")
print("="*50)
final_summary = map_reduce_summarize(fake_long_doc, chunk_size=300)
print("\n最终摘要:")
print(final_summary)
文档长度: 1774 tokens
==================================================
文档分成 7 块,开始 Map 阶段...
  Chunk 1/7 摘要完成
  Chunk 2/7 摘要完成
  Chunk 3/7 摘要完成
  Chunk 4/7 摘要完成
  Chunk 5/7 摘要完成
  Chunk 6/7 摘要完成
  Chunk 7/7 摘要完成

Reduce 阶段...
最终摘要:
本文梳理人工智能发展里程碑:从符号推理向统计方法转变,涌现SVM、随机森林等算法;1997年深蓝胜卡斯帕罗夫,2012年AlexNet及CNN在图像识别上实现超越人类;2017年Transformer奠定现代NLP与大型语言模型基础,2022年ChatGPT使AI走入大众并催生Gemini、Claude、LLaMA等竞品。如今AI已渗透医疗、教育、金融与创作领域,研究重心转向安全性、可解释性与公平性。

5. 实用工具:发送前检查#

在真实项目中,每次调用 API 前都应该检查 token 用量,避免超限报错。

CONTEXT_LIMITS = {
    "openai/gpt-4o":               128_000,
    "openai/gpt-4o-mini":          128_000,
    "anthropic/claude-sonnet-4-6": 200_000,
    "anthropic/claude-haiku-4-5":  200_000,
    "gemini/gemini-2.0-flash":   1_000_000,
}

def token_budget(
    messages: list[dict],
    model: str = None,
    reserved_output: int = 1000,
) -> dict:
    """
    计算当前 messages 的 token 用量和剩余预算。

    messages 格式:[{"role": "user", "content": "..."}, ...]
    """
    if model is None:
        model = os.getenv("LLM_MODEL", "openai/gpt-4o")

    # 统计所有 message 的 token
    total = sum(len(enc.encode(m["content"])) for m in messages)
    # 每条消息有约 4 token 的格式开销
    total += len(messages) * 4

    limit = CONTEXT_LIMITS.get(model, 128_000)
    remaining = limit - total - reserved_output

    return {
        "used": total,
        "limit": limit,
        "reserved_output": reserved_output,
        "remaining": remaining,
        "usage_pct": round(total / limit * 100, 1),
        "ok": remaining > 0,
    }


# 示例:模拟一个多轮对话
conversation = [
    {"role": "system",    "content": "You are a helpful assistant."},
    {"role": "user",      "content": "什么是机器学习?"},
    {"role": "assistant", "content": "机器学习是人工智能的一个分支..."},
    {"role": "user",      "content": "能详细解释一下监督学习吗?"},
]

budget = token_budget(conversation)
print(f"已用: {budget['used']:,} tokens")
print(f"上限: {budget['limit']:,} tokens")
print(f"剩余: {budget['remaining']:,} tokens")
print(f"用量: {budget['usage_pct']}%")
print(f"安全: {budget['ok']}")
已用: 65 tokens
上限: 128,000 tokens
剩余: 126,935 tokens
用量: 0.1%
安全: True

小结#

概念

要点

Context Window

Attention 的 O(n²) 代价决定了物理上限

超出限制

API 直接报错,不会静默截断

遗忘效应

长文本中「中间」的内容容易被忽略(Lost in the Middle)

截断

最简单,适合只关心首尾的场景

滑动窗口

适合需要连续处理的场景,overlap 避免边界割裂

分块摘要

Map-Reduce 模式,适合需要理解全文的场景

下一章 → Sampling Parameters:temperature、top-p、top-k 如何控制模型的「创造力」