大模型生成答案时,用户通常会看到文字逐字出现,而不是等待几十秒后一次性返回。这种体验来自流式输出。
表面上看,服务端只是不断发送文本片段;在 Dify 这类工作流系统中,流里还要同时传递节点开始、节点结束、工具调用、错误和最终用量等事件。
因此,完整链路并不是“模型直接把字发给浏览器”,而是:
1 | 模型 Chunk → 统一运行时事件 → 事件通道 → 响应转换 → SSE → 浏览器状态更新 |
一、为什么不等答案全部生成完
假设模型首个字在 800 毫秒后生成,完整答案需要 20 秒:
- 阻塞响应:用户约 20 秒后才看到内容;
- 流式响应:用户约 800 毫秒后就看到第一个片段。
总耗时可能没有明显变化,但首字延迟大幅降低,用户也能确认系统仍在工作。
流式输出还允许前端实时展示:
- 当前执行到哪个工作流节点;
- Agent 正在调用什么工具;
- 是否需要人工输入;
- 哪个节点执行失败;
- 用户能否停止本次生成。
它既是一种传输方式,也是一套运行过程协议。
二、Chunk 到底是什么
模型供应商通常返回一个可迭代的数据流,每次产生一小段增量内容。例如:
1 | 第 1 个 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 | { |
四、为什么中间还需要事件通道
简单应用可以在 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_id、message_id和时间; - 把不同异常转换为统一错误事件;
- 根据接口类型隐藏过多的节点细节;
- 把结束事件补充为最终用量和元数据;
- 把心跳对象转换为
ping。
这一层很重要。否则内部类名或字段一调整,所有前端和 API 客户端都要跟着修改。
六、SSE 是怎样传输事件的
SSE 全称 Server-Sent Events,是服务端通过一个持续的 HTTP 响应向客户端单向推送文本事件。
Dify 的流式块通常以 data: 开头,并用两个换行符分隔:
1 | data: {"event":"text_chunk","data":{"text":"你"}} |
服务端常设置:
1 | Content-Type: text/event-stream |
SSE 很适合模型输出,因为数据主要是服务端单向流向浏览器,协议简单,也能继续使用普通 HTTP 鉴权和网关。
如果客户端需要高频双向通信,WebSocket 可能更合适;仅用于答案增量推送时,SSE 通常已经足够。
七、浏览器不能假设一次读取就是一个事件
网络传输没有义务保持应用层边界。一次读取可能只得到半个 JSON,也可能同时得到多条 SSE 事件。
因此客户端要维护缓冲区:
1 | 读取字节 |
Dify Web 端会解析流中的 event 字段,再分别触发文本、工作流、节点、Agent 思考和消息结束等回调。
常见错误是对每个网络 Chunk 直接执行 JSON.parse。它在本地测试中可能偶尔成功,上线后却会随机报 JSON 不完整。
八、心跳解决“安静但未结束”
工作流可能在调用工具或等待模型时长时间没有文本输出。中间代理容易把这种安静连接判断为空闲并关闭。
服务端可以定期发送 ping:
1 | 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 不应把“没有收到最终事件”直接解释为执行失败。
较稳妥的设计是:
- 流式事件负责实时体验;
- 数据库运行记录保存最终事实;
- 断线后通过运行 ID 查询最终状态;
- 错误事件和数据库状态使用同一套状态语义。
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 本身不会自动补发所有历史业务事件。如果需要断点续传,就要额外保存事件序号和历史流,并设计重放边界,不能只依赖一条临时连接。
十三、如何测试流式接口
除了检查文字能否逐步显示,还应覆盖:
- 首个事件和首个文本片段是否及时到达;
- 半包、粘包和包含多字节中文时能否正确解析;
- 节点开始、结束和工作流结束的顺序是否合理;
- 模型、工具和工作流异常是否变成结构化错误事件;
- 长时间无文本时心跳是否有效;
- 客户端断开后是否释放订阅与缓冲;
- 主动停止后是否记录正确状态;
- 经过真实 Nginx 或 CDN 后是否仍然实时。
流式问题往往只在并发、弱网和真实代理链路中出现,因此不能只测试本机浏览器中的一次正常请求。
十四、总结
Dify 的流式输出不是一个简单的字符串生成器,而是一条事件驱动链路:
1 | 适配模型增量 |
把这几层分开后,模型供应商、执行引擎和前端协议可以独立演进;而心跳、背压、断连和最终状态这些边界问题,也能在正确的层次上得到处理。