两条线:LangGraph 如何把状态流和控制流拆开(AI 生成)

两条线:LangGraph 如何把状态流和控制流拆开

一、一个字段被覆盖,一个字段留住了

lab_02 搭的是一张会绕圈的图:draft 先写个初稿,expand 每轮往里补一段,router 看长度够不够,不够就绕回去。我在 State 里定义了三个字段:

python 复制代码
class State(TypedDict):
    topic: str
    result: str                                  # 无 reducer
    history: Annotated[list[str], operator.add]  # 有 reducer

两个节点返回的东西长得一样,都是 dict:

python 复制代码
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 的差别只有一行注解:

csharp 复制代码
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 循环,每轮干两件事:先求值条件,再决定继续还是退出。

arduino 复制代码
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 把条件编译进了边里:

sql 复制代码
builder.add_conditional_edges("expand", router, {"expand": "expand", END: END})

三个参数各管一件事。第一个是源节点。第二个是 router 函数,读 state,返回一个字符串。第三个是 path_map,把 router 的返回值翻译成目标节点名。

router 本身长这样:

python 复制代码
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 里我装了一道:

arduino 复制代码
if len(state["history"]) >= MAX_ROUNDS:
    return END

router 里数轮次,到上限就强制退出。这是应用层的保险丝。

框架层还有一道,在 invoke 的 config 里:

arduino 复制代码
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,会得到这个:

arduino 复制代码
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。写完对照正确版本,四个洞:

ini 复制代码
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 行代码。

官方模板版是这样:

ini 复制代码
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 做不到的部分。


附:五道自测题

不看答案,讲一遍。讲不顺的那道,就是没真懂的那道。

  1. 节点为什么返回增量 dict,而不是直接改入参?(追问:两个节点都返回同一个字段,最终值归谁?)
  2. 没有条件边,为什么造不出 while?(追问:router 的返回值被谁消费?)
  3. tool calling 循环和 while 的本质异同是什么?(追问:保险丝为什么从可选变成必装?)
  4. checkpointer 为什么必须给 thread_id?(追问:Java Session 这个类比的边界在哪?)
  5. HITL 为什么必须先有 checkpointer?(追问:resume 为什么要重放节点?)
相关推荐
gugugu.4 小时前
同一个 ReAct,两种手写方式:LangChain 文本协议 vs LangGraph 状态图
ai·langchain
掘黄金的打工人5 小时前
LangChain PromptTemplate 提示词模板
langchain·ai编程
日日大王学编程10 小时前
deerflow-memory-design-blog
python·langchain·开源
前端精髓11 小时前
动态选择工具
python·langchain
云和数据.ChenGuang1 天前
深入解析 Python 的 dir():从用法到 CPython 源码实现
开发语言·人工智能·python·langchain
hqk1 天前
AI Agent 学习总结:从模型到 Harness
langchain·aigc
日日大王学编程1 天前
开源Agent项目记忆设计决策对比
python·ai·langchain·开源·ai编程
前端精髓2 天前
结构化输出with_structured_output(抽卡)
python·langchain
LMT流氓兔2 天前
第五单元——第十六课:给书店助手加上网页界面
java·开发语言·jvm·人工智能·oracle·langchain·1024程序员节