我花了几天时间,把 Qwen 3.5 的「KV Cache 失效」问题追踪到了一行模板代码

3 阅读5分钟

我花了几天时间,把 Qwen 3.5 的「KV Cache 失效」问题追踪到了一行模板代码

本文约 2500 字,阅读需要约 8 分钟


前言

如果你在本地跑 Qwen 3.5 系列模型搭建 AI Agent,并且发现每次后续对话都异常慢——哪怕只是一句"好的,继续"——那么这篇文章可能直接帮你省下大量时间。

我最近在优化一个本地 agent 工作流,模型需要先加载几万 token 的上下文,然后进行工具调用。按理说,第二轮对话时前缀缓存(KV Cache)应该直接命中,后续处理只需要计算新增的少量 token。

但现实是,每一轮对话都在重新处理全部上下文。


一、问题现象

场景如下:

  1. 用户发送一段包含大量背景信息的 prompt(比如 8000+ token)
  2. 模型完成第一轮工具调用,输出结果
  3. 用户追问一个简单问题(比如 20 token)
  4. 模型重新处理了全部 8000+ token,而不是只处理新增的 20 token

这在 M 系列芯片的 Mac 上表现得尤为明显:每轮对话都有明显的停顿,Token 生成速度在 PP(Prompt Processing)阶段居高不下。

起初以为是硬件或框架的问题,换了 oMLX、LM Studio 都一样。后来开始怀疑是模型本身的 prompt 格式问题。


二、排查过程

2.1 前缀缓存的原理

先简单回顾一下 KV Cache 前缀复用的原理:

推理引擎会把历史对话的 Key-Value 计算结果缓存下来。如果新一轮请求的 prompt 前缀 token 序列完全一致,就可以直接复用,只计算新增部分。

关键词是:完全一致。哪怕只有一个 token 不同,缓存就会失效,从差异处开始重新计算。

2.2 定位 Prompt Drift

我开始打印每一轮请求实际发送给模型的 token 序列,并逐轮对比。

发现了一个诡异的现象:

第一轮发送的 prompt 和第二轮发送的历史部分,token 序列不完全相同。

具体来说,第二轮里,历史回合的内容里多了一些额外的标签。

进一步排查,发现问题出在 Jinja 聊天模板的渲染逻辑上。

2.3 找到罪魁祸首

Qwen 3.5 使用了带有推理(thinking)支持的聊天模板。模板里有这样一段逻辑(简化后):

{%- for message in messages %}
  ...
  {%- if loop.index0 > ns.last_query_index %}
    <think>{{ message.reasoning_content or '' }}</think>
  {%- endif %}
{%- endfor %}

问题就在这里:即使 reasoning_content 为空,这段代码依然会渲染出 <think></think> 空标签

这意味着:

  • 第一轮对话时,实际发送的 prompt 里没有空的 <think></think>(因为那一轮还不是"历史")
  • 第二轮对话时,第一轮的历史被序列化,插入了 <think></think> 空标签
  • 两次 prompt 的 token 序列产生了差异 → 缓存失效

这就是 Prompt Drift(提示漂移):同样的对话内容,在不同时机序列化时,产生了不同的 token 序列。


三、修复方案

修复非常简单,一行代码:

{# 修复前 #}
{%- if loop.index0 > ns.last_query_index %}

{# 修复后:增加 reasoning_content 非空判断 #}
{%- if loop.index0 > ns.last_query_index and reasoning_content %}

加上 and reasoning_content 这个条件,确保只有在确实有推理内容时,才渲染 <think> 标签。这样历史记录的序列化结果就和首次发送时完全一致了,前缀缓存可以正常命中。


四、如何应用这个修复

方法一:LM Studio 用户

  1. 打开模型设置
  2. 进入 Inference → Prompt Template → Template (Jinja)
  3. 找到上述那行判断条件,添加 and reasoning_content
  4. 保存并重新加载模型

方法二:使用 oMLX 的用户

参考这个 PR:omlx/pull/637,已经合并了该修复。

方法三:使用 transformers / llama.cpp 的用户

手动修改本地模型的 tokenizer_config.json 中的 chat_template 字段,应用同样的改动。

方法四:等官方修复

HuggingFace 模型页面已有相关 Discussion:Qwen/Qwen3.5-122B-A10B #22


五、效果验证

修复后,社区多位用户反馈:

"Hell yeah,it works!现在压缩时不再重新处理整个上下文了。" —— Pixer---

"Qwen3.5-27b 在 LM Studio 中不再重新处理了,运行完美。" —— ganhedd0

"OpenCode 中不再有缓存失效,流程和 Gemma4 一样流畅。" —— DarkEye1234

从实测数据来看,在长上下文 Agent 场景下,后续对话轮次的处理速度有数倍提升(取决于上下文长度)。


六、技术延伸:这个 Bug 为什么会出现?

这里有一个更深层的设计问题值得思考。

Qwen 3.5 的 thinking 模式是可选的——模型可以在推理时输出 <think> 内容,也可以不输出。聊天模板的设计者为了兼容两种情况,在渲染历史消息时统一加上了 <think> 包裹,用空字符串兜底。

这种设计在生成质量上无伤大雅(模型训练时见过这些空标签),但对缓存一致性来说是致命的。

这提醒我们:在设计聊天模板时,历史消息的序列化结果必须与原始生成时完全一致,否则任何微小差异都会破坏前缀缓存。


七、影响范围

这个 Bug 影响所有使用 Qwen 3.5 聊天模板的场景:

使用场景是否受影响
单轮对话❌ 不受影响
多轮对话(无工具调用)✅ 受影响(轻微)
多轮 Agent / 工具调用✅ 受影响(严重)
长上下文多轮对话✅ 受影响(极严重)

对于 Agent 场景和长上下文对话,这个修复的收益是最显著的。


八、小结

项目内容
问题Qwen 3.5 聊天模板渲染空 <think> 标签导致 Prompt Drift
影响KV Cache 前缀缓存完全失效,每轮对话重新处理全部上下文
根因模板条件判断缺少 reasoning_content 非空校验
修复添加 and reasoning_content 条件,一行代码搞定
受益最大多轮 Agent、长上下文对话场景

如果你在跑 Qwen 3.5 系列本地模型,强烈建议检查一下自己的模板是否已经包含这个修复。对于 Agent 工作流来说,这可能是目前最值得做的性能优化。


参考资料


如果这篇文章对你有帮助,欢迎点个赞 👍,也欢迎在评论区分享你修复后的实测效果。