Skip to content

NeoCode 消息发送缓存命中率重设计 #606

@phantom5099

Description

@phantom5099

状态: Draft
组件: Context Builder(Stable Prefix / Dynamic Suffix)、Runtime Provider Request、OpenAI-compatible / Anthropic / Gemini Provider Adapter、Token Usage 账本与事件
日期: 2026-05-10
相关技术: Prompt Caching, Context Caching, ReAct Loop, Provider Adapter, Token Usage Ledger, Stable Prefix, Tool Schema Canonicalization


1. 摘要

本 RFC 针对 NeoCode 当前消息发送链路的 prompt cache 命中率低、命中情况不可观测、provider 缓存能力未充分接入的问题提出重设计方案。当前设计的核心矛盾是:系统每轮把稳定规则、运行态状态、仓库检索结果、Todo、Git 摘要与临时提醒拼成一个 system prompt,导致 provider 侧用于缓存的前缀频繁变化

主流 provider 到 2026-05-10 的方案已经很清晰:

  1. OpenAI-compatible:自动缓存长输入前缀,建议把静态内容放在 prompt 最前面,并通过 cached_tokens 观测命中
  2. Anthropic:显式在 system/tools/messages 上标记 cache_control 断点,并通过 cache_read_input_tokens / cache_creation_input_tokens 观测命中
  3. Gemini:支持 implicit caching,并可通过 cachedContent 显式创建和复用上下文缓存

本方案不是简单增加一个“缓存开关”,而是从以下五个层面重新设计:

  1. 上下文分层:Context Builder 输出 StableSystemPromptDynamicSystemPrompt
  2. 请求抽象:Provider 请求携带 provider-neutral PromptCache hint
  3. Provider 适配:OpenAI 解析命中指标,Anthropic 接入 cache_control,Gemini 接入 cachedContent
  4. 工具稳定化:Tool schema 稳定排序,skill hints 不再重排工具数组
  5. 可观测性:Usage、Runtime Event 与账本记录 cache read/create/cached token

2. 背景与问题

当前系统已经具备:

  1. internal/context 的统一 prompt 构建能力
  2. internal/runtime 的 provider request 冻结与 token usage 调和能力
  3. OpenAI-compatible、Anthropic、Gemini 等 provider adapter
  4. compact / micro compact / repository context / todo / skills 等上下文投影能力

但当前仍有几个关键问题:

2.1 System Prompt 混合了稳定内容和高频变化内容

newPromptSources 当前把 core prompt、capabilities、rules、task state、plan mode、todos、skills、repository context、system state 按顺序合成同一个 system prompt。前半部分相对稳定,后半部分每轮都可能变化。

这意味着 provider 看到的是一个整体字符串:Todo revision 变化、git dirty 变化、repository retrieval query 变化、hook reminder 注入,都会改变 system prompt 的 token 序列。

缓存前缀不是“有很多重复文本”就能命中,而是要求前缀稳定。

2.2 动态段落出现在 provider 缓存敏感区域内

当前 Task StateTodo StateRepository ContextSystem State 都在 system prompt 中。它们并非不能发送给模型,而是不应混入稳定缓存前缀:

  • Todo Staterev、status、created order 会随执行变化
  • Repository Context 的 targeted retrieval 与 changed files 会随用户意图和工作区变化
  • System State 的 git dirty/ahead/behind 会随工具执行变化
  • hook notification / progress reminder 是临时状态,只应该进入动态尾部

这些内容放在稳定前缀中,会让 provider 每轮创建新缓存,而不是读取旧缓存。

2.3 Provider 缓存能力没有统一抽象

GenerateRequest 当前只有 SystemPromptMessagesToolsThinkingConfig。这使 runtime 无法表达“这段是稳定前缀,那段是动态尾部”,provider adapter 也无法基于通用语义做差异化映射。

结果是:

  • OpenAI-compatible 只能依赖自动缓存,且无法传稳定 cache key
  • Anthropic 无法对稳定 block 加 cache_control
  • Gemini 无法管理 cachedContent

2.4 即使命中,NeoCode 也可能统计不到

provider/types.Usage 当前只记录:

