Исправление отображения Chain of Thought в LangChain для DeepSeek и Stepfun | Гайд 2026 | AiManual
AiManual Logo Ai / Manual.
20 Апр 2026 Гайд

LangChain и reasoning content: Как вытащить цепочку мыслей из DeepSeek и Stepfun

Пошаговое руководство по обработке reasoning content в CoT-моделях через LangChain. Решение проблем интеграции для DeepSeek, Stepfun и других провайдеров.

Проблема: reasoning content исчезает в черном ящике LangChain

Представь ситуацию. Ты подключаешь свежую модель DeepSeek-V3-32B-Reasoning (актуальную на апрель 2026) или Stepfun 4.5-Turbo-Thinking к своему RAG-пайплайну через LangChain. Запрос сложный: "Проанализируй квартальный отчет Tesla и спрогнозируй влияние на цену акций". В консоли провайдера видно, как модель красиво рассуждает, строит цепочку мыслей (Chain of Thought), а потом выдает итоговый ответ.

Но в твоем приложении пользователь видит только сухой вывод. Весь reasoning content, вся "кухня" модели – пропала. UX хромает. А если ты хотел использовать промежуточные размышления для последующей прокачки reasoning или логирования? Пиши пропало.

Проблема не в моделях. Современные CoT-модели (DeepSeek, Stepfun, Qwen в thinking mode) возвращают reasoning content. Проблема в том, что LangChain по умолчанию его игнорирует. Стандартные ChatOpenAI или ChatDeepSeek классы вытаскивают только content из первого сообщения ассистента. Все остальное – в мусор.

Почему так происходит? API нестандартны, а LangChain любит стандарты

Открой документацию DeepSeek API на 2026 год. В ответе есть поле choices[0].message.reasoning_content или reasoning_details. У Stepfun – reasoning_steps внутри метаданных. У Qwen – отдельный флаг в запросе. Каждый провайдер делает по-своему. LangChain же абстрагирует это в единый интерфейс, и в угоду совместимости теряет специфичные данные.

Тебе говорят: "Используй output parsers или callbacks". Но когда ты впервые видишь, как из ответа модели с 20 шагами reasoning ты получаешь одну строку – хочется бросить это дело. Не бросай. Сейчас исправим.

1 Диагностика: что на самом деле возвращает API

Первое – отбрось LangChain в сторону. Напиши прямой запрос к API провайдера и посмотри сырой ответ. Вот пример для DeepSeek (используя актуальный на 2026 Python SDK):

import os
from openai import OpenAI

client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com/v1"
)

response = client.chat.completions.create(
    model="deepseek-reasoner",
    messages=[{"role": "user", "content": "Реши: 15 * 24 + 38"}],
    reasoning_format="detailed",  # Новый флаг 2026 года
    stream=False
)

print("Полный ответ:")
print(response)
print("\n---\n")
print("Reasoning content:")
print(response.choices[0].message.reasoning_content)  # Вот оно!

Увидишь структуру. Теперь понятно, где прячется reasoning. Запомни это поле. У Stepfun, возможно, это будет response.choices[0].message.metadata['reasoning']. Проверь документацию своего провайдера. Не доверяй абстракциям пока не увидишь сырые данные.

💡
Совет: включи логирование запросов в LangChain. Используй langchain.callbacks.tracers.langchain.LangChainTracer или просто мониторь сетевой трафик через httpx. Увидишь, что приходит в ответе, и что из этого LangChain отбрасывает.

2 Кастомный обработчик ответа: заставляем LangChain сохранить reasoning

Теперь создадим свой класс, который наследуется от стандартного ChatDeepSeek (или другого) и переопределит метод обработки ответа. Вот каркас:

from langchain_openai import ChatOpenAI
from typing import Any, Dict, Optional
from langchain_core.messages import AIMessage

