Skip to content

第 20 章 Agent 运行过程怎样显示到界面 ​

本章要解决的问题

第 19 章让后端在失败、重启和长任务下保持正确,但用户还看不到这些状态,也无法可靠地审批、取消和恢复任务。这里的 Runtime 指负责运行 Agent 循环、工具和事件的后端。

  • 模型服务的字节流怎样变成稳定事件?
  • 增量内容与最终结果怎样更新同一条记录?
  • 提问、审批和取消怎样避免答错对象?
  • 断线后怎样恢复页面,又不重复执行工具?

本章先建立字节流到页面状态的事件主干,再处理审批、文件、Computer Use 等交互,最后验证断线恢复。

20.1 不要让模型服务的原始数据块直接进入组件 ​

最短的流式实现通常是这样:

text
reader.read() → JSON.parse() → setMessage(message + delta)

它在正常情况下能显示文字,但很快会遇到四类问题:

  1. ReadableStream 交付的是任意字节块,不是一条 JSON;一个中文字符和一帧 SSE 都可能被从中间切开。
  2. 不同模型服务的字段不同;组件一旦认识 choices[0].delta,更换模型就要改 UI。
  3. 重连会让历史事件与实时事件重叠;简单 append 会重复文字、工具卡片和审批框。
  4. delta 是模型逐步返回的增量片段;最终完整消息可能经过服务端修复、过滤或重组,不能假设“所有片段拼起来”就是最终结果——例如完成事件可能返回服务端修复后的完整工具参数,或中间网关对输出做过过滤/重组,此时拼出来的内容和最终结果已经不是同一份。

建议分成以下几步:

从模型服务的字节流到组件的数据处理步骤:AgentEvent 是 Runtime 与 UI 之间的稳定格式

先统一每条事件都有的公共字段 ​

事件类型可以持续增加,但公共字段要尽早稳定。每条事件至少要说明:

  • 格式版本与类型:schemaVersion、type
  • 所属会话与顺序:sessionId、单调递增的 seq、time
  • 按需携带的对象 ID:turnId / stepId / attemptId / itemId / toolCallId / jobId / requestId
  • 是否需要在服务重启后恢复:durable
  • 具体数据:data

这些 ID 不是装饰,每个都在挡一种真实事故:

字段解决的问题
schemaVersion升级客户端时识别不兼容事件,而不是悄悄算出错误状态
sessionId + seq会话内去重、排序和序号缺口检测;seq 必须单调递增且由服务端分配
turnId取消时不误伤已经开始的下一轮
stepId一轮内多次“模型→工具→模型”归属清楚
attemptId一次独立请求的编号。断流重试后,不把请求 A 的半截文字拼进请求 B
itemId增量片段与完整结果指向同一条消息或推理块,组件 key 稳定
toolCallId工具参数、执行、结果一一配对
jobId后台进程和终端输出不会串台
requestId审批/提问的请求与响应恰好配对

不是每条事件都需要所有 ID。规则是:只要后续存在“更新、完成、取消、应答或重新读取”,就必须有稳定标识,不能靠数组下标和当前选中的会话猜。

哪些事件要保存,哪些只用于实时显示 ​

推荐先定义哪些事件要保存,再写代码:

事件默认建议理由
turn/started、turn/completed、turn/aborted保存恢复每轮的开始与结束状态
assistant/delta、reasoning/delta通常不保存数量多,可由完整结果替代;只有产品要求逐 token 查看历史时才保存
assistant/completed保存UI 与模型历史使用的最终结果
tool/requested、tool/started、tool/completed保存判断工具尚未开始、已经完成还是结果未知
job/output实时显示;按产品承诺决定是否另存全文输出可能很大;事件只保存有界预览、是否丢失、可用日志引用和最终摘要
job/completed、job/failed保存程序重启后判断任务结局
approval/requested、approval/decided保存用于审计;当前仍待回答的请求还需由服务端单独提供
connection/state、hover、输入中不必保存只影响当前页面,重连后重新建立

这里的核心不是“增量片段一律不保存”。dsh 就选择保存原始 assistant/chunk,从而能够精确恢复中断时的显示内容,再用紧凑行编码(把连续多个增量事件合并打包成一行存储、读取时再无损还原出每个事件)控制体积。真正要决定的是:哪些信息必须准确保存、哪些可以重新计算,不要碰巧把 WebSocket 收到的所有东西都写进数据库。

本书为统一 AgentEvent 选择一条状态规则:同一 itemId + attemptId 的 completed(完整结果)覆盖 partial(未完成片段),不能再拼一次。

这是 Runtime 与 UI 的自定义约定,不是模型服务天然提供的事实。转换器必须先按对应服务的 item/content-block/response 结束语义构造出完整结果。完整结果可能带服务端修复后的工具参数、脱敏文本或不同的内容分块,它才是这一协议里的最终状态。

