LLM 在长上下文中找答案时,准确率呈U形曲线——开头和结尾最高,中间最低,甚至不如不看任何文档。设计 prompt 或 RAG 链路时应把关键信息放首尾,避免埋中间。
把答案放在长 prompt 的开头,模型能找到。放在末尾,模型能找到。放在中间,准确率就会下降——有时甚至低于同一模型完全不提供文档时的表现。这是一个有正式名称的已被证实的现象,而且它会影响你组织 prompt 的方式。
这项研究的权威参考文献是 Liu、Liu、Lin、Hewitt、Paranjape、Bevilacqua、Petroni 和 Liang 合著的 Lost in the Middle: How Language Models Use Long Contexts,发表于 2024 年 Transactions of the ACL。其实验方法刻意简单:取一个多文档问答任务,固定文档数量,把包含答案的那一篇文档移动到输入中的每一个位置。
他们报告的结果是一条 U 形曲线。当相关文档位于开头或末尾时准确率最高,而在这两者之间时准确率会下降——这种效应出现在不同架构和不同上下文长度的模型上,甚至包括明确为长上下文构建的模型。他们还报告了一个使这成为一个设计问题而非猎奇现象的发现:在某些配置下,将相关信息埋在上下文中间时,性能会低于同一模型的闭卷表现——这意味着检索到的上下文反而是有害的。
第二条更非正式的证据线是 Greg Kamradt 在 2023 年推广的"大海捞针"方法:在填充文本的某个受控深度藏入一句话,提问,然后绘制准确率相对于深度和总长度的图表。这比 TACL 的研究不够严谨,但远更实用,因为你可以用一个下午在自己使用的模型上跑一遍。
transformer 架构中没有任何规定说早期的 token 有什么特别之处。但多种机制共同作用使它们变得特别:
注意力是一个预算固定的竞争。Softmax 权重之和为 1。十个竞争段落各分得可观的权重;一千个段落时,正确段落分得的注意力份额就会缩小,即使其得分不变。
位置编码的外推并不完美。Rotary embeddings 产生的相对距离信号会衰减;当模型通过插值或缩放技巧在超过训练长度的情况下提供服务时,它们正处于该信号最弱的地方运行。
训练数据有长度分布。非常长的序列在预训练中很罕见,因此使用位置 500,000 的能力远不如使用位置 500 的能力熟练。
近因效应和首因效应是习得的。在普通文本中,开头陈述主题,末尾陈述结论。一个学会了权重这些位置的模型,对其训练分布来说是正确行为,而对你的 prompt 来说则是错误行为。
这里没有人曾在你的模型上跑过这个实验,来自其他模型论文的结果不能替代。这是一整个测试框架;它产生的数据是属于你的:
import csv, itertools
NEEDLE = ("The maintenance code for the Rhodes turbine is ATLAS-4471, "
"and it must be entered before any restart.")
QUESTION = "What is the maintenance code for the Rhodes turbine?"
ANSWER = "ATLAS-4471"
filler = open("haystack.txt", encoding="utf-8").read() # any long neutral text
def build(depth_pct, length_tokens, tok):
ids = tok.encode(filler, add_special_tokens=False)[:length_tokens]
cut = int(len(ids) * depth_pct / 100)
head, tail = tok.decode(ids[:cut]), tok.decode(ids[cut:])
return head + "\n\n" + NEEDLE + "\n\n" + tail
rows = []
for length, depth in itertools.product([4000, 16000, 64000, 128000],
[0, 10, 25, 50, 75, 90, 100]):
ctx = build(depth, length, tok)
out = call_model(system="Answer using only the context.",
user=ctx + "\n\nQuestion: " + QUESTION)
rows.append((length, depth, int(ANSWER in out)))
with open("needle.csv", "w", newline="") as f:
csv.writer(f).writerows([("length", "depth_pct", "hit"), *rows])
四个细节决定结果是否有意义。每次多跑几遍并记录命中率,而不是一次二元结果。使用与针主题无关的填充物,否则你测的是抗干扰能力而非位置效应。在不同轮次中变换针的内容,因为像虚构代码这样容易被记住的字符串比一个合理的句子更容易被发现。而且在每个深度都保持问题完全相同——一次改写 prompt 就会使比较无效。
你要找的不是分数,而是拐点:中间深度不再可靠的长度。那个长度才是真正的设计约束,它通常远低于标称的窗口大小。绘制命中率相对于深度的图,每条线代表一个长度。一组平的线意味着在这些规模下位置不是你的问题。在中间下坠、且随着长度增加下坠得更厉害的线是经典形态,它会告诉你 ordering 在变得重要之前你还有多少空间。
注意一个使更多这类自制实验失效的混淆因素:问题的位置。如果你的 prompt 把指令放在前面、上下文放在后面,那么把针移动到末尾也同时把它移向了"无所谓什么"的附近,而如果指令放在最后,最深的针就紧邻着问题。这两种布局用同一模型会产生不同的曲线。先固定布局,说明你用的是哪一种,然后只在同一布局内比较。
关于解读的一条警示:合成针是最简单的检索任务。真实问题需要跨多个段落综合,所以一个在 128k 通过针测试的模型只证明了地板,而非天花板。那个差距就是有效上下文长度的意义所在。
如果任务描述出现在 100,000 token 上下文之前,它就在和所有这些 token 竞争。在文档之后重新陈述它只需多花几十个 token,这是可用的最高杠杆的单点改动。
如果你已对检索到的段落进行了重排,不要按排名顺序排列它们——把最强的放在开头,第二强的放在末尾,让弱的占据中间,因为那里它们本就处于被低注意力的状态。
这篇论文最可操作的启示是:更多上下文并非单调更好。一个更严格的重排器返回五段可以击败返回五十段的重排器。
对语料库的两半各做一次调用,每次用短上下文,然后再做一次调和调用,往往既更准确又更便宜,比一次超大型调用更好。
有效上下文长度 vs 标称上下文长度
100 万 Token 上下文窗口实际上适合做什么
当 Prompt 装不下时的截断策略