这篇写一个我参与并维护过的体检报告 AI 流水线复盘。场景来自某年服务数十万体检场次的连锁体检机构客户,覆盖智能推项、科普、建议、矛盾识别 4 类生成任务。
我不会把它写成"一个大模型替医生写报告"的故事。真实落地里,模型生成只是中间一步,更麻烦的是:项目编码不能编,医学表述不能越界,建议风格要像机构医生写的,矛盾识别还要避开大量医学上可共存的"假冲突"。
一开始踩到的坑:模型给的答案太像答案了
智能推项最容易让人误判。用户画像、问卷、基础体检项目给进去,LLM 很容易生成一段看起来合理的推荐理由,再附几个检查项目。
问题是,客户要的不是"听起来合理"的项目名,而是本院真实存在、可挂、可计费、可展示的项目。模型一旦编出不存在的 projectId、projectCode 或价格,这个错误就可能进入报告或前端展示。
所以链路没有让模型一步到位,而是拆成了几段:思考、抽取、检索、映射、质检。
这张图的重点是最后一道 quality_control,它不是修饰性校验,而是防止幻觉透传的闸门。
为什么不直接让模型输出结构化推荐
当时也考虑过更短的链路:prompt 写清楚字段,让模型直接输出最终 JSON。这个方案开发体验更舒服,但问题集中在两个地方。
一个是长 prompt 会稀释注意力。客户业务规则、基础项目、用户画像、专项风险、输出格式都塞进去后,模型不一定能稳定照顾到每个约束。
另一个是 project 映射本质上不是"语言理解题",而是"本院库对齐题"。模型知道"低剂量胸部 CT"这个概念,不代表它知道客户库里对应哪个项目、编码是什么、价格是不是可展示。
脱敏后的调试现象大概是这样:项目名看起来对,projectId 回到 relation_items 里却查不到;有时模型还会把价格字段补得像真的一样,但这个价格并没有来自本院库。这里不能靠"看起来合理"放行,只能回查。
最终更稳的做法是:让模型负责推理和粗映射,让系统负责反查和兜底。
python
def quality_control(model_items, relation_items):
checked = []
for item in model_items:
hit = find_in_relation_items(
relation_items,
project_id=item.get("projectId"),
project_code=item.get("projectCode"),
project_name=item.get("projectName"),
)
if not hit:
item["projectId"] = None
item["projectCode"] = None
item["projectPrice"] = None
checked.append(item)
return checked
这段伪代码解决的是 project 字段幻觉问题:宁可少展示价格和编码,也不要把模型编出来的字段交给下游。
专项筛查也走了类似的工程路线。系统维护二十余类专项筛查,其中 3 类因为客户业务不覆盖被注释禁用;运行时用 ThreadPoolExecutor(max_workers=20) 并发判断风险,再合并推荐项目。这个做法不优雅,规则表也要维护,但它比一个超长 prompt 更容易定位问题。
Schema 要小,边界要硬
推项侧的输出 schema 可以抽象成这样:
json
{
"recommendReason": "根据问卷和体检基础项目生成的脱敏推荐理由",
"items": [
{
"projectName": "本院项目名称",
"projectId": "可为空;必须能被本院库反查",
"projectCode": "可为空;必须能被本院库反查",
"projectPrice": "可为空;禁止模型自行编造",
"matchedSource": "internal_relation_items"
}
],
"quality_control": {
"unmatchedPolicy": "clear_id_code_price",
"retry": 3
}
}
这个 schema 示例只保留关键边界:字段可以为空,但不能假装确定;价格、编码这类业务字段必须来自本院库反查。
科普和建议侧的 schema 更关注表达约束。科普有 5 条强禁词,避免对恶性风险、寿命影响作承诺式或过度安抚式表达。建议侧则被限制在 3 类:专科就诊、进一步检查、复查;并且一条建议只能归属一个科室,排除急诊科,复查时间用阿拉伯数字。
这里我更倾向规则集,而不是 few-shot。few-shot 看起来省事,但在医疗文书里容易把示例里的疾病、部位、数值也带偏。风格问题应该拆成规则,比如"肺结节,建议 1 个月复查",而不是让模型模仿一堆医生句子。
矛盾识别:JSON 失败比你想得更烦
矛盾识别分成科间、科间性别、科内、历史 4 路,其中历史分支后来因为长上下文注意力稀释被禁用。服务层总并发是 max_workers=8,核心链路是三段式:
- 自然语言分析,先找可能冲突。
- 结构化 JSON 提取,方便系统消费。
- 二次审核和豁免,把假矛盾排出去。
JSON 阶段做了最多 5 次重试。每次失败时,不是简单重问,而是把上一轮模型输出作为 assistant 消息放回上下文,让模型基于自己的错误继续修。
python
def extract_json_with_retry(messages, max_retry=5):
history = list(messages)
for attempt in range(max_retry):
output = call_llm(history, temperature=0, seed=42)
parsed = try_parse_required_json(output)
if parsed.ok:
return parsed.value
history.append({"role": "assistant", "content": output})
history.append({
"role": "user",
"content": "上一次输出不是可解析 JSON。请保留医学判断,只修正 JSON 字段和格式。"
})
return {"items": [], "error": "json_parse_failed"}
这段伪代码解决的是结构化输出不稳定:代价是最坏情况下多次调用,收益是在当时模型和 schema 工具条件有限时,让链路先可用。
我不觉得这是一种漂亮方案。它更像工程现场里的止血贴:知道它重,但也知道没有它时,下游拿不到稳定结构。
失败样例不是噪音,是规则来源
矛盾识别里最有价值的不是 prompt 写得多长,而是把真实误报沉淀成豁免规则。系统里保留了 13 个具名假矛盾豁免案例。下面是脱敏简化后的模式级示例:
| 失败类型 | 脱敏简化样例 | 为什么错 | 处理策略 |
|---|---|---|---|
| 复测规则误判 | 原始血压异常,某次复测正常,小结写血压正常 | 体检业务里只要一次复测正常就可按正常处理,不能只盯原始值 | 在分析 prompt、二次审核和错误示例里写入血压复测规则 |
| 医学可共存差异 | 肛周皮赘样肿物,小结写外痔 | 两种描述可能指向同一类体检发现,不应按文本不一致判冲突 | 加入假矛盾豁免案例,由二次审核识别 |
| 附带发现误判 | 头颅 CT 未见明显异常,同时出现筛窦附带发现 | "未见明显异常"可能针对主检查范围,不代表所有附带描述都冲突 | 将附带发现类情况放进豁免清单 |
| 文书风格不专业 | 输出"是肺结节,于一个月复查" | 医学含义接近,但不像机构报告建议 | 用规则限制表达:阿拉伯数字、短句、复查区间取最短 |
这张表不是为了展示"模型错得离谱",而是提醒自己:医疗场景的错误经常不是常识题,而是业务规则题。
Coze 退到检索类子任务
项目早期试过用 Coze 承接更完整的工作流编排,后来退回到"主推理 vLLM + Coze 检索类子工作流"的组合。当时主推理模型是 GPT-OSS 120B,vLLM 里 alias 为 gemma。
退回来的原因不是 Coze 不能用,而是复杂度位置不对。这个业务复杂度主要在推荐贴合度、医学误报、输出边界和客户规则上。把这些都塞进工作流平台,调试边界会变得更厚。后续客户内网部署时,Coze 代码节点沙盒冷启动约 3 秒/次,也说明这类节点不适合放在高频主链路上。
我的判断是:检索类、低频、边界清晰的子任务可以交给工作流;强规则、高频、要反查和重试的主链路,还是放在服务代码里更容易兜底。
几个留下来的判断
体检报告 AI 不应该追求"一次生成完美答案"。更现实的做法是把模型放在它擅长的位置:理解、改写、候选生成、粗判断;把确定性边界交给工程:反查、清空、禁词、schema、重试、豁免。
以参与和维护者的视角看这类系统时,我最大的感受是:别急着把 prompt 写长。先问清楚哪些字段绝对不能错,哪些输出宁可为空,哪些规则必须后置校验。只要这些边界没立住,模型越会写,风险越像真的。
如果这篇复盘对你有帮助,欢迎点赞收藏。