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

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

- 作者: zhuermu
- 发布: 2026-07-18
- 网页版: https://zhuermu.com/blog/claude-thinking-signature-corruption/

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

## 问题现象

客户在使用 Claude extended thinking 进行多轮对话时，第 2 轮（或后续轮次）请求稳定返回：

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

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

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

## 背景知识：Signature 是什么

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

```json
{
  "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 decode | `EpEC...+NcSh` → `EpEC... NcSh` |
| `+` → `%2B` | 误用了 URL encode | `EpEC...+NcSh` → `EpEC...%2BNcSh` |
| 末尾 `=` 被去掉 | 某些"优化"逻辑 strip padding | `...ZGAE=` → `...ZGAE` |
| `+`/`/` → `-`/`_` | 误用了 base64url 转换 | 标准 base64 被转成 URL-safe 版 |

典型的根因代码长这样（Go）：

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

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

```go
// ❌ 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

```bash
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 中所有 `+` 替换为空格后回传第二轮：

```bash
# 原始: ...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=`：

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

**结果：`400 Invalid signature in thinking block`** ❌

### 实验三：对照组——原样回传

signature 一个字节不动。

**结果：`200 OK`** ✅

### 实验四：修改 thinking text，保持 signature 不变

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

```bash
"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 时是否引入了字符变换？

### 修复方案

最优解是不解析，直接透传：

```go
// ✅ 正确做法：对上游 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` 保住敏感字段：

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

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

```go
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 或者别的什么。透传永远是最安全的默认行为。

---

## 常见问题

### Claude thinking block 里的 signature 是什么？

signature 是 Anthropic 服务端对完整 thinking 内容生成的加密签名。多轮对话回传 thinking block 时，API 只校验 signature 字段的完整性（逐字节比对），不校验 thinking text 本身。signature 被修改一个字节就会返回 400。

### 为什么我的 LLM 网关会把 signature 弄坏？

最常见的原因是 proxy 对 response body 做了 JSON 解析再序列化，中间混入了 URL decode（+ 变空格）、base64url 转换（+/ 变 -_）、或 strip padding（去掉末尾 =）等字符变换。signature 是标准 base64，这些操作都会破坏它。

### 怎么快速确认是不是 proxy 层损坏了 signature？

绕过 proxy 直连上游（Bedrock/Anthropic API）跑同样的多轮请求：直连 200、走 proxy 400 即可定位。进一步可在 proxy 转发前后对 signature 做 hash 比对，不一致即证明损坏发生在网关内部。


---

## 参考资料

- [Building with extended thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) — Anthropic 官方文档
- [Amazon Bedrock Converse API](https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html) — AWS 官方文档