pi 展示了一个更小的 Runtime/UI 边界:终端可以实时显示模型流和实际完成顺序的工具事件,但 Agent loop 只在完整 assistant message 结束后执行工具,写回模型历史的结果仍按调用顺序排列;扩展通过 beforeToolCall / afterToolCall 等钩子观察或修改行为。它没有把这些本地事件承诺成可断线重放的 Web 协议。若要接浏览器或 IDE,仍需按本章规则补稳定 ID、durable/transient 区分、游标和一次性应答,不能直接把扩展事件透传给组件。

20.2 从字节到事件:按四步解析 ​

20.2.1 保留不完整的 UTF-8 字节 ​

chunk.toString() 或对每块单独 new TextDecoder().decode(chunk) 都可能把跨块的中文字符变成 �。要复用同一个解码器,中间每次解码都带 { stream: true }(让它把未完结的字节序列留到下一块),流结束时再调用一次无参 decode() 把剩余字节解出来。

20.2.2 等一整帧 SSE 到齐再解析 ​

一块网络数据可能包含半帧、一帧或十帧。解析器必须保留未闭合尾巴,直到遇到空行。还要处理 \r\n,多行 data: 和 [DONE]。不要对每个网络 chunk 直接 JSON.parse。

20.2.3 把不同模型服务的事件统一成 AgentEvent ​

OpenAI-compatible 的 choices[].delta、Responses API 的 output item、Anthropic 的 content block 事件不是一套协议。每个模型服务都写一个转换器(adapter),它只做两件事:

  1. 按该服务的规则拼完整内容块、文本和工具参数;
  2. 输出统一的 AgentEvent,不把模型服务的私有字段带进 UI。

转换器不应该负责 Vue store、滚动位置或审批弹窗。否则协议解析会和组件何时创建、更新、销毁绑死。

20.2.4 工具参数完整后再解析 ​

流中的工具参数经常长这样:

text
第 1 块: {"path":"docs/
第 2 块: report.md","line":
第 3 块: 12}

前两块都不是合法 JSON。正确做法是按 toolCallId(没有时先按模型服务的内容块序号)拼 argsRaw,收到内容块或响应完成事件后再执行 JSON.parse → schema 校验 → 权限检查 → 工具执行。中途可以把 argsRaw 给 UI 预览,但绝不能提前执行。

20.3 一个可直接照做的验收练习:随机拆流 ​

20.1/20.2 的每条规则都可以用一个不依赖 API key 的练习验证:比较“完整输入”和“随机切块输入”是否得到相同结果。

准备输入:把一段带中文、带跨块工具参数的 SSE 文本(data: {...}\r\n\r\n 帧),按随机 1~5 字节切块喂给解析器。每次运行切点都不同,能稳定复现“半帧、半个字符和不完整 JSON 参数”。

处理步骤:

  1. 随机字节块经过跨块 UTF-8 解码与 SSE 分帧,兼容 LF/CRLF、多行 data: 和 [DONE]。
  2. 转换器累加文本并发送增量片段,按序号拼接工具的 argsRaw。
  3. 按该服务的协议等待 item、content block 或响应完整结束。
  4. 确认没有拒绝、截断或流错误后发送 assistant/completed,此刻才用 JSON.parse 解析参数。
  5. 页面按 seq 更新状态。

finish_reason 只能算转换器的一项输入,不能单独证明已经有可提交的完整 assistant 结果。

检查三件事:

  1. 多轮随机切块,最终文本完全一致——无 \ufffd,工具参数解析出正确对象;
  2. 重复读取不会重复追加——把“全部历史 + 重复的两条旧事件”再处理一次,页面状态与只处理一次完全相同(按 seq 去重)。这就是幂等:同一事件处理两次,结果与一次相同;
  3. 序号有缺口就先停——实时流只收到 seq 0 时,直接应用最后一条高序号事件必须报 SEQ_GAP,不能跳过去。

这个练习有意在序号缺失时暂停处理。另一种合法实现是先把高序号事件放进缓冲区,补齐缺失序号后再按顺序处理;但不能明知有缺口还继续更新状态,否则工具结果可能出现在工具卡片之前。

生产代码还应把模型服务的解析失败变成字段明确的 stream/error,并把本次请求(attemptId)标为 interrupted。重试要创建新的 attemptId;不要清空旧半截再冒充同一次生成,也不要把两次生成拼成一句话。 第 19 章的故障注入回归给出了更完整的测试组合。

20.4 前端怎样根据事件更新页面状态 ​

按对象 ID 分开保存,不要深拷贝整棵会话树 ​

