



开场:三个预测,三个全错
上一课看的是事件怎么流,这一课看循环怎么停。结论先摆桌上:ReAct 循环有三种收场------正常收束、打满降级、用户中断,前两种都以 RUN_FINISHED 结束,RUN_ERROR 只留给流上真正的 Throwable。结束事件只报"流崩没崩",不报"任务成没成"。
这话不是从文档抄来的,是我拿三个全错的预测换的。给 workbench 里的 ReActAgent 把 maxIters 改成 1,模型换成 fake:fake。fake:fake 是一条免 key 的假模型腿,专门把"模型"这个变量摘出去,单看框架自己的动作。然后发一句"现在几点"。发车前我在笔记里写下三行预测:
我理解的:P1、RUN_ERROR。P2不会;P3RUN_ERROR 。
(P2 押的是那句"时间查询完成"的文本回答不会出现。)实测 0/3:12 条事件,RUN_FINISHED 收场,全程 0 ERROR。三发全偏,还偏在同一个地方------我以为打满等于崩,实际它体面收场、照交答案。
边界先画清:这是框架层怎么收场的事。收场长什么样是框架代码定的(字节码为证);模型和任务决定你会不会走到检查点,不改变走到之后的收法。顺带把"打满"说死:maxIters 份的常规推理轮数用完了,模型还想开新一轮,被检查点拦下------满的是轮数预算。至于打满为什么不算错,往下几节全是它。
循环怎么转:引擎、上限与三个入口
先看引擎本体。官方文档给这个循环的定义只有一句:"the model reasons, calls tools and uses their results to continue until it can respond"------模型推理、调工具、拿结果接着推,直到它能作答。拆开是两拍:Reasoning(推理拍,把历史发给模型,模型要么交最终回答,要么交工具意图),Acting(执行拍,框架把工具跑掉,结果写回历史)。两拍交替,一圈算一轮。上一课抓的那 44 条事件序列,其实就是这两拍转了两圈的完整快照------当时看热闹,这一课看门道。
检查点在每轮 Reasoning 的入口:iter >= maxIters 就转 summarizing(总结收尾),不抛异常。字节码里这趟逻辑长在 executeIteration(iter) → reasoning(iter) 的入口上,转圈的活由 ReActAgent$CallExecution 这个内部类管。"入口"这个位置是后面一连串现象的总开关:循环是在推理开始前被拦下的,不是工具跑到半道被掐死,实验一里某条 warn 为什么缺席也由它解释。
maxIters 数的是什么,我一开始就理解偏了。它数常规推理轮数,是一份 per-run 预算,每次 call 从 0 起算;不是工具个数,也不是模型调用总次数------打满后那次 summary 收尾调用不占额度。这个数法实验一就会给反例(下一节跑出来再验)。默认值 10:官方参数表一行 "maxIters | int | 10 | Max iterations of the ReAct main loop",javap 反编译里对着一句 bipush 10,文档和字节码两处互证。还有个细节:模型哪轮不要工具了,循环立即收束,没有"再推一轮"的宽限------所以一个 run 实际转几圈是模型说了算,maxIters 只负责封顶。
同一个循环,官方开了三个入口。call(...):"Run the reasoning-acting loop and return Mono<Msg>"------跑完只交最终 Msg;streamEvents(...):"Same loop, but emits AgentEvents incrementally"------过程和结果都在流里,workbench 的两条接入腿走的就是它;observe(...):"Append messages to context without triggering reasoning",即往上下文里追加消息、不触发推理,多 agent 的深用在后面的子代理课。差别只在事件流向谁、要不要最终 Msg。Mono 和 Flux 是 Reactor 的响应式类型:前者≈单个最终值,后者≈增量事件流。workbench 两条接入腿都挂在 streamEvents 上:AG-UI 面板吃事件流,OpenAI 兼容端点也吃事件流------同一个循环,两种吃法。
把出口画成图,就是这张三收场地图:圈体上 Reasoning⇄Acting 转圈,开三个口------模型交出最终回答,正常收束;推理入口 iter 打满,转 summarizing 降级;用户中断(interrupt,本篇不展开)。前两个口都汇进 RUN_FINISHED。RUN_ERROR 不在圈上,它挂在圈外:流上抛了 Throwable 才轮到它出场。

