Add advanced LangGraph learning examples and guides
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
218
11_structured_output.py
Normal 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
268
12_runtime_context.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""LangGraph Runtime Context 示例。
|
||||||
|
|
||||||
|
本例演示三类数据的职责:
|
||||||
|
|
||||||
|
1. State:工作流在执行过程中产生和修改的数据。
|
||||||
|
2. Config:LangGraph 执行配置,如 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
100
13_subgraphs.py
Normal 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
87
14_map_reduce_send.py
Normal 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
98
15_long_term_store.py
Normal 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
105
16_functional_api.py
Normal 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
112
17_state_history.py
Normal 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
101
18_testing.py
Normal 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
87
19_observability.py
Normal 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
117
20_persistent_memory.py
Normal 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
130
21_async_graph.py
Normal 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
212
22_safe_tools.py
Normal 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()
|
||||||
257
README.md
Normal file
257
README.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 17~22 课程概要
|
||||||
|
|
||||||
|
### 17:State History / Time Travel
|
||||||
|
|
||||||
|
演示:
|
||||||
|
|
||||||
|
- `graph.get_state(config)` 获取最新快照
|
||||||
|
- `graph.get_state_history(config)` 查看历史
|
||||||
|
- 使用历史 checkpoint config 重放
|
||||||
|
- `graph.update_state()` 从历史状态创建新分支
|
||||||
|
- 验证 Fork 不会修改原历史快照
|
||||||
|
|
||||||
|
### 18:Testing
|
||||||
|
|
||||||
|
使用标准库 `unittest`,不调用真实模型或网络:
|
||||||
|
|
||||||
|
- 普通节点单元测试
|
||||||
|
- 路由函数测试
|
||||||
|
- 编译图的集成测试
|
||||||
|
- 确定性输入和断言
|
||||||
|
|
||||||
|
### 19:Observability
|
||||||
|
|
||||||
|
演示:
|
||||||
|
|
||||||
|
- `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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 20:Persistent Memory
|
||||||
|
|
||||||
|
演示两类持久化:
|
||||||
|
|
||||||
|
- Checkpointer:保存 `thread_id` 对应的执行状态
|
||||||
|
- Store:保存按用户 Namespace 组织的跨线程长期记忆
|
||||||
|
|
||||||
|
生产环境还应考虑连接池、迁移、备份、加密、访问控制和数据保留策略。SQLite 适合本地学习和单进程应用;多实例服务通常使用数据库后端。
|
||||||
|
|
||||||
|
### 21:Async Graph
|
||||||
|
|
||||||
|
演示:
|
||||||
|
|
||||||
|
- `async def` 节点
|
||||||
|
- `await graph.ainvoke(...)`
|
||||||
|
- `async for ... in graph.astream(...)`
|
||||||
|
- 并行异步 I/O
|
||||||
|
- 异步节点 `RetryPolicy`
|
||||||
|
- LangGraph 1.2+ 节点 `timeout`
|
||||||
|
- `NodeTimeoutError`
|
||||||
|
|
||||||
|
### 22:Safe 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>
|
||||||
115
basic_agent.py
115
basic_agent.py
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user