Skip to content

feat: 渠道配置没法表达「这几条渠道共用上游缓存」(暂缓) #22

Open

Description

@tokenmp

状态

暂缓。这是个真缺口(下面有代码与上游两边的证据),但它值不值取决于部署形态,
粒度也还没有真实部署来定。决定与理由在本文件最后两节,先记下来免得忘。

现象

亲和的单位是单条渠道。server/affinity.go 记的是「这个会话上一轮由哪条渠道服务」,
命中之后 orderByChannel 把那条渠道排到候选最前,池内其余候选保持配置顺序
(这个函数的注释就是这么写的:「把某个渠道的候选排到最前,池内其余候选保持配置顺序」)。

于是「换一条渠道」被当成「缓存必丢」。而上游的提示缓存不是按 key 隔离的。
Anthropic 官方文档(Prompt caching)原话:

Prompt caching uses workspace-level isolation. Caches are isolated per workspace,
ensuring data separation between workspaces within the same organization. This
applies to the Claude API, Claude Platform on AWS, and Microsoft Foundry; Bedrock
and Google Cloud maintain organization-level cache isolation.

即:缓存按 workspace 隔开,同一个组织里不同 workspace 互不可见;Claude API、
Claude Platform on AWS 与 Microsoft Foundry 都是这个粒度,Bedrock 与 Google Cloud
只有组织级隔离。

按这段:同一个 workspace 里的两把 key 用的是同一份缓存;Bedrock 与 Google Cloud
更粗,整个组织共用;不同组织之间永不共享。

后果两条:

  1. 该换的时候不换。 同一个 workspace 里的第二条 key 本来能顶上(缓存还在),
    但亲和只认记下来的那一条,把它排在最前;它不可用时退到的是配置顺序里的下一条,
    而不是「同一缓存域的兄弟」。
  2. 不该钉的时候硬钉。 同一个域内的渠道在缓存上等价,现在没有地方能表达这件事,
    只能靠配置顺序偶然对上。

仓库里现在没有这个概念,RelayKit 里也没有(搜过)。

期望

  • 配置能表达「这几条渠道共用同一份上游缓存」。
  • 亲和排序用上它:记下来的那条优先,其次是同域的其他渠道,最后才是别的域。
  • 不写时行为与 v0.5.0 相同(每条渠道自成一个域)。

形状(已倾向)

  1. config.Channel 加一个域标签字段,同名视为同一域。不写时每条渠道自成一个域,
    与 v0.5.0 行为逐字相同 —— 纯增量,标了才起作用,没标过的部署升级后什么都不变。
  2. 只改排序,不改别的:记下来的那条 → 同域的兄弟 → 别的域。
  3. 粒度按渠道(现在一条渠道一把凭据)。

已经能答的几条口径

  • 同域内按配置顺序排;不要把域塞进哈希。 冷启动时哈希的职责是摊负载,
    而同域内缓存本来就共用,摊开是免费的;按域去哈希会让同域的渠道打成平手、
    全落到配置里第一条上,把负载重新集中起来,正好把池子废掉。
  • 不动「前缀短到进不了上游缓存就不做亲和」那道闸(server/affinity.go 里
    minimum > 0 && affinity.PrefixBytes(entries) < minimum 那句)。那条闸的判据是
    「钉住只剩负载集中的坏处」,与域无关:前缀进不了缓存时,钉住仍然是纯害。
  • 不让它进 Outcome。 域标签是宿主自己写进配置的,它本来就知道,
    不需要内核再回报一次。
  • 粒度按渠道,但留一句:若将来要支持「一条渠道多把 key」(上游 new-api 就是这么做的,
    见下),标签要挪到 key 上;标在渠道上就错了。现在只写进注释与文档。

验收条件

  • 写了域标签时,亲和给出的候选顺序是:记下来的那条 → 同域的其他渠道 → 别的域。
  • 不写时排序与 v0.5.0 逐条相同(有测试对照)。
  • 标注错了不影响转发的可用性:某条渠道失败时的重试路径照旧。
  • make check 全绿。

上游 new-api 的情况(查证过)

它有渠道亲和,而且配置面比 core 现在这套大得多;但它没有缓存域这一层。
cache_scope、CacheScope、共用缓存、缓存隔离 四个词在它整个仓库零命中。

