逐日AI
第 1 周 · D6约 4 小时

会话控制与长任务:打断、重试、分支与子 Agent

把一次性的问答变成真正能用的会话:中途打断、失败重试、编辑上一条重发、从某一轮分叉出新分支;再处理长任务的进度呈现与子 Agent 的可视化,以及流断在半路时前端该怎么续上而不产生重复消息。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能用中止控制器实现打断,并说清前端中止之后服务端还需要配合做什么
  2. 能实现编辑重发与会话分支,说出分支树为什么比线性历史更贴合 Agent 场景
  3. 能处理断流后的续播与消息去重,说明为什么去重要以消息标识为准而不是内容

到昨天为止,我们处理的都是「一次运行之内」的事。今天开始处理多次运行之间的关系——这是会话与单次问答的分水岭。读完回到页面顶部把三条目标勾掉。

小白版讲解

打断:叫停译员,但会场的设备还开着

同传现场如果主办方喊停,译员会立刻闭麦。但这只是让你听不见了——讲者还在台上说,录音设备还在转,会场的电还在烧。

AbortController 就是那个闭麦动作。

TypeScriptTypeScript
const controller = new AbortController()
await fetch('/api/agent', { signal: controller.signal, ... })
 
// 用户点停止
controller.abort()

abort() 关闭的是前端这一侧的连接。服务端那边的运行还在跑,token 还在烧

这一点必须讲清楚,因为它直接关系到成本:一个跑了三分钟的 Agent,用户在第十秒点了停止,如果你只做了 abort(),剩下那两分五十秒的费用照付。

真正停下来需要两件事一起做

正确的打断是:

TypeScriptTypeScript
export async function stopRun(controller: AbortController, runId: string) {
  controller.abort()        // 界面立刻响应
 
  await fetch('/api/agent/cancel', {   // 服务端真的停
    method: 'POST',
    body: JSON.stringify({ runId }),
    // 注意:这里不能用同一个已 abort 的 signal
  })
}

两件事各有各的必要性:abort() 让界面立刻停,不然用户点了没反应;取消请求让服务端的运行真的结束,账单才停。

还有一个细节:用户主动打断不是错误fetch 被 abort 时会抛一个 AbortError,如果你把它塞进通用的错误处理,界面会弹一个红色报错,告诉用户「出错了」——而他只是点了停止。

TypeScriptTypeScript
catch (error) {
  if (error instanceof DOMException && error.name === 'AbortError') {
    setRunState('idle')   // 正常回到空闲,不报错
  } else {
    setErrorText(...)
  }
}

这和 D3 那条「用户拒绝不是失败」是同一条判断的两次应用:用户的选择,无论是什么,都不是故障。

断流续播:服务端一定会重复发

流断在半路是常态,不是异常:网络抖动、代理超时、用户切到后台被系统回收连接。

续播的做法是重新发起请求,并告诉服务端「我已经收到哪里了」。服务端从那之后继续发。

但这里有个绕不开的事实:服务端的重放边界不可能精确。它记录的进度可能是「第 12 条事件」,而你实际收到的是第 12 条的一半。为了不丢内容,它只能往前多发几条——重复是必然的

所以去重是前端的责任,跑不掉。

去重要按标识,绝不按内容

这是今天最容易做错的地方。

给每个事件一个单调递增的序号(服务端产生,放在事件的 metadata 里),前端记住收到的最大序号,续播时带上它:

TypeScriptTypeScript
accept(event: BaseEvent): boolean {
  const seq = event.metadata?.seq
  if (typeof seq === 'number') {
    if (seq <= this.lastSeq) return false   // 重放,跳过
    this.lastSeq = seq
    return true
  }
  // 没有序号时退回按标识去重(只对幂等类事件有效)
  ...
}

那为什么不能按内容去重——把事件序列化成字符串,见过的就跳过?

因为模型完全可能连续吐出两个一模一样的增量:两个连续的空格、重复的标点、同一个词说两遍。按内容去重会把这些合法的重复吃掉。

表现是:正文里莫名其妙少几个字,而且极难复现——你得刚好碰到模型吐出重复内容的那一次。这类 bug 排查成本极高。

还有一层:增量类事件不能靠标识去重。同一条消息的所有增量共用一个 messageId,按标识去重会把整条消息只留下第一个字。所以去重键的设计要分类:开始、结束、工具结果这类幂等事件按标识去重;增量类事件靠序号,没有序号时一律放行。

