公司动态

langgraph教程系列-04-让agent会反思-循环与终止条件

📅 2026/7/31 2:20:51
langgraph教程系列-04-让agent会反思-循环与终止条件
本文是「LangGraph 教程系列」第 4 篇。写作时基于 langgraph 1.2.10、langchain 1.3.14、langchain-openai 1.4.1、Python 3.12。配套代码仓库 https://github.com/wxj006007/deep-research-assistant 本篇对应 tagv1。上一篇结尾留了个问题v0.2 的流水线是一条直线检索不够也不会回头补查而一旦允许回头「循环怎么停」立刻变成头号难题。这一篇就来正面回答它。我们给研究助手加上全系列第一条回边让它检索完会反思「资料够不够、缺什么」不够就带着缺口再查一轮。图从此有了环也从此必须回答终止问题。一、v0.2 撞的墙计划是盲开的v0.2 学会了用 reducer 攒资料但它的检索次数在写代码时就焊死了想查两次就铺两个检索节点想查三次就铺三个。更麻烦的是查什么也是 plan 阶段一次性定死的。给它提一个跨两个知识点的问题为什么 LangGraph 需要 checkpoint它和人在回路是什么关系plan 拆出的查询如果只覆盖了 checkpoint检索回来的资料里就没有人在回路的内容。然后呢然后 v0.2 会拿着这份残缺的资料直接写答案。它没有任何机会发现「我还缺东西」更没有机会补查。计划是盲开的一旦首轮没覆盖到关键侧面后面全程认命。人做研究不是这样的。人会先查一轮扫一眼手头的资料发现缺口再针对缺口查下一轮。这个「查、评估、再查」的节奏就是反思循环。二、v1 的图结构第一条回边continuefinishSTARTplan规划首轮查询search检索攒资料evaluate评估找缺口synthesize综合成稿END看图检索后面多了一个evaluate节点它做的事就是反思看着已有资料判断够不够回答问题、缺什么、下一个查询该查什么。判定不满意就沿虚线的continue回到search再查一轮。注意evaluate - search这条边的方向它指向上游。这是全系列第一条回边。此前的 v0、v0.1、v0.2不管有没有分叉箭头全都朝前图论里管这叫有向无环图DAG。从这条边开始图里有了环助手第一次获得了「回头」的能力。能力和风险是一起来的。有环就有绕着环转个不停的可能。所以 v1 从设计之初就带着三层保险evaluate 判定资料充分时正常收敛这是语义终止。轮数达到上限时强制收尾这是纯逻辑兜底。LangGraph 内置的 recursion_limit 是框架层的最后防线触发它说明前两层写错了。三层怎么各司其职下面写代码时逐个看。三、动手实现本篇的检索仍然是模拟的内置 FAKE_CORPUS 加确定性关键词匹配纯教学脚手架可离线复现让注意力集中在循环与终止上。完整代码在仓库src/v1_reflection.pycheckout 到 tagv1就能跑。3.1 State循环的记账本classResearchState(TypedDict):question:str# 本轮要执行的查询首轮由 plan 写入后续轮由 evaluate 写入。# 这里刻意用覆盖语义默认 reducer——每轮只关心当前查询# 历史轨迹由下面的 executed_queries 负责累积。current_query:str# 已执行查询的历史轨迹operator.add 累积永不丢失executed_queries:Annotated[list[str],operator.add]# 检索结果沿用 v0.2 的 reducer 累积设计每轮新增的 doc 追加进来docs:Annotated[list[Doc],operator.add]round:int# 已完成的检索轮数 检索次数verdict:str# sufficient | insufficientgap:str# 评估给出的还缺什么answer:strexecuted_queries和docs挂着operator.add这是上一篇讲透的 reducer 累积语义多轮检索的结果追加不覆盖这里不再展开。真正为循环新增的是三个字段round记已完成的检索轮数verdict记评估结论gap记评估指出的缺口。它们是循环的记账本后面你会看到终止判断读的全是它们。current_query值得多看一眼它故意不挂 reducer用默认的覆盖语义。每一轮只关心「这轮查什么」evaluate 写入新查询时直接覆盖旧的历史轨迹交给executed_queries去攒。同一个 state 里覆盖和累积各用各的选哪个取决于字段的语义不是所有 list 都要 add。3.2 search 节点轮数在这里递增plan 节点和之前差不多让 LLM 生成首轮的一个查询写进current_query代码就不贴了。看检索节点defsearch_node(state:ResearchState)-dict:执行 current_query追加新资料轮数 1。不调 LLM。 轮数在检索节点自增round 的语义就是已完成的检索次数 放在别处比如 evaluate会让计数和实际检索脱节。 querystate[current_query]new_docsfake_search(query,state.get(docs,[]))return{docs:new_docs,# add reducer 负责追加executed_queries:[query],# add reducer 负责累积轨迹# 首轮进入时 state 里还没有 round 字段用 .get 兜底为 0round:state.get(round,0)1,}有个容易忽略的设计决定round的自增放在 search 里不放在 evaluate 里。理由写在 docstring 里了round的语义是「已完成的检索次数」谁干活谁记账。如果放到 evaluate 里递增将来某天有人在 search 和 evaluate 之间插一个节点或者调整了边的走向计数就和实际检索次数脱节了。计数器跟着被计数的动作走这是循环体里的老规矩。3.3 evaluate 节点反思与一个危险的兜底defevaluate_node(state:ResearchState)-dict:LLM 看着已有资料反思够不够缺什么下一查什么 只让模型输出 JSON解析后写回 verdict / gap / current_query。 llmget_llm()# temperature0docs_text\n.join(f- [{doc[title]}]{doc[content]}fordocinstate.get(docs,[]))or目前没有任何资料responsellm.invoke([(system,你是研究助手的资料评估器。判断已收集的资料是否足以回答用户的问题。\n只输出一个 JSON 对象不要输出其他内容格式\n{verdict: sufficient 或 insufficient, gap: 还缺什么信息sufficient 时可为空字符串, followup_query: 下一轮该查的一个查询sufficient 时可为空字符串},),(human,f问题{state[question]}\n\n已收集的资料\n{docs_text},),])# 解析模型可能把 JSON 包在 json 围栏里先剥掉再 loadsrawresponse.content.strip()ifraw.startswith():rawraw.strip()ifraw.startswith(json):rawraw[len(json):]rawraw.strip()try:parsedjson.loads(raw)verdictparsed.get(verdict,)gapstr(parsed.get(gap,))followupstr(parsed.get(followup_query,)).strip()except(json.JSONDecodeError,AttributeError):verdict,gap,followup,,# 兜底权衡解析失败或 verdict 非法时按 insufficient 处理——# 宁可多查一轮也不要拿着残缺资料提前收尾多查的代价有轮数上限托底# 这正是评估判定 最大轮数双保险存在的理由。ifverdictnotin(sufficient,insufficient):verdictinsufficientgapgapor评估输出无法解析保守起见继续检索ifnotfollowup:followupstate[question]# 追问查询兜底退化为用原问题再查return{verdict:verdict,gap:gap,current_query:followup,# 覆盖写入这是下一轮若有要执行的查询}套路和第 2 篇的分类节点一脉相承强约束输出只准吐 JSON、解析、白名单校验、兜底。但这次的兜底值得停下来琢磨。第 2 篇里分类失败兜底为 analytical最坏的代价是答得啰嗦一点。这次解析失败兜底为 insufficient你想想看insufficient 的后果是走回边再查一轮。假如模型抽风每一轮都吐出解析不了的东西这个兜底就每一轮都把图推回循环里。单看这一处代码它是个潜在的死循环制造机。那为什么还敢这么写因为轮数上限在外面托底。宁可多查一轮也不要拿着残缺资料提前收尾多查的代价被 MAX_ROUNDS 封住了上界。反过来如果兜底写成 sufficient倒是永远不会死循环但每次模型输出格式抖动助手就拿残料交稿这种错是悄无声息的比死循环难发现得多。这个事儿给我的教训是兜底策略不能孤立地评估。同一行代码在没有轮数上限的图里是事故在有轮数上限的图里是稳健。安全性是整个终止设计的属性不是某一行代码的属性。3.4 路由函数终止判断集中在一处defroute_after_evaluate(state:ResearchState)-Literal[continue,finish]:决定回边走不走continue 回 search 再查一轮finish 去 synthesize。 终止判断集中在这一个纯函数里不调 LLM、不改 state一眼可审计 - verdict sufficient评估判定资料充分语义终止 - round MAX_ROUNDS轮数兜底纯逻辑、不受 LLM 输出影响—— 哪怕评估节点永远说 insufficient循环也翻不过这道墙。 ifstate[verdict]sufficientorstate[round]MAX_ROUNDS:returnfinishreturncontinue第 2 篇立的纪律在这里原样延续节点干活边选路路由函数是纯函数不调 LLM、不改 state路由依据必须先写进 state。verdict是 evaluate 写的round是 search 写的路由函数只是读出来做个布尔判断。好处在循环场景里被放大了。整张图「会不会停、什么时候停」这个最要命的问题答案就集中在这一个if里六行代码一眼审计完。两个条件正好对应两层保险verdict sufficient是语义终止由 LLM 的判断触发。round MAX_ROUNDS是逻辑兜底纯计数比较LLM 说什么都拦不住它。而 MAX_ROUNDS 本身就一行MAX_ROUNDS3# 最多检索几轮轮数兜底是纯逻辑评估再贪心也翻不过这道墙坦率的讲写循环时最该多想两分钟的就是这类兜底。语义终止是「好的时候停得漂亮」逻辑兜底是「坏的时候停得下来」前者靠模型后者只能靠你。3.5 组装回边就是普通的一条映射defbuild_graph():builderStateGraph(ResearchState)builder.add_node(plan,plan_node)builder.add_node(search,search_node)builder.add_node(evaluate,evaluate_node)builder.add_node(synthesize,synthesize_node)builder.add_edge(START,plan)builder.add_edge(plan,search)builder.add_edge(search,evaluate)# 全系列第一条指回上游的回边evaluate - search。# 从这条边开始图不再是 DAG而是真正的循环图。builder.add_conditional_edges(evaluate,route_after_evaluate,{continue:search,# 回边带着 evaluate 写入的 followup 查询再查一轮finish:synthesize,},)builder.add_edge(synthesize,END)returnbuilder.compile()可能有小伙伴纳闷回边这么重要的东西是不是要什么特殊 API没有。continue: search映射表里一个普通的键值对目标节点恰好在上游而已。LangGraph 不区分前进边和回边图就是图。第 1 篇说过链和循环都是图的特例现在这句话落成了代码循环不过是「条件边加一条指回上游的映射」。3.6 跑起来两条终止路径main()里准备了两个问题分别演示两条退出路径questions[# 路径一预期 2 轮后 sufficient 收敛# 第 1 轮查到 checkpoint评估指出缺人在回路第 2 轮补齐为什么 LangGraph 需要 checkpoint它和人在回路是什么关系,# 路径二语料完全无覆盖每轮 0 新增、评估恒 insufficient# 3 轮后由轮数兜底强制收尾量子计算会如何影响密码学,]第一问就是开头 v0.2 答不好的那个。首轮查询命中 checkpoint 的语料evaluate 看完说 insufficient缺口是人在回路的内容并给出追问查询。第二轮把「人在回路interrupt」的语料补了回来evaluate 判定 sufficient循环从语义出口正常收敛两轮结束。v0.2 只能认命的缺口v1 自己补上了。第二问是故意的刁难FAKE_CORPUS 里压根没有量子计算的内容。每一轮检索新增 0 条evaluate 每一轮都老老实实说 insufficient。要是只有语义终止这就是死循环了。实际跑下来第 3 轮结束后round MAX_ROUNDS命中强制收尾synthesize 拿着空资料如实回答「资料未覆盖」。轮数兜底从设想变成了肉眼可见的执行路径。还剩第三层没露面。调用图的时候forstateingraph.stream({question:question},stream_modevalues,config{recursion_limit:25},):recursion_limit是 LangGraph 框架内置的执行步数上限超了直接抛 GraphRecursionError。它和前两层的关系要摆正它不是给业务用的终止条件是保险丝。正常运行时它永远不该被触发一旦触发说明你的语义终止和逻辑兜底都写错了该做的是修前两层不是调大这个数。家里跳闸正确反应是查电器不是换个更粗的保险丝。四、顺便聊聊Loop 还是 Graph顺着回边这个话题再聊几句。我之前在「从 Loop 到 Graph一条推文背后的 Agent 工程真伪之辨」里分析过一场争论一派说 agent 就是个 while 循环套什么图框架纯属过度工程另一派说没有图编排的 agent 上不了生产。先承认 loop 派有道理的部分一个「调模型、看结果、不满意再来」的循环确实是 agent 最小的可用形态v1 的 search、evaluate、再 search剥掉框架看就是个 while。那篇文章的结论是这场争吵的实质不是二选一Loop Engineering 是内循环graph 负责的是外层编排两者根本不在同一层。今天这篇正好把那个论点落成了代码。v1 的循环没有消失它作为 evaluate 到 search 的回边活在图里而图给这个循环带来的东西是裸 while 给不了的。终止条件集中在一个纯函数里可审计每一轮的 verdict、gap、round 都留在 state 里可回放将来加 checkpoint第 6 篇、加人工审批第 7 篇都是在这张图上加节点加边循环体一行不用动。loop 负责思考的节奏graph 负责工程的秩序。手艺人的重复劳作变成流水线靠的不是取消重复是给重复装上刹车和仪表盘。五、本篇小结回到「循环怎么停」这块。v0.2 的墙是计划盲开、流水线不回头v1 的解法是加一个 evaluate 节点反思缺口再用全系列第一条回边evaluate - search把图从 DAG 变成有环图。有环就必须回答终止v1 的答案是三层evaluate 的 sufficient 判定负责语义收敛MAX_ROUNDS 轮数上限负责逻辑兜底recursion_limit 是永远不该烧断的框架保险丝。另外记住那个兜底的教训解析失败按 insufficient 处理单看是死循环隐患配上轮数上限就是稳健设计兜底策略要和整体终止设计放在一起评估。v1 会反思了但它反思完再怎么查面对的还是那个七条语料的 FAKE_CORPUS。第二问的三轮空转已经把话挑明了语料里没有的东西循环转多少圈也变不出来。模拟检索该换成真家伙了。给助手接上真正的联网搜索工具怎么和图集成下一篇tool node 登场。赞或收藏 关注 我们下次再见