ERP单据图片换了文件名,重新上传后识别结果仍然一样,而且本次OCR调用计数为0,先核对是否命中了提取缓存。文件名改变,不一定改变缓存标识。
本文依据自有进销存工作台的extractImage方法,使用构造的一像素PNG、OCR方法替身和固定时钟验证缓存行为。没有请求OCR或模型,没有真实计费,也没有保存业务订单。图片仅用来满足输入格式,不用于测试识别准确率。
1. 当前缓存键没有包含文件名
实际代码把以下内容序列化,再计算SHA-256:
javascript
const key = createHash('sha256')
.update(JSON.stringify([
s.tenant, order,
this.options.ocrUrl,
this.options.ocrProvider,
this.options.vision?.model,
this.ai.config.model,
file.data
]))
.digest('hex');
这里使用的是file.data字符串,不是file.name,也不是先解码后对图片像素做比较。因此,保持同一data字符串,只改上传文件名,仍会得到同一个键。反过来,不能由"肉眼看起来一样"推断一定命中,编码字符串也必须符合当前键的输入条件。
租户和处理模式order在键中。同一个租户、同一种模式下才有进一步比较的基础,不能把不同租户的结果混在一起统计。
2. 改名命中与十分钟到期的构造结果
第一次提取时,OCR替身返回演示单号DEMO-1。随后固定时钟向前推进,不进行真实等待,继续调用实际extractImage:
| 构造步骤 | cached | 本次ocrCalls | 说明 |
|---|---|---|---|
| 首次上传 | false | 1 | 调用OCR方法替身 |
| 599999毫秒后,仅改文件名 | true | 0 | 复用缓存结果 |
| 首次缓存写入后600000毫秒 | false | 1 | 到期后重新调用替身 |
| 改为另一个构造租户 | false | 1 | 缓存键不同 |
| 修改文本模型型号 | false | 1 | 缓存键不同 |
| cacheEnabled设为false | false | 1 | 跳过缓存读取 |
表中的调用次数来自本地替身,不能用于推断真实服务费用。文本模型型号进入键,因此即便本例由OCR直接返回结构化行,修改该型号也会造成缓存未命中。

图1:依据实际缓存代码和固定时钟构造验证绘制;不是OCR服务输出或客户界面。
3. 命中不会把有效期再延长十分钟
当前读取条件是cached.expires大于Date.now()。写入时设为当前时间加600000毫秒;命中分支直接返回,没有更新expires。
所以本地样本在599999毫秒时命中,下一毫秒仍到期。这里的十分钟从缓存写入算起,不是从最近一次读取算起。恰好等于到期时刻时,严格大于条件已经不成立。
排查时应记录缓存写入时间、读取时间及cached字段。不要只根据"刚刚看过一次"判断它接下来十分钟必然有效。
4. 返回内容采用克隆,避免外部修改污染缓存
写入缓存和命中返回都使用structuredClone。本地实验把首次返回行的单号改为"外部修改",下一次命中仍读到DEMO-1。
这验证的是这个样本的对象隔离:调用方修改返回行,没有直接改坏缓存中的原始结果。它没有验证任意数据类型、多个进程或并发请求下的全部行为。
命中分支还把本次modelCalls、ocrCalls设为0。它们表达这次提取没有走相应调用路径,不表示历史首次提取没有调用,也不表示其他请求没有消耗资源。
5. 缓存复用与业务防重复要分别设计
缓存位于Documents实例中的Map。当前源码在容量达到100时删除最早插入的键,读取命中没有把该键移到末尾,不能据此称为按最近访问淘汰的LRU。容量淘汰属于本次源码阅读范围,没有另造100项实验。
缓存返回的是提取结果,后面仍需匹配客户与商品、核对数量和价格,再确认业务写入。重复图片命中缓存,不会自动证明同一订单不被重复创建。
建议排查时分别保存"缓存是否命中"和"业务是否已经写入"的证据。需要强制重新提取时,可设计明确的刷新入口和失效规则;本文没有实现刷新按钮,也没有验证跨实例共享或生产环境并发合并。
本次确认了改名复用、固定到期、租户和型号改变导致未命中,以及返回对象克隆。真实识别效果、计费和ERP落单均需另行验收。