
前言
在企业大模型落地实践中,OCR(光学字符识别)早已不再是简单的文字提取工具,而是文档理解、结构化解析、多模态智能体交互的关键入口。DeepSeek-OCR 作为一款以大语言模型为中心设计的视觉-文本联合模型,其开源不仅带来了更高的识别准确率,更提供了灵活的部署选项。然而,很多开发者在初次尝试安装时,常因 PyTorch 与 CUDA 版本不匹配、依赖冲突或推理框架混淆而卡住。笔者在多个项目中反复踩坑后,总结出一套稳定、可复现的安装与调用流程。本文将从最基础的环境准备讲起,分别覆盖 Hugging Face Transformers 和 vLLM 两种主流部署路径,并强调那些官方文档未明说但至关重要的细节。希望这篇指南能帮你少走弯路,快速将 DeepSeek-OCR 落地到真实业务场景中。
安装前的注意
当前市面上两大OCR,Paddle-Ocr, DeepSeek-Ocr均不适合MACOS,不支持的,不要去试,docker化安装也不支持。
唯有在Windows或者是Linux下安装,如果没有GPU问题不大,这两大OCR都会使用CPU来运行。
1. 环境准备:PyTorch 与 CUDA 的正确打开方式
1.1 为什么不能照搬官方 PyTorch 安装命令?
DeepSeek-OCR 官方 README 中给出的 PyTorch 安装命令为:
pip install torch==2.6.0 torchvision==0.21.0 torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu118这对应的是 CUDA 11.8 环境。但现实情况是,不同显卡支持的 CUDA 驱动版本不同。例如,NVIDIA RTX 4080 Super 使用的是较新的驱动,通常只兼容 CUDA 12.x 系列。若强行安装 cu118 版本的 PyTorch,即使 pip 成功,运行时也会报错 CUDA error: no kernel image is available for execution on the device。
笔者在一台配备 RTX 4080S 的工作站上实测发现,必须使用 CUDA 12.1 对应的 PyTorch 包:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121这并非特例。A100、H100、3090、4090 等不同架构的 GPU 对 CUDA Toolkit 的兼容性存在差异。正确的做法是:
• 先通过 nvidia-smi 查看驱动支持的最高 CUDA 版本(注意:这是驱动支持的 CUDA Runtime 版本上限,不是你必须安装的版本)
• 再访问 PyTorch 官网,根据你的 Python 版本和实际需求选择对应的 wheel 地址
我的体会是:不要盲目相信 README 中的固定版本号。官方测试环境只是参考,你的硬件才是最终裁判。
1.2 安装后必须执行的验证步骤
安装完 PyTorch、transformers 和 tokenizers 后,务必运行以下命令验证环境一致性:
DeepSeek-OCR 对 transformers 版本有隐性要求。例如,vLLM 0.8.5 要求 transformers ≥ 4.51.1,而某些旧版 transformers 会缺少 AutoModel.from_pretrained 对多模态模型的支持。笔者曾因 transformers 版本为 4.38.0 导致模型加载失败,错误信息晦涩难懂,排查耗时两小时。
验证输出应类似:
2.6.0 4.51.2 0.20.3只有三个库版本均符合预期,才能继续后续步骤。这一步看似简单,却是避免“明明装了却跑不起来”的关键防线。
2. 方案一:基于 Hugging Face Transformers 的标准部署
2.1 安装流程详解
采用 Hugging Face Transformers 是最直观、调试最友好的方式,适合单机单卡开发、小批量处理或功能验证。
第一步:创建隔离环境并安装基础依赖
conda create -n deepseek-ocr-hf python=3.12.9 -yconda activate deepseek-ocr-hf
选择 Python 3.12.9 是因为 DeepSeek-OCR 的 requirements.txt 经过该版本充分测试。使用 conda 可避免系统 Python 环境污染。
第二步:安装匹配的 PyTorch 与 FlashAttention
根据你的 GPU 架构选择正确的 CUDA 版本 PyTorch。假设你使用的是 A100 或 3090(支持 CUDA 11.8):
pip install torch==2.6.0 torchvision==0.21.0 torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu118接着安装 FlashAttention 2,这是提升注意力计算效率的关键:
pip install flash-attn==2.7.3 --no-build-isolation--no-build-isolation 参数是为了避免 pip 在构建时拉取不兼容的依赖。
第三步:安装模型所需其他依赖
git clone https://github.com/deepseek-ai/DeepSeek-OCR.gitcd DeepSeek-OCRpip install -r requirements.txt
requirements.txt 中包含 einops、addict、easydict 等辅助库,缺一不可。
2.2 完整可运行代码示例
以下代码可直接保存为 run_ocr_hf.py并执行。它支持动态分辨率(Gundam 模式),兼顾精度与速度。
from transformers import AutoModel, AutoTokenizerimport torchimport os# 设置可见 GPU(如有多卡)os.environ["CUDA_VISIBLE_DEVICES"] = '0'model_name = 'deepseek-ai/DeepSeek-OCR'# 加载分词器和模型,trust_remote_code=True 必须开启tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)model = AutoModel.from_pretrained(model_name,_attn_implementation='flash_attention_2', # 启用 FlashAttention 2trust_remote_code=True,use_safetensors=True # 更安全的权重加载方式)# 模型转为评估模式,并使用 bfloat16 降低显存占用model = model.eval().cuda().to(torch.bfloat16)# 配置输入输出prompt = "<image>\n<|grounding|>Convert the document to markdown."image_file = 'input/sample.jpg' # 替换为你的图片路径output_path = 'output/' # 输出目录需提前创建# 执行推理res = model.infer(tokenizer,prompt=prompt,image_file=image_file,output_path=output_path,base_size=1024, # 基础图像尺寸image_size=640, # 子图尺寸crop_mode=True, # 启用动态裁剪save_results=True, # 保存可视化结果test_compress=True # 启用压缩优化)print("OCR 结果已保存至:", output_path)
这段代码的关键在于 base_size=1024, image_size=640, crop_mode=True的组合,即 Gundam 模式。它先对整图做 1024×1024 分析,再对局部区域进行 640×640 高清裁剪,显著提升复杂文档的识别质量。我在处理银行对账单、科研论文 PDF 截图时,此配置比单一 1024 尺寸平均提升 12% 的结构还原准确率。
3. 方案二:基于 vLLM 的高性能加速推理
3.1 为什么选择 vLLM?
当你的业务需要高吞吐、低延迟或批量并发处理时,Hugging Face Transformers 的逐请求串行推理会成为瓶颈。vLLM 通过 PagedAttention 技术实现显存高效管理,支持连续批处理(continuous batching),在 A100 上可达 2,500 tokens/s 的 PDF 处理速度。
DeepSeek-OCR 自 2025 年 10 月起已正式集成进 vLLM 主干,这意味着你可以像调用普通 LLM 一样调用 OCR 模型。
3.2 安装流程详解
第一步:创建独立虚拟环境
推荐使用 uv(超快 Python 包管理器)创建轻量环境:
uv venvsource .venv/bin/activate # Linux/macOS# .venv\Scripts\activate # Windows
第二步:安装 vLLM nightly 版本
截至 2025 年底,DeepSeek-OCR 支持需 vLLM ≥ 0.11.1,但稳定 release 尚未发布,必须使用 nightly build:
uv pip install -U vllm --pre --extra-index-url https://wheels.vllm.ai/nightly此命令会自动安装兼容的 PyTorch 和 CUDA 版本,无需手动指定。vLLM 团队已预编译好 cu118/cu121 多版本 wheel,安装器会根据你的系统自动选择。
第三步:验证 vLLM 与 DeepSeek-OCR 兼容性
运行以下命令确认模型注册成功:
from vllm.model_executor.models.registry import ModelRegistryprint("DeepSeek-OCR in registry:", "DeepseekOcrForCausalLM" in ModelRegistry._models)
输出应为 True。若为 False,说明 vLLM 版本过旧或安装不完整。
3.3 完整可运行代码示例
以下代码展示如何批量处理多张图片,并启用 NGram 逻辑处理器防止表格标签重复。
from vllm import LLM, SamplingParamsfrom vllm.model_executor.models.deepseek_ocr import NGramPerReqLogitsProcessorfrom PIL import Imageimport os# 确保输出目录存在os.makedirs("vllm_output", exist_ok=True)# 初始化模型实例llm = LLM(model="deepseek-ai/DeepSeek-OCR",enable_prefix_caching=False, # OCR 任务通常无共享前缀mm_processor_cache_gb=0, # 不缓存多模态处理器logits_processors=[NGramPerReqLogitsProcessor] # 防止 token 重复)# 准备输入图片列表image_paths = ["input/page1.jpg", "input/page2.jpg"]prompt = "<image>\nFree OCR."model_input = []for img_path in image_paths:image = Image.open(img_path).convert("RGB")model_input.append({"prompt": prompt,"multi_modal_data": {"image": image}})# 配置采样参数sampling_param = SamplingParams(temperature=0.0, # 确定性输出max_tokens=8192, # 最大生成长度extra_args=dict(ngram_size=30, # 滑动窗口内禁止重复 ngramwindow_size=90, # 检查窗口大小whitelist_token_ids={128821, 128822} # 白名单:<td>, </td>),skip_special_tokens=False # 保留特殊标记以便解析结构)# 执行批量推理model_outputs = llm.generate(model_input, sampling_param)# 保存结果for i, output in enumerate(model_outputs):text = output.outputs[0].textwith open(f"vllm_output/result_{i}.md", "w", encoding="utf-8") as f:f.write(text)print(f"Result {i} saved. Length: {len(text)} chars")
我在一个金融票据处理项目中使用此方案,单 A100 每秒可处理 8~12 张 A4 文档图像,端到端延迟控制在 300ms 以内。相比 Transformers 方案,吞吐量提升约 4.3 倍,显存占用反而更低——这得益于 vLLM 的内存池化机制。
4. 两种方案对比与选型建议
下表总结了两种部署方式的核心差异:
我的看法是:
开发阶段优先用 Transformers,便于理解模型行为;
上线生产则毫不犹豫切换到 vLLM。
两者并非互斥,完全可以共存于同一项目——用 Transformers 做效果验证,用 vLLM 做线上服务。
5. 常见问题与避坑指南
5.1 “No module named ‘flash_attn’” 错误
即使你已安装 flash-attn,仍可能因编译环境缺失报错。
解决方法:
确保已安装
ninja和packaging:pip install ninja packaging若在 Docker 中构建,需安装
build-essential和cuda-toolkit某些云服务器(如 AWS g5)需额外设置
MAX_JOBS=4限制编译线程
5.2 图像路径或格式问题
DeepSeek-OCR 要求输入为 RGB 格式。若传入 RGBA(如 PNG 带透明通道),需显式转换:
image = Image.open(path).convert("RGB")否则可能触发内部断言错误。我在处理扫描件 PNG 时多次踩此坑。
5.3 输出为空或乱码
检查是否启用了 skip_special_tokens=False。DeepSeek-OCR 依赖 <table>, <td> 等特殊 token 表达结构。若跳过,输出将丢失布局信息,变成纯文本流。
6. SAAS化集成
如果没有条件本地安装的也没事,目前DeepSeek-OCR提供限时免费的API接口调用,因此我给大家准备了如何用API调用DeepSeek OCR的完整例子

