2024 年我做一个文档分析功能,要对用户上传的长文档——合同、报告、论文——做摘要和问答。第一版我做得很省事:把文档塞进 prompt,太长?那就截断到模型上下文吃得下的长度。本地测了几篇——真不错:我手头那几份文档都不算长,摘要质量也挺好。我心里很踏实:"长文档嘛,截断到模型吃得下,塞进去让它自己消化,不就行了。"可等这个功能真正上线、跑起真实的文档流量,一串问题冒了出来。第一种最先把我打懵:一个用户上传了一份几十页的报告,我截断到 8K token 塞了进去,模型给了一段读着很流畅的摘要——可摘要里完全没提报告最后那部分的关键结论,因为那部分早被我截掉了;更要命的是,模型对"自己只读了开头"这件事毫不知情,照样自信地宣称这是一份"完整摘要"。第二种最直接:有些文档我没截,直接整篇塞,模型当场报错"超出上下文长度",请求直接失败。第三种最磨人:后来我学乖了,把长文档切成好几块,每块单独让模型摘要,再把摘要拼起来——结果拼出来的东西读着支离破碎:第三块的摘要里写着"如前所述的那个方案",可"那个方案"在第一块里,第三块根本不知道它是什么。第四种最隐蔽:文档一长,我天真地一块一块顺序调用模型,一份大文档要干等几十秒;而且每块都带着完整的指令重复发,token 成本翻了好几倍。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"长文档就截断到模型吃得下的长度,或者整篇塞进去让模型自己消化"。这句话把"处理一份长文档"想成了"想办法把它塞进窗口"。可它不是。模型的上下文窗口是一个硬性的物理上限。一份超过它的文档,你既不能整篇塞——会直接报错;也不能直接截断——被丢掉的部分会让答案错得悄无声息,而模型还浑然不觉。"处理一份长文档"这件事,本质不是"把它塞进窗口",而是一套需要设计的策略:把文档切成放得进窗口的块,对每一块分别调用模型,再用某种方式把分块的结果合并成一个完整的答案。业界把这套策略归纳成三种基本范式——Stuff(短文档直接塞)、Map-Reduce(每块独立处理再归并)、Refine(带着已有结论顺序迭代),它们各有各的代价与适用场景。真正处理好长文本,核心不是"把文档截到塞得下",而是理解上下文窗口是硬上限、按 token 把文档分块、根据任务在 Stuff / Map-Reduce / Refine 之间选对策略、把分块的结果正确地归并。这篇文章就把大模型的长文本处理梳理一遍:为什么"截断了塞进去"是错的、Stuff 怎么用、Map-Reduce 怎么分块归并、Refine 怎么顺序迭代、三种策略怎么选,以及分块边界、并发、归并丢上下文、token 预算这些把长文本处理真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一套"长文档截断了塞进去"的文档分析功能,上线后冒出一串问题:几十页的报告被截断到 8K,摘要漏掉了后半段的关键结论,模型却照样宣称"完整";有些文档整篇塞,直接报"超出上下文长度";分块各自摘要再拼起来,读着支离破碎、跨块的指代全断了;一块一块顺序调用,慢得要等几十秒,token 成本还翻了几倍。
我当时的错误认知:"长文档就截断到模型吃得下的长度,或者整篇塞进去让模型自己消化。"
真相:这个认知错在它把长文本处理,当成了一道"塞进去"的体力活——好像只要想办法把文字挤进那个窗口,模型就能处理。可上下文窗口是一个不容商量的硬上限:它是模型一次能处理的 token 总量的物理天花板,超过它,要么报错(整篇塞),要么你得自己动手砍掉一部分(截断)。而截断的真正可怕之处,不在于"丢了信息",而在于"丢得无声无息":模型只会处理你实际喂给它的那段文本,它没有任何办法知道这段文本只是一份大文档的开头——于是它会对着半份文档,给出一个语气上无比完整、自信的答案。你拿到的不是一个"报错",而是一个"看起来对、其实漏了一半的答案",这比报错危险得多。所以,处理长文档的正确思路从一开始就不该是"塞",而该是"分而治之":承认单次调用放不下,就把文档切成放得进窗口的块,逐块调用模型,再把结果合起来。而"怎么切、怎么合",就是 Stuff、Map-Reduce、Refine 这三种策略要回答的问题。它们的区别,本质是在"速度、成本、跨块连贯性"这三者之间,做不同的取舍。
要把长文本处理做对,需要几块认知:
- 为什么"截断了塞进去"是错的——上下文窗口是硬上限,截断会无声地丢信息;
- Stuff——文档放得进窗口时,整篇直接塞,最简单也最准;
- Map-Reduce——分块独立处理(Map),再归并(Reduce),块间可并发;
- Refine——顺序读每块,带着已有结论迭代,跨块上下文不丢;
- 策略选型、分块边界、并发、归并丢上下文这些工程坑怎么处理。
一、为什么"截断了塞进去"是错的
先把这件最根本的事钉死:上下文窗口,是模型这个"读者"一次能装进眼里的全部内容,它是一个硬性的、物理的上限。一份文档比这个窗口长,你面对的不是"挤一挤的问题",而是一个"它根本进不来"的事实。这时候摆在你面前只有两条错路和一条正路。错路一,整篇塞——模型直接拒收,报错。错路二,截断——你自己动手把文档砍到窗口大小,塞进去。截断看起来"至少能跑通",但它藏着一个最阴险的陷阱:模型只对你喂进去的那段文本负责,它无从知道这段文本是不是完整的。它会对着半份文档,给你一个语气上斩钉截铁的"完整答案"。你以为你拿到了一份报告的摘要,其实你拿到的是"这份报告前三分之一"的摘要——而没有任何信号告诉你这件事。正路只有一条:承认它进不来,把它拆开,分批处理,再合起来。
下面这段代码,就是我那个"一遇长文档就出事"的第一版:
# 反面教材:文档太长就硬截断到模型吃得下的长度
def summarize_long_doc(doc_text, model_limit=8000):
# 想当然:截断到上下文长度以内,塞进去就行
truncated = doc_text[:model_limit]
prompt = f"请总结下面这份文档:\n\n{truncated}"
return call_llm(prompt)
# 破绽一:被截掉的后半部分,模型根本没看到。
# 破绽二:模型不知道自己只读了开头,照样自信地给出"完整摘要"。
# 破绽三:model_limit 是 token 数,doc_text[:8000] 切的却是字符数。
这段代码在本地测几篇时表现不错,因为我手头挑的那几份文档,要么本来就短、压根没触发截断,要么核心结论恰好都在开头、被截掉的尾巴无关紧要——它的缺陷,被"样本太短、太友好"掩盖了。它的问题不在某一行代码上——截断、拼 prompt、调模型都没写错——而在一个被忽略的前提:它默认"一份文档,总能用某种方式塞进一次调用里"。可真实世界的文档长度没有上限。于是那串问题就有了解释:摘要漏了关键结论,是因为关键结论在被截掉的后半段,而模型对此一无所知;整篇塞报错,是因为文档 token 数实实在在地超过了窗口,这是物理限制,不是能"商量"的;截断按字符切还会偏,是因为 doc_text[:8000] 砍的是字符,而窗口限制的是 token,两者根本不是一回事。问题的根子清楚了:处理长文本的工程量,全在"承认文档进不来、必须分而治之"之后——你不肯拆它,它就只能被你截得残缺不全。先从最简单的情形——文档其实放得下——说起。
二、Stuff:短文档直接塞,但要知道它的天花板
不是所有文档都超长。很多时候,一份文档加上指令、再留出输出的余量,本来就放得进上下文窗口。这种情况,最好的策略恰恰是"什么都不做"——整篇直接塞进去,这就是 Stuff(填充)策略。它之所以最好,不是因为它简单,而是因为它没有任何信息损耗:模型一次性看到了文档的全部,前后文都在,不存在"分块"带来的任何割裂。
# Stuff 策略:文档放得进窗口,就整篇直接塞 —— 最简单,也最准
def summarize_stuff(doc_text):
prompt = (
"请阅读下面这份完整的文档,用中文写一段 200 字以内的摘要,"
"覆盖它的核心结论。\n\n文档内容:\n" + doc_text
)
return call_llm(prompt)
# 适用前提:整份文档 + 指令 + 预期输出,加起来不超过上下文窗口。
# 它没有任何"分块-归并"的信息损耗,放得下时,永远优先用它。
但用 Stuff 有一个绝不能省的前提判断:你得先按 token 算清楚,这份文档到底放不放得下。而且这个"放得下"不是"文档 token 数 ≤ 窗口"那么简单——你还得给指令、给模型的输出,都预留出空间:
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o-mini")
def count_tokens(text):
return len(enc.encode(text))
def need_split(doc_text, context_window=128000, reserve=4000):
"""判断一份文档要不要分块:留出给指令和输出的余量后,还放得下吗。"""
doc_tokens = count_tokens(doc_text)
budget = context_window - reserve # 窗口减去预留,才是文档可用额度
return doc_tokens, doc_tokens > budget # 第二个返回值:是否需要分块
这里的认知要点是:Stuff 不是"低级"策略,而是"首选"策略。后面要讲的 Map-Reduce 和 Refine,本质都是"文档放不下时的无奈之举"——它们用"分块"换来了"能处理超长文档"的能力,但每一次分块,都是一次信息的切割,都可能在块与块的接缝处丢掉点什么。所以工程上的正确顺序是:先用 token 数判断文档放不放得下;放得下,毫不犹豫地用 Stuff,享受它零损耗的好处;只有当它真的放不下,才退而求其次去分块。永远不要因为"文档看起来挺长"就条件反射地分块——先数一数,也许它根本放得下。那如果数完发现真的放不下呢?这就轮到第一种分块策略——Map-Reduce。
三、Map-Reduce:分块独立处理,再归并
当一份文档确实超过了窗口,最直接的分治思路是 Map-Reduce。它分两步。Map(映射):把文档切成若干能放进窗口的块,对每一块独立地调用一次模型,得到一个"局部结果"(比如这一块的摘要)。Reduce(归并):再调用一次模型,把所有局部结果合并成一个最终答案。第一步是分块,要按 token 切,而且相邻块之间留一点重叠,避免在句子中间一刀两断:
# 按 token 把长文档切成块,块之间留一点重叠,避免在句子中间切断
def split_into_chunks(doc_text, chunk_tokens=3000, overlap_tokens=200):
"""把文档切成若干 token 数不超过 chunk_tokens 的块。"""
ids = enc.encode(doc_text)
chunks = []
start = 0
while start < len(ids):
end = start + chunk_tokens
chunk_ids = ids[start:end]
chunks.append(enc.decode(chunk_ids))
if end >= len(ids):
break
start = end - overlap_tokens # 回退 overlap,让相邻块有重叠
return chunks
Map 步骤,就是对每一块,各自调用一次模型。关键在于:这一步处理每块时,明确告诉模型"这只是一部分",并要求它只基于这部分内容、不要编造:
# Map 步骤:对每一个块,独立调用模型做一次"局部处理"
def map_step(chunks):
"""对每块文档独立摘要,得到一组"局部摘要"。"""
partials = []
for i, chunk in enumerate(chunks):
prompt = (
f"这是一份长文档的第 {i + 1}/{len(chunks)} 部分。"
"请用中文摘要这一部分的要点,只基于这部分内容,不要编造:\n\n"
+ chunk
)
partials.append(call_llm(prompt))
return partials
Reduce 步骤,是把这一组局部摘要,再交给模型合并一次——这一步要求模型整合、去重、产出一个连贯的整体:
# Reduce 步骤:把所有局部摘要,归并成一个完整的最终摘要
def reduce_step(partials):
"""把 map 得到的多个局部摘要,合并成一个连贯的整体摘要。"""
joined = "\n\n".join(
f"【第 {i + 1} 部分摘要】\n{p}" for i, p in enumerate(partials)
)
prompt = (
"下面是一份长文档各个部分的摘要。请把它们整合成一段"
"连贯、不重复的整体摘要,用中文,300 字以内:\n\n" + joined
)
return call_llm(prompt)
这里的认知要点是:Map-Reduce 的精髓,是"分治"——把一个放不下的大问题,拆成一堆放得下的小问题,各自解决,再汇总。它最大的优点,是 Map 阶段的各个块彼此独立,谁也不依赖谁,因此可以并发处理,速度很快。但它也有一个与生俱来的代价:Map 时,每一块都是被孤立地处理的,模型看第三块时,完全不知道第一块讲了什么。所以,凡是"跨块的关联"——前文提出一个概念、后文引用它;某个结论需要综合全文才能得出——Map-Reduce 都容易在接缝处丢掉。它适合那种"各部分相对独立、可以分别总结"的任务,不适合那种"必须通读全文才能回答"的任务。那如果任务恰恰需要"通读全文的连贯性"呢?这就是 Refine 要解决的。
四、Refine:带着已有结论,顺序迭代
开头第三个问题——"分块摘要拼起来支离破碎、跨块指代全断了"——根子就在 Map-Reduce 的 Map 阶段,每块是被孤立处理的。Refine(精炼)策略,就是冲着这个问题来的。它不并发,而是顺序地处理每一块:用第一块得到一个初步结论;然后读第二块时,把"第一块得出的结论"一起带上,让模型在已有结论的基础上修订、补充;再读第三块时,带上"前两块综合出的结论"……如此滚动下去,结论像滚雪球一样一路携带着全文的上下文:
# Refine 策略:顺序读每一块,每次都带着"已有的结论"去读下一块
def summarize_refine(chunks):
"""顺序迭代:用第一块得到初稿,之后每块都在前一版基础上修订。"""
prompt = "请用中文摘要下面这部分文档:\n\n" + chunks[0]
summary = call_llm(prompt) # 第一块:得到初稿
for i, chunk in enumerate(chunks[1:], start=2):
prompt = (
f"已有的摘要是:\n{summary}\n\n"
f"下面是文档的第 {i} 部分,请在已有摘要的基础上,"
"补充或修订它,输出一份新的完整摘要:\n\n" + chunk
)
summary = call_llm(prompt) # 带着上下文滚动更新
return summary
Refine 用一个明确的代价,换来了 Map-Reduce 给不了的东西。它的代价是:每一步都依赖上一步的输出,因此天然无法并发,只能一块接一块串行地跑——文档分成十块,就要老老实实地等十次调用顺序完成。它换来的是:处理每一块时,模型手里都攥着"到目前为止全文的结论",所以跨块的指代、需要前后呼应的逻辑,都能被接住。这里的认知要点是:Refine 和 Map-Reduce,是"连贯性"和"速度"之间的一次正面取舍。Map-Reduce 用"块间独立"换来了"可并发的高速度",代价是丢掉跨块上下文;Refine 用"串行迭代"保住了"全程连贯的上下文",代价是慢。没有哪个绝对更好——你要回答的问题是:你这个任务,到底更怕"慢",还是更怕"前后断裂"?一份各章节相对独立的报告做摘要,丢一点跨章关联无伤大雅,选 Map-Reduce;一份逻辑层层递进的论证、一份需要综合全文才能下结论的文档,连贯性是命根子,就得选 Refine,慢也认。三种策略都讲完了,接下来要解决的就是:具体一份文档来了,到底用哪个?
五、策略选型:三种策略,什么时候用哪个
Stuff、Map-Reduce、Refine,不是三个谁更高级的选项,而是三个对应不同场景的工具。选错了,要么慢、要么贵、要么答案断裂。选型其实只需要问两个问题。第一个:文档放得进窗口吗?放得下,直接 Stuff,没有悬念。第二个,只在放不下时才问:这个任务,需要跨块的全局连贯性吗?不需要(各块可独立处理)——Map-Reduce,图它快;需要(结论必须前后呼应)——Refine,图它连贯。把这个决策写成一个函数:
# 策略选择器:根据文档长度和任务类型,自动挑一种策略
def choose_strategy(doc_text, task_needs_global_context=False,
context_window=128000):
doc_tokens, too_long = need_split(doc_text, context_window)
if not too_long:
return "stuff" # 放得下,直接塞,最准
if task_needs_global_context:
return "refine" # 放不下且要全局连贯,顺序迭代
return "map_reduce" # 放不下但各块可独立处理,分治
下面这张图,把这个选型决策完整画出来:
选型还有一个绕不开的维度:成本。三种策略的调用次数差别巨大,选型前最好先算一笔账:
# 三种策略的调用次数差别很大,选型前先算一笔账
def estimate_calls(doc_text, strategy, chunk_tokens=3000):
doc_tokens = count_tokens(doc_text)
n_chunks = max(1, -(-doc_tokens // chunk_tokens)) # 向上取整得到块数
if strategy == "stuff":
return 1 # 1 次调用搞定
if strategy == "map_reduce":
return n_chunks + 1 # 每块 1 次 map,外加 1 次 reduce
if strategy == "refine":
return n_chunks # 顺序 n 次,且无法并发
raise ValueError(strategy)
这里的认知要点是:选型的两个问题,顺序不能乱:先问"放不放得下",再问"要不要连贯"。第一个问题用 token 数客观地回答,没有模糊空间;第二个问题取决于你的任务性质,需要你自己判断。还要把"成本"这第三个维度叠进来一起看:Stuff 永远是 1 次调用,最便宜;Map-Reduce 是"块数 + 1"次,但能并发,所以"贵但快";Refine 是"块数"次,且只能串行,所以"较省调用但最慢"。一份超长文档用 Refine,可能要串行等上十几次调用——选型时,你权衡的从来不只是"答案准不准",还有"用户愿不愿意等""这一次处理你出得起多少钱"。主干都讲完了,最后是几个真正处理起长文档才会撞见的工程坑。
六、工程坑:分块边界、并发、归并丢上下文与 token 预算
三种策略之外,还有几个工程坑,不处理就会让你要么切坏文档、要么慢得离谱、要么归并出一团糨糊。坑 1:分块别在"语义中间"一刀切,优先按自然边界切。前面 split_into_chunks 按固定 token 数硬切,简单,但很可能把一个句子、一个段落从中间劈开,切出来的块读着就别扭。更好的做法是先按段落这种自然边界切,再把段落贪心地装进不超限的块里:
# 改进分块:优先在段落的自然边界切,别在词语中间一刀两断
import re
def split_on_boundary(doc_text, chunk_tokens=3000):
"""先按空行把文档拆成段落,再把段落贪心地装进不超限的块。"""
paragraphs = re.split(r"\n\s*\n", doc_text)
chunks, current, current_tokens = [], [], 0
for para in paragraphs:
pt = count_tokens(para)
if current_tokens + pt > chunk_tokens and current:
chunks.append("\n\n".join(current)) # 当前块满了,先收起来
current, current_tokens = [], 0
current.append(para)
current_tokens += pt
if current:
chunks.append("\n\n".join(current)) # 收尾:最后一块
return chunks
坑 2:Map 阶段一定要并发,别一块一块串行等。Map-Reduce 的各个块互相独立,顺序调用是白白浪费时间——十块就要等十次。正确做法是并发发起,用一个信号量控制同时在飞的请求数,避免一次性把下游打爆:
import asyncio
# 并发 Map:各块互相独立,没有理由一块一块串行等
async def map_step_parallel(chunks, concurrency=5):
"""并发地对每块做局部处理,用信号量限制同时在飞的请求数。"""
sem = asyncio.Semaphore(concurrency)
async def one(i, chunk):
async with sem:
prompt = (f"这是长文档第 {i + 1}/{len(chunks)} 部分,"
"请摘要其要点:\n\n" + chunk)
return await call_llm_async(prompt)
return await asyncio.gather(*(one(i, c) for i, c in enumerate(chunks)))
# 注意:Map 能并发,Reduce 是它之后的一步;Refine 全程串行,无法并发。
坑 3:Map-Reduce 的归并,不只是"把局部结果拼起来"。有人 Reduce 时简单地把各块摘要用换行连起来就完事——这不叫归并,叫堆砌:结果会重复、冗余、缺少整体逻辑。Reduce 必须真的再过一次模型,让它去重、串联、提炼成一个连贯整体(就像第三节 reduce_step 做的那样)。如果局部结果本身又太多、多到一次 Reduce 也放不下,就得做多层归并:先两两(或几个几个)归并,再把归并的结果继续归并,像一棵树一样收口。坑 4:每块的 prompt 开销别忽略。分块处理时,每一块都要带上一遍指令(那段"请摘要……")。块数一多,这段指令就被重复发了 N 遍,token 成本不容小觑——指令能写多精简就写多精简,把字数留给真正的文档内容。坑 5:别忘了给"分块本身"留 token 余量。设 chunk_tokens 时,记得每块还要拼上指令、Refine 还要拼上"已有摘要"——这些都占 token。chunk_tokens 要明显小于窗口,给指令和输出留足空间,否则块"看着没超",拼上指令一调用照样报上下文超限。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 上下文窗口 | 模型一次能处理的 token 上限,长文档处理的硬约束 |
| 硬截断 | 把超长文本切到窗口内,会悄无声息丢掉被截部分 |
| Stuff | 文档放得进窗口时整篇直接塞,最简单也最准确 |
| Map-Reduce | 分块独立处理(Map)再归并(Reduce),块间可并发 |
| Refine | 顺序读每块,带着已有结论迭代修订,跨块上下文不丢 |
| 分块 chunk | 把长文档按 token 切成放得进窗口的小块 |
| 块重叠 overlap | 相邻块共享一段内容,避免在边界切断语义 |
| Map 并发 | 各块互相独立,可并发调用,大幅缩短总耗时 |
| 归并丢上下文 | Map 各块独立处理,跨块的引用与关联会丢失 |
| 策略选型 | 按文档长度与是否需要全局连贯,选 stuff/map-reduce/refine |
避坑清单
- 长文档别硬截断,被截掉的部分会让答案错得悄无声息。
- 文档放得进窗口就用 Stuff,它没有分块归并的信息损耗。
- 判断要不要分块按 token 算,且要留出指令和输出的余量。
- 分块按 token 切不按字符切,字符切会和真实 token 数对不上。
- 相邻块留一点重叠,避免在句子或语义中间一刀切断。
- 优先在段落、句子的自然边界分块,别在词语中间切。
- Map 各块互相独立,务必并发调用,别一块一块串行等。
- Map-Reduce 会丢跨块上下文,需要全局连贯时改用 Refine。
- Refine 只能串行、较慢,但跨块结论连贯,按任务取舍。
- 选型前先估调用次数:Stuff 1 次、Map-Reduce N+1 次、Refine N 次。
总结
回头看那串"摘要漏了关键结论、整篇塞报错、分块拼起来支离破碎、串行调用慢得要命"的问题,以及我后来在长文本上接连踩的坑,最该记住的不是某一个分块函数的写法,而是我动手前那个想当然的判断——"长文档就截断到模型吃得下,或者整篇塞进去让它自己消化"。这句话错在它把长文本处理,当成了一道"塞进去"的力气活。我以为只要想办法把文字挤进那个窗口,模型就能消化。可我忽略了一件事:上下文窗口是一个不容商量的硬上限。一份超过它的文档,塞,会被拒收;截,会被悄悄丢掉一半而模型浑然不觉。处理长文档,从第一步起就不该想"怎么塞",而该想"怎么拆、怎么合"——它不是一个体力问题,是一个需要设计的策略问题。
所以处理好长文本,真正的工程量不在"把文档塞进 prompt"那几行代码上。那几行,谁都会写。真正的工程量,在于你要承认"文档可能根本放不进一次调用",并据此为它设计一套分而治之的策略:文档放得下,你就用 Stuff,享受零损耗;放不下而各块可独立,你就用 Map-Reduce,分块并发再归并;放不下而结论要连贯,你就用 Refine,带着已有结论顺序迭代;分块时,你就按 token、按自然边界切,还要留出重叠;归并时,你就真的再过一次模型去重串联,而不是把局部结果堆在一起。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"截断了塞进去"为什么错,再讲 Stuff 怎么用、Map-Reduce 怎么分块归并、Refine 怎么顺序迭代、三种策略怎么选,最后是分块边界、并发、归并、token 预算这几个把长文本处理守扎实的工程细节。
你会发现,大模型的长文本处理,和现实里"把一本厚书读懂、再讲给别人听"完全相通。一本几百页的厚书,你没法一眼把它全看进眼里——你的"上下文窗口"也是有限的。一个偷懒的人会怎么做?他只翻了前三章就合上书,写下一篇语气十足的"全书读后感"(这就是硬截断——读了开头,却假装读懂了全部)。而一个会读书的人有几种办法。如果是一本薄薄的小册子,他一口气从头读到尾,再讲(这就是 Stuff)。如果是本厚书、又赶时间,他可以找几个人,一人分读一章、各写一份章节摘要,最后他把这些摘要汇总成全书概要——快,但分读第三章的人不知道第一章讲了什么,跨章的呼应容易丢(这就是 Map-Reduce)。如果这本书逻辑层层递进、前后紧密咬合,他就只能自己一个人从第一章读到最后一章,手边放个笔记本,每读完一章就把笔记上的总结修订一遍——慢,但他笔记里的结论,始终带着已经读过的全部内容(这就是 Refine)。同样一本书、同样要读懂讲出来,可偷懒的人交出的是一篇漏了大半的假读后感,会读书的人交出的是一份真正完整的概要——差别不在书的厚薄,只在他认不认"这本书一眼看不完,得有策略地拆着读"这件事。
最后想说,长文本处理做没做对,差距永远不会在"本地测几篇短文档、看着都挺好"时暴露——本地你挑的文档要么本来就短、没触发截断,要么核心内容恰好在开头,被丢掉的尾巴无关紧要,缺陷被"样本太友好"掩盖了,你会觉得"截断了塞进去"已经够用。它只在真实的、用户上传几十页的报告、上百页的合同、逻辑环环相扣的论文涌进来时才显形。那时候它会用最让人尴尬的方式给你结账:做不好,你的摘要会信誓旦旦地漏掉关键结论,你的请求会因为整篇塞而成片报错,你的分块结果会拼成一团读不通的糨糊;而做对了,放得下的文档你一次调用零损耗地处理掉,放不下的文档你分块并发、快速归并,需要连贯的文档你顺序迭代、前后严丝合缝。所以别等"用户拿着一份漏了一半的摘要来质问你"那一刻找上门,在你写下每一处"把文档交给模型"的代码时就该想清楚:这份文档放得进窗口吗——它需要分块吗、该用哪种策略、分块切在哪里、归并够不够连贯,这一道道工序,我是不是都替它设计过了?这些问题有了答案,你写下的才不只是一段"短文档能跑"的调用,而是一套再长的文档也处理得完整、可靠、经得起真实文档流量考验的长文本系统。
—— 别看了 · 2026