class ChatDeepSeekWithReasoning(ChatOpenAI):
    """Кастомный класс для DeepSeek с извлечением reasoning content."""
    reasoning_field: str = "reasoning_content"  # Поле из API ответа

    def _create_message_dict(self, response: Dict[str, Any]) -> Dict[str, Any]:
        """Переопределяем создание словаря сообщения, чтобы добавить reasoning."""
        message_dict = super()._create_message_dict(response)
        
        # Извлекаем reasoning из сырого ответа API
        if self.reasoning_field in response:
            message_dict["reasoning_content"] = response[self.reasoning_field]
        # Или ищем во вложенных структурах (для Stepfun)
        elif "metadata" in response and "reasoning_steps" in response["metadata"]:
            message_dict["reasoning_content"] = response["metadata"]["reasoning_steps"]
        
        return message_dict

    def _stream_response_to_message(self, *args, **kwargs):
        # Аналогично для стриминга (если нужно)
        message = super()._stream_response_to_message(*args, **kwargs)
        # ... логика извлечения reasoning из чанков стрима
        return message

Это основа. Но в реальности LangChain может парсить ответ иначе. Чаще проще создать кастомный Output Parser, который будет работать с сырым ответом модели до его упрощения.

3 Output Parser – более универсальное решение

Создадим парсер, который извлекает и текст, и reasoning. Он будет возвращать структурированный объект, а не просто строку.

from langchain_core.output_parsers import BaseOutputParser
from langchain_core.messages import AIMessage
from typing import List
from pydantic import BaseModel

class ReasoningOutput(BaseModel):
    final_answer: str
    chain_of_thought: List[str]

class DeepSeekReasoningParser(BaseOutputParser):
    """Парсер для ответов DeepSeek с reasoning."""
    def parse(self, text: str) -> ReasoningOutput:
        # Этот метод работает с текстом, но нам нужен доступ к сырому ответу.
        # Поэтому лучше переопределить parse_result.
        raise NotImplementedError("Используй parse_result с сырым ответом.")
    
    def parse_result(self, result: List[Any], **kwargs) -> ReasoningOutput:
        # result - это список Generation или ChatGeneration
        # Докопаемся до сырых данных из response
        raw_response = result[0].message.response_metadata.get("raw_response", {})
        
        reasoning = raw_response.get("choices", [{}])[0].get("message", {}).get("reasoning_content", "")
        final = result[0].message.content
        
        # Разбиваем reasoning на шаги (предположим, это текст с маркерами)
        steps = [step.strip() for step in reasoning.split('\n') if step.strip()]
        
        return ReasoningOutput(
            final_answer=final,
            chain_of_thought=steps
        )

# Использование в цепочке
from langchain_core.prompts import ChatPromptTemplate
from langchain.chains import LLMChain

prompt = ChatPromptTemplate.from_messages([
    ("system", "Ты аналитик. Думай шаг за шагом."),
    ("user", "{question}")
])

llm = ChatOpenAI(base_url="https://api.deepseek.com/v1", model="deepseek-reasoner")
# Настроим llm, чтобы он сохранял сырой ответ в метаданные
llm.model_kwargs = {"include_raw_response": True}  # Если API поддерживает

chain = LLMChain(
    llm=llm,
    prompt=prompt,
    output_parser=DeepSeekReasoningParser()
)

result = chain.invoke({"question": "Сколько дней в 2026 году?"})
print(f"Ответ: {result.final_answer}")
print(f"Цепочка мыслей: {result.chain_of_thought}")

Здесь фокус в том, чтобы получить доступ к raw_response. Некоторые классы LangChain его не сохраняют. Придется либо патчить класс LLM, либо использовать калбэки.

4 Калбэки – мощный, но сложный путь

Если не хочешь лезть в кишки парсеров, используй калбэки. Они перехватывают ответы на каждом этапе. Можно написать калбэк, который при получении ответа от модели сохранит reasoning в контекст или сразу отдаст пользователю.

from langchain_core.callbacks import BaseCallbackHandler

