Add advanced LangGraph learning examples and guides

This commit is contained in:
Eric Wang
2026-07-26 22:49:38 +08:00
parent df11b2b526
commit caea405d28
18 changed files with 3097 additions and 544 deletions

5
.gitignore vendored
View File

@@ -19,6 +19,11 @@ env/
# Local uv environment/version files # Local uv environment/version files
.python-version .python-version
# Local persistence demo data
*.sqlite
*.sqlite3
*.db
# IDE and OS files # IDE and OS files
.vscode/ .vscode/
.idea/ .idea/

218
11_structured_output.py Normal file
View File

@@ -0,0 +1,218 @@
"""LangGraph Structured Output 示例:用 Pydantic 约束模型返回值。
图结构:
START -> classify -> extract -> summarize -> END
本课重点:
1. with_structured_output(Schema) —— 模型直接返回 Pydantic 实例,不必手写 JSON 解析
2. Field(description=...) —— 字段说明会进入 schema引导模型填对内容
3. 把结构化结果放进 State —— 后续节点可以直接读 .category / .persons 等字段
运行:
uv run --with langgraph --with langchain-openai --with python-dotenv \
python 11_structured_output.py
"""
import os
from typing import Literal
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
load_dotenv()
# -----------------------------------------------------------------------------
# 1. Pydantic Schema定义“模型必须返回什么形状”
# -----------------------------------------------------------------------------
class Classification(BaseModel):
"""对用户文本的分类结果。"""
category: Literal["question", "complaint", "praise", "other"] = Field(
description="文本所属类别"
)
confidence: float = Field(description="置信度0 到 1 之间", ge=0, le=1)
reason: str = Field(description="一句话说明分类依据")
class Extraction(BaseModel):
"""从文本中抽出的关键信息。"""
persons: list[str] = Field(default_factory=list, description="提到的人名")
locations: list[str] = Field(default_factory=list, description="提到的地点")
keywords: list[str] = Field(default_factory=list, description="3 到 5 个关键词")
language: Literal["zh", "en", "other"] = Field(description="文本主要语言")
class Summary(BaseModel):
"""综合摘要。"""
summary: str = Field(description="两到三句话的中文摘要")
next_action: str = Field(description="建议的下一步动作,一句话")
# -----------------------------------------------------------------------------
# 2. State节点之间传递文本 + 结构化结果
# -----------------------------------------------------------------------------
class AnalysisState(TypedDict):
text: str
classification: Classification | None
extraction: Extraction | None
summary: Summary | None
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,
)
# 每个 schema 对应一个“只会返回该类型”的模型包装。
classify_model = model.with_structured_output(Classification)
extract_model = model.with_structured_output(Extraction)
summary_model = model.with_structured_output(Summary)
# -----------------------------------------------------------------------------
# 3. 节点invoke 的返回值已经是 Pydantic 对象
# -----------------------------------------------------------------------------
def classify(state: AnalysisState) -> dict:
result = classify_model.invoke(
f"请对下面文本分类。\n\n文本:{state['text']}"
)
print(
f"[classify] {result.category} "
f"(confidence={result.confidence:.0%}) — {result.reason}"
)
return {"classification": result}
def extract(state: AnalysisState) -> dict:
result = extract_model.invoke(
f"请从下面文本提取实体与关键词。\n\n文本:{state['text']}"
)
print(
f"[extract] persons={result.persons} "
f"locations={result.locations} keywords={result.keywords}"
)
return {"extraction": result}
def summarize(state: AnalysisState) -> dict:
classification = state["classification"]
extraction = state["extraction"]
# 后续节点可以直接使用前面节点产出的结构化字段,无需再解析字符串。
prompt = f"""
根据以下信息生成摘要与下一步建议。
原文:{state["text"]}
类别:{classification.category if classification else "unknown"}
分类理由:{classification.reason if classification else ""}
人名:{extraction.persons if extraction else []}
地点:{extraction.locations if extraction else []}
关键词:{extraction.keywords if extraction else []}
"""
result = summary_model.invoke(prompt)
print(f"[summarize] {result.summary}")
print(f"[summarize] next_action: {result.next_action}")
return {"summary": result}
# -----------------------------------------------------------------------------
# 4. 构图
# -----------------------------------------------------------------------------
builder = StateGraph(AnalysisState)
builder.add_node("classify", classify)
builder.add_node("extract", extract)
builder.add_node("summarize", summarize)
builder.add_edge(START, "classify")
builder.add_edge("classify", "extract")
builder.add_edge("extract", "summarize")
builder.add_edge("summarize", END)
graph = builder.compile()
# -----------------------------------------------------------------------------
# 5. 运行
# -----------------------------------------------------------------------------
SAMPLES = [
"今天天气真好,我和小明一起去上海外滩散步,大家都很开心!",
"你们的 App 登录总是失败,我已经重试三次了,非常生气。",
"LangGraph 里 with_structured_output 是怎么工作的?",
]
def run_once(text: str) -> None:
print(f"\n输入:{text}")
print("-" * 50)
result = graph.invoke(
{
"text": text,
"classification": None,
"extraction": None,
"summary": None,
}
)
classification: Classification = result["classification"]
extraction: Extraction = result["extraction"]
summary: Summary = result["summary"]
print("-" * 50)
print("结构化结果(可直接当对象用,不是 JSON 字符串):")
print(f" category = {classification.category}")
print(f" confidence = {classification.confidence}")
print(f" persons = {extraction.persons}")
print(f" locations = {extraction.locations}")
print(f" keywords = {extraction.keywords}")
print(f" summary = {summary.summary}")
print(f" next_action= {summary.next_action}")
def main() -> None:
print("=== LangGraph Structured Output ===")
print("输入文本进行分析;输入 /demo 跑内置样例;输入 quit 退出。")
while True:
text = input("\n文本:").strip()
if text.lower() in {"quit", "exit", "q"}:
break
if not text:
continue
if text == "/demo":
for sample in SAMPLES:
try:
run_once(sample)
except Exception as exc:
print(f"失败:{type(exc).__name__}: {exc}")
continue
try:
run_once(text)
except Exception as exc:
print(f"失败:{type(exc).__name__}: {exc}")
if __name__ == "__main__":
main()

268
12_runtime_context.py Normal file
View File

