长上下文不是免费仓库:为什么塞得越满,模型答得越差

上下文窗口决定模型一次能读多少 token,但读得多不等于答得准。模型对上下文的利用并不均匀:关键信息落在中段时最容易被忽略,无关内容一多,连简单任务也会跟着退化。最常见的误区是把窗口容量当成可用容量,用 Needle in a Haystack 的满分推断长文档问答没问题,或者以为 few-shot 示例越多越好。更工程的一面是缓存:前缀一变,后面的 token 全部要重算。读完你能说清注意力预算、位置偏差与干扰项的真实代价,并学会用静态前缀加动态后缀组织上下文,在准确率、延迟与成本之间做取舍。

是什么

Prompt 工程常被当成"把话说得漂亮",但模型真正看到的只有一串 token:系统指令、工具定义、检索片段、历史对话、示例、问题,全都在争夺同一个位置有限的工作台。所谓上下文管理,就是决定哪些内容进场、以什么顺序摆放、用哪种结构标注,以及什么时候把旧内容清出去。

一句话直觉:上下文窗口是工作台,不是仓库;堆上去的东西越多,能同时看清的东西越少。

典型场景包括 RAG 拼接、Agent 每轮回填工具结果、客服机器人维护多轮历史、长文档审阅。这些场景的共同点往往不是"缺信息",而是"信息太多,模型分不清哪条该用"。

核心原理

先看位置。Liu 等人在《Lost in the Middle》里做了两组任务:多文档问答和键值检索。他们保持问题和文档内容不变,只移动包含答案的那一段,成绩就变了——答案在开头或结尾时最好,落在中段时明显变差,形成一条 U 形曲线。而且这不是早期模型的毛病,明确支持长上下文的模型同样如此。类比一场长桌会议:坐在两端的人发言最容易被记住,坐在中间的人说了什么,反而容易淹没在噪声里。

如果位置偏差只是"中间差一点",多留余量似乎就能解决。Chroma 在 2025 年的Context Rot 报告打掉了这个安慰:他们测了 18 个模型,覆盖 8 种输入长度和 11 个关键信息位置,刻意固定任务难度、只增加无关内容,结果性能仍随输入变长而持续下降,连"复述一串词"这种任务都不稳定。更关键的是,常用的 Needle in a Haystack 基准只考词面检索,几乎人人满分,却掩盖了语义匹配、多跳推理和抗干扰能力的退化。报告里还有两个反直觉结果:只放一个干扰项就能拉低成绩;逻辑连贯的长文档反而比打乱句序的更难,说明模型对结构敏感,"读起来通顺"不等于"好处理"。

LongMemEval 的实验更贴近工程现场:同一批问题,喂入约 11.3 万 token 的完整对话历史,和只喂约 300 token 的相关片段相比,后者在所有模型族上都更好。原因不神秘——全量输入把"找到相关信息"和"使用相关信息"两步压进了一次前向,模型得先自己检索,再推理,错一步就全错;而精选输入让它只做后者。

机制上,可以这样理解:Transformer 每一层注意力都要在全部可见 token 上做归一化,上下文越长,单个 token 能分到的权重越薄;注意力头的容量、位置编码的外推、训练时见过的长度分布,又共同决定了"第 10 万个 token"和"第 100 个 token"待遇不同。这不是模型不努力,而是架构与训练分布共同带来的偏差。Anthropic 在上下文工程的实践文章里把这件事说得很直白:上下文是有限的注意力预算,目标是找到"最小的高信号 token 集合"。

于是可操作的变量只剩三个:位置、干扰、密度。关键约束放开头或结尾,长资料放中段;无关片段是负资产,不是"多给点总没坏处";示例的顺序也会引入偏差,Zhao 等人在《Calibrate Before Use》中发现,仅调整 few-shot 示例的排列,就能让准确率出现大幅波动,背后是多数标签偏差和近因偏差。