class ReasoningCallback(BaseCallbackHandler):
    def on_llm_end(self, response, **kwargs):
        # response.llm_output может содержать сырой ответ
        raw = response.llm_output.get("raw_response", {})
        reasoning = raw.get("choices", [{}])[0].get("message", {}).get("reasoning_content")
        if reasoning:
            # Сохраняем куда-нибудь, например, в глобальную переменную или контекст
            import sys
            sys.reasoning_cache = reasoning
        
# Использование
from langchain.callbacks import CallbackManager

callback_manager = CallbackManager([ReasoningCallback()])
llm.callback_manager = callback_manager

Грязно? Да. Работает? Чаще да. Но калбэки усложняют поток данных. Я предпочитаю явные парсеры.

Нюансы и подводные камни

  • Стриминг: Если используешь stream=True, reasoning может приходить отдельными чанками. Придется их агрегировать. Некоторые провайдеры отправляют reasoning в начале, а затем финальный ответ. Проверь свою модель.
  • Производительность: Сохранение сырых ответов увеличивает объем памяти. Не делай этого для высоконагруженных продакшен-систем без необходимости. Используй флаги только для отладки.
  • Ползунки: Параметры вроде temperature и top_p влияют на качество reasoning. Для аналитических задач ставь температуру пониже (0.1-0.3), чтобы reasoning был последовательным. Для креативных – повыше. Выбор модели и параметров – это искусство.
  • Стоимость: Некоторые провайдеры (как OpenAI o3 в 2026) тарифицируют reasoning tokens отдельно и дороже. Учитывай это в бюджете.

Если ничего не помогло: костыль уровня Senior DevOps

Бывает, что LangChain упорно отказывается отдавать reasoning. Тогда развертываешь прокси-сервер между LangChain и API провайдера. Этот прокси будет логировать все запросы и ответы, а также модифицировать их, добавляя reasoning в понятном LangChain виде. Например, можно засунуть reasoning в поле content перед финальным ответом, или в кастомные заголовки.

# Пример на FastAPI
from fastapi import FastAPI, Request
import httpx
import json

app = FastAPI()

@app.api_route("/v1/chat/completions", methods=["POST"])
async def proxy(request: Request):
    body = await request.json()
    async with httpx.AsyncClient() as client:
        # Отправляем запрос к реальному API DeepSeek
        response = await client.post("https://api.deepseek.com/v1/chat/completions", json=body, headers=request.headers)
        data = response.json()
        # Извлекаем reasoning
        reasoning = data.get("choices", [{}])[0].get("message", {}).get("reasoning_content", "")
        # Добавляем его в content как дополнение
        if reasoning:
            data["choices"][0]["message"]["content"] = f"REASONING:\n{reasoning}\n\nANSWER:\n{data['choices'][0]['message']['content']}"
        return data

Грубо? Да. Но когда deadline горят, а инвесторы требуют фичу – такой костыль спасает. Потом, конечно, нужно переписать на нормальную интеграцию.

Финальный совет: не зацикливайся на LangChain

LangChain – удобный фреймворк для быстрого прототипирования. Но когда нужен полный контроль над reasoning content, возможно, стоит использовать прямое взаимодействие с API или другие библиотеки, например, Ollama Python (партнерская ссылка на инструмент для локального запуска моделей) или собственные клиенты. Особенно если твое приложение – это сложный пайплайн с Society of Thought.

А если все-таки остаешься в LangChain, создай отдельный модуль для обработки reasoning. Вынеси туда все кастомные парсеры и классы. И напиши тесты, чтобы при обновлении LangChain твой костыль не сломался. Потому что сломается. Обязательно.

Провайдер Поле с reasoning (2026) Флаг для активации
DeepSeek message.reasoning_content reasoning_format="detailed"
Stepfun message.metadata.reasoning_steps reasoning=true в запросе
Qwen (Alibaba Cloud) message.thinking incremental_output=true

Держи эту таблицу под рукой. И помни: reasoning content – это не побочный продукт, а ключ к пониманию того, как думает модель. Не теряй его.

Подписаться на канал