Agent 正在做什么:把工具调用过程展示成可理解的进度

运营同学点击“查询活动商品”,页面只剩一个转圈。两秒后还没结果,用户不知道是在分析条件、查询商品,还是已经失败了。

给页面加一段不断增长的文字,不一定能解决这个问题。用户需要知道的是当前做到了哪一步、哪一步出了错,以及最终结果有没有真的返回。

这一篇用 Vue 3、TypeScript 和一个本地 Node 服务做最小示例:服务端发送结构化的工具进度事件,前端逐步更新状态,最后一次性展示商品结果。 重点是事件与页面状态的对应关系,不是让文字看起来像正在生成。

模型决策、商品与工具结果全部是固定演示数据,没有调用真实模型或商品服务。源码包提供真实的本地 HTTP SSE 服务;博客上的公开演示默认只回放相同格式的字节数据。两种模式都不包含模型 token 增量。

机器人把完整的进度事件从工具箱送到显示屏,拆开的消息先拼完整,再更新当前步骤。

先区分事件回放与真实 HTTP

本例有两条运行路径。直接打开 HTML,或访问博客上的演示页,使用离线回放:把预先编好的字节块依次交给解析器,观察页面变化,不发送接口请求。

下载源码并启动 Node 服务后,可以在本地页面切换到 HTTP SSE 模式。浏览器通过同源 fetch 请求进度接口,读取服务端分次写出的响应体。这条路径才能用来检查本地 HTTP 传输是否工作。

GitHub Pages 托管的是静态文件,公开演示不会在网站上启动 Node,也不会偷偷连接读者电脑上的服务。回放页面和本地服务共用演示事件来源与编码,方便对照;回放通过仍然不能证明真实网络传输通过。

查询场景固定为演示活动商品。“理解条件”也是固定流程的一部分,不表示本例已经实现自然语言理解或 Agent 规划能力。

把进度写成稳定的事件协议

页面只展示三个阶段:理解条件、查询商品、整理结果。进度数据可以表示为:

typescript
type Progress = {
  stage: 'understand' | 'query' | 'compose';
  status: 'running' | 'done';
  label: string;
};

服务端还会发送 result 和 error:前者携带最终商品列表,后者携带演示工具失败的说明。进度标签用受控的业务词汇,不把内部提示词、完整工具原始返回或私密推理过程直接暴露给用户。

一个查询中的事件形态如下:

Text
id: 2
event: progress
data: {"stage":"query","status":"running","label":"正在查询演示商品"}

事件流使用 UTF-8,完整事件通过空行分隔。前端取得一个完整事件块后,再解释类型和数据。标准还规定,文件在事件中途结束时,未完成事件不能被当作已收到的事件派发。HTML 标准中的事件流解析

本例要求每条业务事件有 ID,用于一轮执行内去重。这个 ID 只帮助避免同一条演示消息重复更新页面,没有实现持久化投递、断点续传或“恰好执行一次”的业务保证。

一次 read 不等于一条事件

最容易出错的写法,是每次读取响应体后直接执行 JSON.parse。读取到的可能只有半条事件,也可能是两三条事件连在一起;一个中文字符的 UTF-8 字节还可能被分到相邻读取结果中。

因此前端需要先做两件事:持续解码字节,以及保留尚未拼完的文本。解码器使用流式模式,事件解析器则跨读取保留缓冲;遇到完整分隔符后才取出一条事件,剩下的继续等待。

这一条读取路径中,reader.read() 提供的是字节块,不是业务 JSON。MDN 的示例也展示了使用 TextDecoder 流式解码并保存剩余文本的方式。读取响应体与增量解码

解析出事件以后,还要校验字段:阶段名是否支持、状态是否合法、标签是否为限定长度的字符串、最终商品列表是否符合约定。解析器能够拼完整文本,不代表里面的数据已经可以直接显示。

本例实现的是服务所需的精简 SSE 解析路径:支持 LF、CRLF、注释、多行 data,以及本例的数字 ID 和三类事件;不支持裸 CR 等完整标准语义。README 说明了这些范围。它没有代替浏览器完整的 EventSource 功能,也没有实现自动重连和 Last-Event-ID 恢复。

服务端写出事件,前端核对终态

Node 示例接口设置 Content-Type: text/event-stream,逐段写出事件并在结束时关闭响应。正常、工具失败和重复事件场景按迭代顺序生成事件:先写出查询中,再读取本地固定商品数据,随后发送查询完成或错误。拆分、合并和截断场景使用预生成的传输夹具,专门测试解析边界。短定时等待只是便于观察接收,不是模型或真实商品服务的运行耗时。

