让大模型学会自我检查!Python实现AI回答质量评估、错误识别与自动重试机制
一、引言单次调用的大模型本质上是一个不可靠函数把 LLM 接进生产系统的人迟早都会撞上同一堵墙模型的单次输出是不可信的。它可能在 JSON 里塞一段 Markdown 代码块让下游的json.loads当场爆炸可能自信地编造一个不存在的 API 参数可能在你要它只回答三个要点时洋洋洒洒写了一千字也可能在你明确给了参考资料的情况下仍然把资料里没有的数字写进结论。这些问题在 Demo 阶段是偶尔抽风在生产阶段就是 P0 故障。传统的工程解法是重试——失败就再调一次。但对大模型来说盲重试是最糟糕的策略之一温度不变、提示词不变模型大概率会以极高的相似度复现同一个错误。你付出了三倍 token 成本换来三个一样的坏答案。真正有效的做法是把生成从一次性动作改造成一个闭环控制系统生成 → 评估 → 诊断 → 带反馈重试。这篇文章会拆解这套机制的核心原理并用 Python 给出一份可直接落地的实现。二、核心原理把生成变成闭环控制2.1 三个必须解耦的组件一个自检系统的最小闭环由三部分组成Evaluator评估器回答这个答案好不好输出可比较的分数与通过判定Diagnoser诊断器回答哪里不好、为什么不好输出结构化的错误类型与定位信息Retry Policy重试策略回答接下来怎么改决定是否重试、如何修改提示词、用什么参数。工程上最常见的失败是把这三者揉进一个你检查一下有没有问题的提示词里。结果模型回一句回答基本正确可以进一步优化信息量约等于零既无法驱动重试也无法做监控告警。为什么一定要解耦因为这三者的输入输出契约完全不同。评估器的输出要能横向比较不同 case、不同时间点的分数得可比所以它必须是数值稳定的诊断器的输出要能被人和程序同时消费所以它必须是结构化的枚举与定位重试策略是纯程序逻辑不该再依赖模型判断——一旦让模型来决定要不要重试你就把控制流的确定性也交了出去故障时根本无法复盘是模型判断错了还是策略本身错了。解耦之后任何一环出问题都能被单独定位、单独替换、单独测试。2.2 评估信号有三个层次成本差一个数量级层次手段成本适用场景L1 程序化校验JSON Schema、正则、长度、引用存在性、代码能否执行≈0强约束场景能拦掉 60% 的低级错误L2 模型自评LLM-as-a-Judge带 Rubric 打分1 次额外调用语义质量、指令遵循、事实性L3 参考答案与标准答案做语义/事实比对需要标注数据离线评测、回归测试关键原则是能用 L1 解决的绝不交给 L2。JSON 解析失败这种事json.loads一毫秒就能判定让模型去评分纯属浪费钱而且模型对格式错误的判定远不如解析器可靠。这里还有一条容易被忽略的工程细节L1 和 L2 的评估结果需要合并而不是二选一。正确的顺序是先 L1 短路L2 再补语义即 L1 命中致命错误时直接返回判决、不再调用评估模型L1 通过后L2 只负责语义层面的质量打分。这样既省钱又避免了两套信号互相打架——比如 L1 认为 JSON 合法、L2 却因为内容质量差给出低分两者在语义上并不矛盾可以并存。另外L1 的检查项应当尽量穷举可机械验证的约束而不是只盯着 JSON。常见的 L1 检查包括长度区间、是否包含禁用词、是否使用了指定语言、引用的文档 ID 是否真实存在于检索结果里、生成的代码能否通过compile()、正则能否匹配出要求的字段。这些检查加起来往往只有几十行代码却能把下游的脏数据挡掉一大半。2.3 反馈必须可执行错误分类法比分数更重要一个 0.3 分的差值对修复毫无指导意义。真正驱动重试的是错误类型Error Type。实践中可收敛为六类FORMAT结构化输出不合规最常见也最好修INSTRUCTION违反显式指令语言、长度、角色、禁用词HALLUCINATION无据可依的具体断言、数字、引用INCOMPLETE漏答子问题、覆盖不全REASONING推理链断裂或自相矛盾REFUSAL过度拒答。有了分类重试策略就能分化FORMAT直接把解析器的报错原文回灌给模型HALLUCINATION要求模型删除所有参考资料中未出现的具体数字或明确标注为推测REFUSAL则应该软化提示词而不是加热温度。为什么错误分类比分数更重要因为分数只回答要不要重试分类才回答怎么重试。如果只有分数策略层能做的唯一动作就是再试一次那又退回到了盲重试。而一旦有了分类每一类都能对应一句明确的、写死的、经过验证的修复指令——修复指令是工程资产是可以在离线集上反复打磨的而分数不是。还有一条经验错误分类的粒度要克制。分类太粗比如只有好/坏无法驱动策略分类太细比如把 FORMAT 拆成括号缺失、引号缺失、逗号多余等十几类则会让评估器本身变得不稳定模型经常在相似类别间摇摆。六到八类是实践中的甜点区既能覆盖绝大多数场景又能让评估器的判定保持较高一致性。2.4 重试不是重复而是策略升级我推荐的重试阶梯是第 1 次原始提示词temperature0追求确定性第 2 次注入上一轮的结构化批评Critique Revise温度微升至 0.3第 3 次切换提示词策略如强制 CoT、强制先列要点再展开、拆分任务温度 0.7。同时必须配预算控制最大尝试次数、总 token 上限、超时熔断。没有预算约束的自检系统会在长尾 case 上把成本放大到失控。这个阶梯背后的逻辑是逐级引入更多的随机性与更强的约束。第 1 次温度最低是为了让能一次答对的 case稳定答对避免无谓波动第 2 次注入批评、轻微升温是因为多数错误靠知道自己错在哪就能修好升温只是为了跳出上一轮的死循环第 3 次才动用重武器——换提示词骨架、拆任务、强制推理链同时把温度拉高以探索和之前完全不同的输出分布。如果第 3 次还是失败正确做法往往是放弃并且兜底而不是无限加码。还有一个隐藏的坑重试的对话历史会迅速膨胀。每轮都追加上一轮的答案 批评到第三轮上下文里可能塞了五六份完整答案token 成本翻倍不说还会分散模型注意力。正确的做法是只保留最近一轮的批评摘要fix_hint把历史轮次的完整答案丢弃。三、实战用 Python 搭一个自检重试引擎下面用一个带参考资料问答 强制 JSON 输出的任务作为例子完整实现评估器与重试引擎。3.1 第一步定义结构化评估结果from__future__importannotationsfromenumimportEnumfrompydanticimportBaseModel,FieldimportjsonclassErrorType(str,Enum):FORMATformatINSTRUCTIONinstructionHALLUCINATIONhallucinationINCOMPLETEincompleteREASONINGreasoningREFUSALrefusalclassVerdict(BaseModel):score:floatField(ge0,le10,description整体质量分)passed:boolerror_types:list[ErrorType]Field(default_factorylist)issues:list[str]Field(default_factorylist,description具体、可定位的问题每条不超过 50 字禁止空泛评价,)fix_hint:strField(default,description给下一轮重试的一句话修正指令)defprogrammatic_checks(answer:str,expect_json:boolTrue)-Verdict|None:L1 层确定性校验。命中则短路不调用评估模型。ifexpect_json:textanswer.strip()iftext.startswith():# 模型最爱的 Markdown 包裹texttext.strip().split(\n,1)[-1].rsplit(,1)[0]try:json.loads(text)exceptjson.JSONDecodeErrorase:returnVerdict(score0.0,passedFalse,error_types[ErrorType.FORMAT],issues[fJSON 解析失败{e.msg}(line{e.lineno}col{e.colno})],fix_hint只输出单个合法 JSON 对象不要 Markdown 代码块、不要任何解释文字。,)iflen(answer.strip())10:returnVerdict(score1.0,passedFalse,error_types[ErrorType.INCOMPLETE],issues[答案长度异常疑似被截断],fix_hint完整回答问题不要中途停止。,)returnNone# 无需短路交给 L2这段代码的价值在于成本分层绝大多数格式类错误在本地一毫秒内被拦截并且json解析器给出的line/col信息比任何模型评价都更精确、更适合回灌给模型做修复。注意Verdict用 Pydantic 定义而不是裸dict。这带来两个实际好处一是字段类型约束score被限制在 010能在解析阶段就拦住评估器自己的胡说八道二是model_validate_json抛出的异常是明确的、可被try/except捕获的避免了评估结果格式不对导致主流程崩溃这种低级但高频的事故。另外programmatic_checks里那段剥 Markdown 的代码看似随便其实很关键模型包不包代码块的概率相当高如果每次都对带 的输出判 FORMAT 失败会造成大量无意义的重试。在能宽容的地方宽容在必须严格的地方严格是 L1 校验的设计准则——剥壳属于宽容而剥壳后仍不合规则属于严格。3.2 第二步带 Rubric 的 LLM 评估器importosfromopenaiimportAsyncOpenAI clientAsyncOpenAI(base_urlos.getenv(LLM_BASE_URL),api_keyos.getenv(LLM_API_KEY))JUDGE_MODELos.getenv(JUDGE_MODEL,gpt-4o-mini)RUBRIC你是严格的答案质检员请依据以下量规评估【待检答案】 1. 事实性(0-3)是否存在参考资料中不存在的具体断言、数字、引用 2. 指令遵循(0-3)是否满足用户显式要求格式、语言、长度、角色 3. 完整性(0-2)是否覆盖问题的全部子问题 4. 自洽性(0-2)推理是否连贯、有无前后矛盾。 判定规则score 8 且无致命问题事实编造 / 格式非法 / 拒答时 passed 为 true。 issues 必须具体到可修改的位置禁止输出不够好可以优化这类空泛评价。 asyncdefjudge(question:str,answer:str,context:str)-Verdict:respawaitclient.chat.completions.create(modelJUDGE_MODEL,temperature0,# 评估必须稳定绝不能采样messages[{role:system,content:RUBRIC},{role:user,content:(f# 用户问题\n{question}\n\nf# 参考资料\n{contextor无}\n\nf# 待检答案\n{answer})},],response_format{type:json_object},)rawresp.choices[0].message.contentor{}try:returnVerdict.model_validate_json(raw)exceptException:# 评估器本身也会出错必须兜底绝不能让它把主流程拖死returnVerdict(score5.0,passedFalse,error_types[ErrorType.REASONING],issues[评估器输出不可解析降级为保守判定],fix_hint保持与上一轮相同的输出结构重新作答。,)这里有三处值得展开的细节。第一temperature0是评估器的硬性要求。很多人会顺手沿用生成时的温度结果同一个答案被评估两次得到 7 分和 9 分阈值判断随之抖动系统变得神经质。评估是判断任务而非创作任务必须尽量消掉采样随机性。第二response_format{type: json_object}只是尽力而为不是保证。不同厂商、不同模型对结构化输出的支持程度差异很大有的会返回带 Markdown 的 JSON有的会把分数写成8分。所以try/except兜底不是多此一举而是必需品。兜底时选择passedFalse是保守策略宁可多试一次也不要放过一个未经确认的答案。第三Rubric 的分项权重是有意设计的。事实性 3 分 指令遵循 3 分占了满分 10 分的六成是因为这两项直接对应生产事故完整性和自洽性各 2 分属于体验问题。如果你的场景里格式合规是生死线那就该把 FORMAT 提到致命问题里让它的权重凌驾于分数之上——分数只是兜底致命项才是一票否决。3.3 第三步重试策略——把错误类型翻译成修复指令# 每一类错误对应一句经过验证的修复指令。它是工程资产不要随手写。REPAIR_INSTRUCTIONS:dict[ErrorType,str]{ErrorType.FORMAT:(上一轮输出不是合法 JSON。请只输出一个 JSON 对象不要 Markdown 代码块不要前后缀说明文字。),ErrorType.INSTRUCTION:(上一轮违反了显式指令。请重新检查用户对格式、语言、长度、角色的全部要求逐条对照后再作答。),ErrorType.HALLUCINATION:(上一轮包含参考资料中不存在的具体断言或数字。请删除所有无据可依的具体数字或明确标注为「推测」并在每个事实性结论后用 [n] 标注引用来源编号。),ErrorType.INCOMPLETE:(上一轮漏答了部分子问题。请先把用户问题拆成子问题清单逐条作答确保无遗漏。),ErrorType.REASONING:(上一轮推理存在断裂或前后矛盾。请先写出推理步骤再给出结论确保每一步都能由前一步推出。),ErrorType.REFUSAL:(该请求合法且资料充足不应拒绝。请基于已有参考资料直接作答对于确实无法确定的部分明确说明「资料未涵盖」。),}# 阶梯式重试配置(温度, 是否注入批评)RETRY_LADDER:list[tuple[float,bool]][(0.0,False),# 第 1 次原始提示追求确定性(0.3,True),# 第 2 次注入批评Critique Revise(0.7,True),# 第 3 次升温探索同时叠加修复指令]defbuild_retry_suffix(verdict:Verdict,attempt:int)-str:把上一轮的结构化批评翻译成下一轮提示词尾部的修复指令。ifnotverdict.error_typesandnotverdict.fix_hint:returnlines[,---,## 上一轮回答的质检反馈必须修复]foretindict.fromkeys(verdict.error_types):# 去重且保持顺序lines.append(f- [{et.value}]{REPAIR_INSTRUCTIONS[et]})ifverdict.fix_hint:lines.append(f- 额外要求{verdict.fix_hint})ifattempt3:lines.append(- 本轮请改变作答方式先列出回答要点提纲再逐条展开最后仅输出要求的 JSON。)return\n.join(lines)dict.fromkeys那行是个小技巧评估器可能同时报出FORMAT和HALLUCINATION去重后既能避免重复指令稀释注意力又能保持稳定的顺序方便日志比对。3.4 第四步闭环引擎与预算熔断importasyncioimporttimefromdataclassesimportdataclassdataclassclassBudget:max_attempts:int3max_total_tokens:int20000timeout_s:float90.0used_tokens:int0defconsume(self,n:int)-None:self.used_tokensndefexhausted(self)-bool:returnself.used_tokensself.max_total_tokensclassSelfCheckEngine:生成 → 评估 → 诊断 → 带反馈重试 的闭环。def__init__(self,generate,budget:Budget|NoneNone):self.generategenerate# async (prompt, temperature) - (text, tokens)self.budgetbudgetorBudget()asyncdefrun(self,question:str,context:str,base_prompt:str)-tuple[str,Verdict]:deadlinetime.monotonic()self.budget.timeout_s last_answer,last_verdict,Verdict(score0.0,passedFalse)forattempt,(temp,use_critique)inenumerate(RETRY_LADDER,start1):ifattemptself.budget.max_attemptsorself.budget.exhausted():breakiftime.monotonic()deadline:# 超时熔断breaksuffixbuild_retry_suffix(last_verdict,attempt)ifuse_critiqueelseprompt(f{base_prompt}\n\n# 用户问题\n{question}\n\nf# 参考资料\n{contextor无}{suffix})text,tokensawaitself.generate(prompt,temp)self.budget.consume(tokens)last_answertext# L1 优先短路命中就不调用评估模型verdictprogrammatic_checks(text,expect_jsonTrue)ifverdictisNone:verdictawaitjudge(question,text,context)last_verdictverdictifverdict.passed:returntext,verdict# 预算耗尽返回当前最好结果并由调用方决定是否降级returnlast_answer,last_verdict调用侧的用法asyncdefmain():engineSelfCheckEngine(generatemy_llm_call,budgetBudget(max_attempts3))answer,verdictawaitengine.run(question这款相机的续航是多少请以 JSON 返回。,context产品手册电池容量 5200mAh连续拍摄约 4 小时。,base_prompt你是客服助手只依据参考资料作答输出单个 JSON 对象。,)print(passed ,verdict.passed, score ,verdict.score)print(answer)整个引擎只有几十行却同时具备了格式错误的本地拦截、语义质量的模型评估、可执行的反饋回灌、阶梯式重试以及 token 与超时的双重熔断。四、常见问题 FAQQ1评估器自己会不会出错要不要再给评估器加一个评估器会但不要无限套娃。评估器的错误率远低于生成器再叠一层评估成本翻倍而收益递减。正确做法是给评估器加确定性约束结构化输出、温度 0、分项打分并在离线集上统计它与人工标注的一致率。一旦一致率低于阈值就去改 Rubric而不是再加一层。Q2score阈值定多少合适不要拍脑袋定 8。正确做法是收集 50100 条带人工标注的样本画出分数分布找到能同时满足漏检率低和重试率可接受的切点。经验上多数场景在 78 之间但格式合规这类硬约束应该跳过分数、直接一票否决。Q3为什么评估要用另一个模型而不是让生成模型自评自评存在明显的自我偏好偏差——模型倾向于认为自己的输出是对的。实践中更稳的组合是生成用快模型、评估用强模型或者至少用不同的系统提示词与采样配置制造一点视角差异。如果成本受限必须同模型自评那一定要把温度设为 0 并把评估提示词写得极其严格。Q4重试几次是极限三次。第一次追求确定性第二次注入批评第三次换策略。第四次以后的边际收益极低更多时候是在烧钱。超过三次仍失败说明问题不在生成而在于任务本身表述不清或资料不足——这时应该走兜底话术或人工介入而不是继续重试。Q5评估会显著拖慢响应吗会串行评估大约让延迟增加 30%80%。优化手段有三一是尽量让 L1 命中零成本二是把 L1 与 L2 的调用并行化L1 结果出来前的这段时间可以并发发起 L2三是对低风险场景抽样评估比如只评估 20% 的请求而不是全量。Q6异步并发下Budget会不会被多个请求串用会这也是常见 bug。Budget必须每个请求实例化一个绝不能挂在全局。如果需要做全局配额比如整站每分钟 token 上限那要另建一个信号量或令牌桶和单次请求的Budget分开管理。五、踩坑与优化建议坑 1把评估结果直接透传给用户。issues是给系统和开发看的内部语言可能包含疑似被截断评估器输出不可解析这类让人困惑的信息。对外必须做一层翻译只暴露温和的重试提示或直接返回兜底话术。坑 2忽略评估调用本身的 token 消耗。评估动辄几十上百 token是看不见的成本。建议把生成 token 与评估 token 分开打点否则你会在账单上看到生成成本正常总成本翻倍的诡异现象。坑 3response_format一开就以为万事大吉。不同厂商对结构化输出的支持差异巨大有的要求提示词里同时出现 “JSON” 字样有的在长上下文下会偷偷退化成普通文本。务必保留解析失败的兜底路径。坑 4修复指令写得像人话但不可执行。“请更准确一些”注意质量这类指令对模型几乎无效。有效的修复指令必须满足三点指向明确哪一句、哪个字段、动作明确删除/补充/改写、验收明确改完长什么样。坑 5没有记录失败样本。每一次passedFalse都是一条宝贵的训练与调优数据。建议把question / answer / verdict / attempt全量落库定期分析错误分布——你会发现 80% 的失败集中在两三类错误上修好那两三类整体通过率立刻上一个台阶。优化建议 1给致命错误单独设一票否决。无论分数多高只要出现HALLUCINATION或未标注的引用直接判不通过。分数是软约束致命项是硬约束。优化建议 2把高频修复指令前置到系统提示里。如果某类错误反复出现说明它不是偶发失误而是提示词缺陷。这时应该把修复指令从重试后缀提升为原始系统提示的一部分从源头上减少重试。优化建议 3监控重试率而非只看准确率。重试率突然升高往往意味着上游检索质量下降、上游 promopt 变更或模型版本更新。它是最灵敏的异常信号之一。优化建议 4给评估器做小样本回归测试。准备一组固定答案与期望判定每次修改 Rubric 后跑一遍确保没有把原本能通过的 case 判成失败。这条实践能帮你避免为了修 A 类错误而引入 B 类误伤。六、小结单次调用的大模型是不可靠函数但可靠不是靠换更强的模型实现的而是靠把生成包进一个闭环用 L1 拦掉机械错误用 L2 判断语义质量用错误分类驱动差异化重试用预算熔断守住成本。这套机制的核心洞见只有一句话反馈必须是结构化、可分类、可执行的。更多硬核网安与AI工具包请扫码获取完整源码当你把一句回答得不太好替换成[hallucination]删除参考资料中不存在的数字并标注引用来源你就把一次祈祷式的重试变成了一次有方向的修复。了修 A 类错误而引入 B 类误伤。六、小结单次调用的大模型是不可靠函数但可靠不是靠换更强的模型实现的而是靠把生成包进一个闭环用 L1 拦掉机械错误用 L2 判断语义质量用错误分类驱动差异化重试用预算熔断守住成本。这套机制的核心洞见只有一句话反馈必须是结构化、可分类、可执行的。[CSDN_IMG_FOOTER][CSDN_IMG_FOOTER][CSDN_IMG_FOOTER][CSDN_IMG_FOOTER]更多硬核网安与AI工具包请扫码获取完整源码当你把一句回答得不太好替换成[hallucination]删除参考资料中不存在的数字并标注引用来源你就把一次祈祷式的重试变成了一次有方向的修复。