Skip to content

多步 Agent 转圈 10 秒,答案砸脸上:AI 应用的等待体验设计 ​

前一篇《你的 AI 应用一发版,用户记忆全没了》讲的是 Agent 上线后“状态存哪”的事故。这一篇往前挪一步:就算状态存对了、流程跑通了,用户在等待的这 10 秒里,界面到底该给他看什么? 它和《前端也能搞懂流式输出》是互补关系:那篇教你把 SSE 接到手、把字打出来;这篇不碰“怎么接流”,只讨论“哪些东西值得被看见”。

🎯 本文目标

读完你应该能:把一个 Agent 界面的等待过程拆成两层——步骤进度和 token 答案——并说清各自该由什么事件驱动;给多步 Agent 设计一套“不空白、不伪造、能恢复”的等待与失败反馈;最重要的是建立一条产品直觉:用户不只怕等,更怕“不知道机器还在不在替我干活”。


写在前面:Agent 比普通问答更“难等” ​

普通问答慢一点,用户还能理解为“模型在生成”。但多步 Agent 不一样:它可能先查资料、再调工具、再反思、再重来。过程中模型常常沉默几秒,界面上只有一圈转圈的 loading。

这圈 loading 的问题在于:它把“Agent 正在努力”和“程序卡死了”画成了同一个样子。

我自己的体感是,单轮问答哪怕慢一点,只要打字机持续吐字,体验还能接受;但 Agent 闷头转上一阵、中间毫无信号,哪怕最后答案是对的,用户也已经想关页面了。标题里的“10 秒”是对长等待的场景化说法,不是用户研究得出的统一阈值;真实边界要看任务类型和自己的产品数据。Agent 的等待体验,不是“再加个动画”的打磨项,而是它能不能被信任的第一道门槛。

🧭 承上启下

为什么是“两条流”而不是“一个 loading”?先把 Agent 的执行过程拆开看,答案就清楚了。


🧭 第 1 章:Agent 的等待,其实是两种等待叠在一起 ​

🎯 本章目标

建立“节点级等待”和“token 级等待”的区分。后面所有 UI 设计都建立在这个二分上。

一个多步 Agent 跑起来,界面上同时发生两件事:

粒度它回答的问题用户看到什么典型来源
步骤进度“它刚做完什么、现在在做什么?”步骤列表、当前动作、工具状态节点更新、工具生命周期、自定义进度事件
token 级流“最终答案长什么样了?”答案文字逐字长出、闪烁光标模型生成的 token 流

这两件事的节奏完全不同:

  • 步骤进度是“块状”的:可能先规划、再调工具,中间一阵没动静,最后模型才开始写答案。它不是匀速的,也未必提前知道总共有几步。
  • token 级流是“线状”的:一旦模型开始回答,字会相对平滑地往外蹦;但在 Agent 里,它往往只在最后一轮模型调用时才出现,前面若干步可能一个字都不吐。

这就解释了为什么只放一个全局 loading 很糟:loading 把“块状进度”和“线状答案”混成一个不确定信号。 用户既不知道“进行到哪了”,也不知道“是不是快出结果了”,只剩干等。

如果你读过《前端也能搞懂 Agent:从 Function Calling 到自主编排》,会记得 Agent 本质是个循环:调工具 → 回填 → 再问模型。循环里的节点完成、工具开始与结束,都会留下可观察事件;模型调用则会产生 token 事件。让界面分别承接“步骤进度”和“最终答案”,等待就从一团模糊变成可观察的过程。

🧭 承上启下

那这两条流在技术上报上来长什么样?以 LangGraph 为例,它们甚至可以从同一次 stream 里同时拿到。


🔌 第 2 章:一条执行流,两个界面投影 ​

🎯 本章目标

看清“步骤”和“答案”在技术上是同一份执行过程的两个投影,不需要两套独立接口。

在 LangGraph 里,图和《手写 Agent 循环 vs LangGraph》里讲的是同一个东西:agent 节点问模型、tools 节点执行工具、条件边决定下一步。它提供的流式能力,可以投影到前面说的两块界面:

  • streamMode: "updates":节点执行完成后,返回“哪个节点更新了什么”。适合把已经完成的步骤打勾,但不能拿它冒充“节点刚开始执行”。
  • streamMode: "tools":返回工具的开始、过程、结束和错误事件。适合驱动“正在查字段”“查询失败”这类实时状态。
  • streamMode: "messages":返回 (token, metadata) 二元组,也就是图中各次模型调用产生的 token。过滤出最终回答后,适合驱动打字机。
  • 如果节点内部还要上报更细的业务进度,可以使用 custom 模式主动发送事件。

