LangGraph 实战:如何用“双图编排”将多模态 OCR-RAG 做到生产级落地

“不积跬步,无以至千里。”

引言:真正把多模态 RAG 做挂的,往往不是模型,而是流程

很多团队第一次做多模态 RAG,都喜欢一把梭:

文件上传 -> OCR -> 清洗 -> 分块 -> 向量化 -> 检索 -> 生成答案

看起来很顺,像一条漂亮的流水线。真到生产上,它很快就会变成一条“玻璃流水线”——哪一段摔一下,整条链都跟着碎。

尤其是文档场景:扫描 PDF、双栏排版、表格、公式、图片说明、页眉页脚、目录跳转、印章、手写批注,这些东西会把“线性 Chain”的脆弱性放大得非常难看。LangGraph 值钱的地方,不是把流程画成图,而是它把状态持久化、可恢复执行、人工介入、分支路由这些生产能力做成了执行语义,而不只是代码组织方式。LangGraph 官方文档明确把持久化、durable execution、human-in-the-loop 作为核心能力;其 checkpointer 会把状态保存到 thread 中,并在每个 super-step 写 checkpoint。(LangChain 文档)

为什么我放弃了超长 Chain?

1)单点故障会把整条链拖死

在单条超长 Chain 里,OCR 服务抖一下、向量库写入失败一次、外部重排模型超时一次,都会把整个请求打断。更糟的是,失败点前后的上下文经常混成一锅粥:你知道“坏了”,但不知道是哪一页坏了、哪一步坏了、用的哪个解析策略坏了

Graph 的价值在这里非常实际:把复杂流程拆成节点之后,失败会定位在节点边界,而不是埋在一个 500 行的“大函数玄学坨”里。LangGraph 官方也强调,节点拆分能带来更清晰的调试与可观察性;durable execution 在节点边界形成恢复点。(LangChain 文档)

2)状态丢失,会让长耗时步骤白跑

多模态 OCR 最大的问题之一不是“能不能跑”,而是“跑了 18 分钟之后挂了怎么办”。

如果你的流程是一条硬串 Chain,那么常见结果就是:从头再来。前面已经完成的 PDF 分类、页面渲染、OCR、表格结构恢复、图片裁切,全部重跑。GPU 在燃烧,账单在跳舞,人开始骂娘。

LangGraph 的 checkpointer 会把图状态写入 thread;thread_id 是恢复的锚点,interrupt 或故障后都可以基于它继续跑,而不是重新从零开始。官方文档也明确指出:没有 thread_id,就无法在 interrupt 后恢复。(LangChain 文档)

3)调试会变成灾难现场

超长 Chain 在 demo 阶段很迷人,因为它“看起来简单”。但线上调试时,它会给你一份很诚实的报应:

  • 日志只知道这次请求失败了,不知道失败在哪个文档页、哪种解析策略、哪次外部调用。
  • 同一个链里混着“纯计算步骤”和“有副作用步骤”,复跑时根本不敢随便 retry。
  • 你很难给产品、运营、标注同学一个明确的介入点。

LangGraph 的 interrupt 机制本质上就是把“人工兜底”做进执行流:图可以暂停,暴露等待信息,再由外部系统或人工审核后继续恢复。官方文档说明,interrupt() 会暂停执行、持久化图状态,并在恢复时通过 Command 继续;等待的内容会通过 __interrupt__ 返回给调用方。(LangChain 文档)

4)在线请求和离线入库,根本不是一个 SLA

这是最关键的一点。

在线问答链路追求的是低延迟、可降级、快速返回;离线入库链路追求的是高吞吐、强恢复、可审计。把两者硬塞进一条链,相当于要求“3 秒回答用户问题”和“20 分钟慢慢 OCR 整个扫描件”共享同一套时延预算。这不是架构,是自残。

所以我最后的结论非常朴素:

检索与生成是一张图,OCR 与入库是另一张图。两张图通过状态存储和索引层解耦,不要强行同生共死。

核心设计:双图编排架构

