Skip to content

推理 Token(Reasoning tokens) ​

在可折叠块中展示模型的思考与推理过程

推理 Token 暴露了诸如 OpenAI 的 GPT-5 以及 Anthropic 支持扩展思考(extended thinking)的 Claude 等高级模型的内部思考过程。这些模型会生成结构化的内容块,将推理过程与最终答案分离开来,让你可以构建展示模型如何得出其回复的 UI。

什么是推理 Token? ​

当具备推理能力且支持推理的模型处理提示词(prompt)时,它们会生成两种截然不同类型的内容:

  1. 推理块(Reasoning blocks):模型的内部思维链(chain-of-thought)、问题拆解以及逐步分析
  2. 文本块(Text blocks):呈现给用户的最终润色后的回复

这些内容在 AIMessage 中作为带类型的内容块(typed content blocks)进行传递,可通过 contentBlocks 属性访问:

ts
// 推理块
{ type: "reasoning", reasoning: "Let me think about this step by step..." }

// 文本块
{ type: "text", text: "The answer is 42." }

NOTE

并非所有模型都会产生推理 Token。该模式专门适用于支持扩展思考(extended thinking)或思维链(chain-of-thought)输出的模型。标准的聊天模型仅返回文本块。

使用场景 ​

  • 透明度:向用户展示模型的推理过程,以建立对其答案的信任
  • 调试:检查模型的思考过程,以找出出错的位置
  • 教育工具:通过揭示 AI 如何分析问题,教授学生解决问题的方法
  • 决策支持:让领域专家验证建议背后的推理逻辑
  • 质量保证:在受监管行业中审计推理链以确保合规性

提取推理块与文本块 ​

AIMessage 上的 contentBlocks 数组按生成顺序列出了所有块。通过 type 进行过滤,即可将推理与文本分离开来:

ts
import { AIMessage } from "langchain";

function extractBlocks(msg: AIMessage) {
  const reasoningBlocks = msg.contentBlocks
    .filter((b) => b.type === "reasoning")
    .map((b) => b.reasoning);

  const textBlocks = msg.contentBlocks
    .filter((b) => b.type === "text")
    .map((b) => b.text);

  return {
    reasoning: reasoningBlocks.join(""),
    text: textBlocks.join(""),
  };
}

单条消息可能包含多个推理块(例如,模型暂停其推理,输出部分文本,然后进一步推理)。将它们拼接起来即可得到完整的思考过程。

从 useStream 访问消息 ​

将 useStream 连接到具备推理能力的 Agent,并在你的聊天 UI 中遍历 stream.messages。通过 HumanMessage.isInstance 和 AIMessage.isInstance 进行条件分支判断,然后将每条助手消息传递给一个读取 contentBlocks 并分离推理与文本的组件。在 stream.isLoading 为 true 时,将最后一条消息的 isStreaming 设为 true,以便随着 Token 的到达实时更新思考块。

NOTE

代码示例使用 useStream<typeof myAgent> 来实现类型安全的流状态。请参阅针对 Python 或 JavaScript 后端的类型推断。

tsx
import { useStream } from "@langchain/react";
import { AIMessage, HumanMessage } from "langchain";

function Chat() {
  const stream = useStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "reasoning",
  });

  return (
    <div className="messages">
      {stream.messages.map((msg, i) => {
        if (HumanMessage.isInstance(msg)) {
          return <HumanBubble key={i} text={msg.text} />;
        }
        if (AIMessage.isInstance(msg)) {
          return (
            <AIResponse
              key={i}
              message={msg}
              isStreaming={stream.isLoading && i === stream.messages.length - 1}
            />
          );
        }
        return null;
      })}
    </div>
  );
}
vue
<script setup lang="ts">
import { useStream } from "@langchain/vue";
import { AIMessage, HumanMessage } from "langchain";

const stream = useStream<typeof myAgent>({
  apiUrl: "http://localhost:2024",
  assistantId: "reasoning",
});
`&lt;/script&gt;`

<template>
  <div class="messages">
    <template v-for="(msg, i) in stream.messages.value" :key="i">
      <HumanBubble v-if="HumanMessage.isInstance(msg)" :text="msg.text" />
      <AIResponse
        v-else-if="AIMessage.isInstance(msg)"
        :message="msg"
        :isStreaming="stream.isLoading.value && i === stream.messages.value.length - 1"
      />
    </template>
  </div>
</template>
svelte
<script lang="ts">
  import { useStream } from "@langchain/svelte";
  import { AIMessage, HumanMessage } from "langchain";

  const stream = useStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "reasoning",
  });
`&lt;/script&gt;`

