zai-org / zai-org/feedback

[Bug] /api/anthropic 隐式 prompt cache 三项异常:append-only 紧邻轮无法命中上一轮前缀、cache_read 标签与实际耗时脱钩、system 前缀零缓存(附受控复现脚本)

Open
#411 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

priority: P2
Dominant language
No language data
Stars
22
Forks
1
PR merge metrics
No merged PRs in 30d

Description

提交前确认 · Pre-submission checklist
  • 我已搜索过现有 issue,确认这不是重复(最接近的是 #354,但根因与证据维度不同,见文末「与 #354 / #322 的关系」)
  • 我已阅读 CONTRIBUTING.md
问题类别 · Category

API 行为 / 性能(疑似牵连计费口径,见文末「计费疑问」)

涉及的 Agent 框架 · Agent framework

Claude Code(ANTHROPIC_BASE_URL 直连 open.bigmodel.cn/api/anthropic)。复现不依赖任何客户端——下方脚本用 Python 标准库直连端点,可排除客户端前缀不稳定等因素。

严重程度 · Severity

影响体验 · Major:多轮工具调用的长会话中,几乎每个请求都在为「已报缓存命中」的前缀支付全量 prefill 等待(92k ctx 下约 5-6s/请求;真实 15-21 万 token 会话 p50 TTFT 7.8s)。

复现频率 · Reproducibility

必现(S2 append miss、S3 system 零命中每次必现;「标签命中但耗时=全量」间歇出现,多次运行可复现)

问题描述 · Description

https://open.bigmodel.cn/api/anthropic/v1/messages 做了一组受控实验(流式、anthropic-version: 2023-06-01、无任何中转/代理),并结合一个真实 Claude Code 多轮工具调用会话的本地 transcript 逐请求分析(timestamp + usage + 延迟),发现隐式 prompt cache 存在三项异常:

  1. append-only 紧邻轮无法命中上一轮前缀:请求 N+1 的消息序列以请求 N 为逐字节前缀(Anthropic 前缀缓存的标准形态,也是 Claude Code 的实际请求形态),但 N+1 的 cache_read=0、TTFT 与全量冷 prefill 一致;隔 1-2 轮之后才会命中数轮之前的旧断点cache_read 恒等于 round1 的 input,而非紧邻上一轮)。对每 8-30s 一轮的 agent 会话,等效于每轮全量重算。
  2. cache_read 标签与实际 prefill 耗时脱钩:部分请求 usage 报 cache_read≈全部前缀,但 TTFT 与冷值完全一致(对照组数据见下)。真实 Claude Code 会话中全部请求都报了 cache_read>0,但按 TTFT 分桶仅 3% 落在真命中区间。
  3. system 前缀零缓存:system 为大前缀时(带或不带 cache_control、带或不带 context-1m beta header),连发完全相同的请求 cache_read 恒为 0。Claude Code 每个请求都携带大体积 system + tools 前缀,这部分从第一天起就没有被缓存过。

辅助观察:完全相同的请求重复发送时缓存工作良好(TTFT 1.3-1.9s vs 冷 5.5-5.7s),TTL ≥ 120s;无 cache_control 的完全相同请求也可隐式命中;cache_creation_input_tokens 从未返回过值(恒缺席/0),与 #354 观察的 cacheWriteTokens=0 一致。

复现步骤 · Steps to reproduce
export GLM_KEY="你的 API Key"
python3 glm_anthropic_cache_probe.py            # 默认 glm-5.3-flash,约 14 次请求 × 92k input

脚本全文见文末「复现脚本」,仅用 Python 标准库。

实际表现 · Actual behavior(2026-08-29 实测,glm-5.3-flash,前缀 ≈92k tokens)

冷基线(随机前缀,保证 miss):TTFT ≈ 5.5-5.7s。

场景 请求 TTFT input cache_read 判读
S1 完全相同请求 ×3 #1 / #2 / #3 5.6 / 1.5 / 1.3 s 92483 → 3 0 / 92480 / 92480 缓存基础设施可用:相同请求秒级真命中
S2 append-only ×4 round1 5.7s 92478 0 冷启动,正常
round2-append 5.7s 92490 0 上一轮是本轮的逐字节前缀,却 miss、全量重算
round3-append 5.2s 92503 0 连续第二轮 miss
round4-append 1.4s 36 92480 命中的是 round1 的旧断点(92480≈round1 的 92478,而非 round2/3 的 92490/92503)
S3 system 大前缀 ×2 #1 / #2 5.3 / 5.2 s 92466 0 / 0 system 前缀零缓存,连发也不命中
S4 无 cache_control 追加 ×3 round1/2/3 5.2 / 5.1 / 1.3 s →14 0 / 0 / 92480 追加轮同样 miss,偶发命中旧断点