Node 的 response.write() 可以分次写入响应体,但浏览器最后怎样收到这些字节,还会受到网络与中间层影响。不要把一次服务端写入等同于一次浏览器读取,更不能靠一次偶然的分块结果判断解析器正确。Node HTTP 响应写入说明

前端的成功条件是收到并校验最终结果。连接关闭只说明数据流结束,不足以证明查询成功:如果一直没收到有效 result,页面需要显示“结果未完整返回”,而不是把转圈换成绿色成功图标。

遇到错误事件,也不能继续沿用上一次的商品列表。每次开始与重置都会清理旧结果,避免用户把旧卡片误认为本次查询的结果。下一篇再单独讨论用户主动停止和旧响应晚到的处理。

把事件映射成用户看得懂的步骤

同一个阶段会经历查询中、已完成等状态。前端按阶段更新已有步骤,不把每一次状态变化都新增成一条相同标题。重复事件 ID 也应被忽略,否则用户可能看到两次“查询完成”。

当查询还在进行时,页面显示正在查询;最终商品结果通过校验后,才展示结果区域。这里没有百分比,因为本例并不知道真实剩余工作量。把几个步骤等分成 33%、66%、100%,反而可能给用户错误预期。

这也不是 token 流式输出。进度事件是一条条完整的结构化消息,商品结果最后整体返回。即使网络连续传输,也不应写成“模型逐 token 输出了答案”。

状态文案按普通文本渲染,页面布局由固定 Vue 组件负责。来源不明的事件类型和无效字段不能成为动态模板、脚本或任意链接。

让网络边界变成可重复的测试

演示提供六种场景,分别观察事件在不同字节组织与失败方式下的表现:

场景 应看到的行为
正常传输 步骤推进,最后展示商品结果
事件拆成多段 拼完整后处理,中文和事件内容不损坏
多事件合在一段 逐条处理,不能只保留其中一条
重复事件 ID 不重复应用同一事件
查询工具失败 显示错误,不展示成功结果
流在事件中途结束 不把半条消息算成功,提示结果不完整

单元测试可以人为选择字节切分位置,强制把中文字符拆开,或一次送入多个事件。真实 HTTP 测试则检查响应类型、事件内容与终态,不能要求操作系统必须保留测试指定的网络块边界。

输入也需要有界:限制单次执行的总字节、缓冲与事件数量,避免异常连接持续占用内存。这里只验证本地协议与页面边界,没有评估真实模型或生产反向代理的行为。

运行两种模式并核对结果

打开进度事件回放演示 · 下载完整源码与本地 Node 服务

直接解压打开 index.html 即可离线回放,Vue 已打包,不需要 API 密钥或 CDN。若要验证 HTTP SSE,请在解压目录启动:

Bash
npm run serve

用浏览器打开 http://127.0.0.1:8087,再切换到 HTTP SSE 模式。这个 Node 服务只提供演示文件与本地进度接口,没有后台业务写操作;不能把普通静态预览服务当作 SSE 后端。

修改源码时,先按 README 安装锁定依赖,再执行类型检查、测试和构建:

Bash
npm run typecheck
npm test
npm run build
npm run test:http

本次类型检查、10 个核心测试与 1 个本地 HTTP 集成测试通过;集成测试覆盖六种传输与失败场景。浏览器分别操作了六个离线回放场景和六个本地 HTTP 场景,确认进度先于结果出现、重复 ID 不重复更新、断流不显示成功。电脑和手机视口均无整页横向溢出。验证说明保存在源码包中,公开静态页面的可用性另行核对。

接进真实 Agent 时还缺什么

把演示工具替换为受控服务后,在实际执行的边界发送进度:查询开始、查询完成或失败。不要为了安抚用户提前发送“已完成”,也不要把定时变化的文案当作真实工具执行记录。

生产环境还要检查代理缓冲、请求超时、断线恢复、账号授权和事件中的敏感字段。浏览器看到进度,不代表后台写操作可以重试;本例只有只读演示查询,没有覆盖提交订单或退款的安全流程。

这个最小案例最终解决的是一个具体问题:把漫无目的的等待,变成有依据的阶段状态。前端知道收到的是哪一步、是否完整、结果是否到齐,用户才能理解现在发生了什么。

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2022-2026 alan_mf
  • 全站访客— 累计浏览— 统计加载中…

请我喝杯咖啡吧~

支付宝收款码 支付宝
微信收款码 微信