并发语义顺手交代,因为多用户共享一台引擎安不安全,是写 Java 的都会问的一句。官方三句原话摆齐:"Sharing an Agent instance is also supported. The execution engine is stateless, with session state addressed by identity."------agent 实例无状态,状态放在按 (userId, sessionId) 寻址的会话空间里;"the execution gate serializes direct calls for the same user/session while different sessions can run concurrently"------同一会话自动串行,不同会话完全并行。后面还跟半句边界:"stateless does not mean lifecycle-free",无状态不等于没有生命周期,stateStore 和冲突策略归后面的课。workbench 那道"单一共享工厂、每请求新实例"的配置题,答案就在这三句里:实例可以共享,因为无状态;会话不会串,因为寻址加串行门。
最后一句丑话:官方 builder 参数表里的 reactConfig/modelConfig 聚合参数,本地 2.0.3 的 Builder 上不存在(ReactConfig 这个 record 在包里,没接线;重试和备用模型拆成了独立的 maxRetries/fallbackModel)。文档描述能力面,源码才是当前版本的契约------本课所有机制结论,口径都是 javap 2.0.3。
实验一:12 条标本过堂
设定先交代,这是老师给的作业题:maxIters(1),模型 fake:fake,问题就一句"现在几点",外加一个查时间的工具。选假腿不光为免 key------真模型的脾气会把水搅浑,这趟要看的是框架自己的动作,变量越少越好。问题挑得也小有讲究:"现在几点"足够小,模型一轮就想到用工具,绝无分叉,查完必收工------两头钉死,中间那道检查点就是唯一的看点。按这个设计,第一轮该看到工具调用,第二轮就该撞上检查点。
笔记里那三行预测,展开是:P1 押结束事件 RUN_ERROR;P2 押"现在几点"的文本回答不会再出现;P3 是我跟标准答案抬杠的一问:标准说 RUN_FINISHED,我甩过去一句「P3应该是 RUN_ERROR 。」,老师拿字节码四条证据维持 RUN_FINISHED------谁对谁错,跑一遍见分晓。三行押的是两个直觉:"打满=异常终止"管 P1/P3,"打满=没答案"管 P2。顺带说一句预测的写法:必须落成具体选项,不许写"看情况"------有出入的那条才是金子,这是后来两次实验一起攒下的教训。
发车。12 条事件,折叠骨架如下(同类连续事件并成一行,只画有戏的段落,条数口径 12):
TOOL_CALL_START → TOOL_CALL_END ← 第一轮:模型要工具
TOOL_CALL_RESULT ← 工具结果写回历史
RAW(EXCEED_MAX_ITERS) ← 第二轮推理入口被拦
REASONING + TEXT ← summary 收尾:"时间查询完成: 2026-09-29 14:39:32"
RUN_FINISHED ← 我赌输的收场
逐段读。第一段,工具全套 START/END/RESULT 走得干干净净,正常。第二段,等来的不是 REASONING,是一条 RAW------预想中的异常没来,来了件没人见过的马甲,下一节开箱。第三段,REASONING 和 TEXT 照样来了:P2 错,问题有回答,还是句体面话,把工具查到的时间报了出来。最后 RUN_FINISHED,P1、P3 一起错。12 条标本判完,进历史的结论一句话:打满不是错误路径,是正常分支------实验二还要把这条路再走一遍。
面板看完看日志,预测、面板、日志三方对账:1 条 DEBUG,文本 "Maximum iterations reached. Generating summary...",时间戳比 RAW 载荷里的 createdAt 还早 1ms------框架内部先知道,事件随后才到,两边是同一件事。0 条 warn,0 条 ERROR。从序列数模型调用:第一轮推理加收尾摘要,两次------数法对上了,按"调用次数"就乱,按"推理轮数"才对。账单事件(CUSTOM,token_usage)一条也没有------fake 这条腿不报 usage,整条事件不发,第 1 课的结论复核成立。
对完账,两个直觉点名枪毙:「打满=异常终止」错在把循环当会 throw 的代码块;「打满=没答案」错在以为没轮次就没产出。拿结果去对答案,老师给 0/3 的判词是:"这是本次作业最好的结果。"全错比全对值钱------错的每一条,都指着一个我以为理所当然、其实没验证过的假设。这两条直觉从此记进我的错误清单,清单还有第三条,到实验二自己跳出来。
RAW:一件没人认领的马甲
那条 RAW 单独开箱。载荷按面板原样逐字抄:
{type:"EXCEED_MAX_ITERS", maxIters:1, currentIter:1, createdAt:"2026-09-29T06:39:32Z"}
type 自报家门:EXCEED_MAX_ITERS,打满事件。maxIters 和 currentIter 一个 1 一个 1,预算与耗尽对上号。createdAt 是 UTC,换算过来就是本地 14:39:32。还有个细节指给你看:replyId 是空串------这事件没挂到任何一轮回复上,像没登记的访客。
它为什么顶着 RAW 的帽子出场?先说转换器是干嘛的:框架内部事件五花八门,面板只认标准事件类型,谁翻成什么,转换器链上各管一段。我把这条链清点了一遍------12 个转换器,挨个问,没有一个是接 EXCEED_MAX_ITERS 的。RUN_STARTED、RUN_FINISHED 有生命周期专属翻译,token_usage 有自己的 CUSTOM 通道,唯独打满事件无人认领,最后被链尾的 RawAgentEventConverter 兜底:把原事件对象原样塞进载荷发了出去。
"马甲"说到这就收:内容一个字没改,只是没穿上框架备好的制服;机制上就是"没有专属转换器,走了原始兜底"。这件马甲替框架说了句实话------打满在它眼里不是错误路径,连专属翻译器都没混上。所以整齐的事件类型清单里看不见它,面板安静,不等于没发生。后来我看面板多了个习惯:冒出 RAW 开头的事件,先怀疑"有内部事件没被翻译",回去数转换器,而不是当噪音略过。
打满之后,框架干了四件事
RAW 只是框架那套动作里被面板递到我眼前的一件。字节码走读 summarizing(),打满那一刻它按顺序干四件事。
第一步,extractPendingToolCalls:把没执行完的工具调用捞出来。第二步,给烂尾调用补 error 版 ToolResultBlock 写回历史------补的这份带着错误标记,模型下轮读到就知道那次调用没成,比悄悄抹掉记录诚实。
这一步里藏着一条消息不变量:每条 ToolUseBlock 必须配对一条 ToolResultBlock,历史才合法、才能进下一轮请求。这是协议层的硬规矩,一条悬空的 ToolUse 混进请求,历史就不合格;纯字符串拼接式的协议根本表达不出这种配对关系。第 1 课说块的类型化是两腿差异的根------这条配对不变量就是根上最硬的一枝:字符串根本表达不出"这条意图必须有人认领"。
第三步,发 ExceedMaxItersEvent,就是上一节那件 RAW 马甲。第四步,发起 summary 收尾模型调用,末尾还挂一个 onErrorResume:摘要这步要是也失败,吞掉,兜成一条兜底 Msg 照样往前走。顺序有讲究:先收拾现场(捞烂尾、补配对),再广播事件,最后收尾------历史不先补合法,后面两步都没根基。

