默认情况下,
graph.invoke要等整张图执行完才返回最终状态。但真实产品里,用户等不了 30 秒再看一个完整答案------他们要看到 token 一个个蹦出来、要看到「正在检索知识库...」的进度条、要在高风险操作前亲自点一次「确认」。本章讲 LangGraph 的两大交互能力:
stream流式输出(五种模式)与interrupt人工审核(HITL 的核心原语)。
5.1 图内外数据传递:stream 的五种模式
默认的 graph.invoke 是「黑盒执行、一次性出结果」。考虑 Agent 的两个典型需求:
- LLM 输出时就能拿到 token,在前端逐字展示(打字机效果);
- Agent 流程较长时,能知道当前正在执行哪个节点/步骤。
由于 LangGraph 实现了 langchain_core 的 Runnable 接口,天然具备 stream / astream 流式输出方法,正好解决这两个问题。
stream 提供以下模式:
| 模式 | 描述 |
|---|---|
values |
每一步执行后,流式输出完整状态 |
updates |
每一步执行后,流式输出增量更新;同一超步内的多个增量会分别输出 |
custom |
流式输出节点内部通过 stream_writer 发送的自定义数据 |
messages |
在任何调用了 LLM 的节点中,流式输出二元组 (LLM Token, metadata) |
debug |
流式输出所有能输出的信息(任务调度、输入输出等) |
| 混合模式 | stream_mode 传列表,同时得到多种流式输出 |
选择建议:前端打字机效果用 messages;进度条/步骤提示用 custom;日志与审计用 updates;开发调试用 debug。
完整示例
python
import time
from typing import TypedDict, Annotated, List
import operator
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.types import StreamWriter
from langgraph.runtime import Runtime
llm = ChatOpenAI(model="gpt-4o-mini")
# --- 1. 定义状态 ---
class State(TypedDict):
messages: Annotated[List[BaseMessage], operator.add]
current_step: str
# --- 2. 定义节点 ---
def node_input(state: State):
"""模拟接收用户输入"""
return {
"messages": [HumanMessage(content="请帮我写一段Python代码")],
"current_step": "input_received",
}
def node_processing(state: State, runtime: Runtime):
"""模拟中间处理,用 stream_writer 输出自定义流式数据(对应 custom 模式)"""
steps = ["正在分析意图...", "正在检索知识库...", "正在构建Prompt..."]
writer = runtime.stream_writer
for i, step in enumerate(steps):
time.sleep(0.5) # 模拟耗时操作
# 发送自定义数据(不影响图的状态,只能被 custom 模式接收到)
writer({
"step_index": i + 1,
"description": step,
"timestamp": time.time(),
})
return {"current_step": "processing_complete"}
def node_generation(state: State):
"""模拟 LLM 生成过程(messages 模式会自动捕获 LLM 的流式输出)"""
response = llm.invoke(state["messages"])
return {
"messages": [response],
"current_step": "generation_complete",
}
# --- 3. 构建图 ---
builder = StateGraph(State)
builder.add_node("input", node_input)
builder.add_node("process", node_processing)
builder.add_node("generate", node_generation)
builder.add_edge(START, "input")
builder.add_edge("input", "process")
builder.add_edge("process", "generate")
builder.add_edge("generate", END)
graph = builder.compile()
# --- 4. 演示不同的流式输出模式 ---
def run_demo():
initial_state = {"messages": [], "current_step": "start"}
print(f"\n{'='*20} 1. Mode: values {'='*20}")
for event in graph.stream(initial_state, stream_mode="values"):
# event 就是当前的完整 State 字典
print(f"State: keys={list(event.keys())}, step={event.get('current_step')}")
print(f"\n{'='*20} 2. Mode: updates {'='*20}")
for event in graph.stream(initial_state, stream_mode="updates"):
# event 是 {节点名: 该节点的输出} 的字典
print(f"Update: {event}")
print(f"\n{'='*20} 3. Mode: custom {'='*20}")
for event in graph.stream(initial_state, stream_mode="custom"):
# event 就是 writer() 里传的对象
print(f"Custom Data: {event}")
print(f"\n{'='*20} 4. Mode: messages {'='*20}")
for chunk, metadata in graph.stream(initial_state, stream_mode="messages"):
# chunk 是 AIMessageChunk;metadata 里含节点信息
node_name = metadata.get('langgraph_node', 'unknown')
print(f"[{node_name}] Token: {chunk.content!r}")
print(f"\n{'='*20} 5. Mode: debug {'='*20}")
count = 0
for event in graph.stream(initial_state, stream_mode="debug"):
if count < 3:
print(f"Debug Event: {event['type']} - {event.get('payload', {}).get('name')}")
count += 1
print("... (省略后续 debug 信息)")
print(f"\n{'='*20} 6. Mixed Mode {'='*20}")
# 返回 (mode, data) 元组
for mode, data in graph.stream(initial_state, stream_mode=["updates", "custom"]):
if mode == "updates":
print(f"[Updates] 来自节点 {list(data.keys())[0]}")
elif mode == "custom":
print(f"[Custom] {data['description']}")
if __name__ == "__main__":
run_demo()
各模式输出逐个解析
Mode 1:values ------ 完整状态快照流
每个节点执行完,吐一次「当时点的完整状态」。输出形如:
text
State: keys=['messages', 'current_step'], step=input_received
State: keys=['messages', 'current_step'], step=processing_complete
State: keys=['messages', 'current_step'], step=generation_complete
适合前端需要随时渲染全量对话历史的场景(每来一个 event 就重画整个消息列表)。数据量随状态增长,长对话下开销最大。
Mode 2:updates ------ 增量更新流
每个节点执行完,吐一次 {节点名: 增量dict}:
text
Update: {'input': {'messages': [...], 'current_step': 'input_received'}}
Update: {'process': {'current_step': 'processing_complete'}}
Update: {'generate': {'messages': [...], 'current_step': 'generation_complete'}}
只传增量、不重复传全量,比 values 轻量;事件里带节点名,天然适合审计日志(「哪个节点在什么时候写了什么」)。
Mode 3:custom ------ 自定义数据流
只输出节点里 writer(...) 发出的对象,与本例的三个进度事件一一对应:
text
Custom Data: {'step_index': 1, 'description': '正在分析意图...', ...}
Custom Data: {'step_index': 2, 'description': '正在检索知识库...', ...}
Custom Data: {'step_index': 3, 'description': '正在构建Prompt...', ...}
注意这些数据不进状态、不落检查点,纯粹是节点向外界的广播------所以特别适合进度条、状态标签这类「给用户看的中间信息」。
Mode 4:messages ------ LLM Token 流
这是做打字机效果的核心。for chunk, metadata in ... 解包二元组:
chunk.content:本次产出的一小段文本(AIMessageChunk对象);metadata['langgraph_node']:token 产自哪个节点------多 Agent 图里可以用它给不同 Agent 的输出标不同颜色。
LangGraph 的实现原理:节点内部调用的 LLM 对象只要本身是 LangChain Runnable(如 ChatOpenAI),其流式回调就会被运行时捕获并转发出来------节点代码不需要为此做任何改造 ,把 llm.invoke 换成 llm.stream 都不必。
Mode 5:debug ------ 全量调试信息
输出任务调度、每个节点的输入输出等一切细节,数据量非常大,仅在排查「图到底怎么跑的」时使用。
Mode 6:混合模式
stream_mode=["updates", "custom"] 传入列表,事件变成 (mode, data) 元组,用 if mode == ... 分流处理。一个真实前端往往同时要两路:节点进度(updates)+ 打字机(custom/messages),混合模式就是为此设计的。
5.2 人工审核节点:interrupt
Agent 在完成用户设定的任务时,有时我们希望用户参与部分重要决策过程------转账前确认金额、发邮件前确认收件人、执行删除操作前确认范围。LangGraph 为此提供了一个非常方便的原语:interrupt。
工作原理
第一次 invoke:
节点执行 → 遇到 interrupt(待审核数据)
→ 图暂停(状态存入 checkpointer,含中断位置)
→ invoke 返回,__interrupt__ 里带着待审核数据
↓ (人类看数据、做决策)
第二次 invoke:
graph.invoke(Command(resume=决策结果), 同一thread_id)
→ interrupt(待审核数据) 处"恢复",返回值 = 决策结果
→ 节点从该行继续往下执行 → ...... → END
可以在节点内部任意位置 引入 interrupt。当节点执行到 interrupt 所在位置时停止执行,图外能得到需要用户审核的数据;用户审核完成后,再通过 graph.invoke 让图继续往下执行。
四个关键角色:
interrupt(payload):节点内调用,payload是要给审核人看的数据;恢复时它的返回值就是外部传入的决策结果;checkpointer:interrupt 强依赖检查点------暂停的本质是把「执行到哪了」存进 checkpointer,所以 compile 时必须配 checkpointer;Command(resume=value):第二次 invoke 的输入,value就是喂给interrupt返回值的决策数据;thread_id:两次 invoke 必须用同一个 thread_id,才能找回暂停时的状态。
完整示例:转账前人工审核
python
"""
LangGraph interrupt 演示:让用户参与关键决策
场景:准备执行一笔"转账",在真正执行前让用户审核/修改转账信息。
"""
from __future__ import annotations
from typing import Any
from typing_extensions import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
class TransferState(TypedDict):
recipient: str
amount: int
memo: str
approved: bool
final_status: str
def review_transfer(state: TransferState) -> dict[str, Any]:
print("\n[Node] review_transfer:生成待执行的转账请求")
pending_transfer = {
"recipient": state["recipient"],
"amount": state["amount"],
"memo": state["memo"],
}
# ★ 在这里暂停,把待审核数据交给图外的人工
user_review = interrupt(
{
"title": "转账审核",
"pending_transfer": pending_transfer,
"instruction": "请返回 bool(是否批准) 或 dict(可改 recipient/amount/memo,并带 approved 字段)。",
}
)
# ------ 恢复后从这里继续:user_review 就是外部 resume 传进来的决策 ------
approved = False
updated_transfer = dict(pending_transfer)
if isinstance(user_review, bool):
approved = user_review
elif isinstance(user_review, dict):
approved = bool(user_review.get("approved", True))
for k in ("recipient", "amount", "memo"):
if k in user_review:
updated_transfer[k] = user_review[k]
print(f"[Node] review_transfer:用户审核结果 approved={approved},transfer={updated_transfer}")
return {
"approved": approved,
"recipient": updated_transfer["recipient"],
"amount": updated_transfer["amount"],
"memo": updated_transfer["memo"],
}
def execute_transfer(state: TransferState) -> dict[str, str]:
if not state["approved"]:
print("\n[Node] execute_transfer:用户未批准,取消转账")
return {"final_status": "已取消:用户未批准转账"}
print("\n[Node] execute_transfer:模拟执行转账")
return {"final_status": f"已转账:收款人={state['recipient']},金额={state['amount']},备注={state['memo']}"}
def build_graph():
builder = StateGraph(TransferState)
builder.add_node("review_transfer", review_transfer)
builder.add_node("execute_transfer", execute_transfer)
builder.add_edge(START, "review_transfer")
builder.add_edge("review_transfer", "execute_transfer")
builder.add_edge("execute_transfer", END)
# ★ 必须配 checkpointer,interrupt 才能暂停/恢复
return builder.compile(checkpointer=InMemorySaver())
def run_demo():
graph = build_graph()
config = {"configurable": {"thread_id": "interrupt-demo-1"}}
initial_state: TransferState = {
"recipient": "Alice",
"amount": 100,
"memo": "午饭AA",
"approved": False,
"final_status": "",
}
# ===== 第一次 invoke:停在 interrupt =====
first = graph.invoke(initial_state, config=config)
# __interrupt__ 是 invoke 返回值里的特殊键,携带暂停信息
interrupt_payload = first["__interrupt__"][0].value
print("\n[System] 图已暂停,等待用户审核。可给用户展示的数据如下:")
print(interrupt_payload)
# ===== 人工决策(真实场景来自前端表单 / 审批系统回调)=====
user_resume_value = {"approved": True, "amount": 80, "memo": "改为80元(实际应付)"}
# ===== 第二次 invoke:Command(resume=...) 恢复执行 =====
final = graph.invoke(Command(resume=user_resume_value), config=config)
print("\n[System] 已恢复执行,最终结果:")
print(final["final_status"])
if __name__ == "__main__":
run_demo()
逐段解析:
审核节点 review_transfer:
- 先把待审核数据组装成
pending_transfer,再调interrupt({...})。interrupt的参数是「给审核人看的全部信息」------title、待办内容、操作指引,实际项目里往往还有任务 id、发起时间、风险等级; user_review = interrupt(...)这行代码的精妙之处:第一次执行时它是一个「暂停点」 (抛出内部信号,节点到此为止);恢复执行时它是一个「返回语句」(返回值即 resume 传来的决策)。同一行代码,两种身份;- 恢复后对决策结果做了防御式解析:既支持
bool(纯批准/拒绝),也支持dict(批准 + 修改金额/备注)------真实审批场景里「改一下再批」非常常见; - 返回增量:把审核后的最终字段写回状态,下游
execute_transfer拿到的就是修正过的数据。
执行节点 execute_transfer :读 approved 决定执行还是取消。它本身对 interrupt 一无所知------审核逻辑被完全封装在上游节点里,图的拓扑不用为 HITL 做任何特殊设计。
run_demo 的两次 invoke:
- 第一次
invoke(initial_state, config)正常返回(不抛异常!),返回值里多了一个__interrupt__键,first["__interrupt__"][0].value就是传给 interrupt 的那份审核数据。程序此时可以把 payload 推给前端渲染成审批卡片; - 人工决策
user_resume_value = {"approved": True, "amount": 80, ...}------注意这里的改动:把 100 元改成了 80 元; - 第二次
invoke(Command(resume=user_resume_value), config=config):输入不是状态 dict,而是 Command 对象 ;thread_id 不变。图从暂停点恢复,interrupt(...)返回这个 dict,节点继续执行; - 最终输出:
已转账:收款人=Alice,金额=80,备注=改为80元(实际应付)------金额的修改被正确传递到了执行节点。
⚠️ 最重要的注意事项:恢复时的重复执行
使用 interrupt 时有一个必须刻在脑子里的行为细节:
第二次调用
graph.invoke(Command(resume=...))继续执行时,含 interrupt 的函数会从函数起点重新执行,执行到 interrupt 那一行时才被「短路」并拿到恢复值。
也就是说,interrupt 之前如果有更新数据、调用 API 等操作,会再执行一遍:
python
def bad_node(state):
call_api_deduct_fee() # ← 恢复时会再扣一次费!
data = prepare_review(state)
decision = interrupt(data) # 暂停点
...
第一次 invoke 扣了一次费,恢复时又扣一次。因此必须做到以下两点之一:
- 保证操作的幂等性 :让
call_api_deduct_fee内部带幂等键(如请求 id),重复调用不会重复生效; - 把有副作用的操作隔离到单独节点 :副作用放在 interrupt 所在节点之前的节点里,审核节点只做「组装数据 → 暂停 → 应用决策」这三件事:
python
def prepare_node(state): # 独立节点:副作用在这里,只执行一次
call_api_deduct_fee()
return {"pending": prepare_data(state)}
def review_node(state): # 审核节点:无副作用,重跑无害
decision = interrupt(state["pending"])
return {"decision": decision}
这是 interrupt 落地生产时最容易翻车的地方,设计图结构时就要把「副作用节点」和「审核节点」拆开。
5.3 本章小结
stream五种模式:values(全量状态)、updates(增量+节点名)、custom(writer 自定义广播)、messages(LLM token 流)、debug(全量调试);混合模式传列表,收到(mode, data)元组;- 打字机效果用
messages,进度提示用custom+runtime.stream_writer,审计用updates; interrupt(payload)是 HITL 核心原语:第一次 invoke 在此处暂停(数据在返回值__interrupt__里),第二次invoke(Command(resume=决策), 同一thread_id)从暂停点继续;- interrupt 强依赖 checkpointer 和相同 thread_id;
- 恢复时含 interrupt 的函数从头重跑:副作用操作必须幂等,或隔离到前置节点。
下一章讲图的最后一类构件:边------普通边、条件边,以及用条件边构建的循环与递归保护。