这些模式可以一起订阅。下面的伪代码假设最终回答由名为 finalAnswer 的节点生成:

javascript
for await (const [mode, chunk] of await graph.stream(
  { messages: [{ role: 'user', content: q }] },
  { streamMode: ['updates', 'tools', 'messages'] }
)) {
  if (mode === 'updates') {
    // chunk 形如 { agent: {...} } / { tools: {...} }
    // 节点已经执行完成 → 把对应步骤标记为“已完成”
  } else if (mode === 'tools') {
    // chunk.event: on_tool_start / on_tool_end / on_tool_error
    // → 更新“正在执行 / 已完成 / 失败”的工具状态
  } else if (mode === 'messages') {
    const [token, metadata] = chunk
    // messages 会包含图中多次 LLM 调用,不能全部塞进最终答案
    if (
      metadata.langgraph_node === 'finalAnswer' &&
      typeof token.content === 'string'
    ) {
      // → 把增量文字追加到“最终答案”区域
    }
  }
}

这里最容易误解的是:两个界面区域,不等于框架只有两种 stream mode。 updates、tools、custom 都可以服务于左边的步骤进度,过滤后的 messages 服务于右边的最终答案。它们仍然来自同一次执行,只是被界面投影成了两种用户感知。

浏览器通常不会直接运行这段 graph.stream

这段分流一般发生在服务端或 BFF:先把框架事件翻译成稳定的业务事件,再通过 SSE 等通道交给浏览器。前端消费的是“步骤开始 / 完成 / 失败、答案增量”,不必直接认识 ToolNode 或 LangGraph 的 chunk 结构。本文只讨论这些事件该驱动什么界面,不展开传输层实现。

别把 messages 模式当成“全过程都在吐答案”

messages 会流出图中任意 LLM 调用的内容,不保证只来自最后一轮。多步 Agent 前几轮可能只有 tool_calls,也可能夹带可见文本;如果不按 metadata.langgraph_node 或 tags 过滤,中间内容就可能混进最终答案。如果“选择工具”和“最终回答”共用同一个 agent 节点,单靠节点名还不够,可以给最终回答调用加 tag,或者先按调用缓存,确认它不是工具调用后再提交到答案区。与此同时,前几步也可能完全没有可见 token,所以步骤进度仍不能省:它负责填补模型规划、工具执行时的空白。

🧭 为什么没把 Event Streaming 写进主代码?

新版 LangGraph 还提供更高层的 Event Streaming,用类型化投影消费消息、状态和中断。本文沿用手册里的底层 streamMode,因为它更容易把原始事件与界面状态逐个对账;如果是新项目,可以再评估 Event Streaming 或前端 SDK。具体 API 会随版本变化,实践时应以当前官方 Streaming 文档和项目锁定版本为准。

🧭 承上启下

机制清楚了。接下来分别看:节点级流该驱动什么界面,token 级流又该长什么样。


🪜 第 3 章:节点级流——把“它在干嘛”说出来 ​

🎯 本章目标

给每一步设计一句人话状态,而不是把框架节点名直接甩给用户。

节点级流最该解决的,是那句无声的“它在干嘛”。但注意:不要把 agent、tools 这种内部节点名直接显示。 用户不关心图结构,他只关心“我的事进行到哪了”。

一个可用的映射是把节点翻译成动作:

内部事件给用户看的文案界面形态
updates 返回 { agent: ... }“已完成问题分析”将规划步骤打勾
tools 返回 on_tool_start“正在查询字段口径…”当前工具高亮为进行中
tools 返回 on_tool_end“已查到字段口径”工具步骤打勾,不展开原始 JSON
最终回答节点开始吐 token“正在组织答案”答案区域开始逐字生长

把这层映射放进界面,大致会长成下面这样:上面区分了“节点已完成”和“工具刚开始”的真实事件,下面才把它们翻译成用户看到的步骤历史。此时答案还没开始生成,但界面并不沉默。

