通过移动缓存而非重建避免长对话中重复全量 prefill,显著降低每轮响应延迟。
在固定上下文大小的长对话中,当窗口填满时,总得有某种取舍。简单的做法是丢弃最早的对话消息并重新处理剩余内容,但这意味着每一轮对话都要付出完整的 prefill 开销。Context Shifting(上下文漂移)则提供了另一种方案:移动缓存,而不是重建它。
生成(Generation)和提示词处理(Prompt Processing)是两个不同操作,有着不同的成本。Prefill 在一次传递中读取提示词的每个 token,受算术运算约束;生成则每轮传递只产出一个 token,受内存带宽约束。在本地硬件上,prefill 速率通常是生成速率的数倍——这正是为什么重新处理让人感觉像是卡顿而不是无感:你为回复的第一个 token 出现之前数千个 token 的 prefill 付出了代价。
关键在于理解成本的形态。如果你的窗口大小是 C 个 token,每轮对话新增 n 个,简单的「丢弃并重新处理」方案每轮大约要在 C 个 token 上付出 prefill 代价,而真正有用的工作是 n。浪费比率是 C/n,所以上下文窗口越大浪费越严重、消息越短浪费也越严重——而这恰恰是人们最常做的两件事。注意力机制对序列长度是二次方的,所以翻倍 C 带来的重新处理量会超过两倍。
不要从任何地方(包括这里)拿一个数字来套用自己的情况。你自己的运行时在每次请求中会打印提示词评估速率和生成速率;只有来自你自己的机器和模型的这两个数字才有意义。用上下文大小除以提示词速率,就是你在避免的那个停顿时间。
KV Cache 为每一层中的每个 token 存储一个 key 向量和一个 value 向量。这正是生成过程不必在每个新 token 生成时都重新读取整段对话的原因,它本质上是一个按位置索引的普通数组。
当窗口填满时,KoboldCpp 并不会扔掉这个数组。它移除最早期 token 的条目,将剩余条目向下移动以关闭间隙,然后在末尾留出空间容纳新的 token。项目 wiki 将其描述为使用 KV Cache Shifting 来移除旧 token 并添加新 token,而无需任何重新处理。缓存中仍在的所有内容都已经是计算好的;这些 token 除了位置之外没有任何变化。
一个互补的功能完成了另一半工作。Fast Forwarding(默认开启,可用 --nofastforward 关闭)检测到本轮提示词的开头与上一轮处理过的内容是否匹配,如果匹配则跳过它,只处理真正新的 token。Shifting 处理窗口溢出;Fast Forwarding 处理窗口未溢出的一般情况。
移动缓存条目不仅仅是内存拷贝,这是解释该功能局限性的关键部分。现代模型使用旋转方式编码位置:key 向量根据 token 在序列中的位置携带一个旋转应用。在位置 4000 处计算的 key 与同一 token 在位置 3000 处的 key不是同一个向量。
因此,当 token 向下移动一定量时,其缓存的 key 必须调整该旋转量以匹配新位置——这是一个在缓存上一次完成的移动操作。这很便宜:它只访问一次缓存而不是运行模型。但这只对位置编码可以这样调整的模型才有定义。不同方式编码位置的架构,或者不是按 token 存储缓存而是保持状态的架构,根本无法进行漂移——这就是为什么这是 GGUF 专属功能,取决于底层运行时支持什么,而不是一个通用方案。
Shifting 只在幸存缓存对你即将发送的提示词仍然有效时才能工作。任何改变上下文中间某个 token 的操作都会使该 token 之后的所有内容失效,因为那些后续条目是在旧 token 的视野下计算的。Wiki 明确列出了以下情况:
编辑 memory 或较早的 story 文本。上下文开头附近的更改会使其后方的整个缓存失效,你将支付一次完整的重新处理。这就是为什么编辑旧消息代价高昂而追加新消息则不然。
World Info。条目是根据已经说过的话注入到上下文中的,因此注入的块在轮次之间会发生变化。在提示词中间变化的块会破坏漂移所依赖的匹配。
--noshift —— 显式关闭开关。
非 GGUF 模型。该功能仅限 GGUF。
有一个兜底方案:SmartContext,这是更老的做法,它预留大约一半上下文作为备用缓冲区,这样只在每隔一次溢出时重新处理而不是每次都重新处理。Context Shifting 在两者都启用时会覆盖它,而且恰恰因为它完全不消耗上下文空间所以更好。如果你运行的模型无法使用漂移,SmartContext 是你剩下的选择。
关于漂移对对话做了什么,有一点需要坦诚:最早的 token 没了。模型不是在总结它们或压缩它们,而是在丢弃它们,所以长对话是在自身历史上滚动的窗口。这与 LM Studio 溢出策略讨论中讨论的权衡殊途同归,只是路线不同。
KoboldCpp 默认启用 Context Shifting,提供 --noshift 来关闭它。上游 llama.cpp 的 server 做了相反的选择:其 README 记录了 --context-shift、--no-context-shift,默认列为禁用,所以普通的 llama-server 除非你主动要求否则不会漂移。
如果你在比较这两者,这个差异值得了解,因为它产生的症状正是人们报告的「llama.cpp 在长对话中更慢」。它的生成并不更慢;它在窗口边界处做了不同的事情。上游保守的原因是上面的坦诚说明——静默丢弃上下文开头会以 server 运营商可能不希望的方式改变结果,默认选择明确失败而非静默截断,对于通用 server 来说是一个站得住脚的做法。
默认启用哪个 flag 正是这类在两个项目的版本之间可能发生变化的事情,方向不限。上述机制不变;变的是默认值。在你运行的版本上查 --help,而不是相信这里的任何说法。