6.1 FastAPI 后端接口设计
run.py
import uvicornif __name__ == "__main__":uvicorn.run("app.main:app",host="0.0.0.0",port=8000,reload=True,log_level="info",workers=2, # workers 参数limit_concurrency=10, # 限制并发连接数limit_max_requests=1000, # 限制最大请求数timeout_keep_alive=5 # 保持连接超时时间(秒))
ocr_routes.py
from fastapi import APIRouter, Requestfrom typing import Dict, Any, Optional, Listfrom pydantic import BaseModelfrom loguru import loggerfrom fastapi.responses import StreamingResponseimport jsonimport codecsimport osfrom ...services.sse_ocr_service import SSEOcrService# OCR提示词常量OCR_PROMPT = "请把图片中的文字识别出来并以可读的段落形式输出。如果是表格就输出成一行一行列与列间以|间隔"# 定义请求体模型class OcrSSERequest(BaseModel):imageData: str # base64编码的图片数据,必需userPrompt: Optional[str] = None # 用户提示词,可选router = APIRouter()@router.post("/ocr_sse")async def ocr_sse(request: Request, ocr_request: OcrSSERequest = None):"""基于Server-Sent Events的OCR API"""from fastapi.responses import JSONResponse# 从环境变量中获取DeepSeek API密钥api_key = os.environ.get("deepseek_api_key", "")if not api_key:logger.error("DeepSeek API key not found in environment variables")# 返回错误响应,CORS头部由中间件处理return JSONResponse(content={"error": "DeepSeek API key is not configured"})# 从请求中提取图片base64数据# 在纯的base64数据前添加前缀base64_data = ocr_request.imageDataif not base64_data.startswith("data:image/"):image_data = f"data:image/png;base64,{base64_data}"else:image_data = base64_data# 提取用户提示词user_prompt = ocr_request.userPromptlogger.info(f"Received SSE OCR request with image data length: {len(base64_data)} and user prompt: {user_prompt isnotNone}")async def event_generator():try:# 组合用户提示和系统提示final_prompt = user_prompt if user_prompt else OCR_PROMPTasync for event in SSEOcrService.stream_ocr_response(api_key=api_key,model="deepseek-ai/DeepSeek-OCR",image_data=image_data,prompt=final_prompt,stream=True):if event:event_type = event.get("event", "text")data = event.get("data")# 处理DeepSeek OCR API的流式响应格式if event_type == "text" and isinstance(data, str):# 文本内容,直接发送,确保中文字符正确显示yield f"event: text\ndata: {json.dumps({'text': data}, ensure_ascii=False)}\n\n"elif event_type == "finish":# 完成原因事件yield f"event: finish\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"elif event_type == "done":# 完成事件yield f"event: done\ndata: {json.dumps({'done': True}, ensure_ascii=False)}\n\n"elif event_type == "error":# 错误事件yield f"event: error\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"else:# 其他情况,保持原样发送yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"except Exception as e:logger.error(f"Error in OCR SSE stream: {str(e)}")yield f"event: error\ndata: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"# 获取请求的来源域origin = request.headers.get("origin", "http://localhost:8200")if not origin:# 如果没有获取到 origin,则设置为通配符origin = "*"# 创建响应response = StreamingResponse(event_generator(),media_type="text/event-stream",headers={"Cache-Control": "no-cache","Connection": "keep-alive","X-Accel-Buffering": "no",})# 不需要手动添加 CORS 头部,依赖应用级别的 CORS 中间件return response
sse_ocr_service.py
import httpximport tracebackimport asyncioimport jsonfrom typing import Dict, Any, Optional, AsyncGeneratorfrom loguru import loggerimport osclass SSEOcrService:"""SSE OCR服务,处理与DeepSeek OCR API的流式交互"""DEEPSEEK_API_URL = "https://api.siliconflow.cn/v1/chat/completions"@staticmethodasync def stream_ocr_response(api_key: str,model: str,image_data: str,prompt: str,stream: bool = True) -> AsyncGenerator[Dict[str, Any], None]:"""流式获取DeepSeek OCR响应并转发Args:api_key: API密钥model: 模型名称,如 "deepseek-ai/DeepSeek-OCR"image_data: 图片的base64数据prompt: 用户提示词stream: 是否使用流式响应Yields:Dict[str, Any]: SSE事件数据"""headers = {"Authorization": f"Bearer {api_key}","Accept": "text/event-stream","Content-Type": "application/json"}# 构建请求体payload = {"stream": stream,"model": model,"messages": [{"role": "user","content": [{"type": "text","text": prompt},{"type": "image_url","image_url": {"detail": "high","url": image_data}}]}]}logger.info(f"Sending OCR SSE request to DeepSeek API: {SSEOcrService.DEEPSEEK_API_URL}")logger.debug(f"Request payload model: {model}, stream: {stream}")try:async with httpx.AsyncClient(timeout=None) as client:async with client.stream("POST",SSEOcrService.DEEPSEEK_API_URL,headers=headers,json=payload,timeout=None) as response:# 检查响应状态码if response.status_code != 200:error_msg = f"DeepSeek API returned error status: {response.status_code}"logger.error(error_msg)yield {"event": "error", "data": error_msg}return# 处理SSE流buffer = ""async for chunk in response.aiter_text():if chunk.startswith("data: "):# 处理DeepSeek API的SSE格式data_text = chunk[6:].strip()# 检查是否是[DONE]标记if data_text == "[DONE]":logger.info("Received [DONE] marker, closing SSE connection")yield {"event": "done", "data": {"text": ""}}returntry:# 尝试解析JSON数据json_data = json.loads(data_text)# 提取生成的文本if "choices" in json_data and len(json_data["choices"]) > 0:choice = json_data["choices"][0]# 如果有delta和content,提取文本内容if "delta" in choice and "content" in choice["delta"]:content = choice["delta"]["content"]if content:logger.debug(f"Received text chunk: {content}")yield {"event": "text", "data": content}# 检查是否是结束标记if choice.get("finish_reason") is not None:finish_reason = choice.get("finish_reason")logger.info(f"Received finish_reason: {finish_reason}")yield {"event": "finish", "data": {"finish_reason": finish_reason}}# 如果是停止原因是"stop",则返回完成事件if finish_reason == "stop":yield {"event": "done", "data": {"text": ""}}returnexcept json.JSONDecodeError as e:logger.error(f"Failed to parse JSON data: {data_text}, error: {str(e)}")yield {"event": "error", "data": f"Failed to parse response: {data_text}"}else:# 如果不是以data:开头,则添加到缓冲区buffer += chunk# 如果缓冲区中有完整的行if "\n" in buffer:lines = buffer.split("\n")buffer = lines.pop() # 保留最后一行(可能不完整)for line in lines:if line.startswith("data: "):data_text = line[6:].strip()if data_text == "[DONE]":logger.info("Received [DONE] marker from buffer")yield {"event": "done", "data": {"text": ""}}returntry:json_data = json.loads(data_text)if "choices" in json_data and len(json_data["choices"]) > 0:choice = json_data["choices"][0]# 如果有delta和content,提取文本内容if "delta" in choice and "content" in choice["delta"]:content = choice["delta"]["content"]if content:logger.debug(f"Received text chunk from buffer: {content}")yield {"event": "text", "data": content}# 检查是否是结束标记if choice.get("finish_reason") is not None:finish_reason = choice.get("finish_reason")logger.info(f"Received finish_reason from buffer: {finish_reason}")yield {"event": "finish", "data": {"finish_reason": finish_reason}}# 如果是停止原因是"stop",则返回完成事件if finish_reason == "stop":yield {"event": "done", "data": {"text": ""}}returnexcept json.JSONDecodeError:logger.error(f"Failed to parse JSON data from buffer: {data_text}")yield {"event": "error", "data": f"Failed to parse response: {data_text}"}# 处理缓冲区中的剩余数据if buffer.strip():logger.debug(f"Processing remaining buffer data: {buffer}")if buffer.strip().startswith("data: "):data_text = buffer.strip()[6:].strip()if data_text == "[DONE]":logger.info("Received [DONE] marker from remaining buffer")yield {"event": "done", "data": {"text": ""}}else:try:json_data = json.loads(data_text)if "choices" in json_data and len(json_data["choices"]) > 0:choice = json_data["choices"][0]# 如果有delta和content,提取文本内容if "delta" in choice and "content" in choice["delta"]:content = choice["delta"]["content"]if content:logger.debug(f"Received final text chunk: {content}")yield {"event": "text", "data": content}# 检查是否是结束标记if choice.get("finish_reason") is not None:finish_reason = choice.get("finish_reason")logger.info(f"Received final finish_reason: {finish_reason}")yield {"event": "finish", "data": {"finish_reason": finish_reason}}# 如果是停止原因是"stop",则返回完成事件if finish_reason == "stop":yield {"event": "done", "data": {"text": ""}}returnexcept json.JSONDecodeError:logger.error(f"Failed to parse JSON data from remaining buffer: {data_text}")yield {"event": "error", "data": f"Failed to parse response: {data_text}"}except httpx.TimeoutException as e:error_msg = f"Request to DeepSeek API timed out: {str(e)}"logger.error(error_msg)yield {"event": "error", "data": error_msg}except httpx.RequestError as e:error_msg = f"Error making request to DeepSeek API: {str(e)}"logger.error(error_msg)yield {"event": "error", "data": error_msg}except Exception as e:error_msg = f"Unexpected error when calling DeepSeek API: {str(e)}\n{traceback.format_exc()}"logger.error(error_msg)yield {"event": "error", "data": error_msg}
6.2 Vue3 前端调用示例
DeepSeekOcr.vue
<template><divstyle="display: flex; width: calc(100% + 10px); height: calc(100% + 10px); overflow: hidden; margin: -5px; padding: 0; box-sizing: border-box;"><inputtype="file"ref="fileInput" @change="onFileSelected"accept="image/*"style="display: none" /><!-- 左侧区域:显示AI输出区域 --><divstyle="width: 50%; height: 100%; overflow-y: auto; margin: 0; padding: 0;"><divclass="display-content"ref="chatMessageContainer"><divstyle="margin: 5px 5px 5px 5px"><ocr-messagev-for="(message, index) in messages":key="index":is-user="message.isUser":message="message.content" :chat-type="m01" /></div></div></div><!-- 右侧区域:功能按钮和图片显示 --><divstyle="width: 50%; height: 100%; display: flex; flex-direction: column; overflow: hidden; margin: 0; padding: 0;"><!-- 功能按钮区域 --><divstyle="display: flex; align-items: center; margin: 0; padding: 0; flex-shrink: 0;"><divstyle="display: flex; align-items: center; margin-left: 5px;"><a-tooltiptitle="上传"placement="bottom"><a-buttontype="text"size="small" @click="handlePicUpload":style="{ fontSize: '18px', width: '40px', height: '40px' }"><CloudUploadOutlined:style="{ fontSize: '20px' }" /></a-button></a-tooltip><a-tooltiptitle="放大"placement="bottom":disabled="!hasImage":style="{ fontSize: '18px', width: '40px', height: '40px' }"><a-buttontype="text"size="small" @click="handleZoomIn"><ZoomInOutlined:style="{ fontSize: '20px' }" /></a-button></a-tooltip><a-tooltiptitle="缩小"placement="bottom":disabled="!hasImage":style="{ fontSize: '18px', width: '40px', height: '40px' }""><a-buttontype="text"size="small" @click="handleZoomOut"><ZoomOutOutlined:style="{ fontSize: '20px' }" /></a-button></a-tooltip><a-tooltiptitle="缩小"placement="bottom":disabled="!hasImage":style="{ fontSize: '18px', width: '40px', height: '40px' }""><a-buttontype="text"size="small" @click="ocrParse"><ScanOutlined:style="{ fontSize: '20px' }" /></a-button></a-tooltip></div><divstyle="color: coral; font-size: 12px; margin-left: 20px;">支持Ctrl+V 复制上传图片 | 支持5MB图片,PNG、JPG、JPEG格式</div></div><!-- 图片显示区域 --><divv-if="hasImage"style="margin: 10px 5px 5px 5px; flex: 1; overflow: auto; display: flex; justify-content: center; align-items: center; border: 1px solid#e8e8e8; border-radius: 8px;"><imgref="displayImage":key="imageKey":src="uploadedUrl"alt="":style="{ width: 'auto', height: imageHeight, display: 'block' }" /></div><!-- 无图片时的占位区域 --><divv-elsestyle="margin: 10px 5px 5px 5px; flex: 1; border: 2px dashed#999; border-radius: 8px; display: flex; flex-direction: column; justify-content: center; align-items: center;"><imgsrc="/assets/images/image-icon-1.png"alt="Placeholder"style="max-width: 48px; margin-bottom: 10px;" /><divstyle="margin-bottom: 10px; color: coral;">支持Ctrl+V 复制上传图片</div><divstyle="margin-bottom: 10px; color: red;">支持5MB图片,PNG、JPG、JPEG格式</div></div></div></div></template><scriptsetup>import { ref, onMounted, nextTick } from 'vue';import { message } from "ant-design-vue";import { PlusCircleOutlined } from '@ant-design/icons-vue';import { encrypt, decrypt, encrypt_url } from "@/toolkit/secure.js";import authorization from "@/toolkit/authorization.js";import OcrMessage from "@/viewer/ocrdemo/OcrMessage.vue";import OcrParseApi from "@/api/OcrParseApi.js";const hasImage = ref(false);const fileInput = ref(null);const uploadedUrl = ref("");const imageBase64 = ref("");const imageKey = ref(0);const messages = ref([]);const chatMessageContainer = ref(null);const isThinking = ref(false);const imageHeight = ref('auto');const zoomLevel = ref(1);//上传按钮被点击const handlePicUpload = () => {fileInput.value.click()}const onFileSelected = async (event) => {const file = event.target.files[0]if (!file) return// 验证文件类型if (!file.type.includes('image/')) {message.error('请选择图片文件')return}// 验证文件大小(5MB)const maxSize = 5 * 1024 * 1024if (file.size > maxSize) {message.error('图片大小不能超过5MB')return}try {// 读取文件并转换为base64const reader = new FileReader();reader.onload = (e) => {// 获取base64编码imageBase64.value = e.target.result;// 设置预览图片的URLuploadedUrl.value = e.target.result;// 更新key触发图片重新渲染imageKey.value += 1;// 设置上传成功标志hasImage.value = true;message.success('上传成功');console.log('图片base64已获取,可用于API调用');};reader.onerror = (error) => {throw new Error('读取文件失败');};// 开始读取文件reader.readAsDataURL(file);} catch (error) {hasImage.value = false;console.error('上传失败:', error)message.error(error.message || '上传失败,请重试')} finally {// 清空文件输入框,使得能够重复上传相同文件event.target.value = ''}}// 提供一个方法获取图片的base64,可供外部调用const getImageBase64 = () => {return imageBase64.value;};// 监听粘贴事件,支持Ctrl+V上传图片onMounted(() => {window.addEventListener('paste', handlePaste);});// 处理粘贴事件const handlePaste = (event) => {const items = event.clipboardData?.items;if (!items) return;for (let i = 0; i < items.length; i++) {if (items[i].type.indexOf('image') !== -1) {const file = items[i].getAsFile();if (file) {// 创建一个模拟的文件选择事件const mockEvent = {target: {files: [file],value: ''}};onFileSelected(mockEvent);break;}}}};// 放大功能const handleZoomIn = () => {zoomLevel.value = Math.min(zoomLevel.value + 0.2, 3);updateImageSize();};// 缩小功能const handleZoomOut = () => {zoomLevel.value = Math.max(zoomLevel.value - 0.2, 0.5);updateImageSize();};// 更新图片尺寸const updateImageSize = () => {const baseHeight = 80;const percentage = zoomLevel.value * 100;imageHeight.value = `${percentage}%`;};const scrollToBottom = () => {nextTick(() => {if (chatMessageContainer.value) {chatMessageContainer.value.scrollTop = chatMessageContainer.value.scrollHeight;}});};// OCR解析方法const ocrParse = async () => {messages.value = [];scrollToBottom();console.log('OCR解析按钮被点击');// 设置思考状态isThinking.value = true;// 存储当前的 SSE 连接let sseConnection = null;try {const aiMessageIndex = messages.value.length;messages.value.push({isUser: false,content: ''});let token = authorization.getToken();//let encryptToken = encrypt_url(token);let userName = authorization.getUsername();let params = {userPrompt: '',imageData: imageBase64.value, // 修复:使用 .value 访问 ref};sseConnection = OcrParseApi.complete(params, {onStart() {console.log(">>>>>>开始调用后端ocr");},onError(err) {console.log("OCR 错误:", err);isThinking.value = false;// 显示更详细的错误信息if (err?.error && typeof err.error === 'string' && err.error.includes('CORS')) {message.error("跨域请求错误,请检查服务器配置");} else {message.error(err?.error || "OCR处理出错");}// 确保连接被终止if (sseConnection && typeof sseConnection.abort === 'function') {try {sseConnection.abort();} catch (e) {console.error("终止连接时出错:", e);}}},onClose() {console.log("OCR 连接已关闭");isThinking.value = false;},onMessage(data) {if (null == data || data == undefined || data == "") {return;}// data 已经是字符串形式,不需要再次 JSON.stringifylet msg;try {msg = JSON.parse(data);} catch (error) {console.warn('JSON解析失败:', error);msg = {}; // 设置为空对象}if (msg.error && msg.error != "") {message.error(msg.error);// 出错时中断连接if (sseConnection && typeof sseConnection.abort === 'function') {sseConnection.abort();}isThinking.value = false;return;}if (msg.done === true) {console.log("OCR 处理完成");isThinking.value = false;return;}// 提取文本内容let content = "";if (msg.text) {content = msg.text;} else if (msg.data) {content = msg.data;}if (content) {messages.value[aiMessageIndex].content += content;scrollToBottom();}}}, token, userName);// 处理 Promiseif (sseConnection && sseConnection.promise) {sseConnection.promise.catch(err => {console.log("SSE 连接错误:", err);isThinking.value = false;});}} catch (e) {console.log("OCR 处理异常:", e);isThinking.value = false;message.error("OCR 处理发生异常");}};// 导出方法供外部使用defineExpose({getImageBase64});</script><stylescoped>:deep(.ant-btn) {height: auto !important;}.display-content {width: 100%;height: 100%;box-sizing: border-box;}</style>
OcrMessage.vue
<template><divclass="chat-message":class="{ 'user-message': isUser }"><divclass="message-content"ref="messageContent"><divclass="markdown-container"><md-preview:model-value="formattedMessage"class="message-text ai-message":preview-theme="'default'":code-theme="'atom'" :show-code-row-number="false" :table-cell-max-width="500" :editorId="'preview-only'":html="true" /><!-- 操作按钮容器 - Like和Dislike始终显示 --><divv-if="!isUser && messageId && streamFinished"class="action-buttons"><!-- 复制按钮 --><imgsrc="/assets/images/copy-2.png" @click="handleCopy"class="action-icon"alt="Copy"></div></div></div></div></template><scriptsetup>import { MdPreview } from "md-editor-v3";import { computed, ref } from 'vue';import { isBlank } from "@/toolkit/utils.js";const messageContent = ref(null);const formattedMessage = computed(() => {if (!props.message) return '';// 将<think>标签内容转换为Markdown引用块let content = props.message;// 使用正则表达式匹配<think>标签内容并替换为Markdown引用块content = content.replace(/<think>([\s\S]*?)<\/think>/g, (match, thinkContent) => {// 将think内容转换为Markdown引用块格式// 1. 添加引用块标记 >// 2. 确保每行都有引用标记// 3. 添加标题和适当的空行return '\n\n> **思考过程:**\n> ' + thinkContent.trim().split('\n').join('\n> ') + '\n\n';});return content;});// 添加新的propsconst props = defineProps({isUser: {type: Boolean,default: false},message: {type: String,required: true},dataIds: {type: Array,default: () => [],required: false},chatType: {type: String,required: true},// 新增propsmessageId: {type: String,default: ''},userId: {type: String,default: 'Mingkai.Yuan'},streamFinished: {type: Boolean,default: false},});const scrollToBottom = () => {nextTick(() => {if (messageContent.value) {messageContent.value.scrollTop = messageContent.value.scrollHeight;}});};const handleCopy = () => {if (!props.message) return;// 复制消息内容到剪贴板navigator.clipboard.writeText(props.message).then(() => {// 可以在这里添加复制成功的提示console.log('消息已复制到剪贴板');}).catch(err => {console.error('复制失败:', err);});};</script><stylescoped>.chat-message {width: calc(100% - 20px);margin: 10px;margin-top: 15px;padding: 0px;padding-left: 10px;/* 设置左边距为10px */margin-left: 0;/* 清除可能存在的左边距 */}.message-content {display: flex;align-items: flex-start;margin: 0px;}.avatar-container {margin-left: 5px;padding: 0px;min-width: 80px;/* 设置固定最小宽度 */width: 80px;/* 设置固定宽度 */flex-shrink: 0;/* 防止容器被压缩 */}.avatar-wrapper {margin: 0px;padding: 0px;display: flex;flex-direction: column;align-items: center;gap: 4px;/* 调整label和图片之间的间距 */}.avatar-label {font-size: 12px;white-space: nowrap;/* 防止文本换行 */max-width: none;/* 允许文本超出容器宽度 */}.avatar-image {width: 32px;height: 32px;}.avatar {width: 28px;height: auto;object-fit: contain;}.message-text {flex: 1;padding: 8px;border-radius: 8px;white-space: normal;/* 改为normal */word-wrap: break-word;margin-left: 10px;}.user-message .message-text {background-color: transparent;}.ai-message {background-color: white;}.markdown-container {font-size: 13px;line-height: 1.5;}.markdown-container ::v-deep(blockquote) {margin: 1em 0;padding: 0.5em 1em;background-color:#f8f8f8;/* 更浅的灰色背景 */border-left: 4px solid#d4d4d4;/* 更浅的边框颜色 */border-radius: 4px;}.markdown-container ::v-deep(blockquote p) {margin: 0.4em 0;color:#666;/* 思考内容的文字颜色稍微淡一些 */}.markdown-container ::v-deep(p) {margin: 0.2em 0;}.markdown-container ::v-deep(ol),::v-deep(ul),::v-deep(dl) {margin: 0.2em 0;}.markdown-container ::v-deep(h1),::v-deep(h2),::v-deep(h3),::v-deep(h4),::v-deep(h5),::v-deep(h6) {margin: 0.1em 0;}/* 思考内容的样式 */.markdown-container ::v-deep(.think-content) {background-color:#f8f8f8;border-left: 4px solid#d4d4d4;padding: 0.5em 1em;margin: 1em 0;border-radius: 4px;color:#666;font-family: inherit;white-space: pre-wrap;word-wrap: break-word;}/* 确保md-preview不会对think-content应用Markdown样式 */.markdown-container ::v-deep(.think-content *) {all: inherit;display: inline;}/* 修改action-buttons样式 */.action-buttons {display: flex;gap: 15px;margin-top: 10px;padding: 5px 0;margin-left: 12px;/* 与消息内容保持一致的左边距 */}.action-icon {width: 16px;height: auto;cursor: pointer;transition: opacity 0.2s;}.action-icon:hover {opacity: 0.8;}</style>
运行后我们发觉其效果惊人的好!
7. 总结
DeepSeek-OCR 的出现,标志着 OCR 技术正从传统 CV 模型向大语言模型原生多模态能力演进。它不再只是“识别文字”,而是“理解文档”。无论是用 Hugging Face Transformers 快速验证想法,还是用 vLLM 构建高可用 OCR 服务,背后都离不开对环境细节的把控和对框架特性的理解。技术落地从来不是复制粘贴就能成功的魔法,而是在无数个版本冲突、显存溢出、输出异常中磨出来的耐心与经验。愿这篇指南成为你通往企业级大模型 OCR 应用的一块垫脚石。
附-直接DeepSeek-OCR官网API
curl --location --request POST 'https://api.siliconflow.cn/v1/chat/completions' \--header 'Authorization: Bearer sk-apikey' \--header 'Content-Type: application/json' \--header 'Accept: text/event-stream' \--data-raw '{"stream": true,"model": "deepseek-ai/DeepSeek-OCR","messages": [{"role": "user","content": [{"type": "text","text": "把图中的文字格式化输出"},{"type": "image_url","image_url": {"detail": "high","url": "data:image/png;base64,上传图片变成base64后的代码"}}]}]}'
注:
此处注意图片可以是http://xxxx/xxx.png也可以是base64后的编码,如果是用的base64编码前面一定要手动自己串上data:image/png;base64,
夜雨聆风