Проблема: 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.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 – это не побочный продукт, а ключ к пониманию того, как думает модель. Не теряй его.