节点级流驱动 Agent 等待界面:内部节点事件被翻译成用户可理解的步骤状态

关键原则有三条:

  1. 显示“动作”而非“机制”。 “调用 ToolNode”是给开发者看的;“正在核对字段是否含税”才是给用户看的。
  2. 工具原始输入输出默认折叠。 除非你的用户就是工程师,否则一整段 SQL、JSON、向量结果只会制造焦虑。需要展示时,给一个“查看详情”折叠,而不是默认铺开。
  3. 步骤列表要“长出历史”,但别编造未来。 已完成步骤保留并打勾,当前步骤高亮。只有固定工作流才适合提前置灰未来步骤;动态 Agent 连自己都还没决定下一步时,界面也不该替它预告。

这套设计其实和前端路由的加载态是同一个思路:你不会在页面跳转时只放一个“加载中”,而是会显示“正在进入订单页”,失败还能回退。Agent 的步骤列表,就是这次“智能跳转”的导航进度。

🧭 承上启下

步骤解决“进行到哪了”,但用户最终要的是答案。答案怎么出现,是 token 级流的主场。


⌨️ 第 4 章:token 级流——答案要“长出来”,但不能假装一直很忙 ​

🎯 本章目标

沿用打字机,但要处理 Agent 里 token 不是全程匀速出现的现实。

token 级流这部分,和《前端也能搞懂流式输出》里的打字机是同一回事:数据每到一片,就把累计全文写进界面,字自然长出来,不需要 setInterval。

但 Agent 场景要补两个细节:token 不是从头到尾均匀来的,也不一定都属于最终答案。 前面几步可能在调工具、模型没吐可见文本;也可能由中间模型调用产生少量文字。UI 既不能在前几秒保持空白,也不能把所有 token 不加区分地拼进答案。

可行的处理是:

  • 步骤阶段:用第 3 章的步骤列表承担“正在发生什么”;答案区可以显示一行弱提示,比如“稍后将在此给出答案”。
  • 答案阶段:一旦收到经过节点名或 tags 过滤的最终回答 token,答案区接管注意力,光标闪烁,文字逐字生长。
  • 步骤与答案并存:很多产品会把上方步骤列表收成“已完成”,下方答案区展开。这样用户既知道“前面几步干完了”,又能专注看最终结果。

还有个常被忽略的点:流里的 content 未必永远是普通字符串,不同模型也可能返回结构化内容块。示例为了突出主线只处理字符串;真实项目应先把内容标准化,再追加到累计答案,避免把工具调用或非文本块误画到正文里。

💡 一句话抓住这一章

token 级流负责“答案长出来”,节点级流负责“答案来之前的那段空白不空”。两者合起来,用户才不会在 Agent 闷头思考时以为程序死了。

🧭 承上启下

答案能长出来还不够。Agent 真到生产里,工具会报错、步骤会超限、用户会反悔——这些“非正常结束”比成功更考验体验。


🛟 第 5 章:失败与中断——别把半截答案悄悄抹掉 ​

🎯 本章目标

给 Agent 的三种非正常状态设计明确反馈:工具出错、步数超限、用户主动中止。

普通问答失败,大不了弹个错误。Agent 的麻烦在于:它可能已经在界面上显示了半段答案、两步进度、一条工具结果,然后才失败。 这时候最烂的做法是“啪”地清空整个区域,用户既不知道发生了什么,也不知道前面那些努力算不算数。

三种状态分别建议这样处理:

工具报错:把错误当成一步,而不是页面崩溃 ​

如果某个工具执行失败,步骤列表里对应项标记“失败”,并附一句人话,比如“查询数据库失败,已重试”。如果 Agent 自己绕过了(例如换参数重试成功),就让步骤显示“已恢复”;如果最终仍失败,在答案区追加提示,而不是替换掉已有内容:

text
⚠️ 最后一步工具调用失败,以下为已完成的初步结论:
(保留前面已经生成的部分答案)

这正好呼应《前端也能搞懂 Agent》里“把工具报错当结果回填”的思路:模型能读到的错误,比直接崩溃更有救。

步数超限:明确告诉用户“我停了”,并给出出口 ​

