幻觉事故现场用户问了一个知识库里根本没有的问题RAG 系统沉默一会然后流利地编出了一份“电气规程”——引用格式标准、条理清晰、全是假的排查之后发现问题不在模型在我自己的代码里检索一条都没召回时我的answer()依然把空上下文喂给了 LLM然后祈祷它别乱说修复方式说出来很简单——加一个if not results提前返回但顺着这一个 if 挖下去是一整套“生成层失败路径”的设计问题LLM 挂了怎么办答案为空怎么办答案被截断一半怎么办这篇文章把这条链一次讲清拒答为什么拦在检索层而不是让 LLM 自己认怂方案if not results: return 拒答根本不调 LLM为什么没有上下文时 LLM 的“我不知道”不可信它有编造倾向检索层拦截零成本、零风险LLM 挂了返回什么——降级而不是报错方案try/except 接住 LLMError把检索到的原文端给用户为什么原文是真的、可溯源的宁可端出“未加工的食材”不让系统编“精致的假菜”同时避免一个 500 把整个问答页面打挂多个失败出口返回结构怎么管——统一出口 宽契约先定义一个统一的出口def _degrade(reason: str, answer: str, results: List[Any], started: float, timing: Optional[_Timing] None) - Dict[str, Any]: 生成层的统一降级出口 为什么单独抽出来降级出口现在有三个LLM 故障 / 答案为空 如果每个地方各抄一遍返回字典将来加字段比如 truncated一定会漏掉某个分支。 Args: reason: 降级原因llm_error / empty_answer前端和评测按它做统计 answer: 降级后展示给用户的文本一律是检索到的原文片段 results: 检索结果用来生成来源列表 —— 降级时也必须带来源 started: answer() 起始时间戳用来算 elapsed_ms timing: 分段计时器可选。降级往往正是慢的结果 更需要知道时间花在哪所以这里也要带上。 return { answer: answer,#降级后展示给用户的文本一律是检索到的原文片段 refused: False,#是否拒答 degraded: True,#是否降级 reason: reason,#降级原因llm_error / empty_answer前端和评测按它做统计 truncated: False, # 降级返回的是原文整段不存在被截断的答案 sources: _sources(results),#来源列表前端可点击溯源 context_chunks: len(results),#实际喂给 LLM 的条数 elapsed_ms: round((time.perf_counter() - started) * 1000, 2),#总耗时 timing: timing.as_dict() if timing is not None else None, }为什么出口会越加越多分散手抄容易漏字段“字段永远齐全”让前端永远不用判空坑 1refused 判定必须在补截断提示之前——提示文字会污染话术匹配先判后改坑 2推理模型的“思考过程”和正文共用 max_tokens 预算——思考写完正文没开始本次落地的三个决定1. 检索为空 → 直接拒答不进 LLM2. LLM 异常 / 空答案 → 降级返回原文 来源3. 所有出口统一返回 9 字段宽契约前端永不判空https://gitee.com/you-fuming/elec--rag