两条线:LangGraph 如何把状态流和控制流拆开
一、一个字段被覆盖,一个字段留住了
lab_02 搭的是一张会绕圈的图:draft 先写个初稿,expand 每轮往里补一段,router 看长度够不够,不够就绕回去。我在 State 里定义了三个字段:
class State(TypedDict):
topic: str
result: str # 无 reducer
history: Annotated[list[str], operator.add] # 有 reducer
两个节点返回的东西长得一样,都是 dict:
return {"result": result, "history": [f"expand_node: 第 {round_no} 轮加工后, len(result)={len(result)}"]}
跑完之后,result 只剩最后一次的值,history 一条不落全在。同一个节点、同一次返回、同一种写法,两个字段两种命运。
中间没有任何提示。没有报错,没有警告,日志里也看不出异常。
所以问题变成了:return 一个 dict,框架到底拿它做了什么?
答案是它没有"设置"你的状态,它把你交上来的东西当成增量,然后按每个字段自己的合并规则处理。而规则不写在节点里,写在 State 的定义里。
这篇文章想讲的就是这件事,以及它的另一半:节点返回值走 reducer 更新状态,router 返回值走 path_map 更新去向。LangGraph 把状态流和控制流拆成了两条线。
二、状态流:State 不是 Context
2.1 Java 里的 Context 是我的
写 Java 状态机的时候,上下文对象是个普通对象。谁都能拿到引用,谁都能 setter。订单状态机里加一个字段,就是在 Context 类上加一行,然后各个 handler 里随手 ctx.setXxx()。
这种写法有一个默认前提:状态是我的,我想什么时候改、改几次、从哪个线程改,都是我的事。
第一次写 LangGraph 节点时,我一开始以为可以这么干:想改消息列表,就 state["messages"].append(...),然后 return state。逻辑上完全说得通。
但框架不这么看。而且它不会告诉你哪里不对——lab_02 里那个被覆盖的 result,就是这种预期落空之后的样子。
2.2 节点只上报增量
LangGraph 给节点定的契约很短:节点是 state → 增量 dict 的纯函数。你读 state,返回一个 dict 表示"我产生了这些变化",然后就没有你的事了。
真正的写入者是框架,准确地说是 channel 和 reducer。节点碰不到状态本身,它只能提交一份变更申请。
这条约束看起来只是风格问题,其实不是。
2.3 为什么必须是增量
我一开始以为这是设计洁癖,后来发现至少有三层理由,一层比一层硬。
第一层,所有权。 如果每个节点都能就地改 state,那"当前状态是什么"就取决于哪段代码最后跑。图的执行顺序一旦变化(比如加了个并行分支),结果就跟着变。把写入权收到框架手里,状态才有一个唯一可信的来源。
第二层,并行。 LangGraph 的同一个 super-step 里,多个节点可以并行执行。这时候如果两个节点都在就地 mutate 同一个 dict,就是标准的竞态。让它们各自返回增量、由框架做确定性合并,这个问题从根上消失了。
第三层,持久化。 这一层当时没看懂,到第五章才补齐。stream 逐步回放、checkpointer 断点续跑、时间旅行,这三件事都需要框架在每一步手上有一份"这一步之后的状态"可以存。而它能物化出这份状态,前提是节点告诉了它"这一步变了什么"。增量是提交协议,快照是存储结果,前者是后者的必要条件。
三层里前两层是工程规范,第三层是能力边界。增量提交不是为了好看,是因为不这么做,后面那些功能根本没有实现基础。
2.4 同一个字段的两种命运
回到开头那个实验。result 和 history 的差别只有一行注解:
result: str # 默认 reducer:后写的覆盖
history: Annotated[list[str], operator.add] # 换成追加
默认 reducer 是"最后一个写入者获胜"。两个节点都返回 result,第二次的值覆盖第一次,前一次就没了。这是大多数人第一次遇到时的预期之外:你以为在累积,其实在覆盖。
加上 Annotated[list, operator.add] 之后,合并策略从"赋值"换成了"List.addAll"。每次返回的 list 被追加进去,历史全留。
add_messages 是消息场景的专用版。它做两件事:新消息追加进列表;如果消息带 id 且已存在,就原地更新那一条而不是再追加一条。第二件事是为流式和 checkpoint 场景准备的,不然一条消息被流式吐成好几段,历史里会出现好几条残片。
同一次返回,两条去向:
所以 reducer 不是语法糖,它是字段级的合并策略声明。你在 State 里写下的每一个 Annotated,都是在告诉框架"这个字段被多次写入时该怎么算"。
Java 里这个位置通常是空的。Context 字段没有合并策略,因为不存在"合并"这个动作,只有覆盖。LangGraph 把这个空缺补上了,代价是你必须显式回答一次。
三、控制流:条件边是图里唯一的求值点
3.1 while 的两个动作
一个 while 循环,每轮干两件事:先求值条件,再决定继续还是退出。
while len(result) < THRESHOLD:
result = expand(result)
条件表达式是这段代码的求值点。没有它,循环不知道什么时候停。
把这段逻辑搬进图里,会遇到一个问题:图的边是静态的。
3.2 静态边没有求值点
add_edge("draft", "expand") 表达的是"draft 跑完就去 expand",没有条件。节点返回什么,边都不看。它不参与控制决策。
所以一个只由普通边构成的图,没有任何地方在求值"要不要继续"。它没有循环的出口判断,也就没有循环。框架不给你这个求值点。
要说清楚的是,这不是"绕环就会死循环"。真要拿普通边连成一个环,运行时还有 recursion_limit 兜着,超了会抛 GraphRecursionError,不会真的挂在那里转。准确的说法是:静态边构成的环没有退出条件,框架不提供这个判断,所以循环只能靠条件边来表达。
3.3 path_map:把跳转表变成数据
add_conditional_edges 把条件编译进了边里:
builder.add_conditional_edges("expand", router, {"expand": "expand", END: END})
三个参数各管一件事。第一个是源节点。第二个是 router 函数,读 state,返回一个字符串。第三个是 path_map,把 router 的返回值翻译成目标节点名。
router 本身长这样:
def router(state: State) -> str:
if len(state["result"]) < THRESHOLD:
return "expand"
return END
它只负责判断,不负责跳转。跳转是 path_map 干的。
这个分工在 Java 里对应的是 switch:case "expand": goto expand。区别在于,Java 的跳转表写死在代码里,改一条分支要改代码、重新编译;LangGraph 的跳转表是那个 dict 参数,它是数据。
数据意味着它可以被构造、被替换、被画出来。get_graph().draw_mermaid() 能把整个路由画成图,靠的就是这份数据。
不传 path_map 也能跑。此时 router 的返回值被直接当成节点名。省掉那个 dict 看起来更干净,代价是路由关系不再显式声明,画出来的图会缺边,三个月后的你只能回去读 router 的代码才知道它能去哪。
数据化至少带来三个好处。可视化是最表面的一个。第二个是可测试:router 是纯函数,assert router({"result": "x"*200}) == END 一行就能测,不用跑整张图。第三个是可替换:同一个 router 配不同的 path_map,就是另一条流程。
顺带一句,router 必须无副作用。它会被调用,但它的返回值只进控制流,不参与状态合并。在 router 里改 state 不会有任何效果——这个位置根本没有 reducer 通道。
3.4 两条线在这里分岔
现在可以把这个机制说清楚了。图里有两类返回值,走两条完全不同的路:
- 节点返回 dict → 交给 reducer → 更新状态
- router 返回字符串 → 交给 path_map → 决定去向
前者影响"现在是什么",后者影响"接下来去哪"。一个管数据,一个管控制。
Java 状态机把这两件事混在一起:handler 改完 Context,紧接着 return 一个 next state,数据和去向在同一条语句里。LangGraph 把它们拆开,各走各的通道。
图里有个容易画错的细节:router 的输入是从合并之后的 State 来的,不是从节点那个原始返回值来的。它看不到你 return 的那个 dict,它看到的是 reducer 处理完的结果。这也解释了为什么 router 能靠 state["messages"][-1] 判断该不该继续——那条消息是框架合并进去的,不是节点直接递给它的。
这就是标题里那两条线。
四、循环:条件的作者换了人
4.1 while 和 tool calling 的四处不同
tool calling 循环本质上是 while,但有一处根本差异。
| while 循环 | tool calling 循环 | |
|---|---|---|
| 循环条件 | 程序员写死的确定性谓词 | 模型返回的 tool_calls 是否为空 |
| 求值点 | 每轮开头 | router,每轮 agent 跑完 |
| 确定性 | 确定 | 不确定 |
| 保险丝 | 可选 | 必装 |
前三条是描述,第四条是后果。
while len(result) < THRESHOLD 这个条件,你能读、能测、能证明它最终会变成 False。if not resp.tool_calls 这个条件,取决于模型这一轮想不想调工具。模型可能连着五轮都在调,也可能第一轮就收工。你没法证明它一定收敛。
决策权交给了不可靠的第三方,所以保险丝从可选变成了必装。
4.2 双层保险丝
lab_02 里我装了一道:
if len(state["history"]) >= MAX_ROUNDS:
return END
router 里数轮次,到上限就强制退出。这是应用层的保险丝。
框架层还有一道,在 invoke 的 config 里:
graph.invoke(initial_state, config={"recursion_limit": 15})
两道都叫保险丝,但行为完全不同。
而且刻度也不一样,这点很容易忽略。MAX_ROUNDS 数的是 agent 被调用的次数。recursion_limit 数的是图的 super-step,一轮 agent 加 tools 是两步。lab_05 里我写的是 MAX_ROUNDS = 5 配 recursion_limit = 15,五轮最多九步(agent1 → tools1 → … → agent5,第五轮 router 直接给 END),15 留了余量。
如果把两个数写成一样,比如都写 5,第三轮跑到 tools 节点时框架层就先炸了,应用层的软着陆永远轮不到执行。第二个数要按第一个的两倍往上估,因为它们的单位不是同一个东西。
4.3 熔断语义不一样
应用层那道是软着陆。router 返回 END,图正常收尾,state 完整保留,你能拿到一个"因为轮次到顶所以停下"的合法结果,还能带上已经产生的部分答案。
框架层那道是硬闸门。recursion_limit 超了直接抛 GraphRecursionError,异常截断,调用方拿到的是 traceback 不是结果。
一个返回状态,一个抛异常。所以两道都不能省:应用层负责体面地停下来,框架层负责在最坏情况下兜底。你只有应用层,写错了就没有第二道;你只有框架层,每次熔断都是一次线上报错。
写到这里时我以为自己装了两道。到第七章会发现并没有。
五、持久化:带时间轴的 Session
5.1 一个真实的报错
给图挂上 checkpointer 之后,如果 invoke 时忘了带 thread_id,会得到这个:
ValueError: Checkpointer requires one or more of the following
'configurable' keys: thread_id, checkpoint_ns, checkpoint_id
这个报错值得记,因为它把一件事说透了:thread_id 不是可选的上下文,是主键。 没有主键,快照无处安放,框架直接拒绝启动。
对照组很说明问题:同一张图,摘掉 checkpointer 再 invoke,不传 config 也照样跑通。所以"必须有身份"这个要求完全是 checkpointer 带来的,不是图本身的要求。
还有个更容易误解的地方:同一个 thread_id 二次 invoke,不是断点续跑。
上一轮已经走到 END 了,没有待执行节点可续。实际发生的是新输入 merge 进旧快照,然后图从 START 重跑一遍。没传的字段沿用快照里的旧值,带 reducer 的字段跨 invoke 继续追加。
换个 thread_id 就是另一个平行宇宙。同一份图、同一份代码,两个会话各数各的,互不串味。多用户隔离靠的就是这个分区键。
顺带一个坑:想让某个 thread 从零开始,传 history=[] 是没用的。operator.add 追加一个空 list 等于没追加。
update_state 也不是解药。它同样走 reducer——实测过:invoke 之后 history 是 ['a'],update_state({"history": []}) 之后再查,还是 ['a']。只有没有 reducer 的字段才能被 update_state 直接覆盖;带 reducer 的字段,你写进去的东西仍然要被合并一次。
消息字段要清空得用 RemoveMessage。普通 list 没有对应的删除语义,换新 thread_id 最稳。
5.2 Session 存当下,checkpoint 存历史
拿 Java Session 类比很自然,但类比的边界要划清楚。
Session 里存的是当下的状态。用户这次请求进来,你从 Session 读出来,改完写回去。Session 过期了,这份状态就没了,你没有任何办法知道它曾经是什么。
checkpoint 不一样。同一个 thread_id 下,每一次状态变更都存一份,checkpoint_id 递增,串成一条链。你随时可以回到链上任意一个点。
用一句话概括这个差别:Session 过期是"当下没了",checkpoint 是"历史还在"。
差的那一个维度叫时间轴。Session 是一个键值对,checkpoint 是一条链。
这条链撑起了三件事:stream 逐步回放、断点续跑、时间旅行。
这里有两个层次,我一开始混成了一个。节点提交的是增量,但框架每一步物化的是一份完整快照。 checkpoint 里存的是 channel_values,源码注释写得很直白:state snapshot at a given point in time。链上每一环都是全量,不是累积的 diff。
那增量协议的意义在哪?在于没有它就生成不出快照。框架要物化"这一步之后的状态",前提是知道"这一步变了什么"。节点返回增量,框架才有东西可合并、可记录;节点如果就地改状态,框架手上只有一个被改烂的对象,既不知道变化范围,也没法判断这一步该不该单独存一版。
所以第二章第三层的说法要修正一下:不是"存增量才能持久化",而是 "提交增量,框架才物化得出可持久化的快照" 。存储是快照,协议是增量,两件事。
六、HITL:断点必须活在进程外
人工介入有个天然属性:两次调用之间隔着一个人。
人可能三秒后点确认,也可能第二天才回。这中间进程早就退出了。执行栈、局部变量、内存里的一切,全都没了。
所以断点不能存在内存里,必须落到进程外的持久层。这也是为什么 HITL 和 checkpointer 是绑定关系,而不是"可以一起用":没有 checkpointer,interrupt() 直接报错,框架不给你这条路径。
lab_04 里我记了三件事。
第一,interrupt(payload) 做的事是存快照加干净退出。 payload 是你递给人的问题单,比如"这个订单金额 8 万,确认放行吗"。它是给人看的,不是给人答的。人给的答案从 interrupt() 的返回值里出来,两者不是同一个东西。
第二,恢复必须用同一个 thread_id,加上 Command(resume=...)。 断点的主键是 (thread_id, checkpoint_ns, interrupt.id) 三元组。thread_id 定位会话,checkpoint_ns 定位子图(HITL 常写在子图里),interrupt.id 定位同一轮里的第几个断点——一个节点可以调多次 interrupt()。三个都对上,才找得到那个待答项。换个 thread 就是换了把钥匙。
第三,resume 会重放节点。 恢复不是从"中断那一行"继续,而是把节点从头再跑一遍,跑到 interrupt() 那里拿到你给的值。
为什么非得重放?因为框架手上只有 state,没有你的执行栈。进程退出那一刻,栈上的局部变量、循环位置、还没返回的调用,全丢了。恢复时它能做的只有一件事:重新执行这个节点。这也意味着 interrupt() 在节点里的位置决定了重放边界——放得越靠后,重放时要重做的事情越多。
所以 interrupt() 之前不能放不可重复的副作用。发邮件、扣库存、调外部接口,重放一次就是做两次。节点得是幂等的。
第三条是这三条里最容易出事的一条,因为它不报错。第一次重放你可能根本没发现,只是某天对账时发现多扣了一笔。
调试的时候,get_state 和 get_state_history 是两个入口:先看 next 里有没有待执行节点,再看 interrupts 里有没有待答项。这两个字段能回答"现在停在哪、在等什么"。
七、从白板到一行
7.1 白板 v1 的四个 bug
W2 收官那天的练习是不看代码、白板手写一遍 agent loop。写完对照正确版本,四个洞:
rounds = 0
while rounds < MAX_ROUNDS:
resp = llm(messages)
# ❌ ① 漏了:messages.append(resp)
if resp.tool_calls is null: # ❌ ② Java 语法
break
for call in resp.tool_calls:
result = execute(call)
messages.append(ToolMessage(result, tool_call_id=call.id))
# ❌ ③ 漏了:rounds += 1
三个洞在代码里,第四个在代码外:只写了应用层这道保险丝,框架层的 recursion_limit 压根没配。
① resp 没入列。下一轮模型看到的历史是"用户提问 + 一堆 ToolMessage",中间少了那条声明"我要调这些工具"的 AIMessage。有的兼容端点直接拒绝,报 tool_call_id 找不到对应的 assistant 消息;有的默默接受,然后答得莫名其妙。同一个 bug 在不同后端上表现不同,这比直接报错难查十倍。
② is null 是 Java 语法。Python 里它是两个未定义名字的比较,语法合法,跑到这一行才炸。
③ rounds += 1 漏了。rounds 从头到尾是 0,条件 rounds < MAX_ROUNDS 恒真,循环唯一的出口变成了"模型自己不要工具"。模型这轮状态不好一直要工具,程序就跑到你按 Ctrl-C。
④ 保险丝只有一层。上面那道是应用层的,框架层没有。
这四个里,①③④ 一声不吭,② 会报错——但报的是 NameError: name 'null' is not defined。它告诉你有个名字没定义,不会告诉你"这里该写 None"。报错信息指错了方向,有时候比不报错更费时间。
这类 bug 只有亲手写才会长出来。看别人写的正确代码,你看到的是终点,看不到岔路口在哪。
7.2 v2 补的三件事
第二版加了三个东西,都不是白板上原本要求的:
异常包装成观测回喂。 工具炸了不 raise,包成 ERR: xxx 字符串塞回 messages,让模型自己看着办。这是把"工具失败"从异常变成数据,循环不中断。
空回复不判完成。 return Result(resp.text, "done" if resp.text else "empty")。模型返回空字符串和模型正常答完,是两件事。前者要报独立终止原因,不然下游会把"没答出来"当成"答完了"。
token 预算保险丝。 轮数熔断管不住成本,因为一轮可以烧掉几千 token。加一道按 token 计的闸门,比纯轮数更贴近生产。
这三件都不是语法问题,是"知道线上会怎么坏"之后才想得到的东西。
7.3 对账:一行建出的图
手写版在 lab_05_tool_calling.py,228 行,169 行代码。其中真正定义循环骨架的是这五样:State 定义、agent 节点、tools 节点、router、build_graph,合计 52 行代码。
官方模板版是这样:
agent = create_agent(model, TOOLS, system_prompt=SYSTEM_PROMPT)
一行。
跑 get_graph().draw_mermaid(),两张图的拓扑一模一样:START → agent → (条件边) → tools → agent,条件边那条分支指向 END。
节点名一个叫 agent、一个叫 model,边一个实线一个虚线——但把两张图叠起来,形状是同一个。
手写版里的五样东西,模板全包了:State 换成内置 AgentState,agent 节点和 bind_tools 在内部,tools 节点换成内置 ToolNode,router 换成"看最后一条消息有没有 tool_calls",add_messages 内置。
但有两样模板不给。
一是应用层的 MAX_ROUNDS。模板只有框架层的 recursion_limit,也就是说你失去了软着陆的能力,每次熔断都是异常。
二是可观测性。命中哪个工具、跑了几轮、烧了多少 token,模板不管,要自己打点。
循环的骨架归框架,agent 的业务面归自己。 留给你的就三样:tools、system_prompt、model。
7.4 它并没有更短
这里有个反直觉的账,值得单独说。
裸 SDK 手写版 mini-agent-loop.py 是 83 行,其中 run() 本体 36 行。搬进 LangGraph 之后,建图核心是 52 行。
变长了 16 行。
多出来的是什么?State 类、两个节点拆成独立函数、router 单独一个函数、build_graph 里五处调用。这些在裸 SDK 版里全压在一个 while 循环体里,你不需要给它们起名字。
所以封装的收益不是省行数。省下来的是你不必自己维护的那些不变量:消息成对、增量合并、断点可存。换来的是可中断、可回放、可并行——这三个能力在裸 while 里都不存在。
一行 create_agent 也不是"52 行变 1 行"的意思。它是"52 行里的骨架部分你不用写了"。文件里剩下的 117 行代码(环境配置、模型构建、打印、main)还在,只是不在模板的职责范围里。
7.5 皮在换,骨架没动
还有个细节。旧模板建出来的图,节点叫 agent;新模板叫 model,边也改成了虚线。
名字变了,画法变了,拓扑没变。
create_react_agent 在 LangGraph 1.0 已经标了 deprecated,官方入口迁到 langchain.agents.create_agent,参数从 prompt= 改成 system_prompt=。老教程里那句 import 调下去,会发出 LangGraphDeprecatedSinceV10 弃用警告。
但你手写的那套骨架,一行都不用改。
这件事本身就说明了手写的价值:你学的如果是封装,封装一升级你就得重学;你学的是不变量,皮怎么换都不影响你。
八、八条坑,按怎么发现的分类
下面这些是我这两周真踩过的。按"怎么发现"分三类,因为发现方式决定了它有多贵。
8.1 当场出声型:好查,但暴露版本问题
thread_id 缺失。 上面贴过原文。挂了 checkpointer 就必须给主键,这不是配置疏忽,是设计约束。
HITL 的 API 在 0.x 到 1.x 之间大改。 老教程用 update_state 手工注入人工输入,新版本换成了 interrupt() 加 Command(resume=...)。照着旧文档写,报错会指向一个你根本没调用的方法。
create_react_agent 已废弃。 调用时发出 LangGraphDeprecatedSinceV10 警告。注意它是个 Warning 子类,默认只提醒、不中断,代码照样跑;只有把 warning 提升成 error 才会炸。所以它属于"半静默"——你不管它,它就一直在那里。迁移路径见 7.5。
这三条的共同点是:它当场就出声,错的是你参考的文档版本。 先锁版本再学,比学会再查版本省事得多。
8.2 静默错位型:最贵,测试全绿线上错
resume 重放。 第六章说过,恢复会重跑节点。interrupt() 之前如果有副作用,重放就是重复执行。这条不报错,只在某次对账时暴露。
lab_02 那三个隐患。 一是把轮次寄生在日志字段里,日志一改格式轮次就错位;二是 MAX_ROUNDS 数的是日志条数不是加工轮数,天生差一,多一个写入方就漂移;三是拿 len() 当完成度,字符数既不等于 token 也不等于语义完成。
finish_reason 不是布尔值。 它的取值是 stop / tool_calls / length / content_filter。"没有 tool_calls"只是间接推断,finish_reason 才是 API 直接给的权威信号。尤其是 length——被 max_tokens 截断时必须报独立终止原因,绝不能报 done,否则半句话就进了下游。
这一类的共性是:程序不崩,结果错。 测试跑绿,因为你测的是"有没有异常",而它压根不抛异常。
8.3 设计缺口型:不报错也不出错,只是迟早付账
框架不防死循环。 保险丝得自己带,理由见第四章。
工具观测在工具边界就会膨胀。 一次检索返回两千字,整段塞回 messages,下一轮 prompt 就涨两千字。而历史每轮重发,成本是复利的。
算笔账。一次检索返回 2000 字,大约 1500 token。这条观测进了 messages 之后,后面每一轮请求都要重发它。跑到第五轮,它已经被计费五次。十个用户各问一个需要五轮的问题,就是五十次。而 token 熔断是事后止损,管不住这个——它只能告诉你"这次超了",没法阻止那两千字进上下文。
更硬的约束是上下文上限。撞上了直接报 400,整个请求失败。所以裁剪要放在工具返回值那一层:结构化 top-k、每段字符上限、可追溯的引用 id,别留到拼上下文时再做。到那时候成本已经付过了。
这两条不会让程序出错,只会让账单和延迟慢慢变形。
九、回到那个字段
开头那个实验,result 被覆盖、history 留住,原因是两个字段的 reducer 不一样。
现在能说完整了:节点交上来的是增量,reducer 决定怎么合并(状态流);router 交上来的是字符串,path_map 决定去哪(控制流)。两条线各走各的,互不干扰。
再往外一层,是我手写的 52 行和一行 create_agent 的关系:骨架归框架,业务面归自己。骨架这两周没变过,皮换了两回。
回头看第八章那八条坑,出声型只有三条。剩下五条,加上开头那个被覆盖的字段,全都不吭声。这一行最贵的成本从来不是报错,是它安静地把事情做错。
如果你也在从 Java 转过来,我猜你会在同一个地方卡住:你会下意识觉得状态是你的。 在 Java 里它确实是你的,在 LangGraph 里不是。这个认知切换一旦完成,后面那些机制——增量、reducer、checkpointer、interrupt——都是同一件事的不同侧面。
下一篇会写项目 A:把 W1 的检索包成 @tool 塞进这个循环,做 query 改写、多轮检索和拒答护栏。那是静态 RAG 做不到的部分。
附:五道自测题
不看答案,讲一遍。讲不顺的那道,就是没真懂的那道。
- 节点为什么返回增量 dict,而不是直接改入参?(追问:两个节点都返回同一个字段,最终值归谁?)
- 没有条件边,为什么造不出 while?(追问:router 的返回值被谁消费?)
- tool calling 循环和 while 的本质异同是什么?(追问:保险丝为什么从可选变成必装?)
- checkpointer 为什么必须给 thread_id?(追问:Java Session 这个类比的边界在哪?)
- HITL 为什么必须先有 checkpointer?(追问:resume 为什么要重放节点?)