我的推荐做法是:

  • 在线图只负责:查询理解、检索、重排、生成、降级返回。
  • 离线图只负责:文档分类、OCR/版面分析、结构恢复、分块、向量化、写索引。
  • 两张图之间只通过共享状态层沟通:对象存储、解析结果存储、文档状态表、向量库、人工审核队列。

这样做的好处很直接:

  1. 在线图不被长耗时 OCR 拖死。
  2. 离线图可以放心做 fan-out、重试、人工审核、断点续跑。
  3. 你可以把“文档是否 ready”变成一个显式状态,而不是隐含在代码里猜。

下面这张 Mermaid 图,就是我更推荐的生产形态:

离线图:OCR解析与入库

共享状态与存储

在线图:检索与生成

可直接抽文本

扫描件

复杂表格/公式

通过

低置信度

查询状态

检索

异步触发补录

用户提问

在线图入口

Query Rewrite / Intent Detect

文档是否 READY?

Hybrid Retrieve + Rerank

LLM Answer + Citation

返回处理中 / 命中降级策略

Document State Store
NEW / PARSING / REVIEW / READY / FAILED

Parsed Blocks Store

Vector DB

Object Store

人工审核队列

文档上传 / 补录任务

文档分类
Text PDF / Scan / Image

解析策略路由

Direct Text + Layout

OCR / VLM 版面分析

多模态解析器

质量校验

Chunk + Metadata

Embedding

写入索引与状态更新

Interrupt / Human Review

这套架构的关键不是“画成两块”,而是让在线路径只消费结果,不参与重活。这才是生产级解耦。

关键技术点突破

1)先设计状态,再写节点

很多人上来就写节点函数,最后把 state 写成一个巨型 dict 垃圾场。正确顺序应该反过来:先定义状态对象,再决定节点边界

离线图里,至少要把这些字段设计成一等公民:

  • 文档标识:doc_idfile_uri
  • 当前阶段:status
  • 解析策略:parse_mode
  • 中间产物:page_artifactsblockschunks
  • 质量信息:ocr_confidencelayout_score
  • 恢复信息:retry_countlast_error
  • 人工审核信息:review_payload

示意代码:

from typing_extensions import TypedDict
from typing import NotRequired, Literal

class IngestState(TypedDict, total=False):
    doc_id: str
    file_uri: str
    status: Literal["NEW", "PARSING", "REVIEW", "READY", "FAILED"]
    parse_mode: Literal["direct_text", "ocr", "vlm_layout"]
    page_artifacts: list[dict]
    blocks: list[dict]
    chunks: list[dict]
    ocr_confidence: float
    layout_score: float
    retry_count: int
    last_error: NotRequired[dict]
    review_payload: NotRequired[dict]

这一步看着啰嗦,实际上是在给后面的可恢复执行、审计和监控铺路。状态字段越明确,线上救火越像工程;状态字段越含糊,线上救火越像跳大神。

2)Checkpointer 不是“可选增强”,而是生产开关

LangGraph 的持久化是通过 checkpointer 完成的。官方文档说明,图在编译时接入 checkpointer 后,会把状态以 checkpoint 的形式写到 thread 中;这个机制直接支撑 memory、time travel、fault tolerance 和 human-in-the-loop。(LangChain 文档)

对于生产环境,官方建议使用持久化 checkpointer,例如 AsyncPostgresSaver,而不是仅用于测试的内存版。(LangChain 文档)

伪代码如下:

from langgraph.graph import StateGraph, START, END

builder = StateGraph(IngestState)

builder.add_node("classify_doc", classify_doc)
builder.add_node("parse_doc", parse_doc)
builder.add_node("quality_gate", quality_gate)
builder.add_node("chunk_doc", chunk_doc)
builder.add_node("embed_and_index", embed_and_index)

builder.add_edge(START, "classify_doc")
builder.add_edge("classify_doc", "parse_doc")
builder.add_edge("parse_doc", "quality_gate")
builder.add_edge("quality_gate", "chunk_doc")
builder.add_edge("chunk_doc", "embed_and_index")
builder.add_edge("embed_and_index", END)

checkpointer = PersistentCheckpointer(...)  # 伪代码:生产环境接 Postgres/托管持久层
graph = builder.compile(checkpointer=checkpointer)

