“后端:05-AI病虫害识别”,一看就是连续项目里的第五个环节。前四个大概率是用户体系、设备接入、数据上报、基础管理之类的活儿,到05这里终于轮到了核心算法业务的接入。这个模块做得好不好,直接决定了产品是“演示Demo”还是“能落地的工具”。
我在接手这个需求的时候,对方负责人只给了一句话:“我们拍张照片,系统告诉我们是什么病。”听起来很简单,但真往后端一接,就会发现一堆需要决策的点:模型怎么部署、接口怎么设计、识别结果怎么存储、并发上来了怎么办。这篇文章我就用实际做过的方案,把这套东西从架构到代码再到排障完整讲一遍,偏后端视角,但算法侧和前端侧的同学看了也能明白自己在整个链路里处于什么位置。
1. 业务架构与核心技术选型
1.1 需求拆解:识别不是“一个接口”那么简单
先明确业务流。用户端(小程序或App)上传一张叶片照片,后端返回三个信息:这是什么病、置信度多少、大概率是什么。听起来就三个字段,但对后端来说,背后串联的是文件上传、图片预处理、模型推理、结果存储、结果查询这五个环节。
我第一次做的时候,天真地以为网上找个训练好的模型,然后写个POST /recognize接口,调用一下模型就完事了。结果一上线就翻车,主要有几个问题:
- 图片上传动不动就5MB、8MB,接口响应时间直接飙到十几秒。
- 模型推理在GPU上跑得很欢,但服务器没配GPU,CPU硬扛,一个请求要等2~3秒。
- 同一张图被反复识别,每次都要重新过模型,白白浪费算力。
- 结果只有“病名”和“概率”,没有参考图片和防治建议,业务方觉得“不够用”。
所以第一步不是写代码,而是定义清楚整个链路的边界。我的方案里,后端至少要拆成四个核心子模块:
| 子模块 | 职责 | 关键产物 |
|---|---|---|
| 文件网关 | 接收图片、校验大小/格式、去重 | 文件ID、存储路径 |
| 预处理模块 | 缩放、归一化、裁剪 | 符合模型输入的张量 |
| 推理模块 | 加载模型、执行预测、解析输出 | 病种ID、置信度榜单 |
| 业务模块 | 关联用户/地块、存储结果、返回建议 | 识别记录、防病文案 |
1.2 模型部署选型:在线API还是本地推理服务
现在市面上的AI病虫害识别方案大概分三类:
第一类是直接调用云厂商的现成API。好处是省事,不用管模型训练和部署,按次付费就行。坏处是定制能力弱、数据要出网,而且对农业场景来说,很多云API只覆盖常见作物,小众作物(如某些中药材)准确率很差。这个方案适合快速验证,不适合做产品主线。
第二类是本地部署开源模型。典型的像是用PyTorch或者TensorFlow导出的模型权重,自己做推理服务。这个方案可控性强,模型的迭代、微调、自家数据集的适配都很灵活。缺点是需要自己处理推理性能和部署运维的问题,但对后端来说这反而是加分项,毕竟后端不怕写服务,怕的是被算法同事牵着走。
第三类是边缘设备推理。把模型压到手机或专用设备上,离线识别。这个方案响应最快,但算力受限,模型必须做重度剪枝和量化,识别精度会打折扣,而且业务更新模型要发版,太痛苦。适合纯田间作业场景,不适合带管理后台的产品。
我的选择是第二类,本地部署PyTorch模型,再导出一份ONNX格式用于生产环境推理。选ONNX的原因后面详说,这里先给结论:生产环境跑ONNX Runtime要比直接跑PyTorch的Python推理快得多,而且不依赖GPU也能有不错的吞吐。
1.3 后端框架与语言决策
在这个项目上,我选了Python系的FastAPI + ONNX Runtime + MySQL + Redis。选型理由不复杂:
- Python是AI生态最成熟的,虽然有人嫌它慢,但真正的性能瓶颈在模型推理,不在框架。FastAPI的异步特性在图片上传和IO密集场景下够用。
- ONNX Runtime是微软开源的推理引擎,跨平台、轻量、CPU优化做得好。比直接让PyTorch做生产推理省心太多,不需要在生产环境装一套完整的深度学习框架。
- MySQL负责业务数据存储,最稳的选择,团队里谁都熟,不需要额外引入文档数据库。
- Redis做结果缓存和幂等控制,这一层在AI场景下特别值钱,后面细讲。
这里有一个反常识的点:网上很多教程吹Node.js或者Go怎么怎么好,但如果你要跟算法团队配合,数据集的格式、模型导出的流程、特征处理代码几乎都是Python写,你用别的语言,整个协作链路会断掉。跨语言调模型服务不是不行,但那等于自己给自己造微服务,平白多了维护成本。技术选型不能只看性能,还要看团队的协同半径。
2. 数据库设计与状态流转
2.1 识别记录表的核心字段设计
病虫害识别这个业务,核心表就是识别记录表。我先给一个简化但能直接落地的表结构:
CREATE TABLE `disease_identify_record` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `record_no` varchar(32) NOT NULL COMMENT '业务编号', `user_id` bigint unsigned NOT NULL COMMENT '用户ID', `field_id` bigint unsigned DEFAULT NULL COMMENT '关联地块ID', `image_url` varchar(255) NOT NULL COMMENT '原图地址', `image_md5` char(32) NOT NULL COMMENT '图片MD5,用于去重', `disease_code` varchar(32) NOT NULL COMMENT '病种代码', `disease_name` varchar(64) NOT NULL COMMENT '病种名称', `confidence` decimal(5,4) NOT NULL COMMENT '置信度,0~1', `top_k_json` json DEFAULT NULL COMMENT 'TopK候选列表', `suggestion` text COMMENT '防治建议', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1识别成功 2识别失败 3无病害', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_record_no` (`record_no`), KEY `idx_user_time` (`user_id`, `created_at`), KEY `idx_disease_time` (`disease_code`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='病虫害识别记录表';有几个字段设计我要特别解释:
record_no不用自增ID做业务关联。前端回显、客服排查、跟第三方农技系统对接,都靠这个编号走。自增主键只是内部用,对外一律暴露record_no,能避免别人通过ID差值猜你的业务量。
image_md5是必须的。同一块地的同一份病虫害,农户经常会对同一片叶子拍好几张,连拍功能一触发就是3张图。没有MD5去重,系统里全是重复的推理记录,浪费存储又浪费算力。MD5的用途不只是节省资源,更重要的是可以做“历史结果回显”:同一张图再次上传,直接返回上次的识别结果,用户体感瞬间变成“秒回”。
top_k_json用JSON类型。有的业务场景不只是要看第一名,还想知道第二名、第三名是什么,因为很多叶部病害特征相近,模型本身也分不太清。我见过有人用单独的关联表存TopK,那属于过度设计,排序数据写进JSON字段,查询的时候一把拿出来即可。
2.2 识别链路的状态机设计
状态管理是后端最容易翻车的地方。用户点了“识别”按钮,后端不是瞬间出结果的,尤其是排队高峰,可能两三秒后才推理完。这时候如果前端一直傻等着,体验会很差。我设计了三个状态:
PROCESSING:已接收请求,图片在上传或队列中等待。SUCCESS:推理完成,返回了可靠结果。FAILED:图片损坏、格式不支持、模型异常,需要前端提示用户重试。
注意我上面SQL里状态值用的是1和2、3,实际联调的时候我跟前端对齐的是字符串枚举,比如SUCCESS、FAILED。状态值存在数据库里是数字,接口返回给前端的是字符串,这样加状态不用改协议,灵活性高很多。
这个状态机看起来简单,但它真正解决的问题是“超时重试”。用户网络差,前端断开了连接,可后端可能还在推理。如果后端不维护状态,前端重新发起一次识别请求,就会重复推理。有了状态机,前端重连之后先查状态,如果已经是SUCCESS,直接展示上次结果,不重复扣费。
2.3 识别结果的业务关联
单一识别结果没什么稀罕的,稀罕的是把结果跟业务数据关联起来。我在识别记录表里留了field_id,就是地块ID,这个字段让数据维度一下子丰富了。
- 按地块维度聚合:同一块地这个月出现了几次稻瘟病、几次稻飞虱。
- 按时间维度聚合:什么时间段病害高发,是否需要提前预警。
- 按用户维度聚合:某农户的田里如果频繁出现同一病害,可以推送针对性的防治方案。
这块工作看似是业务方的事情,但后端在设计表的时候没预留维度,后面想做数据分析就得重新跑脚本洗数据。所以宁可现在设计宽一点,也不要把表结构做窄。这是我在多个项目里换来的教训。
3. 后端接口设计与推理链路实现
3.1 两个核心接口:上传识别与结果查询
业内标准做法是“上传即异步识别”,而不是“等识别完才返回”。同步接口看着简单,但一旦模型推理超过几秒,前端就得频繁调超时,体验非常差。我处理成两个接口:
POST /api/v1/identify/upload Content-Type: multipart/form-data 参数: - file: 图片文件(必填) - fieldId: 地块ID(可选) - userId: 用户ID(Header中鉴权获取)这个接口的职责很纯粹:接收文件、计算MD5、查缓存、如果没命中就推入消息队列、返回recordNo和status=PROCESSING。耗时控制在200ms以内。
GET /api/v1/identify/{recordNo} 返回示例: { "recordNo": "20240615001", "status": "SUCCESS", "disease": { "code": "rice_blast", "name": "稻瘟病", "confidence": 0.9321 }, "topK": [ { "code": "rice_blast", "name": "稻瘟病", "confidence": 0.9321 }, { "code": "rice_brown_spot", "name": "稻胡麻叶斑病", "confidence": 0.0412 } ], "suggestion": "建议立即施用三环唑,并避免高氮肥。", "imageUrl": "https://cdn.example.com/xxx.jpg" }前端拿到recordNo之后,轮询查询接口,等status变成SUCCESS就展示结果。轮询间隔我设置成1秒,对于用户体验来说,通常1.5秒内能看到结果,体感跟同步差不多。
3.2 文件上传的边界控制与参数校准
图片上传这个环节看似基础,但很多人做的接口经不起压测。我针对移动端真实场景做了三个限制:
- 大小限制:单张图片不超过10MB,超过直接返回413。农业App的用户大多是农户,手机拍出来的图动辄5MB往上,如果不加限制,带宽和内存都会被打满。
- 格式白名单:JPG、PNG、WEBP,其他格式一律拒绝。有人会问为什么支持WEBP?因为很多安卓手机默认输出格式就是WEBP,不放开这个格式会导致一堆用户上传失败。
- 像素下限校验:小于200x200的图片直接拒绝,因为缩得太小的图,模型根本没法提取特征,识别准确率会暴跌。
代码实现上对于FastAPI来说,接收上传文件用UploadFile即可,但要特别注意一点:FastAPI的UploadFile是把整个文件读进内存的,如果并发一高,内存直接被打爆。稳妥做法是先把文件流写入临时文件或者对象存储的分片上传,再对文件做处理。
分享一段处理图片校验的参考逻辑:
from PIL import Image import hashlib async def validate_and_hash(file_stream, max_size=10 * 1024 * 1024): # 读取前10MB做校验,防止恶意大文件拖垮内存 data = await file_stream.read(max_size + 1) if len(data) > max_size: raise ValueError("图片大小超过10MB限制") # 用PIL识别真实格式,防止伪装扩展名的文件 img = Image.open(io.BytesIO(data)) fmt = img.format.upper() if fmt not in ("JPEG", "PNG", "WEBP"): raise ValueError("仅支持JPG/PNG/WEBP格式") # 校验像素尺寸 if img.width < 200 or img.height < 200: raise ValueError("图片尺寸过小") return hashlib.md5(data).hexdigest(), img这里有个细节我踩过坑:不能只用文件后缀判断格式。有人上传一个把txt改成jpg的文件,前端和后端FastAPI都识别不了真实格式,最后传到模型那边,图片解码报错,整个推理链路崩掉。现在栈里加一层PIL的真实格式识别,任何伪装文件直接在这一关被拦截。
3.3 ONNX模型的加载与推理封装
这部分是整个后端模块的核心。算法同事交付给我的东西是这个:
model.onnx label_map.json preprocess.pymodel.onnx就是训练好的模型导出的ONNX格式,label_map.json是索引到病种名称的映射文件,preprocess.py是算法同事写的预处理参考代码。我的工作就是把它包装成一个可以高并发调用的推理服务。
先解释一下为什么用ONNX而不是直接加载PyTorch的pth文件。
PyTorch模型在生产环境跑推理有个毛病:每次推理都要带着整个框架的运行时,哪怕是只跑一个前向传播,也要加载一大堆依赖库,启动就要好几秒。ONNX Runtime不一样,它是一个轻量级推理引擎,只关心计算图的执行,不关心训练的反向传播,所以内存占用小、启动快、CPU优化做得极好。此外ONNX可以锁定模型结构,避免生产环境因为某些库版本不一致导致模型重载结果漂移。
ONNX Runtime的一个使用示例:
import onnxruntime as ort import numpy as np session = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"]) def predict_tensor(input_tensor: np.ndarray) -> np.ndarray: input_name = session.get_inputs()[0].name output_name = session.get_outputs()[0].name outputs = session.run([output_name], {input_name: input_tensor}) return outputs[0]上面的代码看起来简单,但如果直接用在生产环境,两个问题:一是session初始化很慢,不能在每次请求方法内部创建;二是session是否线程安全,多个请求同时打过来,会不会互相干扰。
我的做法是在服务启动时预先创建好session,并且使用一个线程池来处理推理。因为ONNX Runtime的session在CPU上被设计为可并发调用,但为了避免极端情况下某些算子内部共享状态,保险起见还是用一个队列控制并发度更稳。
真实的推理封装我加了预处理逻辑:
import cv2 import numpy as np def preprocess_image(img: Image.Image, target_size=(224, 224)): # 统一缩放 img = img.resize(target_size, Image.LANCZOS) arr = np.asarray(img, dtype=np.float32) / 255.0 # 图像归一化参数,必须与训练保持一致 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) arr = (arr - mean) / std # 从HWC转为CHW arr = np.transpose(arr, (2, 0, 1)) arr = np.expand_dims(arr, axis=0) # 增加batch维度 return arr这里最容易被坑的一点:预处理必须和训练时保持一致。算法同事给我模型时,他们自己训练用的尺寸是256x256,随机裁剪到224x224做实况增强。如果我在生产环境直接resize到224x224,识别效果会下降。因为训练时模型见过的是“大图上裁剪出的物体”,而生产环境是“整图直接挤压”,特征尺度完全不同。我跟算法同事确认后的方案是:先resize到256x256,再中心裁剪224x224,跟训练时的预处理保持一致。
3.4 推理结果解析与置信度校准
模型输出的原始值不是概率,是logits,要经过softmax才能变成概率分布。实际解析我写成这样:
import json def parse_output(raw_output: np.ndarray, label_map_path: str): scores = softmax(raw_output[0]) # 取Top5候选 top_indices = np.argsort(scores)[::-1][:5] top_k = [] for idx in top_indices: code = idx_to_label[idx] # 从label_map得到病种编码 top_k.append({ "code": code, "name": label_map[code]["name"], "confidence": round(float(scores[idx]), 4) }) best = top_k[0] return best, top_k def softmax(x): e_x = np.exp(x - np.max(x)) return e_x / e_x.sum(axis=0)置信度阈值处理是另一个关键决策点。我一开始直接拿Top1的置信度当最终结果,结果发现模型对正常叶片的识别也给出0.9以上的置信度,但实际叶片根本没病。后来按业务要求加了一条规则:
- 置信度低于0.5:判为“疑似未知病害”,不给出具体病种。
- 置信度在0.5到0.85之间:返回Top1病种,但给业务方提示“低置信度,建议人工复核”。
- 置信度高于0.85:正常返回。
这个阈值既能避免算法自信心爆棚导致误导农户,又能保证真病害不被漏报。阈值本身可以通过后台配置接口动态调整,不用改代码重发版本。
4. 缓存、并发与性能优化
4.1 用Redis做三层缓存
推理是个吃算力的操作,尤其是CPU环境下,一次推理可能吃掉500ms以上的CPU时间。为了扛住并发,我在Redis里做了三层缓存。
第一层是图片MD5缓存。用户上传了一张叶片照片,几天后又传了同一张,或者同一块地的农户互相传图,MD5能直接命中缓存,秒回上次结果。这一层解决的是重复计算问题。
第二层是病种统计缓存。管理后台首页要显示“今日识别量”“常见病害排行”,如果直接查数据库去做GroupBy,识别记录一多就会慢。我做了定时聚合,把结果写到Redis的Hash结构,查询的时候O(1)取。
第三层是接口响应缓存。对于TopK结果和防治建议,本身是静态文本,按病种code做缓存即可,所有用户查同一个病种,拿到的建议文案都是一样的,没必要每次查数据库。
import redis import json r = redis.Redis(host="localhost", port=6379, db=0) def get_cached_result(md5: str): cache_key = f"identify:cache:{md5}" cached = r.get(cache_key) if cached: return json.loads(cached) return None def set_cached_result(md5: str, result: dict, ttl: int = 86400): cache_key = f"identify:cache:{md5}" r.setex(cache_key, ttl, json.dumps(result, ensure_ascii=False))TTL我设置成1天。这个时长比较折中,既保证农户近期重复查看不重复计费,又不会让数据库里塞满了几个月前的僵尸数据。
4.2 高并发场景下的任务削峰
识别接口如果直接同步调用模型,QPS一高,CPU直接被打满。我这里用了非常经典的生产者-消费者方案:先把识别任务丢进队列,后端立刻返回PROCESSING状态,由独立的Worker线程池异步消费队列。
我选的是Redis的List结构做队列:
# 生产者:收到上传请求后 r.lpush("identify:queue", json.dumps(task_data)) # 消费者:后台常驻Worker线程 while True: task_json = r.brpop("identify:queue", timeout=5) if task_json: task_data = json.loads(task_json[1]) # 调用模型推理,把结果写回MySQL和Redis run_inference(task_data)这里靠brpop的阻塞特性,避免Worker空转浪费CPU。部署的时候我起3个Worker进程,每个进程内有2个推理线程,整体并发能力大概是单进程模型的6倍。
4.3 计算密集型任务的进程与线程权衡
Python的GIL锁决定了多线程对CPU密集型任务几乎是笑话,推理计算时多线程并不能真正利用多核。所以我的架构是“多进程 + 每进程受限线程池”。
每个Worker进程独立加载一份模型到内存,进程之间天然隔离,不会互相干扰。进程内部用ThreadPoolExecutor(max_workers=2)做并发控制,避免同时多个请求涌入推理引擎导致上下文切换开销过大。
实测数据供参考:单进程CPU推理一次约600ms,开4个Worker进程后,同样600ms内的并发处理能力可以达到4~6个请求,QPS从1.7提升到8左右。因为推理期间CPU多核都跑起来了,吞吐量接近线性扩展。别迷信网上说的“Python多线程垃圾”这种话,它是垃圾是针对纯Python代码,当我们把最重的计算交给了C实现的ONNX Runtime,阻塞点转移到推理引擎内部,多线程仍然有用。
4.4 上下文中需要留意的内存管理
ONNX模型加载进来后,会占用几百MB到1GB不等的常驻内存。如果每个Worker进程都加载一份,8GB内存的服务器开4个Worker已经比较危险。我用的技巧是:先用半边精度模型(FP16)做深度剪枝,再导出为ONNX,内存占用和推理速度都有明显改善。如果算法同事不肯做剪枝,后端就要在模型文件加载前做一次模型服务预热,把显存(内存)中不常用的大块对象提前释放掉,避免垃圾回收在高峰时突然冻结。
5. 常见问题排查与性能调优实录
5.1 模型推理结果与算法本地不一致
这个问题我遇到太多次了,排查思路按优先级排列:
第一步,确认输入图像是否一致。算法本地用的是无损PNG,生产环境拿到的可能是压缩率很高的JPG,颜色失真的情况下,特征差异会被模型放大。
第二步,确认预处理完全一致。前面提到的缩放尺寸、归一化均值标准差、通道顺序BGR还是RGB,任何一项不一致都会导致结果漂移。我踩过一次最隐蔽的坑:图片解码算法同事用OpenCV读图,是BGR顺序,我用PIL读图,是RGB顺序,通道顺序一换,模型输出的Top1直接变了。
第三步,确认模型是否有随机性。有些模型里还留着Dropout或数据增强层,推理时没有切到eval模式,结果每次跑都有细微差异。导出的ONNX文件如果算法同事没处理干净,也会继承这种随机性。这类问题只能让算法同事重新导出模型解决了。
5.2 CPU推理耗时过长
CPU推理慢,多数情况下是模型本身太大,计算图太复杂。后端能做的优化有三板斧:
- 开启ONNX Runtime的CPU优化。
InferenceSession里加graph_optimization_level参数,设置到ORT_ENABLE_ALL,能把计算图中很多冗余算子合并掉。 - 输入尺寸降下来。如果原模型吃224x224,业务上其实不需要那么大分辨率,修改预处理把输入尺寸降到160x160,推理耗时直接降一半。当然这需要跟算法确认对精度的影响。
- 多实例并行推理。单模型占用内存不高时,可以在同一个进程里加载两个推理Session,把请求均匀路由到两个Session上,相当于把单核CPU的瓶颈拆成两个核跑。
这段优化做完,单次推理耗时从800ms降到了350ms,效果比较明显。
5.3 图片上传偶发超时与连接重置
排查这类问题,大部分情况下是Nginx或网关层的超时时间设置得太短。图片上传本身是个耗时操作,如果前端走CDN或跨区域网关,网络链路一长,10MB文件传个十秒八秒很正常。如果Nginx默认的proxy_read_timeout是60秒,平时没事,但遇到弱网环境就疯狂报504。
解决思路是在上传接口上做一个特殊处理:不走普通业务网关,而是直接走对象存储的直传。前端先向后端要上传凭证,拿到凭证后直接把文件传到对象存储,后端拿到存储的文件URL再发起识别任务。这样大文件压力全放在对象存储那边,后端只处理几十KB的JSON,稳定很多。
5.4 断点续传与失败重试机制
移动端弱网环境太普遍了,农户在地里信号不好,上传到一半断掉是家常便饭。我在设计接口时留了一个坑:上传接口不支持分片和断点续传。第一个版本上线后被测试狠狠吐槽,后来紧急改成前端分片上传,后端提供合并接口,才算把这个坑填上。
分片上传的流程:
- 前端把图片切割成1MB左右的片段。
- 每片上传,后端记录当前已收到的偏移量。
- 全部片传完后,前端调用合并接口,后端将所有分片拼接成一个完整文件。
- 合并完成后,后端走MD5去重,发起识别任务。
这套机制看起来增加了几个接口,但对弱网用户非常重要。如果用户流量不好,传了70%断线重传,分片上传只需要续传剩下的30%,体验提升明显。我在真实项目中见过用户用2G网络上传,同步式接口30分钟没有响应,这种场景下没有分片机制,产品根本没法用。
6. 生产环境部署与可观测性建设
6.1 模型文件的版本管理与灰度发布
模型一定是会迭代的,算法同事过两周可能就放出个新版本,准确率提升2个点。但后端不能因为算法迭代就直接替换生产环境的模型文件,万一同一个识别请求,上一条记录用的是v1模型,下一条用的是v2模型,历史数据的分析就乱了。
我维护了一张model_version表:
| 字段 | 说明 |
|---|---|
| model_name | 模型唯一标识 |
| version | 版本号,如v1.2 |
| onnx_path | ONNX文件的存储路径 |
| label_map_path | 标签映射文件路径 |
| accuracy | 该版本在测试集上的准确率 |
| status | 灰度中/已发布/已下线 |
| released_at | 发布时间 |
服务启动时读取“已发布”状态的版本,加载到内存。要做灰度发布时,新版本先以“灰度”状态发布,让内部用户先体验,跑几个小时确认没问题,再切换全量流量。这个过渡状态下,请求路由规则我通过Redis里一个开关控制:1%的流量打到新版模型上,观察线上数据表现。
6.2 关键指标上报与告警规则
没有可观测性的后端都是黑盒,等用户投诉才去翻日志,效率太低。我在项目里接了一套轻量级监控,上报的核心指标包括:
- 推理耗时分布:P50、P95、P99,P99超过1500ms就要告警。
- 识别成功率:识别成功请求数除以总请求数,低于95%要告警。
- 缓存命中率:缓存命中率低于50%,说明大部分请求在重复计算,要么缓存过期太短,要么业务场景里真的没有重复图。
- CPU和内存水位:CPU持续超过80%或者内存持续走高,说明Worker进程可能要崩。
监控不只是埋点,更重要的是给我透传了一个“推理链路全景视图”:来了一个请求,从上传到Django路由、到Redis、到Worker进程、到ONNX Runtime,每一步耗时多少分位值,一眼能定位瓶颈在那。上线第一个月我就是靠这套监控,排查出预处理里有一个不必要的Base64转码耗时200ms,删掉之后整体响应削掉了三成。
6.3 数据回流与模型迭代闭环
最后说一下后端在这个项目里一个不太起眼但价值极高的职责:数据回流。
模型上线后不是一劳永逸的,需要持续用真实业务数据改善。后端在识别记录表里存了image_md5、disease_code、confidence,这些数据定期导出给算法团队,他们用来筛选“低置信度”样本和“模型高错误率”样本,人工标注后扩充训练集。
我在定期的清洗任务里,把置信度在0.4到0.8之间、且用户反馈过“识别不准”的图片单独建了一张feedback_table。这张表记录用户的原话反馈、图片路径、当时模型的输出结果、用户手动纠偏后的正确病种。模型迭代到v2的时候,算法团队拿着这批数据重新训练,准确率提升了接近6个百分点。
后端在这里起的作用,不只是接一个识别接口,而是把业务方、算法方、用户三方的数据链路打通。数据回流闭环,才是AI产品能持续变好的真正引擎。
7. 一些实战中摸索出的前端协作细节
可能有人觉得后端写好了接口就够了,实际联调过程中,跟前端对齐的细节才是最大工作量。我把几个印象深刻的坑列出来。
7.1 移动端图片被Exif旋转
手机拍照片时,传感器方向信息会写进Exif,图片的实际像素方向和后端看到的方向不一定一致。如果不处理,就会出现图片横着传上来、识别结果正确但展示时被旋转90度的问题。
前端如果在上传前用canvas做了压缩,旋转信息多半会被抹掉;但如果前端直接传原图,后端就要先读Exif方向,再对图片做旋转处理。我试过两种方案:一种让前端统一用canvas压缩并重绘成JPG,一种后端读Exif做校正。最终的稳定方案是后端校正,因为前端压缩会牺牲图片细节,影响模型识别。
7.2 白平衡和滤镜导致识别偏移
有个很隐蔽的问题,农户上传照片时用了手机自带的鲜艳滤镜,整张图饱和度被拉高,模型的特征分布被扰动,原本能识别出来的病斑变得不明显。这类问题很难在技术侧完全解决,目前业务侧的折中方案是:上传页加了一个引导文案“请在自然光下拍摄,不要使用滤镜”,虽然有点土,但确实降低了误报率。
训练数据里加入不同光照风格的数据增强,是算法侧的正解,后端能做的是在预处理链上加一层自动白平衡补偿,对颜色异常偏高的图片先做直方图均衡化再进模型。这个方法我试过,对部分病种效果明显。
7.3 识别结果的缓存刷新策略
当病种库调整(比如把“稻曲病”和“稻粒黑粉病”合并成一个新病种)后,历史识别记录里的病种名称已经变了,用户翻旧记录时看到的是老名称。这种历史记录的“病种漂移”问题,需要在改病种库时,跑一遍清洗脚本,把所有引用旧病种的记录做一次映射更新,或者干脆在查询接口里做一次动态映射,根据当前病种字典把旧code翻译成新名称。
第一种方案数据一致性好,但跑全量更新容易出问题;第二种方案实时性好,但要保证病种字典的映射关系不被业务人员误改。
8. 最终经验总结
这一个多月的开发,让我对“AI + 后端”的认知有了很大刷新。传统后端思维里,接口写对了、数据不丢就万事大吉;但AI项目里,性能、缓存、模型版本、数据回流这些统统交织在一起,任何一个环节偷懒都会在线上被放大。
我个人体会最深的三点:
第一,接口设计一定要考虑轮询和异步的配合。同步返回结果在AI场景下是伪需求,真正稳定的方案永远是“提交立即返回 + 前端轮询获取结果”。用户的耐心是有限的,识别一旦超过3秒,体验就崩了。
第二,模型推理性能的优化优先级永远高于代码微优化。后端写代码再精巧,也就省个几毫秒,但模型换成ONNX Runtime + 输入尺寸降低 + 多进程并行,直接省几百毫秒。先把大头吃掉,再抠小钱。
第三,数据是产品真正的护城河。模型可以复用开源权重、算法可以抄论文,但每个业务场景下产生的真实反馈数据,才是别的团队复制不走的。后端的职责就是把这条数据回流链路由设计层面打通,让每次用户反馈都变成模型下一次迭代的养料。
如果你们团队也打算做AI识别类的后端功能,我的建议是先别急着写代码,把整个链路从上到下画出来,跟算法、前端、业务都拉齐一遍,把时间花在前期设计和数据建模上,后面开发效率会成倍提升。别看它只是一个“05号”模块,它可能是整个系统里最能体现后端价值的环节。