推荐按对象类型分别保存:sessions、turns、items(消息或推理块)、toolCalls、jobs、requests(提问或审批)、uploads、artifacts、computerUse 各使用一张 Record<id, State> 表,外加连接状态(connected / reconnecting + generation;generation 是连接代次,每次重连递增,用于识别旧连接的迟到数据)。

记录之间只保存 ID,组件只订阅自己需要的记录。一次终端输出只更新一个 job 的缓冲区,不该重建整个 session 数组。状态更新函数要满足三点:

  • 重复事件不产生重复结果:同一 (sessionId, seq) 处理两次,结果不变。
  • 同样输入得到同样状态:一次读取全部历史,应当和“先读历史再接收实时事件”得到相同状态。
  • 完整结果覆盖未完成片段:按照本书的 AgentEvent 约定,item/completed 替换同 itemId + attemptId 的 partial,不能重复追加。

如果业务允许并行请求,itemId 下面再按 attemptId 区分。失败请求保留为 interrupted 供用户展开,成功请求才成为最终答案。

按浏览器刷新节奏批量更新 ​

模型可能每秒送来几十到数百个增量片段。每一块都通知 store、解析 Markdown 和更新 DOM,会把主线程用在“反复重画同一句话”上。常见做法是先入队,每个浏览器刷新帧合并提交一次:事件到来时只追加到 pending 数组;首个事件调用一次 requestAnimationFrame,回调里批量执行 store.apply(pending) 后清空。一个布尔标志保证同一帧内只调度一次。

非浏览器客户端没有 requestAnimationFrame,可以每 16~50ms 批量更新一次。审批出现、turn 结束和错误应立即刷新,只有文字增量适合合并更新。

后台标签页的 requestAnimationFrame 可能长时间暂停或被强烈限速,所以 pending 不能无限增长:同时设置事件数/字节上限和兜底定时器,文字增量超过上限时按 itemId + attemptId 合并,终端只保留有界尾部,审批与完成等控制事件仍立即处理。页面重新可见时再立刻清空队列。否则用户切走标签页后,内存会随着流式输出一直增长。

Markdown、长列表与自动滚动 ​

三个容易被低估的 UI 坑:

  1. 未闭合 Markdown:代码围栏、表格和链接在流中都可能不完整。较稳妥的做法是只重算当前变化的内容块,收到完整结果后再完整渲染。局部流式渲染和最终渲染都必须走同一套安全规则:默认转义原始 HTML;确需支持时使用经过审查的 sanitizer、URL scheme 白名单和安全链接属性。不能只在完整结果阶段清理,否则增量阶段仍可能短暂执行恶意 HTML。不要每个 token 都重新解析整段会话。
  2. 历史无限增长:会话列表和终端输出都要使用虚拟列表。终端内存只保留最近 N 段;若 Runtime 另存了日志就显示可读取位置,否则明确显示哪些区间已经丢失,不能假装全文都在 DOM 或文件中。
  3. 滚动抢夺:只有用户本来位于底部阈值内时才自动跟随。用户向上查历史后,新 token 只显示“有新内容”,不能每帧把他拽回底部。

20.5 提问、审批、通知、修改要求和取消要分开处理 ​

Agent 与人的交互不能都塞成一条 message。下面五类交互有不同的阻塞方式、默认动作和恢复规则:

类型解决什么是否阻塞是否保存超时/默认重连行为
ask(提问)缺少任务信息,请求用户填写数据通常阻塞当前 turn/Goal保存请求、回答和最终状态默认继续等待或转 blocked;不得编答案重新发布仍待回答且仍有效的请求
approval(审批)对高风险动作作权限决定阻塞目标动作保存请求与决定,用于安全审计过期、响应者/服务端授权状态丢失或没有有效肯定答复时拒绝;绝不默认允许连接断开本身不作决定;服务端仍持有 pending 时用同一 ID 恢复
notify(通知)单向告知进度、完成或警告否保存重要通知;动画和“正在输入”可丢过期后可从 UI 消失,不改变任务事实只补回已经保存且仍相关的通知
steer(修改要求)运行中追加或修正用户意图不应在任意位置硬中断;在安全位置应用保存用户意图、目标及采用或拒绝的结果目标 turn 结束后标为已过期,不能偷偷套到下一轮显示等待、采用或过期等服务端状态
cancel(取消)停止指定 turn、job、子 Agent 或 Goal尽快停止目标,但已发生的副作用撤不回保存请求与最终停止结果未送达不等于已取消;重复调用不能重复伤害重连后查询目标最终状态,不猜测执行