config = {
    "configurable": {
        "thread_id": "doc:20260306:abc123"
    }
}

graph.invoke(
    {"doc_id": "abc123", "file_uri": "s3://bucket/demo.pdf", "status": "NEW"},
    config=config
)

这里有两个实战要点。

第一,thread_id 不要随手乱起。
离线图建议直接绑定 doc_id;在线图建议绑定 session_idquery_id。这样你回查历史状态、补偿执行、做问题定位时,链路才能收得住。官方文档也明确说明,thread_id 是 checkpointer 读写状态的主键。(LangChain 文档)

第二,节点粒度不要贪大。
官方文档强调,图在恢复后会从中断或失败所在节点的开头重新执行,因此节点里如果塞了太多外部调用,恢复时就容易重复副作用;LangGraph 也建议把复杂节点拆开,或者至少把多操作包装成更可控的任务。(LangChain 文档)

我的建议很硬:

  • 纯计算节点可以大一点。
  • 有外部副作用的节点必须小一点。
  • 写对象存储、写数据库、调 OCR API、调 embedding API 之前,都要准备好幂等键。

不然你会在恢复执行时,亲手制造“重复写索引”“重复扣费”“重复落库”。

3)Human-in-the-loop:把人工审核做成图的一部分

多模态 OCR 里,有一类问题是自动化很难彻底消灭的:

  • 表格边界不稳
  • 公式识别冲突
  • 双栏阅读顺序歪了
  • 印章、签批把正文盖住
  • 页脚和正文混在一起

这时最糟糕的做法是:代码里 except Exception: pass,然后让错误数据静悄悄进库。

正确做法是:在质量门控节点里主动 interrupt,把问题送进人工审核队列

LangGraph 官方文档给出的机制很适合这个场景:interrupt() 会暂停执行并保存状态,之后通过 Command 恢复;中断载荷会通过 __interrupt__ 暴露给外层调用方。(LangChain 文档)

伪代码如下:

from langgraph.types import interrupt, Command

def quality_gate(state: IngestState):
    need_review = (
        state.get("ocr_confidence", 1.0) < 0.85
        or state.get("layout_score", 1.0) < 0.80
    )

    if not need_review:
        return {"status": "PARSING"}

    review_result = interrupt({
        "kind": "manual_review",
        "doc_id": state["doc_id"],
        "reason": "low_confidence_layout_or_ocr",
        "payload": {
            "ocr_confidence": state.get("ocr_confidence"),
            "layout_score": state.get("layout_score"),
            "preview": state.get("page_artifacts", [])[:2],
        }
    })

    return {
        "review_payload": review_result,
        "status": "REVIEW"
    }

人工审核通过后继续恢复:

graph.invoke(
    Command(resume={
        "action": "approve",
        "patch": {
            "parse_mode": "vlm_layout"
        }
    }),
    config={"configurable": {"thread_id": "doc:20260306:abc123"}}
)

这个模式最大的好处,不是“支持人工”,而是人工介入不再是图外的脏补丁。它是可审计、可回放、可恢复的一部分。

4)超时与 durability 选择,要按节点类型分层

LangGraph 官方文档给出了三种 durability 策略:

  • exit:只有在执行结束、报错或 interrupt 时才持久化,性能最好,但中间过程不落 checkpoint,进程中途崩掉就救不回来。
  • async:异步写 checkpoint,性能和耐久性折中。
  • sync:每一步同步写入,最稳,但更慢。(LangChain 文档)

这三个模式别当成参数说明看,要当成成本模型看。

我的经验是:

  • 在线图:优先 async,把延迟压住。
  • 离线图中的关键落库节点:优先 sync,特别是 OCR 结果、结构化块、索引写入这种不可白跑的步骤。
  • exit:只适合你明确能接受“中途崩了就整段重来”的场景。对于长耗时 OCR,我基本不建议。

更进一步,在线图和离线图的超时策略也应该完全不同:

  • 在线图:总超时要硬,节点超时更硬,超时后直接走降级答案、提示文档处理中或返回候选引用。
  • 离线图:允许长耗时,但必须可续跑;超时后不是“失败结束”,而是“切换策略 / 重试 / 进入人工审核”。

