Skip to content

fix(gateway): pre-filter invalid thinking-block signatures to prevent 400/retry/cache-thrash cycle#1776

Open
mrsxs wants to merge 1 commit intoWei-Shaw:mainfrom
mrsxs:fix/preempt-filter-invalid-thinking-signatures
Open

fix(gateway): pre-filter invalid thinking-block signatures to prevent 400/retry/cache-thrash cycle#1776
mrsxs wants to merge 1 commit intoWei-Shaw:mainfrom
mrsxs:fix/preempt-filter-invalid-thinking-signatures

Conversation

@mrsxs
Copy link
Copy Markdown

@mrsxs mrsxs commented Apr 21, 2026

问题现象

线上使用经 sub2api 转发的 Claude Opus(Anthropic OAuth 订阅账号)时,/v1/messages 请求几乎每次都会触发如下 WARN + 重试:

WARN  service/gateway_service.go:4162  Account 117: thinking blocks have invalid signature, retrying with filtered blocks
INFO  service/gateway_service.go:4177  Account 117: thinking block retry succeeded (blocks downgraded)

原来为什么会这样

Anthropic 的扩展思考(extended thinking)签名机制:

  • 每个 thinking 块由服务端 HMAC 签名(写入 signature 字段)
  • 下一轮请求回传这些 thinking 块时,上游严格校验签名
  • 签名缺失或无效 → HTTP 400:Invalid \signature` in `thinking` block`

签名丢失最常见的来源:第三方客户端自写的 SSE 解析器实现缺陷。Anthropic 流式响应里 thinking 块的签名通过独立的 signature_delta 事件下发,而非官方 SDK 经常只处理 thinking_delta 忽略 signature_delta,导致存档的 thinking 块缺签名。

现有 sub2api 如何应对

仓库当前策略是失败后补救gateway_service.go:4066-4187):

  1. 首次请求携带缺签名 thinking 块 → 上游 400
  2. 识别为 isThinkingBlockSignatureError → 调用 FilterThinkingBlocksForRetry strip 掉 thinking 块
  3. 二次请求 → 上游 200

功能上这套重试机制 work,但实际产生两个明显副作用:

副作用 1:每次请求额外 2-8 秒延迟

首次 400 + retry 的 RTT 开销不可避免。

副作用 2:Prompt cache 被连锁破坏(核心问题)

  • 首次请求 body:带 thinking 块,cache_control 生效,写入 prompt cache
  • 二次请求 body(retry 过滤后):无 thinking 块,body 内容改变
  • Anthropic 的 prompt cache key 依赖 body 前缀一致 → 缓存判定失效
  • 下一轮正常请求(客户端又回传新的缺签名 thinking 块)再次进入重试路径 → 再次生成不一致 body → 再次 cache miss
  • 恶性循环:每 4-5 次请求就有一次完全丢失 cache 命中,被迫重建 20k-30k cache_creation_tokens

生产实测(Claude Pro 订阅 5h 窗口 / 25 次 /v1/messages)

指标 数值
触发 signature retry 的请求数 25/25(100%)
其中触发 prompt cache 重建的请求数 5/25(20%)
异常请求的 cache_creation_tokens 121,338 tokens
异常请求总成本 $1.30
5h 总成本 $2.42
浪费占比 53.5%

等效影响:Claude Pro 5h quota 被浪费近一半,用户提前触发限额冷却。

修复

仓库里已经有符合 pre-flight 语义的函数 FilterThinkingBlocks()backend/internal/service/gateway_request.go:379):

// 策略:
//   - 当 thinking.type 不是 enabled/adaptive:移除所有 thinking 相关块
//   - 当 thinking.type 是 enabled/adaptive:仅移除缺失/无效 signature 的 thinking 块

这正是预期的 pre-flight 语义:

  • 签名有效的 thinking 块原样保留 → 对官方 SDK / Claude Code 零影响
  • 签名缺失/无效的 thinking 块 → 入站即 strip → 永不触发 400
  • 顶层 thinking: enabled 开关保留 → 用户扩展思考功能不受影响

但该函数从未被主流程调用(grep 确认),只有 retry 路径在用更激进的 FilterThinkingBlocksForRetry(会禁用顶层 thinking)。

本 PR 在三个 pre-filter 入口补上调用,均紧邻现有 StripEmptyTextBlocks(body)

入口函数 位置
Forward gateway_service.go:4071-4074
forwardAnthropicAPIKeyPassthroughWithInput gateway_service.go:4564-4568
ForwardCountTokens gateway_service.go:8195-8201

代码改动

7 行新增,3 个位置。完整 diff:

@@ Forward @@
 	body = StripEmptyTextBlocks(body)
+	// Pre-filter: drop thinking blocks with missing/invalid signatures (valid ones kept).
+	// Prevents the 400-then-retry cycle that otherwise invalidates prompt cache and doubles upstream cost.
+	body = FilterThinkingBlocks(body)

@@ forwardAnthropicAPIKeyPassthroughWithInput @@
 	input.Body = StripEmptyTextBlocks(input.Body)
+	// Pre-filter: drop thinking blocks with missing/invalid signatures to prevent 400/retry/cache-miss cycle.
+	input.Body = FilterThinkingBlocks(input.Body)

@@ ForwardCountTokens @@
 	body = StripEmptyTextBlocks(body)
+	// Pre-filter: drop thinking blocks with missing/invalid signatures to prevent 400/retry/cache-miss cycle.
+	body = FilterThinkingBlocks(body)

影响面分析

客户端场景 影响
请求不含 thinking 块 fast-path 直接返回,零开销
Claude Code / @anthropic-ai/sdk(签名完整) 字节级零变化
第三方自写 SSE 解析(签名缺失) 静默修复 400/retry
thinking 未启用(客户端不发 thinking) 行为对齐 Anthropic 要求
请求经 API key 透传路径 同样受益(第二处改动)
Count-tokens 端点 同样受益(第三处改动)

验证

预期指标变化

指标 修复前 修复后(预期)
/v1/messages 上游 400 率 ~100%(使用受影响客户端) 0%
每请求 retry RTT 开销 +3.5s avg 0
5h cache_creation_tokens 189,450 <20,000
5h 实际成本 $2.42 ~$1.30
Claude Pro 5h quota 利用率 ~50% >95%