这里有三类状态不要混在一起:

  1. 写入日志的事件:ask/requested、approval/decided、steer/applied、turn/aborted,用于恢复与审计;
  2. 仍可回答的请求:Runtime 当前仍持有提问或审批,并且只接受一次有效回答;
  3. 传输与页面状态:弹窗是否打开、按钮是否禁用、通知是否已读,大多可以重新计算或直接丢弃。

提问和审批都需要 requestId,但默认动作不同 ​

两者可以共享一组字段:稳定的 requestId、类型(ask/approval)、所属对象(sessionId/turnId/targetId)、服务端时间戳和可选的 expiresAt。过期时间必须由服务端判断,不能只依赖浏览器的 setTimeout。此外还要写明是否阻塞、默认动作、回答格式或可选决定,并记录状态怎样从 pending 变为 answered / expired / resolved / cancelled。

ask 的回答是任务数据,必须通过 schema 校验;没人回答时最安全的结果通常是 blocked,而不是模型自己补一个默认值。业务确实允许默认值时,要把它明确写进请求并展示给用户。approval 的默认更严格:没有肯定答复就不执行。浏览器本地倒计时只负责显示,最终由服务端判断是否过期,避免休眠、时钟漂移或多标签页给出不同结论。

审批同时有两种记录:

  • approval/requested / approval/decided:可持久化的审计事实;
  • 当前仍可回答的服务端请求:携带 requestId/rpcId(前者是跨重连稳定的请求标识,后者是这一次 RPC 往返的传输层配对编号),并且只接受一次有效响应。

浏览器重连时只补回“曾经请求过审批”的历史事件还不够,服务端还要重新发布仍待回答的请求,并保持同一个 requestId。响应至少携带 sessionId + turnId + toolCallId + requestId + decision。客户端防双击,服务端按 requestId 只接受第一个有效回答;请求已撤回、过期、turn 已取消或工具已完成时,迟到的“允许”必须拒绝。

网络断开本身不是“拒绝”决定:只要 Runtime 仍能证明这个 pending 请求有效,重连后就继续等待同一 requestId。但如果进程重启后只剩审计事件,原 responder(被指定回答该请求的一方)、待回答表或授权上下文已经丢失,就必须 fail closed,把请求标为 expired/rejected 并重新发起新的审批;不能仅凭一条旧 approval/requested 恢复成可点击的“允许”。

通知只告知信息,不代表用户授权 ​

通知不应该偷偷改变 Runtime 状态。“构建完成”可以唤醒一次模型或刷新 job 卡片,但“用户已读”不能被解释为批准后续部署。把通知分成两类:

  • 事实通知:job 完成、预算耗尽、安全警告——有稳定 notificationId 和事实引用,可持久化;
  • 呈现通知:正在输入、短暂 toast、上传动画,不必保存,重连后无需补播。

TTL(过期时间)只控制通知在页面上显示多久。通知对应的 job/Goal/安全事实仍按各自日志保留,不能因为 toast 消失就从历史中消失。

修改要求和取消先排队,再由 Runtime 处理 ​

用户在模型流式输出或工具执行中点击“补充要求”和“停止”时,浏览器不能直接修改后端的 messages 数组或 AbortController。它应提交一个带目标版本的命令,由 Runtime 在模型调用结束、工具调用之间等安全位置处理:

text
submitted → accepted → queued ─┬→ applied
                               ├→ stale      (目标 turn/revision 已结束)
                               ├→ rejected   (权限/状态不允许)
                               └→ cancelled  (用户撤回未应用命令)

这个待处理命令队列至少保存:

text
commandId + kind + sessionId + targetTurnId/goalRevision + submittedAt + payloadHash

UI 展示服务端确认的状态,不能“点完按钮就假装成功”。修改要求通常在模型调用结束、工具调用之间或明确支持的中断点进入下一次推理;已经发生的外部副作用不会被改写。目标 turn 已完成时必须标为 stale(已过期),不能悄悄把旧补充套到新 turn。排队的代价是指令延迟生效——UI 必须立即给出“已排队/已过期”状态;对明显跑偏的长任务,还应保留显式强中断入口。

取消同样必须有目标。一个全局 cancel() 容易遇到时序问题:用户点的是旧 turn 的停止按钮,请求到达时下一 turn 已经开始。

推荐把取消做成带目标版本的命令:取消 turn 要带 turnId,取消 job 要带 jobId,取消子 Agent 要带父子会话 ID,取消 Goal 还要带 goalRevision,每一种都附带 commandId。

重复提交取消请求不能重复取消其他目标:目标已结束时应返回它的真实状态,而不是误伤新目标。取消信号要传到 fetch、工具、子进程组和子 Agent。

UI 收到 turn/aborted 后仍可能遇到网络里迟到的增量片段,应按 turn 和 attemptId 的最终状态丢弃。请求已提交但连接断开,不代表取消已生效;重连后先查询 commandId 和目标状态。

