ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FastGPT 循环节点交互恢复修复:从快照机制到 loopRun 交互 resume 的完整剖析

FastGPT 循环节点交互恢复修复:从快照机制到 loopRun 交互 resume 的完整剖析 FastGPT 循环节点交互恢复修复从快照机制到 loopRun 交互 resume 的完整剖析【免费下载链接】FastGPTFastGPT is a knowledge-based platform built on the LLMs, offers a comprehensive suite of out-of-the-box capabilities such as data processing, RAG retrieval, and visual AI workflow orchestration, letting you easily develop and deploy complex question-answering systems without the need for extensive setup or configuration.项目地址: https://gitcode.com/GitHub_Trending/fa/FastGPT本文基于 FastGPT 仓库内的设计文档 loop-run-interactive-resume-fix.md剖析「条件/数组循环体内放交互节点formInput、userSelect用户提交后工作流被当成新请求从头重跑」这一类 bug 的根因并逐条对照仓库源码说明 Interactive 快照、resume 还原链路以及loopRunInteractive修复的最终落地形态帮助你在阅读工作流调度源码时建立可验证的调用链认知。一、问题背景交互节点放在循环体内时的三个症状FastGPT 的循环节点loopRun支持条件模式与数组模式允许在循环体内放置交互节点如formInput表单输入、userSelect用户选择。交互节点会中断整个工作流、向前端推送interactive响应等用户提交后下一次runWorkflow调用需要resume——从交互节点处继续而不是从workflowStart重跑。修复前用户提交交互内容后会出现三类症状表单循环被重置阻断性用户提交表单后再次弹出同一个表单指定回复节点从未执行循环历史永远是[]。本质是 workflow 被当成新请求从头跑了。循环变量丢失若 resume 真的触发下游节点引用「循环开始 当前循环次数 / 当前循环值」时解析为undefined。响应详情缺失若 resume 真的触发被中断那次迭代的详情树只包含 resume 之后的节点。其中症状 1 是阻断性的它意味着 resume 根本没触发症状 2、3 是在 resume 真正触发之后才会暴露的问题。修复方案因此分为阻断性修复 两个深层修复三层。二、调用链与快照机制resume 依赖三层数据理解这个 bug 的前提是先看清 FastGPT 交互恢复的三条关键链路。2.1 Interactive 冒泡与输出快照handleInteractiveResult一次runWorkflow返回前WorkflowQueue.handleInteractiveResultdispatch/index.ts#L1511-L1550会把this.data.runtimeNodes里每个 node 的outputs[i].value截图到nodeOutputs连同entryNodeIds / memoryEdges / skipNodeQueue / usageId一起包进InteractiveBasicType// packages/service/core/workflow/dispatch/index.tshandleInteractiveResult 内部 const nodeOutputs: NodeOutputItemType[] []; this.data.runtimeNodes.forEach((node) { node.outputs.forEach((output) { if (output.value) { nodeOutputs.push({ nodeId: node.nodeId, key: output.key as NodeOutputKeyEnum, value: output.value }); } }); });这份nodeOutputs快照就是 resume 时还原中断前已执行节点输出的唯一数据来源。注意if (output.value)这个判断是已知遗留问题见第六节。2.2 Top-level 还原只读当前这层 nodeOutputs聊天入口projects/app/src/pages/api/v2/chat/completions.ts在每次请求里执行三步completions.ts#L272-L282const interactive getLastInteractiveValue(newHistories) || undefined; let runtimeNodes storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes, interactive)); // ... runtimeNodes rewriteNodeOutputByHistories(runtimeNodes, interactive);getLastInteractiveValueruntime/utils.ts#L162-L229读取最后一条 AI 消息的interactive字段先用isChildInteractive(type)白名单做直接返回否则逐个匹配userSelect / userInput / agentAsk / paymentPause等具体类型。rewriteNodeOutputByHistoriesruntime/utils.ts#L408-L431按nodeId key把interactive.nodeOutputs里的值叠加回 runtimeNodes 的 outputs 上。它只会读当前这层interactive.nodeOutputs不会递归进params.childrenResponse.nodeOutputs——这一限制是问题 A 的根源。2.3 runLoopRun 的隔离cloneDeep 切断内外层共享dispatchLoopRunrunLoopRun.ts#L79-L81在循环体执行前做深拷贝// Isolate from parent so concurrent siblings dont mutate our view. let isolatedNodes cloneDeep(runtimeNodes); const isolatedEdges cloneDeep(runtimeEdges);循环体用独立的isolatedNodes执行避免污染父层。隔离是必要的并行兄弟节点互不干扰但也意味着内层runWorkflow命中交互时handleInteractiveResult截图的是isolatedNodes的 outputs含loopRunStart.currentIteration 1而外层再截图时对象是父层的runtimeNodes——这层没有循环体节点的 outputs。快照的内外层错位由此产生。三、根因分析三个问题各自的触发路径问题 0阻断性isChildInteractive 白名单漏了 loopRunInteractive修复前的白名单constants.ts只认旧版循环export const isChildInteractive (type) { if ( type childrenInteractive || type toolChildrenInteractive || type loopInteractive // 只有旧 loop没有 loopRun ) return true; return false; };而getLastInteractiveValue的逻辑是先查isChildInteractive(type)白名单做直接返回否则挨个匹配userSelect / userInput / paymentPause / agentPlanAskQuery / agentAsk等具体类型。loopRunInteractive既不在白名单里也不匹配任何具体类型结果返回undefined。连锁反应chat/completions.ts拿到interactive undefinedgetWorkflowEntryNodeIds(nodes, undefined)退化成取workflowStart / systemConfig等默认入口rewriteNodeOutputByHistories(runtimeNodes, undefined)直接原样返回 runtimeNodes无还原runWorkflow({ lastInteractive: undefined })从workflowStart重新跑一轮用户提交的表单 JSON 被当成新 query 的 message textworkflow 从头跑到 iter 1 表单再次中断。所以用户看到的提交表单 → 又弹同一个表单 →指定回复从未执行 → 循环历史为 []全因为resume 根本没触发。问题 Aresume 触发后循环变量仍丢失即使 resume 触发了loopRunStart.currentIteration依旧读不到路径是内层runWorkflow命中交互handleInteractiveResult截图的是isolatedNodes的 outputs含loopRunStart.currentIteration 1放进内层interactive.nodeOutputsrunLoopRun把内层interactiveResponse原样塞进LoopRunInteractive.params.childrenResponse外层handleInteractiveResult再截图一次但截图对象是父层runtimeNodesloopRun 节点自己用的那层外层interactive.nodeOutputs里没有loopRunStart.currentIterationresume 时chat/completions.ts只用外层nodeOutputs还原旧实现里runLoopRun的 resume 分支只设isEntry不调用rewriteNodeOutputByHistories(isolatedNodes, interactiveData.childrenResponse)也不调injectLoopRunStartisolatedNodes上loopRunStart的 outputs 全空下游指定回复/判断器通过引用解析读loopRunStartoutput →undefined。问题 B中断迭代只留下半截详情树修复前循环体命中交互时的处理是直接 breakif (response.workflowInteractiveResponse) { interactiveResponse response.workflowInteractiveResponse; break; // 跳过 pushIterationDetail }注释声称the resumed run will record it但实际上中断前已经跑完的loopRunStart / 判断器等节点 detail 在response.flowResponses里随 break 一并丢弃resume 那一轮的flowResponses只有 resume 之后的节点表单提交回填 指定回复结果同一iteration的详情树只剩后半段。两个补充说明来自设计文档已完成迭代的 wrapper 不会丢它们在中断前已经 push 进loopResponseDetail作为外层loopRunDetail的一部分写入中断响应resume 后新产生的loopRunDetail经saveChat的mergeChatResponseData按mergeSignId合并外层loopRun节点两端是 concat。丢的只是中断那一次迭代自己的 wrapper。loopHistory不受影响loopHistory含 customOutputs通过LoopRunInteractive.params.loopHistory主动透传不依赖详情树。旧版 runLoop.ts 为何恰好能用旧数组循环runLoop.ts不 cloneruntimeNodes直接透传内外层共享同一份节点引用所以外层截图也能带上循环体 outputs问题 A 恰好被绕开问题 B 方面runLoop.ts不做 per-iteration wrapper中断前的flowResponses已 push 进loopResponseDetail通过外层合并链保留。但这是恰好能用的脆弱依赖设计文档明确将其列为 follow-up不在本次修复范围。四、修复方案四处改动与当前代码落地形态修复范围锁定runLoopRun流程用户命中的是条件循环 ifo 模式runLoop.ts保留作为 follow-up。下面按设计文档的改动编号对照仓库当前代码说明最终落地形态。改动 0白名单补 loopRunInteractive阻断性必改constants.ts 当前实现export const isChildInteractive (type: InteractiveNodeResponseType[type]) { if ( type childrenInteractive || type toolChildrenInteractive || type loopInteractive || type loopRunInteractive // 新增 ) { return true; } return false; };这是最小成本、收益最大的一处getLastInteractiveValue拿到loopRunInteractive类型的 interactive 后直接返回getWorkflowEntryNodeIds与rewriteNodeOutputByHistories恢复工作resume 链路第一次真正打通。改动 1LoopRunInteractive schema 扩展持久化字段设计文档提议新增pendingIterationResponses: ChatHistoryItemResType[]用于持久化当前这次迭代、中断前已跑过的子节点响应。当前 type.ts#L77-L94 的最终形态export const LoopRunInteractiveSchema z.object({ type: z.literal(loopRunInteractive), params: z.object({ loopHistory: z.array(z.any()), childrenResponse: z.any(), iteration: z.number(), pendingIterationSummary: z.any().optional() }) });值得注意的实现演进最终字段名是pendingIterationSummary一个运行时统计摘要而非设计稿里的完整响应数组pendingIterationResponses。从 runLoopRun.ts#L105-L126 的注释可以推断其理由——保留中断前运行时摘要已完成节点集合、耗时、点数统计让 loopRun 在 resume 后仍能计算 finished nodes、子节点统计和总耗时而无需在聊天历史里留存完整子节点详情减小持久化体积。改动 2resume 前还原循环体 node outputsrunLoopRun.ts#L88-L95// On resume, the inner loop-body outputs (e.g. loopRunStart.currentIteration) were // captured into childrenResponse.nodeOutputs by the inner handleInteractiveResult. // The top-level restore in chat/completions only reads the outer nodeOutputs, which // doesnt cover the loop body — apply the inner snapshot here so downstream refs // in the resumed iteration resolve correctly. if (interactiveData?.childrenResponse) { isolatedNodes rewriteNodeOutputByHistories(isolatedNodes, interactiveData.childrenResponse); }要点还原发生在cloneDeep(runtimeNodes)之后、循环开始前落点是隔离副本不会把循环体 outputs 泄漏给外层后续兄弟节点。同时resume 分支刻意不调用injectLoopRunStart它会把loopRunStart.isEntry true导致 loopRunStart 重跑并可能把已恢复 outputs 的链条再走一遍判断器也会再跑一次造成 detail 重复。当前 runLoopRun.ts#L157-L177 的分支结构const isResumeIteration !!interactiveData iteration resumeIteration; if (isResumeIteration) { isolatedNodes.forEach((n) { if (interactiveData?.childrenResponse?.entryNodeIds.includes(n.nodeId)) { n.isEntry true; // 只把入口指向交互节点 } }); } else { injectLoopRunStart({ nodes: isolatedNodes, childrenNodeIdList, mode, item: currentItem, index: currentIndex, iteration }); }并且 resume 迭代完成后会显式清理残留的isEntry标志runLoopRun.ts#L299-L308保证后续迭代从干净状态进入。改动 3中断时保留 in-flight iteration 的进度设计稿的方案是局部数组pendingIterationResponses累积、迭代走完清空。最终实现等价地收敛为pendingIterationSummaryrunLoopRun.ts#L107 初始化、L258-L272 中断分支// 暂停时也写一次 wrapper作为本轮 child nodeResponses 的 parent恢复完成后会用同一个 // iterationResponseId 再写增量 wrapper读取时按 id/parentId 累加统计并合并 children。 if (response.workflowInteractiveResponse) { interactiveResponse response.workflowInteractiveResponse; pendingIterationSummary iterationSummary; // 累积支持同一迭代多次中断 // 交互暂停是可恢复 checkpoint需要保留暂停前已完成节点的变量和外部 output 变更。 await syncContainerRunState({ /* isolatedNodes → runtimeNodes */ }); await pushIterationDetail({}); // 中断时也 push 半截迭代的 wrapper break; }三个关键行为变化逐一解决设计文档的诉求中断分支不再跳过记录pushIterationDetail在暂停时执行半截迭代也有 wrapper问题 B 的详情树缺前半段问题消除resume 迭代通过getWrapperSummary合并统计runLoopRun.ts#L108-L126fullSummary 中断前摘要 本轮摘要供finishedNodeIds、customOutputs 快照使用wrapperSummary在 resume 迭代只写本次增量因为同一个iterationResponseId会写两条 wrapper row读取端按 id/parentId 累加写增量避免重复计费/重复统计;迭代完整走完后清空runLoopRun.ts#L302-L312 中interactiveData undefined; pendingIterationSummary undefined;resume 状态是一次性的。最终返回的 interactive payload 带上 pending 摘要runLoopRun.ts#L333-L343[DispatchNodeResponseKeyEnum.interactive]: interactiveResponse ? { type: loopRunInteractive, params: { loopHistory, childrenResponse: interactiveResponse, iteration, pendingIterationSummary } } : undefined,改动 4运行时间与用量的归集设计文档要求耗时只算本轮中断前的耗时已挂在之前那次请求里避免重复。当前实现中iterationRunningTime取自本轮Date.now() - iterationStartTimerunLoopRun.ts#L203totalPoints通过pushSubWorkflowUsage({ usagePush, response, name, iteration })只统计本轮response而详情树 wrapper 里的totalPoints使用wrapperSummary.totalPoints增量口径。assistantResponses / customFeedbacks同理只收集本轮新跑的响应。五、风险点与兼容性设计文档列出的四个风险点与当前代码的对应关系多次中断同一迭代如一个 iteration 内先formInput再userSelectpending 摘要在每轮 resume 时被读出 → 本轮再合并 → 再次中断时整包写回 interactive payload代码注释明确标注 supports multiple interrupts in the same iteration。旧聊天历史无 pending 字段interactiveData?.pendingIterationSummary走undefined兜底向前兼容。外层 mergeChatResponseData外层loopRun节点mergeSignId不变pending 只作用于 wrapper 内部统计不冲突。快照落点是 clone 后的副本rewriteNodeOutputByHistories只改isolatedNodes不会污染外层。六、已知的次要 bug本次不修handleInteractiveResult截图 outputs 时if (output.value)会丢掉0 / / false等合法值dispatch/index.ts#L1524当前代码中该判断依然存在。对数组模式currentIndex 0会有影响条件模式iteration 1不受影响。留作后续专项修复改! undefined。runLoop.ts旧数组循环依赖runtimeNodes共享引用偶然可用建议后续同步迁移到显式rewriteNodeOutputByHistories。七、小结这次修复示范了一个完整的交互恢复链路排查方法先看 resume 的触发条件getLastInteractiveValue的类型白名单再看快照的层级归属内层 isolatedNodes 截图 vs 外层 runtimeNodes 截图最后看恢复侧的还原点top-level 只读外层nodeOutputs。三个问题分别对应resume 未触发 / 循环体 outputs 未还原 / 中断迭代未记录修复也因此在四个位置收敛白名单补全、schema 持久化字段、resume 前对隔离副本做rewriteNodeOutputByHistories、中断分支保留 pending 并照常写 wrapper。对于要在 FastGPT 工作流里组合循环节点与交互节点表单、用户选择的开发者理解这条链路后也能预判自定义节点加入loopRun循环体时对交互恢复机制的要求。【免费下载链接】FastGPTFastGPT is a knowledge-based platform built on the LLMs, offers a comprehensive suite of out-of-the-box capabilities such as data processing, RAG retrieval, and visual AI workflow orchestration, letting you easily develop and deploy complex question-answering systems without the need for extensive setup or configuration.项目地址: https://gitcode.com/GitHub_Trending/fa/FastGPT创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表