给 Agent 接入新模型的推理模式:从配置开关到协议适配

最近我们给一个 agent 系统接入了新的推理模型。

一开始我以为这会是个很小的工作:模型本身能调通,又是 OpenAI-compatible,按经验补几个配置项,前端把思考模式打开,应该就差不多了。

真正做下来才发现,事情没这么简单。

问题不在"模型能不能回答",而在agent 怎么把模型的推理模式接进自己的多轮对话、工具调用和历史消息链路里

这篇文章想讲的就是这件事:给 agent 系统接入一个新模型的 reasoning 模式,到底要改哪些层,代码上又是怎么落地的。


背景:为什么 agent 接入 reasoning 模式比普通聊天更麻烦

如果只是做一个简单聊天页面,很多模型的 reasoning 模式无非是多传一个参数。

比如:

json 复制代码
{
  "extra_body": {
    "thinking": {
      "type": "enabled"
    }
  }
}

或者:

json 复制代码
{
  "enable_thinking": true
}

但 agent 场景不一样。

agent 的一次任务往往会经历这几个步骤:

  1. 用户发消息
  2. 模型决定是否调用工具
  3. 工具返回结果
  4. 模型继续推理
  5. 历史消息被写入线程状态
  6. 下一轮请求再把这些历史发回模型

这时候,reasoning 模式就不只是"这一轮开不开"的问题了。你还得保证:

  • 模型返回的推理字段能被解析出来
  • 这些字段能跟消息一起保存
  • 下一轮重放历史时字段不会丢
  • 前端知道这个模型支持哪些推理档位

所以在 agent 里,reasoning 支持本质上是一条链路,不是一个开关。


这类适配一般要做四层

这次做完以后,我觉得给 agent 接新模型的推理模式,基本都绕不开四层:

  1. 能力声明层

    告诉系统这个模型支不支持 thinking、支不支持 reasoning effort

  2. 响应解析层

    把 provider 返回的推理字段解析出来,放到统一的内部结构里

  3. 历史重放层

    下一轮请求时,把这些推理字段再带回去

  4. 历史兼容层

    旧线程里可能已经有不完整的历史消息,要能兜住

下面就按这四层展开讲。


第一层:先把模型能力声明清楚

这是最表面的一层,但也是最容易漏的。

我们前端之所以会显示 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: true
  • supports_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

这段逻辑做了两件事:

  1. 如果旧的 assistant message 没有 reasoning_content,跳过它
  2. 如果它还带了 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_thinking
  • supports_reasoning_effort

前端自然就知道该不该显示:

  • Thinking
  • Pro
  • Ultra

这也是为什么这次除了 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。

相关推荐
老吴的商业笔记1 小时前
爱搜索 GEO 营销系统全维度实测与价值评估
人工智能
熊猫_豆豆1 小时前
仿真模拟两颗卫星的自主交会对接过程(Python版)
开发语言·python
视***间1 小时前
算力为核,智驱无界——视程空间硬核赋能机器狗与机器人,解锁具身智能产业新未来
大数据·运维·人工智能·机器人·采集卡·机器狗·视程空间
大江东去浪淘尽千古风流人物1 小时前
【SANA-WM】分钟级世界模型:混合线性扩散Transformer与双分支相机控制深度解析
人工智能·深度学习·架构·spark·机器人·transformer·wm
Bruce_Liuxiaowei1 小时前
WorkBuddy案例——教育辅导智能体
人工智能·ai·大模型·智能体·龙虾·workbuddy
小江的记录本1 小时前
【MySQL】《MySQL日志面试背诵版+思维导图》(核心考点 + MySQL 8.0最新优化)
java·数据库·后端·python·sql·mysql·面试
西洼工作室1 小时前
Python邮箱工具类封装:高效邮件发送与管理
python·全栈
子午1 小时前
基于YOLO的水稻害虫检测系统~Python+yolov8算法+深度学习+人工智能+模型训练
人工智能·python·yolo
Matrix_111 小时前
第3篇:色彩空间原理与转换——从RGB到HSI、HSV、LAB
图像处理·人工智能·机器学习