补充运行记录(同日、同样脚本形态的更早一轮,n 更多):

  • 「假命中」样本:完全相同请求连发 6 次,首发 TTFT=4.2s(冷值)但 cache_read=92416 报满额命中;另有一轮 #3 报 cache_read=92416、TTFT=5.1s(冷值)。即标签记命中、算力全量重算的情况稳定间歇出现(多轮连发时约 1/6)。
  • TTL:间隔 120s 重发完全相同请求,仍真命中(1.2s)。
  • glm-5.3(非 flash)复测 S3 形态:同样零命中。
  • anthropic-beta: context-1m-2025-08-07:行为不变。

真实 Claude Code 会话佐证(一个多轮工具调用子 agent,glm-5.3-flash + 1M beta,本地 transcript 逐请求统计):

  • 全部 212 个带 usage 的 API 往返 cache_read>0100%),中位数 cache_read=205,184 tokens——按标签口径几乎每轮「命中」
  • 但按首块 TTFT 统计(n=116 个往返):<3s(真命中区间)仅 3%(4 个),p10/p50/p90 = 4.1 / 7.8 / 16.8 s——与该上下文规模的全量 prefill 耗时一致
  • 该任务累计:input 0.60M + cache_read 45.13M + output 0.11M tokens,墙钟 ~63 分钟,其中模型侧等待占 ~68%
期望表现 · Expected behavior
  1. append-only 请求(上一轮请求是本轮的逐字节前缀)应命中上一轮的完整前缀,只对新增尾部做 prefill——这是 Anthropic 前缀缓存语义,也是 Claude Code 这类 agent 在长会话下可用的前提
  2. cache_read_input_tokens 应反映真实的 KV 复用量(标签 = 算力);若命中失败应报 0 而不是报满额
  3. system / tools 前缀应参与缓存
与 #354 / #322 的关系
  • #354:结论依赖 cache_read 标签的真实性做积分折算(Input 6.9 / Cached 1.7 / Output 24)。本 issue 的发现 2 表明标签可能系统性虚高——若如此,#354 的积分测算还是乐观值。两者互补:#354 证明「客户端前缀重排会降低标签命中率」;本 issue 证明「即使客户端前缀完美(完全相同 / 标准 append-only),服务端仍存在紧邻轮 miss、旧断点命中、标签-耗时脱钩」。另注:#354 中提到同端点 Claude Code 可达 90%+ 命中,与本 issue 的标签口径数据一致(100% 报命中),差异在于我们同时测了耗时,发现标签与耗时脱钩。
  • #322:其「恢复会话全额继承、缓存隔夜过期后每轮全价重发」与本 issue 的「append 紧邻轮 miss」是同一成本放大模式的两端(TTL 过期 / 断点丢失)。
计费疑问(请一并确认)

官方缓存文档(docs.bigmodel.cn/cn/guide/capabilities/cache)说明缓存命中按标准价 50% 计费(OpenAI 端点 cached_tokens 口径,且不含 Coding Plan 套餐)。请确认:

  1. /api/anthropiccache_read_input_tokens 是否同样按折扣入账(按量 & Coding Plan 积分两种口径)?
  2. 当「标签报命中、实际全量重算」(本 issue 发现 2)时,计费以哪个为准?若标签虚高,用户按折扣付费但得不到折扣对应的算力节省,与文档「降低使用成本」的承诺存在偏差。
设备 / 系统 / 环境 · Environment
  • 端点:https://open.bigmodel.cn/api/anthropic/v1/messages(stream,anthropic-version: 2023-06-01
  • 模型:glm-5.3-flash(主测)、glm-5.3(复测一致);带/不带 context-1m-2025-08-07 beta 行为一致
  • 客户端:macOS + Python 3 标准库 urllib 直连(无代理、无中转、无 header 注入);真实会话数据来自 Claude Code 本地 transcript(timestamp + usage 逐请求分析)

