Claude 多轮对话神秘 400:一次 Thinking Signature 损坏的排查实录

客户的 Claude extended thinking 多轮对话在部分网关通道稳定报 400 Invalid signature,其他通道却一切正常。本文完整复盘这次排查:signature 机制是什么、proxy 层 JSON 重序列化如何悄悄损坏 base64、五组对照实验逐一验证,以及给所有 LLM 网关开发者的修复清单。

zhuermu··10 分钟
ClaudeExtended ThinkingBedrockLLM 网关排障Base64
Claude 多轮对话神秘 400:一次 Thinking Signature 损坏的排查实录

部分通道稳定 400,其他通道一切正常 · 五组对照实验 · 一行 URL decode 引发的血案

问题现象

客户在使用 Claude extended thinking 进行多轮对话时,第 2 轮(或后续轮次)请求稳定返回:

400 ValidationException: messages.N.content.0:
Invalid `signature` in `thinking` block

诡异的是:同样的代码、同样的请求,只有部分网关通道出错,其他通道完全正常。

⚠️ 这类”部分通道失败”的症状,几乎可以肯定问题不在客户端代码,而在中间某一层基础设施的差异上。这是整个排查的起点。

背景知识:Signature 是什么

Claude extended thinking 的每轮响应都带一个 thinking block:

{
  "type": "thinking",
  "thinking": "Let me analyze this...",
  "signature": "EpECCkgIDhABGAIqQNlq...+NcSh70b13lvu...+/KthEQ+NjQGgRWXJeDXu8NX34mcZGAE="
}

理解四个关键点:

  • signature 是 Anthropic 服务端对完整 thinking 内容的加密签名
  • 多轮对话时,客户端必须把上一轮的 thinking block(含 signature)原样回传
  • API 收到后验证 signature 完整性,确认 thinking block 未被篡改
  • signature 必须逐字节不变,任何修改都会导致 400

🔍 一个反直觉的细节:API 并不校验 thinking 的 text 字段——text 只是给开发者看的摘要,你随便改都能过。API 只认 signature。这一点后面的实验会验证。

假设:Proxy 层 JSON 重序列化损坏了 Base64

注意 signature 的形态:一段 300+ 字符的标准 base64,含有 +/= 这三个”危险字符”——它们在 URL 编码和 base64url 里都有特殊含义。

如果 proxy 在转发上游(Bedrock / Anthropic)响应时,对 body 做了 JSON 解析再序列化(re-marshal),中间任何一个环节碰了这些字符,signature 就废了:

损坏变体原因表现
+ → 空格误用了 URL query string decodeEpEC...+NcShEpEC... NcSh
+%2B误用了 URL encodeEpEC...+NcShEpEC...%2BNcSh
末尾 = 被去掉某些”优化”逻辑 strip padding...ZGAE=...ZGAE
+//-/_误用了 base64url 转换标准 base64 被转成 URL-safe 版

典型的根因代码长这样(Go):

// ❌ Bug:历史遗留逻辑对 response body 做了 URL decode
func forwardResponse(body []byte) []byte {
    decoded, _ := url.QueryUnescape(string(body))
    return []byte(decoded)
}
// 效果:signature 中的 "+" 全部变成空格

或者出现在 SSE streaming 的拼接逻辑里:

// ❌ Bug:拼接 chunk 时错误地 trim 了 base64 padding
func assembleStreamingResponse(chunks []string) string {
    var result strings.Builder
    for _, chunk := range chunks {
        result.WriteString(strings.TrimRight(chunk, "="))
    }
    return result.String()
}

为什么只有部分通道出问题

这也解释了最初的诡异现象:

  • 正常的通道:proxy 实例直接透传 raw bytes,不做 JSON 重序列化
  • 失败的通道:proxy 实例走了一条包含 JSON 解析/重新序列化的代码路径——比如需要做日志记录、字段注入或 response 改写

同一套网关,两条代码路径,命运截然不同。

复现验证

假设有了,接下来用对照实验钉死它。环境:

  • AWS Bedrock,us-east-1
  • 模型:us.anthropic.claude-sonnet-4-5-20250929-v1:0
  • Extended thinking 开启

获取合法 signature

aws bedrock-runtime converse \
  --model-id us.anthropic.claude-sonnet-4-5-20250929-v1:0 \
  --region us-east-1 \
  --additional-model-request-fields '{"thinking":{"budget_tokens":1024,"type":"enabled"}}' \
  --inference-config '{"maxTokens":2048}' \
  --messages '[{"content":[{"text":"What is 2+2? Answer in one word."}],"role":"user"}]'

返回里能拿到一段 300+ 字符的 base64 signature。

实验一:模拟 + 变空格

把 signature 中所有 + 替换为空格后回传第二轮:

