深入学LangChain官方文档(二十一):Agent 能力如何进入 UI——Tool Calling、Reasoning、Structured Output 与审批
深入学LangChain官方文档(二十一):Agent 能力如何进入 UI——Tool Calling、Reasoning、Structured Output 与审批
本篇对应的官方文档
- Tool calling:说明
useStream怎样把工具请求、执行结果和错误组装成可渲染的toolCalls。- Reasoning tokens:说明模型显式返回的 reasoning/text 内容块怎样通过
AIMessage.contentBlocks进入前端。- Structured output:说明结构化参数怎样从 AIMessage 的 tool call 中提取,并在流式期间处理部分数据。
- Human-in-the-Loop:说明
stream.interrupt、人工 decision 与 resume command 怎样组成审批闭环。本篇讲解范围
本篇讲清工具卡、可见 reasoning 内容、结构化业务结果和人工审批怎样进入同一个 Agent UI,并建立它们各自的状态与安全边界。浏览器侧工具、Time Travel 和 Generative UI 留给下一篇。
企业采购助手收到一个看似普通的请求:“为三名新员工购买笔记本,优先选择现有库存,预算控制在两万元以内。”如果前端只会显示用户消息和助手文本,页面最多呈现一句“正在查询”,随后给出一段采购建议。用户看不到系统查了哪些库存、预算工具是否成功、建议是否已经形成稳定业务对象,也不知道提交订单前是否还需要主管批准。
后端 Agent 实际经历了更丰富的过程:模型发出库存和预算工具调用,两个工具可能并行返回;模型可能产生供应商明确提供的 reasoning 类型内容块;采购建议以结构化对象出现;真正下单前,运行暂停并等待人类确认。它们都发生在同一个 thread 中,却不是同一种 UI 数据。
文本气泡只回答“说了什么”,工具卡回答“系统正在做什么”,结构化卡片回答“业务结果是什么”,审批组件回答“现在谁拥有继续执行权”。四种问题如果挤进同一个 Markdown 字符串,状态会被抹平,用户也无法采取正确行动。Agent Frontend 的核心工作不是把输出装饰得更漂亮,而是把运行时对象变成可理解、可操作、可恢复的产品状态。
能力投影还改变了故障定位方式:用户不再只看到“没有回复”,而能分辨是工具仍在运行、结构化结果尚未完整,还是执行权正在等待审批。后续每一类组件都要保留这种可解释状态。
一、UI 渲染的是运行协议,不是消息类型列表
第 20 篇已经建立了threadId、队列、断线恢复和 checkpoint 分支。那些能力保证会话能够持续,但“能接回同一条运行”还不等于“能正确解释运行”。当页面重新挂载后,它可能同时拿到历史消息、尚未结束的工具调用、一个部分生成的结构化对象,以及等待处理的 interrupt。前端需要知道每类数据的来源、生命周期和允许操作。
useStream在这里承担的是投影层。它把后端 Agent 的消息、工具调用、中断和运行状态整理为响应式对象,组件不用自己重放事件流才能猜出当前状态。但投影并不会替应用完成产品判断:同一个running工具是否允许取消、reasoning 内容是否适合当前用户查看、结构化字段是否足够渲染、审批按钮由谁操作,仍然属于应用合同。
采购场景把同一条运行拆成四个职责稳定的观察面。它们共享 thread 身份和事件顺序,但各自回答不同问题,因此必须保留独立状态后再组合到时间线:
stream.messages保存用户请求和助手回复的消息上下文。stream.toolCalls保存库存、预算、供应商查询的调用状态。AIMessage.contentBlocks保存模型实际返回的 text、reasoning 等类型化内容。stream.interrupt保存待审动作以及恢复运行所需的人类决策。
这些对象可以在视觉上相邻,却不能共享一个状态机。消息已经到达不代表工具已经完成;工具完成不代表结构化采购方案已经通过必要字段校验;页面显示审批卡也不代表订单已经提交。UI 只有保留这些差异,用户看到的“进度”才对应真实运行。
二、callId 维持一次工具调用的身份
工具卡最常见的错误,是把工具名称当作唯一身份。采购 Agent 可能连续两次调用query_inventory,分别查询开发机型和设计机型;也可能同时调用query_inventory与check_budget。如果组件只按name更新,后到的结果会覆盖先到的卡片,错误也可能显示在另一次调用下面。
LangChain Frontend 将工具调用组装为AssembledToolCall。其中name说明调用哪个工具,input/args保存结构化参数,output保存成功结果,error保存失败信息,status则在running、finished、error之间变化。真正串联一次调用的是callId:它对应 AIMessage 中 tool call 的id,也使后续ToolMessage能回到正确请求。
这条关联决定了工具卡应该“原位更新”,而不是每来一个事件就新增一张卡。模型刚发出调用时,组件显示工具名和必要参数;执行成功后,同一callId对应的卡片进入finished并展示经过收窄的结果;执行失败时,它进入error并呈现可定位信息。用户因此看到的是一次动作的生命周期,而不是三条互不相干的系统日志。
工具卡还要绑定产生它的消息。一条 AIMessage 可以包含多个 tool call,整个 thread 又有多条 AIMessage。渲染某条消息时,应使用消息里记录的 tool call ID 集合筛选stream.toolCalls,避免把后续轮次的库存结果挂到前一轮采购建议下面。
三、并行工具不是一个总 loading
采购 Agent 为了缩短等待时间,可以同时查询库存、预算和员工配置标准。三个调用进入running的时间接近,结束顺序却不确定:预算接口可能先返回,库存服务可能超时,配置标准查询仍然成功。若页面只有一个isLoading,任一工具结束都可能让整体加载状态提前消失;若必须等全部完成才展示,用户又会失去已经可用的结果。
并行调用需要把技术状态和业务可用性分开。单个工具是否结束、它属于哪次模型决策、它的结果是否足以支撑采购结论,是三个不同判断,因此状态结构至少分成三层:
- 调用层以
callId保存每个工具的running、finished或error。 - 消息层说明这些调用属于哪次模型决策,避免跨轮次串卡。
- 业务层判断哪些结果是继续生成采购方案的必要条件,哪些失败可以降级。
例如库存失败时,采购建议不能宣称“现货充足”,但预算结果仍可显示;员工配置标准失败时,可以允许用户重新加载该工具,而不是把整个 thread 清空。工具卡的局部错误和 Agent run 的整体错误必须分开,否则一次可恢复的接口故障会被放大成整段会话失败。
页面还需要一个通用 fallback。已知工具可以映射为库存表、预算进度条或供应商卡;未知工具至少要显示工具名、经过脱敏的参数、状态和可折叠结果。没有 fallback 时,新工具上线会表现成空白区域,用户只看到对话突然停住。
四、contentBlocks 保留内容类型,也保留生成顺序
工具状态解决“系统做了什么”,contentBlocks解决“模型返回的内容由什么组成”。支持相应能力的模型可以在AIMessage.contentBlocks中返回reasoning和text类型块。前端据此把模型主动提供的 reasoning 内容放进可折叠区域,把面向用户的正式回答放进普通消息区域。
这里必须守住一个事实边界:前端只能渲染实际收到的 reasoning 类型内容块。不是所有模型都返回这种块,不是每条 AIMessage 都包含 reasoning,也不能把普通文本、日志或应用自己的猜测包装成模型隐藏推理。供应商没有公开的内部计算过程不属于contentBlocks,应用也不应声称能够读取。
一个消息可能先产生 reasoning,再生成一段 text,随后继续 reasoning 并补充 text。若产品需要保留这种交错关系,就应该按contentBlocks原始顺序渲染;先把所有 reasoning 拼起来、再把所有 text 拼起来,会改变用户看到的生成时间线。若产品只需要简化视图,可以分别聚合,但必须明确这是 UI 的展示选择,不是消息原始结构。
流式期间还要区分“没有 reasoning”和“reasoning 尚未到达”。空 reasoning 块应过滤,只有 text 的消息直接显示普通气泡;最后一条 AIMessage 且stream.isLoading为真时,组件才把对应内容标为正在流式更新。否则历史消息重新挂载后可能一直显示“思考中”。
产品体验上,reasoning 区域通常应默认折叠,并与正式回答使用明显不同的视觉层级。更重要的是隐私和权限:模型返回的 reasoning 内容可能包含用户输入、检索片段或中间判断。应用不能因为字段类型叫reasoning就默认向所有角色展示;面向终端用户、审核员和内部调试人员的可见范围应分别配置,并在服务端先完成必要脱敏。
这条边界使 reasoning 组件成为一种条件能力:有内容、允许展示且通过脱敏时才出现;其余情况回退到普通 text 渲染。UI 不因缺少 reasoning 块而报错,也不以此推断模型没有进行内部计算。
五、Structured Output 要从“类型断言”走到“运行时完整”
采购建议适合表示为稳定业务对象,而不是一段需要再次解析的 Markdown。可以定义PurchaseProposal,包含候选设备、数量、总价、预算状态和风险提示。前端拿到对象后,能直接映射为表格、金额组件和审批摘要,也能在提交前做明确校验。
LangChain Frontend 的 structured output 示例从相关 AIMessage 的tool_calls[].args读取对象。这里容易产生误解:TypeScript 的as PurchaseProposal只在开发期帮助编辑器,它不会验证运行时数据。流式过程中args可能只有部分字段,甚至暂时为空;模型或后端返回的值也可能不满足业务约束。
结构化结果的视觉完整和业务可提交不是同一时刻。为了让用户尽早看到进展、又不让半成品进入订单系统,UI 需要分别设置渲染门槛与提交门槛:
- 渲染门槛:决定哪些字段出现后可以展示局部内容。例如候选设备名称到达后先显示骨架卡,但总价未到达时不能显示“预算通过”。
- 提交门槛:决定对象何时可以参与真实业务动作。数量、单价、总价、币种和预算状态必须完整,并通过运行时 schema 校验,才能进入审批。
渐进渲染并不等于渐进信任。页面可以边接收边展示,但不应把半成品对象写入订单系统。对于数组字段,还要处理元素尚未完整、索引变化或模型重新生成的情况;Reactkey不应只用数组位置,最好使用稳定业务标识。
结构化结果也不能和普通工具输出混为一谈。工具输出通常说明一次外部调用的结果,structured output 则表达 Agent 当前希望交付给产品层的业务对象。两者都可能位于 tool call 相关结构中,但职责不同:前者帮助解释执行过程,后者驱动最终界面和后续业务动作。
六、interrupt 把继续执行权交给人
采购方案形成后,submit_purchase_order会产生真实副作用。即使金额没有超过预算,也不应该因为前端已经渲染了结构化卡片就自动提交。Human-in-the-loop 使用 interrupt 在工具执行前暂停运行,并把待审动作带到stream.interrupt。
标准 interrupt payload 包含actionRequests与reviewConfigs。前者记录工具名、参数和可选说明,后者说明每个动作允许approve、reject、edit、respond中哪些决定。UI 不是自行发明按钮,而是根据后端策略渲染当前动作允许的选择。
主管点击批准后,前端通过stream.submit(null, { command: { resume: response } })把 decision 交回同一 thread。后端从 checkpoint 保存的暂停位置继续,而不是从采购请求开头重跑。页面刷新、审核入口切换或审核员从管理后台处理,都不应破坏这个持久暂停点。
审批卡至少要展示:
- 将执行的工具与业务动作;
- 经过脱敏但足以判断风险的参数;
- 允许的 decision;
- 当前 thread、待审动作或业务单据的稳定身份;
- 提交中、已提交、失败、已过期等状态。
缺少稳定身份时,用户双击批准或多个审核员同时操作可能产生重复 resume。前端按钮禁用只能减少误操作,真正的幂等还需要后端以 interrupt/checkpoint、业务请求 ID 或版本号拒绝重复决策。
七、四种 decision 不能只换按钮文案
approve、reject、edit、respond都会恢复运行,但它们交给 Agent 的语义不同。四种 decision 的状态边界要对比三个结果:工具是否执行、参数是否改变,以及 Agent 最终收到什么工具结果。
四种 decision 的差异最终落在“工具是否执行、参数是否改变、什么内容作为工具结果返回”三个维度。组件必须依据reviewConfigs.allowedDecisions展示可选动作,并让文案准确对应后端语义:
approve表示按原参数继续执行待审动作。reject表示工具不执行,并把拒绝原因交给 Agent 决定下一步。对于有副作用的动作,应明确要求 Agent 放弃、补充信息或改用安全替代方案。edit表示人类修改工具名或参数后再允许执行。采购场景可以把数量从三台改为两台,但修改后的参数仍需重新校验权限、预算和 schema。respond表示人类直接提供“询问用户”类工具所需的结果,工具本身不执行。它不等同于拒绝;把拒绝写成respond会让模型把消息当成成功工具结果。
多动作 interrupt 还要求 decisions 与actionRequests一一对应。库存锁定、创建订单和发送通知如果同时等待审查,不能只提交一个总的“同意”。页面可以把它们拆成多张卡逐项选择,但最终 resume payload 必须为每个待审动作提供明确 decision。否则后端无法判断遗漏项是默认批准、默认拒绝还是尚未处理。
edit的 UI 尤其不能直接把任意 JSON 暴露给用户。每种高风险工具都应有自己的表单、字段类型、校验和说明;修改金额、收件地址与邮件正文需要完全不同的控件。通用 JSON 编辑器可以作为内部 fallback,但不适合普通业务用户。
八、代码把三类交接落到对象
采购界面不需要把所有状态塞进一个巨型组件。更稳定的做法是先把useStream返回的对象收窄为几个明确视图模型,再让工具卡、采购方案卡和审批卡分别消费。下面的代码只聚焦三条交接:工具按callId归属消息,结构化对象通过必要字段门槛,interrupt decision 通过 resume command 回到后端。
import { AIMessage } from "langchain"; import type { AssembledToolCall, HITLResponse } from "langchain"; type PurchaseProposal = { proposalId: string; items: Array<{ sku: string; quantity: number; unitPrice: number }>; totalAmount: number; withinBudget: boolean; }; // 作用:选出指定 AIMessage 发出的工具调用;输入为消息和全部组装调用;输出为属于该消息的调用列表。 function selectMessageToolCalls( message: AIMessage, toolCalls: AssembledToolCall[], ) { const ids = new Set(message.tool_calls?.map((call) => call.id) ?? []); return toolCalls.filter((call) => ids.has(call.callId)); } // 作用:从最新 AIMessage 中读取完整采购方案;输入为消息列表;输出为通过必要字段检查的方案或 null。 function extractPurchaseProposal(messages: unknown[]): PurchaseProposal | null { const aiMessages = messages.filter(AIMessage.isInstance); const latest = aiMessages.at(-1); const args = latest?.tool_calls?.[0]?.args as | Partial<PurchaseProposal> | undefined; if ( !args?.proposalId || !Array.isArray(args.items) || typeof args.totalAmount !== "number" || typeof args.withinBudget !== "boolean" ) { return null; } return args as PurchaseProposal; }第一段函数没有按工具名称筛选,而是使用消息中的 tool call ID 与callId做连接,因而支持同名重复调用和多工具并行。第二段函数没有看到args就立即断言成功,而是等待采购方案的必要字段完整。真实项目还应使用 Zod 等运行时 schema 检查数组元素、金额范围和业务约束。
审批恢复保持独立,因为它改变的是运行控制权,而不是普通消息内容:
type StreamController = { submit( values: null, options: { command: { resume: HITLResponse } }, ): Promise<unknown>; }; // 作用:提交一次人工审批并防止界面重复触发;输入为 stream、决策和本地锁状态;输出为恢复请求 Promise。 async function resumeApproval( stream: StreamController, response: HITLResponse, submitting: boolean, ) { if (submitting) { throw new Error("审批正在提交,请勿重复操作"); } return stream.submit(null, { command: { resume: response } }); }本地submitting只负责即时交互保护,不能替代服务端幂等。网络超时后,前端必须重新读取 thread 状态,判断 decision 是否已经生效,再决定提示成功、重新提交或显示冲突,而不是盲目重试副作用动作。
九、生产界面要用状态矩阵验收
当工具、内容块、结构化结果和审批同时存在时,逐个检查组件并不足以证明界面可靠。更有效的方法是建立状态矩阵:横轴是运行对象,纵轴是等待、成功、失败、恢复和权限状态。每个格子都回答“用户看到什么、能做什么、下一步由谁负责”。
状态矩阵把容易遗漏的组合变成可验收场景。每一个运行对象都要经过等待、成功、失败、恢复与权限检查,尤其要覆盖以下会影响真实副作用或用户判断的边界:
- 并发与局部失败:一个工具失败时,其他成功结果仍能展示,最终业务结论不会使用缺失证据。
- 部分结构化数据:渐进卡片不能提前触发提交;必要字段和运行时 schema 未通过时保持只读。
- reasoning 缺失与权限:没有 reasoning 块时正常显示 text;存在时按用户角色、隐私和产品策略决定是否展示。
- 过期 interrupt:审批卡重新挂载后先核对当前 thread 状态,已处理或被替代的动作不能再次 resume。
- 重复操作:按钮防抖、本地提交锁和服务端幂等共同工作;任何一个都不能单独证明没有重复副作用。
- 错误可恢复性:工具重试、重新生成结构化结果和重新加载审批状态属于不同动作,不能统一成“刷新页面”。
- 可访问性:loading、error、expanded、approved 等状态不仅依赖颜色;折叠 reasoning 和审批按钮需要明确标签、键盘操作与 ARIA 状态。
- 敏感信息:工具参数、输出、reasoning 内容和审批 payload 在进入浏览器前完成最小化与脱敏,前端日志也不能记录完整隐私数据。
真正稳定的 Agent UI 会让用户理解当前状态,而不是让用户猜系统是否卡住。库存工具运行时,用户看见调用对象和等待状态;预算结果到达后,卡片原位更新;采购方案逐步形成时,未完成字段不冒充最终结论;下单 interrupt 出现后,继续执行权明确交给主管;decision 提交后,页面重新读取 thread 并展示真实结果。
总结:把 Agent 运行翻译成可操作界面
Agent Frontend 不是消息列表外加几个漂亮卡片,而是一层运行协议。toolCalls用callId保存工具动作的身份和生命周期;contentBlocks保存模型实际返回的内容类型与顺序;structured output 把稳定 schema 映射成业务组件,但必须面对流式部分数据;stream.interrupt则把高风险动作的继续执行权交给人,并通过 resume command 回到持久运行。
企业采购请求因此形成一条可以复述的链路:用户提交需求后,库存和预算工具分别进入独立状态机;模型显式返回的内容块按类型渲染;采购方案经过必要字段与运行时校验进入业务卡;下单前 interrupt 暂停 Agent;主管针对每个待审动作提交 decision;后端从 checkpoint 继续执行;前端最终以 thread 的真实状态确认订单结果。
这套设计留下一个自然问题:如果某些工具本来就应该在浏览器执行,用户希望回到旧 checkpoint 重放运行,或者 Agent 需要根据结果动态生成整块交互界面,固定的工具卡和审批卡还够不够?下一篇将进入 Headless Tools、Time Travel 与 Generative UI,继续处理前端拥有执行能力、历史状态可回放以及界面结构动态生成时的控制边界。