城智连响:基于 LangGraph 与四库分层架构的城市公共设施智能报修与派单系统
一个面向「市民报修 → AI 解析 → 智能派单 → 到场维修 → AI 验收 → 结算闭环」全链路的人工智能应用开发实战项目。
技术栈:FastAPI + SQLAlchemy(异步) + MySQL / Redis / MongoDB / Elasticsearch / RabbitMQ + LangChain + LangGraph + 阿里云百炼(qwen-vl-max) + 高德地图 API。
目录
- 项目背景与痛点
- [总体架构:四库分层 + 消息驱动](#总体架构:四库分层 + 消息驱动)
- [AI 决策链:LangGraph 事件驱动短图](#AI 决策链:LangGraph 事件驱动短图)
- [三大 AI 决策点详解](#三大 AI 决策点详解)
- [派单决策:硬约束 + 四维加权 + LLM 工具循环](#派单决策:硬约束 + 四维加权 + LLM 工具循环)
- 响应速度指标:增量平均算法
- 可靠性工程:锁、延迟队列与降级
- 数据存储设计
- 关键代码走读
- 工程实践与踩坑总结
- 总结与展望
1. 项目背景与痛点
传统城市公共设施的报修高度依赖人工:市民电话报修、话务员手工记录、调度员凭经验派单、维修员到场靠口头反馈。这种模式存在三个典型问题:
- 信息割裂:市民(报修方)、维修员(服务方)、管理员(调度方)三方各自维护一套信息,报修内容靠人工转述,容易失真、遗漏。
- 派单依赖经验:调度员「就近派单」凭直觉,缺乏对维修员实时负载、技能、好评率、响应速度的综合量化,导致「忙的忙死、闲的闲死」。
- 验收靠人工核验:维修是否合格依赖管理员事后抽查,缺乏客观、可复用的验收标准。
本系统以「城市公共设施智能报修与派单」为场景,将 AI 能力嵌入到三个关键决策点------报修解析、派单决策、视觉验收,并围绕「最小运维总成本」目标构建了一套可落地、可降级的智能派单链路。
2. 总体架构:四库分层 + 消息驱动
系统采用 「四库分层存储 + 消息队列解耦」 的架构。核心思想是:MySQL 是唯一同步阻断点(事务性事实源),其余写入全部异步化、可容错。
txt
┌─────────────────────────────────────────────┐
│ FastAPI 应用层 │
│ /api/v1/citizen · /api/v1/worker · /api/v1/admin │
└───────┬───────────────────────────┬──────────┘
│ │
┌──────────────▼──────────────┐ ┌────────▼─────────────────┐
│ LangGraph 决策链 │ │ RabbitMQ 消费者(后台) │
│ route → parse/dispatch/verify│ │ dispatch / timeout / │
│ → rework (短图) │ │ es_sync(重试+DLQ) │
└───────┬──────────────────────┘ └────────┬─────────────────┘
│ │
┌──────────────┼──────────────┬───────────────┬──────┴──────────────┐
▼ ▼ ▼ ▼ ▼
MySQL Redis MongoDB Elasticsearch RabbitMQ
事务事实源 缓存/Geo/锁/计数 文档/日志 搜索索引 消息总线
各存储的职责边界(这是本项目最重要的设计取舍之一):
| 存储 | 定位 | 承载数据 |
|---|---|---|
| MySQL | 事务性事实源(唯一同步阻断点) | tickets 工单主表、workers 维修员档案、settlements 结算单、audit_rules 结算规则 |
| Redis | 高并发读 + 原子操作(拆 4 个逻辑库) | DB0 热状态缓存 ticket:{id}:info、DB1 Geo workers:geo、DB2 分布式锁 lock:ticket:{id}、DB3 计数器 worker:{id}:daily_order |
| MongoDB | 灵活文档 + 过程日志 | ai_analysis_logs AI 分析日志、repair_records 维修记录、ticket_attachments 图片元数据、notifications 通知、audit_logs 审计 |
| Elasticsearch | 全文检索 + 绩效聚合 | tickets_index 工单检索、workers_perf_index 维修员绩效 |
| RabbitMQ | 异步解耦 + 延迟调度 | dispatch、dispatch_timeout(延迟队列)、es_sync(重试+DLQ)、review_queue |
设计原则:MySQL 写是唯一同步阻断点,其余操作(NLP 解析、Mongo 日志、ES 索引、消息发布)全部异步 + try/except 容错------单个环节失败绝不影响主流程。这一原则贯穿了从报修到结算的每一处代码。
3. AI 决策链:LangGraph 事件驱动短图
3.1 为什么是「事件驱动短图」而非「长驻状态机」
这是本项目最核心的架构决策。项目初期曾考虑用 LangGraph 的 checkpointer 持久化图状态、让一张长生命周期状态机「驻留」在内存中跟踪每个工单的全流程。最终否决,理由如下:
- 工单生命周期本质是「天级」的:从报修到结算可能跨越数小时到数天,把状态驻留在进程内存既不现实也脆弱(重启即丢)。
- 状态已有事实源 :工单状态流转已经由 MySQL
tickets.status承担,且 Redis 里有热缓存、MongoDB 里有过程日志,没必要再引入第三份「图状态」。 - 业务事件天然离散:报修(report)、超时(timeout)、完工(complete)是三个独立的触发点,每个事件只需要「跑几秒钟、做完决策、退出」。
因此落地为 事件驱动短图 :每个业务事件触发一次 ainvoke(event=...),图在秒级内跑完即退出;工单生命周期状态存 MySQL,RabbitMQ 延迟队列负责「唤醒」下一次图执行。
python
# agent_state.py ------ 状态仅在单次图执行内流转,不持久化
class TicketState(TypedDict, total=False):
event: str # "report" | "timeout" | "complete"
ticket_id: str
description: str
image_urls: List[str]
ai_category: str
ai_confidence: float
emergency_level: int
selected_worker_id: Optional[str]
assigned_worker_id: Optional[str]
dispatch_result: dict[str, Any]
verdict: Optional[bool]
verify_confidence: float
diff_summary: str
before_photo_urls: List[str]
after_photo_urls: List[str]
repair_description: str
DB 会话不进 state ,而是通过 ainvoke(config={"configurable": {"db": session}}) 传入,节点从 config["configurable"]["db"] 取。这样避免了把不可序列化的 AsyncSession 塞进图状态,也天然隔离了每次事件执行的数据库事务边界。
3.2 图结构:五节点 + 条件边 + 无 checkpointer
python
builder = StateGraph(TicketState)
builder.add_node("route", route_node) # 薄路由:加载工单、映射字段
builder.add_node("parse", parse_node) # 报修解析决策点
builder.add_node("dispatch", dispatch_node) # 派单决策点
builder.add_node("verify", verify_node) # 视觉验收决策点
builder.add_node("rework", rework_node) # 返工副作用节点
builder.add_edge(START, "route")
builder.add_conditional_edges("route", _route_by_event,
{"parse": "parse", "dispatch": "dispatch", "verify": "verify"})
builder.add_edge("parse", END)
builder.add_edge("dispatch", END)
builder.add_conditional_edges("verify", _route_by_verdict,
{"rework": "rework", "end": END})
builder.add_edge("rework", END)
decision_graph = builder.compile() # 不传 checkpointer,不持久化图状态
#mermaid-svg-YyJcDqMT5Qr0HzpU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YyJcDqMT5Qr0HzpU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YyJcDqMT5Qr0HzpU .error-icon{fill:#552222;}#mermaid-svg-YyJcDqMT5Qr0HzpU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YyJcDqMT5Qr0HzpU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YyJcDqMT5Qr0HzpU .marker.cross{stroke:#333333;}#mermaid-svg-YyJcDqMT5Qr0HzpU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YyJcDqMT5Qr0HzpU p{margin:0;}#mermaid-svg-YyJcDqMT5Qr0HzpU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-YyJcDqMT5Qr0HzpU .cluster-label text{fill:#333;}#mermaid-svg-YyJcDqMT5Qr0HzpU .cluster-label span{color:#333;}#mermaid-svg-YyJcDqMT5Qr0HzpU .cluster-label span p{background-color:transparent;}#mermaid-svg-YyJcDqMT5Qr0HzpU .label text,#mermaid-svg-YyJcDqMT5Qr0HzpU span{fill:#333;color:#333;}#mermaid-svg-YyJcDqMT5Qr0HzpU .node rect,#mermaid-svg-YyJcDqMT5Qr0HzpU .node circle,#mermaid-svg-YyJcDqMT5Qr0HzpU .node ellipse,#mermaid-svg-YyJcDqMT5Qr0HzpU .node polygon,#mermaid-svg-YyJcDqMT5Qr0HzpU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YyJcDqMT5Qr0HzpU .rough-node .label text,#mermaid-svg-YyJcDqMT5Qr0HzpU .node .label text,#mermaid-svg-YyJcDqMT5Qr0HzpU .image-shape .label,#mermaid-svg-YyJcDqMT5Qr0HzpU .icon-shape .label{text-anchor:middle;}#mermaid-svg-YyJcDqMT5Qr0HzpU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YyJcDqMT5Qr0HzpU .rough-node .label,#mermaid-svg-YyJcDqMT5Qr0HzpU .node .label,#mermaid-svg-YyJcDqMT5Qr0HzpU .image-shape .label,#mermaid-svg-YyJcDqMT5Qr0HzpU .icon-shape .label{text-align:center;}#mermaid-svg-YyJcDqMT5Qr0HzpU .node.clickable{cursor:pointer;}#mermaid-svg-YyJcDqMT5Qr0HzpU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YyJcDqMT5Qr0HzpU .arrowheadPath{fill:#333333;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YyJcDqMT5Qr0HzpU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YyJcDqMT5Qr0HzpU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YyJcDqMT5Qr0HzpU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YyJcDqMT5Qr0HzpU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YyJcDqMT5Qr0HzpU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YyJcDqMT5Qr0HzpU .cluster text{fill:#333;}#mermaid-svg-YyJcDqMT5Qr0HzpU .cluster span{color:#333;}#mermaid-svg-YyJcDqMT5Qr0HzpU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-YyJcDqMT5Qr0HzpU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YyJcDqMT5Qr0HzpU rect.text{fill:none;stroke-width:0;}#mermaid-svg-YyJcDqMT5Qr0HzpU .icon-shape,#mermaid-svg-YyJcDqMT5Qr0HzpU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YyJcDqMT5Qr0HzpU .icon-shape p,#mermaid-svg-YyJcDqMT5Qr0HzpU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YyJcDqMT5Qr0HzpU .icon-shape .label rect,#mermaid-svg-YyJcDqMT5Qr0HzpU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YyJcDqMT5Qr0HzpU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YyJcDqMT5Qr0HzpU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YyJcDqMT5Qr0HzpU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} event=report
event=timeout
event=complete
verdict=True
verdict=False
START
route 入口路由
parse 报修解析
dispatch 派单决策
verify 视觉验收
END
rework 返工
节点边界规则 (这也是一个值得记下的设计约定):LangGraph 节点 = AI 决策点;工具调用、硬约束、评分算法、降级回退都是「节点内部」的实现细节,不提升为独立节点。这样做让图保持扁平,避免了把确定性业务逻辑(如硬约束过滤)错误地抽象成图节点而过度设计。
3.3 三个调用点如何「唤醒」图
| 业务事件 | 触发位置 | 图入口 | 节点链路 |
|---|---|---|---|
| 市民报修 | report_service.submit_repair_report |
event=report |
route → parse |
| 10 分钟超时 | main.handle_timeout(RabbitMQ 消费者) |
event=timeout |
route → dispatch |
| 维修员完工 | repair_service.worker_complete |
event=complete |
route → verify(→ rework) |
以报修为例,report_service 在同步落地 MySQL 之后,把原本内联的 NLP 解析段落替换为一次图调用,失败仍然只降级为默认分类、绝不阻塞主流程:
python
ai_category = "其他设施"
try:
await decision_graph.ainvoke(
{"event": "report", "ticket_id": ticket_id,
"description": description, "image_urls": image_urls},
config={"configurable": {"db": db}},
)
fresh = (await db.execute(
select(Ticket).where(Ticket.ticket_id == ticket_id)
)).scalar_one_or_none()
ai_category = fresh.ai_category if fresh and fresh.ai_category else "其他设施"
except Exception as e:
logger.warning(f"NLP解析失败(使用默认值): {e}")
4. 三大 AI 决策点详解
4.1 parse 节点:报修解析
输入市民文字描述(可选图片 + 坐标),输出归一化的 {category, sub_category, emergency_level, confidence, ...} 结构化结果,回写 MySQL 的 ai_category / ai_confidence / emergency_level,并落 MongoDB ai_analysis_logs。
实现上走 LangChain with_structured_output ------用 Pydantic 模型 NLPOutput 约束 LLM 输出,LLM 直接返回经过校验的对象:
python
model = provider.get_model()
structured_model = model.with_structured_output(NLPOutput)
result: NLPOutput = await structured_model.ainvoke(messages)
一个容易被忽略的工程细节是 降级知识库 :当 LLM_API_KEY 未配置或调用失败时,analyze_repair_request 不会抛错,而是回退到一份关键词匹配的 mock 知识库 ------内置了路灯故障、道路破损、井盖异常、护栏损坏、环卫设施、交通信号设施、公共绿化等 7 类标准维修知识(维修步骤、所需工具、零件、安全提示)。这样即使 LLM 完全不可用,维修工在 H5 端仍能看到有用的作业指导,做到「降级不降质量」。
4.2 dispatch 节点:派单决策(核心亮点,详见第 5 节)
4.3 verify 节点:视觉验收 + 返工闭环
输入维修前照片、维修后照片与维修描述,调用视觉模型 qwen-vl-max 对比,输出 {verified, confidence, diff_summary}。关键在于它的失败语义:
- 验收通过 (
verdict=True):worker_complete继续执行通过分支------状态repairing → verifying、记录completed_at、ES 同步、Redis 同步、把维修员重新加回 Geo 候选池。 - 验收不通过 (
verdict=False):图的条件边路由到rework_node,由它承担全部返工副作用------状态退回repairing、repair_records打ai_rework_required标记、推送返工通知、ES/Redis 同步、Geo 回池。 - LLM 验收服务不可用 :保留「默认通过」的语义(
verified=True, confidence=0.75),保证链路不因 AI 服务抖动而卡死。
python
def _route_by_verdict(state: TicketState) -> str:
"""验收结果路由:未通过 → 返工节点;通过 → 结束。"""
return "rework" if state.get("verdict") is False else "end"
5. 派单决策:硬约束 + 四维加权 + LLM 工具循环
派单是整条链路的核心亮点,实现为一个三层漏斗:
#mermaid-svg-aKd6PN8Zii47iIo8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aKd6PN8Zii47iIo8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aKd6PN8Zii47iIo8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aKd6PN8Zii47iIo8 .error-icon{fill:#552222;}#mermaid-svg-aKd6PN8Zii47iIo8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aKd6PN8Zii47iIo8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aKd6PN8Zii47iIo8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aKd6PN8Zii47iIo8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aKd6PN8Zii47iIo8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aKd6PN8Zii47iIo8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aKd6PN8Zii47iIo8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aKd6PN8Zii47iIo8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aKd6PN8Zii47iIo8 .marker.cross{stroke:#333333;}#mermaid-svg-aKd6PN8Zii47iIo8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aKd6PN8Zii47iIo8 p{margin:0;}#mermaid-svg-aKd6PN8Zii47iIo8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aKd6PN8Zii47iIo8 .cluster-label text{fill:#333;}#mermaid-svg-aKd6PN8Zii47iIo8 .cluster-label span{color:#333;}#mermaid-svg-aKd6PN8Zii47iIo8 .cluster-label span p{background-color:transparent;}#mermaid-svg-aKd6PN8Zii47iIo8 .label text,#mermaid-svg-aKd6PN8Zii47iIo8 span{fill:#333;color:#333;}#mermaid-svg-aKd6PN8Zii47iIo8 .node rect,#mermaid-svg-aKd6PN8Zii47iIo8 .node circle,#mermaid-svg-aKd6PN8Zii47iIo8 .node ellipse,#mermaid-svg-aKd6PN8Zii47iIo8 .node polygon,#mermaid-svg-aKd6PN8Zii47iIo8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aKd6PN8Zii47iIo8 .rough-node .label text,#mermaid-svg-aKd6PN8Zii47iIo8 .node .label text,#mermaid-svg-aKd6PN8Zii47iIo8 .image-shape .label,#mermaid-svg-aKd6PN8Zii47iIo8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-aKd6PN8Zii47iIo8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aKd6PN8Zii47iIo8 .rough-node .label,#mermaid-svg-aKd6PN8Zii47iIo8 .node .label,#mermaid-svg-aKd6PN8Zii47iIo8 .image-shape .label,#mermaid-svg-aKd6PN8Zii47iIo8 .icon-shape .label{text-align:center;}#mermaid-svg-aKd6PN8Zii47iIo8 .node.clickable{cursor:pointer;}#mermaid-svg-aKd6PN8Zii47iIo8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aKd6PN8Zii47iIo8 .arrowheadPath{fill:#333333;}#mermaid-svg-aKd6PN8Zii47iIo8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aKd6PN8Zii47iIo8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aKd6PN8Zii47iIo8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aKd6PN8Zii47iIo8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aKd6PN8Zii47iIo8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aKd6PN8Zii47iIo8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aKd6PN8Zii47iIo8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aKd6PN8Zii47iIo8 .cluster text{fill:#333;}#mermaid-svg-aKd6PN8Zii47iIo8 .cluster span{color:#333;}#mermaid-svg-aKd6PN8Zii47iIo8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aKd6PN8Zii47iIo8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aKd6PN8Zii47iIo8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-aKd6PN8Zii47iIo8 .icon-shape,#mermaid-svg-aKd6PN8Zii47iIo8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aKd6PN8Zii47iIo8 .icon-shape p,#mermaid-svg-aKd6PN8Zii47iIo8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aKd6PN8Zii47iIo8 .icon-shape .label rect,#mermaid-svg-aKd6PN8Zii47iIo8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aKd6PN8Zii47iIo8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aKd6PN8Zii47iIo8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aKd6PN8Zii47iIo8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} LLM 失败/越界
Redis Geo 半径检索
确定性硬约束过滤
高德驾车距离修正
四维加权评分 兜底
LLM 工具循环 增强
分布式锁 + 指派
5.1 第一层:Redis Geo 半径检索
维修员通过 /location 接口实时上报坐标到 workers:geo,派单时用 GEORADIUS 按故障设施坐标做半径筛选(普通工单 5km、紧急工单 10km,最多取 20 人):
python
DEFAULT_SEARCH_RADIUS_KM = 5 # 默认半径
EMERGENCY_SEARCH_RADIUS_KM = 10 # 紧急工单半径
MAX_CANDIDATES = 20 # 候选上限
nearby_workers = await redis_geo.georadius(
"workers:geo", facility_lng, facility_lat, radius_km,
unit="km", withdist=True, withcoord=True,
sort="ASC", count=MAX_CANDIDATES)
5.2 第二层:确定性硬约束(先于一切评分执行,LLM 无法绕过)
候选必须全部满足三条硬约束,任一违反即剔除:
- 日单上限 :
today_orders < worker.max_daily_orders(当日已接单数未达上限); - 夜班值守 :22:00--06:00 时段必须开启
night_duty; - 技能匹配 :
worker.skills与报修类别匹配。
python
# 约束1:日单量上限
today_orders = int(await redis_counter.get(f"worker:{wid}:daily_order") or 0)
if today_orders >= worker.max_daily_orders:
continue
# 约束2:夜班检查
if is_night and not worker.night_duty:
continue
# 约束3:技能匹配
if worker.skills and not any(s.lower() in facility_lower or facility_lower in s.lower()
for s in skills_list):
continue
紧急工单的放宽兜底 :当硬约束过滤后为空且是紧急工单,会以 _apply_hard_constraints_relaxed 放宽技能/夜班/日单上限(上限放宽至 1.5 倍)再试一次,保证紧急工单一定派得出去。
5.3 两段式距离:Redis Geo 直线 → 高德驾车
直线距离不能反映真实到达时间,因此引入两段式距离管道:
- 粗筛:Redis Geo 直线距离筛出 ≤20 个候选(快、免费);
- 精修 :对候选逐一调高德驾车路径规划
driving_distance,替换为真实路面距离 + 预估耗时,结果带 5 分钟 Redis 缓存 (amap:driving:{worker_id}:{ticket_id})避免重复计费; - 降级:高德失败时保留直线距离(或 Haversine 近似),绝不让外部 API 故障阻塞派单。
5.4 第三层:四维加权评分(确定性兜底)
评分维度与权重(dispatch_score_service.DEFAULT_WEIGHTS):
| 维度 | 权重 | 数据来源 | 归一化公式 |
|---|---|---|---|
| distance 距离 | 40% | 高德驾车距离(修正后) | max(0, 100 - km*20) |
| load 负载 | 30% | worker:{id}:daily_order 当日接单计数 |
max(0, 100 - orders*5) |
| rating 好评 | 20% | worker:{id}:profile 的 star |
min(100, star*20) |
| response 响应 | 10% | worker:{id}:profile 的 avg_response_min |
max(0, 100 - min*5) |
python
total = (w["distance"]*dist_score + w["load"]*load_score
+ w["rating"]*rating_score + w["response"]*response_score) / 100.0
这个确定性加权算法是派单链路的最后一道保险------它不依赖任何外部服务,保证 LLM、高德、Redis 全部不可用时仍能产出可用的派单结果。
5.5 LLM 工具循环(增强,开关控制)
在确定性结果之上,当 LLM_ENABLE_DISPATCH_SCORING=True 时,dispatch 节点会进入 LLM 工具调用循环 ------这是全项目唯一用到 LangChain bind_tools 的地方,模型自主决策调用哪些工具:
| 工具 | 作用 |
|---|---|
search_candidates |
Geo 半径检索候选 |
get_worker_profile |
读维修员画像 + 当日负载 |
get_driving_distance |
高德驾车距离(带缓存) |
submit_dispatch |
终结决策 ,args 复用 DispatchScoreOutput schema |
python
llm = model.bind_tools([
search_candidates, get_worker_profile, get_driving_distance, submit_dispatch,
])
循环最多 4 轮(MAX_TOOL_ITERATIONS = 4),模型在收集够信息后调用 submit_dispatch 提交最终选择。关键安全设计 ------LLM 的最终选择必须落在硬约束过滤后的合法候选池 valid_ids 内,否则回退确定性算法:
python
valid_ids = {c["worker_id"] for c in candidate_pool} # 硬约束过滤后的合法池
...
if final_choice is not None:
if final_choice in valid_ids:
return {"selected_worker_id": final_choice, "scores": final_scores}
logger.warning(f"LLM 派单选择 {final_choice} 不在硬约束过滤后候选池中,回退确定性算法")
return None
一句话总结这个设计:硬约束是确定性的「地板」,四维加权是确定性的「兜底」,LLM 工具循环是「天花板」------LLM 只被允许在硬约束划定的安全边界内做「更聪明的排序」,任何异常、超限、越界都回退到确定性结果。
5.6 指派:分布式锁 + MySQL 乐观锁双保险
无论走确定性还是 LLM 路径,落库副作用统一由 _assign_worker 承担,保证两条路径的副作用完全一致:
SETNX获取lock:ticket:{id}(300s 自动过期防死锁);- MySQL 更新
assigned_worker_id + status=dispatching + dispatched_at; - Redis 状态缓存同步 + 从接单大厅
zrem+ Geo 移除该维修员; - 创建派单通知、MongoDB 审计日志、发布 10 分钟超时检查消息、ES 同步。
抢单路径则另有 MySQL 层的 UPDATE ... WHERE status IN ('pending','accepting') 乐观锁兜底,防止 Redis 锁 TTL 过期等极端场景下的并发双接。
6. 响应速度指标:增量平均算法
「响应速度」被统一为一句话:从工单进入可派发状态到维修工首次响应的时长(分钟)。两种场景的起点不同:
| 场景 | 公式 | 写入点 |
|---|---|---|
| 抢单 | accepted_at − created_at |
accept_ticket |
| 派单 | started_at − dispatched_at |
worker_checkin |
为支持这一指标,tickets 表新增了 dispatched_at 字段,并实现了增量平均(避免每次全量重算历史):
txt
new_avg = old × n/(n+1) + current × 1/(n+1) # n = total_orders
python
n = max(worker.total_orders or 0, 0)
old = float(worker.avg_response_minutes or 0.0)
new_avg = round((old * n + current_minutes) / (n + 1), 2)
worker.avg_response_minutes = new_avg
await db.commit()
# 同步 Redis profile,供派单评分「响应速度」维度实时读取
await redis_cache.hset(f"worker:{worker_id}:profile", "avg_response_min", str(new_avg))
这个指标形成了一个数据闭环:响应速度影响派单评分 → 派单评分决定派给谁 → 响应速度又被新工单更新。
7. 可靠性工程:锁、延迟队列与降级
7.1 RabbitMQ 延迟队列(DLX + per-message TTL)
「10 分钟无人接单自动派单」不靠定时轮询,而是用 RabbitMQ 的 延迟队列 :消息发布到 dispatch_timeout.delay 队列(expiration=600000),TTL 过期后由死信交换机(DLX)路由到 dispatch_timeout 被消费者处理。
python
timeout_delay_queue = await _channel.declare_queue(
QUEUE_DISPATCH_TIMEOUT_DELAY, durable=True,
arguments={
"x-dead-letter-exchange": EXCHANGE_NAME,
"x-dead-letter-routing-key": QUEUE_DISPATCH_TIMEOUT,
})
7.2 ES 同步的可靠投递:重试 + 指数退避 + DLQ
ES 属于「最终一致性」环节,因此设计成全量可靠异步投递 :es_sync 消费者失败后带 x-retry-count 头重新发布到延迟队列(指数退避 2s→120s),重试耗尽(5 次)路由到死信队列 es_sync.dlq 人工处理。消费者每次从 MySQL 全量加载工单再写 ES,避免部分字段覆盖问题。
7.3 全链路降级矩阵
| 依赖 | 故障时行为 |
|---|---|
| LLM(百炼) | NLP/验收走 mock 知识库;派单走确定性四维算法 |
| 高德驾车 API | 回退 Redis Geo 直线距离 / Haversine |
| Redis Geo | 返回空候选,报「半径内无在岗维修员」 |
| MongoDB | 日志/审计写失败仅 warning,不影响主流程 |
| ES / RabbitMQ | 工单已受理,ES 下次全量同步补回 |
| MySQL | 唯一同步阻断点,失败即回滚并返回错误 |
8. 数据存储设计
8.1 工单状态机
#mermaid-svg-ajue7QSW6pck4BGN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ajue7QSW6pck4BGN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ajue7QSW6pck4BGN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ajue7QSW6pck4BGN .error-icon{fill:#552222;}#mermaid-svg-ajue7QSW6pck4BGN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ajue7QSW6pck4BGN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ajue7QSW6pck4BGN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ajue7QSW6pck4BGN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ajue7QSW6pck4BGN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ajue7QSW6pck4BGN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ajue7QSW6pck4BGN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ajue7QSW6pck4BGN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ajue7QSW6pck4BGN .marker.cross{stroke:#333333;}#mermaid-svg-ajue7QSW6pck4BGN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ajue7QSW6pck4BGN p{margin:0;}#mermaid-svg-ajue7QSW6pck4BGN defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-ajue7QSW6pck4BGN g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-ajue7QSW6pck4BGN g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-ajue7QSW6pck4BGN g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-ajue7QSW6pck4BGN g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ajue7QSW6pck4BGN g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-ajue7QSW6pck4BGN .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ajue7QSW6pck4BGN .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-ajue7QSW6pck4BGN .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-ajue7QSW6pck4BGN .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ajue7QSW6pck4BGN .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-ajue7QSW6pck4BGN .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ajue7QSW6pck4BGN .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-ajue7QSW6pck4BGN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ajue7QSW6pck4BGN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ajue7QSW6pck4BGN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ajue7QSW6pck4BGN .edgeLabel .label text{fill:#333;}#mermaid-svg-ajue7QSW6pck4BGN .label div .edgeLabel{color:#333;}#mermaid-svg-ajue7QSW6pck4BGN .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-ajue7QSW6pck4BGN .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-ajue7QSW6pck4BGN .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-ajue7QSW6pck4BGN .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-ajue7QSW6pck4BGN .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-ajue7QSW6pck4BGN .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ajue7QSW6pck4BGN .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ajue7QSW6pck4BGN #statediagram-barbEnd{fill:#333333;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ajue7QSW6pck4BGN .cluster-label,#mermaid-svg-ajue7QSW6pck4BGN .nodeLabel{color:#131300;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-ajue7QSW6pck4BGN .note-edge{stroke-dasharray:5;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-note text{fill:black;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram-note .nodeLabel{color:black;}#mermaid-svg-ajue7QSW6pck4BGN .statediagram .edgeLabel{color:red;}#mermaid-svg-ajue7QSW6pck4BGN #dependencyStart,#mermaid-svg-ajue7QSW6pck4BGN #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-ajue7QSW6pck4BGN .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ajue7QSW6pck4BGN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 市民报修
抢单(accepted_at)
10分钟超时自动派单(dispatched_at)
到场签到(started_at)
完工+AI验收通过(completed_at)
市民确认/7天自动(closed_at)
AI验收不通过(返工)
accepting
repairing
dispatching
verifying
tickets 表的关键时间戳字段:created_at / accepted_at / dispatched_at / started_at / completed_at / closed_at,六个时间戳完整刻画了工单全生命周期,也为响应速度、履约时效等指标提供了事实基础。
8.2 热状态缓存与 Geo
ticket:{id}:info(hash)------工单热状态,14 天 TTL,进度查询毫秒级;tickets:accepting(zset)------接单大厅列表,按创建时间排序;workers:geo------维修员实时位置,派单半径检索数据源。
8.3 MySQL 自动迁移
项目无 alembic,建表靠 Base.metadata.create_all。针对「新增列」这一痛点,mysql.py 里的 _ensure_columns() 会在启动时自动补齐 ORM 模型中新增但表中缺失的列 (如本次新增的 dispatched_at),避免了手动 ALTER 的运维负担。
9. 关键代码走读
9.1 派单选人(确定性)------dispatch_service._dispatch_selection
python
# 状态守卫:超时派单只处理 accepting;普通派单处理 pending/dispatching
if is_timeout_dispatch:
if ticket.status != "accepting":
return fail(...)
# 半径随紧急程度放大
radius_km = EMERGENCY_SEARCH_RADIUS_KM if emergency else DEFAULT_SEARCH_RADIUS_KM
candidate_pool = await search_nearby_workers(redis_geo, facility_lng, facility_lat, radius_km)
# 硬约束过滤 + 紧急放宽
filtered = await _apply_hard_constraints(db, redis_counter, candidate_pool, facility_type)
if not filtered and emergency:
filtered = await _apply_hard_constraints_relaxed(db, redis_counter, candidate_pool,
facility_type, relax_skill=True)
if not filtered:
return fail("附近无可用维修员")
# 高德驾车修正
await _apply_driving_correction(filtered, redis_cache, facility_lng, facility_lat, ticket_id)
# 真实数据 + 四维评分
scoring_input = [{
"worker_id": c["worker_id"],
"distance_km": c["distance_km"],
"today_orders": await _read_daily_orders(redis_counter, c["worker_id"]),
"star_rating": profile["star_rating"],
"avg_response_min": profile["avg_response_min"],
} for c in filtered]
score_result = await score_candidates(ticket_id, scoring_input, facility_lng, facility_lat)
9.2 确定性四维评分------dispatch_score_service._simple_score_candidates
python
for c in candidates:
distance_km = float(c.get("distance_km", 0) or 0)
today_orders = int(c.get("today_orders", 0) or 0)
star_rating = float(c.get("star_rating", 5.0) or 5.0)
avg_response_min = float(c.get("avg_response_min", 0) or 0)
dist_score = max(0, 100 - distance_km * 20) # 越近越高,每公里扣 20
load_score = max(0, 100 - today_orders * 5) # 越闲越高,每单扣 5
rating_score = min(100, star_rating * 20) # 5 星 → 100
response_score = max(0, 100 - avg_response_min * 5) # 越快越高,每分钟扣 5
total = (w["distance"]*dist_score + w["load"]*load_score
+ w["rating"]*rating_score + w["response"]*response_score) / 100.0
scored.sort(key=lambda x: x["total_score"], reverse=True)
return {"selected_worker_id": scored[0]["worker_id"], "scores": scored}
9.3 派单 LLM 工具循环------graph._llm_dispatch_loop
python
async def _llm_dispatch_loop(ticket, candidate_pool):
provider = get_llm_provider()
if not provider.is_available():
return None
llm = provider.get_model().bind_tools([
search_candidates, get_worker_profile, get_driving_distance, submit_dispatch,
])
valid_ids = {c["worker_id"] for c in candidate_pool} # 合法候选池
messages = [SystemMessage(content=DISPATCH_TOOL_SYSTEM_PROMPT),
HumanMessage(content=_build_dispatch_loop_user_text(ticket))]
for _ in range(MAX_TOOL_ITERATIONS):
response = await llm.ainvoke(messages)
messages.append(response)
for tc in getattr(response, "tool_calls", None) or []:
if tc.get("name") == "submit_dispatch":
final_choice = tc["args"].get("selected_worker_id")
if final_choice in valid_ids:
return {"selected_worker_id": final_choice, ...}
return None # 越界 → 回退确定性
result = await _run_tool(tc.get("name"), tc.get("args") or {})
messages.append(ToolMessage(content=json.dumps(result, ensure_ascii=False, default=str),
tool_call_id=tc.get("id")))
return None # 超限 → 回退确定性
9.4 事件触发图------main.handle_timeout
python
if ticket.status == "accepting":
# 仍在接单大厅,执行自动派单(触发 LangGraph 事件驱动短图 event=timeout)
graph_result = await decision_graph.ainvoke(
{"event": "timeout", "ticket_id": ticket_id},
config={"configurable": {"db": db}},
)
dispatch_result = graph_result.get("dispatch_result") or {...}
10. 工程实践与踩坑总结
- 「确定性兜底 + LLM 增强」是 AI 应用落地的关键姿势:永远不要让 LLM 成为单点。本项目三层漏斗里,硬约束是确定性、四维评分是确定性,LLM 只是「可选增强」,且被约束在合法候选池内------任何 LLM 异常都不会让派单链路不可用。
- 节点边界要克制:LangGraph 节点应只对应「AI 决策点」,硬约束、评分、降级都是节点内部实现。把确定性逻辑强行抽象成节点,是常见的过度设计。
- 外部 API 的缓存与降级要成对出现:高德驾车距离既有 5 分钟缓存(省钱)又有直线回退(保命);LLM 既有结构化输出又有 mock 知识库。
- 并发安全靠「锁 + 乐观锁」双保险 :Redis SETNX 锁 + MySQL
UPDATE ... WHERE status=...乐观锁,两者互补,覆盖了锁 TTL 过期等极端场景。 - 状态归属要单一:工单状态流转的「事实」只在 MySQL,Redis 是缓存、MongoDB 是日志、图状态不持久化------避免同一事实在多处维护导致不一致。
- 异步环节要「最终一致 + 可补偿」:ES 同步设计了重试 + 指数退避 + DLQ,即使失败也能在下次全量同步补回。
11. 总结
本项目以「城市公共设施智能报修与派单」为场景,落地了一条 从报修解析、智能派单到视觉验收的完整 AI 决策链,核心亮点可以归纳为:
- LangGraph 事件驱动短图:三节点决策链 + 条件边 + 返工闭环,无 checkpointer,轻量且契合天级业务生命周期;
- 三层漏斗式派单:确定性硬约束(技能/夜班/日单上限)→ 四维加权评分(40/30/20/10)→ LLM 工具循环增强,LLM 不可用时无缝回退确定性算法;
- 四库分层存储:MySQL 事务事实源、Redis 缓存/Geo/锁/计数、MongoDB 文档/日志、ES 搜索索引,各司其职;
- 可靠性工程:延迟队列、分布式锁、指数退避重试、DLQ、全链路降级矩阵。