Repository navigation
【实现】 Prompt 缓存 Phase 2:显式缓存接入与缓存用量口径统一 #733
Description
关联
- 前置 RFC:NeoCode 消息发送缓存命中率重设计 #606 NeoCode 消息发送缓存命中率重设计(Phase 1,已关闭)
- 实现 PR:feat(provider): 接入 prompt 缓存(Anthropic 断点 + OpenAI 路由键) #732
- 后续分层:【架构】 重构 NeoCode Agent Loop:Workflow Orchestrator、统一 AgentLoop 与上下文隔离 #721 Phase 5 的 PromptBundle(与本 issue 无重叠,见"边界")
目标问题(Why)
Phase 1(#606)已经完成 provider-neutral 的请求结构优化:Context Builder 输出 StableSystemPrompt / DynamicSystemPrompt,工具 schema 按名稳定排序,运行态信息全部后置。这让自动前缀缓存型 provider(DeepSeek、OpenAI-compatible)能吃到更长的稳定前缀。
但 Phase 1 按设计没有接入任何显式缓存能力,所以目前:
- Anthropic 侧完全没有
cache_control断点,稳定前缀每轮按全价计费。按当前模板估算,system prompt 核心部分 18611 字符(其中core/tool_usage.md独占 9200),加 19 个工具 schema,单轮输入轻易过 2 万 token;30 轮会话里固定前缀重复发送约 60 万 token。命中价是未命中的 10%,这块是当前最大的单项成本缺口。 - OpenAI 侧虽能靠自动缓存命中,但没有
prompt_cache_key,请求被负载均衡打散,命中率不稳定。 - 没有任何缓存命中量的观测字段,命中率无法验证,也无法进评测基线。
另外发现 Phase 1 遗留的一个隐患:memo 索引被放在 stable 段里。memo 是 agent 自己写入的记忆,memo_remember 记一条就变一次,等于每写一条记忆就摧毁整个缓存前缀。Phase 1 只关心"前缀是否稳定",没有把它当作易变源处理。
与 #606 的范围关系
#606 第 5 节的标题是「非目标」,首句为:
本 RFC 第一阶段不处理:
这是范围限定,不是永久排除。其中第 8 条:
不扩展 token usage、runtime event、TUI 展示或 ledger 来统计缓存命中率。
而 6.2 节同时写明了后续路径:
本 RFC 第一阶段推荐最小实现。等后续接入 Anthropic
cache_control、GeminicachedContent或 OpenAI cache key 时,再引入 provider-neutralPromptCacheHint作为正式 provider 请求契约。
本 issue 就是这句话预告的后续阶段,SystemStablePrefix 即此处的 PromptCacheHint 最小形态。因此越过第 8 条不是否定 Phase 1 的判断,而是 Phase 2 的必要条件:接入显式缓存后若不扩展 usage 与 ledger,会出现静默错误。
Anthropic 的 input_tokens 不含缓存命中与缓存写入,OpenAI 的 prompt_tokens 含。provider_stream.go 原先直接取 Usage.InputTokens 写账本。一旦打上缓存标记,Anthropic 返回 input_tokens=500, cache_read=18000 时账本只累计 500 —— session.TokenInputTotal 严重低估,compact 永不触发、预算门禁形同虚设,且全链路没有任何报错。
Phase 1 没接显式缓存,所以这个问题不存在;Phase 2 接了,它就成了必须先修的配套。所以本次同时做三件事:统一 InputTokens 口径、账本改用含缓存总量、事件透出缓存明细。
核心设计(How)
公共层只表达一个语义:哪一段系统提示是稳定前缀。不引入厂商字段。
type GenerateRequest struct {
SystemPrompt string // 渲染完成的完整文本,单一真源
SystemStablePrefix string // 必须是 SystemPrompt 的真实前缀
}
func (r GenerateRequest) ResolvedStablePrefix() string // HasPrefix 校验,不成立返回空
func (r GenerateRequest) VolatileSystemSuffix() string // 与前缀拼接严格等于原文SystemStablePrefix 是 #606 提到的 "provider-neutral PromptCacheHint" 的最小实现形式:只表达前缀边界,不表达 TTL、断点数量、保留策略等厂商细节。等真正需要 TTL 配置时再升级为结构体,避免提前抽象。
各 adapter 映射:
| driver | 映射方式 |
|---|---|
anthropic |
系统提示拆两个 text block,稳定块末尾打 cache_control: ephemeral;最后一个 tool 再打一个(工具集不变时独立命中);稳定前缀估算不足 1024 token 时不打标 |
openaicompat |
chat completions 与 responses 两种模式均发送 prompt_cache_key(模型 + 稳定前缀 + 工具名集合的 sha256 前 32 hex);默认关闭,需显式 prompt_cache: auto 开启 |
gemini |
依赖隐式缓存,不发送额外参数 |
deepseek / qwen / glm / mimo / minimax |
一律不发送路由键,见下 |
分段策略:默认值按 driver 区分,自研厂商为硬约束
分两层,解决两个不同问题。
第一层:默认值按 driver 区分(避免回归)。 cache_control 是 Anthropic 官方协议字段,端点不会拒绝,默认开启。prompt_cache_key 是 OpenAI 的可选扩展参数,而 openaicompat 服务任意兼容网关 —— 未确认支持前默认发送会让原本可用的网关返回 400,且现有 stream fallback 只处理流式解析失败、不处理 HTTP 参数拒绝。因此该驱动默认关闭,需在 provider 配置中显式写 prompt_cache: auto 声明端点支持。
第二层:五个自研 driver 是硬约束。 chatcompletions.Request 是 DeepSeek / Qwen / GLM / MiMo / MiniMax 共用的中间结构,会被直接 json.Marshal 进请求体。它们有各自的上下文缓存机制(DeepSeek 按前缀全局命中),发路由键要么被忽略、要么被严格网关拒绝,因此即使显式 auto 也不发送。
不做基于错误文本的 400 识别重试:识别脆弱,且会把真实错误掩盖成重试。用能力声明 + 保守默认更可控。
prompt_cache 支持写入 provider.yaml 并随配置读写往返保留;非法值在加载阶段报错,不会静默降级。
边界与非目标
- 不做 Gemini 显式缓存(
caches.create()需管理生命周期与 token-hour 计费,收益待隐式缓存实测后再评估) - 不做多段分层(
[]PromptBlock支持 role / workspace / workflow / node / delta 五层)。那是 【架构】 重构 NeoCode Agent Loop:Workflow Orchestrator、统一 AgentLoop 与上下文隔离 #721 Phase 5 的 PromptBundle,属编排层改造,与本 issue 的前缀打标不是同一件事 - 不改动 provider adapter 的请求内容语义,只增加可选标记字段
- 不引入本地响应缓存
落地清单(What)
-
GenerateRequest增加SystemStablePrefix与前缀校验方法 - runtime 组装请求时计算并校验稳定前缀,无法证明前缀关系时降级为空
- Anthropic:拆块 +
cache_control+ 1024 token 门槛 + 工具断点 - OpenAI-compatible:
prompt_cache_key计算与下发,chat completions 与 responses 两种模式均覆盖 - 默认值按 driver 区分:anthropic 默认开启,其余默认关闭,需显式声明
-
prompt_cache支持 provider.yaml 读写往返,非法值加载即失败 -
Usage增加CacheReadInputTokens/CacheWriteInputTokens、TotalInputTokens()、CacheHitRate() - 统一 InputTokens 口径:OpenAI 侧减去
cached_tokens并收敛负值 - 预算链路与账本改用
TotalInputTokens() -
token_usage/ledger_reconciled事件透出缓存明细 - memo 索引移出 stable 段(
fix(context)独立提交) - 新增 provider 级配置
prompt_cache: auto|off,非法值启动即失败 - 文档同步:
docs/guides/configuration.md、docs/runtime-provider-event-flow.md - TTL 配置(
prompt_cache_ttl: 5m|1h,现用 Anthropic 默认 5m) - TUI / Web 渲染命中率
- 评测基线纳入 cache hit rate 与单位任务成本(依赖 【实现】 Agent Loop 重构 Phase 0:建立任务质量评测基线与迁移护栏 #722)
验收标准(Done)
- 稳定前缀与易变尾部拼接严格等于原始
SystemPrompt(按字节) - 前缀关系不成立时不打标,行为退化为旧链路
- 稳定前缀不足最小可缓存长度时不打标,不产生无效写入溢价
-
prompt_cache: off时 Anthropic 不发送cache_control、OpenAI 不发送路由键,请求内容其余部分不变 - 五家自研 driver 的请求体不含
prompt_cache_key,显式auto也不含 -
openaicompat未显式开启时请求体与旧链路完全一致,不引入 400 回归 -
cached_tokens超过prompt_tokens时总输入收敛为上报量,不虚高 - 缓存 usage 穿过生成与对账链路后,账本累计值与两个事件字段一致
- OpenAI
cached_tokens大于prompt_tokens时 InputTokens 收敛为 0,不出现负数 - 真实调用两轮后,第二轮
cache_read_input_tokens接近稳定前缀 token 数 - 评测集上的单位任务输入成本下降幅度记录(依赖 【实现】 Agent Loop 重构 Phase 0:建立任务质量评测基线与迁移护栏 #722)
风险与回滚
| 风险 | 处理 |
|---|---|
Usage.InputTokens 语义变化影响下游消费方 |
在 PR 中标注为行为变更;事件同时提供缓存明细,消费方可改用 TotalInputTokens |
| 缓存写入溢价(Anthropic 1.25x)在短会话中不划算 | 1024 token 门槛 + prompt_cache: off 开关 |
| 某厂商对未知字段严格校验导致 400 | 五家自研 driver 已排除;openaicompat 用户可关闭 |
| 缓存键碰撞把不相关请求挤到同一后端 | 键包含模型、完整稳定前缀、工具名集合三要素 |
| 回滚 | 全部改动是可选字段与开关,prompt_cache: off 即回到 Phase 1 行为;代码层可按 commit 整体回滚,无 schema 迁移 |
Activity