Files
langgraph-learning-examples/08_streaming.py

255 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""LangGraph Streaming 示例:节点更新 + 模型 Token 流。
图结构:
START -> model
| 有 tool_calls
v
tools
|
+---------> model
model -- 无 tool_calls --> END
本课重点不是工具本身,而是三种 stream_mode:
1. updates —— 每个节点执行完后,推送该节点的状态增量
2. messages —— 模型生成时,逐 token 推送 AIMessageChunk
3. 同时开两者 —— 一边打字机输出,一边看到节点何时完成
运行:
uv run --with langgraph --with langchain-openai --with python-dotenv \
python 08_streaming.py
"""
import os
from typing import Literal
from dotenv import load_dotenv
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
load_dotenv()
# -----------------------------------------------------------------------------
# 1. 工具与模型(沿用上一课的天气查询,方便观察节点切换)
# -----------------------------------------------------------------------------
@tool
def get_weather(city: str) -> str:
"""查询指定城市当前的天气。城市名称使用中文,例如北京、上海。"""
weather_data = {
"北京": "晴,25°C,北风 2 级",
"上海": "多云,28°C,东南风 3 级",
"广州": "雷阵雨,30°C,南风 2 级",
"深圳": "阴,29°C,东风 2 级",
}
return weather_data.get(city, f"暂时没有 {city} 的天气数据")
tools = [get_weather]
model = ChatOpenAI(
model=os.getenv("OPENAI_MODEL", "openai/gpt-5.4"),
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.qnaigc.com/v1"),
temperature=0,
max_retries=2,
)
model_with_tools = model.bind_tools(tools)
def call_model(state: MessagesState) -> dict:
# 即使这里写的是 .invoke,stream_mode="messages" 仍能拿到 token 流。
# LangGraph 会自动把内部 LLM 调用的流式事件透传出来。
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]}
def route_after_model(state: MessagesState) -> Literal["tools", "__end__"]:
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tools"
return END
tool_node = ToolNode(tools)
builder = StateGraph(MessagesState)
builder.add_node("model", call_model)
builder.add_node("tools", tool_node)
builder.add_edge(START, "model")
builder.add_conditional_edges(
"model",
route_after_model,
{"tools": "tools", END: END},
)
builder.add_edge("tools", "model")
graph = builder.compile()
# -----------------------------------------------------------------------------
# 2. 演示 A:stream_mode="updates" —— 看节点级增量
# -----------------------------------------------------------------------------
def demo_updates(question: str) -> None:
"""每个节点跑完才出一包数据:{节点名: 该节点返回的状态增量}。"""
print("\n--- stream_mode='updates' ---")
for event in graph.stream(
{"messages": [{"role": "user", "content": question}]},
stream_mode="updates",
):
# event 形如:{"model": {"messages": [AIMessage(...)]}}
# 或:{"tools": {"messages": [ToolMessage(...)]}}
for node_name, update in event.items():
print(f"\n[{node_name}] 节点完成,增量:")
for message in update.get("messages", []):
message.pretty_print()
# -----------------------------------------------------------------------------
# 3. 演示 B:stream_mode="messages" —— 看模型 Token 流
# -----------------------------------------------------------------------------
def demo_messages(question: str) -> None:
"""模型一边生成,一边吐 AIMessageChunk;适合做打字机效果。"""
print("\n--- stream_mode='messages' ---")
print("助手:", end="", flush=True)
for chunk in graph.stream(
{"messages": [{"role": "user", "content": question}]},
stream_mode="messages",
version="v2",
):
# v2 统一格式:{"type": "messages", "data": (message_chunk, metadata)}
if chunk["type"] != "messages":
continue
message_chunk, metadata = chunk["data"]
# 只打印 model 节点吐出的文本 token;工具调用阶段 content 常为空。
if metadata.get("langgraph_node") != "model":
continue
content = message_chunk.content
if content:
# content 可能是 str,也可能是 content blocks 列表,这里只处理文本。
if isinstance(content, str):
print(content, end="", flush=True)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
print(block.get("text", ""), end="", flush=True)
elif isinstance(block, str):
print(block, end="", flush=True)
print() # 换行
# -----------------------------------------------------------------------------
# 4. 演示 C:同时开 updates + messages —— 最实用的组合
# -----------------------------------------------------------------------------
def demo_both(question: str) -> None:
"""一边流式打印 token,一边在节点结束时打印节点名。"""
print("\n--- stream_mode=['updates', 'messages'] ---")
print("助手:", end="", flush=True)
printed_tokens = False
for chunk in graph.stream(
{"messages": [{"role": "user", "content": question}]},
stream_mode=["updates", "messages"],
version="v2",
):
if chunk["type"] == "messages":
message_chunk, metadata = chunk["data"]
if metadata.get("langgraph_node") != "model":
continue
content = message_chunk.content
if isinstance(content, str) and content:
print(content, end="", flush=True)
printed_tokens = True
elif isinstance(content, list):
for block in content:
text = ""
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
elif isinstance(block, str):
text = block
if text:
print(text, end="", flush=True)
printed_tokens = True
elif chunk["type"] == "updates":
# 节点结束后再换行并标注,避免和 token 混在同一行。
for node_name in chunk["data"]:
if printed_tokens:
print()
printed_tokens = False
print(f" ✓ 节点 [{node_name}] 完成")
if node_name == "model":
print("助手:", end="", flush=True)
if printed_tokens:
print()
# -----------------------------------------------------------------------------
# 5. 入口:依次跑三种模式,对比输出差异
# -----------------------------------------------------------------------------
def main() -> None:
print("=== LangGraph Streaming:节点更新 + Token 流 ===")
print("建议先问:北京天气怎么样? (会触发 tools -> model 两跳)")
print("或问:用一句话介绍 LangGraph。 (只走 model)")
print("输入 quit 退出。\n")
print("命令:")
print(" /u 只看 updates")
print(" /m 只看 messages(token)")
print(" /b 同时看两者(默认)")
mode = "both"
while True:
question = input(f"\n用户[{mode}]:").strip()
if question.lower() in {"quit", "exit", "q"}:
break
if not question:
continue
if question == "/u":
mode = "updates"
print("已切换到 updates")
continue
if question == "/m":
mode = "messages"
print("已切换到 messages")
continue
if question == "/b":
mode = "both"
print("已切换到 both")
continue
try:
if mode == "updates":
demo_updates(question)
elif mode == "messages":
demo_messages(question)
else:
demo_both(question)
except Exception as exc:
print(f"\n执行失败:{type(exc).__name__}: {exc}")
if __name__ == "__main__":
main()