# 原始: ...RZk+NcSh70b13lvukEj9P3kSDPr7olnJ+WKulQrR+RoM...
# 损坏: ...RZk NcSh70b13lvukEj9P3kSDPr7olnJ WKulQrR RoM...

aws bedrock-runtime converse \
  --model-id us.anthropic.claude-sonnet-4-5-20250929-v1:0 \
  --region us-east-1 \
  --additional-model-request-fields '{"thinking":{"budget_tokens":1024,"type":"enabled"}}' \
  --inference-config '{"maxTokens":2048}' \
  --messages '[
    {"content":[{"text":"What is 2+2? Answer in one word."}],"role":"user"},
    {"content":[{"reasoningContent":{"reasoningText":{
      "signature":"<+ 号已替换为空格的损坏版>",
      "text":"The question asks what 2+2 equals..."
    }}},{"text":"Four"}],"role":"assistant"},
    {"content":[{"text":"Now what is 3+3?"}],"role":"user"}
  ]'

结果:400 Invalid signature in thinking block

实验二:模拟截断

去掉 signature 末尾的 AE=

# 原始: ...mcZGAE=
# 截断: ...mcZG

结果:400 Invalid signature in thinking block

实验三:对照组——原样回传

signature 一个字节不动。

结果:200 OK

实验四:修改 thinking text,保持 signature 不变

把 text 改成完全不相干的内容:

"signature": "<原始不变>",
"text": "MODIFIED TEXT - this is completely different"

结果:200 OK ✅ ——证实 API 只校验 signature,不校验 text。

实验结果汇总

测试场景signature 状态结果
原样回传完整不变✅ 200
thinking text 被修改完整不变✅ 200
+ 被替换为空格损坏❌ 400
末尾 = 被去掉损坏❌ 400
signature 被截断损坏❌ 400
跨 us./global. 前缀回传完整不变✅ 200
跨模型版本(Opus→Sonnet)回传完整不变✅ 200

📊 最后两行值得注意:signature 不绑定 inference profile 前缀,也不绑定具体模型版本——只要字节完整就能通过校验。这排除了”跨区域/跨模型导致 400”的干扰假设。

修复建议

🎯 核心原则:Proxy 层对 thinking block 的 signature 字段必须做到 byte-for-byte 透传,不做任何处理。

排查清单

给所有 LLM 网关开发者的自检清单:

  1. proxy 是否对 response body 做了 url.QueryUnescape() 或类似操作?
  2. JSON 序列化库是否对 string 中的 + / = / / 有特殊处理?
  3. streaming(SSE)拼接逻辑是否有截断风险?
  4. 是否有 middleware 做了 base64 → base64url 转换?
  5. 日志/审计模块在 re-serialize 时是否引入了字符变换?

修复方案

最优解是不解析,直接透传:

// ✅ 正确做法:对上游 response body 做 raw passthrough
func forwardUpstreamResponse(w http.ResponseWriter, upstreamResp *http.Response) {
    w.Header().Set("Content-Type", upstreamResp.Header.Get("Content-Type"))
    io.Copy(w, upstreamResp.Body)
}

如果业务上必须解析 JSON(比如要注入字段),用 json.RawMessage 保住敏感字段:

// ✅ 正确做法:RawMessage 保持原始字节不变
type ThinkingBlock struct {
    Type      string          `json:"type"`
    Thinking  string          `json:"thinking"`
    Signature json.RawMessage `json:"signature"` // 不做任何转换
}

再加一道防线——在转发前后对 signature 做 hash 比对:

incomingSig := extractSignature(upstreamResponse)
outgoingSig := extractSignature(forwardedResponse)
if md5(incomingSig) != md5(outgoingSig) {
    log.Error("SIGNATURE CORRUPTED in proxy layer!")
}

附:Anthropic 为什么要设计 Signature 机制

目的说明
防篡改防止中间人修改 thinking 内容来操纵 Claude 后续推理
推理连续性Tool use 场景下,Claude 需要从上一轮 thinking 断点继续推理
安全护栏防止通过伪造 thinking block 绕过安全限制
跨平台兼容signature 在 Anthropic API / Bedrock / Vertex AI 之间通用

写在最后

这个案例的教训可以浓缩成一句话:网关碰过的每一个字节,都是你的责任。

LLM API 的响应体里越来越多地出现加密签名、二进制编码这类”一个字节都不能动”的字段。任何在转发路径上做 JSON 重序列化、字符转换、内容改写的网关,都值得用本文的排查清单过一遍——今天是 thinking signature,明天可能就是 tool call ID 或者别的什么。透传永远是最安全的默认行为。

参考资料

  1. Building with extended thinking — Anthropic 官方文档
  2. Amazon Bedrock Converse API — AWS 官方文档