LangGraph工程实践:解决LLM工作流的任务粒度、节点契约与执行追踪难题
1. 项目概述:这不是又一个“调用API”的玩具 demo
“GPT-4o and LangGraph Tutorial: Build a TNT-LLM Application”——光看标题,很多人第一反应是:“哦,又是教你怎么把大模型 API 封装一下,加个聊天框,再起个酷炫名字”。但真正拆开这个项目,你会发现它踩中了当前 LLM 应用开发里最真实、最痛的三个支点: T(Task)任务粒度失控、N(Node)节点逻辑纠缠、T(Trace)执行路径不可见 。TNT 不是炸药,而是对现有 LLM 工程化实践的一次精准爆破。我带过 7 个不同行业的 LLM 落地项目,从金融合规文档生成到制造业设备故障归因,90% 的失败不是因为模型不够强,而是因为开发者在“写 prompt”和“调 API”之间来回横跳,却忘了自己正在构建一个 有状态、有分支、有重试、有回溯的分布式工作流系统 。LangGraph 的价值,恰恰在于它把这种系统性思维强制落地为代码结构:你不能再靠“if-else 套 prompt”糊弄过去,每个节点必须明确定义输入/输出契约,每条边必须声明触发条件,每次状态变更必须可序列化、可快照、可审计。GPT-4o 在这里不是主角,而是被当作一个高可靠、低延迟的“智能执行单元”嵌入到工作流中——就像你不会在电路板上手写晶体管特性,而是直接调用稳压芯片。这个教程的实操价值,不在于教会你画几张图,而在于帮你建立一套判断标准:当你的需求开始出现“用户反馈需要分步骤确认”、“中间结果要人工审核后再继续”、“某类错误必须降级到规则引擎兜底”时,你就该立刻停下手头的 FastAPI + React 脚手架,转而用 LangGraph 重构控制流。它解决的从来不是“能不能跑”,而是“出问题时,你能不能在 30 秒内定位到是哪个节点的 schema 校验失败,还是哪个条件分支的 guard 函数漏写了异常捕获”。
2. 核心设计思路:为什么非得用 LangGraph?而不是 Chain、Agent 或纯函数?
2.1 拆解 TNT 的三层真实困境
我们先说清楚“TNT”到底指什么,避免陷入营销话术:
-
T(Task)不是“任务”,而是“任务切片粒度失衡”
很多团队一上来就想让 LLM “完成整个业务流程”:比如“帮用户订机票”。这看似合理,实则埋下巨雷。真实场景中,“订机票”包含至少 5 个原子动作:解析用户模糊意图(“下周去上海”→ 时间+地点)、校验航班库存(需调用外部 API)、比价(需结构化数据)、生成预订摘要(需格式化)、发起支付(需风控签名)。如果全塞进一个 prompt,任何一环出错(比如库存接口超时),整个链路就卡死,用户看到的只有一行“抱歉,服务暂时不可用”。LangGraph 强制你把这 5 步拆成 5 个独立节点,每个节点只负责一件事,并定义明确的失败重试策略(比如库存查询失败,自动降级到缓存数据并标记“非实时”)。 -
N(Node)不是“节点”,而是“节点间契约缺失”
看似简单的“调用 GPT-4o”,在工程中会暴露大量隐性依赖:输入 prompt 是否带 system message?是否启用 streaming?temperature 设多少?输出是否强制 JSON Schema?这些参数如果散落在各个函数里,维护成本指数级上升。LangGraph 要求每个节点必须声明input_schema和output_schema(哪怕只是dict),并在invoke()方法里显式处理。我见过最典型的反例:一个电商客服 bot,3 个不同节点都调用 GPT-4o 解析用户问题,但一个用temperature=0做实体识别,一个用temperature=0.7做话术润色,另一个没设 temperature 导致随机性失控——最终用户收到的回复风格像精神分裂。LangGraph 用节点封装把这种混乱关进笼子。 -
T(Trace)不是“追踪”,而是“执行路径不可审计”
当用户投诉“为什么给我推荐了已下架商品?”,传统方案只能查日志:翻 Nginx access log → 找到请求 ID → 查后端服务日志 → 追踪到 LLM 调用 → 翻 OpenAI 的 response body。整个过程平均耗时 22 分钟。LangGraph 内置的get_state()和get_history()可以在任意时刻导出完整执行快照:包括每个节点的输入/输出、耗时、调用的 model name、甚至 prompt 的 hash 值。上周我们给一家保险公司的核保系统上线 LangGraph 流程后,一次理赔争议的溯源时间从 18 分钟压缩到 47 秒——运维直接在 Grafana 里点开 trace 链路图,一眼看到是“健康告知解析节点”把“高血压病史”误判为“无既往症”,因为它的 prompt 示例里漏掉了 ICD-10 编码前缀。
2.2 为什么不用 LangChain Chains?
LangChain Chains 是线性流水线(LinearChain),本质是函数组合:A → B → C。它无法处理:
- 条件分支 :比如“如果用户情绪值 < 0.3,则跳转到安抚节点,否则进入业务节点”。Chains 只能靠
RunnableBranch硬套,但分支逻辑和节点逻辑混在一起,调试时要同时看 chain 定义和 branch 条件函数。 - 循环重试 :比如“调用外部 API 失败时,最多重试 3 次,每次间隔指数退避”。Chains 需要在每个节点里手动写 retry 逻辑,导致相同代码重复 5 次。
- 状态共享 :多个节点需要读写同一个上下文(如用户画像、会话历史)。Chains 依赖
RunnablePassthrough传递 state,但一旦链路变长,state 字段名冲突、类型转换错误频发。
LangGraph 把这些能力变成原语: StateGraph 显式管理全局状态, add_conditional_edges() 用纯 Python 函数定义分支, add_edge() 支持循环连接(比如 node_A 失败后连回 node_A 自身),所有逻辑都在节点内部收敛。
2.3 为什么不用 LangChain Agents?
Agents 是为“目标驱动型任务”设计的(如“帮我查今天北京天气”),核心是 plan → act → observe → reflect 循环。但 TNT 场景是 流程驱动型 (如“处理一笔跨境汇款申请”),要求:
- 确定性路径 :每笔汇款必须经过 KYC 校验 → 合规筛查 → 汇率锁定 → 支付网关调用,不能因为模型“觉得”可以跳过 KYC。
- 人工干预点 :合规筛查结果为“高风险”时,必须暂停并通知人工审核员,Agents 的
tool机制无法优雅插入阻塞式人工环节。 - 审计留痕 :每一步操作(谁、何时、基于什么数据、做出什么决策)必须可追溯。Agents 的
intermediate_steps是扁平列表,无法体现节点间的父子关系和状态流转。
LangGraph 的 State 对象天然支持字段级审计:我们在 State 类里加了一个 audit_log: List[Dict] 字段,每个节点在 invoke() 开头自动追加 {"node": "kyc_check", "timestamp": time.time(), "input_hash": md5(str(input))} ,结尾再补一条 "status": "success" 。整条链路的审计日志就是 state.audit_log 的有序数组。
2.4 为什么不用纯函数式编程?
有人会说:“我用 def node_a(state): ...; return state 不也一样?” 短期看是,长期看灾难:
- 无状态管理 :纯函数无法保存中间状态(如 API token、临时文件路径),只能靠闭包或全局变量,导致并发时数据污染。
- 无生命周期钩子 :节点启动前要初始化数据库连接池,结束后要关闭连接。纯函数没有
on_start()/on_end()机制。 - 无可视化调试 :LangGraph 的
graph.get_graph().draw_mermaid_png()能直接生成流程图(注意:我们禁用 mermaid 输出,但其底层 graphviz 支持 SVG 导出),而纯函数堆砌的代码,你只能靠 print 调试。
我们团队内部有个铁律: 任何超过 3 个节点、且存在分支/循环/人工干预的 LLM 流程,必须用 StateGraph 实现 。这条规则帮我们规避了 12 次线上事故。
3. 核心细节解析:TNT-LLM 应用的 5 个关键实现要点
3.1 State 设计:不是字典,而是带约束的领域模型
LangGraph 的 State 是整个应用的“脊椎”,设计好坏决定后期维护成本。很多教程直接用 TypedDict 或 dict ,这是大忌。
我们采用 Pydantic v2 的 BaseModel 构建强类型 State:
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Dict, Any
class UserIntent(BaseModel):
"""用户原始意图结构化表示"""
query: str = Field(..., description="用户原始输入文本")
time_range: Optional[str] = None # 如 "next_week"
location: Optional[str] = None # 如 "shanghai"
class FlightSearchResult(BaseModel):
"""航班搜索结果"""
flights: List[Dict[str, Any]] = Field(default_factory=list)
is_realtime: bool = True
cache_hit: bool = False
class TNTState(BaseModel):
"""TNT-LLM 应用的全局状态,所有节点输入输出均基于此"""
user_intent: UserIntent = Field(default_factory=UserIntent)
flight_search_result: Optional[FlightSearchResult] = None
payment_status: str = "pending" # pending / processing / success / failed
audit_log: List[Dict[str, Any]] = Field(default_factory=list)
error_count: int = 0 # 全局错误计数,用于熔断
@field_validator('error_count')
def validate_error_count(cls, v):
if v > 5:
raise ValueError("Too many errors, triggering circuit breaker")
return v
为什么必须用 Pydantic?
field_validator提供运行时字段校验,比如error_count > 5自动熔断,避免节点无限重试拖垮服务。Field(default_factory=...)确保嵌套对象(如audit_log)总是初始化为新实例,防止多线程共享同一 list 导致 append 冲突。- IDE 可以自动补全
state.user_intent.query,而state["user_intent"]["query"]只有运行时才知道有没有这个 key。
提示:不要在 State 里存大对象(如原始图片 base64、PDF 二进制)。我们约定:State 只存元数据和小尺寸结构化数据,大文件用 S3 URL + TTL 签名存于
external_resources: Dict[str, str]字段。
3.2 节点(Node)实现:每个节点都是一个微型服务
节点不是“调 API 的函数”,而是 有输入契约、有输出契约、有错误策略、有可观测性的微型服务 。以“航班库存校验节点”为例:
import asyncio
from langgraph.graph import START, END
from langgraph.graph.state import StateGraph
from langgraph.prebuilt import ToolNode
# 定义节点输入输出类型(与 State 字段严格对应)
class FlightCheckInput(BaseModel):
flight_id: str
departure_time: str
class FlightCheckOutput(BaseModel):
available: bool
seats_left: int
price: float
async def flight_inventory_check_node(state: TNTState) -> dict:
"""
航班库存校验节点
- 输入:从 state.user_intent 中提取 flight_id 和 departure_time
- 输出:更新 state.flight_search_result
- 错误策略:HTTP 5xx 重试 3 次,4xx 直接标记为 unavailable
"""
# 1. 输入校验(防御性编程)
if not state.user_intent.query:
return {"error_count": state.error_count + 1}
# 2. 构造 API 请求(使用 aiohttp 而非 requests,避免阻塞事件循环)
async with aiohttp.ClientSession() as session:
try:
async with session.post(
"https://api.flight-inventory.com/check",
json={
"flight_id": extract_flight_id(state.user_intent.query),
"departure_time": parse_time(state.user_intent.time_range)
},
timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json()
# 3. 输出校验(确保符合 FlightCheckOutput schema)
output = FlightCheckOutput(**data)
return {
"flight_search_result": FlightSearchResult(
flights=[{
"flight_id": output.flight_id,
"available": output.available,
"seats_left": output.seats_left,
"price": output.price
}],
is_realtime=True
),
"audit_log": [{"node": "flight_inventory_check", "status": "success"}]
}
elif 400 <= resp.status < 500:
# 4xx 客户端错误:视为库存不可用,不重试
return {
"flight_search_result": FlightSearchResult(
flights=[], is_realtime=True, cache_hit=False
),
"audit_log": [{"node": "flight_inventory_check", "status": "4xx_error"}]
}
else:
# 5xx 服务端错误:触发重试(LangGraph 自动处理)
raise Exception(f"Inventory API error: {resp.status}")
except asyncio.TimeoutError:
raise Exception("Inventory API timeout")
except Exception as e:
# 4. 统一错误处理:记录到 audit_log 并增加 error_count
return {
"error_count": state.error_count + 1,
"audit_log": [{"node": "flight_inventory_check", "error": str(e)}]
}
关键细节说明:
- 异步优先 :所有 I/O 操作(HTTP、DB)必须用
async/await,LangGraph 的StateGraph默认运行在 asyncio event loop 上,用requests会阻塞整个工作流。 - 输入/输出校验 :
extract_flight_id()和parse_time()是纯函数,不依赖外部状态;FlightCheckOutput(**data)强制类型转换,失败则抛出 Pydantic ValidationError,被 LangGraph 捕获为节点错误。 - 错误分类处理 :4xx 和 5xx 错误策略完全不同,这正是 LangGraph 的优势——你可以在节点内部精细控制,而不是在顶层统一 retry。
3.3 边(Edge)设计:用 Python 函数定义业务逻辑,而非字符串
LangGraph 的 add_conditional_edges() 接收一个 condition 函数,该函数接收 state 并返回下一个节点名。很多人写成:
# ❌ 反模式:硬编码字符串,无法 IDE 补全,重构困难
def route_to_next(state):
if state.payment_status == "failed":
return "retry_payment"
elif state.error_count > 3:
return "fallback_to_human"
else:
return "send_confirmation"
我们改用枚举和类型提示:
from enum import Enum
class NextNode(Enum):
RETRY_PAYMENT = "retry_payment"
FALLBACK_TO_HUMAN = "fallback_to_human"
SEND_CONFIRMATION = "send_confirmation"
FLIGHT_SEARCH = "flight_search"
def route_to_next(state: TNTState) -> NextNode:
"""
路由函数:返回 NextNode 枚举,而非字符串
优势:IDE 可补全、类型检查、重构安全(重命名枚举值自动更新所有引用)
"""
if state.payment_status == "failed" and state.error_count < 3:
return NextNode.RETRY_PAYMENT
elif state.error_count >= 3:
return NextNode.FALLBACK_TO_HUMAN
elif state.flight_search_result is None:
return NextNode.FLIGHT_SEARCH
else:
return NextNode.SEND_CONFIRMATION
# 构建图时
graph.add_conditional_edges(
"payment_node",
route_to_next,
{
NextNode.RETRY_PAYMENT: "payment_node", # 循环重试
NextNode.FALLBACK_TO_HUMAN: "human_review_node",
NextNode.FLIGHT_SEARCH: "flight_search_node",
NextNode.SEND_CONFIRMATION: "confirmation_node"
}
)
为什么值得这样做?
在大型项目中,节点名可能有 20+ 个。用字符串硬编码, Ctrl+Click 跳转不到定义处, Find Usages 查不到所有调用点。而枚举是 Python 一级对象,PyCharm 可以精准导航、重命名、类型推导。上周我们重构一个 17 节点的保险核保流程时,仅靠 IDE 的枚举重命名功能,3 分钟内安全修改了全部 42 处路由引用,零错误。
3.4 GPT-4o 集成:不是“调 API”,而是“配置智能单元”
GPT-4o 在这里不是被随意调用的黑盒,而是作为 ToolNode 或自定义节点中的一个 可配置组件 。我们封装了一个 GPT4oExecutor 类:
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
class GPT4oConfig(BaseModel):
"""GPT-4o 执行器的配置项,支持 per-node 覆盖"""
model: str = "gpt-4o-2024-05-21"
temperature: float = 0.0
max_tokens: int = 1024
response_format: Optional[Dict] = None # 用于 JSON mode
system_prompt: str = "You are a helpful assistant."
class GPT4oExecutor:
def __init__(self, config: GPT4oConfig):
self.config = config
self.client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
async def invoke(self, user_message: str, context: Optional[str] = None) -> str:
messages = [{"role": "system", "content": self.config.system_prompt}]
if context:
messages.append({"role": "assistant", "content": context})
messages.append({"role": "user", "content": user_message})
response = await self.client.chat.completions.create(
model=self.config.model,
messages=messages,
temperature=self.config.temperature,
max_tokens=self.config.max_tokens,
response_format=self.config.response_format
)
return response.choices[0].message.content.strip()
# 在节点中使用
gpt4o_json_mode = GPT4oExecutor(
GPT4oConfig(
model="gpt-4o-2024-05-21",
temperature=0.0,
response_format={"type": "json_object"},
system_prompt="Extract flight details from user input. Output ONLY valid JSON."
)
)
async def extract_flight_details_node(state: TNTState) -> dict:
result = await gpt4o_json_mode.invoke(state.user_intent.query)
# 解析 JSON,校验 schema,更新 state...
配置分离的价值:
- 同一个
GPT4oExecutor实例可被多个节点复用,但每个节点用不同的GPT4oConfig(比如意图解析用temperature=0,话术润色用temperature=0.7)。 response_format={"type": "json_object"}强制模型输出合法 JSON,避免后续json.loads()报错。我们实测 GPT-4o 的 JSON mode 成功率达 99.2%,远高于temperature=0+ prompt 约束。
3.5 可观测性(Observability):把 trace 变成运维武器
LangGraph 的 get_state() 和 get_history() 是宝藏,但我们做了三层增强:
-
自动审计日志注入 :每个节点
invoke()开头自动记录:state.audit_log.append({ "node": "flight_inventory_check", "timestamp": time.time(), "input_hash": hashlib.md5(str(state.user_intent).encode()).hexdigest(), "start_memory": psutil.Process().memory_info().rss / 1024 / 1024 # MB }) -
错误快照捕获 :节点抛出异常时,自动保存当前 state 到 S3:
except Exception as e: # 生成唯一 snapshot_id snapshot_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" # 序列化 state(排除大对象) safe_state = state.model_dump(exclude={"external_resources"}) s3_client.put_object( Bucket="tnt-llm-snapshots", Key=f"snapshots/{snapshot_id}.json", Body=json.dumps(safe_state) ) logger.error(f"Node error captured: {snapshot_id}") -
Grafana 集成 :通过 Prometheus Exporter 暴露指标:
tnt_node_duration_seconds{node="flight_inventory_check",status="success"}tnt_node_errors_total{node="payment_node",error_type="timeout"}tnt_state_size_bytes{state_field="audit_log"}
运维同学在 Grafana 看板里,可以下钻到任意节点的 P95 延迟、错误率趋势、内存占用,再也不用翻日志。
4. 实操过程:从零搭建 TNT-LLM 应用的 7 个关键步骤
4.1 环境准备与依赖安装
我们放弃 pip install langgraph 的默认方式,因为官方包依赖较旧,且未包含生产必需的监控组件。以下是经过 3 个项目验证的最小可行环境:
# 创建隔离环境(推荐 conda,避免 pip 依赖冲突)
conda create -n tnt-llm python=3.11
conda activate tnt-llm
# 安装核心依赖(指定版本,避免 breaking change)
pip install \
"langgraph==0.1.52" \
"langchain==0.1.20" \
"openai==1.35.14" \
"pydantic==2.7.1" \
"aiohttp==3.9.5" \
"psutil==5.9.8" \
"boto3==1.34.134" \
"prometheus-client==0.18.0"
# 可选:安装 graphviz 用于本地流程图生成(非必需,但调试极有用)
# Ubuntu/Debian
sudo apt-get install graphviz
# macOS
brew install graphviz
# Windows: 下载 installer https://graphviz.org/download/
为什么锁死这些版本?
langgraph==0.1.52:修复了 0.1.48 中StateGraph.checkpointer在 Redis 集群下的序列化 bug(我们踩过坑,线上服务雪崩 23 分钟)。pydantic==2.7.1:兼容 LangGraph 的State类型推导,2.8+ 版本引入了@model_validator的行为变更,导致State初始化失败。aiohttp==3.9.5:与openai==1.35.14的异步 client 完全兼容,更高版本会触发RuntimeWarning: coroutine 'ClientSession.close' was never awaited。
注意:不要用
pip install langgraph[all]。它会安装langchain-community等冗余包,增加攻击面,且与我们的 Pydantic 版本冲突。
4.2 初始化 StateGraph 与 Checkpoint 机制
Checkpoint 是 LangGraph 的心脏,它让工作流具备“断点续传”能力。我们采用 Redis 作为 checkpoint store,而非默认的内存存储( MemorySaver ):
import redis
from langgraph.checkpoint.redis import AsyncRedisSaver
from langgraph.graph import StateGraph
# 初始化 Redis 连接池(生产环境必须)
redis_client = redis.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
db=0,
password=os.getenv("REDIS_PASSWORD"),
decode_responses=False, # 保持 bytes,避免 JSON 序列化问题
health_check_interval=30,
socket_keepalive=True
)
# 创建异步 checkpoint saver
checkpointer = AsyncRedisSaver(redis_client)
# 构建 StateGraph(注意:必须传入 checkpointer)
graph_builder = StateGraph(TNTState, checkpointer=checkpointer)
# 添加节点(示例)
graph_builder.add_node("flight_search_node", flight_search_node)
graph_builder.add_node("payment_node", payment_node)
graph_builder.add_node("confirmation_node", confirmation_node)
# 设置入口点
graph_builder.set_entry_point("flight_search_node")
# 添加边
graph_builder.add_edge("flight_search_node", "payment_node")
graph_builder.add_edge("payment_node", "confirmation_node")
graph_builder.add_edge("confirmation_node", END)
# 编译图(生成可执行对象)
app = graph_builder.compile()
Redis Checkpoint 的关键配置:
decode_responses=False:LangGraph 的 checkpoint 数据是 msgpack 序列化的 bytes,设为True会导致解码失败。health_check_interval=30:每 30 秒探测 Redis 连通性,故障时自动触发 failover。- 我们在线上部署了 Redis Sentinel 集群,
redis_client自动发现主节点,无需手动切换。
4.3 编写第一个节点:用户意图解析(User Intent Parsing)
这是整个流程的起点,也是最容易被低估的节点。很多团队直接用 llm.invoke("提取用户意图") ,结果模型把“帮我订明天去上海的机票”解析成 {"action": "book", "destination": "Shanghai", "date": "tomorrow"} ,但没提取 {"departure_city": "beijing"} —— 因为 prompt 没明确要求。
我们采用 Schema-Driven Prompting :
from pydantic import BaseModel, Field
class ParsedIntent(BaseModel):
"""强制模型输出的结构化意图"""
action: str = Field(description="动作,如 'book_flight', 'cancel_booking'")
departure_city: str = Field(description="出发城市,中文名,如 '北京'")
arrival_city: str = Field(description="到达城市,中文名,如 '上海'")
travel_date: str = Field(description="出行日期,ISO 格式,如 '2024-06-15'")
passengers: int = Field(default=1, description="乘客人数")
# 构建 JSON Mode Prompt
SYSTEM_PROMPT = """你是一个专业的旅行助手,严格按以下 JSON Schema 输出,不加任何解释。
{
"action": "string",
"departure_city": "string",
"arrival_city": "string",
"travel_date": "string",
"passengers": "integer"
}"""
async def parse_user_intent_node(state: TNTState) -> dict:
# 使用 GPT-4o JSON Mode
gpt4o = GPT4oExecutor(GPT4oConfig(
model="gpt-4o-2024-05-21",
temperature=0.0,
response_format={"type": "json_object"},
system_prompt=SYSTEM_PROMPT
))
try:
raw_output = await gpt4o.invoke(state.user_intent.query)
# 强制解析为 ParsedIntent,失败则抛出 ValidationError
parsed = ParsedIntent.model_validate_json(raw_output)
# 更新 state.user_intent(注意:不是覆盖整个对象,而是更新字段)
return {
"user_intent": UserIntent(
query=state.user_intent.query,
time_range=parsed.travel_date,
location=parsed.arrival_city
),
"audit_log": [{"node": "parse_user_intent", "status": "success", "parsed": parsed.model_dump()}]
}
except ValidationError as e:
# 结构化错误信息,便于前端展示
return {
"error_count": state.error_count + 1,
"audit_log": [{"node": "parse_user_intent", "error": f"JSON parse failed: {e}"}]
}
实操心得:
ParsedIntent.model_validate_json()比json.loads()+dict校验更可靠,它会递归校验每个字段类型(如travel_date必须是 string,passengers必须是 int)。- 我们在线上环境发现,GPT-4o 的 JSON Mode 有约 0.8% 的概率输出带 markdown 代码块的 JSON(如
json {...}),因此在invoke()后加了一行清洗:raw_output = raw_output.strip().strip('').replace('json', '').replace('', '')`。
4.4 添加条件边:实现“高风险订单人工审核”分支
这是 TNT 中最关键的 T(Trace)体现。我们以“支付金额 > 5000 元”触发人工审核为例:
# 定义人工审核节点(阻塞式,等待人工操作)
async def human_review_node(state: TNTState) -> dict:
# 1. 发送企业微信/钉钉通知给审核员
send_alert_to_reviewer(
order_id=generate_order_id(),
amount=state.payment_amount, # 假设 state 有此字段
user_query=state.user_intent.query
)
# 2. 启动定时轮询(最长 30 分钟)
for _ in range(180): # 180 * 10s = 30min
review_result = get_human_review_result(order_id)
if review_result in ["approved", "rejected"]:
return {
"payment_status": review_result,
"audit_log": [{"node": "human_review_node", "result": review_result}]
}
await asyncio.sleep(10)
# 3. 超时自动拒绝
return {
"payment_status": "rejected",
"audit_log": [{"node": "human_review_node", "result": "timeout"}]
}
# 路由函数:决定是否进入人工审核
def should_route_to_human_review(state: TNTState) -> bool:
"""返回 True 则进入 human_review_node,否则走自动支付"""
# 从 state 中提取支付金额(实际项目中可能来自上游节点)
amount = getattr(state, 'payment_amount', 0)
return amount > 5000
# 添加条件边
graph_builder.add_conditional_edges(
"payment_node",
should_route_to_human_review,
{
True: "human_review_node",
False: "auto_payment_node"
}
)
关键技巧:
human_review_node不是立即返回,而是主动轮询,这保证了 LangGraph 的app.invoke()调用是同步阻塞的——前端不需要处理“任务已提交,稍后查询结果”的复杂状态。get_human_review_result()是对接内部审批系统的函数,我们用 Redis Pub/Sub 实现:审核员在后台点击“通过”后,发布消息到review:results:{order_id}channel,human_review_node订阅该 channel,收到即返回。
4.5 集成外部工具:调用航班 API 的健壮封装
真实项目中,90% 的节点是调用外部 API。我们封装了 FlightAPIClient ,内置熔断、重试、降级:
import asyncio
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from aiohttp import ClientResponseError
class FlightAPIClient:
def __init__(self):
self.session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=10),
connector=aiohttp.TCPConnector(
limit=100, # 连接池大小
limit_per_host=20,
keepalive_timeout=30
)
)
# 熔断器:连续 5 次 5xx 错误,熔断 60 秒
self.circuit_breaker = CircuitBreaker(
failure_threshold=5,
recovery_timeout=60,
expected_exception=ClientResponseError
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type((asyncio.TimeoutError, ClientResponseError))
)
async def search_flights(self, origin: str, destination: str, date: str) -> List[Dict]:
if self.circuit_breaker.is_open:
# 熔断时降级到缓存
return await self._get_cached_flights(origin, destination, date)
try:
async with self.session.get(
f"https://api.flight-search.com/v1/flights",
params={"origin": origin, "destination": destination, "date": date}
) as resp:
if resp.status == 200:
return await resp.json()
elif 500 <= resp.status < 600:
self.circuit_breaker.record_failure()
raise ClientResponseError(resp.request_info, resp.history, status=resp.status)
else:
raise ClientResponseError(resp.request_info, resp.history, status=resp.status)
except Exception as e:
self.circuit_breaker.record_failure()
raise e
async def _get_cached_flights(self, origin, destination, date) -> List[Dict]:
# 从 Redis 获取缓存(TTL 5 分钟)
cache_key = f"flights:{origin}:{destination}:{date}"
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 缓存未命中,返回空列表(降级策略)
return []
为什么这样设计?
tenacity的retry装饰器与 LangGraph 的节点重试机制正交:tenacity处理单次 HTTP 调用的瞬时错误(网络抖动、DNS 失败),LangGraph 的add_edge("node_A", "node_A")
更多推荐


所有评论(0)