手写版有 MAX_STEPS,LangGraph 有 recursionLimit。超限不是 bug,是护栏。但用户不需要知道“recursion limit”,他只需要知道:任务太复杂,我没盲目烧下去。 界面上给一句“已自动停止,可拆分问题后重试”,比默默返回半个答案体面得多。

用户反悔:中止要真中止,且保留上下文 ​

和流式输出一样,Agent 也应该支持中止。区别是 Agent 更贵:每一圈都在调模型、调工具。这里要分清两件事:断开前端流,不等于取消服务端任务。 “停止”按钮应同时发起服务端 run 的取消,并保留已经显示的步骤和答案;否则用户看不到了,模型和工具却可能还在后台继续烧。

取消通常还是协作式的:正在执行的节点或工具只有正确响应取消信号,才能尽快停下;已经发出的邮件、写库等外部副作用,也不会因为前端点了停止就自动撤销。因此界面应诚实区分“正在停止”“已停止”和“操作可能已经执行”,不要瞬间把按钮改成“已取消”制造假确定性。

别用“假进度”掩盖失败

最忌讳的是工具挂了,却让 loading 一直转、或进度条慢慢爬到 99% 卡住。用户最终发现是失败,信任感比“直接报错”掉得更狠。失败越早说清楚,越像“靠谱的同事”;拖到最后才暴露,像“隐瞒风险的乙方”。

🧭 承上启下

把正常和异常都接住了,最后看看哪些常见做法其实是在帮倒忙。


🚫 第 6 章:四个反模式——这些“优化”反而更糟 ​

🎯 本章目标

识别 Agent 等待体验里最典型的过度设计。

反模式一:只有一个全局 spinner ​

前面反复说过了。一圈长时间转下去,用户分不清“在思考”还是“挂了”。至少把真实发生的动作暴露出来。

反模式二:把工具原始 JSON 全量展示 ​

一段 SQL、一串 embedding、一屏向量查询结果,对非技术用户是噪音,对技术用户也未必想实时盯。默认折叠,详情按需展开。

反模式三:只在最后甩出完整答案 ​

没有步骤、没有中间信号,一阵等待后答案“砸脸上”。哪怕答案对,用户也会觉得不可控。Agent 的价值之一是“可观察的执行过程”——例如调用了什么工具、哪一步成功或失败;这里说的不是把模型内部推理过程展示出来。

反模式四:过度动画化 ​

粒子特效、彩虹进度条、AI 头像疯狂抖动——这些在 demo 里好看,在真实等待里只会放大焦虑。克制的步骤列表 + 安静的打字机,比花哨动画更专业。 用户要的是“确定性”,不是“热闹”。

🤔 那到底要不要显示“思考中”三个字?(点开看)

可以,但别只显示这三个字。更好的做法是把文案绑定到真实事件:任务提交后先显示“正在开始”,节点内部需要更细进度时用 custom 主动上报“正在规划”,工具收到 on_tool_start 后显示具体动作,最终回答 token 到来后切换到答案区。“思考中”不该是一个永远在转的装饰;如果没有更具体的真实事件,就宁可使用克制的通用状态,也不要编造步骤。


🎁 结语:用户怕的不是等,是“不知道机器还在不在” ​

这三篇 Agent 系列,从《手写 Agent 循环 vs LangGraph》的“框架替我做了什么”,到《你的 AI 应用一发版,用户记忆全没了》的“状态存哪了”,再到这篇的“等待时该看什么”,其实是一条主线:Agent 上线不是把循环跑通就结束,而是要把“自主”包装成用户信得过的产品。

💎 只记一句话也够

多步 Agent 的等待体验,不只关乎加速,更关乎可见:步骤事件告诉用户“刚做完什么、现在在做什么”,最终回答 token 告诉用户“答案长啥样”;两者都接住,长等待就从“像死机”变成“看着它替我转”。

如果只带走一个设计动作,我会选这个:下次做 Agent 界面,别再只放一个 loading。先把“步骤”和“答案”拆成两条流,让用户在沉默的那几秒里,也能看见机器在替他干活。

原创声明

本文首发于我的个人博客。如需转载请注明作者及 原文链接。

👀 本文阅读 ··· 次

基于 VitePress 构建

本站总访问量 ··· 次·访客数 ··· 人