Graph Streaming Infrastructure#
Overview#
Graph streaming in Dify controls when and how Answer nodes emit token chunks to the client during workflow execution. The infrastructure sits between the graphon engine and Dify's SSE pipeline, coordinating edge traversal order so that Answer nodes stream only after all their upstream dependencies have resolved.
Entry point: iter_dify_graph_engine_events() in api/core/workflow/workflow_entry.py wraps GraphEngine.run() with a two-filter chain: HumanInputFormEventFilter (from api/core/workflow/nodes/human_input/boundary.py) followed by ResponseStreamFilter (from graphon.filters) . HumanInputFormEventFilter transforms generic Human Input node completion events into specific NodeRunHumanInputFormFilledEvent or NodeRunHumanInputFormTimeoutEvent before ResponseStreamFilter gates token streaming. The WorkflowEntry class stores the ResponseStreamFilter instance and passes it through to the generator .
Event pipeline: graphon emits raw events β HumanInputFormEventFilter converts Human Input completion into form-filled/timeout events β ResponseStreamFilter gates token streaming and passes variable chunks as QueueTextChunkEvent β _handle_text_chunk_event() in AdvancedChatAppGenerateTaskPipeline appends tokens to WorkflowTaskState.answer β MessageCycleManager.message_to_stream_response() wraps each token as a MessageStreamResponse (SSE message event) β delivered to the client .
ResponseStreamFilter and Blocking Edge Coordination#
ResponseStreamFilter (from graphon.filters) gates Answer node streaming by maintaining a paths_map: a set of blocking edges that must be traversed before each downstream Answer node is unlocked. As edges are taken, they are removed from the map; when a path becomes empty, the node may stream.
Blocking node types: The coordinator's find_paths() function treats edges originating from nodes with execution types BRANCH, CONTAINER, and RESPONSE as blocking . PR #26364 (merged Sep 2025) added NodeExecutionType.RESPONSE to this set, preventing partial or duplicated replies when a Response node is still emitting tokens. A regression test validating Answer node emission order ships alongside this fix in PR #26377.
Variable-blocking nodes: Individual node types can declare that they block streaming for specific variable selectors via a blocks_variable_output(variable_selectors) method. PR #30832 (merged Jan 2026) added this override to VariableAssigner v1 β without it, the coordinator treated the assigner as non-blocking and could start streaming Answer nodes before a conversation variable was actually updated . The fix is covered by a unit test in api/tests/unit_tests/core/workflow/graph_engine/test_streaming_conversation_variables.py .
Complex branch-merge topologies: When multiple if-else branches converge to a shared join node β LLM β Answer, static dependency analysis can fail to model mutually exclusive branches. A hybrid strategy using _has_complex_branch_merge_topology() in graph_engine.py + an AnswerStreamProcessor for dynamic runtime path tracing addresses this .
Streaming in Loop Containers#
Each iteration of a Loop node creates a fresh child GraphEngine via _WorkflowChildEngineBuilder.build_child_engine() . This means a new ResponseStreamFilter-equivalent streaming coordinator is created for every iteration.
Stale subgraph variable bug: Before PR #30059 (merged Dec 2025), variables produced by nodes inside the loop subgraph persisted in the parent variable_pool across iterations. When a subsequent iteration's streaming coordinator found existing values in the pool, it could fall back to those stale outputs and re-stream prior-iteration content. The fix adds _clear_loop_subgraph_variables(loop_node_ids) to LoopNode._run() in api/core/workflow/nodes/loop/loop_node.py, which removes all variables produced by subgraph nodes from self.graph_runtime_state.variable_pool at the start of each iteration before the child engine is recreated .
The same PR standardized loop completion metadata: WorkflowNodeExecutionMetadataKey.COMPLETED_REASON is now set to typed LoopCompletedReason.LOOP_BREAK or LoopCompletedReason.LOOP_COMPLETED values .
Assign Variable nodes inside loops: Issue #38246 documents that Assign Variable nodes inside loops could silently fail to execute (zero execution records) due to missing parentId metadata not being set by the frontend for that node type specifically. This is a distinct problem from streaming β it prevents the node from being included in the loop's execution graph at all.
Pause/Resume State Management#
Dify workflows can pause at Human Input (HITL) nodes and resume after user form submission. Two state components must survive a pause boundary :
GraphRuntimeStateβ variable pool, LLM usage counters, execution metadataResponseStreamFilterβ streaming gate state (thepaths_mapand all associated session tracking fields)
Both are serialized by PauseStatePersistenceLayer into a WorkflowResumptionContext (persisted to the database) on every GraphRunPausedEvent. On resume, WorkflowResumptionContext.get_response_stream_filter() calls ResponseStreamFilter.loads() to restore the filter, which is then threaded as an optional parameter through WorkflowEntry.__init__ and on to iter_dify_graph_engine_events() .
Before the fix (Dify 1.15.0 / graphon v0.5.x): On resume, iter_dify_graph_engine_events() always instantiated a fresh ResponseStreamFilter() . If an upstream if-else or conditional-branch edge was traversed before the pause, that edge would not be re-emitted after resume. The new filter had no record of it, so the downstream Answer node's paths_map entry never emptied and streaming was permanently blocked. The workflow would report succeeded and node_finished/workflow_finished would contain correct output β but the frontend received no token stream and the final message was empty .
Serialized filter state includes: paths_map, active_session, waiting_sessions, pending_sessions, stream_buffers, stream_positions, closed_streams, node_execution_ids .
Backward compatibility: The persisted serialized_response_stream_filter_state field is optional. Runs paused before the fix resume with fresh-filter behavior for that one stale run .
Key Source Files and Notable Bugs#
Source Files#
| File | Role |
|---|---|
api/core/workflow/workflow_entry.py | WorkflowEntry and iter_dify_graph_engine_events() β top-level streaming entry point; registers HumanInputFormEventFilter β ResponseStreamFilter filter chain; accepts optional pre-restored ResponseStreamFilter |
api/core/workflow/nodes/human_input/boundary.py | HumanInputFormEventFilter β transforms generic Human Input node success into NodeRunHumanInputFormFilledEvent or NodeRunHumanInputFormTimeoutEvent; resolves submitted form data and timeout deadlines |
api/core/app/layers/pause_state_persist_layer.py | PauseStatePersistenceLayer β serializes GraphRuntimeState + ResponseStreamFilter on pause; constructs WorkflowResumptionContext |
api/core/app/apps/advanced_chat/generate_task_pipeline.py | AdvancedChatAppGenerateTaskPipeline β event dispatch loop; _handle_text_chunk_event(), _handle_workflow_paused_event(), _seed_task_state_from_message() |
api/core/app/task_pipeline/message_cycle_manager.py | MessageCycleManager.message_to_stream_response() β converts text chunks to SSE MessageStreamResponse |
api/core/app/entities/queue_entities.py | QueueTextChunkEvent and all other queue event types |
api/core/app/entities/task_entities.py | WorkflowTaskState, MessageStreamResponse, StreamEvent enum |
api/core/workflow/nodes/loop/loop_node.py | LoopNode._run() and _clear_loop_subgraph_variables() β stale variable cleanup per iteration |
api/core/workflow/nodes/variable_assigner/v1/node.py | blocks_variable_output() β defers streaming until conversation variable is updated |
api/tests/unit_tests/core/workflow/graph_engine/test_answer_order_workflow.py | Regression test: Answer nodes emit in correct order |
api/tests/unit_tests/core/workflow/graph_engine/test_streaming_conversation_variables.py | Regression test: streaming deferred until variable assignment completes |
Notable Bugs and Fixes#
| PR / Issue | Version | Description |
|---|---|---|
| PR #26364 | Sep 2025 | Added RESPONSE node type to blocking edges; prevents partial replies from streaming |
| PR #30059 | Dec 2025 | Clears stale loop subgraph variables each iteration to prevent re-streaming stale values |
| PR #30832 | Jan 2026 | VariableAssigner v1 now blocks streaming until conversation variable write completes |
| Issue #38432 / PR #38540 | 1.15.0 β fixed | Answer node stream blocked after HITL resume because fresh ResponseStreamFilter lost pre-pause edge traversal state |
| Issue #38246 | 1.14.2β1.15.0 | Assign Variable nodes inside loops skipped due to missing parentId metadata in frontend DSL |