我把一篇超长文档整个塞给大模型让它总结,结果它的回答只覆盖了前半部分、后半段像没看见一样,我对着这个被静默截断的输入排查了大半天的复盘
这是一个让我对大模型"上下文窗口"刻骨铭心的故事。我做了个文档总结功能:把用户上传的文档,整个塞进 prompt,让大模型总结/回答问题。短文档时,效果好极了。可一旦文档比较长,怪事就来了:模型给出的总结,只覆盖了文档的前半部分——后半段的内容,它像完全没看见一样,只字不提;我针对后半段内容提问,它要么答非所问、要么含糊其辞、甚至编造。一个能力很强的模型,怎么会对着一篇完整的文档,"看不见"它的后半部分?
我顺着"只看到前半部分"的线索深挖,才终于揭开真相,补上了我对大模型一个最基础、却极易忽略的认知漏洞:问题的核心,是我塞进去的文档,token 数超过了模型的上下文窗口(context window)上限,而超出的部分,被静默地截断了。我一直想当然地以为,"我把整个文档都给它了,它当然能看到全部内容";可真相是:每个大模型,都有一个固定的"上下文窗口"——也就是它一次能"看到"的最大 token 数量(比如 8K、32K、128K);这个窗口,装的是输入(你的 prompt)+ 输出的总和。而我那篇长文档,token 数超过了这个窗口;于是,超出窗口的那部分内容(往往是后面的),要么被API/框架悄悄地"截断"掉(只保留能塞下的前面部分),要么直接报"超长"错误——而我用的那套处理,选择了静默截断,于是文档的后半部分,根本就没被送进模型!模型从头到尾,就没"看到"过那些内容,它只是基于"残缺的、只有前半部分的输入",自信地、煞有介事地作答了。更隐蔽的是,即使没有超限,过长的上下文,也会有"中间遗忘(lost in the middle)"的问题——模型对一段长文本里,开头和结尾的内容记得比较牢,而正中间的内容,容易注意力涣散、被忽略。我这才痛彻地明白:大模型的"上下文窗口",是一个有明确物理上限的、有限的资源;它不是"你给多少它看多少",而是"超过上限的,它根本看不到";把"超出窗口的内容"塞给它,不会报错(若被静默截断),只会让它基于残缺信息瞎答,极具欺骗性。处理长文本,绝不能"一股脑全塞进去",而必须先估算 token、控制在窗口内;对于超长内容,要用"分块处理"或"检索相关片段"等策略,把"喂给模型的信息",精准地控制在它"真正能看到、且能看好"的范围之内。
故障现场:超长输入超出窗口,被静默截断
我把这个"看不见后半部分"的现场,用伪代码摊开给你看:
# ✗ 灾难: 把超长文档整个塞进 prompt, 超出上下文窗口被静默截断
def summarize(document):
prompt = f"请总结以下文档:\n\n{document}" # ✗ document 可能超长!
return llm.chat(prompt) # 模型窗口比如 8K token
# 当 document 的 token 数 > 模型窗口时:
# - 情况A(静默截断): 框架/API 只把能塞下的前面部分送进去, 后面丢弃。
# → 模型只"看到"前半部分, 后半部分它根本不知道存在!
# → 它基于残缺输入, 自信地总结了"前半部分", 你以为它总结了全文。
# - 情况B(报错): 直接返回 "context length exceeded" / "too many tokens"。
# 为什么"只覆盖前半部分"?
# - 截断通常从后面截(保留前面) → 后半段没进模型。
# - 模型对"它没收到的内容", 当然无从知晓、无法总结。
# 上下文窗口装的是什么?
# - 窗口 = 输入(system+prompt+历史+文档) + 输出(模型的回答)。
# - 即使输入没满, 也要给输出留空间; 输入占太多 → 输出被挤短/截断。
# 更隐蔽的坑: lost in the middle(中间遗忘)
# - 就算没超限, 超长上下文里, 模型对"开头/结尾"记得牢, "中间"容易忽略。
# - 表现: 长文档中间的关键信息, 模型"视而不见"。
# 怎么发现是截断问题?
# - 估算 document 的 token 数, 和模型窗口对比。
# - 看 API 返回的 usage(prompt_tokens 是否接近/达到上限)。
# - 故意问"文档最后一段讲了什么", 看它是否答得上来。
# 根因: 文档 token 超过模型上下文窗口, 超出部分被静默截断,
# 模型只看到部分输入却自信作答; 过长还有"中间遗忘"问题。
看着这段"整个塞进去"的代码,我才算彻底想明白了根源。问题的核心,是文档 token 超过了模型上下文窗口,超出部分被静默截断。当 token 超限时,情况 A(静默截断):框架/API 只把能塞下的前面部分送进去、后面丢弃,模型只"看到"前半部分、后半部分它根本不知道存在,却基于残缺输入自信地总结了"前半部分",你以为它总结了全文;情况 B(报错):直接 context length exceeded。为什么"只覆盖前半部分"?因为截断通常从后面截(保留前面),后半段没进模型,模型对它没收到的内容无从知晓。而上下文窗口装的是什么?窗口 = 输入(system+prompt+历史+文档)+ 输出(回答),即使输入没满,也要给输出留空间。更隐蔽的是 lost in the middle:就算没超限,超长上下文里模型对"开头/结尾"记得牢、"中间"容易忽略。归根结底:文档 token 超过模型上下文窗口、超出部分被静默截断,模型只看到部分输入却自信作答;过长还有"中间遗忘"问题——这,就是根源。
第一件事:搞懂上下文窗口与 token
定位到根源,我必须把"上下文窗口、token、截断"这几件事从根上彻底搞清楚:
上下文窗口: 模型一次能看的最大 token 数; 超了被截断/报错
# token 是什么?
# - 模型处理文本的基本单位, 介于"字"和"词"之间。
# - 粗估: 英文 1 token ≈ 0.75 个单词; 中文 1 个字 ≈ 1~2 token。
# - 一篇几千字的中文文档, 可能就是几千上万 token。
# 上下文窗口(context window):
# - 模型一次能"看到"的最大 token 数(8K/32K/128K/200K...)。
# - 它装的是: 输入(system+用户prompt+对话历史+塞的文档) + 输出(回答)。
# - 是硬上限: 超过它, 模型物理上"看不到"。
# 超过窗口会怎样?
# - 静默截断: 只保留能塞下的部分(常截掉后面)→ 模型看不到被截的 → 瞎答。
# - 或报错: context length exceeded。
# - 关键: 截断往往"无声无息", 比报错更危险(你以为它看全了)。
# lost in the middle(即使没超限也有的问题):
# - 长上下文里, 模型对开头、结尾的信息利用得好, 中间的容易被忽略。
# - 所以"塞得越多≠效果越好", 信噪比和位置都影响效果。
# 输入 vs 输出都占窗口:
# - 输入占满了, 留给输出的 token 就少 → 回答被迫变短/截断。
# - 要给输出预留足够空间(如设 max_tokens)。
# 关键认知: 上下文窗口是有限资源, 要"算着用、精着喂"。
# - 别假设"全塞进去模型就都看到了"; 要估 token、控长度、喂精华。
# 核心: 上下文窗口是模型一次能看的最大token数(输入+输出)、有硬上限;
# 超了被静默截断(看不到就瞎答)或报错; 过长还有中间遗忘, 要估token精准喂。
原理终于清晰了。token 是什么?——模型处理文本的基本单位,介于"字"和"词"之间;粗估英文 1 token ≈ 0.75 个单词、中文 1 个字 ≈ 1~2 token,一篇几千字的中文文档可能就是几千上万 token。上下文窗口:模型一次能"看到"的最大 token 数(8K/32K/128K…),装的是输入(system+prompt+历史+文档)+ 输出(回答),是硬上限——超过它模型物理上看不到。超过窗口会怎样?静默截断(只保留能塞下的、常截掉后面,模型看不到被截的就瞎答)或报错;关键是截断往往无声无息、比报错更危险(你以为它看全了)。而 lost in the middle:即使没超限,长上下文里模型对开头结尾利用得好、中间容易忽略,所以"塞得越多≠效果越好"。还要注意输入和输出都占窗口:输入占满了留给输出就少、回答被迫变短,要给输出预留空间(设 max_tokens)。由此,我刻下一个关键认知:上下文窗口是有限资源,要"算着用、精着喂";别假设"全塞进去模型就都看到了",要估 token、控长度、喂精华。归根结底:上下文窗口是模型一次能看的最大 token 数(输入+输出)、有硬上限;超了被静默截断(看不到就瞎答)或报错;过长还有中间遗忘,要估 token 精准喂。
第二件事:正解——估 token + 分块处理 + 检索喂精华
搞懂了原理,正解就清晰了:先估算 token、控制在窗口内;超长内容用"分块处理"(map-reduce/refine)或"检索相关片段"(RAG),把喂给模型的信息精准控制在它能看好的范围。
# ✓ 正解一: 先估算 token, 判断是否超窗
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4")
def count_tokens(text):
return len(enc.encode(text))
MODEL_WINDOW = 8000
RESERVE_OUTPUT = 1000 # 给输出留空间
def summarize(document):
doc_tokens = count_tokens(document)
if doc_tokens <= MODEL_WINDOW - RESERVE_OUTPUT:
return llm.chat(f"请总结:\n{document}") # ✓ 没超, 直接总结
else:
return summarize_long(document) # ✓ 超了, 走分块策略
# ✓ 正解二: 分块 + map-reduce(超长文档总结的经典法)
def summarize_long(document):
chunks = split_into_chunks(document, max_tokens=3000) # 1. 切成小块
partial = [llm.chat(f"总结这段:\n{c}") for c in chunks] # 2. map: 各块分别总结
combined = "\n".join(partial)
return llm.chat(f"把这些分段总结, 汇总成一份总览:\n{combined}") # 3. reduce: 汇总
# → 每次喂给模型的都在窗口内, 且覆盖了全文。
# ✓ 正解三: refine(逐块迭代精炼, 适合需要连贯性的)
# summary = llm.chat(f"总结第1块: {chunk1}")
# for c in rest_chunks:
# summary = llm.chat(f"已有总结:{summary}\n结合新内容精炼:{c}")
# ✓ 正解四: RAG —— 不塞全文, 只检索"和问题相关"的片段喂进去
# - 问答场景: 把文档切块+向量化, 按问题检索 top-k 相关块, 只喂这几块。
# - 大幅减少 token, 且喂的都是"相关的", 效果更好(避免中间遗忘)。
# ✓ 正解五: 利用 API 的 usage 监控
# resp = llm.chat(...)
# print(resp.usage.prompt_tokens) # 看输入是否接近窗口上限
# 核心: 先估token、留输出空间; 超长用分块 map-reduce/refine 覆盖全文,
# 问答用 RAG 只喂相关片段; 别一股脑塞、靠 usage 监控 token。
修复的方向,是"算着喂、分块喂、精准喂"。正解一,先估算 token:用 tiktoken 等工具算出文档 token 数,和窗口对比(还要给输出留空间),没超就直接总结、超了就走分块策略。正解二,分块 + map-reduce(超长文档总结的经典法):① 把文档切成窗口内的小块;② map:各块分别总结;③ reduce:把各段总结再汇总成总览——这样每次喂给模型的都在窗口内,且覆盖了全文,后半部分再也不会被漏掉。正解三,refine(逐块迭代精炼,适合需要连贯性的);正解四,RAG(问答场景不塞全文,而是把文档切块向量化、按问题检索 top-k 相关块、只喂这几块——大幅减少 token、且喂的都是相关的、还避免了中间遗忘);正解五,用 API 的 usage 监控(看 prompt_tokens 是否接近上限)。归根结底:先估 token、留输出空间;超长用分块 map-reduce/refine 覆盖全文,问答用 RAG 只喂相关片段;别一股脑塞、靠 usage 监控 token。
第三件事:处理长文本的几种策略对比
这次踩坑后,我把处理超长文本的几种策略,横向梳理了一遍,按场景对号入座:
超长文本处理策略(按场景选)
# 1. map-reduce(分块总结再汇总)
# - 适合: 总结、提取全文要点。
# - 优点: 覆盖全文, 可并行(各块同时总结)。
# - 缺点: 块之间的连贯性/跨块信息可能丢失。
# 2. refine(逐块迭代精炼)
# - 适合: 需要保持连贯、逐步累积理解的总结。
# - 优点: 保留上下文连贯性。
# - 缺点: 串行(慢)、靠后的块对早期信息可能稀释。
# 3. RAG(检索相关片段)
# - 适合: 针对长文档的"问答"(只关心相关部分)。
# - 优点: token 省、喂的都相关、效果好。
# - 缺点: 需要切块+向量化+检索基础设施; 检索不准会漏。
# 4. 换更大窗口的模型
# - 适合: 文档不算太大、预算够、要全局理解。
# - 优点: 简单, 直接塞(但仍要注意 lost in middle 和成本)。
# - 缺点: 大窗口模型贵、慢; 窗口再大也有上限。
# 5. 先压缩/抽取再喂
# - 适合: 文档有大量冗余(如日志)。
# - 先用规则/小模型抽取关键部分, 再喂大模型。
# 选型要点:
# - 要"全文总结" → map-reduce / refine。
# - 要"针对性问答" → RAG。
# - 文档不太大 + 要全局 → 大窗口模型(注意成本和中间遗忘)。
# - 冗余多 → 先抽取压缩。
# 核心: 超长文本按场景选 —— 全文总结用map-reduce/refine、问答用RAG、
# 不太大用大窗口、冗余多先压缩; 别指望"塞进去就行"。
这套策略,让我处理长文本时有章可循。map-reduce(分块总结再汇总):适合全文总结、可并行,但跨块信息可能丢;refine(逐块迭代精炼):适合需要连贯性的总结,但串行慢;RAG(检索相关片段):适合针对长文档的问答(token 省、喂的都相关、效果好),但需要检索基础设施、检索不准会漏;换更大窗口的模型:简单直接,但贵、慢、且窗口再大也有上限、仍有中间遗忘;先压缩/抽取再喂:适合冗余多的文档(如日志)。选型要点:全文总结用 map-reduce/refine、针对性问答用 RAG、文档不太大且要全局用大窗口、冗余多先抽取压缩。它给我的启发是:"让大模型处理超出其窗口的长文本",本质上是一个"如何把大问题,拆解成模型能一口口吃下的小块"的工程问题;模型的窗口是固定的,而你能设计的"喂法",却是灵活无穷的——分块、汇总、检索、压缩……这些"喂法"的设计,正是 LLM 应用工程的核心能力之一。模型的能力有边界,但善用工程,能让它处理远超其窗口的任务。
下面这张图,是这次"输入被截断"的成因与解法:
第四件事:长文本处理几种策略的对比表
把上面几种策略,按几个关键维度列成一张表,选型时一目了然。
| 策略 | 适用任务 | 覆盖全文 | 成本/复杂度 |
|---|---|---|---|
| 直接塞(短文档) | 窗口内的小文档 | ✓ | 最低 |
| map-reduce | 全文总结/提要点 | ✓ 全覆盖 | 中(多次调用, 可并行) |
| refine | 需连贯的总结 | ✓ 全覆盖 | 中(串行, 较慢) |
| RAG | 针对性问答 | ✗ 只覆盖相关部分 | 较高(需检索设施) |
| 大窗口模型 | 要全局理解 | ✓(到窗口上限) | 高(贵+慢+中间遗忘) |
| 先压缩抽取 | 冗余多的文本 | 取决于抽取质量 | 中 |
这张表,把选型变成了"对号入座"。判断主要看两点:任务是"全文总结"还是"针对性问答"、以及文档有多大。全文总结(要覆盖全文)→ map-reduce(可并行、快)或 refine(连贯、但串行慢);针对性问答(只关心相关部分)→ RAG(token 省、效果好,代价是要检索设施);文档不太大、要全局理解→ 大窗口模型(简单但贵、慢、有中间遗忘);冗余多的文本→ 先压缩抽取再喂。它给我的启发是:没有"最好"的长文本策略,只有"最适配你任务和文档特征"的策略;而其中最容易被忽略、却往往最该问的一个前置问题是:"我真的需要让模型读全文吗?"——很多时候(尤其问答),你只需要文档里和问题相关的那一小部分(RAG),根本不必、也不应该把全文都喂进去。"少喂、精喂",常常比"多喂、全喂"效果更好、成本更低。
第五件事:token 与上下文窗口的其他影响
这次踩坑让我意识到,token 和上下文窗口的影响,渗透在 LLM 应用的方方面面。我把相关的点梳理了一遍。
| 方面 | 影响 | 应对 |
|---|---|---|
| 成本 | 按 token 计费, 输入越长越贵 | 精简 prompt, 别塞无关内容 |
| 延迟 | token 越多, 处理越慢 | 控制输入长度, 必要时分块并行 |
| 输出被挤短 | 输入占满窗口, 输出空间不够 | 给输出留 token, 设 max_tokens |
| 中间遗忘 | 长上下文中间信息被忽略 | 关键信息放开头/结尾, 或用 RAG |
| 多轮对话累积 | 历史越攒越长, 迟早超窗(见Agent篇) | 摘要压缩+滑动窗口 |
| 不同模型窗口不同 | 换模型窗口变了, 行为变 | 按目标模型窗口设计, 别假设 |
这张表,让我看到了 token 和窗口的影响远不止"截断"。成本(按 token 计费、输入越长越贵,要精简 prompt);延迟(token 越多越慢);输出被挤短(输入占满窗口、输出空间不够,要给输出留 token);中间遗忘(关键信息放开头/结尾或用 RAG);多轮对话累积(历史越攒越长、迟早超窗,要摘要压缩+滑动窗口);不同模型窗口不同(换模型行为会变,要按目标模型窗口设计)。它们共同的启示是:token 和上下文窗口,是 LLM 应用里一个贯穿始终、需要时时盘算的核心资源——它同时关联着正确性(别被截断)、成本(别浪费 token)、延迟(别太长)、效果(别中间遗忘)。它给我的最大启发是:做 LLM 应用,要养成一种"token 意识":对你喂给模型的每一段内容,都心里有一杆秤——"它有多少 token?有必要吗?放在哪个位置?会不会超窗?给输出留够了吗?";把"精打细算地经营上下文窗口",当成 LLM 工程的一项基本功。谁能用更少、更精的 token,达到更好的效果,谁就能做出又好又省的 AI 应用。
第六件事:要把长文本喂给模型时,我现在会怎么决策
现在,每当我准备把一段文本喂给大模型,脑子里都会过一遍这张决策图——核心就一问:它的 token 超窗了吗?任务要不要看全文?
这张图的灵魂,是把"估 token"放在了喂给模型之前的第一步。第一步,先估算 token 数;然后判断:token + 预留输出在窗口内吗?——在窗口内,直接喂(但关键信息放开头结尾、防中间遗忘);超窗了,就按任务选策略。按任务:全文总结用 map-reduce/refine、针对性问答用 RAG、要全局且不太大换大窗口模型、冗余多先压缩抽取。无论哪条,都要设 max_tokens 给输出留空间,并用 usage 监控 token、验证没被截断。这套判断,让我喂长文本时,不再"一股脑塞进去、还以为它全看到了"——核心始终是:喂之前先算 token,超窗就分块或检索,别赌它能看全。
我立下的几条规矩
这场"输入被静默截断"的事故,换来了我做 AI 应用时,刻进骨子里的几条铁律:
- 喂长文本前,先估 token。和模型窗口(还要预留输出)对比,超了别直接塞——它会被静默截断、模型看不到。
- 上下文窗口是硬上限,超出的看不到。不是"给多少看多少";静默截断比报错更危险,会让它基于残缺输入瞎答。
- 全文总结用 map-reduce/refine。分块处理覆盖全文,别让后半部分被漏掉。
- 针对性问答用 RAG。只检索喂相关片段,token 省、效果好,还避免中间遗忘。
- 给输出留 token,关键信息放开头结尾。输入别占满窗口;防 lost in the middle。
- 用 usage 监控 token。看 prompt_tokens 是否接近上限,验证没被截断,别活在"它看全了"的错觉里。
- 培养 token 意识。token 关联正确性、成本、延迟、效果;少喂精喂常比多喂全喂更好。
附:一个安全喂长文本的工具函数
把"估 token + 超窗就分块 + 给输出留空间"封装成一个工具,以后喂长文本就不会再被静默截断了:
import tiktoken
class SafeLLM:
def __init__(self, model="gpt-4", window=8000, reserve_output=1000):
self.enc = tiktoken.encoding_for_model(model)
self.window = window
self.reserve = reserve_output # 给输出预留的 token
def count(self, text):
return len(self.enc.encode(text))
def budget_for_input(self, fixed_prompt):
# 输入可用预算 = 窗口 - 输出预留 - 固定提示词的占用
return self.window - self.reserve - self.count(fixed_prompt)
def summarize(self, document):
fixed = "请总结以下文档:\n\n"
budget = self.budget_for_input(fixed)
if self.count(document) <= budget:
return llm.chat(fixed + document) # ✓ 没超, 直接总结
# ✓ 超了: 按 token 预算切块, map-reduce
chunks = self._split_by_tokens(document, budget // 2) # 留余量
partials = [llm.chat(f"总结这段:\n{c}") for c in chunks] # map
combined = "\n".join(partials)
# ✓ 汇总时也要检查是否超窗(分段总结可能仍很长 → 递归处理)
if self.count(combined) > budget:
return self.summarize(combined) # 递归再缩
return llm.chat(f"汇总成总览:\n{combined}") # reduce
def _split_by_tokens(self, text, max_tokens):
# 按 token 数切块(实际应在句子/段落边界切, 别切断语义)
tokens = self.enc.encode(text)
chunks = []
for i in range(0, len(tokens), max_tokens):
chunks.append(self.enc.decode(tokens[i:i+max_tokens]))
return chunks
# 用法: 不管文档多长, 都安全总结, 永远不会被静默截断
summary = SafeLLM(window=8000).summarize(very_long_document)
# 核心: 把"估token+留输出+超窗分块map-reduce+递归缩"封装成工具,
# 让"喂长文本不被截断"成为默认安全行为, 而非每次手动小心。
这个工具,把前面所有的原则,固化成了一个"安全喂长文本"的默认行为。它的几个关键设计:第一,budget_for_input 精确算出输入可用预算——窗口减去输出预留、再减去固定提示词的占用,得到"留给文档的 token 上限";第二,没超就直接喂、超了就按 token 预算切块 map-reduce;第三(易忽略的),汇总时也要检查——各段总结拼起来可能仍然很长、再次超窗,所以要递归处理,直到能塞下为止;第四,切块要在句子/段落边界切,别把一句话从中间切断、破坏语义。这,正是我想用这段工具,留给每一个做 LLM 应用的人的最后一课:"处理长文本不被截断",不该是每次手写时靠自觉去小心的事,而该封装成一个可复用的、默认安全的工具;把"估 token、留输出、超窗分块、递归缩"这些容易遗漏的步骤,内建进你的基础设施,让"正确"成为最省事的默认选项。当处理长文本的安全性,被这样一个工具兜底之后,你就再也不用担心,某个用户上传的超长文档,会让你的模型"看了个开头就开始胡说"了。
写在最后
回头看,这场由"输入超窗被截断"引发的、模型"看不见后半部分"的事故,真正教给我的,是一个比"估 token、分块"本身更深的道理:我们很容易把大模型,想象成一个"无所不知、无所不能、有着无限脑容量"的智者;可实际上,它和我们一样,有着明确的、物理的"认知边界"——它一次能"读进去、并真正读好"的内容,是有限的。我犯的错,本质是对这个"认知边界"缺乏敬畏:我天真地以为"把全部资料都给它,它自然就全懂了",却忘了它和人一样——给一个人一本一千页的书、让他一秒钟内读完并总结,他也只能囫囵吞枣、甚至只翻了前几页。这让我深刻地领悟到:和大模型协作,不是"资料给得越多越好",而是"把恰当、精炼、且在它认知容量之内的信息,在恰当的时候,喂给它";这,和"如何向一个聪明但时间精力有限的人高效汇报",本质上是相通的:抓重点、分层次、别让他在海量信息里淹没。所以,做 LLM 应用,核心能力之一,就是当好模型的"信息策展人":清楚它的边界,替它筛选、组织、精炼信息,把"它真正该看、且看得过来"的内容,不多不少地递到它面前。尊重模型的认知边界,做它的信息策展人——这,是我用一次"输入被截断"的事故,换来的、关于大模型、也关于"如何与智能体高效协作"的、最朴素也最深刻的领悟。如果这篇复盘,能让你在下一次喂长文本前,先估一估 token、想一想"它看得过来吗",那我对着那个"看不见后半部分"的回答熬的这大半天,就值了。
—— 别看了 · 2026