@@ -0,0 +1,268 @@
"""LangGraph Runtime Context 示例。
本例演示三类数据的职责:
1. State工作流在执行过程中产生和修改的数据。
2. ConfigLangGraph 执行配置,如 thread_id、tags、metadata、recursion_limit。
3. Runtime Context节点运行所需的只读依赖和本次运行参数。
图结构:
START -> load_profile -> generate_answer -> END
运行:
python 12_runtime_context.py
本例不调用真实 LLM 或数据库,可以直接运行。
"""
from dataclasses import dataclass
from typing import Protocol, TypedDict
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
# -----------------------------------------------------------------------------
# 1. State会变化、需要在节点间传递、需要进入 Checkpoint 的业务数据
# -----------------------------------------------------------------------------
class AssistantState(TypedDict):
question: str
profile: dict[str, str]
answer: str
# -----------------------------------------------------------------------------
# 2. 依赖接口与测试实现
# -----------------------------------------------------------------------------
class ProfileService(Protocol):
"""用户资料服务接口。
节点只依赖这个接口,不依赖具体数据库实现,方便测试和替换。
"""
def get_profile(self, user_id: str) -> dict[str, str]:
"""根据用户 ID 返回原始资料。"""
...
class FakeProfileService:
"""内存版资料服务;实际项目中可以替换成数据库/API 实现。"""
def __init__(self) -> None:
self._profiles = {
"user-001": {
"name": "小明",
"role": "Python 初学者",
"preference": "喜欢结合代码解释",
},
"user-002": {
"name": "Alice",
"role": "Backend Engineer",
"preference": "prefers concise technical explanations",
},
}
def get_profile(self, user_id: str) -> dict[str, str]:
print(f" [ProfileService] 查询用户:{user_id}")
return self._profiles.get(
user_id,
{
"name": "访客",
"role": "未知",
"preference": "无特殊偏好",
},
).copy()
# -----------------------------------------------------------------------------
# 3. Runtime Context只读运行参数和依赖
# -----------------------------------------------------------------------------
@dataclass(frozen=True)
class AppContext:
# 本次运行服务于哪个用户。
user_id: str
# 本次回答使用哪种语言。
language: str
# 依赖对象不放进 State避免被 checkpoint、stream 或输出序列化。
profile_service: ProfileService
# -----------------------------------------------------------------------------
# 4. 节点
# -----------------------------------------------------------------------------
def load_profile(
state: AssistantState,
runtime: Runtime[AppContext],
config: RunnableConfig,
) -> dict:
"""通过 Runtime Context 中的服务加载资料,并将结果写入 State。"""
context = runtime.context
# Config 属于 LangGraph/本次调用的执行配置,不是业务状态。
lesson = config.get("metadata", {}).get("lesson", "unknown")
print(f"[load_profile] lesson={lesson}, user_id={context.user_id}")
profile = context.profile_service.get_profile(context.user_id)
return {"profile": profile}
def generate_answer(
state: AssistantState,
runtime: Runtime[AppContext],
config: RunnableConfig,
) -> dict:
"""同时读取 State、Context 和 Config生成个性化回答。"""
profile = state["profile"]
context = runtime.context
tags = config.get("tags", [])
print(
"[generate_answer] "
f"language={context.language}, tags={tags}, "
f"thread_id={runtime.execution_info.thread_id}"
)
if context.language == "zh-CN":
answer = (
f"{profile['name']},你好!你问的是:{state['question']}\n"
f"考虑到你是{profile['role']},并且{profile['preference']}"
"建议先掌握State 保存业务数据Context 提供运行依赖,"
"Config 控制图如何执行。"
)
else:
answer = (
f"Hello {profile['name']}! Your question is: {state['question']}\n"
f"Since you are a {profile['role']} who {profile['preference']}, "
"remember: State stores workflow data, Context supplies runtime "
"dependencies, and Config controls graph execution."
)
return {"answer": answer}
# -----------------------------------------------------------------------------
# 5. 构建图
# -----------------------------------------------------------------------------
builder = StateGraph(
AssistantState,
context_schema=AppContext,
)
builder.add_node("load_profile", load_profile)
builder.add_node("generate_answer", generate_answer)
builder.add_edge(START, "load_profile")
builder.add_edge("load_profile", "generate_answer")
builder.add_edge("generate_answer", END)
# Checkpointer 让 thread_id 对应的 State 可以保存Context 不会因此变成 State。
graph = builder.compile(checkpointer=InMemorySaver())
# -----------------------------------------------------------------------------
# 6. 调用辅助函数
# -----------------------------------------------------------------------------
def run_for_user(
*,
user_id: str,
language: str,
question: str,
thread_id: str,
profile_service: ProfileService,
) -> dict:
"""用同一个已编译图,为不同用户注入不同 Context。"""
# State会在节点之间传递并保存到该 thread 的 checkpoint。
state_input: AssistantState = {
"question": question,
"profile": {},
"answer": "",
}
# Config控制 LangGraph 如何执行,并携带追踪标签/元数据。
config: RunnableConfig = {
"configurable": {"thread_id": thread_id},
"recursion_limit": 10,
"tags": ["runtime-context-demo", language],
"metadata": {"lesson": 12},
}
# Context本次调用的只读参数与依赖。恢复或下一轮调用时应再次提供。
context = AppContext(
user_id=user_id,
language=language,
profile_service=profile_service,
)
return graph.invoke(
state_input,
config=config,
context=context,
)
# -----------------------------------------------------------------------------
# 7. 演示
# -----------------------------------------------------------------------------
def main() -> None:
service = FakeProfileService()
print("=== 调用 1同一个图服务中文用户 ===")
result_1 = run_for_user(
user_id="user-001",
language="zh-CN",
question="Runtime Context 是什么?",
thread_id="thread-user-001",
profile_service=service,
)
print("\n最终回答:")
print(result_1["answer"])
print("\n=== 调用 2同一个图服务英文用户 ===")
result_2 = run_for_user(
user_id="user-002",
language="en-US",
question="What belongs in Runtime Context?",
thread_id="thread-user-002",
profile_service=service,
)
print("\nFinal answer:")
print(result_2["answer"])
print("\n=== 调用 3同一用户的新一轮问题 ===")
result_3 = run_for_user(
user_id="user-001",
language="zh-CN",
question="数据库连接应该放在哪里?",
thread_id="thread-user-001",
profile_service=service,
)
print("\n最终回答:")
print(result_3["answer"])
print("\n=== 职责总结 ===")
print("State question、profile、answer会变化并进入 checkpoint。")
print("Config thread_id、recursion_limit、tags、metadata控制执行。")
print("Context user_id、language、profile_service为节点提供只读依赖。")
print("同一个 graph 对象被三个调用复用,没有为不同用户重新编译。")
if __name__ == "__main__":
main()

100
13_subgraphs.py Normal file
View File

@@ -0,0 +1,100 @@
"""13 - Subgraphs把一个已编译的图作为父图节点。
父图START -> prepare -> research(subgraph) -> write_answer -> END
子图START -> search_web -> filter_results -> END
运行python 13_subgraphs.py
"""
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
class ResearchState(TypedDict):
question: str
query: str
raw_results: list[str]
filtered_results: list[str]
answer: str
def search_web(state: ResearchState) -> dict:
print(" [子图/search_web] 搜索:", state["query"])
return {
"raw_results": [
"LangGraph 支持持久化、流式输出和人工介入",
"无关内容:今天适合散步",
"LangGraph 可以使用子图拆分复杂工作流",
]
}
def filter_results(state: ResearchState) -> dict:
print(" [子图/filter_results] 过滤结果")
return {
"filtered_results": [
item for item in state["raw_results"] if "LangGraph" in item
]
}
research_builder = StateGraph(ResearchState)
research_builder.add_node("search_web", search_web)
research_builder.add_node("filter_results", filter_results)
research_builder.add_edge(START, "search_web")
research_builder.add_edge("search_web", "filter_results")
research_builder.add_edge("filter_results", END)
research_graph = research_builder.compile()
def prepare(state: ResearchState) -> dict:
print("[父图/prepare] 准备查询")
return {"query": state["question"].strip()}
def write_answer(state: ResearchState) -> dict:
print("[父图/write_answer] 生成回答")
evidence = "\n".join(f"- {item}" for item in state["filtered_results"])
return {"answer": f"问题:{state['question']}\n参考资料:\n{evidence}"}
parent_builder = StateGraph(ResearchState)
parent_builder.add_node("prepare", prepare)
# 编译后的子图可以直接作为一个父图节点;双方共享同一 State schema。
parent_builder.add_node("research", research_graph)
parent_builder.add_node("write_answer", write_answer)
parent_builder.add_edge(START, "prepare")
parent_builder.add_edge("prepare", "research")
parent_builder.add_edge("research", "write_answer")
parent_builder.add_edge("write_answer", END)
graph = parent_builder.compile()
def main() -> None:
initial: ResearchState = {
"question": "LangGraph 为什么适合复杂 Agent",
"query": "",
"raw_results": [],
"filtered_results": [],
"answer": "",
}
print("=== 普通调用 ===")
result = graph.invoke(initial)
print("\n", result["answer"])
print("\n=== Streaming包含子图内部事件 ===")
for part in graph.stream(
initial,
stream_mode="updates",
subgraphs=True,
version="v2",
):
if part["type"] == "updates":
location = "父图" if not part["ns"] else f"子图 {part['ns']}"
print(f"{location}: {list(part['data'])}")
if __name__ == "__main__":
main()

87
14_map_reduce_send.py Normal file
View File

@@ -0,0 +1,87 @@
"""14 - 动态 Map-Reduce 与 Send。
START -> generate_topics -> N 个 research_topic动态并行 -> combine -> END
运行python 14_map_reduce_send.py
"""
import operator
import time
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.types import Send
class OverallState(TypedDict):
subject: str
topics: list[str]
# 多个动态并行节点会同时写 summaries必须配置 reducer。
summaries: Annotated[list[str], operator.add]
final_report: str
class TopicState(TypedDict):
"""发送给每个 map 节点的私有输入。"""
topic: str
def generate_topics(state: OverallState) -> dict:
print("[generate_topics] 生成子任务")
return {
"topics": [
f"{state['subject']} 的 State",
f"{state['subject']} 的 Persistence",
f"{state['subject']} 的 Human-in-the-loop",
]
}
def fan_out(state: OverallState) -> list[Send]:
"""运行时才知道分支数量;每个 Send 都会调度一次 research_topic。"""
return [Send("research_topic", {"topic": topic}) for topic in state["topics"]]
def research_topic(state: TopicState) -> dict:
print(f" [research_topic] 开始:{state['topic']}")
time.sleep(0.5)
print(f" [research_topic] 完成:{state['topic']}")
return {"summaries": [f"{state['topic']}:这是对应的研究摘要。"]}
def combine(state: OverallState) -> dict:
print(f"[combine] 汇总 {len(state['summaries'])} 个结果")
return {"final_report": "\n".join(state["summaries"])}
builder = StateGraph(OverallState)
builder.add_node("generate_topics", generate_topics)
builder.add_node("research_topic", research_topic)
builder.add_node("combine", combine)
builder.add_edge(START, "generate_topics")
builder.add_conditional_edges("generate_topics", fan_out, ["research_topic"])
builder.add_edge("research_topic", "combine")
builder.add_edge("combine", END)
graph = builder.compile()
def main() -> None:
started = time.perf_counter()
result = graph.invoke(
{
"subject": "LangGraph",
"topics": [],
"summaries": [],
"final_report": "",
}
)
elapsed = time.perf_counter() - started
print(f"\n耗时:{elapsed:.2f} 秒(三个任务各 0.5 秒,但动态并行)")
print("\n最终报告:")
print(result["final_report"])
if __name__ == "__main__":
main()

