基于 LangGraph + LangChain + FastAPI 构建的银行流水信息提取服务,通过多模态视觉大模型(阿里云百炼 Qwen-VL)从图片/PDF 中自动识别、结构化银行交易流水,并输出标准 CSV。
上游系统 (POST /ocr)
│
▼
┌─────────────────────────────────────────────────────┐
│ FastAPI 服务层 │
│ ┌──────────────────────────────────────────────┐ │
│ │ POST /ocr POST /ocr/upload │ │
│ │ (远程URL方式) (本地文件上传方式) │ │
│ └──────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────┘
│
▼
┌──────────────────────┐
│ URL 修正 & 分类 │
│ (fix_url / detect) │
└──────┬──────────────┘
┌─────────┴──────────┐
图片 PDF
│ │
│ pdf_to_images()
│ (PyMuPDF)
│ │ (每页转 JPEG)
└─────────┬──────────┘
│ asyncio.gather (并发)
▼
┌────────────────────────────────────┐
│ LangGraph OCR Agent │
│ │
│ START │
│ ├─► [extract_transactions] ─┐ │
│ └─► [extract_metadata] ─┤ │
│ (两个 VLM 节点并行) │ │
│ ▼ │
│ [merge_results] │
│ │ │
│ END │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 数据处理层 │
│ Markdown → DataFrame │
│ → transaction_mapper 标准化 │
│ → 多文件 DataFrame 合并 │
└────────────────────────────────────┘
│
▼
返回 base64(CSV) + 统计信息
接收请求体 OCRRequest
└─ extract_remote_urls() 从 transactions[].files[].remote_url 提取所有 URL
└─ fix_url() 不以 / 或 http/data: 开头的 URL 补充 /
└─ asyncio.gather() 并发处理所有 URL(受 MAX_WORKERS 信号量控制)
└─ _process_single_url()
├─ detect_file_type() 通过 URL 后缀 或 data: MIME 判断图片/PDF
├─ [图片] → _process_single_image() → Agent → DataFrame
└─ [PDF] → download_file() / base64 解码
→ pdf_to_images() PDF 拆分为 JPEG 页数组
→ asyncio.gather() 各页并发调 Agent
→ 每页 _process_single_image() → DataFrame
└─ merge_dataframes() 所有 DataFrame 合并为一份
└─ 返回 base64(CSV)
与 /ocr 逻辑完全相同,区别在于:
- 跳过远程 URL 下载:直接接收
multipart/form-data文件字节 - 根据文件名后缀自动判断图片/PDF 并走相同处理链路
- 适用于本地验收测试、联调时无需配置影像平台
参考 账单提取核心.yml Dify DSL 的工作流结构,用 LangGraph 原生实现:
AgentState
image_url → 输入:图片 URL 或 data:image base64
transactions_md → LLM2 输出:Markdown 表格(交易流水正文)
metadata_json → LLM3 输出:JSON(账户元数据)
merged_result → 合并节点输出:最终 JSON 字符串
errors → Annotated[list[str], reducer] 收集并行节点错误
START
├──► extract_transactions (LLM2)
│ System Prompt: 提取全部交易记录 → 输出 Markdown 表格
│ Model: qwen3-vl-flash (temperature=0.3)
│
└──► extract_metadata (LLM3) ← 与 LLM2 并行执行
System Prompt: 仅提取表头客户名称/账号/开户行 → 输出 JSON
Model: qwen3-vl-flash (temperature=0.2)
↓
merge_results
将 Markdown 文本 + JSON 合并为统一结构:
{ "客户名称": ..., "客户账号": ..., "账户所属机构": ...,
"transactions": "| 列1 | 列2 |..." }
↓
END
两个 LLM 节点并行 fan-out,merge_results fan-in 汇聚,利用 LangGraph 的 Annotated reducer 解决并发写入冲突。
Agent JSON
└─ transactions_md (Markdown 表格文本)
└─ markdown_to_dataframe() Markdown → HTML → pd.read_html() → DataFrame
└─ process_transaction_data() (完全复用 transaction_mapper.py)
├─ map_fields() 列名模糊匹配 → 标准字段名
├─ clean_dirty_data() 清洗分隔行、全 "-" 行、无效行
├─ split_datetime_fields() datetime 拆分为 date + time
├─ parse_amount() 金额清洗(去符号、处理负数)
├─ parse_debit_credit() 借贷标志标准化 → 借/贷
└─ 填充 account_name/id/bank/serial_id/行号/时间戳
└─ DataFrames → merge_dataframes()
├─ pd.concat() 合并多文件
├─ 重置 rel_line_num 连续行号
└─ 刷新 create_time/update_time
└─ dataframe_to_csv_string() → UTF-8-SIG CSV 字符串 → base64
| 函数 | 说明 |
|---|---|
detect_file_type(url) |
通过 URL 后缀或 data: MIME 判断 image/pdf |
download_file(url) |
异步 HTTP 下载远程文件(httpx) |
pdf_to_images(bytes) |
PyMuPDF 渲染 → PIL 压缩 → JPEG 字节列表 |
image_to_base64_url(bytes) |
转 data:image/jpeg;base64,... |
TimedRotatingFileHandler
when="midnight" 每天 0 点切割
backupCount=90 保留 90 天(3 个月)
suffix="%Y-%m-%d" 文件名格式: app.log.2026-03-04
编码 = utf-8
日志文件位于 logs/ 目录,同时输出到控制台(INFO 级)。
transactionOCR/
├── app/
│ ├── main.py # FastAPI 应用 & 路由注册
│ ├── config.py # 环境变量配置(dotenv)
│ ├── models.py # Pydantic 请求/响应模型
│ ├── logger.py # 日志配置(3 个月轮转)
│ ├── routers/
│ │ └── ocr.py # POST /ocr POST /ocr/upload
│ ├── services/
│ │ ├── url_fixer.py # URL 修正 & remote_url 提取
│ │ ├── file_handler.py # 文件下载、类型检测、PDF→图片
│ │ ├── ocr_agent.py # LangGraph Agent(双 VLM 并行)
│ │ └── data_processor.py # Markdown→DataFrame→CSV、合并
│ └── utils/
│ └── markdown_parser.py # Markdown 表格 → DataFrame
├── tests/
│ └── test_ocr.py # 单元测试 + 集成测试 + 本地上传测试
├── transaction_mapper.py # 字段映射核心(复用原有逻辑)
├── requirements.txt
├── .env # 本地配置(不提交 Git)
└── logs/ # 运行时生成
请求体:
{
"orderNO": "BIZ-20260101-001",
"remoteUrls": ["https://image-server.com/path/to/bill.pdf"],
"custName": "张三",
"custNo": "CUST-001",
"companyName": "某某有限公司",
"contractNo": "CONTRACT-001",
"accountNo": "6228480190000000000",
"accountName": "张三"
}该接口完成后回调环境变量 CALLBACK_URL 指定的地址。
立即响应(202):
{
"orderNO": "BIZ-20260101-001",
"message": "已受理,处理完成后将回调通知"
}请求字段与 /ocr 一致,并额外传入必填的 callback_url:
{
"orderNO": "BIZ-20260101-001",
"remoteUrls": ["https://image-server.com/path/to/bill.pdf"],
"callback_url": "https://your-domain.com/webhook/result"
}该接口同样立即返回 202,但处理结果会回调 callback_url,而不是使用固定的 CALLBACK_URL。
PDF 输出的 page_no 使用 PDF 物理页序覆盖模型值;图片使用各自页面提取的页码,未识别则为空。多张图片分别提取元数据,禁止跨图片传播账户信息。
CSV 还会返回账期、合计、本页余额、期末余额、打印次数、网点、印章企业名称、cust_no、contract_no、file_url 和 file_type 等字段。其中 file_url 对应原始 remoteUrls 元素,file_type 用于区分 pdf/jpg/png 等源文件类型。
请求体与 /ocr 完全一致。接口等待识别完成后直接返回 OCRResponse,不发送回调;处理过程与异步接口共用相同的任务容量、执行信号量、分块并发和重试机制。
curl -X POST http://localhost:8000/ocr/sync \
-H "Content-Type: application/json" \
-d '{"orderNO":"SYNC-001","remoteUrls":["https://image-server.com/bill.pdf"],"custNo":"CUST-001","contractNo":"CONTRACT-001"}'本地调试、跳过远程 URL 下载,直接上传文件。
curl -X POST http://localhost:8000/ocr/upload \
-F "files=@WechatIMG127.jpg;type=image/jpeg" \
-F "files=@上海银行交易流水20260128-1.pdf;type=application/pdf" \
-F "order_no=LOCAL_TEST_001" \
-F "custNo=CUST-001" \
-F "contractNo=CONTRACT-001"参数与 /ocr 响应结构完全一致。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | / |
服务信息 |
| GET | /health |
健康检查 {"status":"ok"} |
| GET | /docs |
Swagger UI(FastAPI 自动生成) |
| GET | /redoc |
ReDoc 文档 |
所有配置通过 .env 文件或环境变量注入:
| 变量 | 默认值 | 说明 |
|---|---|---|
LLM_API_KEY |
sk-... |
阿里云百炼 API Key |
LLM_BASE_URL |
https://dashscope.aliyuncs.com/compatible-mode/v1 |
OpenAI 兼容端点 |
LLM_EXTRACT_MODEL |
qwen3.5-35b-a3b |
流水提取模型 |
LLM_META_MODEL |
qwen3.5-35b-a3b |
元数据提取模型 |
REQUEST_TIMEOUT |
1200 |
LLM 请求超时(秒) |
MAX_WORKERS |
2 |
PDF 文件级并发上限 |
CHUNK_SIZE |
5 |
每个 chunk 的图片数 |
MAX_CHUNK_WORKERS |
12 |
单请求 chunk 并发上限 |
GLOBAL_SEMAPHORE_SIZE |
8 |
全局 chunk 并发闸门(跨请求) |
BG_TASK_MAX_CONCURRENCY |
2 |
后台任务执行并发上限 |
BG_TASK_QUEUE_LIMIT |
20 |
后台任务队列容量上限 |
TO_THREAD_MAX_WORKERS |
8 |
asyncio.to_thread 默认线程池大小 |
LOG_LEVEL |
INFO |
全局日志级别 |
RAW_HTTP_LOG_ENABLED |
false |
是否记录原始请求/响应报文 |
PDF_QUALITY |
85 |
JPEG 压缩质量(1-100) |
PDF_ZOOM |
2.78 |
PDF 渲染缩放倍率 |
LOG_DIR |
logs |
日志目录 |
LOG_RETENTION_DAYS |
90 |
日志保留天数(3 个月) |
DOWNLOAD_MAX_KEEPALIVE_CONNECTIONS |
20 |
下载连接池 keepalive 上限 |
DOWNLOAD_MAX_CONNECTIONS |
100 |
下载连接池总连接上限 |
PORT |
8000 |
服务端口 |
| 参数 | 建议值 | 备注 |
|---|---|---|
WORKERS |
2 |
先保守,避免多进程放大竞争 |
MAX_CHUNK_WORKERS |
4~8 |
视 LLM 吞吐与 CPU 调整 |
GLOBAL_SEMAPHORE_SIZE |
4~8 |
控制全局并发洪峰 |
BG_TASK_QUEUE_LIMIT |
20~50 |
过载时快速拒绝避免雪崩 |
TO_THREAD_MAX_WORKERS |
4~12 |
建议与容器 CPU 核数联动 |
LOG_LEVEL |
INFO |
排障时短时切到 DEBUG |
RAW_HTTP_LOG_ENABLED |
false |
仅短时排障开启 |
pip install -r requirements.txt复制 .env.example 为 .env,并填入你自己的 API Key:
LLM_API_KEY=your_api_key_herepython -m app.main
# 服务在 http://0.0.0.0:8000 启动或使用 uvicorn 多 worker 模式(生产环境):
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4curl http://localhost:8000/health
# {"status":"ok","service":"transaction-ocr-agent"}python -m pytest tests/test_ocr.py -v -k "not Integration and not LocalUpload"覆盖:健康检查、请求模型验证、URL 修正、文件类型检测、Markdown 解析、PDF 转图片、数据处理。
使用当前目录下的
WechatIMG127.jpg、WechatIMG128.jpg、WechatIMG129.png、上海银行交易流水20260128-1.pdf直接调用/ocr/upload。
# 运行全部本地上传测试
RUN_INTEGRATION=1 python -m pytest tests/test_ocr.py::TestLocalUpload -v -s
# 只测单张图片
RUN_INTEGRATION=1 python -m pytest tests/test_ocr.py::TestLocalUpload::test_upload_single_image -v -s
# 只测 PDF
RUN_INTEGRATION=1 python -m pytest tests/test_ocr.py::TestLocalUpload::test_upload_single_pdf -v -s
# 图片 + PDF 多文件并发
RUN_INTEGRATION=1 python -m pytest tests/test_ocr.py::TestLocalUpload::test_upload_multiple_files -v -s也可以直接用 curl 测试:
# 单张图片
curl -X POST http://localhost:8000/ocr/upload \
-F "files=@WechatIMG127.jpg" \
-F "order_no=TEST001" | python3 -c "
import sys,json,base64
d=json.load(sys.stdin)
print('records:', d['total_records'])
if d.get('csv_data'):
print(base64.b64decode(d['csv_data']).decode('utf-8-sig')[:500])
"
# PDF
curl -X POST http://localhost:8000/ocr/upload \
-F "files=@上海银行交易流水20260128-1.pdf" \
-F "order_no=TEST_PDF"RUN_INTEGRATION=1 python -m pytest tests/test_ocr.py::TestOCRAgentIntegration -v -s| 测试类 | 测试数 | 结果 | 说明 |
|---|---|---|---|
TestHealthCheck |
2 | ✅ | 基础接口 |
TestRequestValidation |
2 | ✅ | 参数校验 |
TestURLFixer |
3 | ✅ | URL 修正逻辑 |
TestFileTypeDetection |
4 | ✅ | 文件类型检测 |
TestMarkdownParser |
3 | ✅ | Markdown 表格解析 |
TestPDFConversion |
1 | ✅ | PDF→JPEG 转换 |
TestDataProcessor |
2 | ✅ | 字段映射 & 合并 |
TestLocalUpload |
4 | ✅ | 本地文件全流程(需 LLM) |
TestOCRAgentIntegration |
4 | ✅ | 远程 URL 全流程(需 LLM) |
| 合计 | 25 | ✅ 全通过 |
服务运行后日志保存在 logs/ 目录:
logs/
├── app.log # 当天日志(实时追加)
├── app.log.2026-03-04 # 历史日志(按天存档)
└── app.log.2026-03-03
# 实时查看日志
tail -f logs/app.log
# 搜索特定订单
grep "order_no=BIZ-001" logs/app.log
# 统计成功/失败
grep -c "处理完成" logs/app.log
grep -c "处理失败" logs/app.log日志默认保留 90 天(3 个月),可通过
LOG_RETENTION_DAYS调整。