返回文章列表
前端架构Streaming UIAgent安全可观测

流式输出 UI 设计:让用户看到“进展”,而不是泄露“思考过程”

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

2026年3月4日
Synthly 团队
预计阅读 13 分钟
流式输出界面:分段更新、状态提示与安全展示的组合示意图

📷 Photo by Daniil Komov via Pexels

这篇文章解决的不是“流式怎么做”,而是“流式该展示什么”

做流式输出最容易陷入一个误区:

  • 你想让用户“看到思考过程”,觉得更可信
  • 于是把模型的中间推理/草稿也流出来
  • 最后发现:越解释越乱,还可能泄露系统提示词与工具细节

正确目标应该是:

  • 用户能感到系统在“推进任务”(progress)
  • 用户能在关键节点“介入/取消/确认”(control)
  • 系统不暴露内部策略与敏感信息(safety)

如果你在做 Agent 产品,建议先建立最小工程基线:


一、把 token stream 升级为 event stream

1)token stream 的三类天然缺陷

  1. 只有“字在变多”,没有“状态”
  • 用户不知道你是在检索、在等工具、在重试,还是卡死
  1. 错误无法解释
  • 超时/429 只能用一段文字糊弄,用户不知道是否需要重试
  1. 无法支持“可控交互”
  • 取消、暂停、确认、重放,都需要状态机而不是纯文本

2)event stream 的核心:统一的事件协议

把 UI 可见的一切变成事件(event),而不是文本拼接。

最低限度,你需要这些事件类型:

  • MESSAGE_DELTA:模型输出增量(安全文本)
  • STEP_STATUS:步骤开始/完成/失败
  • TOOL_CALL:工具调用开始(仅摘要)
  • TOOL_RESULT:工具回执摘要(脱敏)
  • ERROR:错误类型 + 是否会自动重试
  • DONE:任务完成

这能让你的 UI 从“打字机”进化为“任务控制台”。


二、什么叫“看到进展但不泄密”:三条红线

红线 1:永远不要把系统提示词流出来

系统提示词是你的产品策略与安全边界,泄露后会导致:

  • 被提示注入(Prompt Injection)更容易绕过
  • 被复制你的策略(竞争层面)
  • 暴露内部工具与权限信息(安全层面)

所以 UI 侧要有硬规则:任何包含 systemdeveloper 或内部策略字段的内容都不进入用户可见流。

红线 2:不要流式展示“未确认的工具参数”

很多工具参数是敏感的:

  • 邮箱/手机号/地址
  • 搜索关键词(可能包含隐私)
  • 内部资源 ID、token

正确做法:

  • 用户可见:调用工具:发送邮件(收件人:***@xx.com,主题:…)
  • 内部日志:完整参数(脱敏后)

红线 3:不要把“推理草稿”当作可解释性

推理草稿(尤其是长 CoT)存在两个问题:

  • 不稳定:同一问题每次不一样
  • 不可验证:用户无法确认其真伪

你应该展示“可验证证据”:工具回执、引用来源、可点击的中间产物(例如草稿、表格)。


三、UX 结构:把一次回答拆成“可操作的阶段”

建议把一次响应拆成 4 段:

  1. 目标确认:你正在做什么(可选)
  2. 执行阶段:步骤列表 + 状态(running/succeeded/failed)
  3. 产物阶段:草稿/表格/链接等中间产物
  4. 最终输出:用户可复制的结论

其中第 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 更合适。但无论选哪种,关键都在“事件模型”而不是连接类型。

展示“步骤”会不会显得不够智能?

恰恰相反。用户更在意可控与可解释:知道系统在做什么、能不能停、出错会怎样。步骤是可验证的解释,而不是不可验证的推理。

想看更多工程化文章见 /articles,也可以在 /apps/new 体验 Agent 能力。