Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

长上下文与记忆:读得多,也要记得对

工坊助手读过三个月的聊天记录。小林问:“我现在约好星期几来取件?”助手很快回答:“星期四。”

这个答案确实出现在记录里,却已经过时。后来小林改到了星期五;只是那条星期四的旧记录,昨天才补录进来。更麻烦的是,聊天里还提过下个月开始的星期日安排,以及一句“别人建议我改成星期三”。

把全部历史放进输入,问题就解决了吗?未必。助手不仅要找到一句话,还要判断它属于谁、从什么时候算起,以及它是不是已经确认的安排。

本专题沿着这件小事,讨论长上下文、压缩与长期记忆。先读过注意力机制、Transformer、RAG和 KV 缓存,会更容易把各部分连起来。不熟悉位置编码时,可以先跳过第二节的选读。

资料核对至 2026-10-20。这里选取七篇代表研究作为入口,包含 2026 年的交互式记忆与环境经验评测;不是所有相关论文的完整综述。本书另外运行了 27 次真实小模型探测和一个人工事件算例,它们与论文中的大规模实验分别讨论。

1. 四种“记住”,先别混在一起

如果助手昨天知道一个名字,今天却答不上来,我们很容易说“它忘了”。但这句话可能指四件不同的事:

信息放在哪里怎样使用换一次请求会发生什么
模型参数通过训练改变预测规则权重加载后仍在;修改一条事实不等于编辑一行表格
当前输入与 KV 缓存读本次上下文,复用已经计算过的键和值能否继续使用取决于服务是否保留或复用对应前缀
循环状态、压缩后的表示把过去更新到较小的数值状态中需要保存并恢复状态;原始细节未必能够逐条取回
外部记忆把文本、事件或文件存下来,提问时再查可以跨会话保存,但还要处理检索、更新、来源与访问范围

上一专题的固定状态实验展示了两条关联怎样互相干扰;本页会把重点移到“当前输入读得准不准”和“外部记录怎样更新”。这几种记忆也可以合作:长期记录放在外部,当前问题需要的部分取回来,再成为这次模型输入。

KV 缓存尤其容易被误会。它帮助模型继续一次计算,没有自动替我们判断“这句星期四已经失效”。同样,把聊天写入向量数据库,只是让它有机会被找回来;找到之后是否用对,仍是另一件事。

上下文窗口是容量,不是成绩

设这次请求允许的总预算为 CC 个词元,已经套好对话模板的输入长度为 TinT_{\text{in}},预留输出长度为 ToutT_{\text{out}},则要满足:

Tin+Tout≤C T_{\text{in}}+T_{\text{out}}\le C

输入包括指令、历史、检索资料、当前问题以及模板里的特殊标记。不能只数问题有几个汉字,也不能把输出的空间全部挤掉。

接着还要分清四个数字:训练时见过的长度、模型配置声明的上限、当前运行环境允许的预算,以及指定任务上实际测出的表现。它们可能不同。一个模型能够接收很长的输入,不代表其中任何位置的信息都能被同样准确地使用。

例如,本页沿用的小模型配置写着 32768 个位置;实验却把输入加输出限制在 2048 词元以内,实际最长输入只有 1015 词元。后面的结果因此不能称作“测完了它的 32K 长上下文能力”。固定版本的模型配置

2. 怎样把窗口拉长,又要付出什么

先想象模型已经习惯阅读前 4000 个位置。现在希望它处理更远的内容,至少有三件事要接上:位置表示能否适应,训练有没有教会它使用长距离关系,计算与存储能否承担。

选读:把位置刻度压密,是一个怎样的主意?

在 RoPE 中,一对坐标随位置旋转。用高中三角函数的语言说,一组旋转的角度可以写成 pωp\omega:pp 是位置,ω\omega 是这一组的角频率。不同坐标组使用不同频率,像几根转得快慢不同的指针。

假设原来希望覆盖长度 LL,现在想扩展到 L′=sLL'=sL,其中 s>1s>1。一种简单的位置插值思路是:

p′=p/s p'=p/s

例如放大四倍时,把位置 12000 映到 3000。范围压回来了,但相邻位置的间隔也从 1 变成了 1/41/4。所以,问题不仅是“远处能不能表示”,还包括“附近的两个位置会不会变得难以区分”。

YaRN 并非只改一个最大长度数字。Peng、Quesnelle、Fan 与 Shippole 的方法按频率所对应的波长,区别处理不同坐标组,并结合注意力缩放。本页核对的是 2026-02-06 的 v3:第 3 节说明机制,第 4.1 节使用 Llama 2 7B/13B、PG19 长片段进行扩展训练;128K 设置还接续了 64K 设置的检查点。这里没有重跑它的训练。上面的 p/sp/s 只解释最简单的插值直觉,不是完整 YaRN 实现。方法与训练设置

评价时仍要同时检查长输入与短输入。位置规则改变后,某个长文任务改善,不能直接证明原来的短请求表现也保持不变。

窗口变大,缓存也会跟着变大。对单个请求,假设每层都用同样数量的 KV 头,K 和 V 的每头维度相同,常规 KV 缓存的字节数为:

MKV=2LlayersHKVdheadTb M_{\text{KV}}=2L_{\text{layers}}H_{\text{KV}}d_{\text{head}}Tb

LlayersL_{\text{layers}} 是层数,HKVH_{\text{KV}} 是 KV 头数,dheadd_{\text{head}} 是每头维度,TT 是已保存的词元数,bb 是每个数占用的字节数。最前面的 2 分别给 K 和 V 留一份空间。这里用另一个带下标的 LL 表示层数,避免把它和刚才的序列长度混淆。

本页模型有 24 层、2 个 KV 头,每头 64 维。实验用 FP32,每个数 4 字节,代入后每增加一个词元就增加 24576 字节;1024 词元对应 24 MiB,32768 词元对应 768 MiB。这只是常规缓存的理论计数,不含权重、注意力临时张量、框架分配和其他进程内存,也不是本次测量的 RSS。

稠密注意力的预填充还需要处理词元之间的配对。高效算子可以减少中间结果的存储和数据搬运,却不自动赋予模型理解更远信息的能力。位置扩展、算子优化与能力训练是互相配合的工作,评价时也应分别留下证据。

3. 同一条资料,换个位置会怎样

Liu 等的 Lost in the Middle 提供了一个很容易上手的实验想法:保留问题和相关资料,移动相关资料的位置。在其多文档问答与键值检索设置中,若干模型更擅长使用开头或结尾的信息;一些模型在简单键值任务中又几乎全对。因此,“中间一定最差”不是对所有模型和任务都成立的定律。v3 的实验设置与结果

我们做一个更小的探测。资料是一份虚构资产清单,例如:

asset-0000: 200000
asset-cedar: 482731
asset-0001: 207919

问题只要求给出指定资产的六位代码;如果资料里没有,回答 UNKNOWN。这些数字都是教学合成值,不是密码或真实业务数据。实际提示使用英文,以便保持模板简单一致;这也意味着它没有测量中文长文阅读。

方案在运行前固定:

  • 三个目标分别是 cedar、lilac、maple,每个目标配一个不同代码。
  • 清单长度为 16 或 64 条;目标分别放在开头、中间、结尾。相同长度下,干扰记录及其相对顺序不变,共 18 次调用。
  • 另外各做三次单条检索、丢失代码的摘要和目标缺失对照,共计 27 次。

“中间”指从零开始的索引 8 或 32;开头为 0,结尾为 15 或 63。这里控制的是记录位置,不是把目标精确放在整个提示词元长度的某个百分位。模板和问题也占位置,完整输入词元已保留在记录中。

模型是 Qwen/Qwen2.5-0.5B-Instruct,固定修订 7ae557604adf67be50417f59c2c2f167def9a775。使用 CPU、FP32、4 线程和贪心解码,每次最多生成 16 个新词元;不做输出约束、不静默截断,也没有根据初次结果修改提示重选一套更漂亮的数据。

看见失败,比猜一条漂亮曲线有用

图 1:每格只有一次生成。绿色表示完整代码匹配,橙色表示未匹配;文字和颜色一起区分结果。它没有把三条样本拟合成一条普遍的性能曲线。

16 条记录时,开头答对 2/3,中间和结尾都是 0/3;64 条记录时依次为 2/3、1/3、2/3。全部完整清单合计答对 7/18。输入长度分别为 291~293 和 1011~1013 词元。

有两个细节值得停下来:

第一,cedar 在 16 条记录的三个位置都失败,却在 64 条记录中三个位置都成功。不能从这个小实验推出“越短越准”,也不能反过来推荐增加干扰记录。

第二,lilac 放在 64 条记录中间时,模型返回 453408。这个数属于紧邻的另一条资产记录,不是请求的 730284。我们知道它用了错误的值,但仅凭最终文字,不能确定内部注意力怎样分配,更不能直接给出机制解释。

每格只有一个人工构造的样本,三个名字也不是随机抽样的人群。换名字、格式、提示、语言、干扰难度或模型,都可能改变结果。本例展示的是怎样控制位置并检查真实失败,不是原论文的复现,也不是完整的长上下文排名。

找一条信息,只是第一道关

如果问题改成“把三份报告里的金额加起来”,或“甲提到的设备后来被乙改成了哪个编号”,单次键值查找就不够了。还需要跨位置连接信息,并保留所有必要的证据。

Hsieh 等的 RULER 把测试扩展到检索、多跳追踪、信息聚合和问答四类,并允许改变长度与任务复杂度。读这篇论文时,重点不是背一个“真正窗口长度”的数字,而是看这个数字对应哪些任务、阈值和长度分布。这里使用其 2024-04-09 的 v1;本书没有运行整个 RULER。任务设计

如果你要评价自己的助手,可以先沿两条轴扩展:一条改变长度与位置,另一条改变需要用几条证据。这样才知道它是偶尔漏读,还是根本无法把分散的内容连起来。

4. 压缩之后,必要的信息还在吗

长输入会占用预算,很自然会想到“先压缩一下”。但压缩至少可以指几种不同操作:

