迷失在中间:找出你的大模型(LLM)上下文「盲区」,并在调用前修好它

Dev.to ML 2026-10-23T03:37:02.927227

《Lost in the Middle》论文里有个结果,应该改变你搭建 RAG(检索增强生成)提示词的方式。研究者给 GPT-3.5-Turbo 输入 20 篇检索到的文档,并把答案放在中间位置,结果它得分比一篇文档都不给还低——凭记忆回答,反而赢过了带着文档回答。在这里,额外的上下文把答案搞砸了。

Liu 等人(斯坦福、UC 伯克利、Samaya AI,2023)测试了多文档问答和键值检索:把相关段落从第一个位置挪到最后一个位置,观察准确率的变化。结果是一条 U 形曲线——两端表现很强,中间掉 20 多分。新模型把这条曲线压平了不少,但并没有抹掉。除非你亲自测一遍,否则你不会知道自己的模型面对你这种提示词结构时究竟怎么表现。

这篇文章讲三件你今天就能加进流水线的事:一个测出你自己「死区」的测试,一个把重要片段挪出死区的重排步骤,以及一道在发送前拦下超大提示词的预检关卡。

你的上下文窗口是一份预算

200K 的窗口是模型能接受的上限,不是你必须填满的额度。每加一个 token 都要花钱、增加延迟,还要和真正重要的 token 抢注意力。

最容易被忽略的就是这个注意力成本。开发者常把窗口当成水桶:装得下就发。3000 行的文件装得下,15 个 RAG 片段装得下,40 轮 agent 历史装得下。模型照单全收,一声不吭,然后在你唯一在意的那件事上表现变差。不会有报错提醒你「你的指令被埋在 61000 号 token 那里」,你只会拿到一个无视它的答案。

第一步:测出你自己的死区

别拿 2023 年的图去套 2026 年的模型。用你真实的提示词结构跑一次「大海捞针」测试,大概十分钟、几美元就够。

import anthropic, random

client = anthropic.Anthropic()
MODEL = "claude-sonnet-5-5"
NEEDLE = "The deploy freeze for the payments service ends on March 14."
QUESTION = "When does the deploy freeze for the payments service end?"