InputTokens
OutputTokens
TotalTokens
InputObserved
OutputObserved

但主流 provider 的缓存指标不在这些字段里:

  • OpenAI:prompt_tokens_details.cached_tokensinput_tokens_details.cached_tokens
  • Anthropic:cache_read_input_tokenscache_creation_input_tokens
  • Gemini:cached_content_token_count

如果不扩展 usage 结构,TUI / runtime / ledger 都无法回答“本轮命中率是多少”。

2.5 Tool schema 顺序会被 skill hints 扰动

Runtime 会调用 prioritizeToolSpecsBySkillHints 调整工具顺序。这对模型提示优先级有帮助,但对缓存不友好:工具数组顺序变化会改变 provider 请求前缀。

工具 schema 是最适合缓存的内容之一,应该稳定排序。技能对工具的偏好应进入 dynamic prompt,而不是改变工具数组。


3. 典型用户场景

场景 1:同一会话连续发送消息,但缓存命中率很低

旧行为
用户在同一项目中连续让 NeoCode 修复问题。AGENTS.md、核心 prompt 和工具 schema 大部分相同,但每轮 Todo StateSystem StateRepository Context 都变。provider 看到的 system prompt 前缀不断变化,缓存创建多、读取少。用户感受到首 token 慢、输入 token 成本高。

新行为
AGENTS.md、核心 prompt、固定能力说明和工具 schema 进入稳定前缀;Todo、Git、retrieval、hook reminder 进入动态尾部。连续请求复用同一稳定前缀,OpenAI/Anthropic/Gemini 都能读到缓存。

场景 2:切换 skill 后工具 schema 顺序变化,缓存被打断

旧行为
激活某个 skill 后,prioritizeToolSpecsBySkillHints 把相关工具排到前面。工具集合没变,但数组顺序变了,provider 请求结构变化,缓存命中下降。

新行为
工具 schema 始终按工具名稳定排序。skill hints 被渲染到 dynamic prompt,例如“本轮优先考虑这些工具”。模型仍能看到偏好,provider 缓存前缀保持稳定。

场景 3:Anthropic 用户以为开启了缓存,但实际没有 cache_control

旧行为
NeoCode 只是把 system prompt 放到 params.System,没有标记 cache_control。Anthropic 不会按预期建立显式缓存断点,usage 中也没有被统一展示的 cache read/create 指标。

新行为
Anthropic adapter 把 stable system prompt 作为带 cache_control 的 text block,dynamic system prompt 作为普通 block。返回 usage 后,NeoCode 把 cache_read_input_tokenscache_creation_input_tokens 写入统一 usage。

场景 4:Gemini 长规则/长工具说明每轮重复发送

旧行为
Gemini 每轮都收到完整 system instruction 与工具声明。即使 provider 有 implicit cache,NeoCode 也没有显式管理 cachedContent,无法稳定复用长上下文缓存。

新行为
NeoCode 为 Gemini 建立进程内 cachedContent manager。稳定前缀 hash 命中时直接复用 cached content name;创建失败时降级为普通请求,不影响主链路。

场景 5:用户问“缓存命中率为什么低”,系统没有证据

旧行为
Runtime 只上报 input/output/total token。即使 provider 返回缓存字段,也被 adapter 忽略。排查只能靠猜。

新行为
每轮 token_usage event 带 cached_input_tokenscache_creation_input_tokenscache_hit_ratio。排查时能明确看到是前缀没稳定、provider 未返回缓存字段,还是缓存 TTL 过期。


4. 设计目标

本方案要求同时满足:

  1. 稳定 prompt 前缀必须与运行态动态信息分离。
  2. Provider 缓存能力通过统一抽象接入,差异收敛在 internal/provider
  3. 未支持缓存的 provider 必须保持现有行为,不影响主链路。
  4. 缓存命中率必须可观测,可从 usage/event/ledger 中直接计算。
  5. Tool schema 必须按稳定规则排序,不能被 skill hints 扰动。
  6. 缓存失败必须降级为普通请求,不得导致用户请求失败。

5. 非目标