操作留下什么一个容易漏掉的问题
按问题检索或选择原文中的部分记录查询换了,刚才保留的部分可能不够
文本摘要、事实提取重新组织的文字日期、例外或具体数值是否丢失、改写
软表示或循环状态学习得到的向量或状态是否需要专门训练,细节能否恢复
KV 筛选或压缩内部缓存的一部分或近似表示后续生成所需的历史是否被移除

本页只实际比较前两种最简单的处理。对于每个目标,先构造 64 条、目标在中间的原始清单,再按明确的资产名查找一条记录。选择器执行字符串匹配,没有读取标准答案,也没有学习语义检索。由于问题已经给出了准确键,这条记录其实可以直接返回;继续交给模型,是为了观察“找到”和“读对”之间的差别。

选择后输入只有 66~68 个词元,三次答对两次。cedar 的记录明明还在,小模型仍回答 UNKNOWN。所以检索准确,并不自动保证最终回答准确。

接着人为制作一个有损摘要:“清单中有 cedar 等资产,未保留各自的代码。”摘要明确删掉了答案,三次模型都返回 UNKNOWN。这遵守了回答约定,但没有找回任何代码。不能把它与前面的正确代码答案合并,宣称“摘要使任务成功率达到 100%”。

还有一个缺失对照:清单保留 64 条干扰记录,完全移除目标。模型三次都输出 263353,而不是 UNKNOWN。这说明“知道该拒答”的表现也会随提示内容改变;在那句明确声明省略细节的摘要上成功,不能替代真实缺失资料的检查。

压缩比例可以简单写成:

r=TbeforeTafter r=\frac{T_{\text{before}}}{T_{\text{after}}}

两边必须用相同词元化器、相同计数范围。若包含指令、问题和模板,就两边都包含。cedar 的完整中间清单为 1011 词元,选择后为 66 词元,比例约 15.32;但这次选择后的回答仍然失败。比例只告诉我们省了多少输入,不能独自说明任务质量,也没有计入选择器或摘要生成的成本。

LongLLMLingua 针对这个问题,让文档筛选与词元压缩参考当前问题,还结合文档重排和信息恢复。Jiang 等的 2023-10-10 v1 第 4 节展示了这些步骤;它使用模型估计相关信息,不是本页的精确键查找或手写摘要。本书没有运行该方法,也不能用这里的三个摘要反例评价它的实际表现。方法原文

一个实用的检查方式是:压缩前先固定问题,再分别记录必要证据是否还在、模型有没有用对、花了多少时间和词元。若压缩面向尚未出现的未来问题,还要换一组不同类型的问题测试。今天没问到的细节,明天可能恰好最重要。

5. 记忆更新:晚收到,不代表晚生效

回到工坊。我们先不用语言模型抽取事实,而是手写七条事件,把读取规则看清楚。下面五条涉及小林的取件日:

记录内容生效日收到日
e01确认星期二取件10/110/1
e03改为星期五10/1010/10
e05从 11/1 起改为星期日11/110/12
e06转述别人建议星期三,未确认10/1310/13
e07补录:10/5 时曾改为星期四10/510/14

日期均为 2026 年。另两条分别保存小林的材料和小梅的取件日,用来检查字段与人物是否混淆。这里假设每个已确认值从生效日起持续有效,直到下一条已确认安排生效;没有处理撤销、同日冲突或现实中的全部日程规则。

图 2:蓝点和橙框重合,表示当天生效、当天收到。两者分开时,不能靠消息到达顺序推断事实的新旧。虚线表示 10/14;图中仍保留尚未生效和未经确认的事件。

提问时还需要两种时间:

  • as_of:想了解哪个日期的状态。
  • known_at:允许使用截至哪天收到的资料。

“用今天收到的全部资料,回看上周的安排”,与“站在上周当时已知的信息上回答”,是不同问题。

我们的读取规则先检查查询者与记录所属人是否一致,再找同一字段中已经收到、已经生效且明确确认的记录,最后取生效时间最近的一条。记录 ID 留在结果里,方便追溯。查询者身份在这个算例中由调用方给定;真实服务必须先验证身份,不能让用户任意填写一个名字就取得对应资料。

按这套约定:

问法回答来源
用 10/14 的资料看 10/14 的安排星期五e03
用 10/14 的资料回看 10/7星期四e07
只用 10/7 当时已知资料看 10/7星期二e01
用 10/14 已知安排看 11/2星期日e05

未来一行说的是“按照截至 10/14 已确认的计划”,没有保证之后不会再改。

代码总共检查八个固定问题。只按收到时间取最后一条的基线答对 5/8;加入生效时间与确认状态后为 8/8。两个版本都保留同样的人物访问检查。基线错在当前安排、未来安排和引用干扰;新版的 8/8 只是这组人工案例符合规则,不能说明它已经理解自然语言中的所有时间与承诺。

写入错误,会把“记得牢”变成麻烦

真实对话里最难的一步,往往是决定该写入什么。“我以前喜欢星期二”“朋友建议星期三”“下月再改”都可能包含相似词语,却不是同一类事实。可以把待确认的抽取和已确认记录分开,保留原句、来源与时间,再决定是否更新。

