流式输出 UI 设计:让用户看到“进展”,而不是泄露“思考过程”
流式输出能显著提升聊天式产品的体感速度,但做不好就会暴露系统提示词、泄漏工具参数,甚至诱发提示注入。本文从工程与 UX 视角给出可落地方案:把 token stream 升级为 event stream,设计中间态、可取消与可重放;同时用红线规则把“可解释”与“可泄密”分开。

📷 Photo by Daniil Komov via Pexels
这篇文章解决的不是“流式怎么做”,而是“流式该展示什么”
做流式输出最容易陷入一个误区:
- 你想让用户“看到思考过程”,觉得更可信
- 于是把模型的中间推理/草稿也流出来
- 最后发现:越解释越乱,还可能泄露系统提示词与工具细节
正确目标应该是:
- 用户能感到系统在“推进任务”(progress)
- 用户能在关键节点“介入/取消/确认”(control)
- 系统不暴露内部策略与敏感信息(safety)
如果你在做 Agent 产品,建议先建立最小工程基线:
一、把 token stream 升级为 event stream
1)token stream 的三类天然缺陷
- 只有“字在变多”,没有“状态”
- 用户不知道你是在检索、在等工具、在重试,还是卡死
- 错误无法解释
- 超时/429 只能用一段文字糊弄,用户不知道是否需要重试
- 无法支持“可控交互”
- 取消、暂停、确认、重放,都需要状态机而不是纯文本
2)event stream 的核心:统一的事件协议
把 UI 可见的一切变成事件(event),而不是文本拼接。
最低限度,你需要这些事件类型:
MESSAGE_DELTA:模型输出增量(安全文本)STEP_STATUS:步骤开始/完成/失败TOOL_CALL:工具调用开始(仅摘要)TOOL_RESULT:工具回执摘要(脱敏)ERROR:错误类型 + 是否会自动重试DONE:任务完成
这能让你的 UI 从“打字机”进化为“任务控制台”。
二、什么叫“看到进展但不泄密”:三条红线
红线 1:永远不要把系统提示词流出来
系统提示词是你的产品策略与安全边界,泄露后会导致:
- 被提示注入(Prompt Injection)更容易绕过
- 被复制你的策略(竞争层面)
- 暴露内部工具与权限信息(安全层面)
所以 UI 侧要有硬规则:任何包含 system、developer 或内部策略字段的内容都不进入用户可见流。
红线 2:不要流式展示“未确认的工具参数”
很多工具参数是敏感的:
- 邮箱/手机号/地址
- 搜索关键词(可能包含隐私)
- 内部资源 ID、token
正确做法:
- 用户可见:
调用工具:发送邮件(收件人:***@xx.com,主题:…) - 内部日志:完整参数(脱敏后)
红线 3:不要把“推理草稿”当作可解释性
推理草稿(尤其是长 CoT)存在两个问题:
- 不稳定:同一问题每次不一样
- 不可验证:用户无法确认其真伪
你应该展示“可验证证据”:工具回执、引用来源、可点击的中间产物(例如草稿、表格)。
三、UX 结构:把一次回答拆成“可操作的阶段”
建议把一次响应拆成 4 段:
- 目标确认:你正在做什么(可选)
- 执行阶段:步骤列表 + 状态(running/succeeded/failed)
- 产物阶段:草稿/表格/链接等中间产物
- 最终输出:用户可复制的结论
其中第 2 段是流式体验的核心:它让用户“看到推进”,并给出中断点。
1)“步骤视图”比“思考视图”更靠谱
展示:
- 步骤名(简短)
- 当前状态
- 耗时
- 可选的回执摘要
不要展示:
- 内部策略
- 原始工具参数
- 模型推理草稿
2)关键节点的交互:取消、重试、确认
你至少要提供:
Cancel:结束任务(前端发取消请求,后端停止工具/释放锁)Retry:仅对“可恢复错误”的步骤重试Confirm:高风险写操作前的人类确认(HITL)
四、前后端实现:SSE 事件流的最小可行方案
下面给一个“可落地、可扩展”的最小协议示例。
1)SSE 事件格式(后端)
event: step
data: {"stepId":"retrieve","status":"running","ts":1719840000}
event: message
data: {"delta":"我正在检索相关资料…"}
event: tool
data: {"tool":"kb.search","status":"started"}
event: tool
data: {"tool":"kb.search","status":"succeeded","summary":"命中 3 篇文档"}
event: message
data: {"delta":"\n\n下面是整理后的结论:"}
event: done
data: {"ok":true}
要点:
- 事件类型固定(便于前端 switch)
- 工具事件只发摘要(summary),细节写日志
2)前端消费:把事件写进 store,而不是直接拼 DOM
在 Nuxt/Vue 里,建议把事件先落到统一 store(例如 Pinia),再由 UI 渲染派生视图。
伪代码:
type StreamEvent =
| { type: 'message'; delta: string }
| { type: 'step'; stepId: string; status: 'running' | 'succeeded' | 'failed'; ts: number }
| { type: 'tool'; tool: string; status: 'started' | 'succeeded' | 'failed'; summary?: string }
| { type: 'done'; ok: boolean };
function applyEvent(state: ChatRunState, e: StreamEvent) {
switch (e.type) {
case 'message':
state.answerText += e.delta;
break;
case 'step':
state.steps[e.stepId] = { ...state.steps[e.stepId], ...e };
break;
case 'tool':
state.tools.push(e);
break;
case 'done':
state.status = e.ok ? 'done' : 'failed';
break;
}
}
为什么要进 store?因为你迟早需要:
- 断线重连与补事件
- 重放(debug/replay)
- 并发子步骤的状态聚合
五、断线、重连与“补流”:流式系统必踩的坑
1)断线不可避免
移动网络、代理、浏览器休眠都会打断连接。解决思路:
- 每条事件都有单调递增
seq - 客户端记录最后
seq - 重连时带上
Last-Event-ID或 query 参数
2)幂等与去重
事件可能重复到达。前端要能根据 seq 去重,后端也要能按 runId + seq 保证一致。
3)“先出结果,后补细节”的体验策略
对长任务,最好的体验往往是:
- 先给一个可读的阶段性产物(例如草稿)
- 后台继续补充
- UI 以事件形式更新
这比“等到最后一次性吐”更稳定。
六、上线 Checklist(安全 + 体验)
- 事件协议:message/step/tool/error/done 最小集合
- 安全红线:不展示系统提示词、不展示敏感工具参数、不展示推理草稿
- 步骤视图:状态 + 耗时 + 回执摘要(可验证)
- 交互控制:取消/重试/确认(HITL)
- 断线重连:seq + 去重 + 补流
- 可观测:前端埋点(连接断开、重连次数、p95 首字延迟)
常见问题
我应该选 SSE 还是 WebSocket?
如果主要需求是“服务端向客户端单向推送事件”,SSE 更简单,部署与调试成本低;如果需要双向交互、高并发聊天室或多路复用,WebSocket 更合适。但无论选哪种,关键都在“事件模型”而不是连接类型。
展示“步骤”会不会显得不够智能?
恰恰相反。用户更在意可控与可解释:知道系统在做什么、能不能停、出错会怎样。步骤是可验证的解释,而不是不可验证的推理。