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

📅 2026/7/25 1:31:06
深入学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_schemaReadDraftInput)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}fromlangchain;import*aszfromzod;constreadLocalDraftDefinitiontool({name:read_local_draft,description:读取当前设备保存的巡检草稿,schema:z.object({inspection_id:z.string()}),});// 作用从浏览器本地存储读取草稿并返回可序列化的稳定结果。exportconstreadLocalDraftreadLocalDraftDefinition.implement(async({inspection_id}){constrawlocalStorage.getItem(inspection:${inspection_id});if(!raw){return{found:false,inspection_id};}return{found:true,inspection_id,draft:JSON.parse(raw)asunknown,};},);conststreamuseStreamInspectionState({apiUrl:http://localhost:2024,assistantId:inspection_agent,tools:[readLocalDraft],});“数据留在设备端”也不是自动隐私保证。工具结果一旦恢复 run结果可能进入服务端状态、模型上下文和追踪系统。应用必须决定哪些字段可返回、哪些字段应摘要、哪些敏感数据只能在本地完成判断后返回布尔值或脱敏结果。浏览器权限和数据序列化是两道不同边界权限决定能不能执行序列化决定什么结果能进入 Agent。定位、剪贴板、文件和摄像头都可能被用户拒绝。客户端工具应把拒绝、超时和不可用状态转换为明确错误结果让 Agent 决定降级或询问用户而不是无限等待。敏感动作还应复用 Human-in-the-loop先展示用途和范围再触发浏览器权限请求。工具结果进入 run 之前还应带上可追踪的失败类型例如permission_denied、not_found或serialization_failed。这样 Agent 才能针对权限拒绝请求用户改用手工输入针对本地数据缺失创建空白草稿而不是把所有异常压成一条无法恢复的浏览器错误。客户端实现结束时边界也随结果一起回到工具链。四、checkpoint 不是消息快照Time Travel 建立在 LangGraph Agent Server 的持久化状态上。每次节点执行后系统保存一个ThreadStatecheckpoint。它不只是当时的消息列表还包含用于识别快照的checkpoint、完整状态values、待执行任务tasks和后续节点next。要判断某个历史点是否适合恢复界面至少要同时观察这四类对象而不能只显示“第几条消息”。values回答 Agent 当时知道什么tasks和next回答它接下来准备做什么checkpoint metadata 回答这个快照是谁、何时产生。对于包含 interrupt 的 checkpointtasks中还可能带有暂停信息界面需要明显标出“这里正在等待人”不能把它显示成普通完成节点。现场巡检案例中一个 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?:Recordstring,unknown;tasks?:Array{name?:string;interrupts?:unknown[]};next?:string[];};// 作用读取当前 thread 的 checkpoint 历史供时间线按需展示。asyncfunctionloadCheckpointHistory(stream:InspectionStream,threadId:string,):PromiseCheckpointEntry[]{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 生成受控 specGenerative UI 不是让模型输出 HTML、JavaScript 或任意组件名称。官方json-render模式先由开发者定义 catalog哪些组件可用、每个组件的 props schema 是什么、模型在什么场景使用它。模型只在这个允许集合内生成 JSON spec。catalog 的关键观察点是“允许哪些组件”和“每个 props 接受什么”它是生成空间的边界不是一个组件展示页。巡检场景可以只开放InspectionCard、RiskBadge、Checklist和ConfirmButton而不开放任意链接、脚本容器或删除按钮。catalog 越聚焦模型越容易稳定组合也越容易做权限和可访问性检查。catalog 只描述允许的类型registry 才把类型名称映射到真实 React、Vue、Svelte 或 Angular 组件。模型生成的 spec 保存root和elementsRenderer 根据 registry 实例化真实组件。读图重点是这三个对象的职责边界spec 只引用名称和 propsregistry 持有可信实现Renderer 负责按树关系组装。这条职责链把模型输出和真实组件实现隔开并把每次转换的校验位置固定下来catalog 限制候选组件与 props。structured output 产生 JSON spec。registry 绑定应用已经实现的组件。JSONUIProvider提供 state、visibility、validation 和 actions 上下文。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或者有type但props尚未完整。把这个中间对象直接交给 Renderer会产生闪烁、无效组件或错误 action。渐进渲染的状态边界要对比三类元素尚未出现、结构不完整、已具备type与props。图里的“不完整元素 → 完整元素 → 渐进 UI”在代码中落到selectRenderableSpec先确认 root再逐项保留同时具有type与非空props的 element最后把筛选结果交给带loading状态的 Renderer。typeRawElement{type?:string;props?:Recordstring,unknown|null;children?:string[];};typeRawSpec{root?:string;elements?:Recordstring,RawElement;};// 作用过滤流式 spec 中尚不完整的元素避免 Renderer 过早消费半成品。functionselectRenderableSpec(raw:RawSpec|undefined){if(!raw?.root||!raw.elements)returnnull;constrootraw.elements[raw.root];if(!root?.type||root.propsnull)returnnull;constelementsObject.fromEntries(Object.entries(raw.elements).filter(([,element])Boolean(element?.typeelement.props!null),),);return{root:raw.root,elements};}constaiMessagestream.messages.find(AIMessage.isInstance);constrawSpecaiMessage?.tool_calls?.[0]?.argsasRawSpec|undefined;constspecselectRenderableSpec(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 是否通过 schemaaction 是否再次鉴权流式半成品是否被过滤。共同治理tool_call_id、threadId、checkpointId和 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 结果恢复 runAgent 根据结果生成受 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 ToolsTime TravelGenerative UI