Job 和终端输出有自己的状态变化 ​

后台命令至少需要:

text
created → running → stopping → killed
                  ├→ completed
                  ├→ failed
                  └→ orphaned

job/output 只是可能丢失、可能截断的实时显示;进程退出码、最终状态、是否另存文件、保留区间和可用日志位置才是恢复依据。不能把“有 spillPath”写成“任意大小的全文都在文件里”:例如 dsh 本地 bash/pwsh 的单路 spill 默认最多 64 MiB,超过后删除 spill 文件并只保留内存尾部。重启后记录仍是 running 但进程已经找不到,要标成 orphaned,不能伪装成 completed。前端还要区分:

  • terminal 仍连接:可以写 stdin;
  • 进程仍活着但只剩日志:detached,只能观察/按 PID 管理;
  • 进程已丢:orphaned,要求检查外部状态。

20.6 图片和文件只保存引用,不要塞进事件 ​

文本只是消息内容的一种。为了让同一条消息稳定包含图片、音频、视频和文档,先定义不依赖具体模型服务的内容格式。消息由若干内容块组成,每块有稳定的 partId:

  • 文本块保存文本
  • 文件块保存不可变的二进制文件引用 BlobRef
  • BlobRef 包含 blobId、MIME、大小和 sha256
  • 图片、音视频和文档还可以保存宽高、时长、页数、用途、替代文本与转写引用

必须保存的事件只记录不可变的 BlobRef 和 hash,不保存 base64、浏览器 object URL 或短期签名下载 URL。base64 会让 JSONL、数据库、WebSocket 和页面内存一起膨胀;签名 URL 会过期,不能写进长期历史。客户端下载时按 blobId 重新获取短时 URL,服务端每次都检查会话和租户权限。

上传过程也要记录明确状态 ​

没有明确状态,就无法判断哪些文件可以发给模型、断线后也不知道传到了哪一片:

text
selected → hashing → uploading → scanning → ready → attached → consumed
                  ├→ failed      └→ rejected
                  └→ cancelled

前端用本地 uploadId 跟踪进度,服务端成功后返回 BlobRef;只有 ready 的引用才能进入发送事件。大文件可分片上传并按 uploadId + partNumber 续传,重连后向服务端查询已收分片,不要从 0 猜。草稿删除、上传取消和会话删除还要有垃圾回收策略,否则对象存储会积累无法引用的孤儿。可行做法:按 blobId 反查引用并设置软删除窗口;被去重共享的 Blob 要等引用计数归零才能删。

最低安全线:

  • 服务端嗅探 MIME,不信文件扩展名与浏览器上报类型;
  • 限制压缩后/解压后大小、图片像素、页数、时长,防压缩炸弹;
  • 恶意内容扫描完成前不交给模型、解析器或 Computer Use;
  • EXIF、文档属性等隐藏元数据按策略剥离;
  • Blob 去重最多只能在同一租户或同一权限范围内做,不能用 hash 让一个租户探测另一个租户是否有某文件;
  • object URL 用完 URL.revokeObjectURL,预览失败与上传失败分开显示。

模型输出图片或音视频时,partId 同样要稳定:缩略图、转写和最终高清资源应更新同一个内容块,不能每来一个加工结果就追加一张重复卡片。可访问性上为图像提供替代文字,为音视频提供可展开的文字稿;这些只是页面显示内容,原始 Blob 仍单独保存。

20.7 Computer Use:让 Agent 操作界面时,观察和动作必须绑定同一画面 ​

Computer Use 最危险的时序问题是“模型看见按钮 A,执行坐标时页面已经变成按钮 B”。因此观察不是一张裸截图,动作也不能只有 (x, y)。observation(观察)和 action(动作)都要有明确字段:

  • observation(观察)带 frameId、捕获时间、页面引用与 URL、viewport/滚动位置/DPR(设备像素比),以及截图的 BlobRef 与 hash;可附 DOM、可访问性树、Set-of-Marks(给可交互区域编号)的引用。
  • action(动作)包含三部分:
    • 种类(click/type/key/scroll/select/upload)和目标。优先按 DOM selector、可访问名称或当前 observation 的 markId 定位;只有这些方式不可用时才使用坐标,并说明坐标空间
    • expectedFrameId,可选再加截图 hash
    • 等待条件,例如 URL 匹配、元素可见、文本变化、新画面、下载完成或网络安静,并带超时

Runtime 执行前检查 expectedFrameId。页面已经导航、弹窗出现、viewport/DPR/缩放变化或关键区域 hash 不同,就返回 STALE_OBSERVATION 并要求重新观察,不能在新页面上照点旧坐标。截图坐标要声明坐标空间;CSS viewport、设备像素、全页截图和滚动偏移不能混算。