98
15_long_term_store.py Normal file
View File

@@ -0,0 +1,98 @@
"""15 - Long-term Store跨 thread 保存用户记忆。
Checkpointer 保存一条 thread 的执行状态Store 保存跨 thread 的长期数据。
本例使用 InMemoryStore进程退出后会清空仅用于学习。
运行python 15_long_term_store.py
"""
from dataclasses import dataclass
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
from langgraph.store.memory import InMemoryStore
class MemoryState(TypedDict):
message: str
reply: str
@dataclass(frozen=True)
class Context:
user_id: str
def remember_and_reply(
state: MemoryState,
runtime: Runtime[Context],
) -> dict:
"""读取长期偏好;若用户提供新偏好则写入 Store。"""
if runtime.store is None:
raise RuntimeError("此图需要在 compile(store=...) 时提供 Store")
namespace = ("users", runtime.context.user_id, "preferences")
message = state["message"]
# 简单教学协议“记住xxx”会写入跨线程长期记忆。
if message.startswith("记住:"):
preference = message.removeprefix("记住:").strip()
runtime.store.put(
namespace,
"response_style",
{"preference": preference},
)
return {"reply": f"已经长期记住你的偏好:{preference}"}
item = runtime.store.get(namespace, "response_style")
preference = item.value["preference"] if item else "暂无偏好"
return {
"reply": (
f"用户 {runtime.context.user_id} 的长期偏好是:{preference}"
f"本次消息:{message}"
)
}
store = InMemoryStore()
checkpointer = InMemorySaver()
builder = StateGraph(MemoryState, context_schema=Context)
builder.add_node("remember_and_reply", remember_and_reply)
builder.add_edge(START, "remember_and_reply")
builder.add_edge("remember_and_reply", END)
# Store 和 Checkpointer 是两个不同参数、两套不同职责。
graph = builder.compile(checkpointer=checkpointer, store=store)
def invoke(user_id: str, thread_id: str, message: str) -> str:
result = graph.invoke(
{"message": message, "reply": ""},
config={"configurable": {"thread_id": thread_id}},
context=Context(user_id=user_id),
)
return result["reply"]
def main() -> None:
print("=== Thread A保存长期偏好 ===")
print(invoke("user-001", "thread-A", "记住:回答时多给代码示例"))
print("\n=== Thread B新线程仍能读取同一用户的偏好 ===")
print(invoke("user-001", "thread-B", "解释一下 Reducer"))
print("\n=== Thread C另一个用户没有该偏好 ===")
print(invoke("user-002", "thread-C", "解释一下 Reducer"))
print("\n=== 直接查看 Store ===")
namespace = ("users", "user-001", "preferences")
for item in store.search(namespace):
print(f"namespace={item.namespace}, key={item.key}, value={item.value}")
print("\n结论thread_id 隔离短期执行状态user_id namespace 隔离长期记忆。")
if __name__ == "__main__":
main()

105
16_functional_api.py Normal file
View File

@@ -0,0 +1,105 @@
"""16 - Functional API用普通 Python 控制流构建可持久化工作流。
Graph API 显式声明节点和边Functional API 使用 @entrypoint、@task、if/while。
本例演示 task、并行 future、重试、短期记忆(previous)和 streaming。
运行python 16_functional_api.py
"""
import time
from dataclasses import dataclass
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.func import entrypoint, task
from langgraph.runtime import Runtime
from langgraph.types import RetryPolicy
@dataclass(frozen=True)
class Context:
user_id: str
# task 是可独立重试、追踪和 checkpoint 的工作单元。
@task(
retry_policy=RetryPolicy(
max_attempts=3,
initial_interval=0.1,
jitter=False,
retry_on=ConnectionError,
)
)
def research(topic: str) -> str:
print(f" [task/research] {topic}")
time.sleep(0.2)
return f"{topic} 的研究结果"
@task
def write_summary(question: str, findings: list[str], user_id: str) -> str:
print(" [task/write_summary] 汇总")
bullets = "\n".join(f"- {item}" for item in findings)
return f"用户 {user_id},问题:{question}\n{bullets}"
checkpointer = InMemorySaver()
@entrypoint(checkpointer=checkpointer, context_schema=Context)
def assistant(
question: str,
*,
previous: str | None = None,
runtime: Runtime[Context],
) -> entrypoint.final[str, str]:
"""入口函数就是工作流;普通 if/list comprehension 表达控制流。"""
topics = ["State", "Persistence", "Functional API"]
# 调用 @task 返回 future先全部调度再读取结果可并行执行。
futures = [research(topic) for topic in topics]
findings = [future.result() for future in futures]
if previous:
findings.append(f"同一 thread 上一次保存的摘要:{previous[:50]}")
summary = write_summary(
question,
findings,
runtime.context.user_id,
).result()
# value 是本次调用返回值save 会成为下次同 thread 的 previous。
return entrypoint.final(value=summary, save=summary)
def invoke(question: str, thread_id: str) -> str:
return assistant.invoke(
question,
config={"configurable": {"thread_id": thread_id}},
context=Context(user_id="user-001"),
)
def main() -> None:
print("=== 第一次调用 ===")
print(invoke("Graph API 和 Functional API 有什么区别?", "functional-1"))
print("\n=== 同一 thread 第二次调用:可以读取 previous ===")
print(invoke("请结合上次结果继续说明。", "functional-1"))
print("\n=== Streaming查看 task/entrypoint 更新 ===")
for part in assistant.stream(
"Functional API 适合什么场景?",
config={"configurable": {"thread_id": "functional-2"}},
context=Context(user_id="user-002"),
stream_mode="updates",
version="v2",
):
if part["type"] == "updates":
print(part["data"])
print("\n结论Functional API 保留普通 Python 写法,同时获得 task、重试和持久化。")
if __name__ == "__main__":
main()

112
17_state_history.py Normal file
View File

@@ -0,0 +1,112 @@
"""17 - Checkpoint state history, updates, replay, and forks.
This example is completely local: ``InMemorySaver`` stores checkpoints in RAM.
A checkpoint config (thread_id + checkpoint_id) identifies one exact point in time.
Run: python 17_state_history.py
"""
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
value: int
note: str
def increment(state: State) -> dict:
"""First deterministic step."""
return {"value": state["value"] + 1, "note": "incremented"}
def double(state: State) -> dict:
"""Second deterministic step."""
return {"value": state["value"] * 2, "note": "doubled"}
def build_graph():
builder = StateGraph(State)
builder.add_node("increment", increment)
builder.add_node("double", double)
builder.add_edge(START, "increment")
builder.add_edge("increment", "double")
builder.add_edge("double", END)
return builder.compile(checkpointer=InMemorySaver())
def short_config(config: dict) -> dict:
"""Only display the portable checkpoint identity, not internal metadata."""
configurable = config["configurable"]
return {
"thread_id": configurable["thread_id"],
"checkpoint_id": configurable.get("checkpoint_id"),
}
def main() -> None:
graph = build_graph()
thread_config = {"configurable": {"thread_id": "history-demo"}}
print("=== Initial run ===")
result = graph.invoke({"value": 3, "note": "input"}, thread_config)
print("result:", result)
assert result["value"] == 8
# get_state with only a thread_id returns that thread's latest snapshot.
latest = graph.get_state(thread_config)
print("latest values:", latest.values)
print("latest next:", latest.next)
print("latest config:", short_config(latest.config))
# History is returned newest first. Every StateSnapshot has values, next,
# config, metadata, created_at, parent_config, and tasks.
history = list(graph.get_state_history(thread_config))
print("\n=== State history (newest first) ===")
for index, snapshot in enumerate(history):
print(
index,
"values=", snapshot.values,
"next=", snapshot.next,
"checkpoint=", short_config(snapshot.config),
)
# Select the checkpoint immediately after `increment`: `double` is pending.
before_double = next(snapshot for snapshot in history if snapshot.next == ("double",))
checkpoint_config = before_double.config
assert before_double.values["value"] == 4
print("\n=== Replay from an historical checkpoint ===")
# Passing the snapshot's config and None resumes its pending work. Earlier
# nodes are not rerun, so only `double` executes.
replayed = graph.invoke(None, checkpoint_config)
print("replayed:", replayed)
assert replayed["value"] == 8
print("\n=== Fork by updating an historical checkpoint ===")
# update_state does not mutate history. It creates a new checkpoint derived
# from checkpoint_config. The returned config identifies the new branch.
fork_config = graph.update_state(
checkpoint_config,
{"value": 10, "note": "human correction"},
)
fork_snapshot = graph.get_state(fork_config)
print("fork before resume:", fork_snapshot.values, "next=", fork_snapshot.next)
print("fork config:", short_config(fork_config))
forked = graph.invoke(None, fork_config)
print("forked result:", forked)
assert forked["value"] == 20
# The old exact checkpoint still contains 4; checkpoint configs make
# time-travel explicit even after the thread acquires newer branches.
old_snapshot = graph.get_state(checkpoint_config)
assert old_snapshot.values["value"] == 4
print("original historical value is still:", old_snapshot.values["value"])
print("\nAll state-history demonstrations passed.")
if __name__ == "__main__":
main()