回归测试

  • FilterThinkingBlocks() 本身已有测试覆盖(gateway_request_test.go
  • pre-filter 调用位置与 StripEmptyTextBlocks 相同,复用现有测试结构
  • 对于不含 thinking 块的请求走 fast-path(bytes.Contains 快速判断),无性能退化

可选增强

如需做成开关,可加 gateway.preempt_filter_thinking_blocks bool 配置项,默认 true。本 PR 选择默认开启(无需配置),原因:

  • 语义安全:对合规客户端字节级零影响
  • 行为与现有 retry 路径一致(都会 strip 无效签名),只是时机前移
  • 能立即让所有受影响用户受益,无需手动配置

关联问题

本问题最初由 GenericAgent 客户端(https://github.com/lsdefine/GenericAgent)触发。我已向上游提交了客户端侧的根治 PR(https://github.com/lsdefine/GenericAgent/pull/123),但 sub2api 侧的兜底依然有价值:保护其他可能存在相同 bug 的第三方客户端,并避免 upstream 客户端发布新版之前用户持续遭受浪费。

… cache thrashing

## 背景

Anthropic 的扩展思考(extended thinking)要求:assistant 回复里的
每个 `thinking` 块都携带服务端 HMAC 签名(`signature` 字段);下一轮
请求回传这些 thinking 块时,上游会校验签名。签名缺失或无效会触发:

    HTTP 400: Invalid `signature` in `thinking` block

现有网关路径仅在上游**返回 400 之后**才调用
`FilterThinkingBlocksForRetry` 做补救重试:
- gateway_service.go:4066-4187 的 retry 循环

这是一套"失败即过滤"的被动策略,带来两个副作用:

1. 每次请求至少付出一次 400-RTT(实测 2-8 秒延迟开销)
2. retry 过滤后的 body 与首次请求的 body 不一致 →
   Anthropic 的 prompt cache key 改变 → 缓存失效 →
   下一轮请求被迫全量重建 `cache_creation_tokens`

生产实测(Claude Pro 订阅经 sub2api 转发,5h 窗口):
- 25 次 `/v1/messages` 中 5 次触发 cache 重建
- 每次重建消耗 20k-30k `cache_creation_tokens`
- 这 5 次总成本占 5h 总开销的 **53.5%**
- 相当于 5h quota 被浪费近一半

## 问题根因

签名缺失最常见的来源是**第三方客户端的 SSE 解析器实现缺陷**:
Anthropic 流式返回 thinking 块时分两类 delta:

- `thinking_delta` → 思考文本
- `signature_delta` → HMAC 签名(在块末尾一次性追加)

非官方 SDK 的自写解析器经常只处理 `thinking_delta`,signature 静默
丢失。即使客户端侧修复,历史对话里存档的 thinking 块已经缺签名,
回传仍会持续触发 400-重试-缓存重建的连锁反应。

## 修复

仓库里**已经存在** `FilterThinkingBlocks()`(gateway_request.go:379),
语义完全契合 pre-flight 过滤需求:

- 若 `thinking.type ∈ {enabled, adaptive}`:
  仅移除 signature 为空 / 等于 `DummyThoughtSignature` 的 thinking 块,
  **保留签名有效的块**
- 若 thinking 未启用:
  移除所有 thinking 相关块(与 retry 路径行为一致)

但该函数**从未被主流程调用**。本 PR 在三处 pre-filter 入口补上调用:

- `Forward`(gateway_service.go:4071)
- `forwardAnthropicAPIKeyPassthroughWithInput`(:4564)
- `ForwardCountTokens`(:8195)

均插在现有 `StripEmptyTextBlocks(body)` 之后,紧邻既有前置过滤。

## 影响面

| 场景 | 行为 |
|---|---|
| 请求不含 thinking 块 | fast-path 直接返回,零开销 |
| thinking 启用 + 签名有效 | 保留原块,字节级不变 |
| thinking 未启用 | 移除历史 thinking 残留,对齐 Anthropic 要求 |
| thinking 启用 + 签名缺失/无效 | 入站即 strip,避免 400,保护 prompt cache |

对使用官方 SDK(Claude Code / `@anthropic-ai/sdk`)的客户端**字节级零影响**。
只在客户端本来就会触发 400 的场景下静默修复。

## 验证指标

修复前:
    cache_creation_tokens: 189,450 / 5h  (≈30% 请求遭遇全量重建)
    /v1/messages 上游 400 率: ~100%
    每请求平均 retry 开销: 3.5s

修复后(预期):
    cache_creation_tokens: <20,000 / 5h  (仅会话首次写入)
    /v1/messages 上游 400 率: 0%
    每请求 retry 开销: 0

## 代码改动

3 处插入,总计 7 行新增:

    backend/internal/service/gateway_service.go
      +3 行 @ Forward                                  (line 4072-4074)
      +2 行 @ forwardAnthropicAPIKeyPassthroughWithInput (line 4567-4568)
      +2 行 @ ForwardCountTokens                        (line 8200-8201)

均为纯调用现有 `FilterThinkingBlocks(body)`,无新增依赖、无接口变化、
无配置项新增。如需做成开关可轻松改造,但默认开启语义更安全。
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

All contributors have signed the CLA. ✅
Posted by the CLA Assistant Lite bot.

@Wei-Shaw Wei-Shaw force-pushed the main branch 3 times, most recently from d0f362d to 960b2bb Compare April 21, 2026 04:06
@mrsxs
Copy link
Copy Markdown
Author

mrsxs commented Apr 21, 2026

I have read the CLA Document and I hereby sign the CLA

@mrsxs
Copy link
Copy Markdown
Author

mrsxs commented Apr 21, 2026

recheck

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.

1 participant