本 RFC 不处理:

  1. 不改变 TUI / Gateway / Runtime / Provider / Tools 主链路。
  2. 不引入本地响应缓存,不缓存模型输出。
  3. 不缓存用户消息、工具结果或敏感附件内容。
  4. 不默认启用 OpenAI 24h extended retention。
  5. 不要求所有 provider 都实现显式缓存;不支持的 provider 直接忽略 cache hint。
  6. 不把 provider 特定字段泄漏到 runtimecontext

6. 核心设计

6.1 Context Builder 输出 Stable / Dynamic 两段

扩展 BuildResult

type BuildResult struct {
SystemPrompt         string
StableSystemPrompt   string
DynamicSystemPrompt  string
Messages             []providertypes.Message
}

SystemPrompt 继续保留,值为 stable + dynamic 拼接,保证旧调用路径兼容。

稳定段只包含长期稳定内容:

  1. Core prompt sections
  2. Capabilities 的稳定部分
  3. Project / Global Rules
  4. 固定安全边界、工具使用原则、上下文管理原则

动态段包含运行态内容:

  1. Task State
  2. Current Plan / Plan Mode
  3. Todo State
  4. Active Skills
  5. Repository Context
  6. System State
  7. Progress reminder / hook notification / pending system reminder

这样设计的原因是:provider 缓存吃的是稳定前缀,不是系统 prompt 这个字段名本身。把动态信息从稳定段里剥离,才能让连续请求共享同一段 token 前缀。

6.2 增加 provider-neutral PromptCache hint

扩展 provider/types.GenerateRequest

type GenerateRequest struct {
Model              string
SystemPrompt       string
Messages           []Message
Tools              []ToolSpec
ThinkingConfig     *ThinkingConfig
SessionAssetReader SessionAssetReader
PromptCache        PromptCacheHint
}

type PromptCacheHint struct {
Enabled             bool
StableSystemPrompt  string
DynamicSystemPrompt string
StablePrefixKey     string
}

StablePrefixKey 由 runtime 计算:

sha256(provider_identity + model + stable_system_prompt + canonical_tool_schema)

这样设计的原因是:runtime 只知道“哪段稳定”,不知道 provider 要怎么缓存;provider 只知道“怎么映射到厂商协议”,不应重新猜哪些内容稳定。PromptCacheHint 是二者之间的最小契约。

6.3 Runtime 组装请求时冻结稳定前缀

prepareTurnBudgetSnapshot 中:

  1. 调用 context builder 获取 stable/dynamic prompt
  2. 调用 tool manager 获取工具 schema
  3. 对 tool schema 做稳定排序与 canonical hash
  4. 构造 PromptCacheHint
  5. SystemPrompt 仍使用 stable + dynamic + reminder 的最终拼接结果

临时提醒处理规则:

  • withProgressReminder
  • pendingSystemReminder
  • hookNotificationsForTurn

这些内容全部进入 dynamic system prompt 末尾,不得混入 stable prompt。

这样设计的原因是:提醒是最典型的单轮动态信息。把它拼进 stable prompt 会让缓存每轮失效。

6.4 Tool schema 稳定排序,skill hints 改为动态提示

删除或停止在主请求路径使用 prioritizeToolSpecsBySkillHints 对工具数组重排。

新规则:

  1. provider 请求中的 ToolsName 字典序稳定排序
  2. 同名工具保留原始相对顺序作为兜底
  3. skill hints 渲染为 dynamic prompt 中的“preferred tools”段落

示例:

## Skill Tool Hints

- Prefer these tools when relevant: filesystem_read_file, filesystem_grep

这样设计的原因是:工具 schema 通常很长且稳定,是最值得缓存的请求部分。为了提示优先级改变 schema 顺序,收益小于缓存损失。

6.5 OpenAI-compatible:自动缓存 + 指标解析

OpenAI-compatible adapter 分两条路径:

  1. Chat Completions:解析 usage.prompt_tokens_details.cached_tokens
  2. Responses:解析 usage.input_tokens_details.cached_tokens

如果 SDK / compatible gateway 支持请求参数:

  • 设置 prompt_cache_key = StablePrefixKey
  • 默认 prompt_cache_retention = "in_memory"

如果不支持这些字段,则只做稳定前缀排序和 cached tokens 解析,不阻断请求。