101
18_testing.py Normal file
View File

@@ -0,0 +1,101 @@
"""18 - Unit testing LangGraph nodes, routers, and compiled graphs.
All behavior is deterministic and local: no LLM, API key, or network is used.
Run directly to execute the unittest suite: python 18_testing.py
"""
import unittest
from typing import Literal, TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
text: str
normalized: str
result: str
# Keep nodes and routers as ordinary functions. They can be tested cheaply and
# precisely without compiling or invoking a graph.
def normalize(state: State) -> dict[str, str]:
return {"normalized": state["text"].strip().lower()}
def route_by_content(state: State) -> Literal["accept", "reject"]:
return "accept" if state["normalized"] == "langgraph" else "reject"
def accept(_: State) -> dict[str, str]:
return {"result": "accepted"}
def reject(_: State) -> dict[str, str]:
return {"result": "rejected"}
def build_graph():
builder = StateGraph(State)
builder.add_node("normalize", normalize)
builder.add_node("accept", accept)
builder.add_node("reject", reject)
builder.add_edge(START, "normalize")
builder.add_conditional_edges(
"normalize",
route_by_content,
{"accept": "accept", "reject": "reject"},
)
builder.add_edge("accept", END)
builder.add_edge("reject", END)
return builder.compile()
class NodeTests(unittest.TestCase):
"""Pure node tests isolate transformation logic."""
def test_normalize_strips_and_lowercases(self) -> None:
state: State = {"text": " LangGraph ", "normalized": "", "result": ""}
self.assertEqual(normalize(state), {"normalized": "langgraph"})
def test_terminal_nodes_are_deterministic(self) -> None:
state: State = {"text": "", "normalized": "", "result": ""}
self.assertEqual(accept(state), {"result": "accepted"})
self.assertEqual(reject(state), {"result": "rejected"})
class RouterTests(unittest.TestCase):
"""Router tests cover every branch without running the graph."""
def test_accept_route(self) -> None:
state: State = {"text": "", "normalized": "langgraph", "result": ""}
self.assertEqual(route_by_content(state), "accept")
def test_reject_route(self) -> None:
state: State = {"text": "", "normalized": "other", "result": ""}
self.assertEqual(route_by_content(state), "reject")
class GraphTests(unittest.TestCase):
"""Integration tests verify wiring and final state for both paths."""
@classmethod
def setUpClass(cls) -> None:
cls.graph = build_graph()
def test_graph_accepts_normalized_match(self) -> None:
output = self.graph.invoke(
{"text": " LANGGRAPH ", "normalized": "", "result": ""}
)
self.assertEqual(output["normalized"], "langgraph")
self.assertEqual(output["result"], "accepted")
def test_graph_rejects_non_match(self) -> None:
output = self.graph.invoke(
{"text": "LangChain", "normalized": "", "result": ""}
)
self.assertEqual(output["normalized"], "langchain")
self.assertEqual(output["result"], "rejected")
if __name__ == "__main__":
unittest.main(verbosity=2)

87
19_observability.py Normal file
View File

@@ -0,0 +1,87 @@
"""LangGraph 1.2.9 observability without an LLM or required credentials.
This example demonstrates runnable tags/metadata, custom stream events, and safe
execution information. LangSmith tracing is opt-in: set LANGSMITH_TRACING=true
and LANGSMITH_API_KEY in your shell. Never put keys in source code.
"""
from __future__ import annotations
import os
from typing import TypedDict
from langchain_core.runnables import RunnableConfig
from langgraph.config import get_stream_writer
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
text: str
normalized: str
def normalize(state: State, config: RunnableConfig) -> dict[str, str]:
"""Emit application-specific progress through stream_mode='custom'."""
writer = get_stream_writer()
writer({"stage": "normalize", "status": "started"})
# RunnableConfig is execution context. Only inspect known, non-secret fields.
safe_info = {
"run_name": config.get("run_name"),
"tags": config.get("tags", []),
"metadata": config.get("metadata", {}),
"recursion_limit": config.get("recursion_limit"),
}
writer({"stage": "normalize", "execution_info": safe_info})
result = " ".join(state["text"].strip().lower().split())
writer({"stage": "normalize", "status": "finished", "characters": len(result)})
return {"normalized": result}
def build_graph():
builder = StateGraph(State)
builder.add_node("normalize", normalize)
builder.add_edge(START, "normalize")
builder.add_edge("normalize", END)
return builder.compile(name="observability_demo")
def main() -> None:
tracing_requested = os.getenv("LANGSMITH_TRACING", "").lower() == "true"
key_available = bool(os.getenv("LANGSMITH_API_KEY"))
if tracing_requested and key_available:
print("LangSmith tracing: enabled (credential detected but never displayed).")
elif tracing_requested:
print("LangSmith tracing: requested, but no API key is set; running locally.")
# Prevent an accidental network/auth error while preserving credential-free use.
os.environ["LANGSMITH_TRACING"] = "false"
else:
print("LangSmith tracing: disabled; set LANGSMITH_TRACING=true and "
"LANGSMITH_API_KEY to opt in.")
graph = build_graph()
config: RunnableConfig = {
"run_name": "normalize-example",
"tags": ["tutorial", "observability"],
"metadata": {"example": 19, "environment": "local"},
"recursion_limit": 10,
}
print("\nCombined stream (custom events plus state updates):")
final_update: dict | None = None
for mode, payload in graph.stream(
{"text": " Hello, Observable Graph! ", "normalized": ""},
config=config,
stream_mode=["custom", "updates"],
):
print(f"[{mode}] {payload}")
if mode == "updates":
final_update = payload
assert final_update == {"normalize": {"normalized": "hello, observable graph!"}}
print("\nThe tags/metadata become searchable run attributes when tracing is enabled.")
if __name__ == "__main__":
main()

117
20_persistent_memory.py Normal file
View File

