PaddleOCR + Ollama 批量图片识别优化实战:从 17 分钟到 4 分钟
一、背景
我们的设备管理系统需要从 Excel 中批量提取设备标签图片,通过 OCR 识别文字,再用大语言模型提取结构化字段(机台号、厂商、型号、序列号、资产编号、出厂日期)。
系统架构
客户端(C#/Excel)
│
▼
C# ASP.NET Core WebAPI
│
├──→ PaddleOCR-VL (Docker, GPU, 占 21GB 显存) ← OCR 识别
│ │
│ ▼ (OCR 文本)
│
└──→ Ollama + Qwen2.5:7b ← LLM 提取字段
│
▼ (结构化 JSON)
返回给客户端
硬件环境
| 项目 | 配置 |
|---|---|
| 服务器 | Ubuntu 24.04 |
| GPU | NVIDIA L20 (46GB 显存) |
| 容器 | Docker 27.3.1 |
| OCR 服务 | PaddleOCR-VL(占用 ~21GB 显存) |
| LLM 服务 | Ollama + Qwen2.5:7b |
| API 服务 | C# ASP.NET Core(本地运行) |
问题
200 张设备标签图片,逐个调用接口处理,耗时约 17 分钟,严重影响工作效率。
二、瓶颈分析
2.1 单张请求的耗时分解
通过实测,单张图片的完整处理链路:
本地 C# API 服务器
│ │
│── 上传图片 ──────────────→ │ (~0.5-1s 网络传输)
│ │── 发给 OCR ──→ PaddleOCR-VL (~1s)
│ │← OCR 结果 ────
│ │── 发给 LLM ──→ Ollama (~0.8s CPU)
│ │← LLM 结果 ────
│←── 返回结果 ──────────────│
单张总耗时 ≈ 网络(1-2s) + OCR(1s) + LLM(0.8s) + 内部处理(1-2s) ≈ 5s
2.2 200 张串行预估
200 × 5s = 1000s ≈ 17 分钟
2.3 三层瓶颈定位
| 层级 | 瓶颈 | 原因 |
|---|---|---|
| OCR 层 | PaddleOCR-VL 单实例串行推理,每张 ~1s | PaddlePaddle 推理引擎底层限制,无法单实例并行 |
| LLM 层 | Ollama 默认串行,且跑在 CPU 上 | 容器未挂载 GPU,OLLAMA_NUM_PARALLEL 默认为 1 |
| 架构层 | 一次处理一张,200 次 HTTP 往返 | 没有批量接口,每次请求都有完整的网络开销 |
三、优化方案
3.1 第一步:Ollama 挂载 GPU(收益最大)
问题发现
通过 nvidia-smi 发现 Ollama 容器内没有 GPU 进程:
bash
docker exec -it ollama nvidia-smi
# 输出关键信息:
# Processes: No running processes found ← Ollama 没有在用 GPU
蓝绿部署(不影响线上服务)
bash
# 1. 启动新容器(GPU 版本,用 11435 端口,不影响旧容器的 11434)
docker run -d \
--name ollama-gpu \
--gpus all \
-e OLLAMA_NUM_PARALLEL=3 \
-e OLLAMA_MAX_LOADED_MODELS=1 \
-v /data/ollama-data:/root/.ollama \
-v /root/hjl/data/Qwen2.5-7B-Instruct:/models/qwen:ro \
-p 11435:11434 \
--restart unless-stopped \
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/ollama/ollama:latest
# 2. 验证新容器能看到 GPU
docker exec -it ollama-gpu nvidia-smi
# 3. 验证模型存在(复用旧容器数据目录,无需重新下载)
docker exec -it ollama-gpu ollama list
# 4. 发请求测试
curl http://localhost:11435/api/generate \
-d '{"model":"qwen2.5:7b","prompt":"你好","stream":false}'
# 5. 对比速度
echo "=== 新容器(GPU) ==="
time curl -s http://localhost:11435/api/generate \
-d '{"model":"qwen2.5:7b","prompt":"你好","stream":false}' > /dev/null
echo "=== 旧容器(CPU) ==="
time curl -s http://localhost:11434/api/generate \
-d '{"model":"qwen2.5:7b","prompt":"你好","stream":false}' > /dev/null
速度对比
GPU 新容器:0.133s
CPU 旧容器:2.959s
加速比:约 22 倍
验证 GPU 显存变化
模型加载前显存:21538 MiB(只有 PaddleOCR)
模型加载后显存:26769 MiB
增加约 5231 MiB ≈ 5.1 GB(Qwen2.5:7b 加载到 GPU)
Processes 列表出现:ollama_llama_server ✅
替换旧容器(端口不变)
bash
# 备份旧容器配置
docker inspect ollama > /tmp/ollama-old-config.json
# 准备启动脚本
cat > /tmp/start-ollama-gpu.sh << 'EOF'
#!/bin/bash
docker run -d \
--name ollama \
--gpus all \
-e OLLAMA_NUM_PARALLEL=3 \
-e OLLAMA_MAX_LOADED_MODELS=1 \
-v /data/ollama-data:/root/.ollama \
-v /root/hjl/data/Qwen2.5-7B-Instruct:/models/qwen:ro \
-p 11434:11434 \
--restart unless-stopped \
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/ollama/ollama:latest
EOF
chmod +x /tmp/start-ollama-gpu.sh
# 准备回滚脚本
cat > /tmp/rollback-ollama.sh << 'EOF'
#!/bin/bash
docker stop ollama 2>/dev/null
docker rm ollama 2>/dev/null
docker run -d \
--name ollama \
-v /data/ollama-data:/root/.ollama \
-v /root/hjl/data/Qwen2.5-7B-Instruct:/models/qwen:ro \
-p 11434:11434 \
--restart unless-stopped \
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/ollama/ollama:latest
echo "已恢复为 CPU 版本"
EOF
chmod +x /tmp/rollback-ollama.sh
# 执行替换(服务中断约 10 秒)
docker stop ollama && docker rm ollama
docker stop ollama-gpu && docker rm ollama-gpu
bash /tmp/start-ollama-gpu.sh
# 验证
docker exec -it ollama ollama list
docker exec -it ollama nvidia-smi
curl http://localhost:11434/api/generate \
-d '{"model":"qwen2.5:7b","prompt":"你好","stream":false}'
3.2 第二步:C# 新增批量接口
新增 DTO
csharp
// 文件:Model/Dto/BatchExtractDtos.cs
namespace Mflex.SMT.EquipmentManagement.Model.Dto;
public class BatchExtractItemResult
{
public string FileName { get; set; } = "";
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public EquipmentImageRecognizeDto? Data { get; set; }
}
public class BatchExtractResponse
{
public int TotalCount { get; set; }
public int SuccessCount { get; set; }
public int FailCount { get; set; }
public long ElapsedMs { get; set; }
public List<BatchExtractItemResult> Results { get; set; } = [];
}
新增批量接口
csharp
// 在 DeviceController.cs 中新增
[HttpPost("batch-extract")]
[RequestSizeLimit(500 * 1024 * 1024)]
public async Task<IActionResult> BatchExtract(List<IFormFile> files)
{
if (files is null || files.Count == 0)
return Ok(ApiResponse.Fail("请上传图片文件"));
if (files.Count > 300)
return Ok(ApiResponse.Fail("单次最多处理 300 张图片"));
var allowedExts = new HashSet<string>
{
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".tif"
};
foreach (var file in files)
{
var ext = Path.GetExtension(file.FileName)?.ToLowerInvariant();
if (ext is null || !allowedExts.Contains(ext))
return Ok(ApiResponse.Fail($"不支持的文件格式: {file.FileName}"));
}
_logger.LogInformation("批量提取开始,共 {Count} 张图片", files.Count);
var totalSw = Stopwatch.StartNew();
// 并发控制
var ocrSlots = new SemaphoreSlim(2); // OCR 内部串行,2 个足够
var llmSlots = new SemaphoreSlim(3); // 对应 OLLAMA_NUM_PARALLEL=3
var tasks = files.Select(async file =>
{
var fileName = file.FileName;
try
{
// Phase 1: OCR
await ocrSlots.WaitAsync();
string ocrText;
try
{
await using var stream = file.OpenReadStream();
var ocrResp = await _ocrClient.RecognizeAsync(stream, fileName);
ocrText = OcrApiClient.GetRawText(ocrResp);
}
finally { ocrSlots.Release(); }
if (string.IsNullOrWhiteSpace(ocrText))
return new BatchExtractItemResult
{
FileName = fileName,
Success = false,
ErrorMessage = "OCR 未识别到文字"
};
// Phase 2: LLM(流水线重叠:OCR 完成立刻进入 LLM)
await llmSlots.WaitAsync();
Dictionary<string, string> fields;
try
{
var prompt = _extractionPromptTemplate.Replace("{ocrText}", ocrText);
fields = await CallQwenForExtractionAsync(prompt);
}
finally { llmSlots.Release(); }
// Phase 3: 映射 DTO
var dto = fields is { Count: > 0 }
? MapToDto(fields)
: new EquipmentImageRecognizeDto();
return new BatchExtractItemResult
{
FileName = fileName,
Success = true,
Data = dto
};
}
catch (Exception ex)
{
_logger.LogError(ex, "处理图片 {File} 失败", fileName);
return new BatchExtractItemResult
{
FileName = fileName,
Success = false,
ErrorMessage = ex.Message
};
}
});
var results = await Task.WhenAll(tasks);
totalSw.Stop();
var successCount = results.Count(r => r.Success);
var failCount = results.Count(r => !r.Success);
_logger.LogInformation(
"批量提取完成: {Success}/{Total} 成功,耗时 {Ms}ms,加速约 {Speed:F1}倍",
successCount, files.Count, totalSw.ElapsedMilliseconds,
(double)(files.Count * 5000) / totalSw.ElapsedMilliseconds);
return Ok(ApiResponse.Success(new BatchExtractResponse
{
TotalCount = files.Count,
SuccessCount = successCount,
FailCount = failCount,
ElapsedMs = totalSw.ElapsedMilliseconds,
Results = results.ToList()
}));
}
HttpClient 连接池优化
csharp
// Program.cs
builder.Services.AddHttpClient("ollama", client =>
{
client.BaseAddress = new Uri(builder.Configuration["LlmApi:Url"]!);
client.Timeout = TimeSpan.FromSeconds(120);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
MaxConnectionsPerServer = 10,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1)
});
四、关键发现:OCR 并发测试
4.1 测试 VLLM 是否支持 batch 推理
PaddleOCR-VL 底层使用 VLLM 推理服务器。测试并发 vs 串行:
bash
IMG1="/path/to/test1.jpg"
IMG2="/path/to/test2.jpg"
IMG3="/path/to/test3.jpg"
# 串行 3 张
echo "=== 串行 3 张 ==="
time (
curl -s -X POST http://localhost:6511/layout-parsing \
-H "Content-Type: application/json" \
-d "{\"file\":\"$(base64 -w0 $IMG1)\",\"fileType\":1}" > /dev/null
curl -s -X POST http://localhost:6511/layout-parsing \
-H "Content-Type: application/json" \
-d "{\"file\":\"$(base64 -w0 $IMG2)\",\"fileType\":1}" > /dev/null
curl -s -X POST http://localhost:6511/layout-parsing \
-H "Content-Type: application/json" \
-d "{\"file\":\"$(base64 -w0 $IMG3)\",\"fileType\":1}" > /dev/null
)
# 并发 3 张
echo "=== 并发 3 张 ==="
time (
curl -s -X POST http://localhost:6511/layout-parsing \
-H "Content-Type: application/json" \
-d "{\"file\":\"$(base64 -w0 $IMG1)\",\"fileType\":1}" > /dev/null &
curl -s -X POST http://localhost:6511/layout-parsing \
-H "Content-Type: application/json" \
-d "{\"file\":\"$(base64 -w0 $IMG2)\",\"fileType\":1}" > /dev/null &
curl -s -X POST http://localhost:6511/layout-parsing \
-H "Content-Type: application/json" \
-d "{\"file\":\"$(base64 -w0 $IMG3)\",\"fileType\":1}" > /dev/null &
wait
)
# 并发 10 张
IMGS=($(ls /path/to/images/*.jpg | head -10))
echo "=== 并发 10 张 ==="
time (
for img in "${IMGS[@]}"; do
curl -s -X POST http://localhost:6511/layout-parsing \
-H "Content-Type: application/json" \
-d "{\"file\":\"$(base64 -w0 $img)\",\"fileType\":1}" > /dev/null &
done
wait
)
4.2 测试结果
串行 3 张:3.096s(约 1s/张)
并发 3 张:3.023s(约 1s/张)
并发 10 张:10.171s(约 1s/张)
结论:VLLM 内部串行处理,OCR 并发不会提速。
每张固定 ~1s,无论并发多少,总时间 = 数量 × 1s。
4.3 结论
OCR 层面无法通过并发优化。
瓶颈 100% 在 OCR 的串行推理,这是 PaddleOCR-VL + VLLM 的底层限制。
LLM 层面通过 GPU 部署已经解决(0.8s → 0.1s)。
五、最终效果
5.1 实测数据
7 张图片批量测试:
全部成功 ✅
总耗时:12081ms ≈ 12 秒
平均每张:12 / 7 ≈ 1.7 秒/张
20 张图片批量测试:
全部成功 ✅
总耗时:44986ms ≈ 45 秒
平均每张:45 / 20 ≈ 2.25 秒/张
5.2 200 张预估
优化前 优化后
──────── ────────
Ollama 跑在 CPU GPU(L20)
LLM 单次推理 0.8s 0.1s
接口方式 逐个调用 批量并发
流水线 无 有
OCR 阶段:200 × 1s = 200s(串行,物理瓶颈)
LLM 阶段:200 × 0.1s / 3并发 ≈ 7s
网络/调度开销:约 30-50s
优化前:200 × 5s = 1000s ≈ 17 分钟
优化后:≈ 250-270s ≈ 4-5 分钟
加速比:约 3.5-4 倍
5.3 优化贡献拆解
┌──────────────────────────────────────────────────────────┐
│ 优化项 贡献 原因 │
├──────────────────────────────────────────────────────────┤
│ Ollama GPU 部署 ★★★★★ LLM 从 0.8s→0.1s │
│ 批量接口 ★★★★☆ 省掉 200 次 HTTP │
│ 流水线重叠 ★★★☆☆ OCR/LLM 不互等 │
│ HttpClient 连接池 ★★☆☆☆ 防连接争抢 │
│ Semaphore 并发控制 ★★☆☆☆ 防资源打爆 │
└──────────────────────────────────────────────────────────┘
六、踩坑记录
6.1 Ollama 容器没挂载 GPU
现象 :nvidia-smi 在容器内能看到 GPU 信息,但 Processes 列表为空。
原因 :启动容器时没加 --gpus all 参数,Ollama 默认跑在 CPU 上。
验证方法:
bash
docker exec -it ollama nvidia-smi
# 看 Processes 列表是否有 ollama_llama_server
修复 :重建容器时加 --gpus all。
6.2 外网拉取模型超时
现象 :ollama pull qwen2.5:7b 报 dial tcp: i/o timeout。
原因 :服务器无法访问 registry.ollama.ai(国内常见)。
修复 :复用旧容器的数据目录,挂载同一个 /data/ollama-data 路径,无需重新下载。
6.3 容器挂载路径不一致
现象:新容器找不到自定义模型。
原因 :旧容器的第二个挂载是 /root/hjl/data/Qwen2.5-7B-Instruct:/models/qwen:ro,误写成了其他路径。
修复 :用 docker inspect ollama 查看旧容器的真实挂载配置,照抄。
6.4 并发 OCR 不提速
现象:并发发 10 个 OCR 请求,总时间 = 10 × 1s,和串行一样。
原因:PaddleOCR-VL 底层的 VLLM 推理服务器内部是串行处理的,不支持 batch 推理。
结论:OCR 层面无法通过并发优化,每张 ~1s 是物理瓶颈。
七、待优化项
7.1 关闭不必要的 OCR 参数
在 OcrApiClient.cs 中关闭设备标签不需要的功能,可能从 1s 降到 0.7-0.8s:
csharp
var payload = new
{
file = base64,
fileType = 1,
useDocOrientationClassify = false, // 关闭文档方向分类
useDocUnwarping = false, // 关闭文档矫正
useChartRecognition = false, // 关闭图表识别
useSealRecognition = false // 关闭印章识别
};
7.2 换用轻量 OCR 引擎
PaddleOCR-VL 精度高但慢(~1s/张)。传统 PP-OCRv4(检测+识别两阶段)在设备标签场景下精度够用,GPU 上可以做到 0.3-0.5s/张,且支持 batch 并发。但需要重新部署一套 PaddleOCR 服务。
7.3 模型升级
如果 LLM 提取的 JSON 格式偶尔不稳定,可以考虑从 qwen2.5:7b 升级到 qwen2.5:14b(剩余显存足够),提取精度会更高。
八、操作检查清单
准备工作(不影响线上):
✅ 备份旧容器配置:docker inspect ollama > /tmp/ollama-old-config.json
✅ 确认模型数据路径:ls -lh /data/ollama-data/models/
✅ 准备启动脚本:/tmp/start-ollama-gpu.sh
✅ 准备回滚脚本:/tmp/rollback-ollama.sh
C# 代码改动:
✅ 新增 BatchExtractDtos.cs
✅ DeviceController 新增 batch-extract 方法
✅ Program.cs 更新 HttpClient 连接池配置
✅ 编译通过:dotnet build -c Release
容器替换(服务中断约 10 秒):
✅ docker stop ollama && docker rm ollama
✅ bash /tmp/start-ollama-gpu.sh
✅ 验证:docker exec -it ollama nvidia-smi
✅ 验证:curl http://localhost:11434/api/generate
验证:
✅ 单张图片测试
✅ 批量接口测试(7 张、20 张)
✅ GPU 显存确认
紧急回滚:
bash /tmp/rollback-ollama.sh
九、总结
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 单张 LLM 推理 | 0.8s (CPU) | 0.1s (GPU) | 8x |
| 200 张总耗时(预估) | ~17 分钟 | ~4-5 分钟 | 3.5-4x |
| HTTP 请求次数 | 200 次 | 1 次 | 200x |
| 服务中断时间 | - | ~10 秒 | 可控 |
核心优化只有两件事:
- 把 Ollama 从 CPU 换到 GPU ------ LLM 速度提升 8 倍
- 新增批量接口 + 流水线并行 ------ 消除 HTTP 开销,OCR 和 LLM 不互等
OCR 层面(PaddleOCR-VL ~1s/张)是物理瓶颈,当前架构下无法突破。如果需要进一步提速,需要换用轻量 OCR 引擎(如 PP-OCRv4)。