Skip to content

feat(provider): 接入 prompt 缓存(Anthropic 断点 + OpenAI 路由键) - #732

Open
Cai-Tang-www wants to merge 8 commits into
1024XEngineer:mainfrom
Cai-Tang-www:feat/prompt-cache
Open

Cai-Tang-www wants to merge 8 commits into
1024XEngineer:mainfrom
Cai-Tang-www:feat/prompt-cache

Conversation

@Cai-Tang-www

@Cai-Tang-www Cai-Tang-www commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #733

这是 #606(Prompt 缓存 Phase 1,已关闭)规划的第二阶段。Phase 1 做完了 provider-neutral 的请求结构优化(stable/dynamic 分离、工具稳定排序),并明确把显式缓存留给后续;本 PR 就是那个后续。设计与边界写在 #733,这里只说代码改了什么、reviewer 该看哪里。

首轮 review 后调整了默认值策略:openaicompat 由"默认发送"改为"默认不发送、需显式声明支持",避免给未知网关引入 400 回归。逐条处理说明见下方评论。

改动概览

按主题分组,共 8 个提交,每个提交可独立编译与测试:

主题 提交
缓存契约与三家映射 feat(provider)
memo 移出稳定段 fix(context)
配置往返、默认值与计数修正 fix(config) / fix(provider)
responses 模式补齐 feat(provider)
链路测试 test(runtime)
文档 docs(config) × 2

1. 缓存契约

公共层只表达一个语义:哪一段系统提示是稳定前缀。不引入厂商字段。

SystemPrompt       string  // 完整文本,单一真源
SystemStablePrefix string  // 必须是它的真实前缀

ResolvedStablePrefix() 用 strings.HasPrefix 校验,不成立返回空串;VolatileSystemSuffix() 保证前缀 + 尾部按字节还原原文。设计意图是最坏情况只让缓存不生效,绝不发出错误内容 —— 这是选"单切分点 + 可验证前缀"而不是分块数组的理由。

2. 映射与默认值

driver 映射 默认
anthropic 系统提示拆稳定块 + 易变尾部;稳定块末尾与最后一个工具各打一个 cache_control: ephemeral;稳定前缀不足 1024 token 不打标 开启
openaicompat chat completions 与 responses 均发送 prompt_cache_key(模型 + 稳定前缀 + 工具名集合的 sha256 前 32 hex) 关闭,需显式 prompt_cache: auto
gemini 依赖隐式缓存,不发参数 —
deepseek / qwen / glm / mimo / minimax 不发送路由键(硬约束,显式 auto 也不发) —

默认值差异的原因:cache_control 是 Anthropic 官方协议字段,端点不会拒绝;prompt_cache_key 是 OpenAI 的可选扩展参数,而 openaicompat 服务任意兼容网关,未确认支持前发送可能让原本可用的部署返回 400,而现有 stream fallback 只处理流式解析失败、不处理 HTTP 参数拒绝。

没有做"识别 400 后剥离字段重试":错误文本匹配脆弱,且会把真实的 400 掩盖成无意义重试。代价是 OpenAI 官方用户需显式开一次。

给 tools 单独打标是因为 Anthropic 的缓存前缀顺序是 tools → system → messages:只在 system 上打,规则文件一改 tools 的缓存也跟着废。

3. 两处必须同批落地的配套修正

(a) 预算链路必须用含缓存的总输入

Anthropic 的 input_tokens 不含缓存,OpenAI 的 prompt_tokens 含。provider_stream.go 原先直接取 Usage.InputTokens 进账本:Anthropic 返回 input_tokens=500, cache_read=18000 时账本只累计 500,TokenInputTotal 严重低估,compact 永不触发、预算门禁失效,全链路无报错。

现在统一为"未命中缓存的输入"(OpenAI 侧减去 cached_tokens,并把它收敛到 [0, prompt],保证总输入不超过上报量),账本改用 Usage.TotalInputTokens()。

(b) memo 索引必须移出稳定段

memo 是 agent 自己写的记忆,记一条就变一次 —— 放进 stable 等于每写一条记忆就摧毁整个缓存前缀。已归入动态段。原测试 TestDefaultBuilderBuildMemoIsStable 锁定的正是这个行为,已改名并反转断言。

4. 五家自研 driver 为什么不发路由键

chatcompletions.Request 是 DeepSeek / Qwen / GLM / MiMo / MiniMax 自研 HTTP 实现共用的中间结构,字段会被直接 json.Marshal 进请求体。它们有各自的缓存机制(DeepSeek 按前缀全局命中),发路由键要么被忽略、要么被严格网关拒绝。

5. 可观测性

token_usage 与 ledger_reconciled 新增 cache_read_input_tokens / cache_write_input_tokens。没有这两个字段,缓存是否生效只能靠猜。

行为变更(请 reviewer 重点看)

  • Usage.InputTokens 在 OpenAI 兼容 provider 上会变小,因为扣除了缓存命中部分。总输入由 TotalInputTokens() 表达。若有客户端直接拿 input_tokens 展示"输入 token",数字会变,需要改用总量或补上缓存明细。这是统一两家口径的必然代价。
  • providers 配置新增 prompt_cache: auto|off,支持写入 provider.yaml 并随读写往返保留;非法值在加载阶段报错。留空时按 driver 取默认值(见上表)。
  • 未显式开启的 openaicompat 端点,请求体与改动前完全一致。