这样设计的原因是:OpenAI 的缓存主要是自动缓存。NeoCode 最重要的工作是保证静态内容靠前、解析命中指标,并在可用时提供稳定 cache key。

6.6 Anthropic:stable block 加 cache_control

Anthropic request 构建改为:

  1. StableSystemPrompt 非空时,作为第一个 system text block
  2. 该 block 带 cache_control: {type: "ephemeral", ttl: configuredTTL}
  3. DynamicSystemPrompt 非空时,作为第二个普通 system text block
  4. 若 SDK 支持 tool-level cache control,则最后一个稳定 tool definition 也加同样 cache control

TTL 默认:

context:
  prompt_cache:
    anthropic_ttl: "5m"

可选值仅允许 "5m""1h"

这样设计的原因是:Anthropic 的显式缓存断点不是自动从字符串里推断出来的,必须在 request block 上标记。稳定和动态拆成两个 block 后,动态状态不会污染稳定缓存。

6.7 Gemini:implicit cache + cachedContent manager

Gemini v1 分两步:

  1. 先保证 stable prompt 在 system instruction 最前,并解析 cached_content_token_count
  2. 再增加进程内 cachedContent manager

cachedContent key:

provider_identity + model + stable_system_prompt_hash + tool_schema_hash

cachedContent value:

type GeminiCachedContentEntry struct {
Name      string
ExpiresAt time.Time
}

行为:

  1. key 命中且未过期:GenerateContentConfig.CachedContent = entry.Name
  2. key 未命中:创建 cached content,成功后写入 manager,再发送请求
  3. 创建失败:记录 warning,降级普通请求
  4. 请求返回 cached content 不存在或过期:删除 entry,重试一次普通请求

TTL 默认:

context:
  prompt_cache:
    gemini_ttl_sec: 600

这样设计的原因是:Gemini 明确支持显式 context cache。进程内管理足以覆盖 NeoCode 当前 TUI/daemon 的常见连续请求场景,同时避免持久化 provider cache name 带来的过期、权限和数据生命周期复杂度。

6.8 Usage 与事件补齐缓存字段

扩展 providertypes.Usage

type Usage struct {
InputTokens              int
OutputTokens             int
TotalTokens              int
CachedInputTokens        int
CacheCreationInputTokens int
InputObserved            bool
OutputObserved           bool
CacheObserved            bool
}

扩展 TokenUsagePayload

CachedInputTokens        int
CacheCreationInputTokens int
CacheHitRatio            float64
CacheObserved            bool

命中率:

cache_hit_ratio = cached_input_tokens / max(input_tokens, 1)

Ledger 调和规则:

  1. provider 返回 cache 字段 → 记录 observed cache usage
  2. provider 不返回 cache 字段 → CacheObserved=false
  3. cache 字段不参与上下文预算扣减,只用于成本/性能观测

这样设计的原因是:缓存优化必须先可观测。没有 cached/read/create token,就无法判断是前缀不稳定、TTL 过期、provider 不支持,还是 adapter 没解析。

6.9 配置开关与降级策略

新增配置:

context:
  prompt_cache:
    enabled: true
    anthropic_ttl: "5m"
    gemini_ttl_sec: 600

默认行为:

  1. enabled=true,但 provider 可按能力降级
  2. 配置非法时启动阶段失败
  3. 单次 cache create/read 失败不得让用户请求失败
  4. provider 不支持 cache hint 时忽略,不报错

这样设计的原因是:缓存是性能与成本优化,不应成为主链路可用性的单点风险。


7. 与现有模块的关系

context

  • BuildResult 增加 StableSystemPromptDynamicSystemPrompt
  • newPromptSources 拆成 stable sources 与 dynamic sources
  • composeSystemPrompt 保持兼容,用于生成旧字段 SystemPrompt
  • 新增测试确保 dynamic 内容不会进入 stable prompt

runtime

  • prepareTurnBudgetSnapshot 填充 PromptCacheHint
  • tool specs 在进入 request 前稳定排序
  • skill hints 改为追加到 dynamic prompt,不再重排 tool schema
  • newTurnBudgetUsageObservation、ledger reconcile、emitTokenUsage 支持 cache usage

