运营同学点击“查询活动商品”,页面只剩一个转圈。两秒后还没结果,用户不知道是在分析条件、查询商品,还是已经失败了。
给页面加一段不断增长的文字,不一定能解决这个问题。用户需要知道的是当前做到了哪一步、哪一步出了错,以及最终结果有没有真的返回。
这一篇用 Vue 3、TypeScript 和一个本地 Node 服务做最小示例:服务端发送结构化的工具进度事件,前端逐步更新状态,最后一次性展示商品结果。 重点是事件与页面状态的对应关系,不是让文字看起来像正在生成。
模型决策、商品与工具结果全部是固定演示数据,没有调用真实模型或商品服务。源码包提供真实的本地 HTTP SSE 服务;博客上的公开演示默认只回放相同格式的字节数据。两种模式都不包含模型 token 增量。

先区分事件回放与真实 HTTP
本例有两条运行路径。直接打开 HTML,或访问博客上的演示页,使用离线回放:把预先编好的字节块依次交给解析器,观察页面变化,不发送接口请求。
下载源码并启动 Node 服务后,可以在本地页面切换到 HTTP SSE 模式。浏览器通过同源 fetch 请求进度接口,读取服务端分次写出的响应体。这条路径才能用来检查本地 HTTP 传输是否工作。
GitHub Pages 托管的是静态文件,公开演示不会在网站上启动 Node,也不会偷偷连接读者电脑上的服务。回放页面和本地服务共用演示事件来源与编码,方便对照;回放通过仍然不能证明真实网络传输通过。
查询场景固定为演示活动商品。“理解条件”也是固定流程的一部分,不表示本例已经实现自然语言理解或 Agent 规划能力。
把进度写成稳定的事件协议
页面只展示三个阶段:理解条件、查询商品、整理结果。进度数据可以表示为:
type Progress = {
stage: 'understand' | 'query' | 'compose';
status: 'running' | 'done';
label: string;
};
服务端还会发送 result 和 error:前者携带最终商品列表,后者携带演示工具失败的说明。进度标签用受控的业务词汇,不把内部提示词、完整工具原始返回或私密推理过程直接暴露给用户。
一个查询中的事件形态如下:
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,请在解压目录启动:
npm run serve
用浏览器打开 http://127.0.0.1:8087,再切换到 HTTP SSE 模式。这个 Node 服务只提供演示文件与本地进度接口,没有后台业务写操作;不能把普通静态预览服务当作 SSE 后端。
修改源码时,先按 README 安装锁定依赖,再执行类型检查、测试和构建:
npm run typecheck
npm test
npm run build
npm run test:http
本次类型检查、10 个核心测试与 1 个本地 HTTP 集成测试通过;集成测试覆盖六种传输与失败场景。浏览器分别操作了六个离线回放场景和六个本地 HTTP 场景,确认进度先于结果出现、重复 ID 不重复更新、断流不显示成功。电脑和手机视口均无整页横向溢出。验证说明保存在源码包中,公开静态页面的可用性另行核对。
接进真实 Agent 时还缺什么
把演示工具替换为受控服务后,在实际执行的边界发送进度:查询开始、查询完成或失败。不要为了安抚用户提前发送“已完成”,也不要把定时变化的文案当作真实工具执行记录。
生产环境还要检查代理缓冲、请求超时、断线恢复、账号授权和事件中的敏感字段。浏览器看到进度,不代表后台写操作可以重试;本例只有只读演示查询,没有覆盖提交订单或退款的安全流程。
这个最小案例最终解决的是一个具体问题:把漫无目的的等待,变成有依据的阶段状态。前端知道收到的是哪一步、是否完整、结果是否到齐,用户才能理解现在发生了什么。