优先使用更可靠的定位方式 ​

text
业务 API
  > DOM selector / accessibility tree
  > Set-of-Marks(给当前观察帧内的可交互元素编号)
  > 原始截图坐标

能调用 API 就不要模拟点击;能按可访问名称找按钮就不要猜像素。Set-of-Marks(SoM)给一次 observation 中的可交互区域编号,适合没有稳定 DOM 但能检测交互区域的页面。

markId 只在绑定的 frameId 内有效,重新观察、滚动、导航或页面重排后都不能复用。坐标是最后手段,页面稍微移动就可能失效。定位结果应回传“实际命中了什么”,例如标签、可访问名称和边界框,作为审计证据。

等条件,不要 sleep ​

点击后固定 sleep(2s) 同时又慢又不可靠。动作应带可以检查的等待条件:URL 匹配、元素出现或可用、文本变化、新画面、下载完成、网络安静;超时后返回最后一次观察和未满足的条件。对于动画或长轮询页面,network-idle 也可能永远不成立,所以等待条件必须与动作目标匹配并有截止时间。

每个有副作用的动作执行完都要检查结果:

text
observation(frame N)
  → policy/approval
  → action(expectedFrame N)
  → wait(predicate)
  → observation(frame N+1)
  → verify(真实页面/服务端状态)

图中的 policy/approval 不是每个动作都会经过:只有动作命中策略规则或需要人工审批时,才在观察和动作之间插入这一环。

“点击了提交”不是完成证据。前端开发任务至少同时检查 DOM/可访问树、console error、网络响应或后端状态;视觉截图补充布局、遮挡和溢出。自动测试自己生成的页面时也要启动真实构建和浏览器,不能只让模型阅读源码后自评“页面没问题”。

toast、动画和 Canvas 等短暂变化怎样观察? ​

只在动作前后各截一张图,可能正好漏掉一闪而过的 toast、动画中间态、Canvas 变化或视频关键帧。普通网页优先监听 DOM、console 和 network 事件;Canvas、视频等缺少稳定 DOM 的区域,只在像素变化超过阈值时保留少量关键帧,并把短暂变化写成带 frameId 和时间的文字事件。音频只在任务确实需要时转写。

帧缓存必须有数量和时长上限,不能把每一帧都塞进模型上下文。需要模型复查时只提供关键帧、事件摘要和可按引用读取的原始片段。

Computer Use 还带来更多安全风险:网页内容是不可信数据,应使用独立的浏览器配置来隔离 cookie 和存储;下载文件先进入隔离区;剪贴板、文件上传、密码和跨域导航需要分别授权和审批。页面文字不能直接当作系统指令,浏览器登录态也不能自动交给新生成的脚本或工具。

20.8 三种呈现 Agent 产物的方式 ​

这里把可以预览或交互的结果称为 Artifact(产物)。运行时已有 AgentEvent,并不意味着应该让 assistant 文本直接携带任意 HTML。可以根据风险分成三种方式:

方式Agent 产出客户端怎样显示适用场景限制
固定组件{type:"diff", dataRef} 等已知数据使用产品自己的组件diff、计划、终端、图表校验 schema 即可,默认首选
受限组件描述只使用允许清单内的组件、属性和动作从可信组件目录中选择组件动态表单、卡片、数据探索只允许已经注册的组件、属性和动作
可运行 HTML/JSHTML/CSS/JS 或小应用放进隔离 iframe 或独立域名高度定制的可视化、可运行 demo按不可信代码处理,必须沙箱和单独授权

事件协议回答“状态怎么到前端”,组件描述回答“前端渲染什么”,这是两件不同的事。AgentEvent 可以传递产物的状态变化,但不能因为事件来自可信连接就把其中的代码也当成可信代码。

产物在事件中以引用存在,至少包含:

  • artifactId + version + 类型(fixed / declarative-ui / html-app)
  • 内容的 BlobRef 与 hash
  • 状态(draft → validating → ready / rejected / superseded)
  • 申请的权限清单(network / clipboard / download / storage)

每个产物单独授权,并且可以撤回。

建议用 artifact/created → artifact/patch(baseVersion) → artifact/validated → artifact/ready 表示状态变化。修改必须带 baseVersion,版本过旧就返回冲突;未完成的 partial/draft 可以预览,但不能执行有副作用的动作。完整结果覆盖流式草稿,与 assistant/completed 的规则一致。

受限组件描述只允许客户端注册表中的组件、属性和事件;禁止 innerHTML、任意事件处理器、URL scheme 和动态 import。表单提交产生 artifact/action:

text
artifactId + version + actionId + typed payload
  → Runtime 重新校验身份、版本、schema 与权限
  → 转成 ask 回答或工具请求

