上下文窗口:我第一次跑出的「中间遗忘」,全部是截断

调 API 与上下文计费第 2 / 2 篇

本文的每个数字都是跑出来的。 环境是 Ollama 0.33.2 + qwen2.5:7b-instruct-q4_K_M(本地,离线,不需要 key), 验证代码在仓库 experiments/context-window/。 ⚠️ 这是一个运行时(Ollama / llama.cpp)的行为。商业 API 的截断策略不可见, 结论不能搬 —— 能搬的是那几个判别方法。

复核:2026-09-07(Ollama 仍 0.33.2,同模型)

✅ 两条核心结论都复现了,node experiments/context-window/recheck-num-ctx.mjs 可重跑(零成本):

组 num_ctx 实际保留的 prompt 答对?
正例校准 · 原生 /api/chat 4096 1797 ✅
A · 原生 /api/chat 64 34 ❌ 编了一个
B · /v1/chat/completions 64 1797 ✅
  • /v1/chat/completions 仍然静默丢掉 num_ctx —— 同一个参数, 原生那条路认,OpenAI 兼容那条路当没看见,而且 HTTP 200、不报错。
  • 截断仍落在 num_ctx 的一半上:64 → 保留 34,与正文表格里那一行一字不差。

📌 复核脚本刻意做成三组:只测 /v1 的话,「答得出」也可能是因为 64 其实够用。 A 组(已知生效的原生接口)是正例校准 —— 没有它,B 组的结果什么也证明不了。

窗口满了会发生什么?这一篇本来打算讲三件事:截断的形态、输出预留、中间遗忘。

跑完之后主线变了:我第一次拿到的那个“完美”的中间遗忘证据,全部是截断。 而救回它的不是我更聪明,是表里多打了一列 prompt_eval_count。

一、素材:答案必须猜不出来

20 条事实,形如:

工单 A07 的验收码是 F1SY。

答案是 4 位验收码。 这一点是刻意的:模型不可能从先验里知道它, 所以答对只能来自上下文。要是答案换成「负责人是张三」, 模型蒙一个常见姓名就有概率对,那整篇的正确率都不可信。

验收码写死在脚本里,不随机生成 —— 可复现是这一篇的前提。 填充段每段内容不同(带编号),否则「填充了多少」不等于「上下文里有多少信息」。

🚨 一个必须先解决的量具问题:num_ctx 只有原生 /api/chat 认, /v1/chat/completions 会静默丢掉它 —— 请求成功、不报错、参数被扔了。 上一篇踩过一次。 判别方法:传一个荒谬的小值(64)。若生效,开头的事实必然答不出来; 它答得出来,就说明量具没接上。

二、截断的确切行为:HTTP 200,done_reason 是 stop

完整 prompt 是 1983 token。收紧 num_ctx:

num_ctx HTTP 实际保留的 prompt done_reason 答对 输出
64 200 34 stop ❌ "1234"
256 200 130 stop ❌ 「信息不足,请提供…」
768 200 386 stop ✅ K3M9
1024 200 514 stop ✅ K3M9
1536 200 770 stop ✅ K3M9
4096 200 1983 stop ✅ K3M9

全程 HTTP 200,done_reason 全是 stop,一次都没报错。 截断是静默的 —— 唯一的痕迹是 prompt_eval_count 变小了,而那需要你专门去看。

⭐ 失效形态不一致,这一点比“会截断”更值得记:

  • num_ctx=64 输出 "1234" —— 格式完全正确的假答案
  • num_ctx=256 输出「信息不足,请提供…」 —— 它承认了

后者是可发现的,前者不是。一个 4 位码、格式对、值是编的 —— 下游任何校验都拦不住它,除非你手里有正确答案。

🚨 num_ctx 不是可用窗口,截断落在它的一半上

看上表第三列:64 → 34,256 → 130,768 → 386,1024 → 514,1536 → 770。 另换一份 12310 token 的资料,num_ctx=8192 → 保留 4098。

七个数据点全部是 num_ctx 的一半。

⇒ 要装下 T 个 token 的资料,num_ctx 得给到 2T 以上。

我排除过两个解释,都不是原因:

我以为 实测
OLLAMA_NUM_PARALLEL 把窗口切给了并发槽位 设成 1 之后重测,数字一模一样
num_ctx 是加载时属性,已加载的模型不会为更大的窗口重载 先用 8192 加载再请求 16384,/api/ps 的 context 立刻变成 16384,prompt 也跟着涨到全长

这看着像 llama.cpp 在上下文溢出时丢掉一半 KV 的行为。 ⚠️ 机制我没有独立验证,所以只报观测到的规律,不报原因。

三、窗口也卡输出,而它同样说 stop

让它做一件输出很长的事(「把这 20 条验收码各重复 5 遍」):

num_ctx prompt 输出 token done_reason 列出几条码
8192 315 445 stop 20/20
1024 315 445 stop 20/20
512 315 235 stop 10/20
400 315 235 stop 10/20
340 315 235 stop 10/20

输出被砍掉一半,而 done_reason 仍然是 stop —— 不是 length。也就是说:从返回值上看,模型是“正常说完了”的。