复现脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# GLM /api/anthropic 隐式 prompt cache 行为探针(仅标准库)
# 用法: export GLM_KEY=xxx && python3 glm_anthropic_cache_probe.py [model]
import json
import os
import sys
import time
import urllib.request

BASE = os.environ.get("GLM_BASE", "https://open.bigmodel.cn/api/anthropic")
KEY = os.environ.get("GLM_KEY", "")
MODEL = sys.argv[1] if len(sys.argv) > 1 else "glm-5.3-flash"
BIG = ("这是一段用于测试前缀缓存行为的中文填充文本,内容与任何真实任务无关,"
       "仅用于占据上下文长度。" * 16000)[:160000]


def call(label, messages, system=None):
    payload = {"model": MODEL, "max_tokens": 40, "stream": True, "messages": messages}
    if system:
        payload["system"] = system
    headers = {
        "Content-Type": "application/json",
        "x-api-key": KEY,
        "Authorization": "Bearer " + KEY,
        "anthropic-version": "2023-06-01",
    }
    req = urllib.request.Request(BASE.rstrip("/") + "/v1/messages",
                                 data=json.dumps(payload).encode(), headers=headers)
    t0 = time.time()
    ttft = None
    usage = {}
    with urllib.request.urlopen(req, timeout=300) as resp:
        for raw in resp:
            line = raw.decode("utf-8", "replace").strip()
            if not line.startswith("data:"):
                continue
            try:
                d = json.loads(line[5:])
            except Exception:
                continue
            if d.get("type") == "content_block_delta" and ttft is None:
                ttft = time.time() - t0
            if d.get("type") == "message_start":
                usage.update((d.get("message") or {}).get("usage") or {})
            if d.get("type") == "message_delta":
                usage.update(d.get("usage") or {})
    print(f"[{label:20s}] TTFT={ttft:5.1f}s  input={usage.get('input_tokens', '-'):>7}"
          f"  cache_read={usage.get('cache_read_input_tokens', '-'):>7}"
          f"  cache_write={usage.get('cache_creation_input_tokens', '-')}",
          flush=True)


def cc(text):
    return {"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}


print(f"model={MODEL}  prefix≈92k tokens\n")

print("== S1 完全相同请求 ×3(缓存可用性基线)==")
msgs = [
    {"role": "user", "content": [cc(BIG), {"type": "text", "text": "请记住以上资料,稍后提问。"}]},
    {"role": "assistant", "content": "好的,已记住。"},
    {"role": "user", "content": "一句话回答:1+1等于几?"},
]
for i in range(3):
    call(f"S1-identical-{i+1}", msgs)

print("\n== S2 append-only 追加对话 ×4(Claude Code 标准形态)==")
m = [{"role": "user", "content": [cc(BIG), {"type": "text", "text": "请记住以上资料,稍后提问。"}]}]
call("S2-round1", m, system="你是一个简洁的测试助手,请用一句话回答。")
for i, q in enumerate(["第一个问题:随便答。", "第二个问题:再随便答。", "第三个问题:继续随便答。"]):
    m += [{"role": "assistant", "content": "好的。"}, {"role": "user", "content": [cc(q)]}]
    call(f"S2-round{i+2}-append", m, system="你是一个简洁的测试助手,请用一句话回答。")

print("\n== S3 system 大前缀 ×2(system 是否参与缓存)==")
for i in range(2):
    call(f"S3-system-{i+1}", [{"role": "user", "content": "一句话回答:1+1等于几?"}],
         system=[cc(BIG)])

print("\n== S4 无 cache_control 的追加对话 ×3(隐式缓存对 append 的行为)==")
m = [{"role": "user", "content": BIG + "\n请记住以上资料,稍后提问。"}]
call("S4-round1", m)
for i in range(2):
    m += [{"role": "assistant", "content": "好的。"}, {"role": "user", "content": f"第{i+1}个问题:随便答。"}]
    call(f"S4-round{i+2}-append", m)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the attached glm_anthropic_cache_probe.py script and run its S1–S4 scenarios against /api/anthropic/v1/messages, comparing TTFT with cache_read_input_tokens. There are no repository files or tests named in the issue; done means determining the cache behavior's cause and making append-only and system prefixes report and achieve real reuse, with accurate usage labels.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, performance
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.