重试:别让一次重试产生两次副作用

打断讲完了,接着是它的邻居——重试。

失败重试看起来简单:请求挂了,再发一次。但 Agent 场景里有个特殊风险:上一次运行可能已经产生了副作用

设想一次运行调用了「发送邮件」工具,工具执行成功了,但紧接着流断了。用户看到界面卡住,点了重试。如果你的重试是「把这一轮原样重发」,模型会再次决定发邮件,于是同一封邮件发了两遍。

所以重试要分清楚失败发生在哪一步

  • 请求根本没发出去(网络错误、DNS 失败):安全,原样重发即可
  • 流断在中途,但已经有工具结果回来了:不安全,重发会重复执行那些工具
  • 服务端返回了错误:要看错误类型,模型服务不可用可以重试,参数校验失败重试多少次都一样

对第二种情况,正确做法不是重发整轮,而是续播——把已经拿到的工具结果作为上下文带回去,让模型从断点继续,而不是从头再想一遍。这正是上一节那个续播机制的另一个用途:它不只是为了省流量,更是为了不重复执行副作用

如果你的后端不支持续播,退而求其次是给有副作用的工具加幂等键:同一个键的调用只执行一次,重复调用直接返回上一次的结果。这是分布式系统的老办法,在这里同样适用。

一条可操作的判断:凡是会改变外部世界的工具,都要假设它可能被重复调用。

会话是一棵树,不是一条线

现在到今天的另一半。

用户想改三轮前的一句话重新问。如果会话历史是个数组,你只有一个选择:丢掉后面的全部内容。用户改一句话,代价是十几轮对话没了。

而他往往只是想对比两种问法的结果

把历史换成树,这个需求就自然了:

TypeScriptTypeScript
// 编辑重发 = 从这一轮的父节点分叉,等于替换这一轮
export function editAndResend(tree, nodeId, newText) {
  const node = tree.nodes[nodeId]
  return branchFrom(tree, node.parentId, newText)
}

旧分支完整保留,挂在同一个父节点下。界面上用「2 / 3」加左右箭头让用户切换对比。

渲染时只画从根到当前活动叶子的那条路径,其余分支在背景里待着。这是整个数据结构的全部复杂度——不多,但它解决的问题很实在。

长任务:显示步骤,不要显示假的百分比

Agent 跑三五分钟是常态。这段时间里界面不能只转一个圈。

AG-UI 用 STEP_STARTEDSTEP_FINISHED 把一次运行拆成有名字的阶段,前端把它渲染成一行可见的进度:

✓ 拆解任务   ● 并行收集   汇总成文

一个刻意的设计决定:不画百分比进度条

因为你不知道总共有几步——Agent 可能中途决定多做两轮工具调用。假装知道的结果是一个走到 90% 就卡住的进度条,而那比没有进度条更伤信任。显示「已完成的步骤 + 当前步骤」是诚实且有用的。

子 Agent:归组靠标识,因为事件是交错的

复杂任务常会派子 Agent 并行干活。SUBAGENT_STARTED 宣告一个子运行开始,之后属于它的事件都带一个 subagentRunId

关键是两个子 Agent 的事件是交错到达的

sub-a 的第 1 段  →  sub-b 的第 1 段  →  sub-a 的第 2 段  →  ...

按到达顺序拼接一定串台。必须按 subagentRunId 归组。

这是本课第四次遇到同一条规则了:消息靠 messageId、工具靠 toolCallId、生成式载荷靠自己的序号、子 Agent 靠 subagentRunId

流式协议里的一切归并都靠标识,不靠顺序。

记住这一条,比记住任何具体的事件名都有用。

一处逐天叠加的债,以及怎么还的

最后讲一个本课 lab 里真实发生的问题,因为它比技术点更常见。

服务端路由里选剧本的那段代码,从第一天起就是一条 if 链,每天加一个分支。到第六天时出现了一个 bug:同一个取值被赋了两次含义,后一个分支永远不可达

而这个 bug 只是碰巧被 TypeScript 的类型收窄顺带报了出来——如果那个取值是普通字符串,它会完全静默。

修法是把 if 链换成一张映射表,重复的键在语法层面就不可能存在。

