大模型生成答案时,用户通常会看到文字逐字出现,而不是等待几十秒后一次性返回。这种体验来自流式输出

表面上看,服务端只是不断发送文本片段;在 Dify 这类工作流系统中,流里还要同时传递节点开始、节点结束、工具调用、错误和最终用量等事件。

因此,完整链路并不是“模型直接把字发给浏览器”,而是:

1
模型 Chunk → 统一运行时事件 → 事件通道 → 响应转换 → SSE → 浏览器状态更新

一、为什么不等答案全部生成完

假设模型首个字在 800 毫秒后生成,完整答案需要 20 秒:

  • 阻塞响应:用户约 20 秒后才看到内容;
  • 流式响应:用户约 800 毫秒后就看到第一个片段。

总耗时可能没有明显变化,但首字延迟大幅降低,用户也能确认系统仍在工作。

流式输出还允许前端实时展示:

  • 当前执行到哪个工作流节点;
  • Agent 正在调用什么工具;
  • 是否需要人工输入;
  • 哪个节点执行失败;
  • 用户能否停止本次生成。

它既是一种传输方式,也是一套运行过程协议。

二、Chunk 到底是什么

模型供应商通常返回一个可迭代的数据流,每次产生一小段增量内容。例如:

1
2
3
4
第 1 个 Chunk:"流式"
第 2 个 Chunk:"输出"
第 3 个 Chunk:"可以降低"
第 4 个 Chunk:"等待感。"

不同供应商的原始格式并不相同:有的把文本放在 delta.content,有的还会分段返回工具参数、思考内容或用量信息。

Dify 的模型层会先把这些差异转换成统一的响应实体。上层工作流不需要针对每家供应商重新实现流式解析,只需要处理规范化后的增量事件。

三、文本片段只是事件的一种

工作流执行包含一系列有顺序的状态变化。Dify 的流式协议中常见事件包括:

事件 含义
workflow_started 工作流开始
node_started 某个节点开始执行
text_chunk 产生一段可展示文本
node_finished 节点成功、失败或停止
workflow_paused 工作流等待恢复
workflow_finished 工作流到达最终状态
error 执行或传输出现错误
ping 保持连接活跃

其中 text_chunk 可以带上 from_variable_selector,说明文本来自哪个节点的哪个变量。前端因此不仅能拼接文字,还能把输出关联到工作流画布。

一条简化事件可能是:

1
2
3
4
5
6
7
8
9
{
"event": "text_chunk",
"task_id": "task-123",
"workflow_run_id": "run-456",
"data": {
"text": "你好",
"from_variable_selector": ["llm-node", "text"]
}
}

四、为什么中间还需要事件通道

简单应用可以在 API 进程中调用模型,并直接 yield 文本。但工作流可能在另一个线程甚至 Celery Worker 中执行,HTTP 连接却由 API 进程持有。

生产者和消费者不在同一个执行单元时,就需要中间事件通道:

  • 执行器把运行时事件发布出去;
  • API 侧订阅本次任务的事件;
  • 响应生成器逐条读取并返回给客户端。

Dify 中既有基于应用队列管理器的进程内生产消费,也有工作流跨进程执行时使用的 Redis 事件通道。具体路径随应用模式和执行方式而不同,但职责相同:把“产生事件”和“发送 HTTP 响应”解耦。

flowchart LR
    A[模型供应商] -->|原始 Chunk| B[模型响应适配器]
    B -->|统一增量| C[工作流 / Agent 执行器]
    C -->|运行时事件| D[队列或 Redis 事件通道]
    D --> E[Generate Task Pipeline]
    E --> F[Response Converter]
    F -->|SSE| G[浏览器]
    G --> H[拼接文本]
    G --> I[更新节点状态]
    G --> J[记录 task_id]

五、响应转换器解决什么问题

内部事件适合程序处理,却不一定适合直接暴露给 API 用户。响应转换器负责把内部对象变成稳定的公开结构,例如:

  • 添加 task_idmessage_id 和时间;
  • 把不同异常转换为统一错误事件;
  • 根据接口类型隐藏过多的节点细节;
  • 把结束事件补充为最终用量和元数据;
  • 把心跳对象转换为 ping

这一层很重要。否则内部类名或字段一调整,所有前端和 API 客户端都要跟着修改。

六、SSE 是怎样传输事件的

SSE 全称 Server-Sent Events,是服务端通过一个持续的 HTTP 响应向客户端单向推送文本事件。

Dify 的流式块通常以 data: 开头,并用两个换行符分隔:

1
2
3
4
5
6
data: {"event":"text_chunk","data":{"text":"你"}}

data: {"event":"text_chunk","data":{"text":"好"}}

data: {"event":"workflow_finished","data":{"status":"succeeded"}}

服务端常设置:

1
2
Content-Type: text/event-stream
Cache-Control: no-cache

SSE 很适合模型输出,因为数据主要是服务端单向流向浏览器,协议简单,也能继续使用普通 HTTP 鉴权和网关。

如果客户端需要高频双向通信,WebSocket 可能更合适;仅用于答案增量推送时,SSE 通常已经足够。

七、浏览器不能假设一次读取就是一个事件

网络传输没有义务保持应用层边界。一次读取可能只得到半个 JSON,也可能同时得到多条 SSE 事件。

