ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

《宁可端出原文,也不让它编:RAG 生成层的拒答与降级设计》

《宁可端出原文,也不让它编:RAG 生成层的拒答与降级设计》

幻觉事故现场

用户问了一个知识库里根本没有的问题,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, }
  • 为什么:出口会越加越多,分散手抄容易漏字段;“字段永远齐全”让前端永远不用判空

坑 1:refused 判定必须在补截断提示之前——提示文字会污染话术匹配,先判后改

坑 2:推理模型的“思考过程”和正文共用 max_tokens 预算——思考写完正文没开始

本次落地的三个决定

1. 检索为空 → 直接拒答,不进 LLM
2. LLM 异常 / 空答案 → 降级返回原文 + 来源
3. 所有出口统一返回 9 字段宽契约,前端永不判空

https://gitee.com/you-fuming/elec--rag

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进