测试

新增 11 个测试文件。重点覆盖:

  • 前缀校验与字节级还原、最短长度门槛、禁用开关、工具断点
  • 默认值矩阵(driver × 配置)、driver 门控(含大小写与空白归一化)、显式 auto 也不能让自研厂商发送
  • 配置往返:保存后重载一致、手写 prompt_cache 可加载、非法值被拒
  • cached_tokens 归一化与区间收敛(全命中 / 超额 / 负数)
  • 缓存 usage 穿过生成与对账链路(断言累计值与两个事件字段)

本地 go build ./... 通过;internal/provider/...、internal/config、internal/context/...、internal/runtime 全绿。

本机(Windows)另有 3 个失败与本次改动无关,已用 git stash 在干净树上复现:TestMarshalPayloadJSONSchemaMatchesGeneratedFile、TestBuildFrontendAndReadGatewayToken、TestExecutorRunOverheadWithinThreshold(性能阈值 flaky)。

未覆盖

TTL 配置、TUI/Web 命中率展示、评测基线纳入成本指标 —— 都在 #733 的未勾选项里,不阻塞本 PR。

公共层只表达"哪一段系统提示是稳定前缀",各 adapter 按自家协议映射:

- anthropic:拆稳定块与易变尾部,稳定块与最后一个工具各打一个
  cache_control: ephemeral 断点;稳定前缀不足 1024 token 时不打标,
  避免支付写入溢价却拿不到命中
- openaicompat:发送 prompt_cache_key(模型 + 稳定前缀 + 工具名集合哈希),
  提升自动前缀缓存的同前缀路由命中率
- gemini:依赖隐式缓存,不发送额外参数

两处配套修正必须同时落地,否则接入后行为错误且没有任何报错:

- Usage.InputTokens 统一为"未命中缓存的输入"(OpenAI 侧减去 cached 并在
  厂商异常数据下收敛为 0),预算链路改用 TotalInputTokens。Anthropic 的
  input_tokens 不含缓存,直接取值会让 TokenInputTotal 严重低估,导致
  compact 永不触发、预算门禁失效
- prompt_cache_key 按 driver 门控:chatcompletions.Request 是五家自研厂商
  共用的中间结构,字段会无条件扩散进请求体,DeepSeek / Qwen / GLM / MiMo /
  MiniMax 一律不发送

token_usage 与 ledger_reconciled 事件透出 cache_read / cache_write 明细,
供客户端验证命中率。新增 provider 级配置 prompt_cache: auto|off,非法值在
启动阶段校验失败。
memo 是 agent 自身写入的会话记忆,用 memo_remember 记一条就变一次。
原先 NewConfiguredBuilder 把外部 SectionSource 追加进 stable 段,意味着每写
一条记忆都会让整个 prompt 缓存前缀失效:实际命中率趋近于 0,token 成本照烧,
用户完全无感。

改为归入动态段并排在最前。稳定段只保留角色定义与工作区规则文件(AGENTS.md
一类),它们的变化频率足够低,配得上"缓存前缀"这个定位。

原测试 TestDefaultBuilderBuildMemoIsStable 锁定的正是这一错误行为,改名为
TestDefaultBuilderBuildMemoStaysInDynamicSegment 并反转断言,同时补上理由注释。
- configuration.md 增加 prompt_cache 配置项说明、三家 driver 的映射方式,
  以及缓存字段进入 usage 后的口径变化
- runtime-provider-event-flow.md 补充 ledger_reconciled 的
  cache_read_input_tokens / cache_write_input_tokens 字段,并说明
  input_tokens 按"含缓存的总输入"统计
