Day 41 / 共 68 天 · 阶段 7 编排框架

LangGraph② 条件边、循环、人在环路

昨天(Day 40)你修好了一条"直达线"。但真 Agent 得看情况拐弯、能循环重来、危险处还要等人拍板。今天补齐另一半:add_conditional_edges(条件边)、循环(把手写的"想→动手→再想"用图实现)、interrupt(Day37 的"中途等人"真正落地)。学完这两天,你就能用 LangGraph 搭出有脑子的 Agent 了。

📍 你在阶段 7(编排框架 D39-44)的位置
D39 为什么要框架 D40 LangGraph① D41 LangGraph② D42 CrewAI D43 eino D44 选型
💡 用一个类比兜住今天(继续沿用「地铁线路图」的世界观,今天修支线和道口) 昨天修的是直达线。今天让线网有脑子:条件边(add_conditional_edges) = 一个岔路口,列车到这看行李箱里的情况,决定走 A 线还是 B 线;路由函数 = 岔路口的扳道员(读行李箱,喊一声"往左!"还是"往右!");循环 = 一段环线,列车可以绕回上一站再来一圈(这就是昨天手写的"想→动手→再想");interrupt(人在环路) = 一道道口栏杆,危险路段落杆停车、等调度员按钮放行才继续。今天你从"修直路"进阶成"修带岔道、环线和道口的智能线网"。
L01

为什么直线不够用

🤔 痛点昨天的图是写死的一条道:检索→写稿→结束。可现实是:检索没查到东西怎么办?写完稿审校不合格要不要打回重写?这些"看情况"的岔口,直线图表达不了。
💡 本质需要两种新能力:①条件分支——到路口按当前 state 决定走哪条边;②循环——某条边能绕回前面的站再走一遍。有了这俩,昨天手写循环里的 ifwhile,就都能用"图"优雅地表达出来,而且一眼看得懂、还能被监控。
👶 一句话普通边(add_edge)= "永远走这条";条件边(add_conditional_edges)= "到这看情况再决定走哪条"。今天的主角就是后者。
L02

路由函数:岔路口的扳道员

🤔 痛点"看情况走哪条边"——这个"看情况"的判断逻辑写哪?
💡 本质写一个路由函数(扳道员):它和节点函数一样收 state,但不改行李箱,只返回一个字符串——"下一站该去哪"。框架拿这个字符串去查"该走哪条边"。扳道员只管指路,不搬货。
# 扳道员:读行李箱,返回"下一步去哪"的暗号(一个字符串)
def 审校结果如何(state):
    if state["passed"]:               # 审校通过
        return "通过"                  # 返回暗号"通过"
    if state["retries"] >= 3:         # 已重写3次,别死磕
        return "放弃"
    return "回炉"                      # 否则打回重写

# 注意:它没有 return {...} 改 state,只吐一个字符串指方向
📝 举个例子:同一个扳道员,行李箱不同就指不同方向 行李箱 {passed:true} → 扳道员喊 "通过";{passed:false, retries:1} → 喊 "回炉";{passed:false, retries:3} → 喊 "放弃"同一个路口、同一个扳道员,列车走哪条全看此刻行李箱里的内容——这就是"看情况拐弯"。
路由函数返回的字符串是你自己定的"暗号"(如 "通过"/"回炉"/"放弃"),下一讲会把每个暗号映射到一个真实的站名。暗号和站名分开,读起来更清楚。
L03

add_conditional_edges:把岔路口接上图