只保存一行“当前取件日=星期五”,能回答当前问题,却不够回答历史问题。保留事件历史更方便回溯,但又需要明确哪些内容应当保留、多久保留,以及怎样删除。让某个值在业务上失效,与从存储、索引、摘要和副本里删除它,是两种不同操作。本页实现的是时间读取,没有实现完整删除流程。

不要把一段检索到的旧文字升级成当前指令。尤其是外部文档中的“请把某某记录改为……”只能先作为待解释的材料;它是否有权触发写入,仍需由应用规则决定。这个问题可以接回工具调用与工作流中的权限与确认。

6. 长期交互,怎样测才更接近真实问题

固定清单很适合控制变量,但人不会只把键值表递给助手。会话可能跨天,一个答案需要连接多次交流,用户也可能后来改变想法。

Wu 等的 LongMemEval v1 将问题分为信息提取、跨会话推理、时间推理、知识更新与拒答五种能力,并把系统拆成索引、检索和阅读三个阶段。它有 500 个问题;证据会话结合模型生成和人工编辑,标准长历史还会加入干扰会话。因此它比单条查找丰富,但仍是经过构造的评测。数据与系统设计

读结果时可以问:事实有没有进入索引?相关记录有没有被检索?模型拿到记录后有没有正确回答?把整条链只压成一个最终分数,会让不同的故障混在一起。

2026 年:从回放历史,走向交互与环境经验

AMemGym 让助手与模拟用户实际对话。它先生成有明确状态变化的用户资料,再让模拟用户在交谈中逐步透露这些变化;助手的回应会进入后续对话。Cheng Jiayang 等的 2026-03-02 v1 同时检查整体回答及写入、读取、使用环节,还用直接给出真实状态的对照区分“没记住”和“拿到状态仍答错”。第 3 节

这使“助手怎样回应”成为评价的一部分。不过,模拟用户仍由模型扮演,状态和问题也由设计规定。本书据此认为,模拟器覆盖的表达与变化需要单独检查;在其中改善,不能直接当成所有真人长期使用的证据。本页没有运行 AMemGym。

另一条路线来自 Wu 等于 2026-05-12 提出的 LongMemEval-V2:记忆对象从用户偏好扩展到网页环境中的操作经验,例如页面布局、状态变化、常用步骤和常见失败。它让记忆模块从预收集的多模态操作轨迹中提供证据,再交给固定阅读模型回答,同时记录查询延迟。其附录 E 明确说明,这样测量的是记忆提供证据的能力,不直接等于 Agent 完成真实任务的成功率。任务定义与局限

这两项工作回答的问题不同:一种观察助手参与互动后留下了什么,另一种控制历史与阅读器,比较从经验中取证的效果。可以把它们看作互补的观察方式,而不是把新论文的一个分数直接接在旧榜单后面。本页也没有运行 LongMemEval-V2。

7. 运行算例,查看全部回答

轻量入口只使用 Python 标准库,重新计算缓存大小、事件读取与保存模型结果的分数:

python3 src/examples/context_memory.py

实际输出为:

常规 FP32 KV:1024 词元 24 MiB;32768 词元 768 MiB(仅理论缓存)。
按到达时间取最后一条:5/8。
按时间与确认状态读取:8/8。
full/16/first:找回代码 2/3,遵守回答约定 2/3,输入 291~293 词元。
full/16/middle:找回代码 0/3,遵守回答约定 0/3,输入 291~293 词元。
full/16/last:找回代码 0/3,遵守回答约定 0/3,输入 291~293 词元。
full/64/first:找回代码 2/3,遵守回答约定 2/3,输入 1011~1013 词元。
full/64/middle:找回代码 1/3,遵守回答约定 1/3,输入 1011~1013 词元。
full/64/last:找回代码 2/3,遵守回答约定 2/3,输入 1011~1013 词元。
selected:找回代码 2/3,遵守回答约定 2/3,输入 66~68 词元。
summary:找回代码 0/3,遵守回答约定 3/3,输入 69~71 词元。
absent:找回代码 0/3,遵守回答约定 0/3,输入 1014~1015 词元。
模型结果来自保存的真实运行;默认命令只重新计分,不加载模型。

full 是完整清单;selected 是精确键查找后的一条记录;summary 是手写有损摘要;absent 是目标缺失。计分只去掉回答两端的空白,再做完整字符串比较,没有从一大段文字中挑出某个碰巧正确的数字。

可以分别下载核心脚本、固定方案、全部模型输入输出和时间事件与读取结果。若单独下载核心脚本,把模型记录放在脚本旁的 data/context-memory-model.json,或用 --record 文件路径 指定它。

记录包含每次对话、套模板后的提示、输入输出词元 ID、实际文本、是否遇到结束标记、生成耗时,以及模型版本、源代码和模型文件指纹。27 次都正常遇到结束标记,没有把截断回答算成正常结束。记录中的耗时只用于复核这次运行,没有预热和重复计时设计,不用于比较推理速度。

展开标准库核心代码
"""长上下文与记忆:标准库算例、固定探测方案与保存结果的重新计分。"""