def build_haystack(filler_chunks, needle, position):
    """position runs from 0.0 (start) to 1.0 (end).
idx = int(position * len(filler_chunks))
chunks = filler_chunks[:idx] + [needle] + filler_chunks[idx:]
return "\n\n".join(chunks)

def run(filler_chunks, positions=(0.0, 0.25, 0.5, 0.75, 1.0), trials=5):
    results = {}
    for pos in positions:
        hits = 0
        for _ in range(trials):
            random.shuffle(filler_chunks)
            doc = build_haystack(filler_chunks, NEEDLE, pos)
            resp = client.messages.create(
                model=MODEL,
                max_tokens=50,
                messages=[{
                    "role": "user",
                    "content": f"{doc}\n\n{QUESTION} Answer in one short phrase.",
                }],
            )
            if "march 14" in resp.content[0].text.lower():
                hits += 1
        results[pos] = hits / trials
    return results

填充内容要用真实的材料:你自己的文档、旧工单,随便什么生产环境里实际会出现的内容都行。用 lorem ipsum(乱数假文)的话,那根“针”太容易辨认了。真正有说服力的测试,得用一根和周围文本看起来差不多的针。

有一点要提醒:只捞回一个字符串,这属于简单情况。如果你的任务需要模型把中间的一个事实和结尾附近的某个信息组合起来,那测出来的数字会比这个测试更难看。

第二步:把最好的内容块放在两头

检索器按相关度排出内容块之后,大多数流水线就按排名顺序把它们拼起来。第 1 块落在最前面,这没问题。第 2 到第 5 块掉进中间,这就糟了。而最不相关的几块反而待在末尾,紧挨着问题。把这个顺序倒过来。交替放置,让最强的块占据两头,最弱的填中间:

def edge_order(chunks_by_relevance):
    """输入:最相关的排在最前。输出:最好的在两端,最差的在中间。"""
    front, back = [], []
    for i, chunk in enumerate(chunks_by_relevance):
        (front if i % 2 == 0 else back).append(chunk)
    return front + back[::-1]

# 排名 [1, 2, 3, 4, 5, 6] -> [1, 3, 5, 6, 4, 2]

不想自己维护的话,LangChain 已经内置了这个功能,叫 LongContextReorder,在 langchain_community.document_transformers 里。不管哪种方式,都只是几行代码的事。

还有两条几乎零成本的摆放规则:

把问题放在文档后面。提示词末尾是你手里第二好的位置。

在长时间运行的 agent 里,每一轮快结束时都重复一遍核心指令。别指望模型过了四十条消息还记得系统提示词。

第三步:在请求发出前就拦住

重排有用。但更管用的是:干脆别把 120K token 的边缘上下文发出去。

最简单的修法是发请求前先做一次检查:数一下 token,超预算就裁剪,或者直接报一个清楚的错。

MAX_INPUT_TOKENS = 30_000  # 这个值来自第一步的结果,不是模型卡上的数字

class ContextBudgetExceeded(Exception):
    pass

def guarded_create(client, *, model, messages, system=None, **kwargs):
    params = {"model": model, "messages": messages}
    if system:
        params["system"] = system

    n = client.messages.count_tokens(**params).input_tokens
    if n > MAX_INPUT_TOKENS:
        raise ContextBudgetExceeded(
            f"{n} input tokens > {MAX_INPUT_TOKENS} budget; trim retrieval or history"
        )

    return client.messages.create(**params, **kwargs)

MAX_INPUT_TOKENS 该设多少?用你自己的「大海捞针」曲线来定。如果提示词中间位置的准确率到 30K token 还稳得住,到 80K 就开始掉,那你实际能用的窗口就是 30K——不管价格页上写的是多少。

拦截一旦触发,先丢掉排序最低的片段,再把 agent 早期的轮次压缩成一小段状态摘要。如果是代码场景,只发你正在改的那个函数加上直接调用它的代码,别把整个文件塞进去。给模型看 400 行相关代码,通常比看 3000 行、类定义还埋在中间的代码效果更好。

同一道闸也能压住账单

这道闸不光提高准确率,还能省钱。上下文膨胀是 agent 成本失控的常见原因。如果 agent 的循环把每个工具结果都追加进历史,提示词就会随每一步不断变长。到第 30 步,成本可能是第 1 步的好几倍,而且到那时候模型的推理质量还更差。

粗略算一笔:跑 30 步,每步输入增加 4K token,单个任务下来大约是 186 万输入 token。

token 上限管的是准确率问题。美元上限管的是成本问题。我为后者写了 baar-core。

它是一个开源 Python 库,给每次 LLM 调用设定硬性预算,在还没联系服务商之前就返回 402(HTTP 的「需要付费」状态码)。它以原子方式预扣花费:要么全部成功,要么完全不做,不会被并发打断;因此二十个并行 agent 即使各自检查时都在预算之内,也不会合起来超出预算上限。安装:pip install baar-core。

面向团队的 noburn.dev 在此基础上做了扩展:用户一旦超出预算,其 API 调用会在发出前被拦下。两项检查都放在同一位置,即 create() 之前;此时你还能阻止这次调用。

标称窗口能告诉你什么

模型卡上标注的数字,只说明模型最多能接收多少内容,并不说明它能把多少内容用好。这中间的差距,需要针对每个模型、每项任务、每种提示词形态分别测量。而且服务商每发布一个新版本,差距就可能随之变化,所以每次升级后都要重新运行「大海捞针」测试(needle test:把关键信息藏在长文本里,看模型能不能找出来)。

标称窗口就像汽车的最高时速:知道这个数字有用,但那不是你该开的车速。

你测量过自己流水线的有效上下文长度吗?它和规格表上的数字相差多少?

查看原文