上篇

12  前端

       这里的前端主要是介绍前端如何与agent交互,包括提交数据,处理数据流等。这里只是简单对点对点通信做了说明。真正生产使用会面对复杂分布式状态管理问题。所以这个东西要生产落地,面临的问题会复杂很多。有个严肃的问题是pyhon这玩意是不是适合生产落地,是不是与其他语言框架结合,不可替代部分用python,或者就全套python解决方案,是一个非常复杂的问题。

       为使用 createAgent 创建的智能体构建丰富、交互式的前端。这些模式涵盖了从基础的消息渲染到高级工作流(如人工介入审批和时间旅行调试)的所有内容,主要是讲前端和后台接口的交互,后台提供了那些功能可以在前端使用。还有一些编写好的的库。有简单的交互窗口,不流畅,一抖一抖的。了解一下提供的能力就行。

前端代码主要是react示例,reac html和js搅合在一起看起是毫无美感。有个时候看个函数声明和开始结束都感觉费劲

      上图中有个下载按钮,可以下载下来在本地运行,是nodejs项目。有 agent和前端完整代码,这应该是个非常好的工具,有可以运行代码简单示例,学起来会更容易。按安装说明文档安装运行即可(模型使用公服接口)。

12.1 架构

每一种模式都遵循着同一套架构:后端的 createAgent 负责生成状态流,然后通过前端的 useStream 钩子传输给界面

在后端,createAgent 会生成一个编译好的 LangGraph 图谱,并暴露出一个流式 API。在前端,useStream 钩子负责连接到这个 API,并提供各种响应式状态——包括消息列表、工具调用、中断信号、历史记录等等——让你可以用任何前端框架来渲染这些内容

#agents
from langchain import create_agent
from langgraph.checkpoint.memory import MemorySaver

#这个代码肯定是不完整的,肯定是不能独立运行成服务的。
agent = create_agent(
    model="openai:gpt-5.4",
    tools=[get_weather, search_web],
    checkpointer=MemorySaver(),
)

#types.ts
export interface GraphState {
  messages: BaseMessage[];
}

#stream 前端只要拿到这个流就行了。
import { useStream } from "@langchain/react";
import type { GraphState } from "./types";

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

  return (
    <div>
      {stream.messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
    </div>
  );
}

useStream 现已支持 React、Vue、Svelte 和 Angular

import { useStream } from "@langchain/react";   // React
import { useStream } from "@langchain/vue";      // Vue
import { useStream } from "@langchain/svelte";   // Svelte
import { useStream } from "@langchain/angular";  // Angular

12.2 模式

12.2.1 markdown消息

大语言模型天生就能生成格式规范的 Markdown 文本,涵盖标题、列表、代码块、表格以及行内格式。如果把这些内容当作纯文本展示,就白白浪费了模型所提供的结构信息。这个模式将向你展示,如何在所有主流前端框架中,实时解析并渲染从智能体流式传输过来的 Markdown 内容

官网有一个可互动示例

12.2.1.1 渲染流水线
  1. 接收 :
    useStream 会把流式传输过来的文字一点点积攒起来,拼成每条 AI 消息的 msg.text。随着新字符(Token)的到来,这个状态会自动更新(这就是“响应式”)。

  2. 解析 :
    Markdown 解析器会把原始文本转换成 HTML(或者 React 的元素树)。虽然每收到一点更新都要跑一次这个过程,但对于聊天这种长度的内容来说,速度非常快(一条 5KB 的消息通常不到 5 毫秒)。

  3. 渲染 :
    解析好的内容会被真正画到屏幕上(DOM)。

    • React:利用虚拟 DOM 对比技术,只更新变化的部分。
    • Vue 和 Svelte:通常使用 v-html 或 {@html} 指令,直接插入经过安全过滤的 HTML 代码
12.2.1.2 设置上游流

       Markdown 模式使用的是一个简单的聊天智能体,不需要任何特殊配置。你只需要将 useStream 与你的智能体 URL 和助手 ID 连接起来即可。接着,定义一个与你的智能体状态结构相匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,这样你在访问状态值时就能获得类型安全保障。在下面的示例中,请将 typeof myAgent 替换为你自己的接口名称。

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}

12.2.1.3 选择 markdown库

框架 输出 理由
React react-markdown + remark-gfm React elements Component-based, virtual DOM diffing, no dangerouslySetInnerHTML
Vue marked + dompurify Sanitized HTML via v-html Lightweight, fast, GFM built-in
Svelte marked + dompurify Sanitized HTML via {@html} Same as Vue, consistent API
Angular marked + dompurify Sanitized HTML via [innerHTML] Same as Vue/Svelte

React 的 react-markdown 库会直接把 Markdown 转换成 React 元素,所以它不需要进行 HTML 安全清洗。这里面完全不涉及 dangerouslySetInnerHTML 这种危险操作。但对于 Vue、Svelte 和 Angular,在渲染之前,务必使用 dompurify 对解析后的 HTML 进行安全清洗

12.2.1.4 构建markdown组件

12.2.1.5 过滤输出

当你把解析后的 Markdown 作为原始 HTML 进行渲染时(比如使用 v-html{@html} 或 [innerHTML]),你必须对输出内容进行安全清洗,以防止跨站脚本攻击。大语言模型的回复可能包含任意文本,其中可能夹杂着某些标记,而这些标记会被 Markdown 解析器转换成可执行的 HTML 代码。请使用 dompurify 来剔除那些危险的元素

import DOMPurify from "dompurify";

const safeHtml = DOMPurify.sanitize(rawHtml);

DOMPurify 会剔除 <script> 标签、onclick 属性、javascript: 链接以及其他各类跨站脚本攻击向量,同时完整保留标题、列表、代码块、表格和链接等安全的 Markdown 输出内容

React 的 react-markdown 不需要 dompurify,因为它直接生成 React 元素,不涉及原始 HTML 注入

12.2.1.6 流式处理时注意事项

useStream 会随着每个 token 的到来响应式地更新 msg.text。Markdown 组件会在每次更新时重新进行解析。对于典型的聊天消息来说,这种方式的性能是完全足够的:

  • marked 的解析速度约为 1 MB/s。一条 5 KB 的消息解析耗时小于 5 毫秒。
  • react-markdown + remark 的处理管道对于聊天长度的内容来说,速度也类似。
  • 浏览器的布局引擎能够高效地处理 DOM 更新。

对于非常长的回复(> 50 KB),可以考虑以下优化方案:

  • 节流渲染:使用 requestAnimationFrame 将更新频率限制在每秒 60 帧(60fps),而不是每个 token 到来时都触发重渲染。
  • 增量解析:仅解析新内容并将其追加到已渲染的缓冲区中(这属于进阶做法,通常聊天界面不需要这样做)

对于大多数聊天应用来说,每次收到 token 就重新解析完整消息这种简单的方法已经足够了。只有当你发现长消息导致滚动卡顿或掉帧时,才需要进行优化

12.2.1.7 markdown样式

.markdown-content 类应用样式,以控制渲染后 Markdown 的外观。以下是一些基础(必备)样式

.markdown-content p {
  margin: 0.4em 0;
}

.markdown-content ul,
.markdown-content ol {
  margin: 0.4em 0;
  padding-left: 1.4em;
}

.markdown-content pre {
  overflow-x: auto;
  border-radius: 0.375rem;
  background: rgba(0, 0, 0, 0.05);
  padding: 0.5rem;
  font-size: 0.75rem;
}

.markdown-content code {
  border-radius: 0.25rem;
  background: rgba(0, 0, 0, 0.08);
  padding: 0.125rem 0.25rem;
  font-size: 0.75rem;
}

.markdown-content blockquote {
  margin: 0.4em 0;
  padding-left: 0.75em;
  border-left: 3px solid currentColor;
  opacity: 0.8;
}

.markdown-content table {
  border-collapse: collapse;
  margin: 0.4em 0;
}

.markdown-content th,
.markdown-content td {
  border: 1px solid #e5e7eb;
  padding: 0.25em 0.5em;
}

保持聊天气泡内的 Markdown 样式紧凑。由于聊天消息的篇幅通常比博客文章短,所以相比常规的正文样式,你应该使用更紧凑的边距和更小的字号

12.2.1.7 最佳实践

  • 始终进行清洗:在使用 v-html、{@html} 或 innerHTML 时,务必使用 DOMPurify 对解析后的输出进行处理。切勿信任由 LLM 输出的 Markdown 解析器生成的原始 HTML。
  • 启用 GFM:GitHub Flavored Markdown 增加了表格、删除线、任务列表和自动链接功能。LLM 通常会使用这些特性。
  • 处理空内容:在解析前检查是否为空字符串,以避免渲染出空的容器。
  • 启用换行转换:设置 breaks: true,将 LLM 输出中的单个换行符转换为 <br> 标签,而不是将其忽略。LLM 经常使用单个换行符来进行视觉上的分隔。
  • 针对聊天场景优化样式:使用适合聊天气泡的紧凑边距和字号,而不是全宽的文章布局样式。
  • 使用富内容进行测试:使用标题、嵌套列表、包含长行的代码块、宽表格和引用块来验证渲染效果,以发现溢出或布局问题。

12.2.2 工具调用

Agent 可以调用各种外部工具,比如天气 API、计算器、网络搜索、数据库查询等等。这些工具返回的结果通常是原始的 JSON 数据。这个模式将教你如何为 Agent 的每一次工具调用渲染出结构化且类型安全的 UI 卡片,并且包含了加载状态和错误处理

12.2.2.1 工具调用工作机制

当 LangGraph agent 判定需要获取外部数据时,它会在 AI 消息中发出一个或多个工具调用请求。每个工具调用都包含:

  • name:被调用的工具名称(例如 "get_weather" 或 "calculator")
  • args:传递给该工具的结构化参数
  • id:用于将调用请求与其结果关联起来的唯一标识符

Agent 运行时会执行该工具,并将结果以 ToolMessage 的形式返回。而 useStream 钩子会将所有这些复杂的交互统一整合到一个 toolCalls 数组中,供你直接进行渲染

12.2.2.2 设置上游流

第一步是将 useStream 与你的 Agent 后端进行连接。这个钩子会返回响应式状态,其中包括一个 toolCalls 数组,该数组会随着 Agent 的流式输出实时更新。请定义一个与你 Agent 状态模式相匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以便安全地访问状态值。在下面的示例中,请将 typeof myAgent 替换为你自己的接口名称

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}
<script setup lang="ts">
import { useStream } from "@langchain/vue";

const AGENT_URL = "http://localhost:2024";

const stream = useStream<typeof myAgent>({
  apiUrl: AGENT_URL,
  assistantId: "tool_calling",
});
</script>

<template>
  <div>
    <Message
      v-for="msg in stream.messages.value"
      :key="msg.id"
      :message="msg"
      :tool-calls="stream.toolCalls.value"
    />
  </div>
</template>

myAgent 只要和接口接口定义一致,前端就能正确获取数据。