对照实验一,四步里的第二步当时没活干:唯一的工具调用早就配好对,pending 列表是空的,所以 0 warn------"pending tool calls" 那条 warn 只在真有烂尾调用时才出场。烂尾调用怎么来的?说实话两次实验都没给我实例:掐断发生在推理入口,入口前那轮的意图早就配好了对。这条 warn 的真身,留到后面课碰到权限拒绝那类收场再认------先记住它只在"历史里躺着没配对的意图"时出场。
四步看完,设计偏好也露出来了:能变成结果喂回模型的,就不让它变成异常。工具异常包成 ToolResultBlock.error,烂尾调用补 error 配对,摘要失败吞成兜底 Msg------一层层往下兜。兜不住的只剩一类:模型调用本身失败,它没法变成"结果"喂给模型,因为问题恰恰出在模型那一步。这条线索先埋着,讲 RUN_ERROR 那节挖。
实验二:真实模型,三条账单
实验一的毛病不用老师挑,我自己先看出来了:maxIters(1) 配单工具,模型一轮就把工具用完、第二轮直接被拦------这种场景只能验证"拦得住",验证不了"中途掐断"长什么样。对答案时我把这层怀疑甩了出去,反考了老师一句(原话照录,一个字没动):
进阶你这个maxIters,结合实际代码是不是不行啊?你这个场景,就是调用一次tools?能不能搞个稍微复杂的场景,能多调用几次的,我觉得才能测的出来。进阶。
这话戳的不是一个点:maxIters(1) 这个配置合不合理是一层;单工具场景太薄、测不出"中途"的形状是一层;要能多调几次、看循环被掐断时交什么答卷,又是一层。三层都对,实验推倒重搭:三条货代工具串成严格的数据依赖链------query_vessel_schedule 查船期,拿船期里的航次去 query_voyage_cargo 调舱单,再拿舱单第一项货物的编号去 query_cargo_detail 查详情。任务句是钩着的:MSC AURORA 是船名,第一个航次 V-2609A 得从船期结果里拿,舱单第一项货物 C-001 得从舱单结果里拿------前一步不出数,后一步没得调,工具没法并行抢跑,循环的形状就被数据依赖钉死了。正常走完要 3 轮工具调用加 1 轮收尾,maxIters 设 2,正好掐在中途。
模型这回动真格:DeepSeek,走 workbench 的 OpenAI 兼容端点。改代码重启是老师动手,跑还是我来,分工原话:"那你帮我修改代码,重启服务,我来跑。"发给模型的任务句只有一句:
帮我查一下 MSC AURORA 的船期,把它第一个航次的舱单调出来,再把舱单里第一项货物的详情查给我。
发车前照例先写预测:「1、2条;2、2组;3、不会。4、RUN_FINISHED。」展开:第 1 条押账单 2 条------两轮常规推理嘛;第 2 条押工具调用配对出现 2 组;第 3 条押货物详情不会出现在最终回答里;第 4 条押 RUN_FINISHED,吃一堑长一智。
这次数的是 SSE(Server-Sent Events,服务端推送帧)帧数:全程 409 条。SSE 流会把回答切成一帧一帧,原文整贴没有意义,口径交底:409 指帧数,下面骨架里折叠后的行是逻辑条目,别拿帧数去凑行数。骨架:
R1 REASONING → TOOL_CALL_*(query_vessel_schedule,船期到手)
R2 REASONING → TOOL_CALL_*(query_voyage_cargo,舱单到手)
RAW(EXCEED_MAX_ITERS:maxIters:2, currentIter:2)
REASONING + TEXT(summary 收尾)
RUN_FINISHED
逐段读:两轮工具调用都完整走完;第三轮推理入口被拦------第三次工具调用从未发起,流里没有第三个 TOOL_CALL_*,query_cargo_detail 压根没出门。然后 RAW、summary、RUN_FINISHED,跟实验一同一条路。日志里同样没有 "pending tool calls" 的 warn,理由上一节刚讲过。R1、R2 两轮里模型的表现也顺带看一眼:每轮只干一件事,调一个工具,拿到数就停,等下一轮------ReAct 的"一拍一事",在这条链上看得最清楚。
账单又栽了。CUSTOM 出来三条,不是我押的两条。按轮次摆开:

