最近我们给一个 agent 系统接入了新的推理模型。
一开始我以为这会是个很小的工作:模型本身能调通,又是 OpenAI-compatible,按经验补几个配置项,前端把思考模式打开,应该就差不多了。
真正做下来才发现,事情没这么简单。
问题不在"模型能不能回答",而在agent 怎么把模型的推理模式接进自己的多轮对话、工具调用和历史消息链路里 。
这篇文章想讲的就是这件事:给 agent 系统接入一个新模型的 reasoning 模式,到底要改哪些层,代码上又是怎么落地的。
背景:为什么 agent 接入 reasoning 模式比普通聊天更麻烦
如果只是做一个简单聊天页面,很多模型的 reasoning 模式无非是多传一个参数。
比如:
json
{
"extra_body": {
"thinking": {
"type": "enabled"
}
}
}
或者:
json
{
"enable_thinking": true
}
但 agent 场景不一样。
agent 的一次任务往往会经历这几个步骤:
- 用户发消息
- 模型决定是否调用工具
- 工具返回结果
- 模型继续推理
- 历史消息被写入线程状态
- 下一轮请求再把这些历史发回模型
这时候,reasoning 模式就不只是"这一轮开不开"的问题了。你还得保证:
- 模型返回的推理字段能被解析出来
- 这些字段能跟消息一起保存
- 下一轮重放历史时字段不会丢
- 前端知道这个模型支持哪些推理档位
所以在 agent 里,reasoning 支持本质上是一条链路,不是一个开关。
这类适配一般要做四层
这次做完以后,我觉得给 agent 接新模型的推理模式,基本都绕不开四层:
-
能力声明层
告诉系统这个模型支不支持 thinking、支不支持 reasoning effort
-
响应解析层
把 provider 返回的推理字段解析出来,放到统一的内部结构里
-
历史重放层
下一轮请求时,把这些推理字段再带回去
-
历史兼容层
旧线程里可能已经有不完整的历史消息,要能兜住
下面就按这四层展开讲。
第一层:先把模型能力声明清楚
这是最表面的一层,但也是最容易漏的。
我们前端之所以会显示 Thinking / Pro / Ultra,不是因为模型"看起来很强",而是因为后端模型配置明确声明了这些能力。
像这次给两个 MIMO 模型开的配置,大概是这样:
yaml
- name: mimo-v2-5-pro
display_name: MiMo-V2.5-Pro
use: deerflow.models.patched_mimo:PatchedMimoChatModel
model: mimo-v2.5-pro
api_key: $MIMO_API_KEY
base_url: $MIMO_BASE_URL
max_tokens: 4096
temperature: 0.7
supports_thinking: true
supports_reasoning_effort: true
when_thinking_enabled:
extra_body:
thinking:
type: enabled
when_thinking_disabled:
extra_body:
thinking:
type: disabled
reasoning_effort: minimal
stream_usage: true
这里有两个关键信号:
supports_thinking: truesupports_reasoning_effort: true
前者决定这个模型能不能开思考模式。
后者决定前端能不能把 Pro / Ultra 这种强度档位开放出来。
然后运行时工厂会根据这些配置,拼出真正的模型参数:
python
if thinking_enabled and has_thinking_settings:
if not model_config.supports_thinking:
raise ValueError(...)
if effective_wte:
model_settings_from_config.update(effective_wte)
if not thinking_enabled:
if model_config.when_thinking_disabled is not None:
model_settings_from_config.update(model_config.when_thinking_disabled)
也就是说,这一层的作用其实有两个:
- 控制后端怎么发请求
- 控制前端怎么展示模式
如果这里不补,哪怕底层 provider 已经支持 reasoning,页面上也还是只能看到 flash。
第二层:把 provider 返回的推理字段真正接住
接下来才是核心问题。
这次我们遇到的 provider 会在 assistant message 里返回 reasoning_content。
如果不做特殊处理,默认的 ChatOpenAI 会把正常 content 保留下来,但这个 provider-specific 字段会被忽略。
所以第一步是:在响应解析阶段把它接住。
我们最后做法是加一个专门的适配类 PatchedMimoChatModel,继承 ChatOpenAI,只补 MIMO 这条协议需要的部分。
先看非流式响应的处理:
python
def _create_chat_result(
self,
response: dict | Any,
generation_info: dict | None = None,
) -> ChatResult:
result = super()._create_chat_result(response, generation_info)
response_dict = response if isinstance(response, dict) else response.model_dump()
choices = response_dict.get("choices", [])
generations: list[ChatGeneration] = []
for index, generation in enumerate(result.generations):
choice = choices[index] if index < len(choices) else {}
message = generation.message
if isinstance(message, AIMessage):
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
reasoning_content = choice_message.get("reasoning_content")
if isinstance(reasoning_content, str) and reasoning_content.strip():
message = _with_reasoning_content(message, reasoning_content)
generation = ChatGeneration(
message=message,
generation_info=generation.generation_info,
)
generations.append(generation)
return ChatResult(generations=generations, llm_output=result.llm_output)
关键点就一个:
把 provider 返回的 message.reasoning_content 映射到内部消息对象的 additional_kwargs 上。
这样消息在系统里就变成了:
python
AIMessage(
content="...",
additional_kwargs={
"reasoning_content": "..."
}
)
这一步很重要,因为后面所有逻辑都基于这个统一结构继续走。
第三层:流式场景也要保住 reasoning 内容
非流式场景处理好了还不够,很多 agent 默认是流式输出。
而流式场景下,reasoning_content 不是一次性返回的,而是跟普通文本一样分 chunk 回来。
所以如果只改 _create_chat_result(),第一轮看起来可能没问题,但流式过程中 reasoning 其实还是会悄悄丢掉。
所以还要补流式 chunk 处理:
python
def _convert_chunk_to_generation_chunk(
self,
chunk: dict,
default_chunk_class: type,
base_generation_info: dict | None,
) -> ChatGenerationChunk | None:
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
if len(choices) == 0:
return ChatGenerationChunk(
message=default_chunk_class(content=""),
generation_info=base_generation_info,
)
choice = choices[0]
delta = choice.get("delta")
if delta is None:
return None
message_chunk = _convert_delta_to_message_chunk(delta, default_chunk_class)
if isinstance(message_chunk, AIMessageChunk):
if isinstance(delta.get("reasoning_content"), str) and delta["reasoning_content"].strip():
message_chunk = _with_reasoning_content(
message_chunk,
delta["reasoning_content"],
preserve_whitespace=True,
)
return ChatGenerationChunk(
message=message_chunk,
generation_info=base_generation_info,
)
这里做的事也不复杂:
- 读每个 delta 里的
reasoning_content - 把它不断追加到
AIMessageChunk.additional_kwargs["reasoning_content"]
这一步补上之后,流式推理内容和最终消息对象就能对齐了。
第四层:下一轮请求时,把 reasoning 内容带回去
这次真正让问题暴露出来的,其实不是响应解析,而是多轮历史重放。
MIMO 的报错很明确:
text
The reasoning_content in the thinking mode must be passed back to the API.
也就是说,模型不光要求你接住上一轮的 reasoning_content,还要求你下一轮把它原样带回去。
这一步就不能只靠配置解决了,因为默认的 ChatOpenAI 在组装历史消息 payload 时,不会自动把 additional_kwargs["reasoning_content"] 再写回 assistant message。
所以我们在 _get_request_payload(...) 里补了这段逻辑:
python
def _get_request_payload(
self,
input_: LanguageModelInput,
*,
stop: list[str] | None = None,
**kwargs: Any,
) -> dict:
original_messages = self._convert_input(input_).to_messages()
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
payload_messages = payload.get("messages", [])
if len(payload_messages) == len(original_messages):
for payload_msg, orig_msg in zip(payload_messages, original_messages):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_reasoning_content(payload_msg, orig_msg)
else:
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
assistant_payloads = [m for m in payload_messages if m.get("role") == "assistant"]
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
_restore_reasoning_content(payload_msg, ai_msg)
payload["messages"] = _drop_legacy_messages_missing_reasoning(payload_messages)
return payload
其中 _restore_reasoning_content(...) 很直接:
python
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content")
if reasoning_content is not None:
payload_msg["reasoning_content"] = reasoning_content
这就是整个适配里最关键的一步:
从内部消息对象里取出
reasoning_content,重新写回 provider 期望的 payload 格式。
做到这里,新生成的消息链路才算完整闭环:
- provider 返回
reasoning_content - 我们存下来
- 下一轮请求再带回去
最麻烦的部分:旧线程里的历史消息怎么办
如果所有线程都是新开的,做到上面基本就够了。
但真实系统里通常不是这样。
这次我们很快就碰到了一个很现实的问题:老线程里已经存在一批旧消息了。
这些消息是在补丁上线前生成的,当时系统还不会保存 reasoning_content。
于是就会出现这种情况:
- 新代码已经能正确保存和回传 reasoning 字段
- 但旧线程里某条 assistant message 还是缺字段
- 下一轮请求一重放这条历史,provider 继续报 400
这类问题很烦,因为它不是"当前代码错了",而是"历史数据不完整"。
我们的处理方式比较务实:
遇到这种不满足新协议的 legacy assistant turn,就在重放时跳过它。
代码是这样的:
python
def _drop_legacy_messages_missing_reasoning(payload_messages: list[dict]) -> list[dict]:
cleaned: list[dict] = []
skipped_tool_call_ids: set[str] = set()
for message in payload_messages:
role = message.get("role")
if role == "tool":
tool_call_id = message.get("tool_call_id")
if isinstance(tool_call_id, str) and tool_call_id in skipped_tool_call_ids:
continue
cleaned.append(message)
continue
if role == "assistant" and not _has_reasoning_content(message):
for tool_call in message.get("tool_calls") or []:
if isinstance(tool_call, dict):
tool_call_id = tool_call.get("id")
if isinstance(tool_call_id, str) and tool_call_id:
skipped_tool_call_ids.add(tool_call_id)
continue
cleaned.append(message)
return cleaned
这段逻辑做了两件事:
- 如果旧的 assistant message 没有
reasoning_content,跳过它 - 如果它还带了 tool call,把对应的 tool message 一起跳过
这不是最"完美"的方案,但它能解决一个非常实际的问题:
不让旧线程永远卡死在协议错误上。
最后一步:把前端模式也接上
底层协议补完之后,前端还要能把模式显示出来。
我们前端的逻辑大致是这样的:
ts
const supportThinking = selectedModel?.supports_thinking ?? false;
const supportReasoningEffort =
selectedModel?.supports_reasoning_effort ?? false;
模式切换时会把不同 UI 选项映射成不同的 reasoning effort:
ts
reasoning_effort:
mode === "ultra"
? "high"
: mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal"
所以只要后端模型列表把这两个能力位透出来:
supports_thinkingsupports_reasoning_effort
前端自然就知道该不该显示:
ThinkingProUltra
这也是为什么这次除了 provider 适配本身,我们还专门补了模型配置和回归测试。
这次我们怎么验证
这类改动如果只测 happy path,其实很容易出假阳性。
所以这次验证我比较看重三件事:
1. 配置能力是不是正确透出
直接测模型配置,确保两个 MIMO 模型声明了 thinking 和 reasoning effort 支持。
2. provider 适配是不是完整
分别测:
- 非流式
reasoning_content解析 - 流式 chunk 中
reasoning_content的保留 - 下一轮请求的回传
3. 旧线程是不是不会再炸
这一步最关键。
我们用旧 thread id 复测过,确认原来的 400 reasoning_content must be passed back 已经消失,剩下的是 provider 自己的额度错误。这说明协议层的问题已经摘掉了。
结尾
这次做完之后,我最大的感受是:
给 agent 接入新模型的推理模式,重点不在"模型能不能回答",而在"它的推理协议能不能进入系统的完整生命周期"。
要接住的不只是一个开关,还包括:
- 响应解析
- 历史保存
- 多轮重放
- 旧数据兼容
- 前端能力展示
这些层都打通了,才能说这个模型在 agent 里真正支持了 reasoning。