Appearance
第 20 章 Agent 运行过程怎样显示到界面
本章要解决的问题
第 19 章让后端在失败、重启和长任务下保持正确,但用户还看不到这些状态,也无法可靠地审批、取消和恢复任务。这里的 Runtime 指负责运行 Agent 循环、工具和事件的后端。
- 模型服务的字节流怎样变成稳定事件?
- 增量内容与最终结果怎样更新同一条记录?
- 提问、审批和取消怎样避免答错对象?
- 断线后怎样恢复页面,又不重复执行工具?
本章先建立字节流到页面状态的事件主干,再处理审批、文件、Computer Use 等交互,最后验证断线恢复。
20.1 不要让模型服务的原始数据块直接进入组件
最短的流式实现通常是这样:
text
reader.read() → JSON.parse() → setMessage(message + delta)它在正常情况下能显示文字,但很快会遇到四类问题:
ReadableStream交付的是任意字节块,不是一条 JSON;一个中文字符和一帧 SSE 都可能被从中间切开。- 不同模型服务的字段不同;组件一旦认识
choices[0].delta,更换模型就要改 UI。 - 重连会让历史事件与实时事件重叠;简单
append会重复文字、工具卡片和审批框。 delta是模型逐步返回的增量片段;最终完整消息可能经过服务端修复、过滤或重组,不能假设“所有片段拼起来”就是最终结果——例如完成事件可能返回服务端修复后的完整工具参数,或中间网关对输出做过过滤/重组,此时拼出来的内容和最终结果已经不是同一份。
建议分成以下几步:
先统一每条事件都有的公共字段
事件类型可以持续增加,但公共字段要尽早稳定。每条事件至少要说明:
- 格式版本与类型:
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),它只做两件事:
- 按该服务的规则拼完整内容块、文本和工具参数;
- 输出统一的
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 参数”。
处理步骤:
- 随机字节块经过跨块 UTF-8 解码与 SSE 分帧,兼容 LF/CRLF、多行
data:和[DONE]。 - 转换器累加文本并发送增量片段,按序号拼接工具的
argsRaw。 - 按该服务的协议等待 item、content block 或响应完整结束。
- 确认没有拒绝、截断或流错误后发送
assistant/completed,此刻才用JSON.parse解析参数。 - 页面按
seq更新状态。
finish_reason 只能算转换器的一项输入,不能单独证明已经有可提交的完整 assistant 结果。
检查三件事:
- 多轮随机切块,最终文本完全一致——无
\ufffd,工具参数解析出正确对象; - 重复读取不会重复追加——把“全部历史 + 重复的两条旧事件”再处理一次,页面状态与只处理一次完全相同(按
seq去重)。这就是幂等:同一事件处理两次,结果与一次相同; - 序号有缺口就先停——实时流只收到 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 坑:
- 未闭合 Markdown:代码围栏、表格和链接在流中都可能不完整。较稳妥的做法是只重算当前变化的内容块,收到完整结果后再完整渲染。局部流式渲染和最终渲染都必须走同一套安全规则:默认转义原始 HTML;确需支持时使用经过审查的 sanitizer、URL scheme 白名单和安全链接属性。不能只在完整结果阶段清理,否则增量阶段仍可能短暂执行恶意 HTML。不要每个 token 都重新解析整段会话。
- 历史无限增长:会话列表和终端输出都要使用虚拟列表。终端内存只保留最近 N 段;若 Runtime 另存了日志就显示可读取位置,否则明确显示哪些区间已经丢失,不能假装全文都在 DOM 或文件中。
- 滚动抢夺:只有用户本来位于底部阈值内时才自动跟随。用户向上查历史后,新 token 只显示“有新内容”,不能每帧把他拽回底部。
20.5 提问、审批、通知、修改要求和取消要分开处理
Agent 与人的交互不能都塞成一条 message。下面五类交互有不同的阻塞方式、默认动作和恢复规则:
| 类型 | 解决什么 | 是否阻塞 | 是否保存 | 超时/默认 | 重连行为 |
|---|---|---|---|---|---|
| ask(提问) | 缺少任务信息,请求用户填写数据 | 通常阻塞当前 turn/Goal | 保存请求、回答和最终状态 | 默认继续等待或转 blocked;不得编答案 | 重新发布仍待回答且仍有效的请求 |
| approval(审批) | 对高风险动作作权限决定 | 阻塞目标动作 | 保存请求与决定,用于安全审计 | 过期、响应者/服务端授权状态丢失或没有有效肯定答复时拒绝;绝不默认允许 | 连接断开本身不作决定;服务端仍持有 pending 时用同一 ID 恢复 |
| notify(通知) | 单向告知进度、完成或警告 | 否 | 保存重要通知;动画和“正在输入”可丢 | 过期后可从 UI 消失,不改变任务事实 | 只补回已经保存且仍相关的通知 |
| steer(修改要求) | 运行中追加或修正用户意图 | 不应在任意位置硬中断;在安全位置应用 | 保存用户意图、目标及采用或拒绝的结果 | 目标 turn 结束后标为已过期,不能偷偷套到下一轮 | 显示等待、采用或过期等服务端状态 |
| cancel(取消) | 停止指定 turn、job、子 Agent 或 Goal | 尽快停止目标,但已发生的副作用撤不回 | 保存请求与最终停止结果 | 未送达不等于已取消;重复调用不能重复伤害 | 重连后查询目标最终状态,不猜测执行 |
这里有三类状态不要混在一起:
- 写入日志的事件:
ask/requested、approval/decided、steer/applied、turn/aborted,用于恢复与审计; - 仍可回答的请求:Runtime 当前仍持有提问或审批,并且只接受一次有效回答;
- 传输与页面状态:弹窗是否打开、按钮是否禁用、通知是否已读,大多可以重新计算或直接丢弃。
提问和审批都需要 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 + payloadHashUI 展示服务端确认的状态,不能“点完按钮就假装成功”。修改要求通常在模型调用结束、工具调用之间或明确支持的中断点进入下一次推理;已经发生的外部副作用不会被改写。目标 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
└→ orphanedjob/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 匹配、元素可见、文本变化、新画面、下载完成或网络安静,并带超时
- 种类(click/type/key/scroll/select/upload)和目标。优先按 DOM selector、可访问名称或当前 observation 的
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/JS | HTML/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 后继续投递几个 chunk | turn 状态不反转,新 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 越权 action | iframe 伪造旧版本/未知 action | Runtime 拒绝;组件不能直接绕过审批调用工具 |
| 两条连接启动不同步 | 一条连接已经开始,另一条还没准备好 | 等待准备完成要有超时;缺失事件最终能够补回 |
| 超长 terminal | 连续写入数百 MB | DOM/内存有界;明确报告保留区间、内容是否有丢失和可用日志路径。若产品承诺全文可恢复,必须使用能覆盖该大小的持久存储;不能把 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 | 需要动态交互结果时按风险选择 |
表中的选择可以按产品复杂度升级,但四条边界不能省:
- 模型服务的字节先转成内部事件;增量片段负责显示,完整结果负责最终状态。
- UI 从日志重建状态,重连不能重新执行工具,也不能自动恢复执行授权。
- 可更新、可取消、可回答的对象都有稳定 ID;提问、审批、通知、修改和取消分别建模。
- 文件只保存稳定引用,Computer Use 动作绑定画面版本,可执行产物始终按不可信代码隔离。
到这里,实现链已经走完:模型、工具、上下文、安全、状态、可靠性与界面。剩下的是工程之外最难的问题:怎样证明 Agent 真的把任务做对了,以及怎样把这套系统交到用户手里——第 21 章讲评估体系,第 22 章讲上线准备。