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

部分通道稳定 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 decode | EpEC...+NcSh → EpEC... NcSh |
+ → %2B | 误用了 URL encode | EpEC...+NcSh → EpEC...%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 网关开发者的自检清单:
- proxy 是否对 response body 做了
url.QueryUnescape()或类似操作? - JSON 序列化库是否对 string 中的
+/=//有特殊处理? - streaming(SSE)拼接逻辑是否有截断风险?
- 是否有 middleware 做了 base64 → base64url 转换?
- 日志/审计模块在 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 或者别的什么。透传永远是最安全的默认行为。
参考资料
- Building with extended thinking — Anthropic 官方文档
- Amazon Bedrock Converse API — AWS 官方文档