5)最新多模态解析方案,已经不只是“传统 OCR + 规则后处理”了

这一两年的趋势很明显:文档解析正在从“先 OCR,再靠规则拼版面”转向“视觉模型直接参与版面理解、阅读顺序恢复、表格/公式/图表结构解析”。

几个值得关注的方向:

  • Docling 更偏工程落地,强调多格式文档解析、PDF 高级理解、阅读顺序、表格结构,以及统一文档表示,适合做生产管线里的通用解析底座。(GitHub)
  • MinerU2.5 走的是 coarse-to-fine 两阶段路线:先做全局布局分析,再对原图高分辨率局部区域做识别,核心思路就是把“版面理解”和“细粒度识别”解耦,兼顾精度与算力。(arXiv)
  • MonkeyOCR 提出的 SRR(Structure-Recognition-Relation)范式,本质上也是在减少传统多工具流水线的碎片化,同时避免“整页都丢给超大模型硬啃”的低效。(GitHub)
  • Youtu-Parsing 则把动态分辨率视觉编码和区域级解码结合起来,并通过高并行解码加速结构化内容提取,论文里报告了相对传统自回归解码的明显速度提升。(arXiv)
  • olmOCR 则说明了另一条很实用的路线:用视觉语言模型把 PDF 线性化成更干净、更接近自然阅读顺序的文本,特别适合大规模批处理和语料构建。(arXiv)

但注意一个现实:解析模型再先进,也不会消灭流程复杂度
模型越强,分支越多:什么时候走直接抽文本,什么时候走 OCR,什么时候升级到 VLM 版面分析,什么时候人工复核——这些判断天然就是 Graph 比 Chain 更合适的地盘。

生产环境的额外思考

监控不要只盯“整条链成功率”

生产里最没用的指标之一,就是“今天总成功率 92%”。这数字很像安慰剂。

更有用的是按图、按节点、按文档类型拆:

  • 在线图:查询改写耗时、检索召回数、重排耗时、答案生成耗时、降级率
  • 离线图:文档分类命中率、OCR 平均页耗时、表格恢复失败率、人工审核占比、最终 READY 转化率
  • 共享层:状态停留时长、FAILED 堆积量、REVIEW 队列积压量

我的习惯是给每一次运行都挂上四个维度的键:

graph_id / thread_id / doc_id / page_id

这样一出问题,你可以一路钻到“第 27 页的表格结构节点为什么重试了 4 次”。

幂等性比“自动重试”更重要

Graph 一旦具备恢复能力,就要用“节点可能被重放”的心态写代码。

所以外部副作用前,至少做这些事:

  • OCR 结果落对象存储时,用 doc_id + page_no + parser_version 作为幂等键
  • 向量写入前,对 chunk 做稳定 ID
  • 外部 OCR / embedding / rerank API 结果做缓存
  • 状态更新使用显式版本号或乐观锁

别一边用 checkpoint 追求恢复,一边让重放把数据层打成筛子。

在线图一定要有“未入库降级路径”

别让在线提问线程等离线图。
用户问了一个还没 READY 的文档,你有三种合理响应:

  1. 返回“文档处理中”,附预计可检索状态;
  2. 返回已有索引的部分结果;
  3. 触发异步补录任务,然后立即结束在线图。

最糟糕的方案,就是让在线请求挂在那里陪 OCR 一起老去。

总结

我后来对 LangGraph 的理解越来越简单:

它不是“把 Chain 画成 DAG”,而是给复杂 AI 流程加上了执行语义。

对于多模态 OCR-RAG 这种典型生产场景,真正该拆的不是“函数”,而是责任边界

  • 在线图,只负责快、稳、可降级;
  • 离线图,只负责重、慢、可恢复;
  • 中间靠状态层解耦,而不是靠一次请求强行串到底。

一条超长 Chain 在 demo 里很优雅,在生产里通常像玻璃剑:看着亮,真打起来先碎。
双图编排就没那么浪漫了,但它扛揍、能回放、能插人工、能局部重试,出了事故也知道去哪里拧螺丝。

这才是生产级落地最值钱的地方。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