我花了几天时间,把 Qwen 3.5 的「KV Cache 失效」问题追踪到了一行模板代码
本文约 2500 字,阅读需要约 8 分钟
前言
如果你在本地跑 Qwen 3.5 系列模型搭建 AI Agent,并且发现每次后续对话都异常慢——哪怕只是一句"好的,继续"——那么这篇文章可能直接帮你省下大量时间。
我最近在优化一个本地 agent 工作流,模型需要先加载几万 token 的上下文,然后进行工具调用。按理说,第二轮对话时前缀缓存(KV Cache)应该直接命中,后续处理只需要计算新增的少量 token。
但现实是,每一轮对话都在重新处理全部上下文。
一、问题现象
场景如下:
- 用户发送一段包含大量背景信息的 prompt(比如 8000+ token)
- 模型完成第一轮工具调用,输出结果
- 用户追问一个简单问题(比如 20 token)
- 模型重新处理了全部 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 用户
- 打开模型设置
- 进入 Inference → Prompt Template → Template (Jinja)
- 找到上述那行判断条件,添加
and reasoning_content - 保存并重新加载模型
方法二:使用 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 工作流来说,这可能是目前最值得做的性能优化。
参考资料
如果这篇文章对你有帮助,欢迎点个赞 👍,也欢迎在评论区分享你修复后的实测效果。