去年年底,院里抽调了一批人组了个"大模型进医院"的专项小组,任务不是写汇报材料,而是真正把医疗AI大模型落到业务流程里。说实话,刚被拉进去的时候我有点怀疑——这词听着热,但医疗行业的环境、数据、合规要求摆在那里,到底能从哪儿干起?这篇内容我不堆概念,只讲实操:我们在一家三甲医院做医疗大模型开发实战的完整过程,包括"医教研管服"这五条线怎么拆、怎么选型、怎么部署,最后是内部代号 UltraUnion 的超声诊断模型的 PyTorch 代码逐段解析,从 DICOM 数据读取、多帧建模、多标签训练到结构化报告输出,一条链路给你讲清楚。如果你在医院信息中心、医工科,或者在某个医疗AI团队做算法,这篇应该能帮你少走几个月的弯路。
1. 医教研管服五条线,我为什么把"医"排在了第一位
医疗行业里常说"医教研管服"五个字,但落到大模型项目上,五条线的优先级完全不同。不少人一上来就想做面向患者的智能问诊,或者做管理端的指标问答,觉得这些场景门槛低、见效快。可真正做过一轮就会发现,"医"这一条线才是最值得先啃的硬骨头,做完之后的复用价值也最高。
1.1 医:不是让大模型开处方,而是把重复劳动接走
临床端的核心诉求不是让大模型当医生,而是把医生手里那些高重复、高耗时、但逻辑相对固定的活儿接走。拿超声科举例:乳腺超声检查量常年很大,报告里BI-RADS分级、肿块描述、良恶性倾向这些内容,格式相对固定,但写起来非常耗时,不同医生写出的描述词还不统一。我们用大模型和专用视觉模型配合,先自动识别关键超声影像特征,再生成结构化报告草稿,医生只需要复核修改,不用从零开始敲键盘。
这个大方向定下来之后,模型边界一定要划清楚:我们是辅助初筛和报告起草,不是给出确诊结论,所有输出都要求医生复核签字。哪怕模型十个结果里对了九个,只有一个错的,那一个也必须由人来兜底。医疗AI做的是"减少劳动量",而不是"替代决策权",这句话我听很多老前辈说过,真正落地的时候才明白它有多重要。
1.2 教、研、管、服:看似热闹,落地各有取舍
教学线非常适合大模型发挥——我们做了住培教学病例库、模拟患者对话、以及病例报告批改辅助。尤其模拟患者对话,年轻医生练问诊,以前需要标准化病人配合,又费人力又难统一,大模型可以做这个角色,还能按教学大纲调整提问难度。这里的核心风险是病例数据的脱敏,任何教学语料都必须彻底去掉可识别信息,走专门的审核流程。
科研线的价值集中在"找东西"和"整理东西"。比如科研人员要从几千份病历里筛出符合入排标准的受试者,以前是人工翻,现在用大模型做信息抽取和结构化筛选,效率能提升不少。但科研场景不要求实时,对推理延迟的容忍度高,所以我们在技术路线上把科研线放在第二批,先把更刚需的临床线跑通。
管理线和服务线看起来最"好做",但也最容易做成玩具。管理端的运营指标问答、会议纪要生成、病案首页编码辅助,这些大模型做得很好,但价值评估很模糊,很难说省下了多少人力。患者服务端的智能导诊、报告解读、随访提醒也是一样,能提升体验感,但不直接解决临床痛点。所以我的建议很明确:资源有限的情况下,按"医——研——教——管——服"的优先级推进,中间两条可以根据医院实际情况调换,但临床这条线一定要放第一。
2. 底座选型与技术路线:统一大模型和专用小模型怎么取舍
很多团队拿到需求之后,第一个反应是找一个大模型把所有事情都干了,文本生成用同一个模型,图像诊断也往里塞。这个思路看起来很省事,实际落地会碰到两个问题:一是超声诊断这类任务对图像的空间细节非常敏感,通用大模型的视觉编码器不一定比得上为医学图像调优过的专用模型;二是沉默成本会被放大——一个大模型如果某个子任务掉点,你都不好把它单独拉出来优化。
2.1 先回答那个争议性问题:要不要统一大模型
我们最终的方案是两层结构:通用大模型底座负责文本理解、对话生成、信息抽取这类"语言活",相当于项目中台;专用视觉模型负责超声图像这类"细节活",独立训练、独立部署、独立迭代。这样做的理由很简单:超声诊断模型的迭代频率高,数据回流之后每周都可能调阈值、补badcase,如果耦合在大模型底座里,一次微调会影响其他业务线,风险太大。分开之后,各条线可以各自升级,互不干扰。
2.2 底座选型、算力与私有化部署的取舍逻辑
底座模型我们选了参数规模在7B到13B这一档的开源对话模型,没有一上来就上更大的千亿级模型。原因是医疗场景的文本任务并不需要超强的世界知识,更需要的是对临床术语的准确理解和稳定的指令遵循能力。参数越大,部署成本和内网算力压力越成倍增加,对于三甲医院这个体量来说,7B到13B的模型经过针对性微调,已经能满足大部分业务需求。
微调方式上,我们优先做LoRA这类参数高效微调,而不是全参数微调。原因有两个:一是医院能拉到的GPU资源是有限的,全参数微调一次实验的成本太高;二是LoRA可以保留底座的通用能力,不容易灾难性遗忘。等某个业务线效果确实冲到瓶颈,再考虑对模型的一部分层做全参数微调。
| 技术决策点 | 我们选用的方案 | 选择理由 |
|---|---|---|
| 文本底座 | 7B~13B开源对话模型 | 医疗文本任务够用,部署成本可控 |
| 微调方式 | LoRA为主,小范围全参数微调为辅 | 算力有限,迭代快,防止遗忘 |
| 超声专用模型 | Swin Transformer + 序列建模 | 对空间细节敏感,独立迭代,不污染底座 |
| 推理部署 | 院内网络统一网关 | 数据不出院,请求统一管理,延迟可控 |
| GPU资源 | 24GB显存档位显卡做微调,推理用小batch | 平衡成本与吞吐,不在算力上堆钱 |
表格里的方案是我自己整理出来的,不是行业唯一答案,但至少在我们这个体量的医院里是跑得通的。核心原则是:能把任务拆小就不要做大一统,能用LoRA解决就不要全参数硬刚,能在内网解决就不要碰外网。
3. UltraUnion 超声诊断模型代码解析:从DICOM到结构化报告
下面进入代码部分。我们超声诊断模型内部代号叫 UltraUnion,不做大而全的"多模态全能模型",专注一件事:从超声图像序列里输出多标签概率,包括良恶性倾向和BI-RADS分级相关特征,然后交给上层大模型生成报告文本。整体代码链路分四段:DICOM读取与预处理、模型结构、多标签训练、推理输出。
3.1 DICOM读取与预处理:超声图像和自然图像最大的不同
很多人第一次处理医学图像,会直接把DICOM当成普通图片读,这很容易踩坑。超声DICOM和手机拍出来的JPG不同,它保存的是传感器原始响应的灰度数据,像素值本身并不代表视觉亮度,必须做窗宽窗位的映射才能变成人眼习惯的灰度图。更关键的是,超声扫查通常会存储一段帧序列,逐帧之间的动态信息对良恶性判断有价值,这也是我们后来决定用多帧输入而不是单张静态图的原因。
import cv2 import numpy as np import torch import pydicom def load_dicom_grayscale(dcm_path, target_size=(384, 384)): ds = pydicom.dcmread(dcm_path, force=True) img = ds.pixel_array.astype(np.float32) # 超声图像的窗宽窗位调整 if hasattr(ds, "WindowWidth") and hasattr(ds, "WindowCenter"): ww = float(ds.WindowWidth) wc = float(ds.WindowCenter) lo = wc - ww / 2.0 hi = wc + ww / 2.0 img = np.clip((img - lo) / (hi - lo), 0.0, 1.0) else: # 没有窗宽窗位时用百分位截断 p_low, p_high = np.percentile(img, [1, 99]) img = np.clip((img - p_low) / (p_high - p_low + 1e-6), 0.0, 1.0) img = cv2.resize(img, target_size, interpolation=cv2.INTER_LINEAR) return torch.from_numpy(img).unsqueeze(0) # 返回 (C=1, H, W)这段代码里最值得注意的就是"没有窗宽窗位时用百分位截断"这个分支。实际DICOM文件千奇百怪,有的设备写了窗宽窗位,有的没写,如果只按标准字段处理,遇到缺失就直接报错。用百分位截断虽然不完全等价于放射影像的窗宽窗位调节,但在超声灰度图上表现稳定,算是保底的通用方案。
3.2 模型结构:Swin Transformer + 时间维度GRU
单张超声图只能看到静态截面,但医生操作探头时,会根据扫查角度和压力变化观察病灶的动态特征。所以我们把每次检查的连续帧打包成一个序列,用视觉编码器提取每一帧的特征,再交给GRU做时间维度的信息整合。这样设计的好处是:既保留了图像细节,又让模型能感知"肿块的边界在连续帧之间是否清晰稳定"这类动态信息。
import torch import torch.nn as nn import timm class UltraUnionModel(nn.Module): def __init__(self, num_labels=6, use_sequence=True): super().__init__() # 单帧视觉编码器:Swin Transformer,去掉分类头 self.backbone = timm.create_model( "swin_tiny_patch4_window7_224", pretrained=False, num_classes=0 ) # 时间维度建模:使用GRU聚合帧间特征 self.use_sequence = use_sequence if use_sequence: self.gru = nn.GRU(input_size=768, hidden_size=256, num_layers=2, batch_first=True) head_in = 768 + 256 else: head_in = 768 self.classifier = nn.Linear(head_in, num_labels) def forward(self, x_frames): # x_frames: (B, T, C, H, W) B, T, C, H, W = x_frames.shape x = x_frames.view(B * T, C, H, W) frame_feats = self.backbone(x) # (B*T, 768) frame_feats = frame_feats.view(B, T, -1) # (B, T, 768) if self.use_sequence: # 取最后一帧特征 + GRU最后时刻的隐藏状态 _, hidden = self.gru(frame_feats) fusion = torch.cat([frame_feats[:, -1, :], hidden[-1]], dim=-1) else: fusion = frame_feats[:, -1, :] logits = self.classifier(fusion) return logits这里有个容易被忽略的细节:最后一帧特征和GRU最后时刻的隐藏状态是互补的。最后一帧特征保留了视觉编码器对当前图像的最直接判断,GRU隐藏状态则压缩了整段序列的动态信息,两者拼接之后,分类头拿到的信息更完整。如果你用Transformer编码序列,也可以达到类似效果,但GRU在这个数据量下更容易训稳,参数量也小。
3.3 多标签训练:为什么不用单标签分类
一开始我们确实用的是单标签多分类,让模型从"良性/恶性/BI-RADS分级"里选一个。结果发现超声报告里经常同时出现"有恶性倾向但分级不完全确定"这类情况,强行单选会丢失信息。改成多标签之后,我们的输出设计成6个独立的二分类头:良性、恶性、BI-RADS 3、BI-RADS 4A、BI-RADS 4B、BI-RADS 5,每个头用sigmoid输出概率,训练时用带正负样本平衡的BCE损失。
import torch import torch.nn as nn # 6个输出对应的含义 LABELS = ["良性", "恶性", "BI-RADS 3", "BI-RADS 4A", "BI-RADS 4B", "BI-RADS 5"] def train_one_epoch(model, dataloader, optimizer, device): model.train() # 正负样本平衡:恶性类和BI-RADS 4B/5在数据集中更少,调高其正例权重 pos_weight = torch.tensor([1.0, 1.0, 1.0, 1.0, 2.0, 3.0]).to(device) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) total_loss = 0.0 for batch in dataloader: frames = batch["frames"].to(device) # (B, T, 1, H, W) labels = batch["labels"].to(device) # (B, 6) optimizer.zero_grad() logits = model(frames) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)正负样本权重不是拍脑袋写的,而是根据训练集里每个标签的阳性占比反推的。比如BI-RADS 5的样本可能只占千分之几,如果不加权重,模型会倾向把所有样本都预测为阴性,因为这样损失已经很小。调权重之后,召回率能上去,但代价是假阳性也会增加。我们的经验是:医疗场景宁可假阳性多一点,也不能让真阳性溜掉,这个权重方向不能反。
3.4 推理与结构化报告输出
训练结束后的推理不是只输出一个概率,而是要落到医生能直接复核的结构化文本。我们用概率加阈值的方式生成结构化结果,再交给大模型底座润色成报告草稿。这一步的阈值不能只看公共数据集标准,需要在自家数据上做校准,把每个标签的阈值单独调。
def predict_structured_report(frames, model, thresholds, device): model.eval() with torch.no_grad(): frames = frames.to(device) logits = model(frames) probs = torch.sigmoid(logits)[0].cpu().numpy() result = {} for i, label in enumerate(LABELS): result[label] = { "probability": round(float(probs[i]), 4), "flag": bool(probs[i] >= thresholds[i]) } return result比如"恶性"这个标签,我们校准出来的阈值是0.55,"BI-RADS 5"是0.45。阈值设得不一样,背后的逻辑是:恶性这个标签的漏检代价极高,所以要保守一些;BI-RADS 5本身是明确的高危信号,模型只要有一点倾向就建议医生重点复核。拿着这份结构化结果,上层大模型才能生成有依据的报告草稿,而不是凭空想象。
4. 部署与合规:模型做得再好,也绕不过这几件事
模型在实验环境里跑出多好的AUC,都只是一半的工作。医疗场景的部署有几道硬门槛,每一道都能让项目从"看起来不错"变成"用不起来"。
4.1 数据不出院:物理隔离与内部推理网关
医院的数据都受严格管理,训练用的超声DICOM、病历文本都不能出医院网络。我们的做法是在院内搭建独立的推理环境,所有模型请求走内部网关,不走公网。网关做统一鉴权、流量控制和审计日志,模型服务本身不暴露公网接口,外面的任何终端都摸不到它。前期在墙外调试好的代码模型,迁移到院内环境时经常因为缺少依赖库、模型文件路径不对等问题卡住,所以我建议从一开始就按内网环境设计代码,不要把外网路径写死。
4.2 延迟、并发与模型回退
超声科的使用习惯和互联网产品完全不同。医生扫完一个病人的帧序列,希望报告辅诊结果在一两秒内回来,晚了就会觉得"绕了一圈还不如自己写"。我们的超声模型裁剪到16帧、单帧384像素,单次推理在院内GPU上约300到500毫秒,基本可以接受。如果科室同时开了多台超声设备,并发会瞬间上来,网关必须做队列和限流,避免卡死。
还要设计模型回退机制。比如GPU显卡故障、模型服务重启、输入数据异常,这种情况下网关要能把请求自动降级到无AI辅助的普通流程,而不是让整个科室的工作停下来。回退链路看着不起眼,真到关键时刻能救命。
4.3 与现有PACS/RIS系统的衔接
超声报告最终要写进RIS系统,AI输出要嵌进医生原本的报告模板里,不能另起炉灶。我们做的是把模型输出的结构化字段映射到RIS报告模板的对应位置,比如"肿块大小"取测量值,"BI-RADS分级"取模型输出,医生在同一个界面里看到草稿,直接改。如果让医生切到另一个AI系统里看结果再抄回来,使用意愿立刻下降一半。系统集成这件事,很多时候比模型本身更影响项目成败。
5. 训练和调优阶段,我们实打实踩过的五个坑
这个部分我不想讲任何标准流程,只说我真实遇到、并且花过时间解决的问题。
5.1 标注一致性低,Kappa比想象中难看
第一批训练数据由几位超声医生标注,跑出来的Kappa只有0.6左右。一开始我们以为是模型问题,后来把badcase拉出来一看,很多是医生之间对BI-RADS边缘病例的分级本来就不统一。我们的处理办法是:只保留两位以上医生标注一致的样本进训练集;不一致的样本单独放出来,一部分做测试,一部分扔给更多医生会诊。标注质量不过关,模型天花板就被钉死了,再调结构也白搭。
5.2 类别严重不均衡,precision和recall很难两全
良性样本占了绝大多数,恶性,尤其是BI-RADS 5这种高危分级,数量非常少。光靠pos_weight还不够,我们后来还加了类别平衡采样,每一个batch里强制出现一些高危样本。这样训练更稳,但也会让模型产生一点偏差,所以需要周期性回到真实分布上做验证,防止过拟合到"高危样本被过度代表"的世界里。
5.3 JPG截图与DICOM原始帧的"模态断裂"
刚开始为了省事,让数据标注平台导出的都是处理后的JPG截图。模型用JPG截图训练后,在PACS里接DICOM原始帧做推理,效果直接掉了一个档。原因很简单:JPG截图已经丢失了大量像素深度信息,灰度映射也和DICOM不一致。后来我们统一改成从DICOM直接拉帧,标注平台也只认DICOM的派生图,这个问题才算解决。再有人跟我提"用JPG也差不多",我是不信的。
5.4 换一个院区数据就掉点:设备域偏移
模型在A院区的数据上AUC能到0.92,拿到B院区只有0.85。差异来自不同超声设备供应商的图像风格、灰度映射、探头频率都不一样。就算同一个院区,不同型号机器扫出来的帧序列也有细微差别。我们后来在预处理阶段做了灰度分布归一化,并加入不同设备的数据做训练,还让各院区的超声科尽量统一扫查参数,掉点问题才慢慢缓解。设备域偏移是医疗影像里最容易被低估的问题。
5.5 模型输出不稳定会直接毁掉信任
有一段时间模型对同一份检查结果偶尔会出现完全不同的分级,把医生搞得很困惑,科室里直接出现"AI不靠谱"的声音。后来我们固定了随机种子,推理时关闭随机性,并把输出概率做平滑处理,让同一份输入永远得到同一份输出。这个细节看着小,但对一线医生的信任建立极其重要,因为医疗场景不接受"这次猜这个下次猜那个"。
6. 从"模型离线指标不错"到"超声科真用起来":最后一公里
模型离线AUC过了0.9,不代表科室愿意天天用。最后一公里的问题,比训练还磨人。
6.1 人机协作流程怎么设计
我们和科室商量的流程是:超声医生扫查结束后,系统自动调取帧序列,输出结构化和报告草稿,医生在报告界面上复核。整个过程不改变医生的原有操作顺序,AI的介入只是把"从零开始写报告"变成"改一份草稿"。草稿质量要高,至少要省一半打字时间,医生才愿意持续用。如果模型每一步都要人工确认,使用率就会掉得很厉害。
6.2 反馈闭环和badcase回流
我们专门在报告系统里加了一个"AI结果与最终诊断是否一致"的按钮。医生不用填一堆表单,只需要在AI结果上选择"采纳"或"修正",修正后的数据会被脱敏回流到训练集,变成下一轮迭代的badcase。这个闭环是整个项目持续变好的核心。如果模型上线后不回收反馈,效果只会原地踏步,甚至因为数据分布变化慢慢衰退。
6.3 价值度量:不能只看AUC
上线三个月后,我们重点盯的指标不是AUC,而是单份超声报告的平均生成时间下降了多少、医生采纳AI草稿的比例有多高、以及每周反馈badcase的数量。AUC只反映模型在数据集上的判别能力,而"报告速度提升""医生使用率"才直接决定项目有没有价值。我见过不少项目,模型指标亮眼,最后却因为没人用而搁浅,真到落地阶段,一定要把指标从算法侧切换到业务侧。
做完UltraUnion这个模型,再回头看过往这几个月的节奏,最大的感受是:医疗AI大模型开发,难的不只是模型结构,而是怎么把模型放进一家医院真实的流程里。代码是可以学的,框架是可以复用的,但每个医院的数据分布、设备情况、科室习惯都不同,这些只能靠一轮一轮打磨去适应。我现在的习惯是每次模型改动之后,先带着结果跑到超声科门口,找医生当场试几个病例,看看他点不点头。个人体会很简单:医疗AI信任比精度更重要,而信任是靠一次次稳定输出、一次次快速反馈换来的。