深入解析Dify工作流Answer节点的流式输出机制

发现问题

当某一个节点有并行输出的时候会发生什么?

串行回复

混乱输出

当我加上第三条分支,此时的输出就很混乱了,按理来说最后输出的第二波输出反而先结束了,调用LLM结果的第一波输出最后才输出,这是为什么?

当我把LLM节点改为一个不耗时的节点的时候:

猜测:

  1. 开始节点
  2. 启动三个线程并行执行三个支线的代码
    1. 线程1:LLM节点阻塞等待返回
    2. 线程2:在直接回复节点1中,遇到引用变量LLM节点的结果,阻塞等待返回
    3. 线程3:开始-代码执行-直接回复,一路通畅,无任何阻塞,所以最先执行完毕

问题

  1. 推测与界面展示的并行ABC线所画出来的结果并不一致

前置设定

设定三条路线的节点代称名字如下

执行节点顺序为:a b2 c b3 d b1 c d e

工作流节点调度执行逻辑

  1. 顺序执行,判断节点是否为分支节点
    1. 如果不是分支节点,则正常执行节点,获取节点边指向的目标节点(也就是下一个节点)
    2. 如果是分支节点,则进入分支执行逻辑,并且获取分支的汇集节点作为下一个节点,赋值为next_node_id

因此我们可以判断,e节点是最后的汇集节点,所以e节点只会执行一次。

关键事件

QueueNodeStartdEvent

通过QueueNodeStartdEvent触发,然后通过id存储到全局的一个字典中,执行过程中更新这个字典,最终通过id拿到obj返回response

QueueTextChunkEvent

通过QueueTextChunkEvent事件,返回message类型消息,给前端展示:

问题:QueueTextChunkEvent和直接回复节点的执行时间不一致,这是为啥?

哪里publish了 QueueTextChunkEvent ?

NodeRunStreamChunkEvent

NodeRunStreamChunkEvent

在 AnswerStreamProcessor._generate_stream_outputs_when_node_finished 接口中会推送此方法,从名字可以看出,只要有节点执行完毕了就会调用此方法,而此方法就是控制前端回复的方法核心所在。

关键代码解析

节点静态数据存在 self.generate_routes.answer_generate_route[answer_node_id] 中,answer_node_id是节点id,不是节点实例ID,一些关键的数据结构如下

_generate_stream_outputs_when_node_finished作用

  • _generate_stream_outputs_when_node_finished 会在“任何节点”完成后被调用,不依赖 AnswerNode._run 是否已执行。

  • 它的作用是:只要满足依赖条件,就提前把该 Answer 节点配置的文本/变量片段以流式块发出去;真正的 AnswerNode._run 是在节点本身运行时才一次性返回最终 outputs,他的output在工作流运行详情中可以看到。

为什么会输出混乱

  • GraphEngine.run() 把底层事件交给 AnswerStreamProcessor.process 处理;每当接收到任意节点的 NodeRunSucceededEvent,就会触发一次“生成流式输出”的尝试:
def _generate_stream_outputs_when_node_finished(...):
    for answer_node_id, position in self.route_position.items():
        # 关键判定:当前完成的节点不是该 answer 节点时,也允许生成
        if event.route_node_state.node_id != answer_node_id and (
            answer_node_id not in self.rest_node_ids
            or not all(dep_id not in self.rest_node_ids
                       for dep_id in self.generate_routes.answer_dependencies[answer_node_id])
        ):
            continue

        # 满足条件 → 顺序吐出该 Answer 的后续 route chunks(文本或变量)
        for route_chunk in route_chunks:
            if route_chunk.type == TEXT:
                yield NodeRunStreamChunkEvent(chunk_content=route_chunk.text,
                                              from_variable_selector=[answer_node_id, "answer"])
            else:
                value = self.variable_pool.get(route_chunk.value_selector)
                if value:
                    yield NodeRunStreamChunkEvent(chunk_content=value.markdown, ...)
            self.route_position[answer_node_id] += 1
  • 判定逻辑含义:

    • 如果“当前完成的就是这个 Answer 节点”,直接生成对应流式块;

    • 否则,只要“这个 Answer 的所有依赖节点都已经完成”(answer_dependencies 都不在 rest_node_ids 里),也可以提前生成该 Answer 的配置片段(包括静态文本和已经可用的变量值)。

  • 因此,Answer 的“静态文案”或“来自已完成上游的变量值”可以在 AnswerNode._run 之前就流出去。

AnswerNode._run 本身做了什么

  • 运行时只是把配置的文本与变量值按顺序拼接成最终字符串并一次性返回,可以在详情中看到:
def _run(self) -> NodeRunResult:
    generate_routes = AnswerStreamGeneratorRouter.extract_generate_route_from_node_data(self.node_data)
    answer, files = "", []
    for part in generate_routes:
        if part.type == VAR:
            variable = self.graph_runtime_state.variable_pool.get(part.value_selector)
            if variable:
                ...  # files 收集
                answer += variable.markdown
        else:  # TEXT
            answer += part.text
    return NodeRunResult(status=SUCCEEDED, outputs={"answer": answer, "files": files})
  • 也就是说:前面的“流式分块”是由 AnswerStreamProcessor 按依赖推进的;AnswerNode._run 则在节点实际执行时一次性产出完整 outputs,随后会发 node_finished 事件。

这意味着什么

  • 某些 Answer 的固定文案(如“第二波回复开始”)或已就绪的变量(如上游 LLM 的 text)会在上游节点一完成就被路由器提前推给前端。

  • 等到真正轮到该 AnswerNode 执行时,它再一次性返回最终 outputs,前端会收到该节点的 node_finished 事件(包含完整结果)。前面的流式块不会被“覆盖”,而是累积显示;“替换”只会发生在命中输出审核时。

现象解释

节点执行顺序如下:a b2 c b3 d b1 c d e

分支执行大致顺序(不严谨但是便于理解):路径2、路径3、路径1

分支2:检查到c节点的前置依赖执行完毕,尝试流式返回,输出内容:第一次回复 随后尝试输出LLM.txt的时候上图1内容判空导致退出

路径3:在b3节点执行完毕后,前置依赖执行完毕,尝试流式返回,因为b3节点执行已经有结果了,所以e节点的内容会一次性全部输出完毕。输出内容为:

路径1:等待路径1的LLM节点执行完毕后,再次触发了NodeRunSucceededEvent(BaseNodeEvent),继续判断,因为上图2的记录位置,所以输出直接从LLM.txt块进行,输出内容为:

总结

  1. 为什么在node.run返回结果之前,前端就显示结果了?
    1. 因为每当接收到任意节点的 NodeRunSucceededEvent,就会触发一次“生成流式输出”的尝试,尝试返回直接回复节点中的静态数据
  2. 为什么直接回复1节点只执行了一次?
    1. 直接回复静态内容只有一份,所以只执行一次,通过类变量dict[node_id, vals]来控制渲染输出,并且记录当前的执行下标Position来控制只渲染一次
  3. 为什么直接回复2节点的内容先显示结果?
    1. 因为直接回复1的节点需要等待LLM节点执行完毕、而直接回复节点2无需等待,所以可以直接先显示结果。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值