本文使用脱敏后的日志片段和通用场景描述。文中的次数是对特定请求日志的统计,不代表框架存在固定的溢出阈值。
一、事故概览
2026 年 10 月 21 日,一条 Agent 请求看起来一直在正常工作:模型做判断,工具返回结果,框架保存进度,然后模型继续做判断。它没有在某次工具调用时立即失败,却在运行约 2 分 42 秒后突然停在半路,没有正常返回最终答案和结束事件。日志里出现了 java.lang.StackOverflowError,随后是反复出现的 Reactor Operator called default onErrorDropped。
可以把旧执行器想成一个不断转接电话的人:每做完一步,就说"下一步请找后面那个人",却没有结束前面的转接。等到最后要取一个结果,请求得穿过越来越长的转接链。这次故障的直接原因正是类似的结构:图执行器每完成一个节点,就通过 concatWith(Flux.defer(...)) 将下一节点接到当前 Reactor 流上。循环次数增多后,订阅者之间的 request() 调用链越来越深,最终耗尽执行线程的调用栈。
这里的"栈"是线程调用栈,不是模型上下文,也不是 JVM 堆内存。现有证据可以确认:这条请求未正常收尾,旧版图执行链存在长循环栈溢出问题。日志不能证明整个应用进程退出,也不能证明其他请求全部不可用。一次线程栈溢出通常首先影响所在请求;如果错误处理和资源回收不完整,还可能表现为该请求持续等待或占用资源。服务整体可用性需要结合进程、线程池、健康检查和其他请求日志单独判断。
二、怎样从日志定位到图执行链
1. 先还原单条请求的时间线
排查时先按同一链路标识筛出请求日志,再保留原始完整日志用于核对堆栈。下表的行号分别来自内部留存的单链路日志和完整日志;公开稿不展示链路标识、服务环境或业务提问。
| 时间 | 观察到的事件 | 证据位置 | 含义 |
|---|---|---|---|
| 19:11:25.622 | 问答开始 | 单链路日志第 2 行 | 进入 Agent 执行流程。 |
| 19:11:25 至 19:14:07 | 模型、工具、图节点持续执行,并多次写入检查点 | 单链路日志第 4 至 2235 行 | 请求不是一开始就失败,而是在较长循环后失败。 |
| 19:14:07.960 左右 | 首次记录 Operator called default onErrorDropped |
单链路日志第 2236 行;完整日志第 8579 行 | Reactor 报告异常未按正常下游错误路径处理。 |
| 19:14:07.971 | java.lang.StackOverflowError |
完整日志第 8580 行 | 明确是线程调用栈溢出。 |
| 随后 | 相同错误及 throwIfFatal 警告重复出现 |
单链路日志第 2237 行及以后 | 错误没有形成正常的问答完成过程。 |
首次错误之前,单链路日志记录了 57 次"模型调用开始" 、290 次"检查点写入完成"。这两个数字用于描述故障前的执行量,并不是"第 58 次模型调用"或"第 291 次检查点"必然触发溢出。
"检查点写入"可以理解为保存进度:框架把当前图状态存起来,以便后续执行或恢复。它可能发生在模型、工具、钩子等不同节点之后。因此,检查点次数既不是模型调用次数,也不是 JVM 栈帧数量或内存使用量;这里只把它作为长流程的辅助指标。
2. 看堆栈重复的是哪一类代码
完整日志第 8580 至 8589 行的关键形态如下,已去掉环境、实例和链路信息:
text
java.lang.StackOverflowError
at reactor.core.publisher.FluxConcatArray$ConcatArraySubscriber.request(...)
at reactor.core.publisher.MonoFlatMapMany$FlatMapManyMain.request(...)
at reactor.core.publisher.Operators$MultiSubscriptionSubscriber.request(...)
at reactor.core.publisher.FluxConcatArray$ConcatArraySubscriber.request(...)
at reactor.core.publisher.MonoFlatMapMany$FlatMapManyMain.request(...)
... 相同的 request 调用帧反复出现 ...
堆栈顶部不是某条业务 SQL 或某个工具实现,而是 Reactor 操作符的 request() 连续转发。这给出了排查方向:检查图执行器如何拼接节点流,以及每完成一个节点后是否会在原有流外再包一层操作符。
Flux.defer 只保证"下一步流"到订阅时才创建;它本身不会拆掉前面已经搭好的转接链。下游继续请求数据时,request() 仍需穿过一层层 concatWith,链路足够长时就会触发栈溢出。更棘手的是,这次异常出现在 onErrorDropped 日志中,没有变成一次正常的 Agent 错误收尾;因此用户看到的可能是"还在等",而不是明确的失败答复。
3. 排查步骤,可在类似事故中复用
- 限定同一请求:按链路标识提取日志,避免把其他请求的模型、工具和错误事件算进去。
- 找到首个错误 :定位首次
onErrorDropped、StackOverflowError和throwIfFatal,只统计首次错误之前的执行量。 - 识别重复栈帧 :若
FluxConcatArray、MonoFlatMapMany、MultiSubscriptionSubscriber的request()成组反复出现,重点检查 Reactor 流的递归拼接。 - 回到源码找"下一步" :检查节点执行结束后的
concatWith、defer和再次调用主执行器的位置,同时检查开始节点、普通节点、流式节点与结束节点。 - 用无外部依赖的长循环复现:排除模型服务、工具、网络和业务数据干扰,确认图执行机制本身能否承受大量节点切换。
- 验证业务链路收尾:除了没有异常,还要检查是否发出最终答案与结束事件,避免"没报错但一直等待"。
三、源码层面的根因
图执行入口由 CompiledGraph 创建 GraphRunner;GraphRunner 调用 MainGraphExecutor,后者根据当前节点进入 NodeExecutor。Agent 的一次模型调用、一次工具执行或一次钩子处理,都可能形成图上的一次或多次节点推进。
text
Agent 请求 → CompiledGraph → GraphRunner → MainGraphExecutor
→ NodeExecutor → 模型 / 工具 / 钩子节点
→ 决定下一节点 → MainGraphExecutor
修复前:每个节点把下一步接到当前流后面
以普通节点处理为例,原版 NodeExecutor 中的关键代码可简化为:
java
return Flux.just(GraphResponse.of(output))
.concatWith(Flux.defer(() -> mainGraphExecutor.execute(context, resultValue)));
MainGraphExecutor 的开始节点和结束节点,以及 NodeExecutor 的流式、并行等路径,也存在同类"执行完当前部分后,再用 concatWith 接入下一次主执行"的写法。一次调用看起来很短;Agent 反复在模型与工具节点之间切换时,新的流会沿前一次的流继续拼接。旧结构可概括为:
text
节点 1 的流
└─ concatWith(节点 2 的流)
└─ concatWith(节点 3 的流)
└─ concatWith(节点 4 的流)
└─ ...
当订阅者继续请求下一个元素时,调用沿嵌套的操作符层层传递。日志中重复的 request() 栈帧正是这一结构在运行期的表现。问题与模型"思考了多少字"没有直接的固定关系;真正增加风险的是图节点持续推进形成的订阅链深度。
四、上游 GitHub 如何发现并修复
Spring AI Alibaba 上游已经有人指出同类问题,并提供了图执行器级别的补丁。PR 的原标题是 "fix(graph): replace recursive step chaining with iterative expansion to prevent StackOverflowError" ,直译就是"用迭代展开替换递归的步骤串接,防止栈溢出"。这与事故日志里重复的 request() 栈帧正好对上:
| 上游资料 | 值得看的内容 |
|---|---|
| Issue #4594:多轮对话栈溢出 | 报告者提出多轮对话会发生栈溢出;Issue 后来由 PR #4828 关闭。Issue 正文没有完整复现步骤,不能把本文的事故细节归到原报告者名下。 |
| Issue 中的源码分析评论 | 评论指出 MainGraphExecutor.handleStartNode 与 NodeExecutor.handleActionResult 的 concatWith(Flux.defer(...)) 会让 execute 间接调用自身;也提醒默认迭代上限较小时,问题可能被限制条件遮住。 |
| PR #4828:完整修复说明 | PR 作者将根因归纳为每执行一个节点就增加一层操作符,并列出重复的 Reactor request() 栈帧。该 PR 于 2026 年 7 月 25 日合并。 |
| PR 的文件差异 | 可直接查看 GraphResponse、GraphRunner、两个执行器及回归测试如何一起修改。 |
| 上游修复提交 | 固定到该 PR 的公开补丁提交,便于后续对照,避免 main 分支持续变化造成代码行号漂移。 |
| 上游 3000 次自循环测试源码 | 展示一个不依赖真实模型和工具的最小长循环测试。 |
上游 PR 的描述还给出了一个很有辨识度的结果:作者报告旧实现的 3000 次自循环会在 StackOverflowError 被 onErrorDropped 记录后一直等到超时;换成延续标记和 expandDeep 后约 0.4 秒完成。这属于上游 PR 作者的复现记录。本文自己的证据是事故日志、修复版 3000 次回归测试,以及下面三次真实请求验证,不将上游计时当作本地实测。
值得注意的是,上游讨论曾提到默认迭代上限可能让问题不易出现。这解释了为什么短请求通常正常:当流程足够长、迭代限制允许继续推进时,底层链式结构才会暴露。把迭代上限调低可以减少触发机会,却会提前截断合法长任务;真正的修复仍要处理图执行链本身。
五、修复:把"下一步"交给统一驱动器展开
本次采用 Spring AI Alibaba Graph Core 1.1.2.0 源码作为基线,将上游 PR #4828 的修复回移到独立图核心模块。迁移时先固定原版基线,再单独引入补丁,便于核对哪些代码来自上游、哪些地方因基线差异作了适配。
这里的 1.1.2.0 是本次回移修复的源码基线;本文没有列出原事故环境的完整依赖清单,也不据此推断其他发布版本是否受影响。判断自身环境时,应对照实际依赖版本和执行器实现。
修复的重点是改变图执行链的组织方式:各执行器先输出当前结果,再交出一张内部"下一步交接卡";GraphRunner 收到卡片后,在同一个展开算子里启动下一步。前一步不需要继续背着后面所有步骤运行。
1. GraphResponse:表示"还有下一步"
GraphResponse 增加一个惰性延续函数。continueWith(...) 创建的对象只在框架内部传递;transient 和 @JsonIgnore 避免将这个函数作为图状态序列化。
java
@JsonIgnore
transient Supplier<Flux<GraphResponse<E>>> continuation;
public static <E> GraphResponse<E> continueWith(
Supplier<Flux<GraphResponse<E>>> continuation) {
GraphResponse<E> response = new GraphResponse<>(null, null);
response.continuation = continuation;
return response;
}
public boolean hasContinuation() {
return continuation != null;
}
需要特别注意:延续标记的 output 为 null,但它不是业务上的"图已完成"。因此必须先识别并过滤标记,不能让下游按普通 GraphResponse 处理。
2. MainGraphExecutor 与 NodeExecutor:先输出结果,再输出标记
普通节点处理改为:先完成状态合并、路由选择、检查点写入和节点结束监听,再按顺序发出当前节点结果与延续标记。
java
NodeOutput output = context.buildNodeOutputAndAddCheckpoint(updateState);
context.doListeners(NODE_AFTER, null);
return Flux.just(
GraphResponse.of(output),
GraphResponse.continueWith(() -> mainGraphExecutor.execute(context, resultValue))
);
开始节点、结束节点以及流式、并行等需要推进到下一节点的路径,也替换为输出延续标记。对于流式路径,原有 concatWith(updateContextMono.thenMany(...)) 仍用于保持"先处理当前流、再更新上下文"的顺序;变化在于尾部接入的是一个标记对象 ,不再把下一次 mainGraphExecutor.execute(...) 返回的整条流递归接进去。
3. GraphRunner:统一展开并过滤内部标记
java
return mainGraphExecutor.execute(context, resultValue)
.expandDeep(response -> response.hasContinuation()
? Flux.defer(() -> response.getContinuation().get())
: Flux.empty())
.filter(response -> !response.hasContinuation());
expandDeep 遇到延续标记时才调用其函数,获取下一步的流,并持续展开;普通响应不再生成子流。最后的 filter 不让内部交接卡进入 Agent 对外的数据流。这样保留原有的节点输出顺序和状态推进时机,同时避免每执行一个节点就增加一层长期存在的订阅链。上游 PR 特别指出,它处理了两个容易漏掉的细节:流式路径仍先完成 updateContextMono,内部标记也不会改变 graphResponseStream / stream 的外部输出。
想对照原始补丁,可以从固定提交中的 GraphResponse、GraphRunner、MainGraphExecutor 和 NodeExecutor 依次阅读。不同版本的源码行号可能不同,建议按类名和方法名对照。
六、如何复现和验证
1. 真实长流程:说明原问题如何被触发
原始触发条件是一次请求在较长时间内持续进行"模型决策 → 工具执行 → 模型继续决策",使图节点反复推进。故障前已经有 57 次模型调用开始和 290 次进度保存;随后出现上述 Reactor 栈帧和 StackOverflowError,请求没有正常结束。
让模型"调用工具 70 次"可以作为测试环境里的压力场景,但它不保证真的形成 70 次节点推进:模型可能提前回答、拒绝请求,或者一轮调用多个工具。验证时必须以实际日志中的模型、工具和图进度事件为准,也必须检查最终结束事件。
2. 最小复现与回归测试:只测图执行器
DeepLoopStackDepthTest 构造一个 worker 节点:每次把 count 加一;当 count < 3000 时回到自身,否则进入 END。测试把最大迭代次数设置为 3010,用 90 秒超时防止旧版流不终止,并断言最终 count == 3000。它不需要模型服务、工具服务或业务数据库。
java
private static final int ITERATIONS = 3000;
// worker 每次更新 count;条件边决定继续循环还是结束。
CompiledGraph app = graph.compile();
app.setMaxIterations(ITERATIONS + 10);
OverAllState finalState = app.stream(Map.of("count", 0))
.map(NodeOutput::state)
.blockLast(Duration.ofSeconds(90));
assertEquals(ITERATIONS, finalState.value("count").get());
在包含该图核心模块的 Maven 多模块工程根目录,可通过模块坐标运行该测试;具体模块路径按实际工程布局填写:
bash
mvn -pl :spring-ai-alibaba-graph-core -am \
-Dtest=DeepLoopStackDepthTest \
-Dsurefire.failIfNoSpecifiedTests=false test
本次修复版的 3000 次循环测试已通过。旧版的递归拼接结构预期会在足够长的循环中出现栈溢出或无法正常终止;本文用于确认旧版故障的直接证据是生产事故日志,没有把未留存的"旧版 3000 次测试失败"写成已执行结果。修复版测试证明图核心长循环可以正常终止,但它不替代真实 Agent 的 SSE 收尾测试。
3. 三次真实请求的修复后验证
下表只保留技术统计,不展示业务提问或用户信息。模型调用"开始/完成"与工具调用按日志事件计数,耗时为各次请求整体耗时;"进度保存"指 Checkpoint写入完成 事件。
| 验证请求 | 模型调用开始/完成 | 工具调用开始 | 进度保存 | 整体耗时 | 收尾 |
|---|---|---|---|---|---|
| 第一次 | 42 / 42 | 50 | 216 | 2 分 37.528 秒 | 答案与 done 均已发送 |
| 第二次 | 49 / 49 | 62 | 251 | 2 分 58.194 秒 | 答案与 done 均已发送 |
| 第三次 | 83 / 84 | 105 | 424 | 5 分 21.504 秒 | 答案与 done 均已发送 |
前两次执行量低于事故前记录,主要说明常规流程没有被修复破坏。第三次按较少的"模型调用开始"83 次 比较,仍超过事故前的 57 次;424 次进度保存也超过事故前的 290 次。第三次没有观察到 StackOverflowError 或 onErrorDropped,且正常发送答案和 done,因此是针对原问题更有力的验证。
第三次"开始"比"完成"少一条,可能与日志采集有关,不能据此推断模型实际多调用了一次。该次还记录了 11 次工具调用失败、2 次模型限流重试,以及过程事件达到单轮 300 条保存上限的警告;这些没有阻止请求结束,但属于需要分别分析的行为。
七、结论与边界
结论: 原事故的错误特征、旧版 concatWith(Flux.defer(...)) 图执行代码、回移后的 expandDeep 机制,以及 3000 次独立循环和超过事故前执行量的真实请求结果,共同支持"这条图执行链导致的长循环栈溢出已得到修复"。
边界: 三次业务请求不能证明所有输入、所有图分支、所有运行环境都不会再发生任何栈溢出;也不能证明回答内容准确、全部过程事件都已完整持久化,或服务长期资源消耗符合预期。发布后仍应关注长请求的结束率、超时、线程池占用与 StackOverflowError / onErrorDropped 日志。
此外,覆盖率工具分析内置多版本 JAR 时出现的重复类错误属于构建期 JaCoCo 问题 ,与这里的运行期 Reactor 调用栈溢出不是同一故障。排查时应按错误发生阶段和堆栈分别处理。
一页排查清单
text
锁定单条请求与首个错误
→ 查看是否重复出现 Reactor request() 栈帧
→ 查找节点流中的递归 concatWith / defer 拼接
→ 用无模型的长循环测试隔离图执行器
→ 修复后检查长流程、最终答案与 done
→ 持续观察超时、错误率和资源占用