PaddleOCR + Ollama 批量图片识别优化实战:从 17 分钟到 4 分钟

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:7bdial 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 秒 可控

核心优化只有两件事:

  1. 把 Ollama 从 CPU 换到 GPU ------ LLM 速度提升 8 倍
  2. 新增批量接口 + 流水线并行 ------ 消除 HTTP 开销,OCR 和 LLM 不互等

OCR 层面(PaddleOCR-VL ~1s/张)是物理瓶颈,当前架构下无法突破。如果需要进一步提速,需要换用轻量 OCR 引擎(如 PP-OCRv4)。