🤔 痛点扳道员写好了,怎么告诉图"从审校站出来,按扳道员的话去不同的站"?
💡 本质add_conditional_edges(出发站, 扳道员, 暗号→站名的对照表)。意思是:列车离开"出发站"时,让"扳道员"看行李箱喊个暗号,再按对照表把暗号翻译成真正要去的站。一个路口,几条出路,清清楚楚。
from langgraph.graph import END
# 从"审校"站出来,让"审校结果如何"扳道员决定去哪
g.add_conditional_edges(
    "审校",                 # 从这个站出发
    审校结果如何,            # 扳道员函数:返回 "通过"/"回炉"/"放弃"
    {                       # 暗号 → 真实站名 的对照表
        "通过": END,         # 喊"通过" → 到终点
        "回炉": "写稿",       # 喊"回炉" → 绕回写稿站(这就形成了循环!)
        "放弃": END,         # 喊"放弃" → 也结束(兜底,别死循环)
    },
)
审校站是个岔路口:通过→END,回炉→绕回写稿 ✍️ 写稿 🔎 审校 END 通过 回炉(循环) 扳道员看行李箱里的 passed / retries 决定走哪条
图注:一个条件边,就把"审校不过就回炉重写"这种循环逻辑,画成了任何人都看得懂的图。
L04

循环:让列车绕回去,就是 Agent 主循环

🤔 痛点昨天手写 Agent 的核心是"想→动手→看→再想"的 while 循环。用图怎么表达这种"转圈"?
💡 本质循环 = 一条绕回去的条件边。经典 Agent 图长这样:有个"大脑"站(LLM 决策)和"工具"站,大脑决定"要调工具"就走边到工具站,工具站干完绕回大脑站再想;直到大脑说"完事"就走边到 END。这正是昨天手写的循环,只不过画成了图——而且步数上限、状态存档这些护栏,框架都替你兜着。
经典 Agent 环线:大脑 ⇄ 工具,想完就出站 START 🧠 大脑LLM 决策 🔧 工具执行 END 要调工具 绕回再想 完事
图注:大脑站用条件边——"要调工具"就去工具站(工具站再无条件绕回大脑),"完事"就去 END。昨天的 while,今天成了这张环线图。

👶 小白:绕回去会不会像昨天担心的那样无限转、烧钱?

👨‍🏫 老师:会,所以要留兜底。框架有个递归上限(默认转到一定圈数就自动停,类似昨天的 max_steps),你也可以在扳道员里判断"转够 N 次就走去 END"。凡是有循环的图,都必须有一条"通往 END 的出路 + 一个上限",这是铁律,面试也爱问。

L05

interrupt:道口栏杆,落杆等人放行

🤔 痛点Day37 说过,危险动作(发邮件、下单、删数据)要"暂停等人拍板"。在图里怎么让列车停在某站、等人点头再继续?
💡 本质编译时告诉图"在某站前落杆":compile(checkpointer=存档器, interrupt_before=["发邮件"])。列车开到"发邮件"站会自动停下并存档(靠 Day37 的 checkpointer!),把控制权交回给你。人看过后,用同一个 thread_id 再 invoke 一次(通常传 None),列车就从道口继续开。暂停能原样恢复,全因为状态被存住了——这就是"人在环路"落地。
from langgraph.checkpoint.memory import MemorySaver   # 最简单的内存存档器

app = g.compile(
    checkpointer=MemorySaver(),        # 有存档才能"停了还能续"(Day37)
    interrupt_before=["发邮件"],        # 在"发邮件"站前落杆暂停
)
cfg = {"configurable": {"thread_id": "u1"}}   # 存档栏位编号,区分不同会话

app.invoke({"draft": "尊敬的客户..."}, cfg)    # 跑到"发邮件"前停住、已存档
# —— 这里把草稿拿给人看,人点了"同意" ——
app.invoke(None, cfg)                          # 传 None:从道口继续,真正发出
👶 记住这条链能"停在半路等人、还能原样续跑",靠的是三件事串起来:state(行李箱记着现场)+ checkpointer(存档)+ interrupt(落杆)。Day37 的三个词,今天在这一段全用上了——这就是学得扎实的回报。Day54 讲"审批闸/护栏"会再深化。
🔗 想看条件边/循环/interrupt 的完整实战与源码?去看《LangGraph 20 天精讲》→(学完回来继续 Day 42)
L06

完整示例:一个会"回炉重写"的写作 Agent