<div class="messages">
  {#each stream.messages as msg, i}
    {#if HumanMessage.isInstance(msg)}
      <HumanBubble text={msg.text} />
    {:else if AIMessage.isInstance(msg)}
      <AIResponse
        message={msg}
        isStreaming={stream.isLoading && i === stream.messages.length - 1}
      />
    {/if}
  {/each}
</div>
ts
import { Component } from "@angular/core";
import { injectStream } from "@langchain/angular";
import { AIMessage, HumanMessage } from "langchain";

@Component({
  selector: "app-chat",
  template: `
    <div class="messages">
      @for (msg of stream.messages(); track $index) {
        @if (isHuman(msg)) {
          <human-bubble [text]="msg.text" />
        } @else if (isAI(msg)) {
          <ai-response
            [message]="msg"
            [isStreaming]="stream.isLoading() && $index === stream.messages().length - 1"
          />
        }
      }
    </div>
  `,
})
export class ChatComponent {
  stream = injectStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "reasoning",
  });

  isHuman = HumanMessage.isInstance;
  isAI = AIMessage.isInstance;
}

构建 ThinkingBubble 组件 ​

ThinkingBubble 在一个视觉上明显区分的、可折叠的容器中呈现推理 Token。用户可以展开它以查看完整的思考过程,也可以折叠它以专注于最终答案。

tsx
import { useState } from "react";

function ThinkingBubble({
  reasoning,
  isStreaming,
}: {
  reasoning: string;
  isStreaming: boolean;
}) {
  const [isExpanded, setIsExpanded] = useState(false);

  const charCount = reasoning.length;
  const previewLength = 120;
  const preview =
    reasoning.length > previewLength
      ? reasoning.slice(0, previewLength) + "..."
      : reasoning;

  return (
    <div className="thinking-bubble">
      <button
        className="thinking-header"
        onClick={() => setIsExpanded(!isExpanded)}
      >
        <span className="thinking-icon">
          {isStreaming ? (
            <span className="thinking-spinner" />
          ) : (
            "💭"
          )}
        </span>
        <span className="thinking-label">
          {isStreaming ? "Thinking..." : `Thought process (${charCount} chars)`}
        </span>
        <span className={`chevron ${isExpanded ? "expanded" : ""}`}>▶</span>
      </button>

      {isExpanded && (
        <div className="thinking-content">
          <pre>{reasoning}</pre>
        </div>
      )}

      {!isExpanded && !isStreaming && (
        <div className="thinking-preview">{preview}</div>
      )}
    </div>
  );
}

渲染完整的 AI 回复 ​

将 ThinkingBubble 和标准文本气泡组合到单个 AIResponse 组件中:

tsx
function AIResponse({
  message,
  isStreaming,
}: {
  message: AIMessage;
  isStreaming: boolean;
}) {
  const reasoningBlocks = message.contentBlocks
    .filter((b) => b.type === "reasoning")
    .map((b) => b.reasoning)
    .join("");

  const textBlocks = message.contentBlocks
    .filter((b) => b.type === "text")
    .map((b) => b.text)
    .join("");

  const hasReasoning = reasoningBlocks.length > 0;
  const hasText = textBlocks.length > 0;

  const isReasoningPhase = isStreaming && !hasText;
  const isTextPhase = isStreaming && hasText;

  return (
    <div className="ai-response">
      {hasReasoning && (
        <ThinkingBubble
          reasoning={reasoningBlocks}
          isStreaming={isReasoningPhase}
        />
      )}
      {hasText && (
        <div className="ai-text-bubble">
          <p>{textBlocks}</p>
          {isTextPhase && <span className="cursor-blink">▊</span>}
        </div>
      )}
    </div>
  );
}

处理边缘情况 ​

不含推理的消息 ​

并非每条 AI 消息都会包含推理块。当 contentBlocks 仅包含文本块时,渲染一个不带 ThinkingBubble 的标准消息气泡。

空的推理块 ​

某些模型会生成空的推理块作为占位符。将它们过滤掉:

ts
const meaningfulReasoning = message.contentBlocks
  .filter((b) => b.type === "reasoning" && b.reasoning.trim().length > 0);

多个推理-文本循环 ​

单条消息可以在推理块和文本块之间交替出现。如果你需要保留这种交替穿插的结构,请按顺序遍历 contentBlocks,而不是按类型分组:

ts
message.contentBlocks.forEach((block) => {
  if (block.type === "reasoning") {
    // 渲染 ThinkingBubble
  } else if (block.type === "text") {
    // 渲染文本段落
  }
});

最佳实践 ​

  • 默认折叠:按需展示推理过程,而非默认展开
  • 显示字符数:让用户快速了解该回复经历了多大体量的思考
  • 视觉区分:使用鲜明的颜色、边框或背景,避免将推理过程与实际答案混淆
  • 过渡动画:平滑的展开/折叠动画可提升感知质量
  • 兼顾无障碍(Accessibility):在切换按钮上使用正确的 ARIA 属性(aria-expanded、aria-controls)
  • 在预览中截断:折叠时显示推理内容的简短预览,以便用户决定是否展开