12.2.2.3 ToolCallWithResult

toolCalls 数组里的每一项都是一个 ToolCallWithResult 对象

interface ToolCallWithResult {
  call: {
    id: string;
    name: string;
    args: Record<string, unknown>;
  };
  result: ToolMessage | undefined;
  state: "pending" | "completed" | "error";
}
roperty Description
call.id Unique ID matching the AI message’s tool_calls entry
call.name The name of the tool (e.g. "get_weather")
call.args Structured arguments the agent passed to the tool
result The ToolMessage response, available once the tool finishes
state Lifecycle state: "pending" while running, "completed" on success, "error" on failure

12.2.2.4 过滤工具调用的每条消息

一条 AI 消息可能会触发多个工具调用,而你的聊天记录里也可能包含很多条 AI 消息。为了在每条消息下方正确渲染出对应的工具卡片,你需要通过匹配 ID 来进行筛选:将工具调用对象的 call.id 与当前 AI 消息里的 tool_calls 数组进行比对(看起很混乱

function Message({
  message,
  toolCalls,
}: {
  message: AIMessage;
  toolCalls: ToolCallWithResult[];
}) {
  const messageToolCalls = toolCalls.filter((tc) =>
    message.tool_calls?.find((t) => t.id === tc.call.id)
  );

  return (
    <div>
      <p>{message.content}</p>
      {messageToolCalls.map((tc) => (
        <ToolCard key={tc.call.id} toolCall={tc} />
      ))}
    </div>
  );
}
12.2.1.7 构建专有工具卡

别直接把冷冰冰的原始 JSON 数据甩在屏幕上,要为每一个工具专门设计独立的 UI 组件。利用 call.name 来判断该显示哪张卡片

12.2.1.8 类型安全工具参数

如果你的工具(Tools)已经定义好了严谨的结构(Schemas),那你就可以直接利用 ToolCallFromTool 这个工具类型,自动获取到参数(args)的完整类型定义

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const getWeather = tool(async ({ location }) => { /* ... */ }, {
  name: "get_weather",
  description: "Get the current weather for a location",
  #关键之处
  schema: z.object({
    location: z.string().describe("City name"),
  }),
});

# 复制了schema参数信息
type WeatherToolCall = ToolCallFromTool<typeof getWeather>;
// WeatherToolCall.call.args is now { location: string }

使用了 ToolCallFromTool 能让你拥有‘编译时’的安全保障。如果工具的结构(Schema)发生了变动,你的 UI 组件会立刻报错,提醒你类型不匹配

12.2.1.9 流式输出文本的同时实时渲染工具调用

工具调用通常是夹杂在流式文本中间过来的。useStream 这个钩子能确保工具列表和文本流保持同步。所以,只要 AI 发出了调用指令(哪怕工具还没跑完),界面上就会立刻出现一个‘加载中’的卡片。工具调用是‘原地’更新的。同一个 call.id 会从‘等待中’变成‘已完成’(或‘报错’),所以你的 UI 只需要针对同一个组件重新渲染,就能展示出新的状态。

12.2.1.10 多并发工具调用处理

智能体(Agents)可以同时调用好几个工具。这时候,toolCalls 数组里会同时存在好几个状态为 pending(等待中)的条目。因为每个工具都是独立完成的,所以你的 UI 得优雅地处理‘部分完成’的情况

function ToolCallList({ toolCalls }: { toolCalls: ToolCallWithResult[] }) {
  const pending = toolCalls.filter((tc) => tc.state === "pending");
  const completed = toolCalls.filter((tc) => tc.state === "completed");

  return (
    <div className="space-y-2">
      {completed.map((tc) => (
        <ToolCard key={tc.call.id} toolCall={tc} />
      ))}
      {pending.map((tc) => (
        <LoadingCard key={tc.call.id} name={tc.call.name} />
      ))}
    </div>
  );
}
12.2.1.11 最佳实践
  • 始终处理全部三种状态:等待中、已完成和错误。用户不应看到空白卡片。
  • 安全地解析结果。工具结果以字符串形式返回。将 JSON.parse() 包装在 try/catch 中,并在解析失败时显示回退内容。
  • 提供通用回退方案。并非每个工具都需要定制卡片。对于未知的工具名称,渲染可折叠的 JSON 视图。
  • 在加载期间显示工具名称和参数。用户希望知道代理正在做什么,即使在结果返回之前。
  • 保持卡片紧凑。工具卡片嵌入在聊天消息中。避免使用过大的小部件淹没对话

12.2.3 人机回环(HITL)

并不是每一个 AI 的操作都应该在无人监管下运行。当 AI 准备发送邮件、删除记录、执行金融交易,或者进行任何不可逆的操作时,你必须先让人类来审查并批准。‘人机回环’(HITL)模式能让你的 AI 在执行前暂停,把待执行的操作展示给用户看,并且只有在获得明确批准后,才能继续执行

12.2.3.1 中断工作机制

LangGraph 智能体支持中断,即智能体将控制权交还给客户端的明确暂停点。当智能体触发中断时:

  1. 智能体停止执行并发出中断负载
  2. useStream 钩子通过 stream.interrupt 暴露中断
  3. 你的 UI 渲染带有批准/拒绝/编辑选项的审查卡片
  4. 用户做出决定
  5. 你的代码调用带有恢复命令的 stream.submit()
  6. 智能体从中断处继续执行
12.2.3.2 为HITL配置 useStream

定义一个与你的智能体状态模式(schema)相匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以便安全地访问状态值。在下面的示例中,请用你的接口名称替换 typeof myAgent

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}

12.2.3.3 中断载荷

当智能体暂停时,stream.interrupt 会包含一个 HITLRequest 对象,其结构如下

interface HITLRequest {
  actionRequests: ActionRequest[];
  reviewConfigs: ReviewConfig[];
}

interface ActionRequest {
  action: string;
  args: Record<string, unknown>;
  description?: string;
}

interface ReviewConfig {
  allowedDecisions: ("approve" | "reject" | "edit")[];
}
属性 描述
actionRequests 智能体想要执行的待处理操作数组
actionRequests[].action 操作名称(例如 "send_email""delete_record"
actionRequests[].args 该操作的结构化参数(具体的数据内容)
actionRequests[].description 可选的人类可读描述,说明该操作是做什么的
reviewConfigs 每个操作的配置,用于控制允许哪些决策
reviewConfigs[].allowedDecisions 决定显示哪些按钮:"approve"(批准)、"reject"(拒绝)、"edit"(编辑)
12.2.3.4 决策类型

人机回环支持三种决策类型:批准、编辑、和拒绝

#批准
const response: HITLResponse = {
  decision: "approve",
};

stream.submit(null, { command: { resume: response } });
#拒绝
const response: HITLResponse = {
  decision: "reject",
  reason: "The email tone is too aggressive. Please revise.",
};

stream.submit(null, { command: { resume: response } });
#批准前编辑
const response: HITLResponse = {
  decision: "edit",
  args: {
    ...originalArgs,
    subject: "Updated subject line",
    body: "Revised email body with softer language.",
  },
};

stream.submit(null, { command: { resume: response } });

12.2.3.5 构建批准卡

下面是一个完整的审批卡片组件,它处理全部三种决策类型

function ApprovalCard({
  interrupt,
  onRespond,
}: {
  interrupt: { value: HITLRequest };
  onRespond: (response: HITLResponse) => void;
}) {
  const request = interrupt.value;
  const [editedArgs, setEditedArgs] = useState(
    request.actionRequests[0]?.args ?? {}
  );
  const [rejectReason, setRejectReason] = useState("");
  const [mode, setMode] = useState<"review" | "edit" | "reject">("review");

  const action = request.actionRequests[0];
  const config = request.reviewConfigs[0];

  if (!action || !config) return null;

  return (
    <div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
      <h3 className="font-semibold text-amber-800">Action Review Required</h3>
      <p className="mt-1 text-sm text-amber-700">
        {action.description ?? `The agent wants to perform: ${action.action}`}
      </p>

      <div className="mt-3 rounded bg-white p-3 font-mono text-sm">
        <pre>{JSON.stringify(action.args, null, 2)}</pre>
      </div>

      {mode === "review" && (
        <div className="mt-4 flex gap-2">
          {config.allowedDecisions.includes("approve") && (
            <button
              className="rounded bg-green-600 px-4 py-2 text-white"
              onClick={() => onRespond({ decision: "approve" })}
            >
              Approve
            </button>
          )}
          {config.allowedDecisions.includes("reject") && (
            <button
              className="rounded bg-red-600 px-4 py-2 text-white"
              onClick={() => setMode("reject")}
            >
              Reject
            </button>
          )}
          {config.allowedDecisions.includes("edit") && (
            <button
              className="rounded bg-blue-600 px-4 py-2 text-white"
              onClick={() => setMode("edit")}
            >
              Edit
            </button>
          )}
        </div>
      )}

      {mode === "reject" && (
        <div className="mt-4 space-y-2">
          <textarea
            className="w-full rounded border p-2"
            placeholder="Reason for rejection..."
            value={rejectReason}
            onChange={(e) => setRejectReason(e.target.value)}
          />
          <button
            className="rounded bg-red-600 px-4 py-2 text-white"
            onClick={() =>
              onRespond({ decision: "reject", reason: rejectReason })
            }
          >
            Confirm Rejection
          </button>
        </div>
      )}

      {mode === "edit" && (
        <div className="mt-4 space-y-2">
          <textarea
            className="w-full rounded border p-2 font-mono text-sm"
            value={JSON.stringify(editedArgs, null, 2)}
            onChange={(e) => {
              try {
                setEditedArgs(JSON.parse(e.target.value));
              } catch {
                // allow invalid JSON while editing
              }
            }}
          />
          <button
            className="rounded bg-blue-600 px-4 py-2 text-white"
            onClick={() =>
              onRespond({ decision: "edit", args: editedArgs })
            }
          >
            Submit Edits
          </button>
        </div>
      )}
    </div>
  );
}
12.2.3.6 恢复流程

在用户做出决策后,完整的循环如下所示:

  • 调用 stream.submit(null, { command: { resume: hitlResponse } })
  • useStream 钩子将恢复命令发送至 LangGraph 后端
  • 智能体接收 HITLResponse 并继续执行
  • 若获批准,工具将以原始(或经编辑的)参数运行
  • 若被拒绝,智能体将接收原因并决定其后续步骤
  • 随着智能体恢复流式传输,interrupt 属性重置为 null
12.2.3.7 公用案例
用户案例 动作 审查配置
邮件发送 send_email ["approve", "reject", "edit"]
写数据库 update_record ["approve", "reject"]
金融事务 transfer_funds ["approve", "reject"]
文件删除 delete_files ["approve", "reject"]
调用外部API服务 call_api ["approve", "reject", "edit"]
12.2.3.8 处理多个待处理操作

当智能体想要一次性执行多个操作时,一次中断可能会包含多个 actionRequests。请为每个请求渲染一张卡片,并在恢复执行前收集所有的决策

function MultiActionReview({
  interrupt,
  onRespond,
}: {
  interrupt: { value: HITLRequest };
  onRespond: (responses: HITLResponse[]) => void;
}) {
  const [decisions, setDecisions] = useState<Record<number, HITLResponse>>({});
  const request = interrupt.value;

  const allDecided =
    Object.keys(decisions).length === request.actionRequests.length;

  return (
    <div className="space-y-4">
      {request.actionRequests.map((action, i) => (
        <SingleActionCard
          key={i}
          action={action}
          config={request.reviewConfigs[i]}
          onDecide={(response) =>
            setDecisions((prev) => ({ ...prev, [i]: response }))
          }
        />
      ))}
      {allDecided && (
        <button
          className="rounded bg-green-600 px-4 py-2 text-white"
          onClick={() =>
            onRespond(
              request.actionRequests.map((_, i) => decisions[i])
            )
          }
        >
          Submit All Decisions
        </button>
      )}
    </div>
  );
}
12.2.3.9 最佳实践

在实现人机回环(HITL)工作流时,请牢记以下准则:

  • 展示清晰的上下文:始终显示智能体想要做什么以及原因。务必包含操作描述和完整的参数。
  • 让“批准”成为最便捷的路径:如果操作看起来无误,批准操作应只需一次点击。将多步骤流程留给“拒绝”或“编辑”操作。
  • 验证编辑后的参数:当用户编辑操作参数时,请在发送前验证 JSON 结构。对于格式错误的输入,显示行内错误提示。
  • 持久化中断状态:如果用户刷新页面,中断请求仍应可见。useStream 会通过线程的检查点来处理这一点。
  • 记录所有决策:为了审计追踪,请记录每一次“批准/拒绝/编辑”的决策,并附带时间戳和做出决策的用户信息。
  • 周全地设置超时:长时间运行的智能体不应在人工审核阶段无限期阻塞。请考虑显示智能体已等待了多长时间。

12.2.4 分支对话

       与 AI 智能体的对话很少是直线进行的。你可能想要重新措辞提问、重新生成不满意的回复,或者在不丢失之前工作成果的情况下探索完全不同的对话路径。‘分支对话’将版本控制的语义带入了你的聊天界面。每一次编辑都会创建一个新分支,你可以自由地在它们之间穿梭

       此功能需要 LangGraph Agent Server 的支持。请在本地使用 langgraph dev 命令运行你的智能体,或者将其部署到 LangSmith,以便使用此模式

12.2.4.1什么是分支会话

分支式聊天将对话视为一棵树,而非简单的列表。每条消息都是一个节点,编辑消息或重新生成回复会从该点创建一个分支。原始路径作为兄弟分支被保留下来,因此用户可以在不同的对话轨迹之间来回切换。

主要功能:

  • 编辑任意用户消息:重写之前的提示,并从该点重新运行代理。
  • 重新生成任意 AI 回复:要求代理为相同的输入生成不同的答案。
  • 导航分支:使用每条消息的分支控件在不同的对话版本之间切换。
12.2.4.2 通过历史设置useStream

       要启用分支功能,请传递 fetchStateHistory: true,以便 useStream 能够检索分支操作所需的检查点元数据。

       定义一个与你的代理状态模式相匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以获得对状态值的类型安全访问。在下面的示例中,请将 typeof myAgent 替换为你的接口名称

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}
<script setup lang="ts">
import { useStream } from "@langchain/vue";

