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

📅 2026/7/25 1:41:55 👁️ 阅读次数 📝 编程学习
深入学LangChain官方文档(二十二):Frontend 高级形态——Headless Tools、Time Travel 与 Generative UI

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

本篇对应的官方文档

  • Headless Tools:说明服务器工具 schema 怎样通过 interrupt 把真实执行交给客户端 implementation。
  • Time Travel:说明怎样读取ThreadStatecheckpoint 历史,并用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、权限说明、超时和降级策略。

fromtypingimportAnyfromlangchain.toolsimportToolRuntime,toolfromlanggraph.typesimportinterruptfrompydanticimportBaseModelclassReadDraftInput(BaseModel):inspection_id:str# 作用:把巡检草稿读取请求交给拥有浏览器存储权限的前端执行。@tool("read_local_draft",args_schema=ReadDraftInput)defread_local_draft(inspection_id:str,runtime:ToolRuntime)->Any:returninterrupt({"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 数据。

import{tool}from"langchain";import*aszfrom"zod";constreadLocalDraftDefinition=tool({name:"read_local_draft",description:"读取当前设备保存的巡检草稿",schema:z.object({inspection_id:z.string()}),});// 作用:从浏览器本地存储读取草稿,并返回可序列化的稳定结果。exportconstreadLocalDraft=readLocalDraftDefinition.implement(async({inspection_id})=>{constraw=localStorage.getItem(`inspection:${inspection_id}`);if(!raw){return{found:false,inspection_id};}return{found:true,inspection_id,draft:JSON.parse(raw)asunknown,};},);conststream=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 的持久化状态上。每次节点执行后,系统保存一个ThreadStatecheckpoint。它不只是当时的消息列表,还包含用于识别快照的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,再要求用户确认分支。

typeCheckpointEntry={checkpoint:{checkpoint_id:string};values?:Record<string,unknown>;tasks?:Array<{name?:string;interrupts?:unknown[]}>;next?:string[];};// 作用:读取当前 thread 的 checkpoint 历史,供时间线按需展示。asyncfunctionloadCheckpointHistory(stream:InspectionStream,threadId:string,):Promise<CheckpointEntry[]>{return(awaitstream.client.threads.getHistory(threadId))asCheckpointEntry[];}// 作用:从用户确认的 checkpoint 创建新执行分支,保留原历史供回看。functionresumeFromCheckpoint(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。

typeRawElement={type?:string;props?:Record<string,unknown>|null;children?:string[];};typeRawSpec={root?:string;elements?:Record<string,RawElement>;};// 作用:过滤流式 spec 中尚不完整的元素,避免 Renderer 过早消费半成品。functionselectRenderableSpec(raw:RawSpec|undefined){if(!raw?.root||!raw.elements)returnnull;constroot=raw.elements[raw.root];if(!root?.type||root.props==null)returnnull;constelements=Object.fromEntries(Object.entries(raw.elements).filter(([,element])=>Boolean(element?.type&&element.props!=null),),);return{root:raw.root,elements};}constaiMessage=stream.messages.find(AIMessage.isInstance);constrawSpec=aiMessage?.tool_calls?.[0]?.argsasRawSpec|undefined;constspec=selectRenderableSpec(rawSpec);returnspec?(<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 到底做了什么。

官方文档

  • Headless Tools
  • Time Travel
  • Generative UI