@@ -0,0 +1,117 @@
"""Durable LangGraph persistence with a credential-free SQLite demo.
A checkpointer stores per-thread graph state; a Store keeps cross-thread,
user-scoped long-term memory. For production, use a durable backend, stable
thread/user IDs, migrations, backups, encryption/access controls, retention,
and a connection lifecycle appropriate to concurrent workers.
Install the official SQLite integration when needed:
python -m pip install "langgraph-checkpoint-sqlite>=3,<4"
Run ``python 20_persistent_memory.py --fallback`` for an explicit, non-durable
InMemory demonstration when that optional package cannot be installed.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
class State(TypedDict, total=False):
delta: int
total: int
message: str
@dataclass(frozen=True)
class Context:
user_id: str
def remember(state: State, runtime: Runtime[Context]) -> dict:
"""Update thread state and a user profile stored outside the thread."""
namespace = ("users", runtime.context.user_id)
previous = runtime.store.get(namespace, "profile")
visits = int(previous.value["visits"]) if previous else 0
visits += 1
runtime.store.put(namespace, "profile", {"visits": visits})
total = int(state.get("total", 0)) + int(state.get("delta", 0))
return {"total": total, "message": f"visit={visits}; thread_total={total}"}
def build_graph(checkpointer, store):
builder = StateGraph(State, context_schema=Context)
builder.add_node("remember", remember)
builder.add_edge(START, "remember")
builder.add_edge("remember", END)
return builder.compile(checkpointer=checkpointer, store=store, name="persistence_demo")
def exercise(checkpointer, store, label: str) -> None:
graph = build_graph(checkpointer, store)
config = {"configurable": {"thread_id": "thread-001"}}
context = Context(user_id="user-001")
prior_state = graph.get_state(config).values
prior_total = int(prior_state.get("total", 0)) if prior_state else 0
prior_profile = store.get(("users", "user-001"), "profile")
prior_visits = int(prior_profile.value["visits"]) if prior_profile else 0
first = graph.invoke({"delta": 2}, config=config, context=context)
second = graph.invoke({"delta": 3}, config=config, context=context)
profile = store.get(("users", "user-001"), "profile")
print(f"Backend: {label}")
print("First invocation:", first["message"])
print("Second invocation:", second["message"])
print("Long-term profile:", profile.value if profile else None)
assert second["total"] == prior_total + 5
assert profile and profile.value["visits"] == prior_visits + 2
def run_sqlite(database: Path) -> None:
try:
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.store.sqlite import SqliteStore
except ImportError as exc:
print("Official SQLite integration is unavailable.")
print('Install it with: python -m pip install "langgraph-checkpoint-sqlite>=3,<4"')
print("Or run this script with --fallback for a non-durable demo.")
raise SystemExit(2) from exc
database.parent.mkdir(parents=True, exist_ok=True)
connection_string = str(database)
with SqliteSaver.from_conn_string(connection_string) as checkpointer:
with SqliteStore.from_conn_string(connection_string) as store:
# setup() creates/migrates the local tables and is idempotent.
checkpointer.setup()
store.setup()
exercise(checkpointer, store, f"durable SQLite ({database})")
print("Re-run with the same database to observe persistence across processes.")
def run_fallback() -> None:
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
print("WARNING: fallback data disappears when this process exits.")
exercise(InMemorySaver(), InMemoryStore(), "explicit InMemory fallback")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--db", type=Path, default=Path("persistent_memory.sqlite3"),
help="SQLite file (default: %(default)s)")
parser.add_argument("--fallback", action="store_true",
help="use explicit non-durable InMemory persistence")
args = parser.parse_args()
run_fallback() if args.fallback else run_sqlite(args.db)
if __name__ == "__main__":
main()

130
21_async_graph.py Normal file
View File

@@ -0,0 +1,130 @@
"""LangGraph 1.2.9 异步图示例(不访问真实网络)。
演示async 节点、ainvoke、astream、并行异步 I/O、RetryPolicy以及
1.2.9 的节点 timeout 参数。所有 I/O 都由 asyncio.sleep 模拟。
运行:
python 21_async_graph.py
"""
import asyncio
import time
from typing import Annotated, TypedDict
from langgraph.errors import NodeTimeoutError
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
from langgraph.types import RetryPolicy
def append_items(left: list[str], right: list[str]) -> list[str]:
"""并行分支写同一字段时必须使用 reducer。"""
return left + right
class AsyncState(TypedDict):
query: str
results: Annotated[list[str], append_items]
summary: str
async def fetch_profile(state: AsyncState) -> dict:
"""模拟一个 0.12 秒的异步资料服务。"""
await asyncio.sleep(0.12)
print(" [profile] I/O 完成")
return {"results": [f"profile({state['query']})"]}
async def fetch_orders(state: AsyncState, runtime: Runtime) -> dict:
"""首次失败RetryPolicy 自动重试;不在 State 中保存尝试次数。"""
attempt = runtime.execution_info.node_attempt
await asyncio.sleep(0.12)
print(f" [orders] I/O 完成,第 {attempt} 次尝试")
if attempt == 1:
raise ConnectionError("模拟瞬时网络错误")
return {"results": [f"orders({state['query']})"]}
async def summarize(state: AsyncState) -> dict:
await asyncio.sleep(0.02)
return {"summary": " + ".join(sorted(state["results"]))}
builder = StateGraph(AsyncState)
builder.add_node("profile", fetch_profile)
builder.add_node(
"orders",
fetch_orders,
retry_policy=RetryPolicy(
max_attempts=3,
initial_interval=0.05,
backoff_factor=1.0,
jitter=False,
retry_on=ConnectionError,
),
)
builder.add_node("summarize", summarize)
# 同一 superstep 中的两个节点会并发运行,而不是依次等待。
builder.add_edge(START, "profile")
builder.add_edge(START, "orders")
builder.add_edge("profile", "summarize")
builder.add_edge("orders", "summarize")
builder.add_edge("summarize", END)
graph = builder.compile()
class TimeoutState(TypedDict):
value: str
async def too_slow(_: TimeoutState) -> dict:
try:
await asyncio.sleep(0.20)
return {"value": "不应到达"}
finally:
print(" [slow_node] 协程已结束/被取消")
timeout_builder = StateGraph(TimeoutState)
# LangGraph 1.2.9 的 add_node 原生支持 timeout秒或 timedelta/TimeoutPolicy
timeout_builder.add_node("slow_node", too_slow, timeout=0.05)
timeout_builder.add_edge(START, "slow_node")
timeout_builder.add_edge("slow_node", END)
timeout_graph = timeout_builder.compile()
async def demo_ainvoke() -> None:
print("\n=== ainvoke并行异步 I/O + RetryPolicy ===")
started = time.perf_counter()
result = await graph.ainvoke({"query": "user-42", "results": [], "summary": ""})
elapsed = time.perf_counter() - started
print(" 最终摘要:", result["summary"])
print(f" 总耗时:{elapsed:.2f}s两个 0.12s 分支并发,而非简单串行)")
async def demo_astream() -> None:
print("\n=== astream按节点观察 updates ===")
async for event in graph.astream(
{"query": "stream", "results": [], "summary": ""},
stream_mode="updates",
):
print(" event:", event)
async def demo_timeout() -> None:
print("\n=== 节点 timeout ===")
try:
await timeout_graph.ainvoke({"value": "pending"})
except NodeTimeoutError as exc:
print(f" 已捕获 {type(exc).__name__}slow_node 超过 0.05s")
async def main() -> None:
await demo_ainvoke()
await demo_astream()
await demo_timeout()
print("\n结论:异步图应使用 ainvoke/astream并行分支共享字段需 reducer。")
if __name__ == "__main__":
asyncio.run(main())

212
22_safe_tools.py Normal file
View File

@@ -0,0 +1,212 @@
"""LangGraph 1.2.9 安全工具模式(完全模拟,无真实副作用)。
涵盖 Pydantic 输入校验、Runtime Context 权限、敏感操作 interrupt 审批、
幂等键/仓库和审计日志。所谓“转账”只写入内存仓库。
运行:
python 22_safe_tools.py
"""
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any, Literal, TypedDict
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
from langgraph.types import Command, interrupt
class TransferRequest(BaseModel):
"""工具边界的严格 schema多余字段也拒绝。"""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
idempotency_key: str = Field(min_length=8, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
recipient: str = Field(min_length=3, max_length=60)
amount: Decimal = Field(gt=0, le=Decimal("10000"), max_digits=10, decimal_places=2)
currency: Literal["CNY", "USD"]
memo: str = Field(default="", max_length=100)
@field_validator("recipient")
@classmethod
def recipient_must_not_be_placeholder(cls, value: str) -> str:
if value.lower() in {"unknown", "test"}:
raise ValueError("recipient 不能是占位值")
return value
@dataclass
class IdempotencyRepository:
"""教学用内存幂等仓库;生产环境应使用带唯一约束的事务数据库。"""
records: dict[str, dict[str, Any]] = field(default_factory=dict)
def execute_once(self, key: str, payload: dict[str, Any]) -> tuple[dict[str, Any], bool]:
if key in self.records:
return self.records[key], False
# 仅记录模拟结果:这里绝不连接支付系统。
result = {"simulation_id": f"sim-{len(self.records) + 1}", "payload": payload}
self.records[key] = result
return result, True
@dataclass
class AuditLog:
entries: list[dict[str, Any]] = field(default_factory=list)
def add(self, *, actor: str, event: str, detail: str) -> None:
entry = {"sequence": len(self.entries) + 1, "actor": actor, "event": event, "detail": detail}
self.entries.append(entry)
print(" [audit]", entry)
@dataclass(frozen=True)
class SecurityContext:
"""每次运行注入的只读身份、权限和依赖;不会写入 State/checkpoint。"""
actor: str
permissions: frozenset[str]
repository: IdempotencyRepository
audit: AuditLog
class SafeState(TypedDict):
request: dict[str, Any]
validated: dict[str, Any]
status: str
message: str
def validate_request(state: SafeState, runtime: Runtime[SecurityContext]) -> dict:
try:
request = TransferRequest.model_validate(state["request"])
except ValidationError as exc:
detail = exc.errors(include_url=False)[0]["msg"]
runtime.context.audit.add(actor=runtime.context.actor, event="validation_rejected", detail=detail)
return {"status": "invalid", "message": detail}
# mode="json" 将 Decimal 转成可 checkpoint/展示的 JSON 值。
validated = request.model_dump(mode="json")
runtime.context.audit.add(
actor=runtime.context.actor,
event="validation_passed",
detail=f"key={request.idempotency_key}",
)
return {"validated": validated, "status": "validated", "message": ""}
def route_after_validation(state: SafeState) -> Literal["authorize", "__end__"]:
return "authorize" if state["status"] == "validated" else END
def authorize(state: SafeState, runtime: Runtime[SecurityContext]) -> dict:
if "transfer:simulate" not in runtime.context.permissions:
runtime.context.audit.add(
actor=runtime.context.actor,
event="permission_denied",
detail="missing transfer:simulate",
)
return {"status": "denied", "message": "权限不足,操作未执行"}
runtime.context.audit.add(actor=runtime.context.actor, event="permission_granted", detail="transfer:simulate")
return {"status": "authorized"}
def route_after_authorize(state: SafeState) -> Literal["approval", "__end__"]:
return "approval" if state["status"] == "authorized" else END
def approval(state: SafeState, runtime: Runtime[SecurityContext]) -> Command[Literal["simulate"]]:
"""interrupt 必须位于任何副作用之前;恢复后根据人工决定继续。"""
decision = interrupt(
{
"type": "sensitive_operation_approval",
"message": "是否批准这笔模拟转账?",
"actor": runtime.context.actor,
"request": state["validated"],
}
)
approved = bool(decision.get("approved")) if isinstance(decision, dict) else bool(decision)
if not approved:
runtime.context.audit.add(actor=runtime.context.actor, event="approval_rejected", detail="no execution")
return Command(update={"status": "rejected", "message": "人工拒绝,操作未执行"}, goto=END)
runtime.context.audit.add(actor=runtime.context.actor, event="approval_granted", detail="approved by human")
return Command(goto="simulate")
def simulate_transfer(state: SafeState, runtime: Runtime[SecurityContext]) -> dict:
"""幂等检查与记录应在同一原子仓库操作中完成。仅模拟,无真实转账。"""
payload = state["validated"]
result, created = runtime.context.repository.execute_once(payload["idempotency_key"], payload)
event = "simulation_created" if created else "idempotency_replay"
runtime.context.audit.add(
actor=runtime.context.actor,
event=event,
detail=f"key={payload['idempotency_key']}, id={result['simulation_id']}",
)
message = ("已创建" if created else "幂等命中,复用") + f"模拟结果 {result['simulation_id']}(无真实副作用)"
return {"status": "simulated" if created else "duplicate", "message": message}
builder = StateGraph(SafeState, context_schema=SecurityContext)
builder.add_node("validate", validate_request)
builder.add_node("authorize", authorize)
builder.add_node("approval", approval)
builder.add_node("simulate", simulate_transfer)
builder.add_edge(START, "validate")
builder.add_conditional_edges("validate", route_after_validation)
builder.add_conditional_edges("authorize", route_after_authorize)
builder.add_edge("simulate", END)
graph = builder.compile(checkpointer=InMemorySaver())
def initial_state(request: dict[str, Any]) -> SafeState:
return {"request": request, "validated": {}, "status": "new", "message": ""}
def invoke_with_approval(request: dict[str, Any], context: SecurityContext, thread_id: str) -> dict:
config = {"configurable": {"thread_id": thread_id}}
paused = graph.invoke(initial_state(request), config=config, context=context)
pending = paused.get("__interrupt__", ())
assert pending, "敏感操作应在执行前暂停"
print(" [interrupt]", pending[0].value["message"])
# 实际应用在另一次请求中收集真实审批者的决定;本例固定模拟批准。
return graph.invoke(Command(resume={"approved": True}), config=config, context=context)
def main() -> None:
repository = IdempotencyRepository()
audit = AuditLog()
allowed = SecurityContext("alice", frozenset({"transfer:simulate"}), repository, audit)
denied = SecurityContext("bob", frozenset(), repository, audit)
valid = {
"idempotency_key": "order_2026_001",
"recipient": "merchant-7",
"amount": "88.50",
"currency": "CNY",
"memo": "课程演示",
}
print("=== 1. Pydantic 拒绝非法输入 ===")
bad = graph.invoke(initial_state({**valid, "amount": "-1"}), config={"configurable": {"thread_id": "invalid"}}, context=allowed)
print(" result:", bad["status"], bad["message"])
print("\n=== 2. Runtime Context 权限拒绝 ===")
no_permission = graph.invoke(initial_state(valid), config={"configurable": {"thread_id": "denied"}}, context=denied)
print(" result:", no_permission["status"], no_permission["message"])
print("\n=== 3. interrupt 审批后执行模拟操作 ===")
first = invoke_with_approval(valid, allowed, "approved-1")
print(" result:", first["status"], first["message"])
print("\n=== 4. 同一幂等键再次获批,也不会重复创建 ===")
second = invoke_with_approval(valid, allowed, "approved-2")
print(" result:", second["status"], second["message"])
assert len(repository.records) == 1
print(f"\n验证完成:仓库记录={len(repository.records)},审计事件={len(audit.entries)};没有真实外部副作用。")
if __name__ == "__main__":
main()

1200
AGNETS.md Normal file

File diff suppressed because it is too large Load Diff

257
README.md Normal file
View File

@@ -0,0 +1,257 @@
# LangGraph 学习示例
一个按难度递增的 LangGraph 课程仓库,覆盖从基础 `StateGraph` 到持久化、人工审批、动态并行、长期记忆、Functional API、测试与安全工具设计。
当前示例按 **LangGraph 1.2.9** 编写和验证。
## 学习路线
```text
基础图与状态
→ 条件路由与 Checkpoint
→ Tool Calling / ReAct
→ Human-in-the-loop / Command
→ Streaming / Retry / Error Handling
→ Parallel / Reducer / Structured Output
→ Runtime Context / Subgraph / Send
→ Store / Functional API
→ State History / Testing / Observability
→ Durable Persistence / Async / Safe Tools
```
## 课程目录
| 课程 | 文件 | 核心内容 | 是否需要模型 API |
|---:|---|---|:---:|
| 01 | `01_basic_agent_no_llm.py` | `StateGraph`、State、Node、Edge、`START/END` | 否 |
| 02 | `02_chat_graph.py` | Chat Model 节点、消息 Reducer | 是 |
| 03 | `03_conditional_edge.py` | 条件边与路由函数 | 否 |
| 04 | `04_checkpointer.py` | Checkpointer、`thread_id`、多轮状态 | 是 |
| 05 | `05_tool_call.py` | `bind_tools``ToolNode`、ReAct 工具循环 | 是 |
| 06 | `06_human_in_the_loop.py` | `interrupt()`、人工审批、暂停与恢复 | 是 |
| 07 | `07_command.py` | `Command(update/goto/resume)` | 否 |
| 08 | `08_streaming.py` | `updates``messages`、多模式 Streaming | 是 |
| 09 | `09_retry_and_errors.py` | `RetryPolicy``error_handler`、循环保护 | 否 |
| 10 | `10_parallel_and_reducers.py` | 并行 Super-step、Reducer、更新冲突 | 否 |
| 11 | `11_structured_output.py` | Pydantic、`with_structured_output` | 是 |
| 12 | `12_runtime_context.py` | State / Config / Runtime Context、依赖注入 | 否 |
| 13 | `13_subgraphs.py` | 子图作为父图节点、子图 Streaming | 否 |
| 14 | `14_map_reduce_send.py` | `Send`、动态并行 Map-Reduce | 否 |
| 15 | `15_long_term_store.py` | Checkpointer 与 Store、跨线程长期记忆 | 否 |
| 16 | `16_functional_api.py` | `@entrypoint``@task`、Future、`previous` | 否 |
| 17 | `17_state_history.py` | 状态快照、历史、Replay、Fork、Time Travel | 否 |
| 18 | `18_testing.py` | 节点、路由和整图的确定性单元测试 | 否 |
| 19 | `19_observability.py` | Tags、Metadata、自定义事件、可选 LangSmith | 否 |
| 20 | `20_persistent_memory.py` | SQLite 持久化 Checkpointer/Store、进程间恢复 | 否 |
| 21 | `21_async_graph.py` | `ainvoke/astream`、异步并行、重试和节点超时 | 否 |
| 22 | `22_safe_tools.py` | 参数校验、权限、审批、幂等与审计 | 否 |
## 环境要求
- Python 3.11+(当前在 Python 3.12 环境验证)
- LangGraph 1.2.9+
- 模型课程还需要 `langchain-openai``python-dotenv`
- 课程 20 的 SQLite 持久化后端是可选依赖
### 使用 Conda
```powershell
conda create -n langgraph python=3.12 -y
conda activate langgraph
python -m pip install -U "langgraph>=1.2.9" langchain-openai python-dotenv pydantic
```
SQLite 持久化课程额外安装:
```powershell
python -m pip install "langgraph-checkpoint-sqlite>=3,<4"
```
### 使用 uv
```powershell
uv venv
.venv\Scripts\activate
uv pip install "langgraph>=1.2.9" langchain-openai python-dotenv pydantic
```
## 模型配置
需要模型 API 的课程会从 `.env` 读取配置。创建本地 `.env`
```env
OPENAI_API_KEY=your-api-key
OPENAI_BASE_URL=https://your-openai-compatible-endpoint/v1
OPENAI_MODEL=your-model-name
```
`.env` 已被 `.gitignore` 排除,不要提交真实密钥。
使用官方 OpenAI 时,可以根据服务要求省略 `OPENAI_BASE_URL`;本仓库部分早期示例带有兼容服务的默认地址,运行前请检查模型名和地址是否与自己的服务匹配。
## 运行示例
运行单个课程:
```powershell
python .\01_basic_agent_no_llm.py
python .\10_parallel_and_reducers.py
python .\17_state_history.py
```
运行测试课程:
```powershell
python .\18_testing.py
```
运行持久化课程:
```powershell
# 真正写入本地 SQLite重复运行可观察跨进程持久化
python .\20_persistent_memory.py
# 无需可选依赖的非持久化 fallback
python .\20_persistent_memory.py --fallback
```
默认 SQLite 文件是:
```text
persistent_memory.sqlite3
```
可指定其他路径:
```powershell
python .\20_persistent_memory.py --db .\data\memory.sqlite3
```
运行异步课程:
```powershell
python .\21_async_graph.py
```
## 1722 课程概要
### 17State History / Time Travel
演示:
- `graph.get_state(config)` 获取最新快照
- `graph.get_state_history(config)` 查看历史
- 使用历史 checkpoint config 重放
- `graph.update_state()` 从历史状态创建新分支
- 验证 Fork 不会修改原历史快照
### 18Testing
使用标准库 `unittest`,不调用真实模型或网络:
- 普通节点单元测试
- 路由函数测试
- 编译图的集成测试
- 确定性输入和断言
### 19Observability
演示:
- `RunnableConfig` 中的 `tags``metadata`
- `get_stream_writer()` 发送自定义进度事件
- `stream_mode=["custom", "updates"]`
- 可选启用 LangSmith不设置密钥也能本地运行
启用 LangSmith 时,在 `.env` 中配置:
```env
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=your-langsmith-key
LANGSMITH_PROJECT=langgraph-learning
```
### 20Persistent Memory
演示两类持久化:
- Checkpointer保存 `thread_id` 对应的执行状态
- Store保存按用户 Namespace 组织的跨线程长期记忆
生产环境还应考虑连接池、迁移、备份、加密、访问控制和数据保留策略。SQLite 适合本地学习和单进程应用;多实例服务通常使用数据库后端。
### 21Async Graph
演示:
- `async def` 节点
- `await graph.ainvoke(...)`
- `async for ... in graph.astream(...)`
- 并行异步 I/O
- 异步节点 `RetryPolicy`
- LangGraph 1.2+ 节点 `timeout`
- `NodeTimeoutError`
### 22Safe Tools
使用完全模拟的“转账”流程演示:
- Pydantic 严格参数校验
- Runtime Context 权限检查
- `interrupt()` 敏感操作审批
- 幂等键避免重复副作用
- 审计日志
- 密钥和依赖不进入 State
该课程不会连接支付系统,也没有真实外部副作用。
## 重要概念速查
### State、Context 与 Config
```text
State = 工作流知道和产生的数据,会随节点更新并可进入 Checkpoint
Context = 本次运行所需的只读依赖,如用户身份、服务、权限
Config = LangGraph 执行配置,如 thread_id、recursion_limit、tags
```
### Checkpointer 与 Store
```text
Checkpointer = 一条 thread 的执行状态、暂停位置和历史
Store = 跨 thread 的用户记忆或长期业务数据
```
### Graph API 与 Functional API
```text
Graph API = 显式节点和边,适合可视化、复杂编排
Functional API = @entrypoint + @task + 普通 Python 控制流
```
### 安全原则
- 不将 API Key、密码或 Token 写入 State
- 敏感工具执行前进行权限检查和人工审批
- 外部副作用必须支持幂等
- 参数错误不要盲目重试
- `recursion_limit` 是安全网,不是业务终止条件
- 生产环境不要使用 `InMemorySaver` / `InMemoryStore` 代替持久化后端
## 推荐学习方式
1. 按文件编号顺序运行。
2. 先阅读 State再阅读节点最后查看构图代码。
3. 修改输入并观察路由和状态变化。
4. 使用 `stream_mode="updates"` 调试节点输出。
5. 每学完一课,为错误路径补一个测试。
6. 完成课程后,将知识组合成一个完整项目,而不是继续堆叠孤立示例。
## 官方文档
- LangGraph Overview<https://docs.langchain.com/oss/python/langgraph/overview>
- Graph API<https://docs.langchain.com/oss/python/langgraph/graph-api>
- Functional API<https://docs.langchain.com/oss/python/langgraph/functional-api>
- Persistence<https://docs.langchain.com/oss/python/langgraph/persistence>
- Streaming<https://docs.langchain.com/oss/python/langgraph/streaming>
- Fault Tolerance<https://docs.langchain.com/oss/python/langgraph/fault-tolerance>

View File

@@ -1,115 +0,0 @@
"""
Basic LangGraph Example - A simple conversational agent with state management.
This demonstrates core LangGraph concepts:
- State schemas
- Nodes (functions that process state)
- Edges (connections between nodes)
- Conditional routing
"""
from typing import TypedDict, Literal, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
# Define the state schema
class State(TypedDict):
"""State represents what flows through our graph."""
messages: Annotated[list, add_messages]
counter: int
# Define node functions
def greeting_node(state: State) -> dict:
"""Process greeting messages."""
print("Processing greeting...")
return {"counter": state["counter"] + 1}
def math_node(state: State) -> dict:
"""Process math-related messages."""
print("Processing math request...")
return {"counter": state["counter"] + 1}
def default_node(state: State) -> dict:
"""Handle unknown/unrecognized messages."""
print("Handling general message...")
return {"counter": state["counter"] + 1}
# Define the router function
def route_message(state: State) -> Literal["greeting", "math", "default"]:
"""Route to appropriate node based on message content."""
messages = state["messages"]
if not messages:
return "default"
last_message = messages[-1].lower() if isinstance(messages[-1], str) else str(messages[-1]).lower()
if any(word in last_message for word in ["hello", "hi", "hey", "greetings"]):
return "greeting"
elif any(word in last_message for word in ["add", "subtract", "multiply", "divide", "calculate"]):
return "math"
else:
return "default"
# Build the graph
def create_graph():
"""Create and compile the LangGraph workflow."""
# Initialize the graph builder
builder = StateGraph(State)
# Add nodes
builder.add_node("greeting", greeting_node)
builder.add_node("math", math_node)
builder.add_node("default", default_node)
# Add edges with conditional routing
builder.add_conditional_edges(
START,
route_message,
{
"greeting": "greeting",
"math": "math",
"default": "default"
}
)
# All nodes lead to END (or could chain to other nodes)
builder.add_edge("greeting", END)
builder.add_edge("math", END)
builder.add_edge("default", END)
# Compile the graph
return builder.compile()
def main():
"""Run the basic LangGraph agent."""
graph = create_graph()
print("=== Basic LangGraph Agent ===")
print("Try messages like: 'hello', 'add 5 and 3', or anything else")
print()
# Test cases
test_messages = [
"Hello there!",
"Can you add 5 and 3?",
"What's the weather like?"
]
for message in test_messages:
print(f"Input: {message}")
initial_state = {"messages": [message], "counter": 0}
result = graph.invoke(initial_state)
print(f"Output: Processed with counter = {result['counter']}")
print()
if __name__ == "__main__":
main()

View File

@@ -1,231 +0,0 @@
"""
LangGraph with ChatOpenAI and Structured Output Example.
This demonstrates using LangGraph with ChatOpenAI's with_structured_output feature
for type-safe structured JSON responses.
"""
from typing import TypedDict, Literal, List
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
import os
from dotenv import load_dotenv
load_dotenv()
# ============ Helper to create model ============
def create_chat_model(model: str = "glm-4.5-air", temperature: float = 0.7):
"""Create a ChatOpenAI model instance."""
return ChatOpenAI(
model=model,
temperature=temperature,
max_retries=2,
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4"),
)
# ============ Structured Output Schemas ============
class SentimentAnalysis(BaseModel):
"""Schema for sentiment analysis output."""
sentiment: Literal["positive", "negative", "neutral"] = Field(
description="Overall sentiment of the text"
)
confidence: float = Field(
description="Confidence score between 0 and 1",
ge=0,
le=1
)
emotions: List[str] = Field(
description="List of detected emotions"
)
keywords: List[str] = Field(
description="Key words or phrases extracted"
)
analysis: str = Field(
description="Detailed analysis explanation"
)
class EntityExtraction(BaseModel):
"""Schema for entity extraction output."""
persons: List[str] = Field(default=[], description="Person names mentioned")
locations: List[str] = Field(default=[], description="Locations mentioned")
organizations: List[str] = Field(default=[], description="Organizations mentioned")
dates: List[str] = Field(default=[], description="Dates or time references")
class CombinedAnalysis(BaseModel):
"""Combined analysis result."""
sentiment: SentimentAnalysis
entities: EntityExtraction
summary: str = Field(description="Brief summary of the input text")
# ============ State Schema ============
class State(TypedDict):
"""State flows through the graph."""
input_text: str
sentiment_result: SentimentAnalysis | None
entity_result: EntityExtraction | None
final_summary: str
# ============ Node Functions ============
def analyze_sentiment(state: State) -> dict:
"""Analyze sentiment using with_structured_output."""
print(f"Analyzing sentiment for: '{state['input_text']}'...")
# Create model with structured output
model = create_chat_model().with_structured_output(SentimentAnalysis)
prompt = f"""
你是一个情感分析专家。请分析用户输入的情感。
输入文本:{state['input_text']}
请按照指定的 JSON Schema 返回分析结果,包括 sentiment, confidence, emotions, keywords 和 analysis。
"""
result: SentimentAnalysis = model.invoke(prompt)
print(f" Sentiment: {result.sentiment}")
print(f" Confidence: {result.confidence:.0%}")
print(f" Emotions: {', '.join(result.emotions)}")
return {"sentiment_result": result}
def extract_entities(state: State) -> dict:
"""Extract entities using with_structured_output."""
print("Extracting entities...")
# Create model with structured output
model = create_chat_model().with_structured_output(EntityExtraction)
prompt = f"""
你是一个命名实体识别专家。请从文本中提取实体。
输入文本:{state['input_text']}
请提取 persons, locations, organizations, dates 等实体信息。
"""
result: EntityExtraction = model.invoke(prompt)
print(f" Persons: {result.persons}")
print(f" Locations: {result.locations}")
print(f" Organizations: {result.organizations}")
return {"entity_result": result}
def generate_summary(state: State) -> dict:
"""Generate a combined summary using with_structured_output."""
print("Generating final summary...")
sentiment = state.get("sentiment_result")
entities = state.get("entity_result")
# Create model with structured output
model = create_chat_model().with_structured_output(CombinedAnalysis)
prompt = f"""
请对以下分析结果生成综合摘要:
输入文本:{state['input_text']}
情感分析结果:
- 情感:{sentiment.sentiment if sentiment else 'N/A'}
- 置信度:{sentiment.confidence if sentiment else 'N/A'}
- 情绪:{sentiment.emotions if sentiment else 'N/A'}
- 关键词:{sentiment.keywords if sentiment else 'N/A'}
实体提取结果:
- 人名:{entities.persons if entities else 'N/A'}
- 地点:{entities.locations if entities else 'N/A'}
- 组织:{entities.organizations if entities else 'N/A'}
- 日期:{entities.dates if entities else 'N/A'}
请生成一个简短的摘要。
"""
result: CombinedAnalysis = model.invoke(prompt)
return {"final_summary": result.summary}
# ============ Build Graph ============
def create_analysis_graph():
"""Create and compile the analysis workflow graph."""
builder = StateGraph(State)
# Add nodes
builder.add_node("sentiment", analyze_sentiment)
builder.add_node("entities", extract_entities)
builder.add_node("summarize", generate_summary)
# Add edges
builder.add_edge(START, "sentiment")
builder.add_edge("sentiment", "entities")
builder.add_edge("entities", "summarize")
builder.add_edge("summarize", END)
return builder.compile()
# ============ Main ============
def main():
"""Run the structured analysis agent."""
graph = create_analysis_graph()
print("=== LangGraph with ChatOpenAI Structured Output ===\n")
# Test with a sample text
test_text = "今天天气真好,我和朋友们一起去公园野餐,大家都很开心!"
print(f"Input text: {test_text}\n")
print("-" * 50)
# Run the graph
initial_state = {
"input_text": test_text,
"sentiment_result": None,
"entity_result": None,
"final_summary": ""
}
result = graph.invoke(initial_state)
print("-" * 50)
print("\n=== Final Summary ===")
print(result["final_summary"])
# Access structured pydantic models
print("\n=== Structured Sentiment Data ===")
sentiment: SentimentAnalysis = result["sentiment_result"]
print(f" sentiment: {sentiment.sentiment}")
print(f" confidence: {sentiment.confidence}")
print(f" emotions: {sentiment.emotions}")
print(f" keywords: {sentiment.keywords}")
print(f" analysis: {sentiment.analysis}")
print("\n=== Structured Entity Data ===")
entities: EntityExtraction = result["entity_result"]
print(f" persons: {entities.persons}")
print(f" locations: {entities.locations}")
print(f" organizations: {entities.organizations}")
print(f" dates: {entities.dates}")
if __name__ == "__main__":
main()

View File

@@ -1,198 +0,0 @@
"""
LangGraph Example with ChatOpenAI.
A multi-turn conversational agent with memory and tool routing.
"""
from typing import TypedDict, List, Literal
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
import os
from dotenv import load_dotenv
load_dotenv()
# ============ Model Helper ============
def create_chat_model(model: str = "openai/gpt-5.4", temperature: float = 0.7):
"""Create a ChatOpenAI model instance."""
return ChatOpenAI(
model=model,
temperature=temperature,
max_retries=2,
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.qnaigc.com/v1"),
)
# ============ State Schema ============
class Message(BaseModel):
role: str
content: str
class State(TypedDict):
messages: List[Message]
category: str
# ============ Router Function ============
def categorize_message(state: State) -> Literal["greeting", "question", "fallback"]:
"""Use LLM to categorize the user's message."""
model = create_chat_model(temperature=0.1)
last_message = state["messages"][-1].content if state["messages"] else ""
prompt = f"""
Categorize this message into ONE category: greeting, question, or fallback.
Return ONLY the category name.
Message: {last_message}
"""
response = model.invoke(prompt).content.strip().lower()
if "greeting" in response:
return "greeting"
elif "question" in response:
return "question"
else:
return "fallback"
# ============ Node Functions ============
def handle_greeting(state: State) -> dict:
"""Handle greeting messages."""
print(" [Node: Greeting]")
model = create_chat_model()
last_message = state["messages"][-1].content
prompt = f"""
Respond to this greeting in a friendly way. Keep it brief.
User: {last_message}
Assistant:
"""
response = model.invoke(prompt).content
return {
"messages": state["messages"] + [Message(role="assistant", content=response)]
}
def handle_question(state: State) -> dict:
"""Handle question messages."""
print(" [Node: Question]")
model = create_chat_model()
last_message = state["messages"][-1].content
prompt = f"""
Answer this question helpfully and concisely.
User: {last_message}
Assistant:
"""
response = model.invoke(prompt).content
return {
"messages": state["messages"] + [Message(role="assistant", content=response)]
}
def handle_fallback(state: State) -> dict:
"""Handle unrecognized messages."""
print(" [Node: Fallback]")
model = create_chat_model()
last_message = state["messages"][-1].content
prompt = f"""
Respond to this message in a helpful way.
User: {last_message}
Assistant:
"""
response = model.invoke(prompt).content
return {
"messages": state["messages"] + [Message(role="assistant", content=response)]
}
# ============ Build Graph ============
def create_chat_graph():
"""Create and compile the chat graph."""
builder = StateGraph(State)
# Add nodes
builder.add_node("greeting", handle_greeting)
builder.add_node("question", handle_question)
builder.add_node("fallback", handle_fallback)
# Add conditional routing
builder.add_conditional_edges(
START,
categorize_message,
{
"greeting": "greeting",
"question": "question",
"fallback": "fallback"
}
)
# All paths lead to END
builder.add_edge("greeting", END)
builder.add_edge("question", END)
builder.add_edge("fallback", END)
return builder.compile()
# ============ Main ============
def main():
"""Run the chat agent."""
graph = create_chat_graph()
print("=== LangGraph Chat Agent with ChatOpenAI ===\n")
# Test messages
test_inputs = [
"你好,很高兴见到你!",
"Python 中如何读取 JSON 文件?",
"随便聊聊吧"
]
for user_input in test_inputs:
print(f"User: {user_input}")
initial_state = {
"messages": [Message(role="user", content=user_input)],
"category": ""
}
result = graph.invoke(initial_state)
assistant_message = result["messages"][-1].content
print(f"Assistant: {assistant_message}\n")
print("-" * 50 + "\n")
if __name__ == "__main__":
main()