langchain的ChatOpenAI和ChatDeepSeek不回传思考内容

实测总结:

Provider 类 / 端点 捕获 (能看到) 多轮回传 signature
ChatOpenAI (OpenAI 兼容) ❌ 直接丢 -
ChatDeepSeek (deepseek OpenAI 兼容) -
ChatQwen (qwen DashScope OpenAI 兼容) -
ChatAnthropic (deepseek /anthropic) message id
ChatAnthropic (qwen DashScope apps/anthropic)

在使用langchain框架开发Agent时,模型对工具调用时的思考内容是需要回传的, 以保证上下文语义及逻辑是连续的。但langchain的ChatOpenAI,以及专门针对deepseek做的ChatDeepseek都没有把思考内容回传。

测试代码如下:

"""实测: langchain `ChatDeepSeek` 在多轮工具调用里到底回不回传思考内容。

结论预期(本脚本会验证):
  - ChatDeepSeek **会捕获** 每轮的 reasoning_content 到 AIMessage.additional_kwargs
    (所以你本地能看到思考);
  - 但**不会把 reasoning_content 回传**给 API —— 下一轮请求的 assistant 历史消息里
    没有这个字段。
  - 行为佐证: 最后让模型"一字不差复述第一轮的思考",由于它从没收到过自己之前的
    思考,模型无法准确复现(只能现编)。

对照: 你 notebook 里用原始 anthropic SDK(api.deepseek.com/anthropic)是会回传的,
模型能复述;这里用 ChatDeepSeek 走 OpenAI 兼容协议,不回传。

运行:  uv run python chatdeepseek_roundtrip_test.py
"""
from __future__ import annotations

import tomllib
from pathlib import Path

from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool
from langchain_deepseek import ChatDeepSeek

ds = tomllib.loads((Path(__file__).parent / "config/settings.toml").read_text())["orchestrator"]


@tool
def get_date() -> str:
    """Get the current date."""
    return "2026-06-24"


@tool
def get_weather(location: str, date: str) -> str:
    """Get weather of a location on a given date (YYYY-mm-dd)."""
    return "Cloudy 25~30°C"


TOOLS = {"get_date": get_date, "get_weather": get_weather}

llm = ChatDeepSeek(
    model=ds["model"], base_url=ds["base_url"], api_key=ds["api_key"],
    max_tokens=4000, extra_body={"thinking": {"type": "enabled"}},
).bind_tools([get_date, get_weather])


def show(tag: str, m) -> None:
    rc = m.additional_kwargs.get("reasoning_content")
    print(f"  [{tag}] reasoning_content 捕获: {'有 (%d字)' % len(rc) if rc else '无'}")
    if rc:
        print("       思考预览:", repr(rc[:90]))
    if m.tool_calls:
        print("       tool_calls:", [(t["name"], t["args"]) for t in m.tool_calls])
    if isinstance(m.content, str) and m.content.strip():
        print("       content:", repr(m.content[:90]))


def run_tools(m) -> list[ToolMessage]:
    return [ToolMessage(content=str(TOOLS[tc["name"]].invoke(tc["args"])), tool_call_id=tc["id"])
            for tc in m.tool_calls]