⭐ 这一段第一版什么都没测出来。当时的任务是「把 20 条码列出来」, 输出只有约 150 token,三档全是 20/20。 要看到“输出被窗口卡住”,任务必须要求足够长的输出 —— 否则你测的是一个从不触发的边界。

⚠️ 一处我没有解释的:那个输出上限不随 num_ctx 变(512/400/340 都是 235), 而且这三档的 prompt 都保留了完整的 315,没走第二节那条“砍一半”的规律。 如实写在这里,不编解释。

四、截断从前面开始 —— 这是个悬崖

同一份资料、同一批 20 个问题,唯一的差别是事实块放在开头还是末尾:

num_ctx 实际保留的 prompt 事实在末尾 事实在开头
8192 1983(全部) 20/20 20/20
2048 1983(全部) 20/20 20/20
1536 770 20/20 0/20
1024 514 20/20 0/20
768 386 20/20 0/20

⇒ 截断从前面开始。 放在末尾的信息安全,放在开头的先死。 而且不是渐进衰减 —— 1536 那一档直接从 20/20 掉到 0/20。

📌 实用含义:把重要东西放在最前面不等于它最安全。 系统提示词、项目约定、长期记忆 —— 这些习惯性放在最前面的东西, 恰恰是第一批被丢掉的(除非运行时对它们有特殊处理)。

🚨 这一段第一版只测了「事实在末尾」,五档全是 20/20, 我差点写成「收紧窗口对结果没有影响」。 加上「事实在开头」那一列它才有判别力 —— 一列数据全是满分的时候,先怀疑对照组缺了,别急着下结论。

五、那个“完美”的中间遗忘证据

判据要求给出三个位置的正确率对比。第一次跑出来是这样:

位置   实际 prompt   20 条答对   正确率
开头          8194        0/20      0%
中间          8194        0/20      0%
结尾          8194       20/20    100%

看着无懈可击:资料长度三档完全相同(21151 字符)、问题完全相同、 只有事实块的位置不同,而两端差了 100 个百分点。 这正是「中间遗忘」该长的样子,甚至更漂亮。

它全部是截断。

看那一列 prompt_eval_count:三档都是 8194。而资料约 1.5 万 token —— 我设的 num_ctx=16384,以为“远小于窗口”,可按第二节那条规律, 可用的只有 8192。资料被砍掉大半,而「开头」和「中间」的事实块 正好落在被砍掉的那部分里。

⭐ 救回它的不是我想通了什么,是表里多打了一列。 那一列现在被做成脚本里的硬前提:先用极大窗口量出「未截断时的 prompt 长度」, 再逐档核对,不一致就打 ❌ 并中止,不产出可疑数字。

「没有截断」这件事必须由脚本证明,不能由我断言。 第一版我就是断言的(写在注释里:「远小于 16384 → 没有截断」), 于是把量具的失效读成了模型的性质。

六、修好之后:中间遗忘没测出来

num_ctx=32768,两个尺度各跑一遍,脚本确认三档 prompt 与未截断基准一致:

资料规模 实际 prompt 开头 中间 结尾
11,871 字符 8,704 token 20/20 19/20 20/20
21,151 字符 15,504 token 20/20 19/20 20/20

判据要的对比给了:100% / 95% / 100%。而这个对比的结论是 —— 在这个模型、这个任务、这个尺度上,中间遗忘没有测出来。

中间只少 1 道,两个尺度下少的还是同一道(A16)。temperature=0, 所以这是确定性的:那 1 道有它自己的原因,不是“越靠中间越容易丢”。 而 n=20 的分辨率本来也撑不起 5 个百分点。

📌 这不等于「中间遗忘不存在」。 已知的那些结果用的是几万 token 的上下文 和更难的检索任务;这里是 15k token 加精确匹配的 4 位码, 7B 在任何位置都近乎满分。正确的读法是「没测出来」,不是「不成立」 —— 两者的区别就是这一篇存在的理由。

七、能带走的四个判别方法

数字会随运行时和模型变,下面这几条不会:

  1. 看 prompt_eval_count,不要看有没有报错。 截断的唯一痕迹就是它, 而 HTTP 200 和 done_reason: stop 什么都不保证。
  2. 用荒谬值证伪你的参数。 传 num_ctx=64,如果结果照样正确, 那这个参数根本没生效(/v1 就是这样静默丢掉它的)。
  3. 一列数据全是满分时,先怀疑对照组缺了。 第四节那五档 20/20 不是“没影响”,是“我只测了安全的那一头”。
  4. 需要“没有截断”这个前提的表格,让脚本去证明它。 前提写在注释里,它就只是一句愿望。

本文没有回答的三个问题:

一是 那条“砍一半”规律的机制。七个数据点一致,但我没有独立验证 它是不是 llama.cpp 的 context shift,也没解释第三节输出上限为什么不跟着 num_ctx 变。

二是 商业 API 的截断策略。它们既不给 num_ctx,也不告诉你截了没有 —— 有些会返回 400,有些直接吞掉。本文一个字节都没测过它们。

三是 真正的中间遗忘需要多大尺度。这个模型的上限是 32768, 可用一半即 16384,所以本文最多只能测到 15k token。要触到那个现象, 得换长上下文模型,那是另一轮实验。