import argparse
from collections import defaultdict
import hashlib
import json
from pathlib import Path


DATA = Path(__file__).resolve().parent / "data"
MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
REVISION = "7ae557604adf67be50417f59c2c2f167def9a775"
TARGETS = [("asset-cedar", "482731"), ("asset-lilac", "730284"),
           ("asset-maple", "195608")]
SYSTEM = ("Use only the supplied inventory. Return exactly the six-digit code "
          "for the requested asset, with no explanation. "
          "If the inventory does not provide its code, return UNKNOWN.")


def digest(path):
    result = hashlib.sha256()
    with Path(path).open("rb") as stream:
        for block in iter(lambda: stream.read(1024 * 1024), b""):
            result.update(block)
    return result.hexdigest()


def fingerprint(value):
    raw = json.dumps(value, ensure_ascii=False, sort_keys=True,
                     separators=(",", ":")).encode()
    return hashlib.sha256(raw).hexdigest()


def save_new(path, value):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("x") as stream:
        json.dump(value, stream, ensure_ascii=False, indent=2)
        stream.write("\n")


def fillers(count):
    return [{"asset": f"asset-{i:04d}", "code": f"{200000 + i * 7919:06d}"}
            for i in range(count)]


def select(records, asset):
    """先按明确的键查找,再把查得的记录交给语言模型。"""
    return [record for record in records if record["asset"] == asset]


def make_plan():
    cases = []
    for asset, code in TARGETS:
        target = {"asset": asset, "code": code}
        for count in (16, 64):
            for position, index in (("first", 0), ("middle", count // 2),
                                    ("last", count - 1)):
                records = fillers(count - 1)
                records.insert(index, target)
                cases.append(dict(id=f"{asset}/{count}/{position}", mode="full",
                                  asset=asset, original_code=code, records=records,
                                  count=count, position=position, target_index=index,
                                  expected=code, fact_available=True))
        originals = fillers(63)
        originals.insert(32, target)
        cases.extend([
            dict(id=f"{asset}/selected", mode="selected", asset=asset,
                 original_code=code, records=select(originals, asset),
                 original_records=originals, expected=code, fact_available=True),
            dict(id=f"{asset}/summary", mode="summary", asset=asset,
                 original_code=code, records=[], original_records=originals,
                 summary=f"The inventory includes {asset} among other assets. "
                         "Individual codes are omitted.",
                 expected="UNKNOWN", fact_available=False),
            dict(id=f"{asset}/absent", mode="absent", asset=asset,
                 original_code=code, records=fillers(64),
                 expected="UNKNOWN", fact_available=False),
        ])
    return dict(
        schema=1, model=MODEL, revision=REVISION,
        purpose="Small position/selection/lossy-summary probe; not a 32K benchmark.",
        generation=dict(max_new_tokens=16, do_sample=False, input_plus_output_cap=2048,
                        attention="eager", device="cpu", dtype="float32",
                        threads=4, seed=2026),
        scoring="Exact stripped text; recovery and contract compliance are separate.",
        cases=cases,
    )


def messages(case):
    inventory = case.get("summary", "\n".join(
        f"{row['asset']}: {row['code']}" for row in case["records"]))
    return [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": f"Inventory:\n{inventory}\n\n"
                                  f"Requested asset: {case['asset']}"},
    ]


def grade(case, text):
    answer = text.strip()
    return dict(contract_correct=answer == case["expected"],
                code_recovered=(case["fact_available"] and
                                answer == case["original_code"]),
                abstained=answer == "UNKNOWN")


def kv_bytes(tokens, layers=24, kv_heads=2, head_dim=64, bytes_per_value=4):
    """单请求、每层相同且 K/V 同维的常规缓存;不含权重和其他内存。"""
    return 2 * layers * kv_heads * head_dim * tokens * bytes_per_value


def memory_inputs():
    # 事件、人物代号和“确认/引用”标签均为人工编写,未调用模型抽取。
    rows = [
        ("e01", "lin", "pickup", "Tuesday", "2026-10-01", "2026-10-01", "confirmed"),
        ("e02", "lin", "material", "PLA", "2026-10-02", "2026-10-02", "confirmed"),
        ("e03", "lin", "pickup", "Friday", "2026-10-10", "2026-10-10", "confirmed"),
        ("e04", "mei", "pickup", "Monday", "2026-10-11", "2026-10-11", "confirmed"),
        ("e05", "lin", "pickup", "Sunday", "2026-11-01", "2026-10-12", "confirmed"),
        ("e06", "lin", "pickup", "Wednesday", "2026-10-13", "2026-10-13", "quoted"),
        ("e07", "lin", "pickup", "Thursday", "2026-10-05", "2026-10-14", "confirmed"),
    ]
    events = [dict(zip(("id", "owner", "slot", "value", "valid_from",
                       "observed_at", "kind"), row)) for row in rows]
    queries = [
        ("now", "lin", "lin", "pickup", "2026-10-14", "2026-10-14", "ok", "Friday", "e03"),
        ("past", "lin", "lin", "pickup", "2026-10-07", "2026-10-14", "ok", "Thursday", "e07"),
        ("future", "lin", "lin", "pickup", "2026-11-02", "2026-10-14", "ok", "Sunday", "e05"),
        ("other", "mei", "mei", "pickup", "2026-10-14", "2026-10-14", "ok", "Monday", "e04"),
        ("missing", "lin", "lin", "color", "2026-10-14", "2026-10-14", "unknown", None, None),
        ("denied", "lin", "mei", "pickup", "2026-10-14", "2026-10-14", "denied", None, None),
        ("known_then", "lin", "lin", "pickup", "2026-10-07", "2026-10-07", "ok", "Tuesday", "e01"),
        ("quote", "lin", "lin", "pickup", "2026-10-13", "2026-10-13", "ok", "Friday", "e03"),
    ]
    queries = [dict(zip(("id", "requester", "owner", "slot", "as_of", "known_at",
                        "expected_status", "expected_value", "expected_source"), row))
               for row in queries]
    return events, queries