表后点评。input 从 613 涨到 748,涨的是历史:第一轮的工具调用和结果进了上下文。R2 的 cached 512 是缓存命中------前缀原样复用才会命中,按前缀缓存的一般行为推测,命中的是系统提示加前一轮历史那截没动的部分。output 61 和 60,模型说话很省。summary 那行先按住不表------三个数全反常,下一节单独审。四条预测里第 2、3、4 条实测对上,栽的是第 1 条,对应直觉清单第三条:「summary 不报 usage」。它报。规律实测到位:每轮模型调用各报一条 token_usage,含 summary 那轮,replyId 与该轮调用一一对账------打满场景 = 常规轮数 + 1。数 CUSTOM 就是数模型调用,这句先记下,收口还要用。
收尾摘要的内幕
summary 那行三个数全反常。input 386,比上轮的 748 少了小一半------历史明明在变长,请求反而变小?cached 0------前两轮焐热的缓存全失。最扎眼的是模型的回答里带着一句 "I hit max iterations... told to respond directly":它不光知道自己打满了,还知道"被要求直接回答"------这指令是谁给它的?
三个反常指向同一个结论:summary 不是"再多推理一轮",是换了请求形状。字节码两条实证:summaryStream 给 ModelCallInput 传的工具表是 List.of(),空列表,说明书根本没发给模型;prepareSummaryMessages 把前缀换成收尾指令。为什么抽工具表?我的推断是:轮数已经用完,再留说明书,模型没准还想上手干活;说明书一抽,它只剩说话一条路。------注意这只是推断,字节码证明了"没发",没证明"为什么"。input 变小、缓存归零、模型念出框架的私房话,三角互锁。
![04-正常推理请求工具表:q
那句英文还有更有意思的半截:模型说它 could still have called query_cargo_detail------还能调那个查详情的工具。可工具表根本不在它的请求里,这个工具名是哪来的?我的推断是按命名规律脑补的------工具表没发是字节码实锤,名字不可能从请求里来;但收尾指令的原文我手上没有,不敢排除指令文本里提过一嘴。门外到底有没有人递条子,证据不够,先按"脑补"记,标明是推断。
再看 summary 交回来的活。船期表、舱单表都在,最后一段是诚实交接:第三步没做,C-001 那条货物详情还没查,让用户回复「继续」,剩下的那步马上补。这个收尾要是按"打满即失败"的老直觉写,该是抛异常,或者一句冷冰冰的 error;实际交出来的是两张表、一句差距说明、一个下一步动作。而那句"继续"为什么真能续上?maxIters 是 per-run 预算,回一句"继续"就是新 run,计数从 0 起算,剩下的那步马上补完。一个被打满的 run,交出来的不是异常,是带交接说明的半成品。
RUN_ERROR 什么时候才出现
铺垫到此,正面回答我在笔记里追过的问题:什么情况才有 RUN_ERROR?
答案藏在方法签名里。产出 RUN_ERROR 的方法,签名是 errorEvents(threadId, runId, RunAgentInput, Throwable, boolean)------参数列表里就带着 Throwable。没有异常沿流抛上来,这个方法根本不会被调到。所以 RUN_ERROR 不是"任务失败事件",是"流崩了事件"。
拿老直觉对一对。RUN_FINISHED 像 HTTP 200:状态行只承诺传输这趟走完了,body 里照样可以写"第三步没查到,请回复继续";RUN_ERROR 像 HTTP 500:连接这层出事,压根不保证 body 有货。排查线上问题时,你不会因为 body 报了业务失败就去查网关------同理,agent 回答里说"还差一步"时,不必怀疑框架崩了;反过来,看到 RUN_ERROR,先查的也不是 agent 逻辑。边界照例交代:类比帮理解,不是机制等价------HTTP 状态码管请求-响应那一层的成败,这里管事件流里有没有 Throwable,两层不是一回事,别拿 500 硬套具体行为。
RUN_ERROR 的最高频来源是模型调用失败:key 无效、欠费、网络断、响应解析崩。这几个词,值过班的都认识------不是你的代码崩了,是你依赖的模型服务没应答。生产上它多半长这样:某个凌晨 key 过期,前端红字 RUN_ERROR,日志里跟着模型服务的报错。工具异常反而很少把流干崩:循环里有 3 处 onErrorResume,外加 ToolResultBlock.error 兜底,工具炸了多半被包成结果喂回模型,正是第 5 节那句设计哲学。两次实验全程 0 ERROR,也因为打满本来就不该往这上头想。排障指向由此定下来:RUN_ERROR 出现,先查 key、网络、额度,再谈 agent 逻辑。
这引擎凭什么值钱
顺手把"跟 Spring AI、LangChain4j 比有什么本质区别"一并答了------这问题我自己问过,还带着火气:"是不是太浅、没本质区别?"ReAct 推理加调工具,主流框架都有,手写也就百来行:循环是商品,不是壁垒。AgentScope 押的是循环守住的三样:一,消息不变量(ToolUse 必配对 ToolResult,历史才合法);二,可暂停的运行体(interrupt 检查点、人在环挂起恢复、同会话串行、状态快照------那句"请回复继续"的握手就是雏形);三,每拍扩展点(middleware 能挂在 onReasoning/onActing/onModelCall 上)。demo 跑通和生产可用之间差的那几样------可观测、可干预、可恢复、可扩展------要靠这三样押注一样样去补。官方文档里还有一句给下一课留了扣子:"Both ReActAgent and HarnessAgent run the same reasoning-acting loop for each call." 同一循环,不同机体。诚实边界:单轮问答加调工具,选谁都没差;溢价要等长任务、人在环、多会话这些需求出现才收得回。
收口:带走三样,临走三问
带走三样。
第一样,收场地图:正常收束、打满降级、用户中断,前两种都以 RUN_FINISHED 结束,RUN_ERROR 只在有 Throwable 沿流抛出时出现。配套的 maxIters 语义:数常规推理轮数,per-run 预算,summary 不占额度,默认 10。
第二样,口诀:数账单就是数调用。CUSTOM(token_usage)每轮模型调用报一条,打满场景等于常规轮数加 1。机理实验二那节讲完了,口诀只是把机理压短。
第三样,排障指向:见 RUN_ERROR,先查 key、网络、额度;见 RUN_FINISHED,别默认任务全成------看看回答里有没有交接说明。
自测搬走,十分钟的事。在 workbench 里找到构建 ReActAgent 的那行 builder 代码,把 maxIters 改成 1------注意这是代码参数不是配置键,改完重启服务;用 --workbench.agent.model=fake:fake 起(免 key),然后从 AG-UI 面板发一句"现在几点"。发车前写三个预测:会不会冒出没见过的事件 type?最终文本回答还出现吗?结束事件是 RUN_FINISHED 还是 RUN_ERROR?写完再跑。跑完对着面板数事件条数,别只看个大概;日志里搜 "Maximum iterations reached" 翻那 1 条 DEBUG,对一对它和 RAW 的时间戳毫秒位。标准答案(workbench 这套沙盘的口径):12 条事件;RAW 里躺着 EXCEED_MAX_ITERS;summary 照常给出回答;RUN_FINISHED 收场;日志 1 条 DEBUG、0 warn、0 ERROR。对不上的两种常见情况:没见 RAW------检查点没撞上,多半是 maxIters 没改生效;蹦出 RUN_ERROR------回去查 key、网络、额度。测完把 maxIters 改回 10,别把现场留给下一课。这套实验不花钱:fake 腿不报 usage,也就没有账单要数------注意力正好全放在事件形状上。先预测后实测的铁律移交给你------有出入的那条才是金子。
临走三问,只给场景不给答案:
- 那条 EXCEED_MAX_ITERS 为什么顶着 RAW 的 type 出现在面板上?12 个转换器里谁最后接的手?
- 还是那条三工具链的任务,把 maxIters 放开让模型正常收束,CUSTOM 账单会出几条?跟 maxIters(2) 掐断收尾的那次比,多出来的是什么?
- 你接手一个生产 agent,监控半夜报 RUN_ERROR,你的前三个排查动作是什么?
三道都答对了,这一课才算真的带走。
第 1 课看事件怎么流,这一课看循环怎么停,下一课往上看一层:官方 Quickstart 起手用的不是 ReActAgent,是 HarnessAgent。上一节引过的官方原话------两者跑的是同一条 reasoning-acting 循环------已经把话放在那儿了。循环之上,还盖着楼。
AgentScope Java v2 第2课,ReActAgent 的循环引擎
2026.10.12 · 阅读约 14 分钟
如果这篇文章对你有帮助,欢迎转发给需要的同事和朋友。