const AGENT_URL = "http://localhost:2024";

const stream = useStream<typeof myAgent>({
  apiUrl: AGENT_URL,
  assistantId: "branching_chat",
  fetchStateHistory: true,
});

function handleEdit(msg: any, metadata: any, text: string) {
  const checkpoint = metadata.firstSeenState?.parent_checkpoint;
  if (!checkpoint) return;

  stream.submit(
    { messages: [{ ...msg, content: text }] },
    { checkpoint }
  );
}

function handleRegenerate(metadata: any) {
  const checkpoint = metadata.firstSeenState?.parent_checkpoint;
  if (!checkpoint) return;

  stream.submit(undefined, { checkpoint });
}
</script>

<template>
  <div>
    <Message
      v-for="msg in stream.messages.value"
      :key="msg.id"
      :message="msg"
      :metadata="stream.getMessagesMetadata(msg)"
      @edit="(text) => handleEdit(msg, stream.getMessagesMetadata(msg), text)"
      @regenerate="handleRegenerate(stream.getMessagesMetadata(msg))"
      @branch-switch="(id) => stream.setBranch(id)"
    />
  </div>
</template>
12.2.4.3 理解元信息

getMessagesMetadata(msg) 函数会返回每条消息的分支信息

interface MessageMetadata {
  branch: string;
  branchOptions: string[];
  firstSeenState: {
    parent_checkpoint: Checkpoint | null;
  };
}
属性 描述
branch 此特定消息版本的分支 ID
branchOptions 此消息位置可用的所有分支 ID 数组
firstSeenState.parent_checkpoint 此消息之前的检查点。将其用作编辑和重新生成的分叉点

当一条消息只有一个版本时,branchOptions 仅包含一个条目。在编辑或重新生成后,新的分支 ID 会被添加到 branchOptions 中,此时你就可以在这些分支之间进行导航切换了

12.2.4.4 编辑消息

要编辑用户消息并创建一个新分支:

  1. 从消息的元数据中获取 parent_checkpoint
  2. 使用该检查点提交编辑后的消息。
  3. 代理将从该点重新运行,从而创建一个新分支。
function handleEdit(
  stream: ReturnType<typeof useStream>,
  originalMsg: HumanMessage,
  metadata: MessageMetadata,
  newText: string
) {
  #这里解释一下,前端显示的消息列表的时候是来自后端的历史数据
  #所以显示的原始文本originalMsg,和对应的元数据是配对好的
  #加?是为了防止脏数据或者老格式数据
  const checkpoint = metadata.firstSeenState?.parent_checkpoint;
  if (!checkpoint) return;

  stream.submit(
    {
      messages: [{ ...originalMsg, content: newText }],
    },
    { checkpoint }
  );
}

编辑完成后:

  • 该消息的分支选项(branchOptions)中会增加一个新的条目;
  • 视图会自动切换到这个新分支;
  • 智能体(Agent)会从分叉点开始,基于更新后的消息重新运行;
  • 原始版本会被保留,并且可以通过分支切换器随时访问
12.2.4.5 重新生成响应

要在不更改输入的情况下重新生成 AI 回复:

  1. 从 AI 消息的元数据中获取父检查点(parent_checkpoint);
  2. 使用空输入(undefined input)和该父检查点进行提交;
  3. 智能体将生成全新的回复,并创建一个新分支
function handleRegenerate(
  stream: ReturnType<typeof useStream>,
  metadata: MessageMetadata
) {
  const checkpoint = metadata.firstSeenState?.parent_checkpoint;
  if (!checkpoint) return;

  stream.submit(undefined, { checkpoint });
}

每次重新生成都会在该位置为 AI 消息创建一个新分支。随后,用户可以通过分支切换器来对比不同的回复

12.2.4.6 创建分支切换器

当某条消息存在多个分支时,请显示一个紧凑的行内控件,其中包含当前的版本索引和用于导航的箭头

function BranchSwitcher({
  metadata,
  onSwitch,
}: {
  metadata: MessageMetadata;
  onSwitch: (branchId: string) => void;
}) {
  const { branch, branchOptions } = metadata;

  if (branchOptions.length <= 1) return null;

  const currentIndex = branchOptions.indexOf(branch);
  const hasPrev = currentIndex > 0;
  const hasNext = currentIndex < branchOptions.length - 1;

  return (
    <div className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
      <button
        disabled={!hasPrev}
        onClick={() => onSwitch(branchOptions[currentIndex - 1])}
        className="hover:text-gray-900 disabled:opacity-30"
        aria-label="Previous version"
      >
        ◀
      </button>
      <span className="min-w-[3ch] text-center">
        {currentIndex + 1}/{branchOptions.length}
      </span>
      <button
        disabled={!hasNext}
        onClick={() => onSwitch(branchOptions[currentIndex + 1])}
        className="hover:text-gray-900 disabled:opacity-30"
        aria-label="Next version"
      >
        ▶
      </button>
    </div>
  );
}

当用户点击分支箭头时,请调用 stream.setBranch(branchId),将对话视图切换到该分支。由于 fetchStateHistory: true 已经加载了所有分支的数据,因此该切换操作是即时完成的

切换分支不仅会影响目标消息,还会影响其后的所有消息。如果你切换到消息 3 的另一个版本,消息 4、5、6 等也会随之更新,以呈现该版本之后的对话内容

12.2.4.7 分支消息底层原理

LangGraph 会将每一次的状态转换都持久化保存为一个检查点。当你带着检查点参数提交请求时,后端不会在当前对话的末尾继续追加内容,而是会从该检查点位置进行分叉。最终形成的结果就是一个树状结构

User: "What is React?"
  └─ AI: "React is a JavaScript library..." (branch A)
  └─ AI: "React is a UI framework..." (branch B, regenerated)

User: "Tell me about hooks" (branch A)
  └─ AI: "Hooks are functions..."

User: "Tell me about JSX" (edited from branch A)
  └─ AI: "JSX is a syntax extension..."

每个分支都是贯穿对话树的一条独立路径。切换分支只会更新显示的消息,而不会删除任何数据。所有分支都会永久保存在检查点存储中

12.2.4.8 完整消息组件

这是一个集成了消息显示、编辑、重新生成以及分支切换功能的完整组件

function MessageWithBranching({
  message,
  metadata,
  stream,
}: {
  message: BaseMessage;
  metadata: MessageMetadata;
  stream: ReturnType<typeof useStream>;
}) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(message.content as string);

  const isHuman = message._getType() === "human";
  const isAI = message._getType() === "ai";
  const hasBranches = metadata.branchOptions.length > 1;

  return (
    <div className="group relative py-2">
      {isEditing ? (
        <EditForm
          text={editText}
          onChange={setEditText}
          onSave={() => {
            handleEdit(stream, message as HumanMessage, metadata, editText);
            setIsEditing(false);
          }}
          onCancel={() => {
            setEditText(message.content as string);
            setIsEditing(false);
          }}
        />
      ) : (
        <>
          <div className={isHuman ? "text-right" : "text-left"}>
            <div
              className={
                isHuman
                  ? "inline-block rounded-lg bg-blue-600 px-4 py-2 text-white"
                  : "inline-block rounded-lg bg-gray-100 px-4 py-2"
              }
            >
              {message.content as string}
            </div>
          </div>

          <div className="mt-1 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
            {isHuman && (
              <button
                className="text-xs text-gray-400 hover:text-gray-700"
                onClick={() => setIsEditing(true)}
              >
                Edit
              </button>
            )}

            {isAI && (
              <button
                className="text-xs text-gray-400 hover:text-gray-700"
                onClick={() =>
                  handleRegenerate(stream, metadata)
                }
              >
                Regenerate
              </button>
            )}

            {hasBranches && (
              <BranchSwitcher
                metadata={metadata}
                onSwitch={(id) => stream.setBranch(id)}
              />
            )}
          </div>
        </>
      )}
    </div>
  );
}

function EditForm({
  text,
  onChange,
  onSave,
  onCancel,
}: {
  text: string;
  onChange: (text: string) => void;
  onSave: () => void;
  onCancel: () => void;
}) {
  return (
    <div className="space-y-2">
      <textarea
        className="w-full rounded-lg border p-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
        value={text}
        onChange={(e) => onChange(e.target.value)}
        rows={3}
      />
      <div className="flex gap-2">
        <button
          className="rounded bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
          onClick={onSave}
        >
          Save & Rerun
        </button>
        <button
          className="rounded border px-4 py-1.5 text-sm hover:bg-gray-50"
          onClick={onCancel}
        >
          Cancel
        </button>
      </div>
    </div>
  );
}
12.2.4.9 结合乐观更新

结合分支功能与乐观更新,可以带来无缝的编辑体验。当用户保存编辑时,在服务器响应之前,先在界面上乐观地显示更新后的消息

function handleEditOptimistic(
  stream: ReturnType<typeof useStream>,
  originalMsg: HumanMessage,
  metadata: MessageMetadata,
  newText: string
) {
  const checkpoint = metadata.firstSeenState?.parent_checkpoint;
  if (!checkpoint) return;

  const updatedMsg = { ...originalMsg, content: newText };

  stream.submit(
    { messages: [updatedMsg] },
    {
      checkpoint,
      optimisticValues: (prev) => {
        if (!prev?.messages) return { messages: [updatedMsg] };

        const idx = prev.messages.findIndex((m) => m.id === originalMsg.id);
        if (idx === -1) return prev;

        return {
          ...prev,
          messages: [...prev.messages.slice(0, idx), updatedMsg],
        };
      },
    }
  );
}
12.2.4.10 添加导航快捷

针对高级用户,建议添加键盘快捷键以方便地切换分支

useEffect(() => {
  function handleKeyDown(e: KeyboardEvent) {
    if (!focusedMessageMetadata) return;

    const { branch, branchOptions } = focusedMessageMetadata;
    const idx = branchOptions.indexOf(branch);

    if (e.altKey && e.key === "ArrowLeft" && idx > 0) {
      stream.setBranch(branchOptions[idx - 1]);
    }
    if (e.altKey && e.key === "ArrowRight" && idx < branchOptions.length - 1) {
      stream.setBranch(branchOptions[idx + 1]);
    }
  }

  window.addEventListener("keydown", handleKeyDown);
  return () => window.removeEventListener("keydown", handleKeyDown);
}, [focusedMessageMetadata, stream]);
12.2.4.11 最佳实践
  • 必须开启历史获取:始终启用 fetchStateHistory: true,否则 getMessagesMetadata 无法返回分支信息。
  • 仅在必要时显示:只有当存在多个分支时才显示分支切换器;如果是 1/1 的单一状态,显示该指示器只会增加界面杂乱度而无实际价值。
  • 保持控件紧凑:分支切换器应内嵌在消息控制栏中,不应在界面上占据主导地位。
  • 保留滚动位置:切换分支时,尽量保持视口锚定在发生变化的那条消息上,避免页面跳动。
  • 悬停显示控件:分支导航箭头和编辑按钮应在鼠标悬停时出现,以保持界面整洁。
  • 标识当前分支:使用微妙的视觉提示(例如彩色圆点或分支标签),让用户清楚自己当前正在查看哪个分支。
  • 流式传输时禁用:在智能体 actively streaming( actively 生成)回复时,不要允许编辑或重新生成。在执行这些操作前,请检查 stream.isLoading 状态。
  • 取消时保留原文:如果用户开始编辑随后又取消,应将文本域重置为原始消息内容。
  • 深度分支树测试:频繁编辑和重新生成的用户可能会创建大量分支。请务必确保分支切换器和数据处理在深层分支树结构下仍能保持高性能

12.2.5 推理令牌

推理令牌(Reasoning tokens)揭示了像 OpenAI 的 o1/o3 和 Anthropic 的 Claude(具备扩展思维能力)等高级模型的内部思考过程。这些模型会生成结构化的内容块,将推理过程与最终答案分离开来,从而让你能够构建出展示模型如何得出结论的用户界面

12.2.5.1 什么是推理令牌

当具备推理能力的模型处理提示词时,它们会生成两种不同类型的内容:

  • 推理块:模型的内部思维链、问题拆解和逐步分析过程
  • 文本块:最终呈现给用户的、经过润色的回复

这些内容以文本块的形式在 AIMessage 中传递,可以通过 contentBlocks 属性进行访问

// Reasoning block
{ type: "reasoning", reasoning: "Let me think about this step by step..." }

// Text block
{ type: "text", text: "The answer is 42." }

并不是所有模型都会生成推理令牌。这种模式专门适用于支持扩展思维或思维链输出的模型。标准的聊天模型仅返回文本块

12.2.5.2 使用场景
  • 提升透明度:向用户展示模型的推理过程,以增强其对回答的信任度
  • 辅助调试:检查模型的思维过程,以识别其出错的具体环节
  • 教育工具:通过揭示 AI 处理问题的方式,教导学生如何解决问题
  • 决策支持:让领域专家验证建议背后的推理逻辑
  • 质量保证:在受监管的行业中,审查推理链以确保合规性
12.2.5.3 抽取推理文本

AIMessage 上的 contentBlocks 数组包含了所有按生成顺序排列的块。你可以通过类型进行过滤,从而将推理内容与文本内容区分开来

import { AIMessage } from "@langchain/core/messages";

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(""),
  };
}

单条消息可能包含多个推理块(例如,如果模型暂停推理,生成部分文本,然后继续推理)。将它们拼接起来,你就能获得完整的思维过程

12.2.5.4 从userSteam访问消息

定义一个 TypeScript 接口以匹配你的 Agent 状态结构,并将其作为类型参数传递给 useStream,从而实现对状态值的类型安全访问。在以下示例中,请将 typeof myAgent 替换为你的接口名称

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}

myagent是要满足接口契约

// 1. 引入基础消息类型
import type { BaseMessage } from "@langchain/core/messages";

// 2. 定义接口 (就是你之前看到的)
interface AgentState {
  messages: BaseMessage[];
}

// 3. 定义 myAgent (作为类型引用的锚点)
// 方式 A: 直接定义一个类型别名
type myAgent = AgentState; 

// 或者 方式 B: 定义一个空对象 (用于运行时类型推断)
export const myAgent = {} as AgentState;
12.2.5.5 构建思维气泡组件

ThinkingBubble 组件会将推理令牌(reasoning tokens)呈现在一个视觉上独特且可折叠的容器中。用户可以展开它以查看完整的思维过程,或者将其折叠以专注于最终答案

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>
  );
}

设置组件样式

ThinkingBubble 组件会将推理令牌(reasoning tokens)呈现在一个视觉上独特且可折叠的容器中。用户可以展开它以查看完整的思维过程,或者将其折叠以专注于最终答案

.thinking-bubble {
  background-color: #f8f5ff;
  border: 1px solid #e2d9f3;
  border-radius: 8px;
  padding: 12px;
  margin: 8px 0;
  font-size: 0.9em;
}

.thinking-header {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  background: none;
  border: none;
  width: 100%;
  text-align: left;
  color: #6b21a8;
  font-weight: 500;
}

.thinking-content {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid #e2d9f3;
  white-space: pre-wrap;
  color: #4a4a4a;
  line-height: 1.5;
}

.thinking-preview {
  margin-top: 4px;
  color: #9ca3af;
  font-style: italic;
  font-size: 0.85em;
}

.chevron {
  margin-left: auto;
  transition: transform 0.2s;
}

.chevron.expanded {
  transform: rotate(90deg);
}
12.2.5.6 推理过程流式指示器

当模型仍在生成推理令牌时,显示一个动画指示器,以传达思考正在进行中

.thinking-spinner {
  display: inline-block;
  width: 16px;
  height: 16px;
  border: 2px solid #e2d9f3;
  border-top-color: #6b21a8;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

在流式传输期间,默认保持 ThinkingBubble 折叠状态,仅显示旋转加载图标。在传输中途展开可能会导致新令牌到达时出现布局抖动。请让用户在推理阶段完成后再进行展开操作

12.2.5.7 渲染完整AI回复

将 ThinkingBubble 和一个标准的文本气泡合并到一个名为 AIResponse 的组件中

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>
  );
}
12.2.5.8 处理边缘情况

无逻辑推理

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

空推理块

有些模型会生成空的

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

推理块作为占位符。请将这些空块过滤掉

多推理文本循环

单条消息可能会在推理块和文本块之间交替。如果你需要保留这种交错顺序,请按顺序迭代 contentBlocks,而不是按类型进行分组

message.contentBlocks.forEach((block) => {
  if (block.type === "reasoning") {
    // Render ThinkingBubble
  } else if (block.type === "text") {
    // Render text paragraph
  }
});
12.2.5.9 最佳实践
  • 默认折叠:按需显示推理内容,而非默认展开
  • 显示字符数:让用户快速了解回复背后的思考量
  • 视觉区分:使用独特的颜色、边框或背景,确保推理内容不会与最终答案混淆
  • 动画过渡:平滑的展开/折叠动画能提升感知质量
  • 考虑无障碍性:在切换按钮上使用正确的 ARIA 属性(aria-expanded, aria-controls)
  • 预览截断:折叠时显示推理内容的简短预览,以便用户决定是否展开

12.2.6 结构化输出

结构化输出让智能体能够返回类型化、机器可读的数据,而不是纯文本。你得到的不再是单一的字符串,而是一个结构化对象,可以将其映射到任何用户界面:卡片、表格、图表、分步明细或特定领域的渲染器

12.2.6.1 什么是结构化输出

智能体不再返回自由格式的文本回复,而是通过调用工具来返回符合预定义模式的结构化对象。这为你提供了以下优势:

  • 类型安全的数据:将响应解析为已知的 TypeScript 类型。
  • 精确的渲染控制:为每个字段应用独立的 UI 样式。
  • 一致的格式:无论底层模型如何,每条响应都遵循相同的结构。

智能体通过调用“结构化输出”工具来实现这一点,该工具的参数包含响应数据。该工具本身不执行任何逻辑,纯粹是返回类型化数据的载体

12.2.6.2 应用场景
  • 产品对比:功能表、优缺点列表、评分
  • 数据分析:包含指标、明细和高亮信息的摘要
  • 分步指南:带有描述和代码片段的有序说明
  • 食谱:食材、步骤、时间和营养信息
  • 数学与科学:使用 LaTeX 渲染的公式、分步推导
  • 旅行规划:包含日期、地点和费用估算的行程
12.2.6.3 定义模式

为智能体返回的结构化数据定义一个 TypeScript 类型。该模式的形状决定了你如何渲染用户界。你的模式可以是任何形式。无论形状如何,该模式的工作方式都是一样的

12.2.6.4 从消息抽取结构化数据

结构化输出位于最后一条 AIMessage 的 tool_calls 数组中。通过找到该 AI 消息并访问第一个工具调用的参数来提取它

import { AIMessage } from "@langchain/core/messages";

function extractStructuredOutput<T>(messages: any[]): T | null {
  const aiMessages = messages.filter(AIMessage.isInstance);
  if (aiMessages.length === 0) return null;

  const lastAI = aiMessages[aiMessages.length - 1];
  const toolCall = lastAI.tool_calls?.[0];
  if (!toolCall) return null;

  return toolCall.args as T;
}

结构化输出工具的调用参数(args)可能直到智能体完成流式传输后才会完全填充。在流式传输过程中,参数可能仅部分填充或为未定义状态。在渲染之前,请务必检查其完整性

12.2.6.4 设置useStream

定义一个与你的智能体状态模式匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以实现对状态值的类型安全访问。在下面的示例中,请将 typeof myAgent 替换为你的接口名称

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}

12.2.6.5 渲染结构化数据

一旦你拥有了类型化对象,就构建一个组件,将每个字段映射到相应的 UI 元素。这是该模式的核心:将结构化数据转化为专门构建的界面

同样的方法适用于任何领域。将每个字段映射到最能代表它的 UI 元素

数据类型 渲染策略
纯文本 段落、标题、列表项
数字/指标 统计卡片、进度条、徽章
数组 列表、表格、网格
嵌套对象 嵌套卡片、手风琴折叠面板
Markdown Markdown 渲染器 (例如 react-markdown)
LaTeX/数学公式 KaTeX 或 MathJax
日期/时间 格式化时间戳、相对时间
网址 链接、嵌入预览
12.2.6.5 什么是结构化输出

在流式传输期间,工具调用的参数可能是不完整的 JSON。请在你的提取逻辑中对此进行防护

function extractStructuredOutput<T>(
  messages: any[],
  requiredFields: string[] = [],
): T | null {
  const aiMessages = messages.filter(AIMessage.isInstance);
  if (aiMessages.length === 0) return null;

  const lastAI = aiMessages[aiMessages.length - 1];
  const toolCall = lastAI.tool_calls?.[0];
  if (!toolCall?.args) return null;

  const args = toolCall.args as Record<string, unknown>;
  const hasRequired = requiredFields.every(
    (field) => args[field] !== undefined
  );

  if (requiredFields.length > 0 && !hasRequired) return null;
  return args as T;
}

使用 requiredFields 参数,在渲染之前等待关键字段被填充

const recipe = extractStructuredOutput<Recipe>(stream.messages, [
  "title",
  "ingredients",
  "steps",
]);
12.2.6.6 渲染流进度

与其等待完整的结构化输出,不如在字段到达时立即进行渲染。这样可以在智能体仍在生成内容时为用户提供即时反馈

function ProgressiveRecipeCard({ messages }: { messages: any[] }) {
  const partial = extractStructuredOutput<Partial<Recipe>>(messages);
  if (!partial) return null;

  return (
    <div className="recipe-card">
      {partial.title && <h3>{partial.title}</h3>}
      {partial.description && <p>{partial.description}</p>}

      {partial.ingredients && partial.ingredients.length > 0 && (
        <div className="recipe-ingredients">
          <h4>Ingredients</h4>
          <ul>
            {partial.ingredients.map((ing, i) => (
              <li key={i}>
                {ing.amount} {ing.unit} {ing.name}
              </li>
            ))}
          </ul>
        </div>
      )}

      {partial.steps && partial.steps.length > 0 && (
        <div className="recipe-steps">
          <h4>Instructions</h4>
          {partial.steps.map((step, i) => (
            <div key={i} className="step">
              <div className="step-number">Step {i + 1}</div>
              <p>{step.instruction}</p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

渐进式渲染在模式具有自然的自上而下顺序(例如:标题,然后是描述,接着是详情)时效果很好。智能体通常按照模式顺序生成字段,因此用户界面会自然地逐步填充内容。

12.2.6.6 重置和冲提交

为了让用户在查看结果后能够提交新的查询,请添加一个用于开启新会话

{recipe && (
  <button onClick={() => stream.switchThread(null)}>
    Start over
  </button>
)}
12.2.6.7 最佳实践
  • 渲染前校验:由于流式传输可能会传递不完整的数据,因此在渲染前务必检查必填字段是否存在。
  • 使用通用提取函数:通过类型和必填字段参数化你的提取逻辑,使其能够适用于不同的模式。
  • 渐进式渲染:在字段到达时即时显示,而不是等待完整对象,以便为用户提供即时反馈。
  • 提供回退表示:如果某个字段支持富文本渲染(如 LaTeX、Markdown、图表),请在模式中包含纯文本等效项作为回退方案。
  • 尽量保持模式扁平化:深度嵌套的模式更难进行渐进式渲染,并且在部分流式传输过程中更容易出错。
  • 使 UI 与数据匹配:选择最能代表每个字段类型的渲染策略(例如:数组用表格,嵌套对象用卡片,状态字段用徽章)。

 12.2.7 消息队列

消息队列允许用户快速连续发送多条消息,而无需等待智能体完成当前消息的处理。每条消息都会在服务器端被加入队列并按顺序处理,从而让你完全掌控待处理队列的可见性和控制权。看下面这个演示效果就清楚了:

12.2.7.1 什么是消息队列

在典型的聊天界面中,用户必须等待智能体完成回复后才能发送下一条消息。这在以下几种场景中会造成阻碍:

  • 批量提问:用户希望一次性提出五个相关问题,而不是等待每一个回答。
  • 连续追问:在智能体仍在处理时,提交澄清说明或补充上下文。
  • 自动化测试序列:以编程方式发送一系列提示词以验证智能体的行为。
  • 数据录入工作流:依次输入结构化数据以供处理。

消息队列通过立即接收所有提交并按顺序处理来解决这个问题

12.2.7.2 工作原理

在底层,LangGraph 使用 multitaskStrategy: "enqueue" 来管理并发提交。当智能体已经在处理消息时,如果提交了新消息,它会被添加到服务器端的队列中。一旦当前运行完成,下一个排队的消息就会自动被提取处理。

useStream 钩子提供了一个 queue 属性,让你可以实时查看待处理的消息

队列属性

属性 类型 描述
queue.entries QueueEntry[] 包含所有待处理队列条目的数组。
queue.size number 当前队列中的条目数量。
queue.cancel(id) (id: string) => Promise<void> 根据 ID 取消特定的队列条目。
queue.clear() () => Promise<void> 取消所有已排队的条目。

队列元素

字段 类型 描述
id string 此队列条目的唯一标识符。
values object 提交的输入值(包括消息)。
options object 提交时传递的任何附加选项。
createdAt string 条目创建时的 ISO 时间戳。
12.2.7.3 设置useStream

定义一个与你智能体状态模式相匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以实现对状态值的类型安全访问。在下面的示例中,请将 typeof myAgent 替换为你的接口名称

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}

12.2.7.4 显示队列

构建一个 QueueList 组件,用于显示每条待处理的消息并附带一个取消按钮。这样用户就能清楚地看到哪些内容正在排队等待处理,并且可以移除那些不再需要的条目

function QueueList({ entries, queue }) {
  return (
    <div className="queue-panel">
      <div className="queue-header">
        <span>Queued messages ({entries.length})</span>
        <button onClick={() => queue.clear()}>Clear all</button>
      </div>
      <ul className="queue-entries">
        {entries.map((entry) => {
          const text = entry.values?.messages?.[0]?.content ?? "Unknown";
          return (
            <li key={entry.id} className="queue-entry">
              <span className="queue-text">{text}</span>
              <span className="queue-time">
                {new Date(entry.createdAt).toLocaleTimeString()}
              </span>
              <button
                className="queue-cancel"
                onClick={() => queue.cancel(entry.id)}
              >
                Cancel
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

显示每条排队消息的前几个字符作为预览,这样用户无需阅读全文即可快速识别并取消特定条目

12.2.7.5 取消队列消息

有两种方式取消队列消息:

  • 取消单个队列消息:通过 ID 从队列中移除特定消息。代理将跳过它并转向下一个条目。
  • 清空整个队列:一次性移除所有待处理消息。当用户切换上下文或想要重新开始时非常有用。

取消队列条目仅对尚未开始处理的消息有效。如果代理已经在处理某条消息,从队列中取消它将不起作用。请使用 stream.stop() 来中断当前运行

12.2.7.6 使用onCreate串联后续提交

nCreated 回调函数会在新建一个运行(run)时触发,这为你提供了一个钩子,以便以编程方式提交后续消息。这在构建多步骤工作流时非常有用,例如,下一个问题取决于前一个提交是否被接受

stream.submit(
  { messages: [{ type: "human", content: "What is quantum computing?" }] },
  {
    onCreated(run) {
      console.log("Run created:", run.run_id);
      // Chain a follow-up
      stream.submit({
        messages: [{ type: "human", content: "Give me a simple analogy." }],
      });
    },
  }
);

这种模式会自然添加任务到队列。第一条消息会立即开始处理,而后续的消息则会排在其后的队列中

12.2.7.7 启动新线程

当用户想要开始全新的对话时,请使用 switchThread(null) 来创建一个新的线程。这将清除当前的消息历史和队列

12.2.7.8 完整的例子

综合以上内容,这里是一个包含队列管理功能的完整聊天组件

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

  const [input, setInput] = useState("");

  const handleSubmit = () => {
    if (!input.trim()) return;
    stream.submit({
      messages: [{ type: "human", content: input.trim() }],
    });
    setInput("");
  };

  return (
    <div className="chat-container">
      <header>
        <h2>Queue Chat</h2>
        <button onClick={() => stream.switchThread(null)}>New thread</button>
      </header>

      <div className="messages">
        {stream.messages.map((msg, i) => (
          <MessageBubble key={i} message={msg} />
        ))}
        {stream.isLoading && <TypingIndicator />}
      </div>

      {stream.queue.size > 0 && (
        <div className="queue-panel">
          <strong>Queued ({stream.queue.size})</strong>
          <button onClick={() => stream.queue.clear()}>Clear all</button>
          {stream.queue.entries.map((entry) => (
            <div key={entry.id} className="queue-item">
              <span>{entry.values?.messages?.[0]?.content}</span>
              <button onClick={() => stream.queue.cancel(entry.id)}>×</button>
            </div>
          ))}
        </div>
      )}

      <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message (you can send multiple!)"
        />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}
12.2.7.9 最佳实践
  • 限制队列大小
    虽然客户端对队列大小没有硬性限制,但要注意过大的队列会降低用户体验。建议当队列超过合理阈值(例如 10 条)时,显示一个警告提示。

  • 显示队列位置
    给每个排队的项目编号,这样用户就能清楚地知道处理的先后顺序。

  • 保持输入焦点
    在提交消息后,保持输入框处于聚焦状态,这样用户可以直接接着输入下一条消息,无需再次点击。

  • 增加过渡动画
    当队列中的项目开始处理时,让它们从队列面板平滑地移动到消息列表中(而不是生硬地突然消失或出现)。

  • 优雅地处理错误
    如果队列中的某条消息失败了,要显示错误信息,但不要阻塞后续队列条目的处理(即:坏了一个,后面的还要继续跑)。

  • 防抖快速提交
    对于自动化或程序化的提交,在消息之间增加微小的延迟,以防止服务器过载

12.2.8 加入与重加入流

加入”和“重新加入”功能允许你断开与正在运行的代理流的连接,而不会停止代理,随后还可以稍后重新连接。当你断开连接时,代理会在服务器端继续执行,而你可以完全从上次离开的地方无缝接续上这个流

左边显示连接状态,右边会显示断开按钮或者重连按钮

12.2.8.1 为啥需要加入与重加入流

传统的流式 API 将客户端和服务器紧密耦合:一旦客户端断开连接,流就会丢失。而“加入和重新加入”模式打破了这种耦合,从而实现了几种重要的应用模式:

  • 网络中断:移动用户在基站或 Wi-Fi 网络之间切换时,可以无缝恢复连接。
  • 页面导航:用户离开聊天页面稍后再返回时,进度不会丢失。
  • 应用后台运行:被操作系统挂起的应用,在恢复到前台时可以重新加入流。
  • 长时间运行的任务:代理执行需要数分钟的操作(如研究、代码生成、数据分析)时,用户无需一直开着页面。
  • 多设备切换:在手机上开始对话,然后在桌面上重新加入继续
12.2.8.2 核心概念

加入/重新加入模式涉及三个关键机制

方法 / 选项 作用
stream.stop() 断开客户端与流的连接,但不会停止代理的运行。
stream.joinStream(runId) 通过运行 ID 重新连接到现有的流。
onDisconnect: "continue" 提交选项,用于告知服务器在客户端断开连接后继续运行。
streamResumable: true 提交选项,用于启用流的稍后重加入功能。

stream.stop() 与取消运行有着根本的不同。停止操作仅仅是断开客户端的连接,而代理会在服务器端继续处理。若要真正取消代理的执行,你应该改用中断或取消机制

12.2.8.3 设置useStream

关键的设置步骤是在 onCreated 回调中捕获 run_id,以便稍后可以重新加入。
请定义一个与你代理状态模式匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以实现对状态值的类型安全访问。在下面的示例中,请将 typeof myAgent 替换为你的接口名称

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}

12.2.8.4 提交添加重连选项

当你提交消息时,请传入 onDisconnect: "continue" 和 streamResumable: true,以启用加入/重新加入的流程

stream.submit(
  { messages: [{ type: "human", content: text }] },
  {
    onDisconnect: "continue",
    streamResumable: true,
  }
);
选项 默认值 描述
onDisconnect "cancel" 当客户端断开连接时会发生什么。设置为 "continue" 会让代理继续运行;设置为 "cancel" 则会停止它。
streamResumable false 当设置为 true 时,服务器会保留流的状态,以便客户端稍后可以重新加入。

务必同时使用这两个选项。如果只设置 onDisconnect: "continue" 而不设置 streamResumable: true,意味着代理虽然会继续运行,但你无法重新加入流来查看它的输出结果

12.2.8.5 断开流
stream.stop();

调用 stream.stop() 来断开客户端的连接。此时,代理会在服务器端继续处理

在调用 stop() 之后:

  • stream.isLoading 变为 false
  • 消息列表会保留断开连接之前收到的所有消息
  • 代理会在服务器上继续运行
  • 在重新加入之前,不会收到任何新消息
12.2.8.6 重连流

在重新加入之后:

  • stream.isLoading 再次变为 true
  • 断开连接期间生成的所有消息都会被传送过来
  • 新的流式消息会恢复实时传输
  • 如果代理已经完成了任务,你会立即收到最终状态
stream.joinStream(savedRunId);
12.2.8.7 创建连接状态指示器

一个视觉指示器能帮助用户了解他们当前是否正在接收来自代理的实时更新

function ConnectionStatus({ connected }: { connected: boolean }) {
  return (
    <div className="connection-status">
      <span
        className={`status-dot ${connected ? "connected" : "disconnected"}`}
      />
      <span className="status-text">
        {connected ? "Connected" : "Disconnected"}
      </span>
    </div>
  );
}

用绿色或红色的圆点来设计这个指示器

.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  display: inline-block;
  margin-right: 6px;
}

.status-dot.connected {
  background-color: #22c55e;
  box-shadow: 0 0 4px #22c55e;
}

.status-dot.disconnected {
  background-color: #ef4444;
  box-shadow: 0 0 4px #ef4444;
}
12.2.8.8 断开连接和重连控制

提供明确的“断开连接”和“重新加入”按钮,以便用户拥有完全的控制权

function ChatControls({ stream, savedRunId, isConnected }) {
  const [input, setInput] = useState("");

  const handleSend = () => {
    if (!input.trim()) return;
    stream.submit(
      { messages: [{ type: "human", content: input.trim() }] },
      { onDisconnect: "continue", streamResumable: true }
    );
    setInput("");
  };

  return (
    <div className="controls">
      <div className="input-row">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message..."
          onKeyDown={(e) => e.key === "Enter" && handleSend()}
        />
        <button onClick={handleSend}>Send</button>
      </div>

      <div className="stream-controls">
        {isConnected ? (
          <button onClick={() => stream.stop()} className="disconnect-btn">
            Disconnect
          </button>
        ) : (
          savedRunId && (
            <button
              onClick={() => stream.joinStream(savedRunId)}
              className="rejoin-btn"
            >
              Rejoin stream
            </button>
          )
        )}
      </div>
    </div>
  );
}
12.2.8.9 持久化runId

对于跨会话的重新加入(例如,用户关闭了浏览器稍后又回来),需要将运行 ID 持久化存储起来

const stream = useStream<typeof myAgent>({
  apiUrl: "http://localhost:2024",
  assistantId: "join_rejoin",
  onCreated(run) {
    localStorage.setItem("activeRunId", run.run_id);
  },
});

// On page load, check for an active run
const existingRunId = localStorage.getItem("activeRunId");
if (existingRunId) {
  stream.joinStream(existingRunId);
}

当一个任务运行结束时,应该清理掉那些已保存的运行 ID。你需要监听流的完成状态,并移除存储的 ID,以避免尝试重新加入已经完成的运行

12.2.8.10 错误处理

如果运行已过期、被删除,或者服务器已经重启,重新加入可能会失败。请优雅地处理这些情况

try {
  stream.joinStream(savedRunId);
} catch (error) {
  console.error("Failed to rejoin stream:", error);
  // Clear stale run ID and inform the user
  setSavedRunId(null);
  localStorage.removeItem("activeRunId");
}
12.2.8.11 完整例子
function JoinRejoinChat() {
  const [savedRunId, setSavedRunId] = useState<string | null>(null);
  const [input, setInput] = useState("");

  const stream = useStream<typeof myAgent>({
    apiUrl: "http://localhost:2024",
    assistantId: "join_rejoin",
    onCreated(run) {
      setSavedRunId(run.run_id);
    },
  });

  const isConnected = stream.isLoading;

  const handleSend = () => {
    if (!input.trim()) return;
    stream.submit(
      { messages: [{ type: "human", content: input.trim() }] },
      { onDisconnect: "continue", streamResumable: true }
    );
    setInput("");
  };

  return (
    <div className="chat-container">
      <header>
        <h2>Join & Rejoin Demo</h2>
        <ConnectionStatus connected={isConnected} />
      </header>

      <div className="messages">
        {stream.messages.map((msg, i) => (
          <MessageBubble key={i} message={msg} />
        ))}
      </div>

      <div className="controls">
        <form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Type a message..."
          />
          <button type="submit">Send</button>
        </form>

        <div className="stream-actions">
          {isConnected ? (
            <button onClick={() => stream.stop()}>
              Disconnect
            </button>
          ) : (
            savedRunId && (
              <button onClick={() => stream.joinStream(savedRunId)}>
                Rejoin stream
              </button>
            )
          )}
        </div>
      </div>
    </div>
  );
}
12.2.8.12 最佳实践
  • 始终保存运行 ID: 没有它,重新加入是不可能的。为了系统的韧性(抗风险能力),请同时使用组件状态和持久化存储。
  • 显示清晰的连接状态: 用户应该始终清楚自己是在接收实时更新,还是仅仅在查看一个静态快照。
  • 在可见性变化时自动重连: 利用页面可见性 API,当用户切回标签页时自动重新加入。
  • 设置合理的超时时间: 如果重连尝试耗时过长,应降级为直接获取线程历史记录。
  • 清理已完成的运行: 当代理任务结束时,移除持久化的运行 ID,以避免尝试重连过期的会话

12.2.9 时间旅行

LangGraph 代理的每一次状态变更都会生成一个检查点,也就是该时刻代理状态的完整快照。“时间旅行”功能让你能够查看任意一个检查点,审视代理当时持有的确切状态,并从那个节点恢复执行,以探索不同的路径。它既是调试器,也是撤销按钮,同时还是审计日志,可谓一举三得

右边就是检查点,点击检查点,会从检查点重新开始执行。

12.2.9.1 工作原理

LangGraph 会在每个节点执行后持久化代理状态。每一个持久化的状态都是一个 ThreadState 对象,它包含了以下内容:

  • checkpoint:标识此特定快照的元数据(ID、时间戳)。
  • values:此时完整的代理状态(消息、自定义键值)。
  • tasks:计划接下来要运行的图节点。
  • next:执行计划中即将运行的节点名称。

这就构建了一个线性的时间轴,记录了代理做出的每一个决策、调用的每一个工具以及生成的每一个响应。你的用户界面可以渲染这个时间轴,并允许用户跳转到任意节点

12.2.9.2 设置useStream

通过向 useStream 传递 fetchStateHistory: true 来启用检查点历史记录。这会指示该钩子加载当前线程的完整检查点时间轴。

定义一个与你的代理状态模式相匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以便对状态值的访问进行类型安全检查。在下面的示例中,请将 typeof myAgent 替换为你的接口名称

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}

12.2.9.3 线程状态对象

历史记录数组中的每个条目都是一个 ThreadState,代表时间轴中的一个检查点

interface ThreadState {
  checkpoint: {
    checkpoint_id: string;
    checkpoint_ns: string;
  };
  values: Record<string, unknown>;
  tasks: Array<{
    id: string;
    name: string;
    interrupts?: unknown[];
  }>;
  next: string[];
}
属性 描述
checkpoint 标识此快照。将其传递给 submit 即可从这里恢复执行。
values 此时完整的代理状态,包括消息和任何自定义状态键。
tasks 在此检查点运行的图节点,包括它们的名称和任何中断信息。
next 计划在此检查点之后执行的节点名称。

12.2.9.4 创建检查点时间线

时间轴侧边栏将每个检查点显示为可点击的条目。每个条目都会展示当时运行的节点以及那时存在的消息数量(可以看看前面的图)

function TimelineSidebar({
  history,
  onSelect,
}: {
  history: ThreadState[];
  onSelect: (cp: ThreadState) => void;
}) {
  return (
    <aside className="w-80 overflow-y-auto border-l bg-gray-50 p-4">
      <h2 className="mb-4 text-sm font-semibold uppercase text-gray-500">
        Checkpoint Timeline
      </h2>
      <div className="space-y-2">
        {history.map((cp, i) => {
          const taskName = cp.tasks?.[0]?.name ?? "unknown";
          const msgCount = (cp.values?.messages as unknown[])?.length ?? 0;

          return (
            <button
              key={cp.checkpoint.checkpoint_id}
              onClick={() => onSelect(cp)}
              className="w-full rounded-lg border bg-white p-3 text-left
                         hover:border-blue-400 hover:shadow-sm transition-all"
            >
              <div className="flex items-center justify-between">
                <span className="text-xs text-gray-400">#{i + 1}</span>
                <NodeBadge name={taskName} />
              </div>
              <p className="mt-1 text-sm font-medium">{taskName}</p>
              <p className="text-xs text-gray-500">
                {msgCount} message{msgCount !== 1 ? "s" : ""}
              </p>
            </button>
          );
        })}
      </div>
    </aside>
  );
}
12.2.9.5 检查检查点状态

点击检查点应该显示该时刻的完整状态。通过 JSON 查看器,开发者可以完全洞察代理当时的所知与所决

function CheckpointInspector({ checkpoint }: { checkpoint: ThreadState }) {
  const [expanded, setExpanded] = useState(false);

  return (
    <div className="rounded-lg border bg-white p-4">
      <div className="flex items-center justify-between">
        <h3 className="font-semibold">
          Checkpoint {checkpoint.checkpoint.checkpoint_id.slice(0, 8)}...
        </h3>
        <button
          onClick={() => setExpanded(!expanded)}
          className="text-sm text-blue-600 hover:underline"
        >
          {expanded ? "Collapse" : "Expand"} state
        </button>
      </div>

      <div className="mt-2 space-y-1 text-sm">
        <p>
          <strong>Node:</strong>{" "}
          {checkpoint.tasks?.[0]?.name ?? "—"}
        </p>
        <p>
          <strong>Next:</strong>{" "}
          {checkpoint.next?.join(", ") || "—"}
        </p>
        <p>
          <strong>Messages:</strong>{" "}
          {(checkpoint.values?.messages as unknown[])?.length ?? 0}
        </p>
      </div>

      {expanded && (
        <div className="mt-3 max-h-96 overflow-auto rounded bg-gray-900 p-3">
          <pre className="text-xs text-gray-200">
            {JSON.stringify(checkpoint.values, null, 2)}
          </pre>
        </div>
      )}
    </div>
  );
}

对于生产环境的用户界面,建议使用带有可折叠节点的专业 JSON 查看器组件,而不是原始的 JSON.stringify。像 react-json-view 或 react-json-tree 这样的库能为用户提供更佳的探索体验

12.2.9.6 从检查点重新开始

时间旅行功能的核心在于能够从任意先前的检查点恢复执行。当用户选中一个检查点时,请调用 submit 并传入 null 作为输入,同时传递该检查点的引用

stream.submit(null, { checkpoint: selectedCheckpoint.checkpoint });

这会指示 LangGraph 执行以下操作:

  • 回滚到所选检查点的状态
  • 从该点开始重新执行图谱
  • 将新的结果流式传输给客户端

选中检查点之后的现有消息会被新的执行路径所替换。这实际上是在对话时间轴中创建了一个分支

从检查点恢复并不会删除原始时间轴。之前的检查点依然保留在历史记录中。这意味着用户随时可以返回并尝试不同的路径,而不会丢失任何先前的工作

12.2.9.7 SplitView布局

时间旅行功能在分栏布局下效果最佳,左侧显示主聊天窗口,右侧显示时间轴

function TimeTravelLayout() {
  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId: "time_travel",
    fetchStateHistory: true,
  });

  const [selectedCheckpoint, setSelectedCheckpoint] =
    useState<ThreadState | null>(null);

  const history = stream.history ?? [];

  return (
    <div className="flex h-screen">
      {/* Main chat area */}
      <main className="flex-1 overflow-y-auto p-6">
        <div className="mx-auto max-w-2xl space-y-4">
          {stream.messages.map((msg) => (
            <Message key={msg.id} message={msg} />
          ))}
        </div>
        <ChatInput
          onSubmit={(text) =>
            stream.submit({ messages: [{ type: "human", content: text }] })
          }
          isLoading={stream.isLoading}
        />
      </main>

      {/* Timeline sidebar */}
      <aside className="w-96 overflow-y-auto border-l bg-gray-50">
        <TimelineSidebar
          history={history}
          selected={selectedCheckpoint}
          onSelect={setSelectedCheckpoint}
          onResume={(cp) =>
            stream.submit(null, { checkpoint: cp.checkpoint })
          }
        />
        {selectedCheckpoint && (
          <CheckpointInspector checkpoint={selectedCheckpoint} />
        )}
      </aside>
    </div>
  );
}
12.2.9.8 抽取检查点数据

将原始检查点数据转换为适合在时间轴上显示的条目

function formatCheckpoints(history: ThreadState[]) {
  return history.map((cp, index) => ({
    index,
    id: cp.checkpoint?.checkpoint_id,
    taskName: cp.tasks?.[0]?.name ?? "unknown",
    messageCount: (cp.values?.messages as unknown[])?.length ?? 0,
    hasInterrupts: cp.tasks?.some((t) => t.interrupts?.length) ?? false,
    nextNodes: cp.next ?? [],
  }));
}

这样一来,在渲染时间轴条目时就能轻松使用有意义的标签,而不是原始的 ID 了

12.2.9.9  使用场景

时间旅行功能在许多场景下都极具价值:

  • 调试代理行为:逐步检查代理的决策过程,以了解其为何选择特定路径。
  • 撤销操作:如果代理走错了方向,可以从较早的检查点恢复并重试。
  • 探索替代方案:从对话中途的检查点创建分支,观察不同的输入如何改变结果。
  • 审计:审查代理操作的完整历史,用于合规性检查、质量保证或事后分析。
  • 教学:逐步演示代理的执行过程,解释多步推理的工作原理。

当与“人机回环”模式结合使用时,时间旅行功能尤为强大。如果人工审核员在中断点否决了代理的操作,他们可以恢复到操作发生前的检查点,并提供纠正性的输入

12.2.9.10 处理中断时间线

包含中断(人机回环暂停)的检查点值得特别的视觉处理。它们代表了代理停止并等待人工输入的时刻

function TimelineEntry({
  checkpoint,
  index,
}: {
  checkpoint: ThreadState;
  index: number;
}) {
  const hasInterrupt = checkpoint.tasks?.some(
    (t) => t.interrupts && t.interrupts.length > 0
  );

  return (
    <div
      className={`rounded-lg border p-3 ${
        hasInterrupt
          ? "border-amber-300 bg-amber-50"
          : "border-gray-200 bg-white"
      }`}
    >
      <div className="flex items-center gap-2">
        <span className="text-xs text-gray-400">#{index + 1}</span>
        {hasInterrupt && (
          <span className="rounded bg-amber-200 px-1.5 py-0.5 text-xs font-medium text-amber-800">
            Interrupt
          </span>
        )}
      </div>
      <p className="mt-1 text-sm font-medium">
        {checkpoint.tasks?.[0]?.name ?? "—"}
      </p>
    </div>
  );
}
12.2.9.10 最佳实践
  • 按需加载历史记录:对于拥有数百个检查点的线程,建议采用分页加载或仅加载最近的 N 个条目,以保持用户界面的响应速度。
  • 显示有意义的标签:展示节点名称和消息数量,而不是原始的检查点 ID。用户需要的是上下文,而不是 UUID。
  • 恢复前确认:从旧检查点恢复会替换当前的执行路径。请显示确认对话框,以免用户意外丢失当前的对话状态。
  • 高亮当前检查点:在视觉上明确标识出哪个检查点对应于对话的当前状态。
  • 支持键盘导航:高级用户会希望通过方向键逐步检查各个检查点。为时间轴添加键盘事件处理程序,以提供流畅的调试体验。
  • 检查点间状态差异对比:对于高级用户,展示两个连续检查点之间的变化,可以揭示代理的状态在每一步是如何演变的

12.2.10 生成UI

生成式用户界面(Generative UI)允许 AI 根据自然语言提示生成完整的用户界面。AI 的输出不再是聊天框中的文本回复,而是直接生成表单、卡片、仪表盘等 UI 组件。开发者负责定义可用的组件(即“组件目录”),AI 则将它们组合成一个有效的 UI 树。

这种模式采用了 json-render 这一生成式 UI 框架,用于定义组件目录、通过 AI 生成规格说明,并在 React、Vue、Svelte 和 Angular 等框架中安全地进行渲染

这玩意每看出来怎么获取完整源码,全部AI生成,不经过人工处理,后期项目应该就是一团糟。

12.2.10.1工作原理
  1. 定义目录:声明 AI 可以使用的组件及其类型化的属性(props)。
  2. 提示 AI:用自然语言描述你想要的界面。
  3. AI 生成规格说明:生成一个描述组件树的 JSON 文档。
  4. 安全渲染json-render 的渲染器使用你的组件来渲染该规格说明。

组件目录起到了护栏的作用:AI 只能使用你定义的组件,且属性必须符合你的模式。因此,输出结果总是可预测且安全的。

12.2.10.2 定义组件目录

组件目录描述了 AI 被允许使用的每一个组件。每个组件都包含一个用于定义其属性(props)的 Zod 模式,以及一段描述,AI 会阅读这段描述来理解何时使用该组件

import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    Card: {
      description: "A card container with optional title and padding",
      props: z.object({
        title: z.string().optional(),
        padding: z.enum(["sm", "md", "lg"]).optional(),
      }),
    },
    TextInput: {
      description: "A text input field with optional label and placeholder",
      props: z.object({
        label: z.string().optional(),
        placeholder: z.string().optional(),
        type: z.enum(["text", "email", "password", "number", "textarea"]).optional(),
      }),
    },
    Button: {
      description: "A clickable button with label and style variants",
      props: z.object({
        label: z.string(),
        variant: z.enum(["primary", "secondary", "ghost", "link"]).optional(),
        fullWidth: z.boolean().optional(),
      }),
    },
  },
  actions: {},
});

保持组件目录的精简与聚焦。仅包含该用例所需的组件。相比大而全的“大杂烩”式目录,精简的目录反而能产生更好的效果

12.2.10.3 组件注册

注册表将目录中的每个组件映射到其实际的渲染实现。使用 defineRegistry 可以在目录属性(props)和组件函数之间获得类型安全的绑定

12.2.10.4 连接到agent

代理使用结构化输出来返回 json-render 规格说明。请先使用代理的助手 ID 设置 useStream,然后从 AI 消息的 tool_calls 中提取规格说明

12.2.10.5 流式渲染处理进度

在流式传输过程中,规格说明是逐步构建的。元素会逐个到达,且最初可能缺少类型或属性。请筛选出仅完整的元素,并将 loading={true} 传递给渲染器,这会指示它静默跳过尚未到达的子元素。这样,用户界面就会逐个组件地构建起来

/*
 * Filter the streamed spec to only include elements with valid type/props,
 * enabling progressive rendering as the AI response builds up. Passing
 * loading={true} to the Renderer tells it to skip missing children silently.
 */
const spec = (() => {
  if (!rawSpec?.root || !rawSpec?.elements) return null;
  const rootEl = rawSpec.elements[rawSpec.root];
  if (!rootEl?.type || rootEl?.props == null) return null;

  const safeElements = {};
  for (const [key, el] of Object.entries(rawSpec.elements)) {
    if (el?.type && el?.props != null) {
      safeElements[key] = el;
    }
  }
  return { root: rawSpec.root, elements: safeElements };
})();

return (
  <>
    {spec && (
      <JSONUIProvider registry={registry}>
        <Renderer spec={spec} registry={registry} loading={stream.isLoading} />
      </JSONUIProvider>
    )}
  </>
);

JSONUIProvider 是必需的,用于设置 json-render 的内部上下文提供者(包括状态、可见性、验证和操作)。Renderer 组件必须在其内部进行渲染

12.2.10.6 规格说明格式

AI 代理生成一个扁平的 JSON 规格说明,其中包含一个指向根元素的根键,以及一个包含所有组件的元素映射表。

{
  "root": "login-card",
  "elements": {
    "login-card": {
      "type": "Card",
      "props": { "title": "Login" },
      "children": ["login-stack"]
    },
    "login-stack": {
      "type": "Stack",
      "props": { "direction": "vertical", "gap": "md" },
      "children": ["email-input", "password-input", "submit-btn"]
    },
    "email-input": {
      "type": "TextInput",
      "props": { "label": "Email", "placeholder": "Enter your email", "type": "email" },
      "children": []
    },
    "password-input": {
      "type": "TextInput",
      "props": { "label": "Password", "placeholder": "Enter your password", "type": "password" },
      "children": []
    },
    "submit-btn": {
      "type": "Button",
      "props": { "label": "Sign In", "variant": "primary", "fullWidth": true },
      "children": []
    }
  }
}
12.2.10.7 最佳实践

这里有很重要的一点就是流式传输,有些数据可能式不完整的,后面的流数据可能会补充未完成的数据,这个可能式很多容易出问题的地方

  • 编写描述详尽的组件说明:AI 依赖这些说明来判断何时使用各个组件。清晰的描述有助于生成更优质的用户界面。
  • 渲染前先校验:由于流式传输会传递不完整的数据,因此在将元素传递给渲染器之前,务必检查其类型是否有效且属性不为空。
  • 为流式传输而设计:在流式传输期间传入 loading={true},以便渲染器能优雅地处理尚未到达的子元素。这样用户就能实时看到界面逐步构建,而无需等待完整响应。
  • 使用设计变量进行样式设计:利用 CSS 自定义属性,让渲染的组件能自动适配浅色和深色主题。
  • 用 JSONUIProvider 包裹:渲染器必须置于 JSONUIProvider 内部,才能访问 json-render 用于状态、可见性和操作的内部上下文

12.3 集成

useStream 是与 UI 无关的。它返回包含消息、工具调用、加载状态和线程历史的纯响应式状态,你可以将其连接到任何你选择的视觉层。这些页面展示了不同库如何与 LangChain 前端集成,每种库都代表了构建 AI 聊天和生成式 UI 的不同理念

库/框架 描述
CopilotKit 完整的 AI 聊天运行时,支持结构化的生成式 UI。在你的 LangGraph 部署中添加自定义的 CopilotKit 端点,然后在 React 中渲染动态组件树。
AI Elements 基于 shadcn/ui 的可组合 AI 聊天组件。直接引入 ConversationMessageToolReasoning 组件,并将它们连接到 stream.messages
assistant-ui 带有完整运行时层的无头(Headless)React 框架。通过 useExternalStoreRuntime 适配器,将 useStream 桥接到 AssistantRuntimeProvider
OpenUI 生成式 UI 库,允许代理以声明式组件 DSL 生成完整的交互式仪表盘。专为数据丰富、报告风格的 UI 而设计。

库选择

特性 CopilotKit AI Elements assistant-ui OpenUI
最佳适用场景 完整的聊天运行时加上结构化的生成式 UI 包含丰富消息类型的聊天 开箱即用、功能齐全的聊天 生成的仪表盘和报告
UI 风格 CopilotKit 聊天外壳 + 自定义消息渲染器 可组合的 shadcn/ui 组件 无头插槽 + 默认主题 带有声明式 DSL 的预制组件库
定制化 自定义后端端点、代理上下文和渲染器 直接编辑源文件 覆盖组件插槽 通过 CSS 自定义属性进行主题定制
流式传输体验 运行时管理的聊天流,包含结构化的辅助负载 组件级的渐进式渲染 内置的线程管理 提升(Hoisting)—— 外壳立即出现,数据随后填充
工具调用 通过 CopilotKit 运行时和自定义渲染器 Tool / ToolHeader / ToolOutput 通过消息插槽自定义 内嵌在生成的 UI 中
代理格式 结构化的助手响应及可选的 Markdown 任意 stream.messages 任意 stream.messages 代理输出 openui-lang 文本

这四种方案都能与 LangChain 代理良好协作,而后三者(AI Elements、assistant-ui 和 OpenUI)还能直接连接到 useStream。当你需要一个更强大的运行时层,以及一个能与 LangGraph 部署并行的专用端点时,CopilotKit 就显得尤为有用

       用这些组件,前期可能会比较爽,后买你可能会遇到不能满足要求的组件或者功能。可能会成为大杂烩。看这些库可能主要使用react,如果要使用也要注意选择框架。有人说语言不重要,选择不好,你就要自己干很多活,或者该干不好

12.3.1 CopilotKit

CopilotKit 提供了一个完整的 React 聊天运行时,当你希望代理返回结构化的 UI 负载而不仅仅是纯文本时,它与 LangGraph 的配合尤为出色。在这种模式下,你的 LangGraph 部署既服务于图 API,也服务于一个自定义的 CopilotKit 端点,而前端则将助手消息解析为动态的 React 组件。

当你有以下需求时,这种方法非常有用:

  • 需要一个现成的聊天运行时,而不是自己去连接 stream.messages
  • 需要一个自定义的服务器端点,以便在你部署的图旁边添加特定于提供商的行为
  • 需要从受限的组件注册表中渲染结构化的生成式 UI

12.3.1.1 工作原理

从宏观层面来看,CopilotKit 位于你的 React 应用和 LangGraph 部署之间。前端将对话状态发送到一个挂载在图 API 旁边的自定义 /api/copilotkit 路由,该路由将请求转发给 LangGraph,然后返回的响应中既包含助手消息,也包含你的组件注册表可以渲染的任何结构化 UI 负载。

具体步骤如下:

  1. 像往常一样使用 LangSmith 或 LangGraph 开发服务器来部署图。
  2. 通过一个 HTTP 应用扩展部署,该应用在图 API 旁边挂载一个 CopilotKit 路由。
  3. 在前端用 CopilotKit 进行包裹,并将其指向那个自定义的运行时 URL。
  4. 注册动态 UI 组件,并在渲染时将助手响应解析为这些组件

12.3.2 AI Elements

AI Elements 是一个基于 shadcn/ui 的可组合组件库,专为 AI 聊天界面量身打造。诸如 ConversationMessageToolReasoning 和 PromptInput 等组件,旨在直接嵌入任何 React 项目中,并以极少的胶水代码连接到 stream.messages

12.3.2.1 工作原理
  • 以源文件形式安装组件:AI Elements 通过 CLI 分发,该 CLI 会将组件直接添加到你的项目中(类似于 shadcn/ui 注册表的风格)。
  • 将消息映射到组件:遍历 stream.messages,将 HumanMessage 实例渲染为用户气泡,将 AIMessage 实例渲染为助手响应。
  • 构建更丰富的 UI:用 <Tool> 包裹工具调用,用 <Reasoning> 包裹推理过程,并将所有内容置于 <Conversation> 中以进行滚动管理

12.3.3 assistant-ui

assistant-ui 是一个用于 AI 聊天的无头(Headless)React UI 框架。它提供了一个完整的运行时层——包括线程管理、消息分支和附件处理——并通过 useExternalStoreRuntime 适配器连接到 useStream

12.3.3.1 工作原理
  • 使用 useStream 进行流式传输 — 连接到你的代理,获取响应式消息、加载状态以及提交/取消回调。
  • 使用 useExternalStoreRuntime 进行适配 — 通过将 BaseMessage[] 转换为 ThreadMessageLike[],将 stream.messages 桥接到 assistant-ui 的运行时格式。
  • 提供运行时 — 用 AssistantRuntimeProvider 包裹你的 UI,并渲染任意 assistant-ui 线程组件

12.3.4 OpenUI

OpenUI 是一个生成式 UI 库,它允许语言模型以一种名为 openui-lang 的声明式格式生成完整的交互式 UI。代理返回的不是聊天消息,而是一个包含卡片、图表、表格、标签页和表单的组件树,然后由 Renderer 将其转换为真实的 React UI。

这种集成非常适合数据丰富的输出,例如报告、仪表盘和数据浏览器,在这种场景下,模型既是数据分析师,也是 UI 设计师。

12.3.4.1 工作原理
  • 生成系统提示词:在启动时调用一次 openuiLibrary.prompt();它会生成一个完整的 openui-lang 参考文档,模型利用该文档来编写有效的组件树。
  • 在第一条消息中注入:当新对话开始时,将系统提示词作为开场系统消息发送。
  • 模型编写 openui-lang:模型会响应一段程序代码(例如 root = Stack([header, kpis, chart])),而不是散文文本。
  • 使用 Renderer 渲染:将文本传递给 OpenUI 的 Renderer 和组件库;它会解析并渲染该组件树。

总结

  到此完成了前端后交互的一些预览,如果要选择使用提供的一些前端库,可能使用react框架会更好一些。不过有能力的团队最好通过自主控制ai展示,尽量少依赖UI库,形成一套自己的UI组件,免得以后升级更新不好把控。高级使用请看下篇

下篇

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