这是「逐天叠加」这种写法的典型代价:每天单看都合理,攒到第六天就出问题。 你自己的项目里,那些「先加个分支」的地方值得定期回头看一眼。

源码导读

动手实验

🧪 D6 实验:支持打断、重试、分支与子 Agent 视图的工作台

代码位置:labs/frontend-agent-ux-7days/day-06-workbench

验收标准:

  1. 发送后可以打断,界面立刻停下且不显示红色报错
  2. 停止后可以续播,接着收完剩下的内容
  3. 续播时重复到达的事件被去掉,正文不出现重复段落
  4. 每一轮都能编辑重发,旧分支保留并可切换
  5. 长任务的步骤逐个打勾,两个子 Agent 各自显示在自己的区域
  6. pnpm typecheck && pnpm selftest 退出码为 0

今天的实验是全课最重的一个。建议顺序:先做会话树(纯数据结构,容易验证),再做去重(自检覆盖得最全),最后做打断与界面组装。

  1. 用中止控制器实现打断,并在界面上留下被打断的痕迹
  2. 实现编辑重发与重试,验证不会产生重复消息
  3. 把会话历史改成树结构,实现分支切换
  4. 制造断流,实现续播与去重
  5. 渲染子 Agent 的独立区域,按子运行标识归组
  6. 用运行阶段事件渲染长任务的进度

面试题

今天 4 道题在下方题库区,侧重打断的前后端配合、会话分支的数据结构、断线续播与幂等。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。

检查清单与明日预告

  • 能用中止控制器实现打断,并说清前端中止之后服务端还需要配合做什么
  • 能实现编辑重发与会话分支,说出分支树为什么比线性历史更贴合 Agent 场景
  • 能处理断流后的续播与消息去重,说明为什么去重要以消息标识为准而不是内容
  • 能说清为什么长任务不该画百分比进度条
  • 实验的 6 条验收标准全部通过
  • 4 道面试题不看要点也能答出至少 3 道

明天(D7)是最后一天,处理两件到现在为止一直欠着的事:可访问性性能预算。可访问性那部分会颠覆一个很自然的直觉——把实时区域指向流式渲染的元素,是屏幕阅读器场景里最常见也最错的做法。做完这两件,七天的成果就能收成一个可以放进作品集、也经得起 code review 的完整项目。

