大模型推理 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 校验规则,我们有两种办法:

  1. 在处理其他模型的产出时,不是简单地回传,而是需要做归一化。尤其是当 workflow 中存在 Anthropic 模型时,在请求 Anthropic 之前需要把其他模型生成的 reasoning 都去掉。抑或是把 reasoning 的内容重新加上分隔符放到普通的内容里。
  2. 尽可能在请求时使用兼容模式,比如 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)

spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.