provider/types

  • GenerateRequest 增加 PromptCache
  • Usage 增加缓存字段
  • 保持 JSON tag 使用 snake_case

provider/openaicompat

  • Chat Completions adapter 解析 prompt_tokens_details.cached_tokens
  • Responses adapter 解析 input_tokens_details.cached_tokens
  • 支持时透传 prompt_cache_keyprompt_cache_retention
  • 不支持时保持现有请求结构

provider/anthropic

  • BuildRequest 使用 stable/dynamic system block
  • stable block 添加 cache_control
  • usage 解析补齐 cache read/create 字段
  • TTL 从 runtime config 注入 provider runtime config

provider/gemini

  • BuildRequest 支持 cached content name
  • Provider 持有进程内 cachedContent manager
  • usage 解析补齐 cached content token 字段
  • 创建/读取失败降级普通请求

config

  • 新增 PromptCacheConfig
  • 默认启用
  • 校验 anthropic_ttl 只允许 "5m" / "1h"
  • 校验 gemini_ttl_sec > 0

docs

  • 新增 docs/prompt-cache-hit-rate-redesign.md
  • 后续实现时同步更新 docs/guides/configuration.md
  • 实现完成后可新增 docs/prompt-cache.md 作为用户排查指南

8. 测试场景

  1. 同一 BuildInput 只改变 Todo revision,StableSystemPrompt 不变,DynamicSystemPrompt 变化。
  2. 同一 BuildInput 只改变 git dirty,StableSystemPrompt 不变。
  3. 同一 BuildInput 只改变 repository retrieval query,StableSystemPrompt 不变。
  4. Project Rules 内容变化时,StablePrefixKey 变化。
  5. Tool schema 顺序输入不同但集合相同,canonical tool hash 相同。
  6. Active skill hints 变化时,tool schema 顺序不变,dynamic prompt 变化。
  7. OpenAI Chat Completions usage 带 prompt_tokens_details.cached_tokens 时,Usage.CachedInputTokens 正确。
  8. OpenAI Responses usage 带 input_tokens_details.cached_tokens 时,Usage.CachedInputTokens 正确。
  9. Anthropic request 的 stable system block 带 cache_control,dynamic block 不带。
  10. Anthropic usage 带 cache_read_input_tokenscache_creation_input_tokens 时,统一 usage 正确。
  11. Gemini cachedContent manager 命中时,request 使用 cached content name。
  12. Gemini cachedContent 创建失败时,请求降级为普通 generate,不返回错误。
  13. Gemini cached content 过期时,删除 entry 并普通请求重试一次。
  14. context.prompt_cache.enabled=false 时,各 provider 不发送显式 cache 控制,但仍可解析 provider 返回的 cache usage。
  15. Provider 不返回 cache 字段时,CacheObserved=false,input/output usage 仍正常调和。

9. 假设与默认决策

  1. OpenAI-compatible 的 v1 优先做命中指标解析与稳定前缀;prompt_cache_key / retention 参数按 SDK 与网关能力 best effort 接入。
  2. OpenAI 24h extended retention 不默认开启,避免改变数据保留语义。
  3. Anthropic 默认 TTL 为 "5m""1h" 仅通过配置显式启用。
  4. Gemini cachedContent 只做进程内复用,不把 cache name 持久化到 session 或配置文件。
  5. 缓存字段不参与 context budget 计算,只用于成本、性能和命中率观测。
  6. Prompt cache 失败不影响主链路;任何显式缓存失败都降级为普通请求。
  7. Tool schema 稳定排序优先级高于 skill hints 的工具顺序偏好。
  8. 动态 prompt 仍完整发送给模型,本方案不牺牲模型可见运行态上下文。

10. 一句话结论

NeoCode 当前缓存命中率低的问题不是“provider 没有缓存”,而是“NeoCode 没有给 provider 一个稳定可缓存的前缀”。新架构通过 StableSystemPrompt / DynamicSystemPrompt 分层PromptCacheHint 统一请求抽象OpenAI / Anthropic / Gemini provider 适配cache usage 可观测性,把缓存从碰运气变成可设计、可验证、可排查的主链路能力。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions