# 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()