def recall(events, query, policy="temporal"):
    if policy not in {"temporal", "arrival"}:
        raise ValueError("未知读取规则")
    if query["requester"] != query["owner"]:
        return dict(status="denied", value=None, source=None)
    candidates = [e for e in events if e["owner"] == query["owner"]
                  and e["slot"] == query["slot"]
                  and e["observed_at"] <= query["known_at"]]
    if policy == "temporal":
        candidates = [e for e in candidates if e["kind"] == "confirmed"
                      and e["valid_from"] <= query["as_of"]]
        key = lambda e: (e["valid_from"], e["observed_at"], e["id"])
    else:
        key = lambda e: (e["observed_at"], e["id"])
    if not candidates:
        return dict(status="unknown", value=None, source=None)
    event = max(candidates, key=key)
    return dict(status="ok", value=event["value"], source=event["id"])


def memory_report():
    events, queries = memory_inputs()
    results = []
    for query in queries:
        expected = dict(status=query["expected_status"], value=query["expected_value"],
                        source=query["expected_source"])
        results.append(dict(query=query, expected=expected,
                            arrival=recall(events, query, "arrival"),
                            temporal=recall(events, query, "temporal")))
    return dict(events=events, rows=results)


def summarize(record):
    plan = make_plan()
    if record["plan_sha256"] != fingerprint(plan) or not record["complete"]:
        raise ValueError("记录的方案不同或尚未完成")
    cases = {case["id"]: case for case in plan["cases"]}
    if len(record["rows"]) != len(cases) or {r["id"] for r in record["rows"]} != set(cases):
        raise ValueError("实验记录缺失或重复")
    groups = defaultdict(list)
    for row in record["rows"]:
        case = cases[row["id"]]
        if row["messages"] != messages(case):
            raise ValueError("实际提示与预先固定的方案不一致")
        group = (f"full/{case['count']}/{case['position']}" if case["mode"] == "full"
                 else case["mode"])
        groups[group].append((row, grade(case, row["text"])))
    return groups


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--record", type=Path, default=DATA / "context-memory-model.json")
    parser.add_argument("--plan-out", type=Path)
    args = parser.parse_args()
    if args.plan_out:
        save_new(args.plan_out, make_plan())
        print(f"已保存固定的 27 次探测方案:{args.plan_out}")
        return
    print(f"常规 FP32 KV:1024 词元 {kv_bytes(1024) / 2**20:.0f} MiB;"
          f"32768 词元 {kv_bytes(32768) / 2**20:.0f} MiB(仅理论缓存)。")
    report = memory_report()
    for policy, label in (("arrival", "按到达时间取最后一条"), ("temporal", "按时间与确认状态读取")):
        correct = sum(row[policy] == row["expected"] for row in report["rows"])
        print(f"{label}:{correct}/{len(report['rows'])}。")
    groups = summarize(json.loads(args.record.read_text()))
    for name in ("full/16/first", "full/16/middle", "full/16/last",
                 "full/64/first", "full/64/middle", "full/64/last",
                 "selected", "summary", "absent"):
        rows = groups[name]
        recovered = sum(score["code_recovered"] for _, score in rows)
        correct = sum(score["contract_correct"] for _, score in rows)
        counts = [row["input_tokens"] for row, _ in rows]
        print(f"{name}:找回代码 {recovered}/{len(rows)},"
              f"遵守回答约定 {correct}/{len(rows)},输入 {min(counts)}~{max(counts)} 词元。")
    print("模型结果来自保存的真实运行;默认命令只重新计分,不加载模型。")


if __name__ == "__main__":
    main()

可选:重新调用真实模型

这一步需要下载约 1 GB 权重,并用更多内存加载为 FP32。本次环境为 Python 3.12.12、PyTorch 2.14.1、Transformers 4.57.3,CPU 运行;其余版本固定在可选依赖文件。结果可能随软件和设备的数值实现变化,重新运行时应保留自己的完整记录。

python -m pip install -r src/examples/requirements-inference.txt
python src/examples/context_memory_models.py --run \
  --out-dir runs/my-context-memory
python src/examples/context_memory.py \
  --record runs/my-context-memory/model.json

输出目录必须尚不存在。程序先写 plan.json,再加载模型;逐次保存尚未完成的记录,全部结束才将 complete 置为真。如果输入超过预算,会明确停止。

只运行模型脚本而不传 --run,不会加载模型,输出为:

显式传入 --run --out-dir 新目录,才下载并运行固定的 27 次模型探测。
展开真实模型探测代码
"""显式 --run 才运行长上下文与记忆专题的小模型探测;CPU,无静默截断。"""

import argparse
from datetime import datetime, timezone
from importlib.metadata import version
import json
from pathlib import Path
import platform
import time

import context_memory as lab


def experiment(directory):
    directory.mkdir(parents=True, exist_ok=False)
    plan = lab.make_plan()
    lab.save_new(directory / "plan.json", plan)  # 在下载、加载、生成之前固定。
    import torch
    from huggingface_hub import snapshot_download
    from transformers import AutoModelForCausalLM, AutoTokenizer
    settings = plan["generation"]
    torch.set_num_threads(settings["threads"])
    torch.manual_seed(settings["seed"])
    snapshot = Path(snapshot_download(
        plan["model"], revision=plan["revision"],
        allow_patterns=["*.json", "*.safetensors", "merges.txt", "README.md", "LICENSE"]))
    tokenizer = AutoTokenizer.from_pretrained(snapshot, trust_remote_code=False)
    encoded = []
    for case in plan["cases"]:
        conversation = lab.messages(case)
        rendered = tokenizer.apply_chat_template(
            conversation, tokenize=False, add_generation_prompt=True)
        inputs = tokenizer.apply_chat_template(
            conversation, tokenize=True, add_generation_prompt=True,
            return_dict=True, return_tensors="pt")
        length = inputs["input_ids"].shape[1]
        if length + settings["max_new_tokens"] > settings["input_plus_output_cap"]:
            raise ValueError(f"{case['id']} 超出预先固定的预算,停止而不截断")
        encoded.append((case, conversation, rendered, inputs))
    model = AutoModelForCausalLM.from_pretrained(
        snapshot, dtype=torch.float32, attn_implementation=settings["attention"],
        trust_remote_code=False).eval()
    report = dict(
        complete=False, started_at=datetime.now(timezone.utc).isoformat(),
        model=plan["model"], revision=plan["revision"], generation=settings,
        plan_sha256=lab.fingerprint(plan),
        source_sha256={path.name: lab.digest(path) for path in
                       (Path(lab.__file__), Path(__file__))},
        model_files_sha256={path.name: lab.digest(path) for path in snapshot.iterdir()
                            if path.is_file() and (path.suffix in {".json", ".safetensors"}
                                                   or path.name == "merges.txt")},
        environment=dict(python=platform.python_version(), platform=platform.platform(),
                         packages={name: version(name) for name in
                                   ("torch", "transformers", "huggingface-hub",
                                    "tokenizers", "safetensors")}),
        rows=[],
    )
    target = directory / "model.json"

    def checkpoint():
        temporary = directory / "model.pending.json"
        temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
        temporary.replace(target)

    checkpoint()
    eos = model.generation_config.eos_token_id
    eos = [eos] if isinstance(eos, int) else eos
    for case, conversation, rendered, inputs in encoded:
        length = inputs["input_ids"].shape[1]
        started = time.perf_counter()
        with torch.inference_mode():
            output = model.generate(
                **inputs, max_new_tokens=settings["max_new_tokens"],
                do_sample=False, temperature=None, top_p=None, top_k=None,
                pad_token_id=tokenizer.eos_token_id)
        seconds = time.perf_counter() - started
        ids = output[0, length:].tolist()
        text = tokenizer.decode(ids, skip_special_tokens=True)
        row = dict(id=case["id"], messages=conversation, rendered_prompt=rendered,
                   input_ids=inputs["input_ids"][0].tolist(), input_tokens=length,
                   output_ids=ids, output_tokens=len(ids), text=text,
                   stopped_on_eos=bool(ids and ids[-1] in eos), seconds=seconds,
                   **lab.grade(case, text))
        report["rows"].append(row)
        checkpoint()
        print(f"{case['id']}: {text!r}, 输入 {length}, "
              f"约定正确 {row['contract_correct']}", flush=True)
    report["complete"] = True
    report["finished_at"] = datetime.now(timezone.utc).isoformat()
    checkpoint()
    lab.save_new(directory / "memory.json", lab.memory_report())
    print(f"已完成全部 {len(report['rows'])} 次探测:{target}")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--out-dir", type=Path)
    args = parser.parse_args()
    if not args.run:
        print("显式传入 --run --out-dir 新目录,才下载并运行固定的 27 次模型探测。")
        return
    if args.out_dir is None:
        parser.error("请用 --out-dir 指定尚不存在的目录")
    experiment(args.out_dir)


if __name__ == "__main__":
    main()

8. 下一次实验,怎样问得更有收获

本页的失败给出了几个具体的后续问题,下面这些扩展尚未执行。

如果想研究位置,增加目标名字和代码的独立组合,加入多个长度,固定每组干扰记录后配对移动目标;同时保留问题在前、问题在后的独立条件。不要在看到结果后只留下最明显的一组。

如果想研究压缩,先指定允许删除多少输入,再记录每类问题需要的证据是否还在。除了当前问题,也准备未来才提出的问题:一份适合查代码的摘要,未必适合追查资产的历史变化。将压缩本身的时间和调用也计入成本。

如果想研究长期记忆,把新增、变更、迟到、引用、冲突、缺失与删除分开测试。先用人工事件验证规则,再接入自然语言抽取器,才能看到新增加的错误来自哪里。对于同日互相矛盾的确认,本例按时间和 ID 决胜只是程序的确定性约定;实际产品通常需要显示冲突或进一步确认。

真正值得追问的,不只是“还能再塞多少词元”,而是:在什么任务、预算与交互条件下,系统能把必要的信息一直带到答案里?这需要窗口、检索、压缩和更新共同配合,也需要分别观察它们会在哪里失手。

自测与小实验

  1. 总预算为 4096,套模板后的输入占 3700,想最多输出 512 个词元,能直接提交吗?至少需要减少多少输入?
  2. 本页常规 KV 若改为每个数 2 字节,32768 词元时是多少 MiB?这是否等于整个进程的内存?
  3. 为什么摘要对照遵守约定 3/3,却找回代码 0/3?
  4. cedar 在 64 条记录中比 16 条更好,能否据此建议所有请求增加无关记录?
  5. 10/14 回看 10/7 为什么得到星期四,而只用 10/7 当时的知识会得到星期二?
  6. 如果删掉 e07,哪些查询结果会改变?先预测,再修改事件列表运行 recall。
  7. 有人从原始对话中抽取出“星期三”,但漏掉“别人建议”四个字。后续检索再准确,能补回这个错误吗?
  8. 设计一项需要两条相隔很远的证据才能回答的任务。应分别记录哪些结果,才能区分遗漏证据与组合错误?
  9. 将查询者与记录所属人写成不同名字,为什么例子要返回 denied 而不是 unknown?这个返回值是否已经实现真实身份验证?
参考答案与提示
  1. 3700+512=4212,超过 4096;保持输出上限不变,至少减少 116 个输入词元。减少后仍要重新套模板并计数。
  2. 384 MiB,只有该假设下的 KV。权重、临时结果和框架等内存另算。
  3. 代码已从摘要中删除,回答 UNKNOWN 符合约定,却没有满足查回原始代码的需求。这是两个不同指标。
  4. 不能。这里只有三个固定目标和一种模板;记录数量变化也改变了整体输入。需要更充分的配对实验与任务覆盖。
  5. e07 在 10/14 才被收到,却描述 10/5 起的状态。回看时可以使用它;站在 10/7 的知识范围内则还不知道它。
  6. 时间规则下,past 从星期四变成星期二,其他问题不变。到达顺序基线的 now、past、future 会从 e07 的星期四变成 e06 的星期三,仍有错误。说明只删一个迟到事件不能解决引用识别问题。
  7. 不能保证。若原句还保留,可以重新核查;若只剩错误的摘要,读取阶段可能再也看不到丢掉的限定条件。
  8. 例如一处记录设备代号与负责人,另一处记录该负责人的取件安排。分别统计两条证据的保留和检索情况,以及证据齐全时的回答正确率;不要把最终错误一律归为检索失败。
  9. 前者表示规则不允许访问,后者表示允许查询但没有可用事实。例子假定身份已经由可信调用方给定,没有实现登录、身份认证或完整授权系统。

原始资料与更新入口

下表固定本次实际阅读版本。阅读范围包括各论文的问题、主要方法或任务设计与所述局限;没有安装和运行这些作者仓库,也没有复制论文插图。两张图来自本页实际记录。

文献与版本作者代码入口
Liu 等,Lost in the Middle: How Language Models Use Long Contexts,2023-11-20 v3lost-in-the-middle
Peng、Quesnelle、Fan、Shippole,YaRN: Efficient Context Window Extension of Large Language Models,2026-02-06 v3;初稿发表于 2023 年yarn
Hsieh 等,RULER: What’s the Real Context Size of Your Long-Context Language Models?,2024-04-09 v1RULER
Jiang 等,LongLLMLingua: Accelerating and Enhancing LLMs in Long Context Scenarios via Prompt Compression,2023-10-10 v1LLMLingua
Wu 等,LongMemEval: Benchmarking Chat Assistants on Long-Term Interactive Memory,2024-10-14 v1LongMemEval
Cheng Jiayang 等,AMemGym: Interactive Memory Benchmarking for Assistants in Long-Horizon Conversations,2026-03-02 v1amemgym
Wu 等,LongMemEval-V2: Evaluating Long-Term Agent Memory Toward Experienced Colleagues,2026-05-12 v1LongMemEval-V2

本专题首版日期为 2026-10-20。后续更新会继续比较位置与任务类型、压缩后保留下来的证据、记忆更新规则,以及助手参与交互后出现的新错误;若这些证据改变基础讲解,也会同步回到 RAG、Agent 与推理成本章节修订。