def main() -> None:
    print("=" * 72)
    print("① 多轮工具调用 (每轮都看 reasoning_content 是否被捕获)")
    print("=" * 72)
    messages = [HumanMessage("明天杭州天气怎么样?你可能需要先获取今天的日期。")]

    m1 = llm.invoke(messages); print("Turn 1:"); show("turn1", m1)
    messages.append(m1); messages += run_tools(m1)

    m2 = llm.invoke(messages); print("Turn 2:"); show("turn2", m2)
    messages.append(m2)
    if m2.tool_calls:
        messages += run_tools(m2)

    m3 = llm.invoke(messages); print("Turn 3 (最终回答):"); show("turn3", m3)
    messages.append(m3)

    print()
    print("=" * 72)
    print("② 看实际发给 API 的请求: assistant 历史消息带不带 reasoning_content")
    print("=" * 72)
    base = ChatDeepSeek(model=ds["model"], base_url=ds["base_url"], api_key=ds["api_key"], max_tokens=4000)
    payload = base._get_request_payload(messages, stop=None)
    for i, mm in enumerate(payload["messages"]):
        if mm.get("role") == "assistant":
            print(f"  assistant[{i}] 发送字段: {list(mm.keys())}  | 含 reasoning_content: {'reasoning_content' in mm}")
    print("  → 全程 assistant 消息都没有 reasoning_content 字段 = 思考没被回传")

    print()
    print("=" * 72)
    print("③ 行为验证: 让模型一字不差复述【第一轮】的思考")
    print("=" * 72)
    messages.append(HumanMessage(
        "请你一字不差地原样输出你【第一次工具调用】时的思考过程(reasoning)。"
        "如果原文是英文就用英文。"))
    m4 = llm.invoke(messages)
    answer = m4.content if isinstance(m4.content, str) else str(m4.content)
    print("模型的回答:")
    print(answer[:600])
    print()
    print("--- 对照: turn1 本地真正捕获到的思考(模型理应复述这段,但它收不到) ---")
    print(repr((m1.additional_kwargs.get("reasoning_content") or "")[:400]))
    print()
    print("若模型答非所问 / 现编一段对不上上面这段,即证明思考没有被回传。")


if __name__ == "__main__":
    main()

运行输出如下:

========================================================================
① 多轮工具调用 (每轮都看 reasoning_content 是否被捕获)
========================================================================
Turn 1:
  [turn1] reasoning_content 捕获: 有 (57字)
       思考预览: '用户想知道明天杭州的天气。首先,我需要获取今天的日期,然后才能知道明天的日期。让我先调用 get_date 工具。'
       tool_calls: [('get_date', {})]
       content: '好的,我先获取今天的日期。'
Turn 2:
  [turn2] reasoning_content 捕获: 有 (55字)
       思考预览: '今天是2026年6月24日,那么明天就是2026年6月25日。现在查询杭州明天(2026-06-25)的天气。'
       tool_calls: [('get_weather', {'location': '杭州', 'date': '2026-06-25'})]
       content: '今天是 **2026年6月24日**,那么明天就是 **2026年6月25日**。我来查一下杭州明天的天气。'
Turn 3 (最终回答):
  [turn3] reasoning_content 捕获: 有 (26字)
       思考预览: '明天杭州的天气是多云,温度在25°C到30°C之间。'
       content: '明天(2026年6月25日)杭州的天气情况如下:\n\n- **天气状况**:☁️ 多云\n- **温度范围**:25°C ~ 30°C\n\n整体来看,明天杭州天气不错,多云为主,气温适中'

========================================================================
② 看实际发给 API 的请求: assistant 历史消息带不带 reasoning_content
========================================================================
  assistant[1] 发送字段: ['content', 'role', 'tool_calls']  | 含 reasoning_content: False
  assistant[3] 发送字段: ['content', 'role', 'tool_calls']  | 含 reasoning_content: False
  assistant[5] 发送字段: ['content', 'role']  | 含 reasoning_content: False
  → 全程 assistant 消息都没有 reasoning_content 字段 = 思考没被回传

========================================================================
③ 行为验证: 让模型一字不差复述【第一轮】的思考
========================================================================
模型的回答:
好的,我第一次工具调用时的思考过程是:

> 好的,我先获取今天的日期。

--- 对照: turn1 本地真正捕获到的思考(模型理应复述这段,但它收不到) ---
'用户想知道明天杭州的天气。首先,我需要获取今天的日期,然后才能知道明天的日期。让我先调用 get_date 工具。'

若模型答非所问 / 现编一段对不上上面这段,即证明思考没有被回传。      

但使用ChatAnthropic是能够正常回传思考内容的。
验证代码如下:

"""对照实测: langchain `ChatAnthropic`(指向 DeepSeek 的 anthropic 兼容端点)
在多轮工具调用里**会回传**思考内容。

与 `chatdeepseek_roundtrip_test.py` 同一套工具 + 同一套流程, 只换 provider, 便于并排对比:
  - ChatAnthropic 的思考是 content 里的一等 `thinking` block(不是 additional_kwargs);
  - 多轮里 thinking block **会被原样回传**给 API(本脚本抓真实请求体证明);
  - 行为佐证: 让模型一字不差复述第一轮思考, 因为它收到了自己之前的 thinking,
    应该能较准确地复现(对照 ChatDeepSeek 版复现不出)。

模型仍是 deepseek-v4-flash, 只是走 https://api.deepseek.com/anthropic 这个 anthropic 协议端点。

运行:  uv run python chatanthropic_roundtrip_test.py
"""
from __future__ import annotations

import tomllib
from pathlib import Path

from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool

ds = tomllib.loads((Path(__file__).parent / "config/settings.toml").read_text())["orchestrator"]
ANTHROPIC_BASE = "https://api.deepseek.com/anthropic"

# 抓真实发出去的 anthropic 请求(ground truth)
CAPTURED: list[dict] = []
import anthropic.resources.messages as _am
_orig_create = _am.Messages.create
def _patched(self, **kw):
    CAPTURED.append(kw)
    return _orig_create(self, **kw)
_am.Messages.create = _patched


@tool
def get_date() -> str:
    """Get the current date."""
    return "2026-06-24"


@tool
def get_weather(location: str, date: str) -> str:
    """Get weather of a location on a given date (YYYY-mm-dd)."""
    return "Cloudy 25~30°C"


TOOLS = {"get_date": get_date, "get_weather": get_weather}

llm = ChatAnthropic(
    model=ds["model"], base_url=ANTHROPIC_BASE, api_key=ds["api_key"],
    max_tokens=4000, thinking={"type": "enabled", "budget_tokens": 2000},
).bind_tools([get_date, get_weather])


def _blocks(m, t: str) -> list:
    return [b for b in m.content if isinstance(b, dict) and b.get("type") == t] \
        if isinstance(m.content, list) else []


def thinking_of(m) -> str:
    return "".join(b.get("thinking", "") for b in _blocks(m, "thinking"))


def show(tag: str, m) -> None:
    th = thinking_of(m)
    print(f"  [{tag}] thinking block: {'有 (%d字)' % len(th) if th else '无'}")
    if th:
        print("       思考预览:", repr(th[:90]))
    if m.tool_calls:
        print("       tool_calls:", [(t["name"], t["args"]) for t in m.tool_calls])
    text = "".join(b.get("text", "") for b in _blocks(m, "text"))
    if text.strip():
        print("       text:", repr(text[:90]))


def run_tools(m) -> list[ToolMessage]:
    return [ToolMessage(content=str(TOOLS[tc["name"]].invoke(tc["args"])), tool_call_id=tc["id"])
            for tc in m.tool_calls]