缓存则把这件事变成一道工程取舍。Prompt caching 按前缀哈希命中,顺序是 tools → system → messages,任何前置字节变化都会让后面整段失效;默认 TTL 5 分钟,写入约 1.25 倍、读取约 0.1 倍价格(不同模型有差异),还有最低可缓存长度。所以"把最重要的指令放最后"不能机械照做——长而稳定的资料在前、短而关键的任务指令在尾,并在尾部复述一次约束,才是同时照顾位置与缓存的写法。时间戳、随机 ID、每次变化的摘要,都不该出现在前缀里。

def build_messages(system, tool_schema, docs, question):
    # 稳定前缀:system 与工具定义逐字节不变,缓存断点打在这之后
    prefix = [
        {"role": "system", "content": system},
        {"role": "system", "content": tool_schema},
    ]
    # 检索结果先重排再截断,只留高相关片段,并编号便于引用
    ctx = "\n".join(f"[{i}] {d}" for i, d in enumerate(docs[:5], 1))
    # 任务、约束、问题贴在最末尾:靠近生成位置,又不污染前缀
    tail = (
        f"<context>\n{ctx}\n</context>\n"
        "只依据 <context> 回答;资料不足就说“不知道”,并给出引用编号。\n"
        f"问题:{question}"
    )
    return prefix + [{"role": "user", "content": tail}]

常见考点与误区

  • 窗口有 100 万 token,就能放心塞满:容量是上限,不是可用量。Chroma 的测试里任务难度不变、只增加无关内容,成绩照样下降;"能放进去"和"能用上"是两件事。
  • Needle in a Haystack 满分,说明长文档没问题:NIAH 考的是词面匹配,把一句已知的话藏进长文再问位置。真实任务要语义匹配、要在多个相似片段里排除干扰,换成 NoLiMa 这类非字面匹配的基准,分数立刻掉下来。
  • 关键信息放中间也没关系,模型会自己找:位置实验说明并非如此。U 形曲线在多个模型上成立,明确的长上下文模型也一样,中段恰恰是召回最脆弱的地方。
  • few-shot 示例越多越好,顺序无所谓:示例占预算、抬成本,还会带来多数标签偏差与近因偏差;仅改变顺序,部分任务上的准确率就能差出几十个百分点。示例要覆盖决策边界,而不是凑数量。
  • 加一句"让我们一步步思考"总能提分:对数学和多步推理可能有用,对检索、抽取、格式转换这类任务往往只是增加 token,甚至让模型在无关步骤上编造。推理模型内部已经做了这一步,外部再要求一遍通常是冗余。
  • system prompt 写了"不得泄露",它就是安全边界:模型没有可依赖的硬优先级,system 与 user 冲突时的行为取决于训练和具体实现;真正的权限校验必须放在代码与工具层。
  • 开了 prompt caching 就会自动省钱:把缓存断点打在每次都会变的时间戳块上,是最常见的错误;前缀一变,缓存只能重写,钱照付。

实践建议

  • 先建 20–50 条带标注的回归集,固定 temperature 等解码参数,每次改 prompt 都跑同一批用例,看通过率和失败类型,而不是凭单次输出的"感觉不错"下结论。
  • 先做减法:删掉重复背景、客套话、与当前决策无关的示例。每加一段内容都问一句"它会让模型改变哪个判断",答不上来就不加。
  • 用结构代替叮嘱:分节标签、片段编号、输出 schema、明确的"不知道"出口;要求模型引用片段编号,既方便人工回溯,也能做自动校验。
  • 顺序与缓存一起设计:静态前缀(system、工具定义、固定示例)逐字节固定,动态内容后置;关键约束开头声明、结尾复述一次;上线后监控 cache_read_input_tokens 和首 token 延迟。
  • 检索侧先重排再截断:少而准的 5 条,通常优于多而全的 20 条;给每段标来源与时间,让模型能判断信息新旧,也让你能定位错误来自检索还是生成。
  • Agent 场景要设预算:工具结果先裁剪或摘要再回填,历史超过阈值就做压缩摘要,子任务用独立上下文,不要把整条轨迹无限追加。

一句话总结:上下文管理的目标不是塞进更多,而是让每一千个 token 都对准同一个判断。

相关阅读

← 返回文章志