组件不能从浏览器绕过 Runtime 直接调用工具。即使按钮文案是“删除”,是否需要审批仍由第 9 章的服务端策略决定。

任意 HTML/JS 要放到隔离 iframe 或独立域名。使用严格的内容安全策略(CSP),不共享宿主页面的 cookie 和存储,默认禁止联网,限制下载、弹窗和导航,只通过校验过的 postMessage 交换数据。

权限按产物单独授予且可以撤回;不要在给出 allow-scripts 的同时无条件给出 allow-same-origin。产物引用敏感 Blob 时,也只提供短时且范围最小的访问权限,不能把宿主页面 token 注入 iframe。

20.9 “恢复对话”其实有四层 ​

用户看到一个“正在重新连接”,工程上至少包含四件不同的事:

层问题成功标准
1. 传输重连WebSocket/SSE 断了新连接建立、认证和订阅恢复
2. 重新读取历史页面本地状态不可信或从未加载从已经保存的历史重新计算完整页面状态
3. 按游标补事件只缺断线期间的一段从 lastEventId/lastSeq 补齐,去重且没有序号缺口
4. 恢复执行授权任务是否应该继续产生副作用重新检查 Goal、预算、权限、工具结局和用户意图

前 3 层解决“界面再次正确”,第 4 层解决“Agent 能否继续行动”。界面恢复不等于执行授权恢复。 尤其当日志里只有 tool/started 没有结果时,外部副作用可能已经发生;正确状态是“结果未知”,先检查外部系统,不能因为 WebSocket 重连就再跑一次。

建议按以下顺序重连 ​

text
1. 记录当前连接代次 `generation`;丢弃旧连接迟到的数据
2. 原子地取得“完整 snapshot + 快照最后序号 `snapshotSeq` + 从下一序号开始的订阅”。服务端可先建立并缓冲订阅再读快照,也可在同一事务中返回两者,但不能在快照与订阅之间留下漏事件窗口
3. 客户端先应用 snapshot,再只接纳 `seq > snapshotSeq` 的事件;历史补发和缓冲的实时事件都按 seq 去重
4. 发现序号缺口时暂停处理高序号事件
5. 拉取缺失的尾部事件;仍不连续就重新读取全部历史并记录新的快照最后序号
6. 拉取当前待处理请求:重新发布仍待回答的 ask/approval,同步 steer/cancel 的服务端状态,不复活已结束的请求
7. 恢复上传、产物和 Computer Use 时只恢复页面状态:查询 uploadId、artifact version 和最新 frame;不重传已确认分片,不重新执行动作
8. 单独决定 pause/resume,不在 socket onopen 里自动执行工具

代码中常见的七个英文词表示不同操作,不要混用:

  • reconnect:重新建立连接和订阅;
  • replay:根据已保存的事件重新计算页面状态,不执行工具;
  • resume:继续原来的会话和任务;
  • retry:创建新的 attemptId,再执行一次操作;
  • restart:重启进程或 Runtime;
  • fork:从某段历史开始创建新会话;
  • rewind:让会话回到较早状态;是否恢复文件取决于明确的模式。只回退对话不会还原文件,不能根据命令名猜测或默认重做副作用。

20.10 至少要测试这些分块和时序问题 ​

流式 UI 的 bug 大多不在状态更新函数的普通分支,而在分块和时序组合里。至少要测试以下场景:

