LangGraph 实战:如何用“双图编排”将多模态 OCR-RAG 做到生产级落地
它不是“把 Chain 画成 DAG”,而是给复杂 AI 流程加上了执行语义。对于多模态 OCR-RAG 这种典型生产场景,真正该拆的不是“函数”,而是责任边界在线图,只负责快、稳、可降级;离线图,只负责重、慢、可恢复;中间靠状态层解耦,而不是靠一次请求强行串到底。一条超长 Chain 在 demo 里很优雅,在生产里通常像玻璃剑:看着亮,真打起来先碎。双图编排就没那么浪漫了,但它扛揍、能回放、能
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/版面分析、结构恢复、分块、向量化、写索引。
- 两张图之间只通过共享状态层沟通:对象存储、解析结果存储、文档状态表、向量库、人工审核队列。
这样做的好处很直接:
- 在线图不被长耗时 OCR 拖死。
- 离线图可以放心做 fan-out、重试、人工审核、断点续跑。
- 你可以把“文档是否 ready”变成一个显式状态,而不是隐含在代码里猜。
下面这张 Mermaid 图,就是我更推荐的生产形态:
这套架构的关键不是“画成两块”,而是让在线路径只消费结果,不参与重活。这才是生产级解耦。
关键技术点突破
1)先设计状态,再写节点
很多人上来就写节点函数,最后把 state 写成一个巨型 dict 垃圾场。正确顺序应该反过来:先定义状态对象,再决定节点边界。
离线图里,至少要把这些字段设计成一等公民:
- 文档标识:
doc_id、file_uri - 当前阶段:
status - 解析策略:
parse_mode - 中间产物:
page_artifacts、blocks、chunks - 质量信息:
ocr_confidence、layout_score - 恢复信息:
retry_count、last_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_id 或 query_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 的文档,你有三种合理响应:
- 返回“文档处理中”,附预计可检索状态;
- 返回已有索引的部分结果;
- 触发异步补录任务,然后立即结束在线图。
最糟糕的方案,就是让在线请求挂在那里陪 OCR 一起老去。
总结
我后来对 LangGraph 的理解越来越简单:
它不是“把 Chain 画成 DAG”,而是给复杂 AI 流程加上了执行语义。
对于多模态 OCR-RAG 这种典型生产场景,真正该拆的不是“函数”,而是责任边界:
- 在线图,只负责快、稳、可降级;
- 离线图,只负责重、慢、可恢复;
- 中间靠状态层解耦,而不是靠一次请求强行串到底。
一条超长 Chain 在 demo 里很优雅,在生产里通常像玻璃剑:看着亮,真打起来先碎。
双图编排就没那么浪漫了,但它扛揍、能回放、能插人工、能局部重试,出了事故也知道去哪里拧螺丝。
这才是生产级落地最值钱的地方。
更多推荐



所有评论(0)