Files

179 lines
7.8 KiB
Python

# requires: langgraph, pydantic
"""工单处理图:结构化分类 → 起草回复(失败自动重试)→ 高风险人工审批(interrupt)→ 发送(幂等)。
演示:任务状态字段、流式进度(custom 流)、检查点续跑(SqliteSaver)、人机协作、异常恢复。
不调用真实大模型:classify / draft 用规则模拟,接口形状和真实做法一致。
运行:python ticket_flow.py
基于 langgraph 1.2 编写,API 以官方文档为准:https://docs.langchain.com/oss/python/langgraph/overview
"""
from __future__ import annotations
import operator
import os
import re
import tempfile
from typing import Annotated, Literal, TypedDict
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.config import get_stream_writer
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, RetryPolicy, interrupt
from pydantic import BaseModel, Field, ValidationError
Status = Literal["new", "classified", "drafted", "waiting_approval", "approved", "rejected", "sent", "failed"]
class Triage(BaseModel):
"""分类结果:真实项目里是大模型的结构化输出,这里用 pydantic 校验它。"""
category: Literal["refund", "bug", "question"]
amount: float = Field(ge=0, default=0)
urgent: bool = False
class TicketState(TypedDict, total=False):
ticket_id: str
text: str
triage: dict
draft: str
status: Status
reviewer_note: str
events: Annotated[list[str], operator.add] # 追加式字段:每个节点追加一条,作为审计记录
class TransientError(Exception):
"""可重试的错误:超时、限流、5xx。"""
# ---------- 外部依赖(测试里可以替换) ----------
class FakeLLM:
def __init__(self, fail_times: int = 0):
self.fail_times = fail_times
self.calls = 0
def classify(self, text: str) -> str:
# 真实模型返回 JSON 字符串;这里也返回字符串,交给 pydantic 校验
if "退款" in text:
m = re.search(r"(\d+(?:\.\d+)?)\s*元", text)
amount = float(m.group(1)) if m else 0
return f'{{"category": "refund", "amount": {amount}, "urgent": {str("加急" in text).lower()}}}'
if "报错" in text:
return '{"category": "bug", "urgent": true}'
return '{"category": "question"}'
def draft(self, text: str, triage: Triage) -> str:
self.calls += 1
if self.calls <= self.fail_times:
raise TransientError("模型接口超时")
return f"[{triage.category}] 您好,关于“{text}”,我们已经处理,请留意通知。"
class Mailer:
"""发送通道。用 ticket_id 做幂等键:同一个工单重复执行也只发一次。"""
def __init__(self, crash_once: bool = False):
self.sent: dict[str, str] = {}
self.crash_once = crash_once
def send(self, ticket_id: str, body: str) -> None:
if ticket_id in self.sent:
return
if self.crash_once:
self.crash_once = False
raise RuntimeError("进程崩溃(模拟)")
self.sent[ticket_id] = body
APPROVAL_AMOUNT = 500
def needs_approval(triage: dict) -> bool:
return triage["category"] == "refund" and triage["amount"] >= APPROVAL_AMOUNT
def build(llm: FakeLLM, mailer: Mailer, checkpointer):
def classify(state: TicketState):
get_stream_writer()({"step": "classify", "msg": "正在分类"})
raw = llm.classify(state["text"])
try:
triage = Triage.model_validate_json(raw)
except ValidationError as e: # 结构化输出不合法:降级为人工处理,而不是让流程崩掉
return {"status": "failed", "events": [f"classify_invalid:{e.error_count()}"]}
return {"triage": triage.model_dump(), "status": "classified", "events": [f"classified:{triage.category}"]}
def draft(state: TicketState):
get_stream_writer()({"step": "draft", "msg": "正在起草回复"})
text = llm.draft(state["text"], Triage(**state["triage"]))
status = "waiting_approval" if needs_approval(state["triage"]) else "drafted"
return {"draft": text, "status": status, "events": ["drafted"]}
def need_approval(state: TicketState) -> str: # 条件边只做路由,不修改状态
return "approve" if needs_approval(state["triage"]) else "send"
def approve(state: TicketState):
# interrupt 之前的代码在恢复时会重新执行一次,所以这里不要放有副作用的操作
decision = interrupt({"ticket_id": state["ticket_id"], "draft": state["draft"],
"amount": state["triage"]["amount"]})
if decision.get("ok"):
return {"status": "approved", "reviewer_note": decision.get("note", ""), "events": ["approved"]}
return {"status": "rejected", "reviewer_note": decision.get("note", ""), "events": ["rejected"]}
def after_approve(state: TicketState) -> str:
return "send" if state["status"] == "approved" else END
def send(state: TicketState):
get_stream_writer()({"step": "send", "msg": "正在发送"})
mailer.send(state["ticket_id"], state["draft"])
return {"status": "sent", "events": ["sent"]}
g = StateGraph(TicketState)
g.add_node("classify", classify)
g.add_node("draft", draft, retry_policy=RetryPolicy(max_attempts=3, initial_interval=0.01,
retry_on=TransientError))
g.add_node("approve", approve)
g.add_node("send", send)
g.add_edge(START, "classify")
g.add_conditional_edges("classify", lambda s: END if s["status"] == "failed" else "draft")
g.add_conditional_edges("draft", need_approval, ["approve", "send"])
g.add_conditional_edges("approve", after_approve, ["send", END])
g.add_edge("send", END)
return g.compile(checkpointer=checkpointer)
def main() -> None:
db = os.path.join(tempfile.mkdtemp(), "checkpoints.sqlite")
llm, mailer = FakeLLM(fail_times=1), Mailer(crash_once=True)
cfg = {"configurable": {"thread_id": "T-1001"}}
ticket = {"ticket_id": "T-1001", "text": "订单 A1 申请退款 800 元", "status": "new", "events": []}
# 第一次运行:draft 第一次超时被自动重试;遇到 800 元退款,停在人工审批
with SqliteSaver.from_conn_string(db) as cp:
app = build(llm, mailer, cp)
for mode, chunk in app.stream(ticket, cfg, stream_mode=["custom", "updates"]):
if mode == "custom":
print("进度:", chunk["msg"])
snap = app.get_state(cfg)
print("等待节点:", snap.next, "状态:", snap.values["status"])
assert snap.values["status"] == "waiting_approval" and snap.values["triage"]["amount"] == 800
print("待审批内容:", snap.tasks[0].interrupts[0].value)
assert snap.next == ("approve",) and llm.calls == 2
# 审批人第二天才处理:新的进程、新的图对象,只要 thread_id 一样就能接着跑
with SqliteSaver.from_conn_string(db) as cp:
app = build(llm, mailer, cp)
try:
app.invoke(Command(resume={"ok": True, "note": "金额核对无误"}), cfg)
except RuntimeError as e:
print("发送时崩溃:", e)
snap = app.get_state(cfg)
print("崩溃后停在:", snap.next, "状态:", snap.values["status"])
# 崩溃恢复:传 None 表示从最近的检查点继续,已经完成的节点(起草、审批)不会重新执行
with SqliteSaver.from_conn_string(db) as cp:
app = build(llm, mailer, cp)
final = app.invoke(None, cfg)
print("最终状态:", final["status"], "事件:", final["events"])
assert final["status"] == "sent" and llm.calls == 2 and "T-1001" in mailer.sent
if __name__ == "__main__":
main()