面试题库

  • 用户点了停止,前端调用中止控制器就够了吗?服务端还需要做什么?The user clicks stop. Is calling AbortController enough on the frontend? What does the server still need to do?
    国内高频海外高频进阶#abort-controller#cost-control#streaming

    分析过程 · 先想清楚再作答

    1. 这题的区分度在于你有没有意识到「界面停了」和「账单停了」是两件事。只答「调 abort」的人通常没做过成本敏感的项目。
    2. 先说清楚事实:`AbortController.abort()` 关闭的是**前端这一侧**的连接。服务端那边的运行还在跑,token 还在烧。一个跑三分钟的 Agent,用户第十秒点停止,只做 abort 的话剩下两分五十秒的费用照付。
    3. 所以正确做法是两件事一起做:abort 让界面立刻响应,另外发一个显式的取消请求让服务端真的结束这次运行。服务端收到后给那个运行打取消标记,由运行循环在下一步检查时退出。
    4. 有些运行时能感知连接关闭并自动终止,但**不能依赖它**:经过网关、负载均衡、或者服务端有缓冲时,连接关闭的信号可能根本传不到运行循环。显式取消是唯一可靠的。
    5. 一个很容易踩的坑值得主动说:取消请求**不能复用同一个已经 abort 的 signal**,否则它在发出前就被取消掉了。这个 bug 极隐蔽,因为界面表现完全正常(确实停了),只有账单会告诉你真相。
    6. 可预期的追问是「打断后界面该显示什么」——`fetch` 被 abort 会抛 AbortError,**不要把它当错误渲染**。用户只是点了停止,弹一个红色报错说「出错了」是错的。这和「用户拒绝工具调用不是失败」是同一条判断:用户的选择不是故障。

    How to reason about it · think before answering

    1. The discriminator is whether you see 'the UI stopped' and 'the bill stopped' as two different things. Answering only 'call abort' usually means you have not worked on a cost-sensitive product.
    2. State the fact: `AbortController.abort()` closes **the frontend's side** of the connection. The server's run keeps going and keeps burning tokens. On a three-minute run stopped at ten seconds, an abort-only implementation still pays for the remaining two minutes fifty.
    3. So do both: abort for immediate UI response, plus an explicit cancel request so the server actually ends the run, typically by flagging it so the run loop exits at its next check.
    4. Some runtimes detect a closed connection and terminate on their own, but **do not rely on it**: behind a gateway, a load balancer, or server-side buffering, that signal may never reach the run loop. Explicit cancellation is the only reliable path.
    5. Raise the easy trap unprompted: the cancel request **must not reuse the already-aborted signal**, or it is cancelled before it is sent. The bug is nearly invisible because the UI behaves correctly — only the bill reveals it.
    6. Expect the follow-up on what the UI shows after stopping: aborting `fetch` throws an AbortError, and **it must not be rendered as an error**. The user pressed stop; showing a red failure is wrong. Same judgment as 'a rejected tool call is not a failure': a user's choice is not a fault.

    答题要点

    • abort 只关闭前端这一侧的连接,服务端的运行还在跑、token 还在烧。
    • 正确做法是两件事一起做:abort 让界面立刻停,显式取消请求让服务端真的结束。
    • 不能依赖服务端自动感知连接关闭,经过网关或有缓冲时那个信号可能传不到。
    • 取消请求不能复用已 abort 的 signal,否则它在发出前就被取消,界面正常但账单不停。
    • AbortError 不要当错误渲染,用户主动打断不是故障。

    Key points

    • Abort closes only the client side; the server run continues and keeps consuming tokens.
    • Do both: abort for instant UI feedback, plus an explicit cancel request so the server actually stops.
    • Do not rely on the server noticing a closed connection; gateways and buffering can swallow that signal.
    • Never reuse the aborted signal for the cancel request, or it is cancelled before sending.
    • Do not render AbortError as a failure; a deliberate stop is not a fault.
  • 会话历史用数组还是用树?编辑重发和分支这两个需求会怎么影响你的选择?Should conversation history be an array or a tree? How do edit-and-resend and branching affect that choice?
    国内高频海外高频进阶#data-structure#session-management#ux

    分析过程 · 先想清楚再作答

    1. 这题考的是从需求推数据结构的能力。直接答「用树」而不说为什么,接不住「数组不行吗」的追问。
    2. 先把数组方案的死角说清楚:用户想改三轮前的一句话重新问,数组方案只有一个选择——**丢掉后面的全部内容**。用户改一句话,代价是十几轮对话没了。
    3. 而用户的真实意图往往是**对比两种问法的结果**,不是把后面的都不要了。数组结构从根本上表达不了这个意图。
    4. 树的模型下这件事很自然:编辑重发等于从这一轮的**父节点**分叉出一个新分支,旧分支完整保留挂在同一个父节点下。界面上用「2 / 3」加左右箭头切换。渲染时只画从根到当前活动叶子的那条路径。
    5. 实现上最容易写错的一行值得说出来:把新节点挂到父节点下时,父节点原有的 children 不能覆盖只能追加。写成覆盖就悄悄退化成线性历史了,而界面上什么异常都看不出来——这种静默的退化最危险。
    6. 可预期的追问是「树会不会让别的功能变复杂」——会。滚动定位、搜索高亮、导出会话都要处理「哪条路径」的问题。所以判断依据是产品到底要不要分支:只做一次性问答的场景,数组完全够用,不要为了显得完备提前上树。

    How to reason about it · think before answering

    1. This tests deriving a data structure from requirements. Answering 'a tree' without the why does not survive 'why not an array?'
    2. Name the array's dead end: if the user wants to revise something three turns back, an array leaves exactly one option — **discard everything after it**. One edited sentence costs a dozen turns.
    3. But the user's actual intent is usually to **compare two phrasings**, not to throw away what followed. An array fundamentally cannot express that.
    4. A tree makes it natural: edit-and-resend branches from that turn's **parent**, leaving the old branch intact under the same parent. The UI shows '2 / 3' with arrows, and rendering walks only the path from root to the active leaf.
    5. Call out the line most easily got wrong: when attaching the new node, append to the parent's children rather than replacing them. Replacing silently degrades the tree back to linear history with no visible symptom, and silent degradation is the worst kind.
    6. Expect the follow-up on what a tree complicates: scroll position, search highlighting, and exporting all have to answer 'which path?'. So the deciding factor is whether the product needs branching at all. For one-shot Q&A, an array is fine, and adopting a tree early buys complexity you will not use.

    答题要点

    • 数组方案下编辑重发只能丢掉后面全部内容,而用户往往只是想对比两种问法。
    • 树的模型下编辑重发是从父节点分叉,旧分支完整保留可切换对比。
    • 渲染只画从根到活动叶子的那条路径,其余分支在背景待着。
    • 实现要点是挂新节点时追加而不是覆盖父节点的 children,覆盖会静默退化成线性。
    • 树会让滚动定位、搜索、导出变复杂,只做一次性问答时数组完全够用。

    Key points

    • With an array, edit-and-resend must discard everything after it, though users usually just want to compare phrasings.
    • A tree branches from the parent, keeping the old branch intact and switchable.
    • Render only the path from root to the active leaf; other branches sit in the background.
    • Append rather than replace the parent's children, or the tree silently degrades to linear history.
    • Trees complicate scrolling, search, and export; arrays are fine for one-shot Q&A.
  • 流断在一半,前端重连续播,怎么保证不出现重复的消息?A stream breaks midway and the client reconnects to resume. How do you prevent duplicate messages?
    国内高频海外高频深入#resumption#deduplication#streaming

    分析过程 · 先想清楚再作答

    1. 这题的关键不在「怎么去重」,而在你有没有意识到**重复是必然的**,以及去重的口径选错会造成什么。
    2. 先说为什么必然重复:续播时前端告诉服务端「我收到哪了」,但服务端记录的进度不可能精确到字节——它记的可能是第 12 条事件,而你实际收到的是第 12 条的一半。为了不丢内容,它只能往前多发几条。所以去重是前端跑不掉的责任。
    3. 做法是给每个事件一个**单调递增的序号**,由服务端产生。前端记住收到的最大序号,续播时带上它,收到序号小于等于它的事件就跳过。
    4. 然后是最关键的一条:**绝不按内容去重**。把事件序列化成字符串、见过就跳过,看起来很省事,但模型完全可能连续吐出两个一模一样的增量——两个空格、重复的标点、同一个词说两遍。按内容去重会把这些合法的重复吃掉,表现是正文莫名其妙少字,而且极难复现,排查成本极高。
    5. 还有一层容易漏:**增量类事件也不能按标识去重**。同一条消息的所有增量共用一个 messageId,按标识去重会让整条消息只剩第一个字。所以去重键要分类——开始、结束、工具结果这类幂等事件按标识去重,增量类靠序号,没有序号时一律放行。
    6. 可预期的追问是「服务端该怎么配合」——保存事件日志并支持从某个序号之后重放。如果做不到,退而求其次是让整次运行可重跑且结果幂等,但那对长任务代价太大。

    How to reason about it · think before answering

    1. The key is not the dedup mechanism but recognizing that **duplication is inevitable**, and what choosing the wrong dedup key costs you.
    2. Why it is inevitable: on resume the client says where it got to, but the server's recorded progress cannot be byte-exact — it may have logged 'event 12' while you received half of event 12. To avoid losing content it must replay a few extra. Deduplication is therefore unavoidably the client's job.
    3. The mechanism: give every event a **monotonically increasing sequence number** produced by the server. The client tracks the highest it has seen, sends it on resume, and drops anything at or below it.
    4. Then the critical rule: **never deduplicate by content**. Serializing events and skipping ones you have seen looks convenient, but a model can legitimately emit two identical deltas — two spaces, a repeated punctuation mark, the same word twice. Content-based dedup eats those, producing text that mysteriously loses characters, nearly impossible to reproduce.
    5. One more easily missed layer: **delta events cannot be deduplicated by identifier either**, since all deltas of a message share one messageId and you would keep only the first character. So classify your keys: idempotent events like start, end, and tool results dedupe by identifier; deltas rely on sequence numbers and otherwise pass through.
    6. Expect the follow-up on server support: persist an event log and support replay from a sequence number. Failing that, make the whole run idempotently re-runnable, though that is expensive for long tasks.

    答题要点

    • 重复是必然的:服务端的重放边界不可能精确,为了不丢内容只能多发几条。
    • 用服务端产生的单调递增序号去重,前端记住最大序号并在续播时带上。
    • 绝不按内容去重:模型会合法地连续吐出相同增量,按内容去重会让正文莫名少字。
    • 增量类事件也不能按标识去重,同一条消息的增量共用 messageId。
    • 去重键要分类:幂等事件按标识,增量靠序号,没有序号时放行。

    Key points

    • Duplication is inevitable since the server's replay boundary cannot be exact and it must over-send to avoid loss.
    • Deduplicate with a server-issued monotonic sequence number, tracked client-side and sent on resume.
    • Never dedupe by content: models legitimately emit identical consecutive deltas, and content dedup silently drops text.
    • Deltas also cannot dedupe by identifier, since all deltas of one message share a messageId.
    • Classify keys: identifier for idempotent events, sequence numbers for deltas, pass through when neither applies.
  • Agent 跑一个三分钟的长任务,界面上你会怎么表现进度?An agent runs a three-minute task. How do you present progress in the UI?
    国内高频海外高频进阶#long-running-tasks#progress-ui#ux

    分析过程 · 先想清楚再作答

    1. 这题看着开放,其实有一个明确的错误答案,答了就减分:画百分比进度条。
    2. 为什么不能画:**你不知道总共有几步**。Agent 可能中途决定多做两轮工具调用,也可能提前收敛。假装知道的结果是一个走到 90% 就卡住的进度条,而那比没有进度条更伤信任——用户会觉得程序挂了,而且以后再也不信你的进度条。
    3. 正确做法是显示**已完成的步骤加当前步骤**。协议层有运行阶段事件,每个阶段有名字,前端渲染成一行:已完成的打勾,当前的高亮,后面的不预告。这是诚实的,而且信息量比一个数字大得多——用户知道它在「收集数据」还是在「汇总成文」。
    4. 如果任务会派子 Agent,还要把它们各自的进展显示出来。归组靠子运行标识,因为多个子 Agent 的事件是交错到达的,按顺序拼一定串台。
    5. 有一个补充手段值得提:**长任务要考虑用户不在场的情况**。三分钟足够用户切走去干别的,所以要么支持后台继续并在完成时通知,要么在页面回到前台时能正确恢复显示。只做前台可见的进度,等于假设用户会盯着看。
    6. 可预期的追问是「那要不要显示预计剩余时间」——同理不要,除非你有可靠的历史数据做估算。不准的剩余时间和不准的百分比是同一类伤害。

    How to reason about it · think before answering

    1. It looks open-ended but has one clearly wrong answer that costs you points: a percentage progress bar.
    2. Why not: **you do not know how many steps there are**. The agent may decide on two more tool calls, or converge early. Pretending otherwise yields a bar that stalls at 90%, which is worse than no bar — users conclude it hung, and stop trusting your progress indicators generally.
    3. The right approach shows **completed steps plus the current one**. The protocol has run-stage events with names; render them as a row where finished steps are ticked, the current one is highlighted, and nothing ahead is promised. That is honest and far more informative than a number, since the user can see whether it is 'gathering data' or 'writing the summary'.
    4. If the task spawns subagents, surface their individual progress too, grouped by subagent run id, since multiple subagents' events interleave and order-based assembly will cross the wires.
    5. One addition worth raising: **long tasks must account for an absent user**. Three minutes is plenty of time to switch away, so either support background continuation with a completion notification, or restore state correctly when the tab returns to the foreground. Foreground-only progress assumes the user is watching.
    6. Expect a follow-up on estimated time remaining: same answer, avoid it unless you have reliable historical data. An inaccurate ETA does the same damage as an inaccurate percentage.

    答题要点

    • 不要画百分比进度条:你不知道总共几步,卡在 90% 比没有进度条更伤信任。
    • 显示已完成步骤加当前步骤,用协议的运行阶段事件,后面的不预告。
    • 有子 Agent 时各自显示进展,按子运行标识归组,因为事件是交错到达的。
    • 长任务要考虑用户切走:支持后台继续并通知,或回前台时正确恢复。
    • 同理不要显示不准的预计剩余时间,除非有可靠的历史数据。

    Key points

    • Avoid percentage bars: you do not know the step count, and stalling at 90% is worse than no bar.
    • Show completed plus current steps from the protocol's run-stage events, promising nothing ahead.
    • Surface each subagent's progress grouped by subagent run id, since their events interleave.
    • Account for the user leaving: continue in the background with a notification, or restore correctly on return.
    • Skip unreliable time estimates for the same reason as unreliable percentages.

评论