@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.98131% with 30 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/provider/prompt_cache.go 65.21% 16 Missing ⚠️
internal/provider/anthropic/provider.go 11.11% 4 Missing and 4 partials ⚠️
internal/provider/anthropic/request.go 94.28% 1 Missing and 1 partial ⚠️
internal/provider/estimate.go 60.00% 1 Missing and 1 partial ⚠️
internal/provider/openaicompat/generate_sdk.go 0.00% 1 Missing and 1 partial ⚠️
Flag Coverage Δ
unittests 80.28% <85.98%> (+0.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
internal/config/provider.go 87.96% <100.00%> (+0.30%) ⬆️
internal/config/provider_custom_normalize.go 92.12% <100.00%> (+0.12%) ⬆️
internal/config/provider_loader.go 91.17% <100.00%> (+0.15%) ⬆️
internal/context/builder.go 96.42% <100.00%> (+0.06%) ⬆️
internal/provider/contracts.go 100.00% <ø> (ø)
...l/provider/openaicompat/chatcompletions/adapter.go 68.26% <100.00%> (+2.28%) ⬆️
...l/provider/openaicompat/chatcompletions/request.go 96.47% <100.00%> (+0.05%) ⬆️
...nternal/provider/openaicompat/responses/request.go 81.81% <100.00%> (+0.86%) ⬆️
internal/provider/types/request.go 100.00% <100.00%> (ø)
internal/provider/types/usage.go 100.00% <100.00%> (ø)
... and 9 more

... and 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The review found a configuration-loading defect, a conditional gateway compatibility regression, incorrect accounting for malformed cache counts, and testing/documentation gaps. Provider, context, and runtime tests passed; the configuration suite encountered the unrelated failure below.

Unrelated issues
[P3] /workspace/internal/config/loader_test.go:1719: The unchanged permission test fails when run as root because chmod 000 does not prevent root from reading the directory.

Reviewed head commit: 1280c567bc6a0c6877448897a1a63a9c86d3a40a

  • [P1] Add prompt_cache to the provider file load/save path (internal/config/provider.go:33-34) — see inline comment
  • [P2] Gate cache keys on endpoint support (internal/provider/prompt_cache.go:35-38) — see inline comment
  • [P2] Bound cached tokens before deriving uncached input (internal/provider/openaicompat/chatcompletions/adapter.go:279-286) — see inline comment
  • [P2] Test cached usage through generation and reconciliation (internal/runtime/ledger_cache_payload_test.go:21-29) — see inline comment
  • [P3] Scope the cache-routing documentation to chat completions (docs/guides/configuration.md:289) — see inline comment

Comment thread internal/config/provider.go Resolved
Comment thread internal/provider/prompt_cache.go Resolved
Comment thread docs/guides/configuration.md Resolved
customProviderFile 使用 KnownFields(true) 解码,但结构体没有 prompt_cache 字段,
按文档在 provider.yaml 里写 `prompt_cache: off` 会直接解析失败;SaveCustomProviderInput
与读写两侧的字段映射也都缺这一项,导致该配置只能通过程序化构造生效。

补全 customProviderFile、SaveCustomProviderInput、加载双向映射、保存构造与
NormalizeCustomProviderInput,并在归一化时统一为小写,非法值由 ProviderConfig.Validate
拦截,保持保存与加载两条路径共用同一套规则。

新增往返测试:保存后重新加载取值一致、手写含 prompt_cache 的 provider.yaml 可加载、
非法值被拒绝且错误信息包含字段名。
两个 review 意见合并处理,都来自"默认开启"这一假设过于乐观:

1. 默认值改为按 driver 区分
   openaicompat 服务任意兼容网关,prompt_cache_key 属可选扩展参数。此前默认发送会让
   原本可用的网关返回 400,而现有 stream fallback 只处理流式解析失败,不处理 HTTP 参数
   拒绝,等于给已工作的部署引入回归。
   现在只有 anthropic 默认开启(cache_control 是官方协议字段);openaicompat 默认关闭,
   需在 provider 配置里显式写 prompt_cache: auto 声明端点支持。五个自研 driver 由
   SupportsPromptCacheKey 硬约束排除,显式 auto 也不发送。
   不做基于错误文本的 400 识别重试:识别脆弱且会掩盖真实错误,用能力声明更可控。

2. 缓存命中数按区间收敛
   prompt=100、cached=150 时,原来只对减法结果做下限裁剪,cached 保留 150,
   TotalInputTokens() 得 150,高于厂商上报的总量,而该值会被账本当作已观测输入持久化。
   改为 normalizeCachedPromptTokens 把 cached 收敛到 [0, prompt],总输入严格等于上报量。

顺带把 key 计算提到 provider.ComputePromptCacheKey,供 chat completions 与 responses 共用。
此前只有 chat completions 发送 prompt_cache_key,而 chat_api_mode: responses 走
responses.BuildRequest + generateSDKResponses,两条路径都不带该字段。文档里那行
映射因此对 responses 模式并不成立。

Responses API 本身支持 prompt_cache_key,所以补齐实现而不是收紧文档:
Request 增加字段,BuildRequest 复用 provider.ComputePromptCacheKey,与 chat completions
共用同一套键计算,保证同一前缀在两种模式下路由一致。默认关闭与显式开启的规则相同。
原测试直接构造已对账结果、只验证 payload 字段拷贝,若 generateStreamingMessage 取错
InputTokens 或对账丢弃缓存明细,测试仍然通过,缓存写入路径也始终为零。

新增两条回归:
- 生成链路:假 provider 上报 input=500 / read=1800 / write=1200,断言累计得 3500,
  即必须走 TotalInputTokens,否则账本低估会让 compact 永不触发
- 对账链路:已观测时必须用观测值而非估算值,且缓存明细透传到 ledger 与 token_usage 两个事件
- 字段说明从"留空等同 auto"改为按 driver 取默认值:anthropic 默认 auto,其余默认 off
- 映射表补充 responses 模式同样发送路由键,并新增五个自研 driver 不发送的说明
- 补一段默认值差异的原因:cache_control 是官方字段不会被拒,prompt_cache_key 是可选
  扩展参数,未确认支持前默认发送会让兼容网关返回 400

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

【实现】 Prompt 缓存 Phase 2:显式缓存接入与缓存用量口径统一

1 participant