在前面的智能化改造中,我们已经完成了大模型接入、LLM 调用层封装以及多个业务场景的落地实践。接下来,我们继续回到孢子记账的既有能力中,审视另一个非常适合智能化改造的功能 OCR 识别。
当前项目中的 OCR 实现还存在一个明显的工程问题,识别能力依赖固定的第三方 SDK,并且相关调用逻辑直接写在业务代码中。以前的 OCRConsumerService.cs 会在构造函数里创建百度 Ocr 客户端,然后在消费 MQ 消息时调用 AccurateBasic。这意味着 OCR 服务商、SDK、请求方式和响应格式都被绑死在消费者里,一旦后续需要更换 OCR 服务商、调整识别模型,或者从传统 OCR SDK 切换到大模型 OCR,就必须修改代码、重新编译并重新发布服务。对于一个持续演进的产品来说,这种强绑定会不断抬高维护成本,也会限制后续技术选型的灵活性。
因此本篇文章将对 OCR 能力进行一次通用化改造,把原来写死在 OCR 消费者里的百度 SDK 调用,替换成一个通用的 OCR Provider 机制。改造完成后,消费者只负责"拿图片、调用 OCR、保存文本",至于 OCR 怎么识别、请求哪个接口、使用什么模型,都交给独立的 IOcrProvider 实现处理。这样一来业务流程不再依赖某个具体 OCR 服务商,后续只要仍然使用 OpenAI 兼容的视觉/OCR 模型,就可以通过 Nacos 配置切换模型、接口地址和提示词,而不需要重新改动业务代码和发布服务。
一、通用 OCR 接口
1.1 IOcrProvider 的作用
要把 OCR 能力从具体 SDK 中解耦出来,首先需要为业务代码定义一个稳定的调用边界。这个边界不能暴露百度、SiliconFlow 或其他服务商的专有概念,也不能让消费者去感知 HTTP 请求、SDK 客户端、响应 JSON 结构这些实现细节。否则即使把百度 SDK 换成了大模型 OCR,业务层仍然会和新的实现方式绑定在一起,后续再切换模型或服务商时,还是会遇到同样的问题。
因此这次改造中最关键的抽象就是 IOcrProvider.cs。它代表"一个能够完成 OCR 识别的提供者",消费者只依赖这个接口,而不直接依赖任何具体 OCR 实现。接口本身保持得非常克制,只暴露一个方法:
csharp
Task<OcrRecognitionResult> RecognizeAsync(byte[] image, CancellationToken cancellationToken = default);
这个方法把 OCR 的输入和输出固定下来。输入永远是图片字节,输出永远是统一的 OcrRecognitionResult。至于图片字节最终是交给 SDK 处理,还是转成 base64 后发送给 OpenAI 兼容的多模态接口,都属于 Provider 内部的实现细节。消费者不需要知道供应商返回的是百度的 words_result,还是 OpenAI 兼容接口的 choices,也不需要知道响应 JSON 的具体结构。
这样做的好处是,OCR 调用链被拆成了两层,消费者负责业务流程,Provider 负责识别实现。消费者只关心"这张图片有没有识别出文字",Provider 则负责处理"如何调用外部服务、如何解析响应、如何把结果转换成统一模型"。只要 Provider 能把自己的响应转换成 RecognizedText,消费者就可以继续工作。
换句话说 IOcrProvider 是这次通用化改造的中心点。它让 OCR 能力从"某个服务商的 SDK 调用"变成了"业务系统中的一个可替换能力"。后续如果需要新增其他 OpenAI 兼容视觉模型,甚至重新接入传统 OCR 服务,也只需要新增或替换 Provider 实现,而不需要再改 MQ 消费、文件下载和数据库写入这些业务流程。
1.2 统一结果模型
有了 IOcrProvider 之后,还需要继续解决另一个问题,不同 OCR 服务商返回的数据结构并不一致。如果直接把外部响应传回业务层,那么消费者仍然需要知道每个服务商的返回格式。比如百度 OCR 返回的是 words_result,OpenAI 兼容接口返回的是 choices[0].message.content,而大模型在 content 中还可能再包一层 JSON 字符串。只统一调用入口还不够,识别结果本身也必须在项目内部拥有一个稳定的形状。因此我们定义了统一结果模型 OcrRecognitionResult.cs:
csharp
public class OcrRecognitionResult
{
public string RecognizedText { get; set; } = "";
public List<string> Lines { get; set; } = new List<string>();
public string Provider { get; set; } = "";
public string RawResponse { get; set; } = "";
}
这个模型的职责不是完整表达某个服务商的全部响应细节,而是提炼出业务流程真正需要的通用信息。
RecognizedText 是最核心的字段,它表示最终要写入数据库的完整文字。对于消费者来说,只要这个字段有值,就说明 OCR 识别已经产出了可用结果,后续就可以继续创建或更新 ImageText 数据。Lines 用来保留逐行识别结果。当前业务写库时主要使用完整文本,但逐行结果仍然很有价值。后续如果要做票据结构化、账单字段提取、前端分行展示,或者对识别结果做二次校验,就可以直接复用这个字段,而不需要重新解析原始响应。Provider 用于记录本次识别来自哪个 Provider。这个字段在日常功能里看起来不显眼,但在排查问题时很有用。比如同一个图片在不同模型下识别效果不同,或者某个时间段内 OCR 质量突然下降,通过日志中的 Provider 信息就能更快定位到具体实现或配置。RawResponse 保留外部服务商返回的原始内容。大模型 OCR 和传统 OCR SDK 不同,它的输出有时会受到提示词、模型版本和图片质量影响。保留原始响应可以帮助我们在解析失败、JSON 格式异常、文本缺失时回看模型到底返回了什么,而不是只得到一个"识别失败"的结果。
通过这个模型,OCR 识别结果在项目内部有了稳定形状,外部服务商变化不会继续污染业务流程。
二、SiliconFlow OCR Provider
2.1 配置热切换
真正调用 SiliconFlow 的实现是 OpenAiCompatibleOcrProvider.cs。虽然当前接入的是 SiliconFlow,但这个类的命名没有直接写成 SiliconFlowOcrProvider,而是使用了 OpenAiCompatible 这个更通用的表达。原因在于它依赖的不是某个厂商 SDK,而是 OpenAI 兼容的 chat/completions 协议。只要后续的视觉/OCR 模型仍然兼容这套请求格式,Provider 的主体逻辑就可以继续复用。在配置读取上,它同时使用 IOptionsMonitor<LlmOptions> 和 IOptionsMonitor<OcrOptions>:
csharp
LlmOptions llmOptions = _llmOptions.CurrentValue;
OcrOptions ocrOptions = _ocrOptions.CurrentValue;
ValidateConfiguration(llmOptions, ocrOptions);
这里把配置拆成了两类。LlmOptions 保存大模型平台的通用接入信息,例如 BaseUrl、APIKey 和 Chat 路径,OcrOptions 保存 OCR 场景自己的行为配置,例如 Model、Prompt、TimeoutSeconds、MaxTokens、Temperature 和 ImageDetail。这样拆分以后,通用网关配置和 OCR 业务配置不会混在一起,后续如果其他业务也要复用 LLM 配置,也不会被 OCR 专属参数污染。
更重要的是 Provider 没有在构造函数里把这些配置缓存成私有字段,而是在每次 RecognizeAsync 执行时读取 CurrentValue。这就是 Nacos 热切换能生效的关键。如果在构造函数中读取一次配置,那么服务启动后拿到的就是一份固定快照,即使 Nacos 中的配置已经刷新,当前 Provider 实例仍然可能继续使用旧的模型、旧的提示词或旧的接口地址。
使用 IOptionsMonitor.CurrentValue 以后,每次 OCR 识别都会读取当前最新配置。对于 MQ 消费场景来说,这个特性非常实用,因为 OCR 消费者通常是长期运行的后台服务,不会像普通 HTTP 请求那样频繁创建和释放。我们希望调整模型、提示词或接口地址时,不需要重启整个资源服务,更不需要重新发布代码。
Nacos 配置刷新以后,下一条 MQ OCR 消息进来时,Provider 就会拿到新的 BaseUrl、Model、Prompt 等配置。如果线上发现某个模型识别票据效果不理想,可以先在 Nacos 中切换模型名或调整提示词,然后让后续消息直接走新配置。整个过程从代码发布动作变成了配置变更动作,运维成本和试错成本都会低很多。
2.2 图片转为多模态请求
传统 OCR SDK 通常会提供一个直接接收图片字节或图片路径的方法,调用方把图片传进去以后,SDK 内部会负责上传、编码和请求封装。但现在我们不再依赖某个厂商 SDK,而是直接调用 OpenAI 兼容的多模态接口,所以 Provider 需要自己把图片转换成接口能够识别的输入格式。第一步是识别图片类型,再把图片字节转成 base64 data URL:
csharp
string mediaType = DetectImageMediaType(image);
string imageDataUrl = $"data:{mediaType};base64,{Convert.ToBase64String(image)}";
这里的 DetectImageMediaType(image) 负责根据图片文件头判断媒体类型,例如 image/png、image/jpeg 或 image/webp。这样做比直接写死 image/jpeg 更稳妥,因为用户上传的图片格式并不固定,可能是手机拍照的 jpg,也可能是截图生成的 png。如果媒体类型不准确,模型接口可能无法正确解析图片内容。
得到媒体类型以后,再通过 Convert.ToBase64String(image) 把图片字节转换成 base64 字符串,并拼成 data:{mediaType};base64,... 这种 data URL。这样请求体中就可以直接携带图片内容,不需要先把图片上传到一个公网可访问的临时地址,也不需要额外维护图片访问权限和过期时间。对于 OCR 消费者来说,图片已经从 OSS 下载到了服务端内存中,直接转成 data URL 是最简单、最封闭的一种传递方式。
接下来构造 OpenAI 兼容的 chat/completions 多模态请求,把模型名、提示词、图片一起传给 SiliconFlow:
csharp
var requestBody = new Dictionary<string, object?>
{
["model"] = ocrOptions.Model,
["messages"] = new object[]
{
new Dictionary<string, object?>
{
["role"] = AIRole.System,
["content"] = ocrOptions.Prompt
},
new Dictionary<string, object?>
{
["role"] = AIRole.User,
["content"] = new object[]
{
new Dictionary<string, object?>
{
["type"] = "text",
["text"] = "请识别这张图片中的文字。"
},
new Dictionary<string, object?>
{
["type"] = "image_url",
["image_url"] = new Dictionary<string, object?>
{
["url"] = imageDataUrl,
["detail"] = ocrOptions.ImageDetail
}
}
}
}
},
["max_tokens"] = Math.Max(1, ocrOptions.MaxTokens),
["temperature"] = Math.Clamp(ocrOptions.Temperature, 0d, 2d),
["response_format"] = new Dictionary<string, string>
{
["type"] = AIResponseFormat.JsonObject
}
};
这个请求体里有几个关键点。首先,model 使用的是 ocrOptions.Model,而不是写死在代码中的模型名称。这样后续从一个视觉模型切换到另一个视觉模型时,只需要调整 Nacos 配置即可。
其次,messages 中同时包含了 system 消息和 user 消息。system 消息使用 ocrOptions.Prompt,用于约束模型的输出格式和识别要求,例如要求只返回 JSON、不要解释、不要补充无关内容。user 消息则是真正的本次识别输入,它的 content 不是普通字符串,而是一个数组,里面同时包含文本指令和图片内容。
文本指令 请识别这张图片中的文字。 起到的是本次任务说明的作用,告诉模型当前要做的是识别图片中的文字。图片内容则通过 image_url 传入,其中 url 就是前面生成的 base64 data URL,detail 使用 ocrOptions.ImageDetail。ImageDetail 可以控制模型处理图片时的细节等级,比如使用 auto 让模型自行判断,或者在需要更高识别精度时配置为更细致的模式。
最后,max_tokens 和 temperature 也都来自 OCR 配置。max_tokens 决定模型最多可以返回多少内容,票据、截图或长文本图片都可能产生较长识别结果,因此不能给得太小。temperature 则控制输出随机性,OCR 场景追求的是稳定和准确,所以通常会配置为 0。代码中使用 Math.Max 和 Math.Clamp 做了一层兜底,避免配置值不合理时直接构造出非法请求。
response_format 指定为 json_object,再配合默认 prompt 要求模型返回 { "text": "...", "lines": [...] }。这样 Provider 后续就可以按结构化 JSON 解析模型输出,而不是从一段自然语言回答里再去猜测哪部分才是识别文本。对于 OCR 这种需要进入数据库的结果来说,输出格式越稳定,后续处理就越可靠。
2.3 HTTP 请求发送
请求体构造完成以后,下一步就是把它发送到 OpenAI 兼容的 chat/completions 接口。这里没有直接 new HttpClient(),而是通过前面依赖注入中注册的 named HttpClient 获取客户端。这样做可以把连接池、DNS 刷新、超时策略等生命周期问题交给 HttpClientFactory 管理,避免后台服务长期运行时因为频繁创建 HttpClient 带来的连接耗尽问题。
发送请求时,会创建一个 HttpRequestMessage,并在请求头里带上 Bearer token:
csharp
using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", llmOptions.APIKey);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json");
这里的 requestUrl 通常由 llmOptions.BaseUrl 和 llmOptions.Chat 拼接而来,也就是平台地址和聊天补全路径分开配置。比如当前使用 SiliconFlow 时,BaseUrl 可以配置为 https://api.siliconflow.cn/v1,Chat 配置为 /chat/completions。如果后续要切换到其他 OpenAI 兼容平台,只要接口路径保持一致,通常只需要调整这两项配置。
Authorization 请求头使用的是 Bearer token 认证方式,token 来源于 llmOptions.APIKey。这和前面把密钥从代码中移到配置中的目标是一致的,代码只负责读取当前配置并组装请求,不保存任何固定密钥。密钥轮换时,只需要更新 Nacos 中的 LLM:APIKey,下一次识别就会使用新的 token。
Accept 请求头声明客户端希望接收 application/json 响应,StringContent 则把前面构造好的 requestBody 序列化成 JSON,并使用 UTF-8 编码发送。这里显式指定 application/json 很重要,因为 OpenAI 兼容接口接收的是标准 JSON 请求体,模型名、消息数组、图片 data URL、输出格式等参数都会通过这个 JSON 传给服务端。
这里没有继续沿用百度 SDK,也没有把 SiliconFlow 写成强绑定 SDK。Provider 只依赖标准 HTTP 和 OpenAI 兼容协议,真正和供应商相关的内容被收敛到了配置中,BaseUrl 决定请求发往哪里,Chat 决定调用哪个接口路径,Model 决定使用哪个视觉/OCR 模型,APIKey 决定认证身份。以后只要还是兼容 chat/completions 的视觉模型,基本只需要改 Nacos 配置里的这些值,而不需要改 Provider 的主体代码。
这种写法还有一个额外好处,请求发送逻辑完全可观察、可调试。相比 SDK 内部封装的黑盒调用,标准 HTTP 请求更容易记录请求地址、响应状态码、耗时和失败原因。后续如果要增加重试、超时控制、日志埋点或链路追踪,也可以围绕 HttpClient 和 HttpRequestMessage 继续扩展。
2.4 响应解析
请求发送成功以后,Provider 拿到的并不是最终可以直接写入数据库的 OCR 文本,而是一段 OpenAI 兼容接口返回的完整 JSON。这个 JSON 里既包含模型回答,也可能包含模型名称、完成原因、token 用量等协议信息。因此响应解析不能一步到位,而是分成两层处理,第一层解析平台协议响应,第二层解析模型真正返回的 OCR 内容。
第一层先复用现有 DeepSeek chat response 模型,从 choices[0].message.content 里取出模型回答:
csharp
DeepSeekChatResponse? response = JsonSerializer.Deserialize<DeepSeekChatResponse>(rawResponse, _jsonSerializerOptions);
string? content = response?.Choices?.FirstOrDefault()?.Message?.Content;
这里复用 DeepSeek chat response 模型,是因为 SiliconFlow 的接口整体遵循 OpenAI 兼容格式,响应结构和普通对话补全基本一致。对于 Provider 来说,这一层关心的不是 OCR 业务字段,而是先确认接口返回是否能被正常反序列化,并从标准位置拿到模型生成的 content。如果这一层解析失败,说明问题可能出在平台响应、接口兼容性或请求本身,而不是 OCR 文本解析。
第二层再把 content 解析成 OCR 自己的结构化结果:
csharp
OcrTextResponse? response = JsonSerializer.Deserialize<OcrTextResponse>(jsonContent, _jsonSerializerOptions);
这一层处理的是我们在 prompt 中约定的业务格式,也就是 { "text": "...", "lines": [...] }。text 会转换成 OcrRecognitionResult.RecognizedText,作为最终写入数据库的完整文本。lines 会转换成 OcrRecognitionResult.Lines,用于保留逐行识别结果。通过这一步,Provider 把模型输出从"对话内容"转换成了项目内部统一的 OCR 结果模型。
不过,大模型输出和传统 SDK 的返回值不完全一样。即使已经设置了 response_format,也不能完全排除模型返回中带有 markdown code fence、额外说明文字,或者直接返回纯文本的情况。为了提高容错性,解析逻辑通常会先尝试提取有效 JSON 内容,例如去掉 JSON 代码块外层包裹,再反序列化为 OcrTextResponse。如果仍然无法按 JSON 解析,就降级把 content 当作普通识别文本处理。
这种降级策略的目标不是鼓励模型随意输出,而是避免一次格式瑕疵导致整条 MQ 消息直接失败。对于 OCR 任务来说,只要模型已经识别出了文字,即使它没有完全按照 JSON 结构返回,也比直接丢弃结果更有价值。当然,主路径仍然是结构化 JSON,因为只有结构化结果才能稳定承载完整文本、逐行文本以及未来可能扩展的置信度、区域坐标或版面信息。
最后,Provider 会把原始接口响应保存在 RawResponse 中。这样如果后续发现某张图片识别结果异常,或者模型没有按约定返回 JSON,就可以通过原始响应回看当时平台到底返回了什么。这个字段不会影响正常业务流程,但对于排查模型行为、调整 prompt 和评估不同模型的稳定性非常有帮助。
三、消费者侧的变化
3.1 移除百度 SDK 调用
Provider 层完成以后,消费者侧的改造就变得非常直接,把所有和百度 SDK 绑定的代码从 OCRConsumerService.cs 中移出去。原来的消费者不仅要负责消费 MQ 消息、下载图片、保存识别结果,还要维护百度 Ocr 客户端、读取百度配置、判断配置是否完整,并在真正识别时调用 AccurateBasic。这些逻辑混在一起以后,消费者就不再只是一个业务流程编排者,而是同时承担了 OCR 服务商适配的职责。
这次改造中,原来的百度客户端字段、百度配置校验和 AccurateBasic 调用都删掉了。消费者不再知道百度 SDK 的存在,也不再关心外部 OCR 服务到底是 SDK 调用还是 HTTP 调用。现在它只需要在处理消息的作用域里解析 IOcrProvider:
csharp
var ocrProvider = scope.ServiceProvider.GetRequiredService<IOcrProvider>();
这里从 scope.ServiceProvider 中解析 Provider,是因为 OCR 消费者通常是一个长期运行的后台服务,而每次处理 MQ 消息时会创建独立的依赖注入作用域。把 IOcrProvider 放在消息处理作用域中解析,可以让 Provider 以及它依赖的配置、日志、HTTP 客户端等服务都遵循正常的 DI 生命周期,也避免在后台服务构造函数里提前持有不该长期缓存的业务依赖。
下载图片后,消费者只调用统一接口:
csharp
var result = await ocrProvider.RecognizeAsync(image, stoppingToken);
if (string.IsNullOrWhiteSpace(result.RecognizedText))
{
_logger.LogError("OCR识别失败,文件id:" + fileInfo.Id);
return;
}
这段代码说明消费者已经不再关心"具体是谁在识别"。它只关心两个问题,图片是否成功下载,以及 Provider 是否返回了可用文字。如果 RecognizedText 为空,消费者就认为本次 OCR 没有产出有效结果,记录日志后结束当前消息处理,如果有内容,后续就继续走原来的数据库写入流程。
从职责划分上看,OCRConsumerService.cs 现在回到了它应该承担的位置,负责 MQ 消息处理和业务流程编排。至于模型选择、请求构造、响应解析、格式兼容、错误兜底这些 OCR 供应商相关逻辑,都被下沉到了 IOcrProvider 的具体实现中。这样后续即使新增其他 Provider,消费者代码也不需要再跟着变化。
3.2 数据库写入保持稳定
消费者侧还有一个很重要的设计点,数据库写入逻辑基本保持不变。也就是说这次改造并没有因为更换 OCR 实现方式,就顺手调整 ImageText 的表结构、保存流程或后续业务读取方式。OCR 供应商调用层发生了变化,但持久化层的业务契约保持稳定。
原来使用百度 OCR 时,SDK 返回的是逐行识别结果,消费者需要从 words_result 中取出文字列表,再通过 string.Join("", wordList) 拼成完整文本写入数据库。改造之后,逐行结果的解析和拼接已经被收敛到了 Provider 内部,消费者拿到的就是统一结果模型中的 RecognizedText,因此写库时只需要直接使用这个字段:
csharp
imageText.RecognizedText = result.RecognizedText;
这行代码看起来只是一个字段赋值变化,但背后的意义是消费者不再负责"如何从供应商响应中拼出最终文本",而是只接收 Provider 已经整理好的业务结果。至于这个文本来自百度的逐行数组,还是来自大模型返回的 JSON text 字段,对数据库写入逻辑来说已经没有区别。
保持数据库写入稳定,可以显著降低这次改造的影响面。MQ 消息消费、OSS 图片下载、文件归属校验、图片格式校验、ImageText 创建或更新等流程都还在原来的位置,只有"调用谁来识别图片"这一层被替换掉。这样验证时也更清晰,如果写库后的数据结构和原来一致,就说明下游读取 OCR 文本的功能不需要跟着改。
这也是服务改造中比较理想的状态,外部依赖可以替换,内部业务契约尽量稳定。对于调用 OCR 结果的其他功能来说,它们仍然从 ImageText.RecognizedText 中读取识别文本,不需要知道底层已经从百度 SDK 换成了 OpenAI 兼容的大模型 OCR。这样既完成了技术升级,又没有把变化扩散到不相关的业务模块。
四、依赖注入和配置绑定
4.1 OCRServiceExtensions 的注册逻辑
完成接口、Provider 和消费者改造之后,还需要把这些能力统一注册到 .NET 的依赖注入容器中。这个工作放在 OCRServiceExtensions.cs 里完成。它的作用类似一个 OCR 模块的入口,把 OCR 相关配置、HTTP 客户端、业务服务和 Provider 实现集中注册起来,避免在 Program.cs 或其他启动代码中散落一堆细节。核心注册代码如下:
csharp
services.Configure<LlmOptions>(configuration.GetSection("LLM"));
services.Configure<OcrOptions>(configuration.GetSection("OCR"));
services.AddHttpClient(OpenAiCompatibleOcrProvider.HttpClientName);
services.AddScoped<IOCRService, OCRServiceImpl>();
services.AddScoped<IOcrProvider, OpenAiCompatibleOcrProvider>();
这里第一步是绑定配置。services.Configure<LlmOptions>(configuration.GetSection("LLM")) 会把配置文件或 Nacos 中的 LLM 节点绑定到 LlmOptions,供后续 IOptionsMonitor<LlmOptions> 读取。LLM 负责通用网关能力,比如 SiliconFlow 的 BaseUrl、APIKey、Chat 路径,这些配置未来不只 OCR 能用,其他大模型能力也可以复用。
第二个配置绑定是 OcrOptions。它只承载 OCR 场景自己的行为参数,比如 Provider、Model、Prompt、TimeoutSeconds、MaxTokens、Temperature 和 ImageDetail。把这部分从 LLM 中拆出来,是为了让通用平台配置和具体业务配置保持边界清晰。平台地址、密钥、接口路径属于 LLM 接入层,使用哪个 OCR 模型、提示词怎么写、图片细节等级如何控制,则属于 OCR 业务层。
接着注册 named HttpClient。services.AddHttpClient(OpenAiCompatibleOcrProvider.HttpClientName) 会为 OpenAiCompatibleOcrProvider 准备一个命名客户端。这样 Provider 需要发送 HTTP 请求时,可以通过 IHttpClientFactory 获取对应客户端,而不是自己创建 HttpClient。这和前面 HTTP 请求发送部分是一致的,连接生命周期交给框架管理,Provider 只关注如何构造请求和处理响应。
最后两行分别注册业务服务和 OCR Provider。AddScoped<IOCRService, OCRServiceImpl>() 说明对外暴露的 OCR 业务服务实现是 OCRServiceImpl,它负责文件校验、图片类型判断、发送 OCR 消息等业务入口逻辑。AddScoped<IOcrProvider, OpenAiCompatibleOcrProvider>() 则说明当前真正执行识别的是 OpenAI 兼容 Provider。
这种注册方式把"业务入口"和"识别实现"分开了。业务代码依赖 IOCRService 或 IOcrProvider,容器负责决定实际注入哪个实现。后续如果要支持多个 OCR Provider,可以在这里继续扩展为按配置选择实现,而在当前阶段只注册 OpenAiCompatibleOcrProvider 就能满足从百度 SDK 切换到大模型 OCR 的目标。
完成这些注册以后,OCR 模块的入口、配置和具体识别实现就都进入了统一的依赖注入体系。接下来还需要处理的,就是把原来带有"百度"语义的业务服务命名调整掉。
4.2 OCRServiceImpl 的命名调整
原来的 BaiduOCRServiceImpl 被替换成 OCRServiceImpl.cs。这个类从业务上看并不是真正"百度 OCR 服务",它只是负责校验文件归属、校验图片格式,然后把文件信息发到 OCR 队列。改名以后语义更准确,也避免项目里残留"百度"概念。
五、百度相关内容清理
这次改造没有保留百度 Provider 作为回退,而是直接把百度相关内容从运行时链路中清理掉。这样做的原因很简单,本篇文章的目标不是在原有百度 OCR 旁边再追加一个新实现,而是把 OCR 能力从"绑定某个 SDK"改造成"面向统一 Provider 调用"。如果继续保留百度 SDK、百度配置和百度客户端初始化逻辑,项目里就会同时存在两套 OCR 认知,读代码的人也很难判断当前到底应该维护哪一条链路。
因此清理工作主要分为两类。第一类是代码和依赖清理,BaiduOCROptions.cs 已删除,Baidu.AI 包引用也从 SP.ResourceService.csproj 中移除。这样资源服务启动时就不再需要百度 OCR 相关配置,也不会再加载百度 SDK。第二类是配置清理,原来面向百度 OCR 的配置节点不再作为运行时配置存在,OCR 相关参数统一迁移到新的 LLM 和 OCR 两段配置中。
清理完成后,核心配置形态变成这样:
json
"LLM": {
"APIKey": "",
"BaseUrl": "https://api.siliconflow.cn/v1",
"Chat": "/chat/completions"
},
"OCR": {
"Provider": "OpenAiCompatible",
"Model": "",
"Prompt": "请识别图片中的全部文字,只返回JSON对象,格式为:{\"text\":\"完整文字\",\"lines\":[\"逐行文字\"]}",
"TimeoutSeconds": 60,
"MaxTokens": 4096,
"Temperature": 0,
"ImageDetail": "auto"
}
这里的 LLM 节点保存的是大模型平台接入所需的通用配置。APIKey 用于接口认证,BaseUrl 指向 SiliconFlow 的 OpenAI 兼容地址,Chat 表示聊天补全接口路径。把这些内容放在 LLM 下,是因为它们不只服务于 OCR 场景,后续其他智能化能力也可能复用同一套大模型网关配置。
OCR 节点则保存 OCR 场景自己的配置。Provider 表示当前使用的 OCR Provider 类型,当前值是 OpenAiCompatible,Model 需要填写 SiliconFlow 当前可用的视觉/OCR 模型名;Prompt 用来约束模型识别图片文字并返回指定 JSON 结构;TimeoutSeconds、MaxTokens、Temperature 和 ImageDetail 则分别控制请求超时、最大输出长度、随机性和图片解析细节等级。
其中最关键的是 OCR:Model 和 OCR:Prompt。模型名称放在配置里,意味着后续如果要从一个视觉模型切换到另一个视觉模型,不需要再修改 Provider 代码,提示词放在配置里,则可以根据识别效果持续调整输出要求,比如要求保留换行、去除无关说明、只返回 JSON 等。这些调整都可以通过 Nacos 完成,不再需要重新编译和发布服务。
从结果上看,百度相关内容清理以后,项目里的 OCR 链路会更干净,业务代码面对的是 IOcrProvider,运行时实现是 OpenAiCompatibleOcrProvider,配置入口是 LLM 和 OCR。旧的 BaiduOCR 配置、百度 SDK 包和百度专属 Options 都退出运行时链路,后续维护时不会再出现"新旧两套 OCR 实现并存"的混乱。
六、总结
这次改造不是简单地"把百度换成 SiliconFlow",而是把 OCR 供应商调用从消费者业务流程里抽离出来。现在业务代码面对的是稳定的 IOcrProvider,配置面对的是 LLM 和 OCR,供应商协议集中在 OpenAiCompatibleOcrProvider。以后只要还是 OpenAI 兼容的视觉/OCR 模型,就可以通过 Nacos 调整地址、模型和提示词完成切换,不需要再改消费者和数据库写入逻辑。同时,Nacos 热切换也被考虑进去了。Provider 每次识别时读取 IOptionsMonitor.CurrentValue,所以配置刷新后,下一条 OCR 消息就会使用新配置。这一点让"切换 OCR 服务商或模型"变成运维配置动作,而不是代码发布动作。