大模型推理 API 在响应时,除了最终答案,还可能把推理过程中的 CoT 或其摘要作为 reasoning 内容一起输出。当 agent 进行下一轮调用时,需要把之前输出的内容重新作为输入,让大模型继续处理当前任务。这里“之前输出的内容”也可能包括前面的 reasoning。(有些厂商把这部分内容称作 thinking,为了保持一致,本文统一使用 reasoning 这个说法,API 字段名除外。)对于有些厂商的模型,比如 Gemini 3,在尚未结束的工具调用轮次中,回传签名甚至是必选的,缺少时 API 会报告 400 错误。DeepSeek 的文档也给出工具调用中必须回传 reasoning 的要求,但是最新的 V4 Flash 实测已经没有执行这一限制。
为了隐藏模型的完整思维过程,一些专有模型厂商不会直接输出完整的 reasoning,而是返回摘要和 signature,或者只返回带有 signature 的空 reasoning block。用户在下一轮回传这些 blocks 时,应原样保留相应的 signature。
Gemini
以 Gemini 3 为例,在当前工具调用轮次中,如果回传的模型消息缺少每一步第一个 functionCall part 对应的 signature,就会得到 400 响应;普通非工具调用响应则没有同样的强制要求。不过,对于从其他模型迁移历史等场景,Google 提供了跳过校验的兼容入口:使用 skip_thought_signature_validator 或 context_engineering_is_the_way_to_go 这两个指定值。
例如,下面是使用官方兼容值的消息片段。正常回传时应使用 API 返回的原始签名。在当前工具调用轮次中直接删掉该字段,则会触发上述 400。
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "get_weather",
"args": { "city": "Paris" }
},
"thoughtSignature": "skip_thought_signature_validator"
}
]
}把值换成 context_engineering_is_the_way_to_go 也可以。
Anthropic
相比之下,Anthropic 虽然允许在已完成的普通对话轮次之间不回传上一次的 reasoning,但会校验回传的原生 thinking blocks,缺少有效 signature 或篡改 block 都可能导致 400,在跨厂商转换时需要特别处理。有一种场景:agent 先用 DeepSeek 这种 reasoning 不带 Anthropic signature 的模型生成上下文,再调用 Anthropic 的模型继续处理。如果直接把 DeepSeek 的 reasoning 包装成 Anthropic 的 thinking block,就会缺少必需的签名。不过,这不意味着其他模型产生的上下文都不能使用:对于已完成的问答,可以只保留用户消息和最终答案,不把外部 reasoning 冒充原生 thinking block。
例如,把下面的 assistant 消息片段传给 claude-fable-5-1,会返回 HTTP 400。这个特定用例用固定文本模拟外部 reasoning:
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "Reasoning returned by another provider."
},
{ "type": "text", "text": "The answer is 21." }
]
}返回的错误为:
{
"type": "invalid_request_error",
"message": "messages.1.content.0.thinking.signature: Field required"
}Anthropic 自家模型之间也存在 reasoning 兼容性规则。按照官方文档,Fable 5.1 可以读取更早 Claude 模型的 thinking,包括 Haiku 4.5;反过来,更早的模型不能读取 Fable 5.1 的 thinking,API 会丢弃不兼容的 blocks,只不过不会因为模型不兼容就报告 signature 错误。
例如,本次先调用 claude-haiku-4-5-20251001,使用 thinking: {"type": "enabled", "budget_tokens": 1024} 获得完整的带签名响应,再保持 system、tools 和之前的用户消息不变,把返回的 content 原样交给 Fable 5.1。下面的片段中,request 是原始请求,haiku_response 是 Haiku 的响应:
request["model"] = "claude-fable-5-1"
request["thinking"] = {
"type": "adaptive",
"block_binding": {"prefix_mismatch_behavior": "error"},
}
request["messages"].extend(
[
{"role": "assistant", "content": haiku_response["content"]},
{"role": "user", "content": "Reply with just OK."},
]
)带上 anthropic-beta: thinking-binding-controls-2026-08-01 后,实测返回 HTTP 200,响应中的 input_transformations 为 [],没有丢弃 Haiku 的 thinking。如果反过来,会看响应中存在:
{
"input_transformations": [
{
"type": "thinking_dropped",
"path": "messages.1.content.0",
"reason": "model_binding_mismatch"
}
]
}表示 Fable 的 thinking 已经在 Haiku 中被丢弃。
为了防止通过修改上下文诱导模型输出原本加密的 CoT,Anthropic 从 Fable 5.1 开始进一步要求 reasoning 与产生它的上下文绑定:即使 block 本身没有变化,修改前面的 system、tools 或消息也可能导致 400。这是 Anthropic 对防蒸馏措施的官方解释:
https://support.claude.com/en/articles/16761192-preserved-thi...
这一检查在 Fable 5.1 上默认适用于 2026-08-31 00:00 UTC 起创建的新账户;旧账户可以通过 beta header 和显式设置 thinking.block_binding.prefix_mismatch_behavior 启用。作为用户,如果确实需要改写之前的上下文,可以选择 opt-in 的 drop_block 模式,让 Fable 丢弃失效 reasoning,而不是返回 400;也可以在遇到这一前缀不匹配错误时,删除历史中的 thinking 和 redacted_thinking blocks,保留其他内容后重试。注意 drop_block 并不是让任意缺失或伪造的签名通过校验,只是切换成兼容模式。具体说明见:
https://platform.claude.com/docs/en/build-with-claude/thinkin...
例如,本次先获取 Fable 5.1 的真实 thinking block,再原样回传 assistant 内容,并在之前的用户消息末尾追加 Use plain text.。使用上述 beta header 和 prefix_mismatch_behavior: "error" 时,实测返回 HTTP 400:
{
"type": "invalid_request_error",
"message": "messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation. Remove the block, or set `thinking.block_binding.prefix_mismatch_behavior` to \"drop_block\". Content before this block differs from when it was created, first at `messages.0.content.0`."
}保持失败请求的历史不变,只把 thinking 配置改成下面这样,并继续发送 beta header:
{
"thinking": {
"type": "adaptive",
"display": "omitted",
"block_binding": { "prefix_mismatch_behavior": "drop_block" }
}
}实测返回 HTTP 200,响应包含:
{
"input_transformations": [
{
"type": "thinking_dropped",
"path": "messages.1.content.0",
"reason": "prefix_binding_mismatch"
}
]
}同一条失败请求原样重试仍是 400;删除全部旧 thinking 后,不发送 beta 也能得到 200。丢弃只对当前请求生效,如果后续仍回传失效 blocks,就需要继续使用 drop_block 或在客户端移除它们。
结论
如果你的 workflow 涉及多种不同厂商的模型,为了对付专有模型厂商复杂的 reasoning 校验规则,我们有两种办法:
- 在处理其他模型的产出时,不是简单地回传,而是需要做归一化。尤其是当 workflow 中存在 Anthropic 模型时,在请求 Anthropic 之前需要把其他模型生成的 reasoning 都去掉。抑或是把 reasoning 的内容重新加上分隔符放到普通的内容里。
- 尽可能在请求时使用兼容模式,比如 Gemini 中的
"thoughtSignature": "skip_thought_signature_validator"和 Anthropic 中的"prefix_mismatch_behavior": "drop_block"。
当然这些方法只是解决了报错问题。由于 reasoning 内容往往占到全部输出 tokens 的 80% 到 90%,丢掉这些上下文很难说不会影响事实上的生成结果。所以我认为厂商制造如此复杂的检验逻辑,他们自己的安全需求确实存在,但其实现方式客观上增加了跨厂商成本,且缺乏统一标准,用户被夹在中间。这里没有把 OpenAI 拿出来批判一番,是因为 OpenAI 在这个问题上做得其实还不错,即使不回传签名也能工作,不需要一个特别的兼容模式来适配 reasoning 校验规则。
附录
感兴趣的读者可以使用以下脚本自行测试:
"""Live reasoning-signature checks for Gemini, DeepSeek, and Anthropic."""
import argparse
import copy
import http.client
import json
import os
import sys
BETA = "thinking-binding-controls-2026-08-01"
TOOL = {
"name": "lookup_number",
"description": "Look up a stored number by name.",
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
}
PROMPT = "Call lookup_number once for 'sample', then report its value. Do not guess."
def require(condition, message):
if not condition:
raise RuntimeError(message)
def post(provider, body, label, expected=200, error_contains=None, beta=True):
routes = {
"gemini": (
"GEMINI_API_KEY",
"generativelanguage.googleapis.com",
f"/v1beta/models/{args.gemini_model}:generateContent",
),
"deepseek": ("DEEPSEEK_API_KEY", "api.deepseek.com", "/chat/completions"),
"anthropic": ("ANTHROPIC_API_KEY", "api.anthropic.com", "/v1/messages"),
}
variable, host, path = routes[provider]
key = os.environ.get(variable)
require(bool(key), "Missing credentials. Fix the exports in ~/xx_env.")
headers = {"content-type": "application/json"}
if provider == "gemini":
headers["x-goog-api-key"] = key
elif provider == "deepseek":
headers["authorization"] = "Bearer " + key
else:
headers.update({"x-api-key": key, "anthropic-version": "2023-06-01"})
if beta:
headers["anthropic-beta"] = BETA
print(f"{provider}: {label}", flush=True)
connection = http.client.HTTPSConnection(host)
try:
connection.request("POST", path, json.dumps(body), headers)
response = connection.getresponse()
status = response.status
data = json.loads(response.read())
finally:
connection.close()
error = data.get("error", {})
allowed = expected if isinstance(expected, tuple) else (expected,)
passed = status in allowed and (
not error_contains or error_contains.lower() in str(error).lower()
)
summary = {
"http_status": status,
"check": "OBSERVE" if len(allowed) > 1 and passed else "PASS" if passed else "FAIL",
}
if error:
summary["error"] = next(
(
phrase for phrase in (
"Corrupted thought signature.",
"signature: Field required",
"bound to a different conversation",
"thought_signature",
"reasoning_content",
)
if phrase.lower() in str(error).lower()
),
"Provider error (details suppressed).",
)
else:
summary["model"] = data.get("model", data.get("modelVersion"))
summary["input_transformations"] = data.get("input_transformations", [])
if data.get("choices"):
summary["finish_reason"] = data["choices"][0].get("finish_reason")
print(json.dumps(summary, ensure_ascii=True).replace(key, "[REDACTED]"), flush=True)
require(passed, f"Unexpected API result for {provider}: {label}")
return data
def gemini():
seed = {
"contents": [{"role": "user", "parts": [{"text": PROMPT}]}],
"tools": [{"functionDeclarations": [TOOL]}],
"toolConfig": {"functionCallingConfig": {"mode": "ANY"}},
"generationConfig": {"maxOutputTokens": 2048},
}
result = post("gemini", seed, "seed")
candidates = result.get("candidates", [])
require(bool(candidates), "Gemini returned no candidate.")
content = candidates[0].get("content", {})
parts = content.get("parts", [])
calls = [part for part in parts if "functionCall" in part]
require(
len(calls) == 1 and calls[0].get("thoughtSignature"),
"Gemini seed needs exactly one signed function call.",
)
function_call = calls[0]["functionCall"]
require(function_call["name"] == TOOL["name"], "Unexpected Gemini tool.")
function_response = {
"name": function_call["name"],
"response": {"value": 21},
}
if "id" in function_call:
function_response["id"] = function_call["id"]
replay = copy.deepcopy(seed)
replay["toolConfig"]["functionCallingConfig"]["mode"] = "NONE"
replay["contents"].extend(
[content, {"role": "user", "parts": [{"functionResponse": function_response}]}]
)
post("gemini", replay, "original signature")
for label, signature, expected in [
("missing signature", None, 400),
("fabricated signature (observe)", "bm90LWEtcmVhbC1zaWduYXR1cmU=", (200, 400)),
("documented sentinel 1", "skip_thought_signature_validator", 200),
("documented sentinel 2", "context_engineering_is_the_way_to_go", 200),
]:
body = copy.deepcopy(replay)
part = next(p for p in body["contents"][1]["parts"] if "functionCall" in p)
if signature is None:
del part["thoughtSignature"]
else:
part["thoughtSignature"] = signature
post("gemini", body, label, expected, "signature" if expected == 400 else None)
def deepseek():
seed = {
"model": args.deepseek_model,
"max_tokens": 4096,
"thinking": {"type": "enabled"},
"tools": [{"type": "function", "function": TOOL}],
"messages": [{"role": "user", "content": PROMPT}],
}
result = post("deepseek", seed, "seed")
choice = result["choices"][0]
message = choice["message"]
calls = message.get("tool_calls", [])
require(
choice["finish_reason"] == "tool_calls"
and len(calls) == 1
and message.get("reasoning_content"),
"DeepSeek seed needs reasoning and exactly one tool call.",
)
require(calls[0]["function"]["name"] == TOOL["name"], "Unexpected DeepSeek tool.")
replay = copy.deepcopy(seed)
replay["messages"].extend(
[message, {"role": "tool", "tool_call_id": calls[0]["id"], "content": "21"}]
)
result = post("deepseek", replay, "preserve reasoning during tool turn")
final = result["choices"][0]
require(final["finish_reason"] == "stop", "DeepSeek tool turn did not finish.")
missing = copy.deepcopy(replay)
del missing["messages"][1]["reasoning_content"]
result = post("deepseek", missing, "remove reasoning during tool turn (observe)", (200, 400))
if "error" in result:
require(
"reasoning_content" in str(result["error"]),
"DeepSeek rejected the request for an unrelated reason.",
)
else:
require(result["choices"][0]["finish_reason"] == "stop", "DeepSeek response incomplete.")
next_turn = copy.deepcopy(replay)
next_turn["messages"].extend(
[final["message"], {"role": "user", "content": "Reply with just OK. No tools."}]
)
for message in next_turn["messages"]:
message.pop("reasoning_content", None)
post("deepseek", next_turn, "remove old reasoning after completed turn")
def anthropic():
seed = {
"model": "claude-fable-5-1",
"max_tokens": 2048,
"thinking": {
"type": "adaptive",
"display": "omitted",
"block_binding": {"prefix_mismatch_behavior": "error"},
},
"output_config": {"effort": "high"},
"system": "You are a concise arithmetic assistant. Return only final answers.",
"tools": [{
"name": TOOL["name"],
"description": TOOL["description"],
"input_schema": TOOL["parameters"],
}],
"messages": [{
"role": "user",
"content": "Without tools, find the smallest positive integer n such that "
"n mod 7 = 3, n mod 11 = 5, and n mod 13 = 7.",
}],
}
result = post("anthropic", seed, "Fable seed")
require(
result.get("stop_reason") == "end_turn" and any(
b.get("type") == "thinking" and b.get("signature")
for b in result.get("content", [])
),
"Fable seed needs a complete signed thinking response.",
)
replay = copy.deepcopy(seed)
replay["messages"].extend([
{"role": "assistant", "content": result["content"]},
{"role": "user", "content": "Reply with just OK."},
])
result = post("anthropic", replay, "unchanged replay")
require(result.get("input_transformations") == [], "Unchanged thinking was dropped.")
unsigned = copy.deepcopy(replay)
unsigned["messages"][1]["content"] = [
{"type": "thinking", "thinking": "Reasoning returned by another provider."},
{"type": "text", "text": "The answer is 21."},
]
post("anthropic", unsigned, "unsigned thinking", 400, "signature: Field required")
for field in ("system", "tools", "messages"):
changed = copy.deepcopy(replay)
if field == "system":
changed["system"] += " Use plain text."
elif field == "tools":
changed["tools"][0]["description"] += " Return an integer."
else:
changed["messages"][0]["content"] += " Use plain text."
post("anthropic", changed, f"edited {field}", 400, "bound to a different conversation")
post("anthropic", changed, "identical retry", 400, "bound to a different conversation")
drop = copy.deepcopy(changed)
drop["thinking"]["block_binding"]["prefix_mismatch_behavior"] = "drop_block"
result = post("anthropic", drop, "drop_block recovery")
require(any(
item.get("type") == "thinking_dropped"
and item.get("reason") == "prefix_binding_mismatch"
for item in result.get("input_transformations", [])
), "No prefix-binding drop was reported.")
stripped = copy.deepcopy(changed)
del stripped["thinking"]["block_binding"]
for message in stripped["messages"]:
if isinstance(message["content"], list):
message["content"] = [
b for b in message["content"]
if b["type"] not in ("thinking", "redacted_thinking")
]
post("anthropic", stripped, "strip thinking recovery", beta=False)
default = copy.deepcopy(changed)
del default["thinking"]["block_binding"]
result = post("anthropic", default, "account default (observe)", (200, 400), beta=False)
if "error" in result:
require(
"bound to a different conversation" in result["error"].get("message", ""),
"Account-default request failed for an unrelated reason.",
)
haiku = copy.deepcopy(seed)
haiku["model"] = "claude-haiku-4-5-20251001"
haiku["thinking"] = {"type": "enabled", "budget_tokens": 1024}
del haiku["output_config"]
result = post("anthropic", haiku, "Haiku seed")
require(
result.get("stop_reason") == "end_turn" and any(
b.get("type") == "thinking" and b.get("signature")
for b in result.get("content", [])
),
"Haiku seed needs a complete signed thinking response.",
)
migrated = copy.deepcopy(replay)
migrated["messages"][1]["content"] = result["content"]
result = post("anthropic", migrated, "Haiku thinking replayed to Fable")
require(result.get("input_transformations") == [], "Haiku thinking was dropped.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("provider", choices=("gemini", "deepseek", "anthropic", "all"))
parser.add_argument("--gemini-model", default="gemini-3-flash-preview")
parser.add_argument("--deepseek-model", default="deepseek-v4-flash")
args = parser.parse_args()
try:
for name, run in (("gemini", gemini), ("deepseek", deepseek), ("anthropic", anthropic)):
if args.provider in (name, "all"):
run()
print("All selected checks completed.")
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
except (OSError, http.client.HTTPException, ValueError, KeyError, IndexError, TypeError) as exc:
print(f"Request or response failure ({type(exc).__name__}); details suppressed.", file=sys.stderr)
sys.exit(2)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。