💡 本质把今天的条件边 + 循环拼成一个能跑的小图:写稿→审校,不过就回炉,最多 3 次。为聚焦流程,审校用"字数够不够"这种简单规则代替真 LLM(真项目里换成模型打分即可)。
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    draft: str                      # 当前稿子
    passed: bool                    # 审校过没
    retries: Annotated[int, add]    # 重写次数(追加型:每回炉一次+1)

def 写稿(state):
    n = state.get("retries", 0)
    return {"draft": f"这是第 {n+1} 版稿子,内容更充实了……" * (n+1)}

def 审校(state):
    ok = len(state["draft"]) >= 40           # 简单规则:够长就算过
    return {"passed": ok}

def 扳道员(state):                            # 决定:过了/回炉/放弃
    if state["passed"]:            return "通过"
    if state.get("retries", 0) >= 2: return "放弃"   # 最多再回炉,别死循环
    return "回炉"

g = StateGraph(State)
g.add_node("写稿", 写稿); g.add_node("审校", 审校)
g.add_edge(START, "写稿")
g.add_edge("写稿", "审校")
g.add_conditional_edges("审校", 扳道员,
    {"通过": END, "放弃": END, "回炉": "回炉计数"})   # 回炉先去计数站
g.add_node("回炉计数", lambda s: {"retries": 1})      # +1 次后回写稿
g.add_edge("回炉计数", "写稿")

app = g.compile()
out = app.invoke({"draft": "", "passed": False})
print("最终稿:", out["draft"][:30], "…")
print("重写次数:", out["retries"], "|通过:", out["passed"])
📝 举个例子:它会自己转几圈 第 1 版太短→审校不过→回炉计数(+1)→回写稿写第 2 版……直到稿子够长审校通过、走 END。你没写一个 while,循环却在图里自然发生了——而且这张图能画出来、能被监控。
L07

今日小结 + 动手 10 分钟

🧠 今天你应该能回答

  • 普通边和条件边(add_conditional_edges)有什么区别?
  • 路由函数(扳道员)和节点函数有什么不同?(它只返回字符串指路,不改 state)
  • 循环在图里怎么表达?为什么有循环就必须有"通往 END 的出路 + 上限"?
  • interrupt_before 怎么让列车停下等人?它依赖 Day37 的哪个东西?(checkpointer)
  • "能暂停还能原样续跑"靠哪三件事串起来?(state + checkpointer + interrupt)

✋ 动手 10 分钟:给写作 Agent 加一道"人工审批"

在 L06 的图上,把"通过"之后先别直接 END,而是加一个"发布"站,并在发布前落杆等人:

from langgraph.checkpoint.memory import MemorySaver
def 发布(state): return {"draft": state["draft"] + "(已发布)"}
g.add_node("发布", 发布)
# 把"通过"的去向从 END 改成 "发布",再 发布→END
#   {"通过": "发布", ...} 、 g.add_edge("发布", END)

app = g.compile(checkpointer=MemorySaver(), interrupt_before=["发布"])  # 发布前落杆
cfg = {"configurable": {"thread_id": "t1"}}
app.invoke({"draft": "", "passed": False}, cfg)   # 跑到"发布"前停住
print("暂停,等人审批……当前稿:", app.get_state(cfg).values["draft"][:20])
app.invoke(None, cfg)                              # 人点头 → 继续,真正发布

观察:第一次 invoke 停在发布前;第二次 invoke(None) 才真发布。你就亲手做出了"人在环路"。

明日预告 · Day 42:LangGraph 是"自己画图、控制力强"的路子。明天换个风格——CrewAI:它不让你画图,而是让你像组建一个团队那样声明"有哪几个角色(Agent)、每人负责哪个任务(Task)、按什么流程(Process)协作",框架自动帮他们接力。同样是编排,一个像"画电路图",一个像"带团队",体会两种思路的差别。
← Day 40 · LangGraph① 画成图 Day 42 · CrewAI 像带团队 →