LangGraph 与 Human-in-the-Loop 实战指南
LangGraph 是一个把 AI 工作流建模成有状态图的框架。它解决了普通 LLM 调用无法处理的几类问题:多步骤分支、循环重试、流程中断与恢复、以及人类介入审批。
本文系统讲解 LangGraph 的五大核心概念(State、Node、Edge、条件边、终止条件),并深入剖析 Human-in-the-Loop(HITL)的三个核心机制(interrupt、Checkpointer、Command),最后用一个完整可运行的示例串联所有知识点。
一、何时需要 LangGraph
判断标准很简单------对比下表:
| 特征 | 普通 LLM 调用 | LangGraph |
|---|---|---|
| 流程 | 一问一答 | 多步骤、有分支 |
| 状态 | 无需保存 | 需要跨步骤共享数据 |
| 决策路径 | 固定 | 根据中间结果动态跳转 |
| 人类介入 | 不需要 | 关键节点需要确认 |
| 循环 | 否 | 可能需要重试、迭代 |
满足其中 2 项以上,就值得用 LangGraph。
典型场景:
- AI 理财顾问下单(涉及资金,必须人类确认)
- 合同审查(AI 标记风险点,人类决定是否签)
- 客服工单流转(简单问题自动答,复杂问题转人工)
- 代码 Review Agent(AI 提建议,人决定接受哪些)
- 邮件群发助手(AI 起草,人审核后发出)
二、核心三要素:State、Node、Edge
LangGraph 的整个心智模型,就是这张图:
┌─────────────────────────────────────┐
│ State(共享数据,所有节点都能读写) │
└─────────────────────────────────────┘
▲
│ 读 / 写
│
START ──→ [Node A] ──→ [Node B] ──→ END
↑ ↑
└───────────┘
Edge(连接节点的边)
State------流程的"共享内存"
用 TypedDict 定义,类似一张表单结构。每个节点都能读写其中的字段。
python
from typing import TypedDict
class PostState(TypedDict):
idea: str # 用户的想法
content: str # AI 生成的文案
decision: str # 用户的决定
关键规则 :节点返回的字典不是"覆盖整个 State",而是"只更新返回的字段"。其他字段保持不变。LangGraph 内部会自动合并。
Node------一个普通的 Python 函数
python
def write(state: PostState) -> dict:
response = llm.invoke([HumanMessage(content=state["idea"])])
return {"content": response.content} # 只返回变化的字段
节点函数的三条规范:
- 接收
state(整个共享内存) - 返回 一个字典,只包含要更新的字段
- 不要直接修改
state,永远用 return 的方式更新
Edge------节点之间的连接
普通边表示固定方向:
python
builder.add_edge(START, "write") # 流程从 write 开始
builder.add_edge("write", "confirm") # write 完了 → confirm
条件边表示动态路由(下一节详述)。
三、条件边------流程的动态分支
条件边的本质是一个路由函数 :根据当前 State 的内容,返回下一个节点的名字(字符串)。
python
def route(state: PostState) -> str:
if state["decision"] == "post":
return "post" # 跳到 post 节点
else:
return "cancel" # 跳到 cancel 节点
builder.add_conditional_edges("confirm", route)
循环的实现
LangGraph 没有专门的循环语法。条件边返回上游节点的名字,就形成循环:
python
def route(state):
if state["satisfied"]:
return "publish" # 满意 → 发布 → 结束
else:
return "write_draft" # 不满意 → 回到起草节点 → 形成循环
这就是一个 "AI 写 → 人审 → 不满意重写 → 再审" 的循环,直到满意为止。
终止条件
返回特殊节点 END,流程结束:
python
builder.add_edge("post", END)
builder.add_edge("cancel", END)
把三个机制画出来:
START
│
▼
[Node A]
│
▼
[Node B] ────┐
│ │ 条件边
▼ ▼
[Node C] [Node D]
│ │
└───┬────┘
▼
END
四、Human-in-the-Loop:让人类介入的三大机制
普通流程图只能描述"输入 → AI 处理 → 输出"。要让人类在中间插入决策,需要三个机制配合:
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐
│ interrupt() │ → │ Checkpointer │ → │ Command(resume) │
│ 暂停执行 │ │ 保存现场 │ │ 恢复执行 │
└──────────────┘ └────────────────┘ └──────────────────┘
1. interrupt()------暂停点
python
from langgraph.types import interrupt
def confirm(state):
decision = interrupt({"文案": state["content"]})
return {"decision": decision}
执行到 interrupt(...) 时:
- 把括号里的内容"扔出去"给外部调用者(用户看到的审核内容)
- Graph 暂停,当前函数中止
- 等待外部传入新数据后从这一行继续
2. Checkpointer------状态持久化
HITL 的基础设施。interrupt() 暂停后,整个 State 必须有地方保存,否则恢复时数据就丢了。
python
from langgraph.checkpoint.memory import MemorySaver
graph = builder.compile(checkpointer=MemorySaver())
- 开发 :
MemorySaver(保存在内存) - 生产 :
PostgresSaver/SqliteSaver,服务器重启也能恢复
3. Command(resume=...)------恢复执行
python
from langgraph.types import Command
graph.invoke(
Command(resume="post"), # 携带用户的决定
config=config, # 必须用同一个 thread_id
)
thread_id 必须前后一致------它是 Checkpointer 找回之前保存 State 的钥匙。
五、关键数据流:Command 是如何影响路由的
这是 HITL 中最容易混淆的地方。当外部调用:
python
graph.invoke(
Command(resume={"action": "post"}),
config=config,
)
{"action": "post"} 究竟是怎么传递到路由判断里的?追踪完整链路:
外部调用:
Command(resume={"action": "post"})
│
│ LangGraph 内部:
│ 找到上次暂停的位置(confirm 节点)
│ 把 {"action": "post"} 塞回 interrupt() 的返回值
▼
┌───────────────────────────────────────────────┐
│ def confirm(state): │
│ decision = interrupt(...) │ ← decision = {"action": "post"}
│ return {"decision": decision["action"]} │ ← State["decision"] = "post"
└───────────────────────────────────────────────┘
│
│ confirm 返回后,LangGraph 调用条件边路由函数
▼
┌───────────────────────────────────────────────┐
│ def route(state): │
│ return state["decision"] │ ← 读 "post",返回 "post"
└───────────────────────────────────────────────┘
│
│ LangGraph 拿到字符串 "post"
▼
跳转到名为 "post" 的节点
三个关键传递点
Command(resume=X)里的 X ,会成为interrupt()的返回值interrupt()的返回值,由当前节点写进 State- 路由函数读 State,返回下一节点名字符串,LangGraph 据此跳转
State 是中间人:节点写进去,路由函数读出来。没有任何隐藏机制。
一个理解模型
interrupt() 像一个快递柜:
AI 把内容"放进快递柜" → Graph 暂停
│
│ 外部看到内容
│ 把决定放回快递柜
▼
节点"开柜取件",拿到 {"action": "post"}
六、完整示例:朋友圈文案生成器
需求:用户描述想发什么,AI 生成文案,用户确认后发布,否则取消。
流程:
START
│
▼
[write] AI 写文案
│
▼
[confirm] ⏸ 暂停,等用户确认
│
├── "post" → [post] → END
└── "cancel" → [cancel] → END
完整代码:
python
from typing import TypedDict, Literal
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import MemorySaver
# ① State:流程共享数据
class PostState(TypedDict):
idea: str # 用户的想法
content: str # AI 生成的文案
decision: str # 用户的决定
# ② LLM
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.9)
# ③ 节点
def write(state: PostState) -> dict:
"""AI 根据想法写文案"""
response = llm.invoke([
SystemMessage(content="写朋友圈文案,带 emoji 和 hashtag,50 字以内。"),
HumanMessage(content=state["idea"]),
])
return {"content": response.content}
def confirm(state: PostState) -> dict:
"""⏸ 暂停,等用户决定"""
decision = interrupt({"文案": state["content"]})
return {"decision": decision["action"]}
def post(state: PostState) -> dict:
print(f"🚀 已发布:{state['content']}")
return {}
def cancel(state: PostState) -> dict:
print("🗑️ 已取消")
return {}
# ④ 条件边路由
def route(state: PostState) -> Literal["post", "cancel"]:
return "post" if state["decision"] == "post" else "cancel"
# ⑤ 构建 Graph
builder = StateGraph(PostState)
builder.add_node("write", write)
builder.add_node("confirm", confirm)
builder.add_node("post", post)
builder.add_node("cancel", cancel)
builder.add_edge(START, "write")
builder.add_edge("write", "confirm")
builder.add_edge("post", END)
builder.add_edge("cancel", END)
builder.add_conditional_edges("confirm", route)
graph = builder.compile(checkpointer=MemorySaver())
# ⑥ 运行
if __name__ == "__main__":
config = {"configurable": {"thread_id": "post-001"}}
# 第一次调用:执行到 interrupt() 自动暂停
graph.invoke(
{"idea": "今天爬山了,累但很开心", "content": "", "decision": ""},
config=config,
)
# 用户决定
choice = input("发布吗?(post / cancel):").strip()
# 第二次调用:恢复执行
graph.invoke(
Command(resume={"action": choice}),
config=config,
)
知识点与代码的对应
| 代码 | 知识点 |
|---|---|
class PostState(TypedDict) |
① State |
def write / confirm / post / cancel |
② Node |
add_edge(START, "write") |
③ 普通边 |
add_conditional_edges("confirm", route) |
③ 条件边 |
interrupt(...) |
④ HITL 暂停点 |
MemorySaver() |
④ Checkpointer |
Command(resume=...) |
④ HITL 恢复 |
add_edge("post", END) |
⑤ 终止条件 |
七、常见误区
误区 1:两次 invoke() 是两个独立的程序
HITL 流程通常需要两次 graph.invoke(),中间间隔一段时间(等待人类输入)。它们是同一个流程的两个阶段 ,靠 thread_id + Checkpointer 串联。可以用 graph.stream() + while 循环让代码更连贯,本质上等价。
误区 2:Command(resume=X) 里的 X 有固定格式
X 可以是字符串、字典、列表,完全自由。传什么进去,interrupt() 就返回什么。
误区 3:节点 return 字典会覆盖整个 State
不会。LangGraph 会自动合并------只更新返回的字段,其他字段保持不变。
误区 4:路由函数可以返回任意内容
必须返回字符串 ,且必须是已注册的节点名或 END。返回错了会报错。
误区 5:thread_id 可以随便改
thread_id 是 Checkpointer 找回 State 的钥匙。暂停时用的 thread_id,恢复时必须完全一致,否则找不到之前的状态。
核心五要素------State / Node / Edge / 条件边 / interrupt------已经能覆盖 80% 的实际场景。
总结
LangGraph 的设计哲学是把 AI 工作流抽象成图结构:
- 节点是步骤
- 边是顺序
- State 是数据
- 条件边是分支
- interrupt 是人类介入点
理解了这五个抽象,遇到任何复杂业务需求,第一步不再是写 prompt,而是先画图:
这个流程有几个节点?哪里需要分支?哪里需要人类介入?
图画清楚了,代码只是把图翻译出来。