
AI医疗诊断系统实战记录从模型推理到数据存证的完整闭环这一篇聊聊我前段时间做的一个AI医疗诊断辅助项目。项目本身不复杂但把AI出诊断结论和诊断数据存证这两件事串在一起做完整闭环踩坑不少值得整理出来给各位参考。整体技术栈是Python做模型推理FastAPI处理后端前端用Vue 2数据存证基于哈希指纹加数字签名的方案实现整条流程覆盖了影像上传、模型推理、报告生成、存证固化、结果溯源五个环节。如果你正准备做类似的医疗AI项目或者对模型结果怎么防篡改、可追溯这个需求感兴趣这篇文章应该能省你不少弯路。先说清楚一个定位问题这个项目做的是诊断辅助系统的技术原型用于科研和内部验证不是拿过证的医疗器械软件。真正要落到临床场景合规和认证工作比我们这篇聊的技术实现要多得多。但底层逻辑是一样的尤其是数据存证这件事医疗场景几乎是刚需。1. 项目整体设计与技术选型思路1.1 需求拆解一个诊断辅助系统到底需要什么项目启动前产品侧给的需求一句话能讲完医生上传CT影像系统给出肺部病灶的辅助诊断建议并且整个诊断过程的数据不能被篡改事后能追溯。但这句话落到工程上至少拆出四块硬需求。第一块是影像接入。医院里的影像设备导出的标准格式是DICOM但实际传上来的文件五花八门有原始DICOM有PNG截图还有PDF报告。系统必须兼容这些格式并统一转成模型能吃的张量。第二块是模型推理。这里选择的是肺结节检测这个相对成熟的方向用ResNet50做迁移学习检测部分用了一个轻量化的U-Net变体。第三块是报告生成。模型输出检测框和分类概率系统要用模板把这些结构化成一份人能读懂的诊断建议报告。第四块是数据存证。诊断完成后原始影像、模型版本、推理参数、检测框坐标、诊断结论这些都要生成一个不可篡改的指纹并固化存储随时可验证。这里最容易忽略的是模型版本和推理参数。很多人在存证时只想到存结论但实际追溯的时候如果不知道当时是哪个模型版本、什么阈值条件下给出的结论这个存证是没有意义的。所以从设计之初就必须把这些元数据纳入存证范围。1.2 技术选型为什么是这套组合选型上有几个关键决策我逐个说下当时的考虑。模型侧选了PyTorch。原因很简单医疗影像社区的开源预训练模型、数据增强库、可视化工具PyTorch生态明显更全。当时也对比过TensorFlow但涉及自定义损失函数和推理时的动态图调试PyTorch写起来爽太多。后端选了FastAPI。项目需要把模型推理封装成HTTP接口FastAPI的异步支持和Pydantic数据校验在接收文件上传、参数校验、返回结构化JSON这些场景下开发效率极高。相比FlaskFastAPI自动生成的Swagger文档帮了大忙联调时前端同事自己就能看接口文档。前端选了Vue 2加Element UI。这个没有太多纠结团队本来就用这套组件库齐全尤其是上传组件和表格展示成熟稳定。说句实话这个项目的技术难点全在模型和存证链路上前端只要稳就行。存证方案上我从一开始就没打算直接上区块链节点的完整搭建。原因很实际项目周期紧搭建和维护联盟链节点的人力成本高而且业务场景只需要防篡改可追溯不需要完整的智能合约逻辑。最终用的是哈希指纹 国密SM2签名 存证服务值固化的轻量方案后面章节详细说。1.3 系统总体架构整体架构画出来大概是这样的链路前端上传影像到FastAPI后端后端先把原始文件做SHA-256哈希然后分发到两个并行的流程。一个流程走模型推理把影像预处理后送进模型得到检测结果和置信度另一个流程走存证准备把影像哈希、诊断结果、模型版本、时间戳这些打包成一个结构体。两个流程汇合后对完整的数据包生成最终哈希用SM2私钥签名再把签名值和哈希值送到存证服务端固化。前端拿到诊断报告的同时也能拿到一条存证编号随时可以查验证书。这个架构最大的好处是诊断和存证解耦。即使模型升级了旧的存证记录依然有效因为验证时只需要重新计算哈希比对签名即可。这为后面的模型迭代打下了基础。2. 医学诊断模型从数据到诊断结果2.1 数据准备医疗数据远比想象中麻烦模型部分我用的数据集是公开的肺部CT影像数据集包括LIDC-IDRI的部分子集。但即便是开源标准数据处理起来依然有一堆坑。首先是DICOM解析。DICOM文件不仅包含像素数据还包含大量元数据标签比如患者ID、检查日期、窗宽窗位、像素间距等。直接拿原始DICOM喂给模型是不行的。我的做法是先用pydicom库解析重点保留PixelSpacing像素间距和SliceThickness层厚这两个物理信息然后把像素值按标准肺部窗宽窗位窗宽1500HU窗位-600HU做灰度映射最后归一化到0到1。这里如果不做窗宽窗位调整模型的输入分布会偏差很大训练和推理的输入不一致性能直接崩。其次是样本不均衡。肺结节检测数据集中真正的阳性结节只占所有候选框的很小比例。我用了两个手段一是正负样本比例控制训练时让正负样本比维持在1比3左右二是用了Focal Loss作为分类损失函数。Focal Loss的核心思想是让模型把注意力集中在难分类的样本上对于易分类的负样本降低它们的loss权重。这个处理在医疗场景非常关键因为漏诊的代价远高于误诊。第三个坑是标注数据稀疏。公开数据集中有专业医生标注的结节位置。预处理时要把标注框从DICOM坐标系转换到模型输入的像素坐标系注意DICOM的原点方向可能和numpy数组的坐标系方向不一致这里一旦搞错训练出来的模型预测框位置会整体偏移。我当时就踩了这个坑症状是验证集上的mAP看起来还行但可视化出来发现预测框比真实框大了整整一圈最后定位到是坐标变换时忘了取整加偏移修正。2.2 模型训练与评估指标要盯着临床应用看模型结构我用了两阶段方案第一阶段用2D U-Net做候选区域提取第二阶段用一个轻量分类网络对每个候选框判断是结节还是非结节。为什么不用3D网络因为3D模型对显存和训练时间的要求高出很多而且公开数据集的标注质量参差不齐2D方案在工程上更稳。训练时几个关键参数供参考输入分辨率224乘224batch size 32初始学习率1e-4用CosineAnnealing学习率调度训练大约30个epoch。分类网络用了预训练的ResNet34在医学影像上做迁移学习前10个epoch冻结backbone只训练分类头之后解冻全部参数微调。评估指标上我建议别只盯着Accuracy。医疗场景里灵敏度Sensitivity/Recall比精确率重要得多因为漏诊一个真实结节可能造成严重后果。这个项目里我同时监控了F1和AUC曲线最终测试集上的AUC在0.91左右灵敏度0.88F1值0.83。实话说这个结果发论文算不上亮眼但作为辅助诊断工具已经能起到第二意见的作用。论文里很少提的是推理速度的优化。初版模型推理一张CT切片要大概400毫秒一整套CT序列上百张切片医生可等不了。后面做了裁剪把只包含肺实质区域的切片找出来再推理省掉了大量的空气背景计算整体推理时间压缩到原来的三分之一。这个优化方向很笨但非常有效。2.3 可解释性让医生敢用你的AI这是项目里被吐槽最多的部分。医生看到检测到结节置信度0.87第一反应不是放心而是想知道为什么你觉得这是结节因此我在推理流程里嵌入了热力图生成模块。用的是Grad-CAM把模型最后一层卷积的梯度信息反传到输入图像上生成一张和原图同尺寸的热力图。热力图叠到原图上医生可以直观看到模型关注的是哪个区域——如果模型标出的高响应区域确实是一个疑似病灶医生就更容易接受这个结论反之如果热力图高亮区域完全不在检测框附近说明模型可能是靠背景特征在蒙答案这个结果就要打上高可疑标记。另外我在诊断报告里加了一个关键因素列表把检测框区域内的纹理特征、密度分布、边缘清晰度这几个可解释特征量化输出。这些特征不参与模型决策但能为报告阅读者提供额外的判断依据。这个做法不复杂但临床反馈效果出奇地好。3. 数据存证模块让诊断结果不可抵赖3.1 为什么医疗场景必须做存证聊存证之前得先把概念讲清楚。数据存证不是简单的存个备份它要解决的是这个数据是什么时候产生的、当时内容是什么、之后有没有被改过这三个问题。对应到医疗场景就是当诊断结论在后续会诊、争议处理、科研回溯中被质疑时你能拿出一个可信的证据链证明当时的诊断结论就是现在看到的这份没有被任何人动过手脚。传统做法是数据库里加个create_time字段、加个操作日志。但这解决不了根本问题——如果数据库管理员有权限他可以同时改数据和日志你根本查不出来。存证的核心思路是哈希指纹签名固化。哈希算法把任意长度数据映射成固定长度的数字指纹哪怕原数据改了一个bit哈希值都会完全变化。签名则是用私钥对这个指纹做加密变换确保指纹本身来自可信方。最后一步是把这个签名后的指纹固化到一个无法单方篡改的地方。这里要说明的是我用的存证服务端固化方案是把哈希指纹发给一个独立的存证服务服务端会记录写入时间并返回一个存证编号。在更大的系统里这一步可以直接对接公证机构或区块链平台思路完全一致只是信任级别不同。尽职尽责地讲如果是生产级的严肃场景我还是建议上合规的第三方存证平台或联盟链方案我自己这个版本是为了在有限条件下跑通全流程。3.2 存证核心流程与代码实现存证流程可以细分为五步数据标准化、计算哈希、组装存证结构体、SM2签名、固化入库。我这里给出核心代码逻辑方便你直接参考。第一步定义什么需要存证。我的方案是存一个JSON结构体包含原始影像哈希、诊断结果、模型版本、推理参数、时间戳和随机数。这个结构体的意义在于它完整还原了诊断发生时的现场。import hashlib import json import time import uuid def build_evidance_payload(image_hash, diagnosis_result, model_version, infer_params): payload { image_hash: image_hash, # 原始影像的SHA-256 diagnosis: diagnosis_result, # 结构化诊断结果 model_version: model_version, # 模型版本号 infer_params: infer_params, # 推理参数(阈值等) timestamp: int(time.time() * 1000), # 毫秒级时间戳 nonce: uuid.uuid4().hex # 随机数防碰撞 } return payload第二步对原始影像做哈希。为什么是原始影像而不是预处理后的张量因为原始DICOM是唯一的源头证据预处理过程是可复现的只要代码版本固定所以用原始影像哈希作为数据指纹最稳妥。def sha256_file(file_path, chunk_size8192): h hashlib.sha256() with open(file_path, rb) as f: while chunk : f.read(chunk_size): h.update(chunk) return h.hexdigest()注意大文件哈希必须分块读一次性read整个文件到内存CT序列动辄几百MB直接爆内存。第三步组装完整存证结构体。哈希一定要等模型推理完成后才能组装因为结构体里包含诊断结果。第四步是签名。项目里用了SM2算法这是国密标准的非对称加密算法类比RSA但对安全性要求更高。签名用的是结构体序列化后计算出的哈希值然后对哈希值做签名。from gmssl import sm2, func # 私钥和公钥长度都是64字节hex字符串 private_key 私钥hex字符串 public_key 公钥hex字符串 sm2_crypt sm2.CryptSM2( public_keypublic_key, private_keyprivate_key ) # 对结构体哈希签名 data_hash sha256_str(json.dumps(payload, sort_keysTrue, ensure_asciiFalse)) signature sm2_crypt.sign(data_hash.encode(utf-8))这里有个细节json.dumps必须用sort_keysTrue并统一编码否则同一个结构体因为字段顺序不同序列化出来的字符串就不同哈希就变了签名验证必然失败。这个问题坑了我整整半天。第五步把签名结果和结构体一起发送到存证服务端。服务端验证签名通过后将哈希指纹写入存储返回存证编号。同时我本地数据库会保存一条关联记录把诊断报告ID和存证编号绑在一起。3.3 验证与追溯存证做出来是给人查的存证不能光写不读。验证一个存证记录是否被篡改流程也很清晰拿到当时存证的原始结构体重新计算哈希用公钥验证签名是否匹配如果签名验证通过说明这个结构体自从签名之后就没人动过再把当前影像文件重新做哈希和结构体里的image_hash对比一致则证明影像也没被动过。这个验证接口我做成了RESTful API前端报告页面上嵌入了一个验真按钮。点一下后端拉取当时的存证记录执行上面三步验证返回一个三行结果表。验真这块有个容易被忽略的问题——哈希比对要关注当前拿到的文件和当时存证的原始文件是否一致但很多系统里文件经过二次保存比如从数据库中导出、经过传输可能元数据已经变了。DICOM文件里哪怕改了PatientName这个标签整个文件的哈希就会变但影像内容本身没变。这就是为什么存证时要同时保存原文件哈希和像素内容哈希。像素内容哈希需要自己解析DICOM提取像素数据后再计算代价高一些但验证时能区分内容被改和仅元数据被改。我在第二版里加了这一层可靠度提升明显。4. 前后端流程打通从提交到存证的完整链路4.1 后端API设计一个接口搞定还是拆开最初设计API时我纠结过一个问题是把诊断和存证放在一个接口里还是拆成两个独立接口。单一接口的优点是前端一次请求全搞定逻辑简单缺点是耦合度高万一存证服务超时诊断结果也拿不到。最终我拆成了三个接口。第一个是文件上传接口POST /api/upload。前端先把原始影像传上来后端返回一个image_id。这个接口只做文件存储和原始哈希计算。第二个是诊断接口POST /api/diagnose入参是image_id和可选参数如置信度阈值后端执行模型推理返回诊断报告和热力图URL。第三个是存证接口POST /api/attest入参是image_id和diagnosis_id后端把这两块数据组装成存证结构体签名固化返回存证编号。这样拆的好处是每个环节都可以独立重试和监控。如果模型推理失败不需要重新上传影像如果存证失败诊断结果还在可以稍后补存。我用FastAPI实现代码结构大概是这样的from fastapi import FastAPI, UploadFile, File from pydantic import BaseModel app FastAPI() app.post(/api/upload) async def upload_file(file: UploadFile File(...)): image_id storage.save(file) image_hash compute_sha256(storage.path(image_id)) return {image_id: image_id, image_hash: image_hash} class DiagnoseReq(BaseModel): image_id: str threshold: float 0.5 app.post(/api/diagnose) async def diagnose(req: DiagnoseReq): result model_infer(storage.path(req.image_id), req.threshold) return {diagnosis_id: result.id, report: result.report, heatmap_url: result.heatmap_url} class AttestReq(BaseModel): image_id: str diagnosis_id: str app.post(/api/attest) async def attest(req: AttestReq): payload build_evidance_payload(req.image_id, req.diagnosis_id) evidance sign_and_send(payload) return {attest_id: evidance.id, verify_endpoint: /api/verify}接口层的异常处理这里必须重点说。FastAPI的UploadFile在大文件上传时会先写临时文件如果直接把AsyncFile对象传给后续处理线程容易出现文件已经关闭的报错。我的做法是上传接口直接把文件落盘到一个本地目录返回路径给内部模块用不要让文件句柄跨接口传递。这个小问题当时排查了很久症状是偶发性的Bad file descriptor时好时坏非常恶心。4.2 前端交互让医生看得懂也用得动前端交互设计上最重要的原则是不假装自己很聪明。界面布局分三块左侧是影像列表和上传入口中间是影像展示区叠加检测框和热力图右侧是诊断报告区和存证状态区。影像展示用了Cornerstone.js这个库专门做医学影像的Web展示支持窗宽窗位调节、缩放、序列翻页。如果这里不用专门的库而用普通canvas光是窗宽窗位调节和DICOM灰度映射的联动就要写不少代码而且性能和兼容性都难保证。在这一步团队的共识是医学影像的显示交互是一个专业领域不要重复造轮子。存证状态区是我特别要求加的。每份诊断报告下方有三行状态信息影像哈希是否已计算、诊断结果是否已生成、存证是否已完成。这个设计源于实际使用中的一个痛点——以前存证是后台静默执行的医生不知道自己的诊断到底存没存上出了问题要等事后排查。现在把状态显性化任何人打开一份历史报告一眼就知道它的存证是否完整。4.3 端到端联调把三块模块串起来跑通联调阶段的核心工作是处理文件、数据、存证三者的生命周期一致性。我整理了一个联调检查清单这里列出来给你参考上传后立即计算哈希不要把哈希计算延迟到诊断完成后因为文件可能在传输或存储过程中被改动。诊断时传入的阈值参数必须记录到存证结构体里不同阈值下的诊断结果不可混为一谈。模型推理失败时前端要能区分上传成功但诊断失败和诊断成功但存证失败两类错误界面提示完全不同。存证服务返回的存证编号必须在数据库里做唯一约束防止同一份报告重复存证出现两条记录。大文件上传需要支持断点续传实际使用时医院的网络环境并不稳定一个200MB的CT序列上传到一半断掉如果要从头传体验极差。联调时最重要的一个测试是篡改尝试。我会刻意在数据库里修改一条诊断记录的某个字段然后触发验真流程确认系统能发现异常。这个测试在项目验收时给客户演示的震动效果特别大——他们亲眼看到改了一个字验真结果就变成存证不通过。5. 常见问题与排查技巧实录5.1 模型推理中的典型问题问题一训练时loss降不下去。排查后发现是数据预处理时忘了做归一化不同批次的输入数据尺度不一致。这个问题的隐蔽性在于模型不会完全训练不动loss会缓慢下降但validation指标一直上不去。建议在训练脚本里每次迭代前打印输入数据的均值和方差确认分布稳定。问题二推理结果和训练指标差异大。最常见的原因是训练时用了数据增强翻转、裁剪但推理时没有关闭数据增强的随机性。比如我用的数据增强里有一个随机裁剪推理时如果代码里忘了设置eval模式就会对同一张图每次推理出不同的结果。这个坑表面上是模型不稳定实际是代码bug。问题三显存溢出。医学影像分辨率很高直接整图送进模型必爆显存。解决思路是切patch——把大图切成多个重叠的小块分别推理再把结果拼接起来。拼接时要对重叠区域做非极大值抑制把重复检测到的目标合并成一个。这块逻辑不复杂但写起来容易出错建议单独抽一个工具类来做方便单测。5.2 存证环节的经典踩坑存证模块的坑主要集中在三处。第一处是签名和验证必须用同一套序列化逻辑。我前面提到过sort_keysTrue的问题这里再强调一次Python的json.dumps默认会处理中文为ensure_asciiTrue如果你在一个环境里用的是默认参数另一个环境里改成了ensure_asciiFalse同一个内容的哈希就完全不同。签名的本质是对字节串签名所以序列化的规则必须固定下来最好封装成一个公共函数所有模块都调用它。第二处是时间戳的时区问题。前端提交的存证请求里带了本机时间后端签名时用了服务器时间两个时间戳不一致验证时会出现可信时间点的争议。我的处理是存证结构体里的时间戳一律以后端服务器时间为准前端传的时间只作为参考信息不参与签名。第三处是私钥管理。项目里为了简单直接把私钥写在配置文件中这在开发环境没问题但生产环境绝对不行。私钥泄露意味着任何人都能伪造存证记录。务实的做法是用硬件加密机或者至少用环境变量加密钥管理系统来管理私钥签名服务单独部署业务服务只能调用接口拿不到私钥本身。5.3 前后端联调的疑难杂症联调期最折磨人的是偶发性超时。前端上传一个大文件后后端在做文件哈希和模型推理的耗时较长默认的HTTP超时时间不够导致请求中断。排查过程很浪费生命——刚开始以为是模型太慢优化推理耗时之后问题仍然存在最后才发现是Nginx的proxy_read_timeout默认60秒的限制。这个案例值得单独记一笔。在后端服务里打印耗时日志但日志显示整个请求在3秒内就完成了前端的报错却是请求超时。后来查了Nginx的访问日志才发现代理层在60秒后主动断开了连接。这里让我学到一个教训排查超时问题必须从头到尾把每次跳转前端到Nginx、Nginx到后端、后端内部耗时都加上时间戳逐层排查。任何一层都可能成为瓶颈不能想当然。另外前端Vue 2组件在接收大文件上传进度时如果用axios默认的onUploadProgress事件在某些浏览器版本下进度条会跳变。解决方法是自己包了一层request拦截器统一处理进度事件。这个不算bug但确实影响使用体验提前封装好能省不少事。5.4 问题的快速排查表这里整理一个我在项目维护期经常翻的排查表每一条都是真实踩过的现象可能原因排查方向模型推理结果每次不同数据增强没有在eval模式下关闭检查推理入口是否调用了model.eval()存证验真一直失败序列化规则不一致或字段顺序变化固定json.dumps参数校验两次签名用同一函数上传大文件后请求超时网关代理超时时间过短检查Nginx/网关的read/send超时配置检测框在图上位置偏移DICOM坐标转换未对齐验证坐标系原点和方向手动框对比测试存证记录查不到数据库唯一约束或事务没提交检查存证表和诊断表的关联外键、事务隔离级别热力图全蓝无高亮Grad-CAM输入张量未做梯度保留确认输入requires_gradTrue且用的是最后一层卷积写在最后的实操心得项目跑完我的最大体感是AI模型本身占这个项目的工作量不到四成剩下的全在工程化上。数据怎么清洗、推理怎么部署、结果怎么让用户信任、记录怎么防篡改这几件事任何一件没做好模型再准也落不了地。特别是数据存证这块刚开始团队觉得是额外负担一个辅助诊断工具存什么证。但做到后面发现存证不只是为了追溯和防扯皮它反过来推动了整个系统的规范化——因为每个环节的输入、参数、版本都要被记录和签名逼着我们把模型管理、配置管理、异常处理全部理清楚了。如果你的项目也涉及AI给出关键结论的场景不管是不是医疗我都建议把存证这个模块前置考虑不要等技术债积累到后期再补。最后分享一个小技巧在开发环境里我习惯把整个诊断链路做成一个一键造假模式——用固定时间戳、固定随机种子、固定模型权重这样每次跑出来的存证结果完全一致方便在测试环境里反复验证存证和验真逻辑。等逻辑全部验证通过再切回真实的时间戳和随机数。这个模式极大节省了联调和回归测试的时间你可以试试。