「200K context」不等于「200K 有效注意力」。
所有大模型厂商都在卷 context window:Claude Sonnet 4.5 给了 1M、Gemini 2.5 给到 2M、GPT-5 也跟到 400K。于是大量人开始把整个 codebase / 全公司文档 / 三十轮历史对话一股脑塞进去,然后困惑:「为什么模型答不上来?我明明把答案放进去了。」答案是 context window 长 ≠ context 有效。注意力在长序列上是退化的、信息排布的位置决定模型能不能用、chunk 切的方式比换一个 embedding model 重要 10 倍。这一期讲四件事:长 context 真实有效长度的衡量、信息在 prompt 里的物理位置怎么放、为什么 90% 的 RAG 输给作者自己的 chunk 策略、以及当 history 超长时怎么做 compaction 而不丢关键信息。
Liu et al. 2023 的 Lost in the Middle: How Language Models Use Long Contexts(TACL 2024 收录)是这一切讨论的起点。他们让模型在一组文档里找答案,把 ground-truth 文档分别放在第 1、5、10、15、20 个位置,画出准确率曲线。结果在所有测试的模型(包括 GPT-3.5、Claude、Longchat)上都是 U 形:首尾位置准确率接近 75%,中段塌到 50% 以下——比关闭整个 retrieval 还差。
原因有两层。第一,预训练阶段,token 之间的依赖大多是局部的、或对齐到文档开头(causal mask 让 attention 天然偏向最近 token,RoPE 等位置编码的 long-tail 衰减也让远距离 attention score 变弱)。第二,post-training 的指令数据多数是「短 prompt → 答案」结构,模型对「指令在尾、关键事实在中段」的形态见过的样本少。
2024 年 NoLiMa(Modarressi et al.)进一步证明:当 question 和 answer 之间没有词面 overlap 时,所谓的 100K+ 长上下文模型在 32K 处就掉到基线以下。Chroma 2025 年的 Context Rot 报告复测了 18 个最新模型(含 Claude Sonnet 4、GPT-4.1、Gemini 2.5),结论一样:有效 context 远短于宣传的 max context,且和任务难度反向相关。
跑一个最小 NIAH(Needle in a Haystack)测试,先量一下你常用模型的真实有效长度:
import anthropic, random, string
client = anthropic.Anthropic()
needle = "BigCat 的会议密码是 PURPLE-OWL-9182。"
def haystack(n_tokens, needle_pos):
# 用 Paul Graham 文章拼到 n_tokens 长度
filler = open("pg_essays.txt").read()
chunks = filler.split("\n\n")
random.shuffle(chunks)
text = "\n\n".join(chunks)[:n_tokens * 4] # 粗略 4 char/token
pos = int(len(text) * needle_pos)
return text[:pos] + "\n\n" + needle + "\n\n" + text[pos:]
for length in [8000, 32000, 100000, 180000]:
for pos in [0.1, 0.5, 0.9]:
ctx = haystack(length, pos)
r = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=128,
messages=[{"role":"user","content": ctx +
"\n\nQ: BigCat 的会议密码是什么?只回答密码。"}])
print(length, pos, r.content[0].text.strip())
跑完会得到一张 (length × position) 的命中表。你只能信任那张表里命中率 ≥ 95% 的格子——超过的部分,不管厂商宣传多少 K,都不该塞关键信息。
既然首尾权重最高,prompt 内部的物理排布就该按重要性梯度铺。Anthropic 官方文档给的 long-context 建议(claude.com/docs · Long context tips)非常直白:把待引用的长文档放在 prompt 的最上方,让 question 出现在 user turn 的末尾。这条规则在 Claude 上反复被测过,能把 long doc Q&A 的准确率提 10-20%。
实操有三个动作:
<documents> 之后——而不是之前。<documents>
<document index="1">
<source>contracts/acme-2024.pdf</source>
<content>...8K tokens...</content>
</document>
<document index="2">...</document>
</documents>
Instructions (重申):
- 只引用 documents 中明确写出的条款,不要外推
- 先列出你将引用的原文片段,再给结论
Question:
我方在 Acme 合同里能否在第 18 个月单方面终止?请逐条列出依据。
团队上线 RAG 的标准流程:固定 512-token chunk + 50 overlap + OpenAI embedding + 简单 top-k。然后效果不好就开始换 embedding model、换向量库。换完发现差不多。这是因为:
真正决定 RAG 上限的三件事:chunk 边界(语义切分)、chunk 上下文(contextual prefix)、检索融合(hybrid + rerank)。embedding model 在合理选择下只是 ±5% 的差异。
Anthropic 推荐的 Contextual Retrieval 流程(可直接搬到自己的项目):
# 步骤 1:为每个 chunk 生成上下文(用 Claude Haiku 批量做,配合 prompt caching)
context_prompt = """
<document>{whole_doc}</document>
Here is the chunk we want to situate within the whole document:
<chunk>{chunk}</chunk>
Please give a short succinct context to situate this chunk within
the overall document for improving search retrieval. Answer only
with the context, nothing else.
"""
# 步骤 2:把 context 拼到 chunk 前再做 embedding + BM25 索引
indexed_chunk = f"{generated_context}\n\n{original_chunk}"
# 步骤 3:查询时做 hybrid (dense + BM25),取 top-150 → rerank 到 top-20
dense_hits = vector_store.search(query, k=150)
bm25_hits = bm25_index.search(query, k=150)
fused = reciprocal_rank_fusion([dense_hits, bm25_hits])
top20 = cohere_rerank(query, fused[:150], top_n=20)
关键工程细节:第 1 步的 {whole_doc} 在 prompt 里复用同一个 system + document prefix,所有 chunk 共享 prompt cache,成本可以压到原 1/10。Anthropic 自己测下来一篇 100 页文档的 contextualization 成本大约 $1.02——比换 embedding model 便宜得多,效果好得多。
Agent / 长聊会话跑到第 50 轮,accuracy 经常断崖下跌。原因不一定是 token 超了——很多时候离 max context 还远,但模型已经晕了。这就是 context rot:相关信号被无关历史稀释,加上 lost-in-the-middle,模型把注意力压到了无关 token 上。
处理 long history 有四种主流策略,工程选择对应不同 trade-off:
给一个长跑的 coding agent 写一段最小可用的 compaction loop:
def maybe_compact(history, model="claude-sonnet-4-6", threshold=120_000):
tok = count_tokens(history)
if tok < threshold:
return history
# 保留最近 6 轮全文,之前的压成结构化摘要
recent, old = history[-6:], history[:-6]
summary = client.messages.create(model=model, max_tokens=2000,
system="You compress agent histories. Output strict JSON.",
messages=[{"role":"user","content": f"""
Compress the following turns into JSON with keys:
- goal: the user's overall objective
- decisions: list of decisions made (with rationale)
- artifacts: files/code created or modified (paths only)
- open_questions: anything explicitly deferred
- forbidden: constraints user said NOT to do
<turns>{serialize(old)}</turns>
"""}]).content[0].text
return [{"role":"system","content":f"<prior_session>{summary}</prior_session>"}] + recent
关键经验:压缩的 schema 比压缩的算法重要。强制模型按固定 key 输出(goal / decisions / artifacts / open_questions / forbidden),可以让下一轮 prompt 直接引用 <prior_session>.forbidden,比让模型自由总结鲁棒得多。
把上面四点串成一个周末项目:拿你自己的 Notion / Obsidian 笔记当 corpus,做一个可日常用的 personal knowledge agent。
## 以上每一段为一个 chunk,附 frontmatter + breadcrumb(文件路径 + heading 链)。<documents> 段最前;指令 + question 放最末,且在末尾 restate 「只引用 documents 中明确出现的内容」。这一套跑下来,你会发现 chunking + contextualization + rerank 这三步带来的提升,比把 embedding model 换 5 次都大。