如何在图执行过程中实时获取输出?如何让图"暂停等待人工确认"?
本节是 Human-in-the-loop 的基础,彻底搞懂 Streaming 与 Interrupt。
默认情况下,调用 graph.invoke() 是阻塞式的:你等图完整跑完,才能拿到最终 State。对于简单任务这没问题,但对于:
LangGraph 提供了一流的流式支持(First-class Streaming),让你可以在图执行过程中实时获取各种粒度的输出。本节的终极目标是理解 Streaming 是如何为 Module 3 的核心特性——Human-in-the-loop——铺路的。
LangGraph 的流式输出有两个维度:一是节点粒度(每个节点执行完后吐出一次数据),二是Token 粒度(LLM 生成每个 token 时立刻传出来)。两者都很重要,用途不同。
本节 notebook 复用了 Module 2 的带摘要的 Chatbot 图。先理解这张图的结构,才能看懂后面的流式输出。
from langchain_deepseek import ChatDeepSeek
from langchain_core.messages import SystemMessage, HumanMessage, RemoveMessage
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.graph import MessagesState
# ─── State:继承 MessagesState,额外加 summary 字段 ───
class State(MessagesState):
summary: str
# ─── Node 1:调用 LLM 进行对话 ───
def call_model(state: State, config: RunnableConfig):
summary = state.get("summary", "")
if summary:
system_message = f"Summary of conversation earlier: {summary}"
messages = [SystemMessage(content=system_message)] + state["messages"]
else:
messages = state["messages"]
response = model.invoke(messages, config) # config 支持 token 流式
return {"messages": response}
# ─── Node 2:对历史对话做摘要(消息过多时触发)───
def summarize_conversation(state: State):
summary = state.get("summary", "")
if summary:
summary_message = f"This is summary of the conversation to date: {summary}\n\n" \
"Extend the summary by taking into account the new messages above:"
else:
summary_message = "Create a summary of the conversation above:"
messages = state["messages"] + [HumanMessage(content=summary_message)]
response = model.invoke(messages)
# 保留最近 2 条消息,其余删除
delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][:-2]]
return {"summary": response.content, "messages": delete_messages}
# ─── 路由函数:消息超过 6 条时触发摘要 ───
def should_continue(state: State):
if len(state["messages"]) > 6:
return "summarize_conversation"
return END
# ─── 构建图 ───
workflow = StateGraph(State)
workflow.add_node("conversation", call_model)
workflow.add_node(summarize_conversation)
workflow.add_edge(START, "conversation")
workflow.add_conditional_edges("conversation", should_continue)
workflow.add_edge("summarize_conversation", END)
# 使用 MemorySaver 作为 checkpointer,支持线程级别的对话记忆
memory = MemorySaver()
graph = workflow.compile(checkpointer=memory)
call_model 函数接受第二个参数 config: RunnableConfig,并将其传入 model.invoke(messages, config)。这对于在 Python 3.10 及以下版本中启用 Token 级流式输出是必需的。config 携带了流式回调信息,使 LangGraph 能够在 token 生成时立刻捕获并发出事件。
stream_mode="updates" 是最常用的流式模式。图每执行完一个节点,就会推送一个 chunk,chunk 里只包含这个节点对 State 做出的变化部分,而不是完整 State。
# 创建一个对话线程(thread_id 用于区分不同对话)
config = {"configurable": {"thread_id": "1"}}
# 使用 stream() 方法,指定 stream_mode="updates"
for chunk in graph.stream(
{"messages": [HumanMessage(content="hi! I'm Lance")]},
config,
stream_mode="updates"
):
print(chunk)
chunk 的结构是一个字典,键是节点名称,值是该节点返回的更新内容。比如上面的 conversation 节点返回了 messages 字段的更新,里面包含 LLM 的回复。
for chunk in graph.stream(
{"messages": [HumanMessage(content="hi! I'm Lance")]},
config,
stream_mode="updates"
):
# 从 conversation 节点的更新中取出消息,美化打印
chunk['conversation']["messages"].pretty_print()
如果图中有多个节点同时执行(并行情况),你会收到多个 chunk,每个 chunk 的键对应各自的节点名。在访问 chunk 里的数据时,要先通过节点名找到对应的更新字典,再取你需要的字段。
图执行一个节点 → 推送该节点的状态变更 dict → 图继续执行下一个节点 → 再次推送...
stream_mode="values" 与 "updates" 的区别在于:每次推送的不是某个节点的变更,而是当前整张图的完整 State。
# 使用新的 thread_id 开启新对话
config = {"configurable": {"thread_id": "2"}}
input_message = HumanMessage(content="hi! I'm Lance")
for event in graph.stream(
{"messages": [input_message]},
config,
stream_mode="values"
):
# event 就是完整的 State 字典
for m in event['messages']:
m.pretty_print()
print("---"*25) # 分隔符,区分不同节点执行后的状态
注意:第一次推送发生在图接收到输入之后、第一个节点执行之前(状态里只有 Human Message);第二次推送发生在 conversation 节点执行完之后(状态里有 Human + AI Message)。
| 维度 | stream_mode="updates" | stream_mode="values" |
|---|---|---|
| 每个 chunk 的内容 | 只包含该节点修改的字段(增量) | 包含整个 State 的所有字段(全量) |
| chunk 结构 | {node_name: {changed_fields}} |
{all_state_fields} |
| 推送时机 | 每个节点执行完毕后 | 每个节点执行完毕后(也包括初始输入时) |
| 典型用途 | 只关心某节点的输出,比如 LLM 回复 | 需要看完整历史,比如调试、观察所有消息 |
| 数据量 | 较小(只有变化部分) | 较大(完整状态,随对话增长) |
生产应用中,updates 模式更常用:你可以精确指定关注哪个节点的输出,数据量小,处理更高效。values 模式更适合调试和监控,可以完整观察每一步后图的全局状态变化。
前面两种模式(updates / values)都是节点粒度的流式——必须等节点执行完才推送。但聊天应用往往需要 LLM 生成每个 token 时就立刻显示给用户,这就需要 astream_events。
graph.astream_events() 是一个异步方法(需要 async for),它在节点内部执行时同步触发各类事件,你可以根据事件类型过滤出你关心的信息。
config = {"configurable": {"thread_id": "3"}}
input_message = HumanMessage(content="Tell me about the 49ers NFL team")
# version="v2" 是目前推荐的版本,事件格式更稳定
async for event in graph.astream_events(
{"messages": [input_message]}, config, version="v2"
):
# 每个 event 有 metadata.langgraph_node(哪个节点触发)
# event.event(事件类型)
# event.name(具体名称)
print(f"Node: {event['metadata'].get('langgraph_node','')}. "
f"Type: {event['event']}. Name: {event['name']}")
每个 event 字典包含以下关键字段:
| 字段 | 说明 | 示例值 |
|---|---|---|
event |
事件类型 | "on_chat_model_stream", "on_chain_start" |
name |
事件名称 | "ChatDeepSeek", "conversation" |
data |
事件携带的数据 | 包含 chunk 字段(AIMessageChunk) |
metadata.langgraph_node |
触发该事件的 LangGraph 节点名 | "conversation" |
node_to_stream = 'conversation' # 指定只监听哪个节点的输出
config = {"configurable": {"thread_id": "4"}}
input_message = HumanMessage(content="Tell me about the 49ers NFL team")
async for event in graph.astream_events(
{"messages": [input_message]}, config, version="v2"
):
# 只处理聊天模型产生的 token 事件,且来自 conversation 节点
if (event["event"] == "on_chat_model_stream"
and event['metadata'].get('langgraph_node', '') == node_to_stream):
data = event["data"]
# data["chunk"] 是一个 AIMessageChunk,.content 是这个 token 的文本
print(data["chunk"].content, end="|") # 用 | 分隔每个 token
事件类型 "on_chat_model_stream" 是 Token 级流式的核心。每当聊天模型生成一个 token,就会触发一次这个事件,event["data"]["chunk"] 就是对应的 AIMessageChunk。通过 metadata["langgraph_node"] 可以精确知道这个 token 来自哪个节点,从而过滤出你关心的输出。
config = {"configurable": {"thread_id": "5"}}
input_message = HumanMessage(content="Tell me about the 49ers NFL team")
async for event in graph.astream_events(
{"messages": [input_message]}, config, version="v2"
):
if (event["event"] == "on_chat_model_stream"
and event['metadata'].get('langgraph_node', '') == 'conversation'):
data = event["data"]
# end="" 让 token 连续显示,不换行
print(data["chunk"].content, end="|")
| 方法 | 粒度 | 是否异步 | 典型用途 |
|---|---|---|---|
.stream(stream_mode="updates") |
节点级(增量) | 同步 | 观察每个节点的输出变化 |
.stream(stream_mode="values") |
节点级(全量) | 同步 | 调试、观察完整 State |
.astream_events() |
Token 级(最细) | 异步 | 实时打字机效果、精细监控 |
当你通过 LangGraph API(LangSmith Studio 或部署后的服务) 远程调用图时,除了 values 和 updates 之外,还有一种仅在 API 层面支持的特殊模式:stream_mode="messages"。
使用 LangGraph API 需要先启动本地开发服务器(在 /studio 目录运行 langgraph dev),然后通过 langgraph_sdk 连接。本节的 API 示例假设服务已在 http://127.0.0.1:2024 运行。
from langgraph_sdk import get_client
from langchain_core.messages import convert_to_messages
client = get_client(url="http://127.0.0.1:2024")
# 创建新线程
thread = await client.threads.create()
input_message = HumanMessage(content="Multiply 2 and 3")
async for event in client.runs.stream(
thread["thread_id"],
assistant_id="agent",
input={"messages": [input_message]},
stream_mode="values" # 远程 API 也支持 values 模式
):
messages = event.data.get('messages', None)
if messages:
print(convert_to_messages(messages)[-1]) # 打印最新消息
print('='*25)
API 返回的 event 对象结构:event.event(类型字符串)和 event.data(数据内容)。
stream_mode="messages" 专门为消息型 LangGraph(state 里有 messages 字段)设计,会推送四类事件:
| 事件类型 | 说明 |
|---|---|
metadata |
本次运行的元信息,包含 run_id |
messages/partial |
消息的部分内容(token 级流式),逐步积累直到完整 |
messages/complete |
完整的已形成消息(非流式内容,如 tool call 结果) |
messages/metadata |
消息级别的元信息 |
thread = await client.threads.create()
input_message = HumanMessage(content="Multiply 2 and 3")
async for event in client.runs.stream(
thread["thread_id"],
assistant_id="agent",
input={"messages": [input_message]},
stream_mode="messages"
):
print(event.event) # 查看事件类型
def format_tool_calls(tool_calls):
"""格式化工具调用列表为可读字符串"""
if tool_calls:
formatted_calls = []
for call in tool_calls:
formatted_calls.append(
f"Tool Call ID: {call['id']}, Function: {call['name']}, Arguments: {call['args']}"
)
return "\n".join(formatted_calls)
return "No tool calls"
thread = await client.threads.create()
input_message = HumanMessage(content="Multiply 2 and 3")
async for event in client.runs.stream(
thread["thread_id"],
assistant_id="agent",
input={"messages": [input_message]},
stream_mode="messages"
):
# 处理运行元信息
if event.event == "metadata":
print(f"Metadata: Run ID - {event.data['run_id']}")
print("-" * 50)
# 处理流式消息片段
elif event.event == "messages/partial":
for data_item in event.data:
# 用户输入消息
if "role" in data_item and data_item["role"] == "user":
print(f"Human: {data_item['content']}")
else:
content = data_item.get("content", "")
tool_calls = data_item.get("tool_calls", [])
if content:
print(f"AI: {content}")
if tool_calls:
print("Tool Calls:")
print(format_tool_calls(tool_calls))
messages 模式的最大优势是对工具调用(Tool Call)的流式支持:你可以看到工具的参数是如何被 LLM 逐步"写出来"的,例如 {'a': 2} → {'a': 2, 'b': 3},这对于调试 Agent 的工具调用行为非常有帮助。
流式输出让你观察图的执行;而中断机制(Interruption)则让你控制图的执行——在特定节点前后暂停图,等待外部(通常是人)做出决策,再恢复执行。这正是 Human-in-the-loop 的核心机制。
中断机制依赖 Checkpointer(检查点机制)。图每执行一个节点,就把当前状态存档到 Checkpointer(如 MemorySaver 或数据库)。图被中断后,状态被冻结存档;当你恢复时,图从存档点继续,不需要从头运行。没有 Checkpointer 就无法使用中断功能。
from langgraph.checkpoint.memory import MemorySaver
memory = MemorySaver()
# interrupt_before:在执行指定节点"之前"暂停
graph = workflow.compile(
checkpointer=memory,
interrupt_before=["tools"] # 在执行 tools 节点之前暂停
)
# interrupt_after:在执行指定节点"之后"暂停
graph = workflow.compile(
checkpointer=memory,
interrupt_after=["conversation"] # 在 conversation 节点执行完后暂停
)
interrupt_before 和 interrupt_after 都接受一个节点名列表,可以同时声明多个中断点:
# 同时声明多个中断点
graph = workflow.compile(
checkpointer=memory,
interrupt_before=["tools", "human_review"], # 在这两个节点前都暂停
interrupt_after=["planner"] # 在 planner 执行后暂停
)
| 参数 | 暂停时机 | 此时 State 的状态 | 典型用途 |
|---|---|---|---|
interrupt_before=["X"] |
X 节点执行之前 | X 节点尚未修改 State | 在危险操作前让人审批(如发邮件、执行代码) |
interrupt_after=["X"] |
X 节点执行之后 | X 节点已经更新 State | 让人审查 LLM 的输出,决定是否继续 |
图被中断后,你可以查看当前 State:
config = {"configurable": {"thread_id": "1"}}
# invoke 会在中断点停下,不会继续执行后续节点
result = graph.invoke(
{"messages": [HumanMessage(content="search for the latest AI news")]},
config
)
# 获取当前的图状态快照
snapshot = graph.get_state(config)
# 查看下一步将执行哪个节点(中断在哪里)
print(snapshot.next) # 例如:('tools',) 表示即将执行 tools 节点
# 查看当前 State
print(snapshot.values) # 完整的 State 字典
snapshot.next 返回一个元组,表示图暂停时,下一步待执行的节点名称。如果图已经执行完毕(正常结束),snapshot.next 为空元组 ()。
掌握了中断机制后,我们来看完整的 Human-in-the-loop 工作流:图暂停 → 人工检查/修改 State → 恢复执行。
如果人工审查后认为 LLM 的决策正确,直接让图继续:
# 以 None 作为输入调用 invoke,表示"继续从中断处运行,不改变 State"
# 关键:必须使用同一个 config(同一个 thread_id),图才能找到存档点
result = graph.invoke(None, config)
# 图从 tools 节点继续执行,完成后返回最终 State
print(result)
传入 None 告诉 LangGraph:不要重新输入新的初始状态,而是从 Checkpointer 中加载上次暂停时存档的状态,从中断点继续执行。thread_id 是找到正确存档点的唯一标识。
这是 Human-in-the-loop 的精髓:人工修改图的 State,然后恢复执行:
# 1. 先查看当前 State
snapshot = graph.get_state(config)
current_messages = snapshot.values["messages"]
print("当前最后一条消息(LLM 的工具调用请求):")
print(current_messages[-1])
# 2. 人工修改 State(例如:修改工具调用的参数)
# 假设 LLM 要搜索 "latest AI news",但人工觉得应该搜索更具体的内容
from langchain_core.messages import AIMessage
# 构造修改后的消息
modified_message = AIMessage(
content="",
id=current_messages[-1].id, # 复用原消息的 id,触发覆盖而非追加
tool_calls=[{
"id": current_messages[-1].tool_calls[0]["id"],
"name": "tavily_search",
"args": {"query": "GPT-5 release date 2024"} # 人工修改了搜索词
}]
)
# 3. 更新图的 State(使用 update_state)
graph.update_state(
config,
{"messages": [modified_message]}, # id 相同 → add_messages reducer 原地覆盖该条消息
as_node="agent" # 以 agent 节点的视角更新
)
# 4. 恢复执行(图会使用修改后的 State 继续运行)
result = graph.invoke(None, config)
用户输入问题,图开始运行。LLM 分析问题,决定需要调用工具(如搜索、执行代码)。
在 tools 节点执行前(interrupt_before=["tools"]),图自动暂停,将当前 State 存档到 Checkpointer。snapshot.next = ('tools',)。
人工通过 graph.get_state(config) 查看当前状态:LLM 想做什么(工具调用参数)、当前对话历史等。
如果人工不满意,使用 graph.update_state() 修改 State,例如更正工具调用参数、注入额外上下文信息,然后恢复执行。
如果人工满意,调用 graph.invoke(None, config),图从中断处继续执行,完成工具调用并返回最终结果。
LLM 生成代码后,在执行前暂停,让开发者审查代码内容,确认没有危险操作后才允许运行。
Agent 起草完邮件后暂停,人工检查收件人、正文是否正确,确认无误后才真正发送。
在执行 INSERT/UPDATE/DELETE 前中断,展示即将执行的 SQL,人工确认后才执行,防止误操作。
AI 生成内容后暂停,编辑人员审核是否符合规范,修改或批准后再发布到平台。
流式输出和中断机制共同构成了 Human-in-the-loop 的基础设施:流式输出让人实时看到 Agent 的"思考过程",中断机制让人在关键节点干预 Agent 的"行动"。两者结合,就是一个既透明又可控的 AI Agent 系统。
stream_mode="updates" — 节点增量stream_mode="values" — 完整 Stateastream_events() — Token 级"messages" 模式interrupt_before — 节点执行前暂停interrupt_after — 节点执行后暂停get_state() — 查看暂停时的 Stateinvoke(None, config) — 恢复执行.stream(input, config, stream_mode=).astream_events(input, config, version=).get_state(config).update_state(config, values, as_node=)