1. 项目缘起:当AI开始“编造”你的体检报告
去年,我们团队接手了一个听起来很“性感”的项目:用大模型技术,为体检中心开发一个智能报告解读与健康建议生成系统。想象一下,用户拿到一堆充满医学术语和异常箭头的体检报告,正一头雾水时,我们的AI能像一位耐心的私人医生,用通俗易懂的语言解释每一项指标的含义,评估风险等级,并给出生活、饮食、复查等具体建议。这不仅能提升用户体验,还能为体检机构创造巨大的附加价值。
项目初期进展顺利。我们用高质量的医学知识库和标注好的历史报告数据,微调了一个中等规模的模型,在内部测试集上表现惊艳,解读准确率、语言流畅度都远超预期。然而,当我们满怀信心地将第一个版本推向小范围真实用户测试时,问题接踵而至。
最让我印象深刻的是一位用户的反馈。他的体检报告显示“甘油三酯 2.1 mmol/L”(轻度升高),但AI生成的解读中赫然写着:“您的甘油三酯水平显著偏高,已达到3.5 mmol/L,属于中度高甘油三酯血症,建议立即就医并考虑药物治疗。” 用户被吓得不轻,差点直接跑去挂急诊。我们一查原始数据,模型完全“捏造”了一个更严重的数值和对应的诊断。这不是个例,在血压、血糖、肿瘤标志物等关键指标上,模型时不时就会“自由发挥”,给出偏离事实的结论。这就是典型的“AI幻觉”——模型基于其训练模式“自信地”生成看似合理但不符合输入事实的内容。
更棘手的是工程层面的问题。系统上线后,监控面板上频繁出现“服务超时”、“内部错误”的报警。尤其是在早晨体检报告集中上传的高峰期,大量请求失败。错误日志里充斥着各种网络抖动、第三方依赖服务不稳定、模型推理超时的记录。一个简单的“重试”机制,在初期被我们想得过于简单,直接无脑重试导致雪崩,差点拖垮整个服务集群。
从“AI幻觉”到“重试策略”,这两个看似不相关的问题,恰恰是AI工程化落地中最常见、也最致命的坑。前者关乎产品的可信度与安全性,是生死线;后者关乎系统的可用性与健壮性,是生命线。本文将结合我们踩过的这些坑,拆解背后的原因,并分享我们最终采用的、经过实战检验的解决方案。
2. 拆解“AI幻觉”:不止于数据,更是系统工程问题
一提到大模型幻觉,很多人的第一反应是“数据不够”或“模型不行”。但在体检报告这种强事实性、高严谨性的场景下,我们发现幻觉的根源远比想象中复杂,它是一个典型的系统工程问题,涉及数据、模型、提示工程和校验链路多个环节。
2.1 数据层面的“先天不足”与“后天污染”
我们的训练数据主要来自两部分:一是脱敏后的历史体检报告与人工撰写的解读文本对;二是公开的医学教科书、指南和文献。问题恰恰出在这里。
首先,历史数据存在标注噪声。体检报告的解读本身就有一定主观性,不同医生对同一指标波动的表述可能不同。例如,对于边缘升高的尿酸,有的医生写“建议低嘌呤饮食,多饮水,3个月后复查”,有的可能写“尿酸偏高,注意饮食控制”。模型在学习时,会模糊地认为这两种表述都对“尿酸升高”这个事实,导致生成时在“建议复查”和“提示风险”之间摇摆,甚至混合生成。
其次,公开医学知识的“范围溢出”。我们为了让模型显得更“博学”,喂入了大量的通用医学知识。但模型无法精准区分“通用医学事实”与“当前用户特定情况”。例如,训练数据中反复出现“甘油三酯高于2.26 mmol/L可诊断为高甘油三酯血症”。当用户指标是2.1时,模型可能会“联想”到与之相关的诊断标准、并发症描述,并在生成时不小心“补全”了符合诊断标准的假想数值(如2.5或3.5),以便让后续的论述逻辑自洽。这就是一个由关联知识引发的数值幻觉。
注意:在医疗、金融等严肃领域,盲目追求模型的“博学”和“发散性”是危险的。知识注入必须精确、有边界,最好能与事实抽取模块解耦。
2.2 模型微调与推理的“过度泛化”
我们最初采用标准的全参数微调。这种方式虽然能让模型快速适应“体检报告解读”这个任务格式,但也放大了模型固有的“生成偏好”。大模型本质是概率模型,其训练目标是预测下一个词的概率。在微调后,模型学会了“如何组织一份像模像样的解读报告”,但并没有真正学会“严格遵从输入数据”。当输入中存在模糊或边界情况时(比如指标处于临界值),模型倾向于生成它认为“更完整、更典型”的叙事,而这个叙事可能基于训练数据中的常见模式,而非当前输入。
例如,训练数据中“甘油三酯升高”常与“建议戒酒、控制主食”同时出现。即使当前用户的报告未提及饮酒史和主食摄入情况,模型也可能“画蛇添足”地生成这些建议。这虽然不是事实性错误,但属于不准确的“建议幻觉”,同样会降低可信度。
2.3 提示工程的“阿喀琉斯之踵”
我们最初的提示词(Prompt)是这样的:“你是一名专业的健康管理师。请根据以下用户的体检报告数据,生成一份详细、易懂的健康解读与建议报告。” 这个提示词的问题在于:
- 指令模糊:“详细、易懂”是主观要求,没有约束模型必须“严格基于提供的数据”。
- 缺少结构化输出要求:模型自由发挥的空间太大。
- 没有防御性指令:未明确告诉模型“如果数据不足或不确定,应该怎么办”。
一个糟糕的Prompt,就像给一个知识渊博但粗心的医生一份病历,却不告诉他“只看标红的部分”,他很可能根据自己的经验侃侃而谈,从而偏离重点。
3. 我们的“抗幻觉”工程化方案:三层过滤网
认识到幻觉的多源性后,我们放弃了“用一个更牛模型解决所有问题”的幻想,转而设计一套工程化的防御体系。这套体系像三层过滤网,逐级降低幻觉输出到用户的概率。
3.1 第一层:输入标准化与知识约束
在数据进入模型之前,我们增设了一个报告解析与知识约束模块。
- 结构化提取:使用一个轻量级的NER(命名实体识别)模型或规则引擎,从原始报告文本中精确提取关键指标(如“甘油三酯:2.1 mmol/L”)、参考范围、异常标志。将其转化为结构化的JSON数据,例如:
{ "indicators": [ { "name": "甘油三酯", "value": 2.1, "unit": "mmol/L", "is_abnormal": true, "reference_range": "<=1.7" } ] } - 知识库实时检索:不把所有医学知识都塞进模型。我们构建了一个本地的、可更新的医学知识图谱(包含指标含义、临床意义、临界值、典型建议模板)。在生成前,系统根据提取的结构化指标,从知识库中检索出最相关的、准确的定义和建议模板片段。
- 提示词重构:将原始的模糊Prompt改为精确的、包含上下文的Prompt:
角色:你是一名严谨的健康报告解读助手。 任务:基于以下**严格准确**的用户体检数据和**提供的医学知识片段**,生成解读。 规则: 1. 所有结论必须严格基于“用户数据”部分,不得自行编造、修改或推测数据。 2. 所有医学解释必须来自“参考知识”部分,不得使用外部知识。 3. 如果“用户数据”中某项指标正常,则不要讨论其疾病风险。 4. 如果“参考知识”中没有对应建议,则该项建议留空或写“请咨询临床医生”。 5. 输出必须为JSON格式,包含“指标解读”、“风险评估”、“生活建议”三个字段。 用户数据:{structured_data_json} 参考知识:{retrieved_knowledge_snippets}
这一步的核心是将事实(用户数据)与知识(医学常识)分离,并通过Prompt进行强绑定,极大限制了模型“信口开河”的空间。
3.2 第二层:模型策略优化与采样控制
在模型推理环节,我们做了以下调整:
- 从微调转向检索增强生成(RAG):我们大幅减少了全参数微调的范围,仅让模型学习“如何组织语言和遵循指令”。核心的医学事实则通过上述的“知识库检索”环节注入(RAG模式)。这样,模型需要“编造”的内容就少了很多,它更像一个严谨的“文书”,根据给定的事实和资料进行撰写。
- 采用低温度(Temperature)采样:在文本生成时,将Temperature参数设置为一个较低的值(如0.1或0.2)。这意味着模型在选择下一个词时,会更倾向于选择概率最高的那个,而不是进行更多随机、创造性的探索。这能显著提高输出的确定性和一致性,减少“天马行空”的幻觉。代价是文本可能略显枯燥,但对于体检报告解读,准确性远重于文采。
- 自我一致性采样与投票:对于高风险结论(如建议就医、提示肿瘤风险),我们让同一模型在相同输入下,用不同的随机种子生成3-5个版本。然后通过一个简单的规则校验器或另一个轻量模型,检查这些版本在关键事实(指标数值、结论定性)上是否一致。如果不一致,则触发第三层校验或直接返回“人工复核”状态。
3.3 第三层:输出后校验与人工兜底
这是最后一道,也是最关键的安全阀。
- 规则校验器:开发一系列基于规则的校验脚本。例如:
- 数值校验:生成的文本中提取出的数值是否与输入数据一致?(利用正则表达式或小型提取模型)
- 逻辑校验:如果输入数据中所有肿瘤标志物均正常,生成文本中是否出现了“癌症风险”相关词汇?
- 禁忌校验:生成的建议中是否包含了对于特定人群(如孕妇、肝肾功能不全者)的禁忌建议?(维护一个禁忌词表)
- 关键结论置信度评分:用一个经过训练的、更保守的分类模型,对生成报告中“风险评估等级”(如“正常”、“轻度风险”、“建议就医”)等关键结论进行二次判断,并与主模型的生成结果对比。如果差异较大,则标记为“低置信度”。
- 人工复核队列:所有被规则校验器或置信度模型标记的异常报告,以及所有包含最高风险关键词(如“立即就医”、“疑似”、“恶性”)的报告,都会自动进入人工复核队列,由专业的医学编辑进行审核。系统会清晰标出AI生成的内容与原始数据的对比,辅助人工快速判断。
通过这三层过滤,我们将生产环境中严重的“事实性幻觉”事件降低了95%以上。这套体系的精髓不在于追求100%的零幻觉(这在当前技术下几乎不可能),而在于通过工程手段,将风险可控地收敛到一个极小的、可被人工兜底的范围。
4. 重试策略的深水区:从“简单重试”到“自适应熔断”
解决了“对不对”的问题,下一个就是“稳不稳”的问题。我们的服务依赖链包括:用户请求接入 -> 报告解析服务 -> 向量知识库检索 -> 大模型API调用 -> 结果后处理。其中,大模型API(无论是自研模型服务还是第三方商用API)和向量数据库检索是最不稳定的环节,受网络、GPU负载、服务方限流等因素影响极大。
我们最初的重试策略非常“朴素”:在任何服务调用失败时,直接无间隔重试3次。结果在第一次流量高峰时就吃了大亏。
4.1 “朴素重试”如何引发雪崩
假设大模型服务因为负载过高,响应时间从平时的1秒延长到5秒,随后开始出现部分超时失败。我们的服务在收到失败后立即重试。
- 第一波用户请求(R1)超时,触发重试(R1‘)。
- 重试请求(R1’)和新的用户请求(R2)同时到达,使得大模型服务的并发请求数几乎翻倍。
- 服务负载进一步加重,响应时间变得更慢,如10秒,并产生更多失败。
- 更多失败触发更多重试(R1‘’, R2‘),形成正反馈循环。
- 短时间内,大量重试请求堆积,不仅拖垮了大模型服务,也耗尽了我们自身服务的线程池资源,导致整个服务集群不可用。这就是典型的雪崩效应。
此外,对于某些非临时性的错误(如请求参数错误、权限认证失败、模型不存在),重试是毫无意义的,只会浪费资源。
4.2 设计“智能”重试策略的核心原则
我们重新设计了重试机制,其核心思想是:区分错误类型,尊重下游状态,保护自身系统。
1. 错误分类与重试决策表我们首先对所有可能的错误响应进行分类:
| 错误类型 | 示例 | 是否重试 | 理由与策略 |
|---|---|---|---|
| 网络层错误 | 连接超时、连接拒绝、TCP重置 | 是 | 通常是临时性网络抖动,适合重试。 |
| 服务端5xx错误 | 500 Internal Server Error, 503 Service Unavailable | 谨慎重试 | 下游服务内部错误或过载。需结合熔断器状态和退避策略。 |
| 客户端4xx错误 | 400 Bad Request (参数错误), 401 Unauthorized, 429 Too Many Requests | 否 | 400/401是自身请求问题,重试无用。429是限流,应等待并可能降低请求频率。 |
| 业务逻辑错误 | 模型内容过滤触发、输入过长 | 否 | 请求本身不合规,需修正请求内容。 |
| 超时错误 | 读超时、写超时 | 是 | 可能是下游处理慢,也可能是网络问题。需配合超时时间调整。 |
2. 指数退避与随机抖动对于决定重试的请求,绝不能立即重试。我们采用指数退避策略:第一次重试等待1秒,第二次等待2秒,第三次等待4秒……以此类推,给下游服务恢复的时间。同时,在退避时间上增加一个随机抖动(如±0.5秒),避免大量失败请求在同一时刻同时重试,形成“重试风暴”。
3. 熔断器模式这是防止雪崩的终极武器。我们为每一个下游依赖(如大模型API)设置一个熔断器。它有三种状态:
- 关闭:请求正常通过,并统计失败率。
- 打开:当失败率(或慢调用率)在时间窗口内超过阈值(如50%),熔断器“跳闸”,进入打开状态。此时所有对该依赖的请求立即失败,不再尝试调用。这给了下游服务宝贵的恢复时间。
- 半开:熔断器打开一段时间(如10秒)后,进入半开状态。允许少量试探请求通过。如果这些请求成功,则认为下游已恢复,熔断器关闭;如果失败,则熔断器再次打开,并等待下一个周期。
熔断器确保了当某个下游服务不可用时,故障被隔离,不会蔓延到整个系统。我们的服务可以快速失败,并可能返回一个缓存值、默认值或友好的降级提示(如“系统正在优化,请稍后刷新”)。
4.3 实战配置示例
以下是我们使用Go语言和github.com/sony/gobreaker熔断器库,结合context超时控制的一个简化版核心逻辑:
package main import ( "context" "fmt" "math/rand" "time" "github.com/sony/gobreaker" ) // 模拟一个不稳定的下游服务调用 func callUnstableService(ctx context.Context, req string) (string, error) { // ... 模拟网络调用,可能失败或超时 // 这里简化处理,随机返回成功或失败 if rand.Intn(10) < 3 { // 30% 失败率 return "", fmt.Errorf("service unavailable") } time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond) // 随机延迟 return "response for " + req, nil } func main() { // 配置熔断器 cb := gobreaker.NewCircuitBreaker( gobreaker.Settings{ Name: "UnstableService", MaxRequests: 5, // 半开状态下允许的最大请求数 Interval: 10 * time.Second, // 关闭状态下的统计周期 Timeout: 15 * time.Second, // 打开状态后的超时时间,之后进入半开 ReadyToTrip: func(counts gobreaker.Counts) bool { // 当失败率超过50%,且总请求数大于10时,触发熔断 failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) return counts.Requests >= 10 && failureRatio >= 0.5 }, }, ) // 带有重试和退避的调用函数 callWithRetry := func(ctx context.Context, req string, maxRetries int) (string, error) { var lastErr error for i := 0; i <= maxRetries; i++ { // 通过熔断器执行调用 result, err := cb.Execute(func() (interface{}, error) { // 设置单次调用的超时 ctxWithTimeout, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() return callUnstableService(ctxWithTimeout, req) }) if err == nil { return result.(string), nil } lastErr = err // 判断错误类型,决定是否重试 (此处简化,仅模拟) // 如果是熔断器打开错误,直接退出 if err == gobreaker.ErrOpenState { return "", fmt.Errorf("circuit breaker is open: %v", err) } // 如果不是最后一次重试,则进行指数退避 if i < maxRetries { backoff := time.Duration(1<<uint(i)) * time.Second // 指数退避 jitter := time.Duration(rand.Intn(500)) * time.Millisecond // 随机抖动 select { case <-time.After(backoff + jitter): continue case <-ctx.Done(): return "", ctx.Err() } } } return "", fmt.Errorf("after %d retries, last error: %v", maxRetries, lastErr) } // 模拟调用 ctx := context.Background() for i := 0; i < 20; i++ { resp, err := callWithRetry(ctx, fmt.Sprintf("req-%d", i), 2) // 最多重试2次 if err != nil { fmt.Printf("Request %d failed: %v\n", i, err) } else { fmt.Printf("Request %d success: %s\n", i, resp) } time.Sleep(300 * time.Millisecond) } }这套组合拳实施后,服务在面对下游波动时表现得异常“坚韧”。监控图表上,以前频繁出现的毛刺和断崖式下跌变成了平滑的曲线,即使在大模型服务临时故障时,我们的服务也能通过快速失败和优雅降级,保持核心功能的可用性。
5. 监控、评估与持续迭代:将“坑”转化为护城河
解决了幻觉和重试这两个核心工程坑,并不意味着项目的结束。恰恰相反,这只是一个可靠AI系统运行的起点。要让系统在线上持续稳定、可靠地运行,必须建立完善的监控、评估和迭代闭环。
5.1 可观测性:不仅监控错误,更要洞察质量
我们建立了多层次的监控仪表盘:
- 基础设施层:CPU、内存、GPU使用率,网络延迟,服务响应时间(P99, P95)。这是基础健康度。
- 业务流量层:请求量、成功率、失败类型分布(幻觉触发规则数、各类4xx/5xx错误数)。这能快速定位问题范围。
- AI质量层(核心):这是我们自定义的关键。
- 幻觉率采样:每天对1%的线上请求进行抽样,由医学背景的同事进行人工复核,计算“事实性错误”的比例。这个指标是核心生命线。
- 输出稳定性:对于同一份标准测试报告,每天定时跑一次,对比AI输出关键结论(如风险等级)的一致性。
- 用户反馈收集:在报告页面设置“反馈”按钮(“内容有误”、“建议不准确”等),将用户直接反馈的问题作为最高优先级的优化样本。
5.2 A/B测试与渐进式发布
任何针对抗幻觉策略或模型版本的更新,都绝不直接全量上线。我们采用严格的A/B测试流程:
- 小流量实验:将新策略或新模型部署在实验组,仅对5%的流量生效,与对照组(旧策略)进行对比。核心对比指标不仅是幻觉率,还包括用户停留时间、反馈好评率、后续付费咨询转化率等业务指标。
- 逐步放量:如果实验组在核心指标上显著优于对照组,且未发现新的严重问题,则逐步将流量比例提升至20%、50%,最后全量。每一步都观察至少一个完整的业务周期(如24小时)。
- 快速回滚机制:所有发布都具备一键快速回滚的能力。监控到任何关键指标(如幻觉率、错误率)的异常飙升,都能在分钟级内回退到上一个稳定版本。
5.3 数据飞轮:用线上数据反哺模型
线上系统运行中产生的数据是最宝贵的资产。我们构建了一个数据闭环:
- 自动收集:所有被规则校验器拦截的、被人工复核修改的、以及用户主动反馈错误的案例,都会自动进入“问题案例库”。
- 分析归因:定期(如每周)分析案例库,对幻觉问题进行归因。是某个指标的知识片段缺失?是Prompt在某个边界场景下指令不清?还是模型对某种表述有固有偏见?
- 定向优化:根据归因结果,进行精准优化。例如,补充特定指标的知识片段;修改Prompt中关于边界值的描述;或者从问题案例中构造高质量的“负样本”(即输入数据+正确输出+模型之前的错误输出),用于模型的对抗性训练或强化学习微调,让模型学会“避开”这些错误。
这个闭环使得我们的系统不再是静态的,而是一个能够从错误中学习、不断进化的有机体。最初让我们头疼的“坑”,反而成了我们迭代和提升的燃料。
6. 总结与反思:AI工程化是“脏活累活”,也是价值所在
回顾从“AI幻觉”到“重试策略”这一路的踩坑与填坑,我最大的体会是:将AI能力转化为稳定、可靠、可信的产品,其难度和复杂度远超模型研发本身。算法工程师追求的是指标上的几个百分点提升,而AI工程师或算法产品工程师,需要面对的是整个系统工程的海量细节。
关于幻觉:它不是一个能一劳永逸解决的算法问题,而是一个需要持续管理的系统性风险。我们的三层过滤网方案,本质上是将“完全信任模型”转变为“有限信任,多重校验”。在严肃应用中,对AI的信任必须通过严谨的工程约束来建立,而不是单纯依靠模型的“能力”。
关于稳定性:重试、熔断、降级、限流,这些并不是什么新技术,而是传统分布式系统领域的成熟模式。但在AI系统中,由于下游依赖(大模型服务)的不可控性更高、成本更昂贵,这些模式的应用需要更加精细和审慎。一个健壮的AI服务,其韧性往往不体现在模型有多聪明,而体现在这些“脏活累活”做得有多扎实。
这个项目上线大半年后,幻觉率维持在极低的水平,服务可用性达到了99.95%以上。更重要的是,我们建立了一套应对不确定性、持续监控和快速迭代的工程方法论。这套方法论的价值,已经超越了体检报告解读这个具体场景,成为我们团队处理其他AI落地项目的标准流程。AI工程化的道路没有捷径,每一个坑都值得深挖,因为填平它的过程,就是在构筑你产品最坚实的护城河。