上下文窗口:我第一次跑出的「中间遗忘」,全部是截断
本文的每个数字都是跑出来的。 环境是 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/chat4096 1797 ✅ A · 原生 /api/chat64 34 ❌ 编了一个 B · /v1/chat/completions64 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 在任何位置都近乎满分。正确的读法是「没测出来」,不是「不成立」 —— 两者的区别就是这一篇存在的理由。
七、能带走的四个判别方法
数字会随运行时和模型变,下面这几条不会:
- 看
prompt_eval_count,不要看有没有报错。 截断的唯一痕迹就是它, 而 HTTP 200 和done_reason: stop什么都不保证。 - 用荒谬值证伪你的参数。 传
num_ctx=64,如果结果照样正确, 那这个参数根本没生效(/v1就是这样静默丢掉它的)。 - 一列数据全是满分时,先怀疑对照组缺了。 第四节那五档 20/20 不是“没影响”,是“我只测了安全的那一头”。
- 需要“没有截断”这个前提的表格,让脚本去证明它。 前提写在注释里,它就只是一句愿望。
本文没有回答的三个问题:
一是 那条“砍一半”规律的机制。七个数据点一致,但我没有独立验证 它是不是 llama.cpp 的 context shift,也没解释第三节输出上限为什么不跟着
num_ctx变。二是 商业 API 的截断策略。它们既不给
num_ctx,也不告诉你截了没有 —— 有些会返回 400,有些直接吞掉。本文一个字节都没测过它们。三是 真正的中间遗忘需要多大尺度。这个模型的上限是 32768, 可用一半即 16384,所以本文最多只能测到 15k token。要触到那个现象, 得换长上下文模型,那是另一轮实验。