深入学LangChain官方文档(二十二):Frontend 高级形态——Headless Tools、Time Travel 与 Generative UI

深入学LangChain官方文档(二十二):Frontend 高级形态------Headless Tools、Time Travel 与 Generative UI

本篇对应的官方文档

  • Headless Tools:说明服务器工具 schema 怎样通过 interrupt 把真实执行交给客户端 implementation。
  • Time Travel:说明怎样读取 ThreadState checkpoint 历史,并用 forkFrom 创建新执行路径。
  • Generative UI:说明 json-render 怎样通过 catalog、spec、registry 与 Renderer 生成受控界面。

本篇讲解范围

本篇讲清浏览器侧工具、checkpoint 状态分支和受控 UI spec 的完整前端运行链,并建立权限、序列化、外部副作用和 action 授权边界。Tool Calling、Structured Output 与人工审批的基础投影沿用第 21 篇,不在本文重复展开;跨运行追踪、测试、评估与部署留给后续第三模块。

第 21 篇已经把 Tool Calling、Reasoning、Structured Output 和人工审批投影成了前端状态。但那仍然建立在一个常见假设上:Agent 在服务器执行,前端负责展示和提交决策。

真实应用很快会越过这条线。现场巡检助手需要读取浏览器定位和本地草稿,这些数据不应先传到服务器;巡检路径走错后,用户希望回到某个历史状态重新执行;不同现场还需要由 Agent 组合不同的表单、风险卡片和检查清单。普通聊天气泡无法承接这三类需求。

这时,前端不再只是 Agent 的显示器,而会以三种方式参与运行:

  • Headless Tools 让真实工具实现留在浏览器。
  • Time Travel 让用户观察 checkpoint,并从历史状态创建新路径。
  • Generative UI 让模型在组件白名单内生成 UI spec,再由应用组件渲染。

三者都增加了前端能力,也都扩大了权限、状态和副作用风险。本文用一个现场巡检助手贯穿整条链路:浏览器读取定位与本地草稿,Agent 生成巡检界面,用户发现路线错误后回到历史 checkpoint 重新执行。

一、前端开始参与 Agent 运行

这三种高级形态解决的不是同一个问题。Headless Tool 决定"动作在哪里执行",Time Travel 决定"从哪个状态继续",Generative UI 决定"结果用哪些受控组件表达"。把它们都理解成"更丰富的聊天组件",会丢失各自的运行边界。

三条路径的共同点是:服务器不再独占全部执行决策。客户端持有浏览器权限,checkpoint 历史提供状态入口,组件 catalog 限定可生成的 UI。前端因此必须像后端一样处理身份、校验、错误和审计,而不能只处理 CSS。

现场巡检助手可以把定位读取放在客户端,把每次节点运行后的状态留在 Agent Server,再让模型输出一个巡检面板 spec。但"客户端可执行""状态可分支""界面可生成"不等于"模型可以任意操作浏览器、回滚现实世界或输出任意代码"。

二、Headless Tool 分离定义与实现

普通服务器工具把 schema 和实现都放在后端。Headless Tool 保留 Agent 能理解的工具名、描述和参数 schema,却把真正依赖浏览器的实现留给前端。官方模式在 Agent 端注册普通工具,然后立即调用 interrupt(),让当前 run 停下来等待客户端结果。

前后端必须对齐的是工具协议,不是运行环境。观察重点是同一个工具名和参数结构如何跨越边界,而真实实现只存在于浏览器。

Agent 端的工具不是"假的工具"。它仍然向模型提供可调用的 schema,也仍然产生带 tool_call_id 的一次调用。不同之处在于,工具函数不直接访问定位或 IndexedDB,而是把工具名、参数和调用身份放进 interrupt payload。

前端再镜像相同工具名和参数,用 .implement(...) 绑定浏览器行为,并把实现数组交给 useStream({ tools: [...] })。当 hook 发现匹配调用时,它执行客户端实现,并用返回值恢复被中断的 run。

这里还有一个容易被忽略的部署问题:服务器 schema 与客户端 implementation 必须作为同一份协议演进。假如服务器已经把 inspection_id 改成 inspectionId,旧前端仍按原字段注册实现,模型虽然能发出工具调用,客户端却可能无法正确匹配或校验参数。生产系统应为协议带上版本,在应用启动时核对已注册工具集合,并在找不到 implementation 时让 interrupt 进入明确错误状态,不能让 run 永久停在"等待客户端"。

Headless Tool 也不适合被设计成一个通用的 execute_browser_code。这种工具把文件、定位、存储和页面动作混进同一入口,模型获得的参数空间过大,权限提示无法说明具体用途,审计记录也无法判断发生了什么。窄工具虽然数量更多,却能为每次动作定义独立 schema、权限说明、超时和降级策略。

python 复制代码
from typing import Any

from langchain.tools import ToolRuntime, tool
from langgraph.types import interrupt
from pydantic import BaseModel


class ReadDraftInput(BaseModel):
    inspection_id: str


# 作用:把巡检草稿读取请求交给拥有浏览器存储权限的前端执行。
@tool("read_local_draft", args_schema=ReadDraftInput)
def read_local_draft(inspection_id: str, runtime: ToolRuntime) -> Any:
    return interrupt(
        {
            "type": "tool",
            "tool_call": {
                "id": runtime.tool_call_id,
                "name": "read_local_draft",
                "args": {"inspection_id": inspection_id},
            },
        }
    )

这个函数返回的不是草稿,而是客户端完成动作后恢复 run 时提供的值。因此同一个 tool_call_id 仍然要贯穿请求、客户端执行、结果和后续消息,不能只按工具名称寻找"最近一次结果"。

三、客户端执行仍是一条工具链

浏览器收到 interrupt 后,不应绕过工具协议直接修改聊天消息。完整链路是"Agent 调用 → interrupt → 客户端匹配 implementation → 浏览器 API → JSON 结果 → run 恢复"。每一段都可能等待、拒绝或失败。

客户端实现可以访问 localStorage、IndexedDB、Geolocation、剪贴板、文件选择器或 Canvas,但返回值必须能够跨网络和状态系统传递。DOM 节点、打开的文件句柄、函数和带循环引用的对象都不是稳定的工具结果。应把它们转换为窄小、可序列化、可审计的 JSON 数据。

ts 复制代码
import { tool } from "langchain";
import * as z from "zod";

const readLocalDraftDefinition = tool({
  name: "read_local_draft",
  description: "读取当前设备保存的巡检草稿",
  schema: z.object({ inspection_id: z.string() }),
});

// 作用:从浏览器本地存储读取草稿,并返回可序列化的稳定结果。
export const readLocalDraft = readLocalDraftDefinition.implement(
  async ({ inspection_id }) => {
    const raw = localStorage.getItem(`inspection:${inspection_id}`);
    if (!raw) {
      return { found: false, inspection_id };
    }
    return {
      found: true,
      inspection_id,
      draft: JSON.parse(raw) as unknown,
    };
  },
);

const stream = useStream<InspectionState>({
  apiUrl: "http://localhost:2024",
  assistantId: "inspection_agent",
  tools: [readLocalDraft],
});

"数据留在设备端"也不是自动隐私保证。工具结果一旦恢复 run,结果可能进入服务端状态、模型上下文和追踪系统。应用必须决定哪些字段可返回、哪些字段应摘要、哪些敏感数据只能在本地完成判断后返回布尔值或脱敏结果。

浏览器权限和数据序列化是两道不同边界:权限决定能不能执行,序列化决定什么结果能进入 Agent。

定位、剪贴板、文件和摄像头都可能被用户拒绝。客户端工具应把拒绝、超时和不可用状态转换为明确错误结果,让 Agent 决定降级或询问用户,而不是无限等待。敏感动作还应复用 Human-in-the-loop:先展示用途和范围,再触发浏览器权限请求。

工具结果进入 run 之前还应带上可追踪的失败类型,例如 permission_deniednot_foundserialization_failed。这样 Agent 才能针对权限拒绝请求用户改用手工输入,针对本地数据缺失创建空白草稿,而不是把所有异常压成一条无法恢复的浏览器错误。客户端实现结束时,边界也随结果一起回到工具链。

四、checkpoint 不是消息快照

Time Travel 建立在 LangGraph Agent Server 的持久化状态上。每次节点执行后,系统保存一个 ThreadState checkpoint。它不只是当时的消息列表,还包含用于识别快照的 checkpoint、完整状态 values、待执行任务 tasks 和后续节点 next

要判断某个历史点是否适合恢复,界面至少要同时观察这四类对象,而不能只显示"第几条消息"。

values 回答 Agent 当时知道什么,tasksnext 回答它接下来准备做什么,checkpoint metadata 回答这个快照是谁、何时产生。对于包含 interrupt 的 checkpoint,tasks 中还可能带有暂停信息,界面需要明显标出"这里正在等待人",不能把它显示成普通完成节点。

现场巡检案例中,一个 checkpoint 可能处在"已经读取本地草稿、尚未提交巡检结论"的位置。另一个 checkpoint 可能已经调用外部工单系统。两者都能被选中,但恢复它们的副作用风险完全不同。

checkpoint 列表还不是一次性静态数据。新的节点运行完后,历史会继续增长;用户切换 thread 时,旧请求也可能晚到。如果前端没有把历史响应与当前 threadId 绑定,就可能把 A 现场的 checkpoint 显示在 B 现场的侧栏。历史加载需要取消过期请求或在回写前再次核对 thread 身份,并在 stream 停止 loading 后刷新当前 thread,而不是把所有结果追加到一个全局数组。

五、Time Travel 创建新执行分支

前端通过 stream.client.threads.getHistory(threadId) 显式获取 checkpoint 历史。用户选择某个 ThreadState 后,再把它的 checkpoint_id 放进 forkFrom 提交。这个动作不是删除后续消息,而是从历史状态重新执行并形成新路径。

这条状态链在代码中对应两个独立动作:loadCheckpointHistory 只读取历史,resumeFromCheckpoint 才改变当前执行路径。把读取与恢复拆开,界面才能先展示 checkpoint 的节点、消息数和 interrupt,再要求用户确认分支。

ts 复制代码
type CheckpointEntry = {
  checkpoint: { checkpoint_id: string };
  values?: Record<string, unknown>;
  tasks?: Array<{ name?: string; interrupts?: unknown[] }>;
  next?: string[];
};

// 作用:读取当前 thread 的 checkpoint 历史,供时间线按需展示。
async function loadCheckpointHistory(
  stream: InspectionStream,
  threadId: string,
): Promise<CheckpointEntry[]> {
  return (await stream.client.threads.getHistory(threadId)) as CheckpointEntry[];
}

// 作用:从用户确认的 checkpoint 创建新执行分支,保留原历史供回看。
function resumeFromCheckpoint(
  stream: InspectionStream,
  checkpointId: string,
): void {
  stream.submit(
    {},
    { forkFrom: { checkpointId } },
  );
}

原有 checkpoint 不会因为分支而消失。界面应该区分当前路径、历史路径和新分支,否则用户会误以为点击"恢复"覆盖了审计记录。

时间线最好显示节点名、消息数量、interrupt 标记和当前 checkpoint,而不是一排 UUID。历史很长时要分页或只加载最近 N 条;恢复前要确认,因为当前界面会切换到新执行路径。

更重要的边界是:checkpoint 恢复不等于现实世界回滚。已经发出的工单、邮件、支付或设备指令不会因状态分支自动撤销。从旧 checkpoint 重新执行还可能再次触发副作用,所以外部工具需要幂等键、执行记录或补偿动作。Time Travel 能回到 Agent 状态,不能替代数据库事务和业务补偿。

例如巡检助手已经用 work-order:inspection-42:risk-3 创建过工单,再从旧 checkpoint 重跑时,工具应通过这个业务幂等键返回原工单,而不是重复创建。若用户希望撤销现实动作,界面必须触发明确的"关闭工单"补偿流程,并把补偿结果写回新分支;状态回溯本身不能偷偷承担这个职责。

六、Generative UI 生成受控 spec

Generative UI 不是让模型输出 HTML、JavaScript 或任意组件名称。官方 json-render 模式先由开发者定义 catalog:哪些组件可用、每个组件的 props schema 是什么、模型在什么场景使用它。模型只在这个允许集合内生成 JSON spec。

catalog 的关键观察点是"允许哪些组件"和"每个 props 接受什么",它是生成空间的边界,不是一个组件展示页。

巡检场景可以只开放 InspectionCardRiskBadgeChecklistConfirmButton,而不开放任意链接、脚本容器或删除按钮。catalog 越聚焦,模型越容易稳定组合,也越容易做权限和可访问性检查。

catalog 只描述允许的类型;registry 才把类型名称映射到真实 React、Vue、Svelte 或 Angular 组件。模型生成的 spec 保存 rootelements,Renderer 根据 registry 实例化真实组件。读图重点是这三个对象的职责边界:spec 只引用名称和 props,registry 持有可信实现,Renderer 负责按树关系组装。

这条职责链把模型输出和真实组件实现隔开,并把每次转换的校验位置固定下来:

  1. catalog 限制候选组件与 props。
  2. structured output 产生 JSON spec。
  3. registry 绑定应用已经实现的组件。
  4. JSONUIProvider 提供 state、visibility、validation 和 actions 上下文。
  5. Renderer 只渲染通过检查的 spec。

"catalog 是 guardrail"只说明模型不能随意发明组件和 props,不代表 action 自动获得业务授权。一个 catalog 中即使存在 ConfirmButton,按钮对应的提交动作仍然要检查用户身份、当前状态、数据权限和幂等键。

组件描述同样属于运行合同。描述过宽时,模型可能在风险提示位置选择普通卡片;props 过于自由时,虽然类型合法,仍可能出现不可访问的颜色、超长标签或无效业务值。应用应让 catalog 保持场景化,并在 registry 组件内部继续执行设计 token、可访问性和领域校验。

spec 与消息也必须建立身份关系。一个 thread 中可能已经生成过多份巡检面板,不能简单拿到任意一条 AIMessage 的第一个 tool call 就覆盖当前界面。应用应根据消息顺序、目标 tool 名、call id 或业务版本选择当前 spec,并保留上一份已验证界面,直到新 root 和必要 element 完整到达。这样流式半成品只改变 loading 状态,不会让旧面板突然消失。

七、流式 spec 只能渐进信任

Generative UI 的 spec 通常来自相关 AIMessage.tool_calls[].args。流式生成时,root 可能先到,某些 element 只有 id 还没有 type,或者有 typeprops 尚未完整。把这个中间对象直接交给 Renderer,会产生闪烁、无效组件或错误 action。

渐进渲染的状态边界要对比三类元素:尚未出现、结构不完整、已具备 typeprops

图里的"不完整元素 → 完整元素 → 渐进 UI"在代码中落到 selectRenderableSpec:先确认 root,再逐项保留同时具有 type 与非空 props 的 element,最后把筛选结果交给带 loading 状态的 Renderer。

ts 复制代码
type RawElement = {
  type?: string;
  props?: Record<string, unknown> | null;
  children?: string[];
};

type RawSpec = {
  root?: string;
  elements?: Record<string, RawElement>;
};

// 作用:过滤流式 spec 中尚不完整的元素,避免 Renderer 过早消费半成品。
function selectRenderableSpec(raw: RawSpec | undefined) {
  if (!raw?.root || !raw.elements) return null;
  const root = raw.elements[raw.root];
  if (!root?.type || root.props == null) return null;

  const elements = Object.fromEntries(
    Object.entries(raw.elements).filter(
      ([, element]) => Boolean(element?.type && element.props != null),
    ),
  );
  return { root: raw.root, elements };
}

const aiMessage = stream.messages.find(AIMessage.isInstance);
const rawSpec = aiMessage?.tool_calls?.[0]?.args as RawSpec | undefined;
const spec = selectRenderableSpec(rawSpec);

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

loading={true} 让 Renderer 在流式期间跳过尚未到达的子节点,但应用仍要检查组件类型、props、action 和业务数据。结构完整只代表"可以解析",不代表"有权执行"或"业务上有效"。

八、三种能力要共用治理边界

Headless Tool、Time Travel 和 Generative UI 看起来分别属于工具、状态和界面,生产系统却必须用一张统一状态矩阵验收:

状态矩阵不能只列功能名称,而要把每类能力的身份、权限、恢复和副作用判断放在同一验收面上:

  • Headless Tool:当前工具由谁执行,浏览器权限是否已获得,结果是否可序列化,失败能否恢复。
  • Time Travel:当前 thread 和 checkpoint 是否匹配,选择点是否含 interrupt,恢复是否会重复外部副作用。
  • Generative UI:组件是否在 catalog 中,props 是否通过 schema,action 是否再次鉴权,流式半成品是否被过滤。
  • 共同治理:tool_call_idthreadIdcheckpointId 和 spec version 能否进入审计记录;错误能否定位到具体对象,而不是只记一条"页面失败"。

验收时还应主动制造失败:拒绝定位权限、让 IndexedDB 返回损坏 JSON、从含外部副作用的旧 checkpoint 恢复、让流式 spec 暂时缺少 props,并让 catalog 收到一个未注册组件名。每个失败都应停在所属边界内,给出可恢复状态,同时不能污染另一条 thread 或悄悄执行 action。

还可以用一条关联链检查审计是否闭合:tool_call_id 标识哪次客户端动作,threadId + checkpointId 标识动作发生在哪条状态路径,spec version 标识用户当时看到哪份界面,action 记录再写入执行人、权限结论和幂等键。任何一段缺失,事故复盘都会只剩"用户点击了按钮"或"Agent 重跑了一次",无法回答它依据什么状态、调用了哪个工具、是否已经执行过外部动作。

界面上的恢复也要按能力分别设计。客户端权限拒绝后,可以让用户改用手工输入;checkpoint 恢复前,要展示将被替换的当前路径和可能重复的外部动作;spec 校验失败时,应继续保留上一份稳定 UI,并提供文本降级结果。三类失败不能共用一个"重试"按钮,因为它们重试的对象、权限和副作用都不同。

现场巡检助手的一次完整运行可以这样复述:Agent 调用 read_local_draft 后以 interrupt 把动作交给浏览器;客户端在权限和数据驻留边界内执行,返回 JSON 结果恢复 run;Agent 根据结果生成受 catalog 限制的巡检 spec,前端过滤完整元素并渐进渲染;用户若发现路径错误,则从 checkpoint history 选择状态,通过 forkFrom 产生新分支,同时保留原历史并防止外部动作重复执行。

九、模块二到这里真正收口

从第 13 篇的事件流,到工具治理、RAG、多 Agent、会话持续性、能力投影,再到本文的客户端工具、checkpoint 分支和受控 UI,模块二完成了一次完整扩展:Agent 不只会回答,还能连接外部能力、移动控制权、持续运行,并把运行过程交给人操作。

前端高级形态的最简记法是:

Headless Tool 决定动作在哪里执行,Time Travel 决定从哪个状态继续,Generative UI 决定用哪些受控组件表达。

三者都没有取消工程边界。浏览器权限不是模型权限,checkpoint 分支不是现实回滚,组件白名单也不是业务授权。只有 schema、状态身份、权限、序列化、幂等和审计共同成立,前端才真正成为可靠的 Agent 运行参与者。

下一模块将从"系统能运行"转向"系统是否可观察、可测试、可评估、可部署"。首先要回答的就是:当这些复杂路径发生问题时,怎样通过 LangSmith Observability 与 Studio 看清 Agent 到底做了什么。

官方文档

相关推荐
大郭鹏宇9 小时前
基于 LangGraph 构建智能分诊系统(一):项目概述与环境搭建
大数据·人工智能·microsoft·langchain
玉鸯10 小时前
Agent Hook:在概率推理之上,为 Agent 叠加确定性控制
python·langchain·agent
chaors14 小时前
DeepRearchSystem 0x00:初识
langchain·llm·ai编程
<小智>17 小时前
鸿蒙多功能工具箱开发实战(二十)-性能优化与打包发布
ui·华为·harmonyos
BerryS3N18 小时前
深度解析 LangChain V1.3:架构演进、核心源码剖析与企业级应用落地指南
架构·langchain
xxwl58520 小时前
markdown基础语法
状态模式
三川69820 小时前
深入浅出SSD 01:SSD综述
状态模式
cyadyx20 小时前
MapStruct 的转换实践
状态模式·mapstruct·充血模式
大郭鹏宇21 小时前
基于 LangGraph 构建智能分诊系统(三):FastAPI 接口与 Gradio WebUI 实战
大数据·网络·数据库·人工智能·langchain