def main() -> None:
    print("=" * 72)
    print("① 多轮工具调用 (每轮看 thinking block 是否被捕获)")
    print("=" * 72)
    messages = [HumanMessage("明天杭州天气怎么样?你可能需要先获取今天的日期。")]

    m1 = llm.invoke(messages); print("Turn 1:"); show("turn1", m1)
    messages.append(m1); messages += run_tools(m1)

    m2 = llm.invoke(messages); print("Turn 2:"); show("turn2", m2)
    messages.append(m2)
    if m2.tool_calls:
        messages += run_tools(m2)

    m3 = llm.invoke(messages); print("Turn 3 (最终回答):"); show("turn3", m3)
    messages.append(m3)

    print()
    print("=" * 72)
    print("② 看实际发给 API 的请求: assistant 历史消息带不带 thinking block")
    print("=" * 72)
    # CAPTURED[1] 是 turn2 的请求, 它的历史里含 turn1 的 assistant 消息
    req = CAPTURED[1]
    for i, mm in enumerate(req["messages"]):
        if mm.get("role") == "assistant" and isinstance(mm.get("content"), list):
            types = [b.get("type") for b in mm["content"]]
            has_think = "thinking" in types
            print(f"  assistant[{i}] content blocks: {types}  | 含 thinking: {has_think}")
            for b in mm["content"]:
                if b.get("type") == "thinking":
                    print("       回传的思考预览:", repr(b.get("thinking", ""))[:80],
                          "| signature:", repr(b.get("signature", ""))[:18])
    print("  → assistant 消息里带着 thinking block = 思考被回传")

    print()
    print("=" * 72)
    print("③ 行为验证: 让模型一字不差复述【第一轮】的思考")
    print("=" * 72)
    messages.append(HumanMessage(
        "请你一字不差地原样输出你【第一次工具调用】时的思考过程(reasoning)。"
        "如果原文是英文就用英文。"))
    m4 = llm.invoke(messages)
    text = "".join(b.get("text", "") for b in _blocks(m4, "text")) \
        if isinstance(m4.content, list) else str(m4.content)
    print("模型的回答:")
    print(text[:600])
    print()
    print("--- 对照: turn1 本地捕获的真实思考(模型这次收到了它, 应能较准复述) ---")
    print(repr(thinking_of(m1)[:400]))
    print()
    print("若模型这次能把上面这段思考较准确地复述出来, 即证明思考被回传了。")


if __name__ == "__main__":
    main()

运行输出如下:

========================================================================
① 多轮工具调用 (每轮看 thinking block 是否被捕获)
========================================================================
Turn 1:
  [turn1] thinking block: 有 (35字)
       思考预览: '我需要先获取今天的日期,然后查询明天杭州的天气。让我先获取今天的日期。'
       tool_calls: [('get_date', {})]
       text: '好的,我先获取今天的日期。'
Turn 2:
  [turn2] thinking block: 有 (51字)
       思考预览: '今天是2026年6月24日(周三),那么明天就是2026年6月25日(周四)。让我查询明天杭州的天气。'
       tool_calls: [('get_weather', {'location': '杭州', 'date': '2026-06-25'})]
       text: '今天是2026年6月24日,明天是6月25日。让我查询一下杭州明天的天气。'
Turn 3 (最终回答):
  [turn3] thinking block: 有 (21字)
       思考预览: '明天杭州的天气是阴天,气温25~30°C。'
       text: '明天(2026年6月25日,周四)杭州的天气情况如下:\n\n🌤 **天气状况:** 阴天(多云)\n🌡 **气温范围:** 25°C ~ 30°C\n\n整体来说是比较舒适的一天,没有降雨'

========================================================================
② 看实际发给 API 的请求: assistant 历史消息带不带 thinking block
========================================================================
  assistant[1] content blocks: ['thinking', 'text', 'tool_use']  | 含 thinking: True
       回传的思考预览: '我需要先获取今天的日期,然后查询明天杭州的天气。让我先获取今天的日期。' | signature: '5c9adaf6-b579-42a
  → assistant 消息里带着 thinking block = 思考被回传

========================================================================
③ 行为验证: 让模型一字不差复述【第一轮】的思考
========================================================================
模型的回答:
好的,我【第一次工具调用】时的思考过程(reasoning)原文如下:

**我需要先获取今天的日期,然后查询明天杭州的天气。让我先获取今天的日期。**

--- 对照: turn1 本地捕获的真实思考(模型这次收到了它, 应能较准复述) ---
'我需要先获取今天的日期,然后查询明天杭州的天气。让我先获取今天的日期。'

若模型这次能把上面这段思考较准确地复述出来, 即证明思考被回传了。
posted @ 2026-06-25 10:33  RolandHe  阅读(37)  评论(0)    收藏  举报