因此客户端要维护缓冲区:

1
2
3
4
5
读取字节
→ 追加到 buffer
→ 按空行切出完整事件
→ 解析 data
→ 保留末尾不完整部分

Dify Web 端会解析流中的 event 字段,再分别触发文本、工作流、节点、Agent 思考和消息结束等回调。

常见错误是对每个网络 Chunk 直接执行 JSON.parse。它在本地测试中可能偶尔成功,上线后却会随机报 JSON 不完整。

八、心跳解决“安静但未结束”

工作流可能在调用工具或等待模型时长时间没有文本输出。中间代理容易把这种安静连接判断为空闲并关闭。

服务端可以定期发送 ping

1
2
event: ping

心跳主要用于:

  • 告诉网关连接仍然活跃;
  • 让客户端区分“暂时无输出”和“连接已经断开”;
  • 更快发现断连。

心跳不是业务进度,也不代表工作流一定健康。客户端收到心跳时不应把它拼进答案。

九、代理缓冲为什么会破坏流式体验

应用已经逐条 yield,浏览器仍可能几秒钟才收到一大段内容,常见原因是中间层在缓冲:

  • Nginx 聚合小响应;
  • CDN 不及时转发;
  • 压缩模块等待更多内容;
  • 应用服务器自身存在缓冲;
  • 客户端 SDK 一次性读取完整响应。

排查时可以从最靠近服务端的位置使用 curl -N 观察,再逐层经过网关和 CDN。若源站实时、经过代理后成批出现,问题通常不在模型层。

生产环境还应确认代理的读取超时大于最长生成时间,并关闭不适合 SSE 的响应缓冲。

十、慢客户端会带来背压

模型生成很快,但客户端网络很慢时,未发送数据会不断堆积。这就是背压问题。

可采用的保护手段包括:

  • 限制单个事件和缓冲区大小;
  • 合并过于细碎的文本 Chunk;
  • 对事件通道设置容量或过期时间;
  • 客户端断开后尽快取消订阅;
  • 对超长输出设置 Token 和时间上限。

不能简单地无限缓存,因为一个长期不读取的连接就可能持续占用内存。

事件合并也需要权衡:片段越小,首字体验越好,但系统调用和网络开销越高;片段过大,又会失去“实时出现”的感觉。

十一、断开连接不等于任务已经停止

用户关闭页面时,HTTP 连接会断开,但后台 Worker 可能仍在调用模型或执行工具。

系统需要明确两种语义:

  • 停止接收:客户端不再读取,任务可能继续完成;
  • 停止生成:通过 task_id 主动请求取消任务。

Dify 的事件中携带 task_id,客户端可以用它调用停止响应接口。执行器还需要在节点之间检查停止标记,并把最终状态记录为 stopped 或相应状态。

取消通常是协作式的,而不是瞬间杀死线程。正在进行的外部 HTTP 调用能否立刻中断,取决于供应商 SDK 和执行阶段。

十二、最终事件与数据库状态谁更可信

流式连接可能在 workflow_finished 到达前断开,但工作流仍然成功。因此 UI 不应把“没有收到最终事件”直接解释为执行失败。

较稳妥的设计是:

  1. 流式事件负责实时体验;
  2. 数据库运行记录保存最终事实;
  3. 断线后通过运行 ID 查询最终状态;
  4. 错误事件和数据库状态使用同一套状态语义。
sequenceDiagram
    participant U as 浏览器
    participant A as API / SSE
    participant W as Worker
    participant D as 数据库
    U->>A: 发起流式请求
    A->>W: 投递执行任务
    W-->>A: workflow_started
    W-->>A: text_chunk...
    A-->>U: SSE 事件
    U-xA: 网络断开
    W->>D: 保存最终状态
    U->>A: 根据 run_id 查询
    A->>D: 读取运行记录
    D-->>U: succeeded / failed / stopped

普通 SSE 本身不会自动补发所有历史业务事件。如果需要断点续传,就要额外保存事件序号和历史流,并设计重放边界,不能只依赖一条临时连接。

十三、如何测试流式接口

除了检查文字能否逐步显示,还应覆盖:

  1. 首个事件和首个文本片段是否及时到达;
  2. 半包、粘包和包含多字节中文时能否正确解析;
  3. 节点开始、结束和工作流结束的顺序是否合理;
  4. 模型、工具和工作流异常是否变成结构化错误事件;
  5. 长时间无文本时心跳是否有效;
  6. 客户端断开后是否释放订阅与缓冲;
  7. 主动停止后是否记录正确状态;
  8. 经过真实 Nginx 或 CDN 后是否仍然实时。

流式问题往往只在并发、弱网和真实代理链路中出现,因此不能只测试本机浏览器中的一次正常请求。

十四、总结

Dify 的流式输出不是一个简单的字符串生成器,而是一条事件驱动链路:

1
2
3
4
5
6
7
适配模型增量
→ 生成工作流事件
→ 跨线程或跨进程传输
→ 转换为稳定的公开协议
→ 用 SSE 持续发送
→ 前端按事件更新界面
→ 数据库保存最终事实

把这几层分开后,模型供应商、执行引擎和前端协议可以独立演进;而心跳、背压、断连和最终状态这些边界问题,也能在正确的层次上得到处理。