场景注入方式必须满足的结果
UTF-8 跨块每 1 字节切一次中文/emoji最终文本无 �,与未切分一致
SSE 半帧在 data:、JSON、\r\n\r\n 任意位置切每帧只解析一次,尾巴不丢
工具参数半 JSON每个标点后切分完成前不执行,完成后只解析一次
重复事件同 seq 发 2~10 次state 与发一次完全相同
乱序事件先发 N+1,后发 N不跨过序号缺口处理;补齐后顺序正确
永久缺失事件删除一条必须保存的事件拉取缺失事件失败后重新读取全部历史,或明确报错
实时事件与历史补发重叠历史补发未完时继续送实时事件无重复文字、工具卡和审批
snapshot 与订阅交界快照读完、订阅建立前插入一条事件事件要么进入 snapshot,要么在 snapshotSeq 之后补发,不能丢失
完整结果后迟到增量片段延迟旧连接的一块完整结果不被污染
旧连接复活新连接建立后发送旧连接数据旧连接的数据全部丢弃
事件计数重置服务端重启后从 0 发 ID根据日志恢复计数,或为新连接使用新的编号范围
提问中断线或过期请求展示后断开或服务端判定过期待回答请求可恢复;过期转 blocked 或明确默认动作;浏览器时钟不作最终判断
审批中断线request 展示后断开pending 可恢复;已结算请求不复活;绝不默认允许
修改要求的时序模型流中、工具执行中、turn 结束后各提交一次只在安全位置采用;旧目标标为已过期;不串到下一 turn
取消与迟到输出abort 后继续投递几个 chunkturn 状态不反转,新 turn 不被误杀
cancel 断线提交后、收到确认前断开按 commandId 查询最终状态,不假定成功也不重复误杀
上传断线/续传分片中断并重复最后一片hash/大小一致,分片不重复;ready 前不能发送给模型
Blob URL 过期读取历史时遇到旧签名 URL用 blobId 换新 URL;长期保存的内容不依赖旧 URL 或 base64
Computer Use 使用旧画面观察后改变页面再执行点击返回 STALE_OBSERVATION,不在新页面点击旧坐标
截图缩放和裁剪改变 DPR、缩放、滚动,再加入留白或裁剪偏移坐标正反变换误差在阈值内;旧画面上的动作失效;四角位置均能正确映射
短暂视觉变化注入短 toast、动画和 Canvas 关键帧在有界缓存内留下关键帧或文字事件,不需要保存全部帧
Computer Use 等待注入慢 DOM、长轮询和导航失败等待可以检查的条件,而不是固定 sleep;超时带最后画面与证据
Artifact 并发 patch两个客户端从同一 baseVersion 修改只接纳一个版本;另一个显式冲突,不静默覆盖
Artifact 越权 actioniframe 伪造旧版本/未知 actionRuntime 拒绝;组件不能直接绕过审批调用工具
两条连接启动不同步一条连接已经开始,另一条还没准备好等待准备完成要有超时;缺失事件最终能够补回
超长 terminal连续写入数百 MBDOM/内存有界;明确报告保留区间、内容是否有丢失和可用日志路径。若产品承诺全文可恢复,必须使用能覆盖该大小的持久存储;不能把 dsh 默认 64 MiB spill 上限误写成全文保证

最有效的检查方法不是断言某个中间数组,而是比较两种处理方式的最终结果:

text
完整有序事件得到状态 A
随机切块 + 重复事件 + 断线 + 历史补发得到状态 B
断言 A 与 B 完全相同

再在每次写入必须保存的事件之后模拟进程崩溃,验证恢复后 UI 状态正确且工具副作用没有重复,这才覆盖了“从 Runtime 到 UI”的全部步骤。

【对比小结】 ​

决策简单方案完整方案什么时候升级
传输SSE 下行 + HTTP 命令WebSocket/JSON-RPC 或 ACP(Agent Client Protocol,Agent 与编辑器通信的协议)多种 server request、双向低延迟或需要接入 IDE
UI 协议模型服务字段直接交给 UI每个模型服务转换为统一的 AgentEvent接入第二家模型服务或第二种客户端之前
增量文本只保存完整结果连增量片段也保存并使用紧凑编码要精确查看中断现场、训练记录或细粒度审计时
重连重新读取全部历史按游标补发;有缺口时读取全部历史会话长、重连频繁、全量恢复太贵
页面数据单个 messages 数组按对象 ID 分开保存,组件只订阅需要的记录出现工具、job、审批或多 Agent 后
渲染每个增量片段都 setState按浏览器刷新帧合并更新,并使用虚拟列表首次出现卡顿前就应合并更新;长历史必须虚拟化
人机交互全部当普通消息按提问、审批、通知、修改和取消分别处理,服务端只结算一次出现这些交互时立即区分
图片和文件base64 塞进消息BlobRef + 明确上传状态 + 短时下载 URL文件稍大,或需要恢复、去重和权限审计时
Computer Use截图 + 坐标动作绑定画面版本,优先按元素定位,执行后等待并检查页面会异步变化或动作有副作用时
交互式产物assistant 直接输出 HTML固定组件 → 受限组件描述 → 隔离 iframe需要动态交互结果时按风险选择

表中的选择可以按产品复杂度升级,但四条边界不能省:

  1. 模型服务的字节先转成内部事件;增量片段负责显示,完整结果负责最终状态。
  2. UI 从日志重建状态,重连不能重新执行工具,也不能自动恢复执行授权。
  3. 可更新、可取消、可回答的对象都有稳定 ID;提问、审批、通知、修改和取消分别建模。
  4. 文件只保存稳定引用,Computer Use 动作绑定画面版本,可执行产物始终按不可信代码隔离。

到这里,实现链已经走完:模型、工具、上下文、安全、状态、可靠性与界面。剩下的是工程之外最难的问题:怎样证明 Agent 真的把任务做对了,以及怎样把这套系统交到用户手里——第 21 章讲评估体系,第 22 章讲上线准备。

基于 MIT 协议发布,欢迎转载与贡献。