ZCode 开源之后,我第一件事不是去装来玩,而是翻它的架构设计和指标说明。圈子里讨论最多的其实是另一件事:98.6% 的缓存命中率。这个数字容易被当成一句宣传口号划过去,但对长期跑终端型 AI 编程代理的人来说,它意味着完全不同的东西——每月的 API 账单、每次任务响应的速度、以及这个工具能不能扛住一整天的高强度编码会话。
我为什么对缓存命中率这么敏感?因为终端代理的工作方式决定了它是一个天然的“token 吞噬机”。你每让它读一个文件、跑一条命令、改一处代码,它都要把整个上下文重新发给模型一次。上下文越长、会话轮次越多,重复计算的部分就越夸张。缓存命中率,就是按住这部分浪费的闸门。
这篇文章不聊开源公告本身,也不做功能对比,重点拆 98.6% 背后到底有哪几层缓存、需要付出多少上下文工程的细节才能把数字顶到这么高,以及开源之后我们这些做工具、选工具的人能从中抄到什么作业。全文是工程视角,但原理和计算我都会拆开讲,刚接触这块的读者跟起来也不吃力。
1. 缓存命中率,为什么是 AI 编程代理的“隐形生死线”
1.1 一次会话要烧掉多少 token:先算一笔账
先看终端型 AI 编程代理的工作方式。你给 ZCode 一个任务,比如“把登录模块的超时逻辑修一下”,它会进入一个循环:先读代码、搜索关键函数、跑测试、改文件、再验证结果。每一步都是一次模型 API 调用,每次调用的输入都包含“系统提示词 + 工具定义 + 全部历史对话 + 最近一次工具返回结果”。
关键在于,这个输入是累积的。第一次调用上下文 20k,第二次就变成 20k 加新增内容,第三、第四次继续膨胀。一个中等规模的任务跑 30 到 50 轮 agent loop 是常态,长一点的会话上下文能涨到几十万 token。
做个最简单的估算。假设稳定上下文 40k token,每轮新增 300 token,跑 50 轮:
- 没有前缀缓存时,每一轮都要重算全部历史。总处理量 = 50 × 40k + 300 × (1+2+...+49) ≈ 2,000k + 367.5k ≈ 236.75 万 token。
- 有前缀缓存时,只有第一轮需要完整算 40k,后面每轮只需算新增的 300 token。总处理量 ≈ 40k + 49 × 300 ≈ 5.47 万 token。
同样一个任务,计算量差了 40 多倍。这就是为什么我说缓存命中率是隐形生死线:它不直接出现在功能列表里,但每天都在用真金白银和响应速度投票。
1.2 命中率低,不只是多花钱那么简单
账单是第一个被影响的。主流模型 API 对“缓存读取 token”和“普通输入 token”普遍是区别定价的,缓存读取往往只有正常输入价格的十分之一甚至更低。命中率高,意味着大量历史上下文都以低价甚至免费的方式被读取;命中率低,等于每一轮都在为前面所有轮次重复全额付费。
第二个被影响的是延迟,具体说是 TTFT(首 token 生成时间)。模型服务端在处理请求时要先对整个输入序列做 prefill 编码,上下文越长这一步越慢。前缀缓存命中后,服务端跳过了 prefill 的大部分计算,直接从缓存继续生成,TTFT 明显下降。对一个高频交互的编码工具来说,TTFT 从 3 秒掉到 0.6 秒,体验上的差距不是“快了一点”,而是“从等得烦躁变成基本无感”。
第三个是稳定性和并发。缓存命中率高时,实际计算压力小;厂商对缓存读取的额度通常会比真实计算宽松。反过来,如果命中率忽高忽低,工具的表现就神一阵鬼一阵,排障时也很难判断问题是出在模型推理还是上下文组装。
所以说,98.6% 这个数字对产品团队是成本指标、是体验指标、也是工程质量的综合体现。做 C端 应用的缓存命中率到 98% 不稀奇,但在上下文结构高度动态的 agent 场景里,这个数字值得认真看看。
2. 98.6% 到底算的哪一种缓存
2.1 三层缓存,别混在一起说
公开讨论里经常把“缓存命中率”当成一个笼统的指标,但做工程的人都知道,不定义统计口径的数字没有意义。终端型 AI 编程代理场景下的缓存其实可以拆成三层:
| 缓存层 | 位置 | 匹配粒度 | 典型命中来源 |
|---|---|---|---|
| 前缀缓存(KV Cache) | 模型服务端 | 前缀逐 token 精确匹配 | 稳定的系统提示词、工具定义、历史对话 |
| 语义缓存 | 客户端/中间层 | 请求级相似度匹配 | 重复或相近的用户意图 |
| 动作/文件缓存 | 客户端 | 幂等操作结果复用 | 重复读取同一文件、同一目录列表、同一条 git 查询 |
98.6% 通常不是某一层单独贡献的,而是三层叠加后的总体效果。宣传口径里提到的缓存命中率,按 token 数算和按请求数算会差不少。按请求数算容易偏高,因为很多工具调用只带少量新 token,稍微命中一下就计入一次命中;按 token 数算更反映真实的计算节省。我下面的分析统一按 token 口径来。
2.2 前缀缓存:厂商侧的红利
前缀缓存(也就是常说的 KV Cache / Prefix Cache)是这里的大头。Transformer 模型在生成回答时,需要先读一遍整个输入序列,算出每个 token 对应的中间状态。这个中间状态就是 KV Cache。如果下一次请求的输入序列跟之前某个请求共享一段完全一致的前缀,服务端就可以直接复用那段缓存,只对新增部分做计算。
原理听起来简单,但它有一个非常苛刻的前提:前缀必须完全一致,精确到 token 级别。也就是说,同样的系统提示词里多了一个空格、换了一个标点、加了一串时间戳,前缀就断了,缓存全部失效。
这解释了为什么优秀的 agent 框架对系统提示词管理近乎偏执。系统提示词必须是一个“全会话保持不变”的常量,工具定义的顺序和 JSON Schema 格式也不能变。把这些都做到位,厂商侧的前缀缓存会自然生效,而且是越大越长的上下文越划算。
2.3 语义缓存与动作缓存:客户端自己攒下的家底
前缀缓存吃的是厂商红利,客户端自己还能攒两层家底。
动作缓存是最容易见效的。agent 在完成任务时会反复读取同一个文件、反复列出同一个目录、反复执行同一条 git status。这些幂等操作的结果完全可以本地缓存起来。比如 LLM 刚读完某个文件的内容,下一步又请求读同一文件,直接返回上次的内容就行,不用再让模型处理一遍。ZCode 这类工具跑 agent loop 时,这类重复读取的占比不小,命中它们能同时抬高请求级和 token 级的命中率。
语义缓存要谨慎用。简单说就是把请求做向量化,跟历史请求比对相似度,相似就直接复用结果。这个思路在问答型应用里很成熟,但编码任务的正确性要求很高,两个表面相似的请求可能因为改了某个上下文而需要完全不同的方案,所以语义缓存只能用在非常确定的地方,比如“重新解释某段代码的作用”这类高重复、低风险的需求。
2.4 算一算:代理型任务为什么天然容易出高分
现在把账算细致一点。我按 80 轮 agent loop、稳定上下文 60k token、每轮新增 150 token 来建模:
- 无缓存时总处理量 = 80 × 60k + 150 × (1+2+...+79) = 4,800k + 150 × 3160 = 5,274k token。
- 有缓存时总处理量 = 60k + 79 × 150 = 71,850 token。
- 缓存命中率 = (5,274k − 71.85k) / 5,274k ≈ 98.64%。
算出来正好接近 98.6%。这个模型的假设完全符合终端代理的真实使用模式:一个任务里,新增信息(工具调用、简短推理、观察结果)远小于被反复重发的上下文。所以这个数字不是夸大,反而说明 ZCode 在尽力维持前缀稳定,并充分吃到了模型服务端的前缀缓存红利。
3. 把命中率顶到 98.6%,背后是这些上下文工程细节
3.1 稳定前缀:系统提示词和工具定义不许乱动
去读任何一款优秀终端代理的上下文组装代码,第一眼看到的都是同一个原则:前缀必须是常量。
系统提示词放在序列最前端,全会话保持不变,甚至会长达几 k token。它包含了角色设定、任务规范、回复格式要求、技能(skill)说明等内容。这部分越稳定,前缀缓存的基础越牢固。
工具定义紧随其后。每个模型 API 调用都会带上工具列表,LLM 根据这些定义决定调哪个工具。这里最容易出问题的点有两个:一是工具定义的顺序不能乱,二是 JSON Schema 格式不能变。有些框架为了让模型“更聚焦”,会按需注入工具,今天给三个工具明天给五个工具,前缀就被切碎了,命中率直接下滑。ZCode 这类产品公开资料里强调 skill 体系,本质上也是把技能相关的固定提示词块作为稳定前缀的一部分,天然对缓存友好。
还有一个细节很多人忽略:不要在系统提示词里注入动态内容。当前时间、随机数、临时路径,这些都会让每次请求的前缀不同。如果确实需要告诉模型“现在是几点”,也应该放在消息序列的尾部,而不是头部。
3.2 只追加、不插队:会话结构的纪律
前缀稳定之后,接下来是消息序列的纪律。
每次 agent loop 产生的新消息——模型发出的工具调用、工具返回的观察结果、用户的反馈——都应该追加到消息数组的尾部。听起来简单,但很多实现会在中途插入系统级信息,比如“相关文件已更新”“历史消息过长即将压缩”,这些插入会破坏从开头算起的缓存前缀。
更要命的是消息重排。有些工具在上下文超限后会做裁剪,把早期的某条消息删掉,或者把中间部分压缩成摘要,前面的上下文就变了。正确的做法是从尾部开始裁剪,或者整体折叠成新的摘要前缀。ZCode 开源之后,这部分代码是值得重点看的:它如何设计消息结构的追加、如何避免在中间插入动态字段、如何在分支任务间共享同一个基础前缀,这些都是能直接抄的作业。
3.3 长会话的压缩与分叉
长会话总会撞上模型的上下文窗口上限,这时候就必须压缩。压缩策略对缓存命中率的影响很大。
常见的做法是把早期对话折叠成一个 summary,放在前缀位置,后续请求复用这个新前缀。但要注意:压缩后的第一次请求前缀变了,一定是一次 cache miss,这是正常代价。好的实现会选择在合适的时机触发压缩,而不是每个 turn 都尝试压缩。比如等到上下文接近 80% 阈值时一次性压缩,压缩后的会话继续稳定运行,后面几十轮又重新享受高命中率。
分支任务的处理也值得关注。终端代理在复杂任务里经常会派生子任务,比如“先分析代码结构,再设计修改方案,最后执行修改”。如果每个子任务都从头组装上下文,缓存就全浪费了。更好的做法是让子任务共享同一个基础前缀——项目结构、代码索引、核心约束都放在前面,每个子任务只在自己的尾部追加差异化内容。这样并行执行的多个子任务都能命中前面那段共享前缀。
3.4 与模型 API 的配合:显式传 session 还是靠自然对齐
前缀缓存生效还有一层“体制”问题:不同的模型服务商对缓存的实现不完全一样。有的提供显式的会话复用参数,有的纯粹靠输入前缀自然对齐。客户端能做的,就是保持每次请求的模型型号、采样参数、格式设置完全一致,让厂商侧有最高的缓存匹配概率。
实操上还有一个容易被忽视的点:请求头的稳定性。同一会话里,如果某次请求突然换了模型版本号、改了 temperature、加了多余的参数,缓存 key 就可能不匹配。我在实测 GLM 系列 API 时发现,只要前缀稳定,缓存命中率就稳定;但一旦某个版本调整了工具定义的 JSON Schema 格式,接下来半小时内命中率会有肉眼可见的下滑,直到新的前缀重新预热。所以这类产品发布新版本时,最好在发布说明里标注“工具定义格式变更可能影响缓存效率”。
4. 开源之后,缓存设计第一次被摆上台面
4.1 代码可审计,指标可复现
开源带来的最直接变化是:98.6% 从一句宣传语变成了可以被验证的工程事实。
现在你可以直接翻它的上下文组装代码,看系统提示词是不是常量、工具定义顺序是否固定、消息追加逻辑有没有破坏前缀;你也可以看缓存层的实现,确认动作缓存和语义缓存的 TTL、失效策略是怎么设计的。更重要的是,指标可以自己复现:模型 API 返回的 usage 字段里通常带有 cache_read_tokens、cache_creation_tokens 之类的统计,跑一组标准任务,算一遍命中率,就能验证这个数字是不是稳定成立。
开源也把之前的很多争议拉回到了就事论事的轨道上。代码摆在明面上,任何关于隐私、数据上传、网络行为的讨论都可以直接通过审查代码来验证,而不是停留在猜测层面。对我这种习惯先看代码再下结论的人来说,这比任何发布会都有说服力。
4.2 社区能往哪几个方向贡献
读代码只是第一步,更有意思的是社区能在哪些方向上做贡献。
一个是缓存策略的增强。目前的开源实现可能已经优化得很细,但语义缓存、跨会话共享前缀、多会话间的公共上下文复用,都还有很大的探索空间。比如同一个项目每天开工都要重新加载一遍项目索引,如果能做出一层跨会话的项目级缓存,对整个工作流的效率提升会非常明显。
一个是不同模型的适配层。各家模型的缓存规则不完全相同,有的按时间窗口失效,有的严格按前缀匹配,有的对幂等请求有额外优化。社区可以为更多模型做适配,把这套高命中率的经验复制到不同后端上。
还有一个很有价值的方向是观测工具。把缓存命中率、TTFT、token 成本做成可视化面板,加上异常告警。有了这些,用户才能在日常使用中感知到“这周工具变慢了”到底是模型问题还是缓存被什么新功能破坏了。
4.3 对自建终端代理的参考价值
如果你也在自己搭命令行 AI 代理,或者基于 ZCode 改一套内部工具,有几条原则是可以直接平移的:
- 系统提示词必须是常量,动态信息一律放尾部。
- 上下文组装只做尾部追加,禁止中间插入和重排。
- 工具调用结果回填时保持原始格式,不要加额外的解释性噪音。
- 打开 API usage 日志,持续盯 cache_read 和 cache_creation 两个字段。
这四条看着简单,我见过太多项目在第一条上就栽了跟头。系统提示词里随手拼一个当前时间,整个缓存体系就白搭了。
5. 常见问题与排查经验
5.1 命中率上不去,先查这四个地方
如果你的自建代理或者基于开源方案改的工具命中率上不去,别急着怀疑模型厂商,先按这张表排查:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 命中率持续走低 | 系统提示词里混入了动态内容 | 检查 prompt 模板中是否有时间戳、随机数、临时路径 |
| 命中率出现周期性下跌 | 工具定义格式随会话变化 | 固定工具集,或至少固定声明顺序和 JSON Schema |
| 某次更新后暴跌 | 上下文组装逻辑插入了中间内容 | 审查消息数组的更新逻辑,确认只有尾部追加 |
| 刚开始会话时命中率低 | 前缀需要预热窗口 | 观察连续多轮调用,区分瞬时 miss 和持续 miss |
这里要强调的是,缓存命中率的统计窗口很关键。单次会话的数据波动大,至少要跑一批中等复杂度的任务、统一模型版本和上下文配置,再取平均值才有参考意义。宣传里的 98.6% 大概率是长期运行数据,不是随机挑一个会话的数据。
5.2 几个容易忽略的细节
有几个细节属于“不踩一次坑不会长记性”的类型。
第一个是字符差异。token 化之后,多一个空格、空行、全角半角混用,前缀匹配就会失败。我见过一个团队改 prompt 时把中文逗号换成了英文逗号,命中率瞬间掉了二十个百分点,排查了很久才发现是标点问题。
第二个是定时任务和异步请求的缓存预热。有些厂商的缓存有准入门槛,或者预热需要先来一次完整请求。并发场景下,如果你同时发起多个相同前缀的请求,只有第一个会触发缓存创建,其他的才可能命中。所以压测时看到的命中率会比实际情况乐观。
第三个是本地动作缓存的失效问题。文件缓存在文件内容变更后必须立即失效,否则 agent 会拿着旧内容做决策。这类正确性问题比命中率数字严重得多,宁可少命中也要确保数据新鲜度。
5.3 缓存不是越高越好,取舍要清楚
最后说一个容易被忽视的反向视角:缓存命中率不是越高越好。
过度追求命中率会让你不敢调整 prompt 结构,功能迭代被缓存绑住手脚。加了新 skill、换了更优的工具定义,都可能短时间破坏缓存稳定性,但长期看是值得的。健康的做法是把缓存命中率、平均 TTFT、任务成功率放在一起看,单个指标片面的情况很多。
另外要区分“缓存命中”和“回答正确”。动作缓存命中率高但内容过期,会造成“系统很快但输出是错的”这种最危险的状态。我自己吃过这种亏,所以现在做缓存设计一定会给每个缓存项明确的失效条件和强制的数据新鲜度校验。
再回到 98.6% 这个数字。拆完这些层之后你会发现,它并不是什么神奇的算法带来的,而是把一系列朴素的工程纪律执行到位之后自然出现的副产品:稳定的前缀、严格的追加式会话管理、合理的压缩时机、以及对厂商缓存机制的理解。开源之后,这些纪律都以代码的形式摆在所有人面前,这大概是这次开源最有价值的部分。对我个人来说,最实用的收获反而是那条最简单的经验:想让缓存命中率高,不是去写多聪明的缓存,而是让你的上下文“在足够长的情况下尽量一成不变”,把一切变化都压到尾部去。照着这个原则去检查你自己的 agent,账单和延迟都会给你正反馈。