项 内容
代码 service/channel_affinity.go、setting/operation_setting/channel_affinity_setting.go、一套管理界面与 API
规则匹配 model_regex、path_regex、user_agent_include
会话键来源 key_sources 四种类型:context_int / context_string / request_header / gjson,可配多条、按顺序取第一个非空
每条规则 value_regex、ttl_seconds、session_mode(off / prefer / strict / inherit)、skip_retry_on_failure、include_using_group、include_model_name、include_rule_name、param_override_template
全局 enabled、switch_on_success、keep_on_channel_disabled、max_entries、default_ttl_seconds
内置两条规则 codex cli trace(取 gjson: prompt_cache_key)、claude cli trace(取 gjson: metadata.user_id)
缓存载体 cachex.HybridCache[int]:内存 LRU + TTL + janitor,可选 Redis 共享(多实例)
管理 GET / DELETE /api/option/channel_affinity_cache,带审计条目

两条值得记住的:

  • 它的单位也是单条渠道。 getChannelAffinityCache() *cachex.HybridCache[int],
    缓存里存的值就是渠道 ID;命中只返回这一个 ID。它和 core 一样把「换渠道」当成
    「缓存必丢」。
  • 它的多密钥轮换把同一个问题放大了一层。 一条渠道里可以放多把 key,
    MultiKeyModeRandom(随机)或 MultiKeyModePolling(轮询)挑一把;而亲和钉的是
    渠道 ID、不是 key。所以钉住渠道之后渠道内部的 key 还在轮换,那几把 key 若分属不同
    workspace,缓存照样丢,它所有的亲和设置都挡不住。

它顺手做了「用命中率验证亲和有没有用」那件事:usageCacheSignals(usage) 返回
(hit, cachedTokens, promptCacheHitTokens),按规则名、分组、键指纹统计。

价值上限(为什么暂缓)

  • 只有「同一个缓存域里配了多条渠道」才吃得到;省下的只是「记下来的那条不可用时」
    那部分命中率。
  • 上游的多密钥轮换说明,真实部署里「一条渠道装多把 key」比「多条渠道装同一账号的
    key」更常见。真要是这样,这个字段的粒度一开始就不对。
  • 所以粒度等真实部署来定,比现在按猜测把公开面钉死强。

相关零件

  • 亲和的三个部分:internal/affinity 的累积哈希链、server/affinity.go 的
    orderByChannel 与 orderByHash、进程内记忆表(FIFO 淘汰)。
  • 会话标识:请求头优先,OpenAI 的 prompt_cache_key 兜底
    (server/affinity.go 的 explicitSessionKey)。
  • 命中率的分子与分母都有了:server.Usage.PromptTokens 与 CachedTokens。
    后者在 v0.5.0 之前跨方言路径上恒为 0(issue Usage.CachedTokens 在跨方言路径上恒为 0,且没有缓存写字段 #18),现在交得对,
    所以域标签有没有用可以直接看命中率,不用凭感觉。

没查的

  • new-api 自己的 docs/ 里有没有篇章讲亲和的用途(只做了关键词检索,没通读)。
  • 它多密钥轮换时是否在别处考虑了缓存(没找到任何迹象,也没穷尽整条 key 选择路径)。
  • 它是否在 issue 或 PR 里讨论过缓存域(没翻它的 issue 列表)。

环境

core v0.5.0(dd1c7ab)。函数名与行号取自这一版。

Activity

  1. tokenmp commented on Oct 10, 2026

    @tokenmp
    OwnerAuthor

    一条影响粒度前提的补充(查证)

    Messages API 参考页的 Headers 节里有这么一条:

    anthropic-workspace-id: optional string

    Optional header to select the Workspace for this request. The value is a Workspace ID
    (for example, wrkspc_011CZkZaBF1tNoB5wlCeusgy).

    Only needed for credentials that can act on more than one Workspace. A credential that
    belongs to a specific Workspace may omit it; if sent, it must match that Workspace.

    也就是:同一把凭据可以作用于多个 workspace,选哪个是每请求用头指定的。

    这对本 issue 有两处影响:

    1. 「缓存域」未必是渠道的静态事实。 粒度可能是「渠道 × 每请求的 workspace 头」,
      而不是「渠道」。上面「形状(已倾向)」里的第 3 条(粒度按渠道)要打个问号。
    2. 反过来,这给出了一条不用加字段的达成方式:如果同一个凭据能作用于多个 workspace,
      中继可以通过设这个头把请求指到同一个 workspace,就不需要靠标签来表达域了。
      所以先确认真实部署里是「一个账号多把 key」还是「一把 key 多个 workspace」,
      再决定要不要这个字段。

    这条头该由中继或宿主决定,不从客户端放行 —— 让客户端选上游 workspace,等于让
    客户端选计费归属。这一句同时记在 issue #23(客户端协议头放行名单)里。

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions