
AI 说错了怎么办——给生成性 Agent 装上 Self-RAG 自审循环Agent 早就跑通了但有一条横切线一直没单独写过深度阅读那种动辄一千多字的输出怎么知道 LLM 是不是在自圆其说。这周回过头来补这一篇顺便把本周做的几个小改动一并挂进去——它们各自不大但都跟让 AI 自己审 AI沾边。起因深度阅读 的伪批判SP5 早期跑 deep_read 的批判分析阶段时碰到一个让人挠头的现象。模型输出像这样该工作引入了对角编码Diagonal Encoding显著降低了 KeySwitching 操作次数但在表 3 的实验里BERT-large 的精度只下降了 0.3%远低于 NEXUS 的 1.2%……听起来很有理有据。但回去 PDF 里翻表 3根本没有 NEXUS 的对照行。“BERT-large 精度下降 0.3%” 这个数也对不上。模型在编。它不是胡说八道——它是把读到的几个事实揉到一起往中间塞一些看起来合理的数字。这是大模型的经典短板。在 chat 那种短回答里好查用户自己看一眼就知道但是 deep_read 一次输出 1500-2000 字用户大概率不会逐句对原文错就这么被吃进了用户的笔记里。Self-RAG 那篇 2023 的论文给的思路是事后检索让模型生成时把每一条主张拆出来做 atomic claim事后回到原文里查每一条是否有支持。我们做了个简化版——按句子拆而不是按 claim 拆理由下面讲。一、为什么按句子拆而不是按 claim 拆最初想法是让另一个 LLM 把 deep_read 的输出拆成 “断言列表”每个断言一个 JSON 对象附带需要什么证据字段。听起来很 Self-RAG 原教旨。试了一次之后放弃了。拆 claim 这一步本身就是个不可控的 LLM 调用拆得太碎一段话 30 条 claim检索证据 30 次太慢拆得太粗一段话 3 条 claim每条都包含 2-3 个事实验证粒度变成了整段对不对JSON 输出经常带 markdown 围栏、解释、漏字段又得加一层兜底而且拆 claim 引入了两层 LLM 不确定性一层拆一层验。第二层错的可能性只会被第一层放大。最后改成按句子拆简单到只用一个正则_SENTENCE_BOUNDARY_REre.compile(r(?[。.!?])\s*)defsplit_sentences(text:str)-list[str]:parts_SENTENCE_BOUNDARY_RE.split(text)return[s.strip()forsinpartsifs.strip()]中英文句末标点都覆盖到了零宽位置切前置 lookbehind不丢句末标点。一段 1500 字的批判一般拆出 30-50 个句子规模可控。代价是丢了一些 claim 跨句子的情况——比如 “X 把 Y 降到了 Z” 这种主张如果被拆成 “X 引入了 Y。Z 比之前低了 30%。” 两句每句单独看都基本合理但合起来才是错的。不过实测里这种情况很少trade-off 我们接受。二、检索原文当证据拿到 N 个待验证句子之后要去原文里找证据。直接把整段论文塞进 prompt 显然不行context 装不下所以走 RAG 检索。这一步本来想直接复用 SP9 的多路召回week4 写过 cosine BM25 union但有个细节要改用户提的查询通常是问题“这篇论文用了什么方法”而我们要验证的句子是陈述句“这篇论文用了对角编码”。chunk 边界、术语词频都不一样召回效果差不少。简单办法把句子按 4 句一组打包联合查询_BATCH_SIZE4forbatch_startinrange(0,total,_BATCH_SIZE):batchsentences[batch_start:batch_start_BATCH_SIZE]batch_text .join(batch)ifrag_serviceanddoc_id:resultsmulti_search(batch_text,doc_id,rag_service,top_k_per_path3)evidence_texts[r[text]forrinresults[:3]]ifnotevidence_textsandpaper_text:evidence_texts[paper_text[:3000]]else:evidence_texts[paper_text[:3000]]ifpaper_textelse[]4 句一起查的好处术语覆盖更密BM25 召回的 chunk 更命中坏处跨句的伪相关也可能被错召回当证据。实测 4 是个甜点——再大召回噪声明显增加再小比如 2经常召回到不相关的元数据块。兜底链multi_recall → 失败 → 用 paper_text 前 3000 字 → 还没有 → 空证据数组。空证据时 prompt 里写 “未找到相关原文”让 LLM 直接给 NO。这条 fallback 在 RAG 服务没起来时比如新机器还没加载 BGE 模型也能让流程跑完不会把整条 graph 卡死。三、让 LLM 给 YES/NO不要解释prompt 简短到出乎意料你是一个学术事实核查助手。请逐条判断以下断言是否有提供的原文证据直接支持。 ## 断言列表 [1] DiagFuse 引入了对角编码降低 KeySwitching 操作次数。 [2] BERT-large 上精度下降 0.3%。 [3] 远低于 NEXUS 的 1.2%。 ## 原文证据 前面 multi_recall 拿到的 3 段 chunk ## 要求 对每条断言回答一行格式为: [编号] YES 或 NO 仅回答编号和YES/NO不要解释。“不要解释” 这句话非常重要。早期版本没写模型每一条都加一段该断言可由原文 ‘XXX’ 支持因此 YES看起来认真但每次 LLM 多花两百个 token整篇 deep_read 验证完算下来多花十几次的钱。让 LLM 给最少必要输出是 prompt 工程里很容易被忽略的省钱手段。解析也极简def_parse_verification_response(raw:str,batch_size:int)-list[bool]:results:list[bool][]forlineinraw.strip().splitlines():upperline.upper().strip()ifYESinupper:results.append(True)elifNOinupper:results.append(False)whilelen(results)batch_size:results.append(True)# 模型偷懒少回了几条缺省视为通过returnresults[:batch_size]不强求[1] YES这种严格格式只看 “YES” 或 “NO” 关键词行内有就算。这是兜着 LLM 会乱写格式的一手。少回的句子默认True——给模型 benefit of doubt不把模型沉默算成幻觉。实测 LLM 验证输出DeepSeek 在一次 deep_read 后的 verify 调用里返回的[1] YES / [2] NO / ...形式响应演示 prompt不要解释那句话起作用——回答干净没有冗余流程图critical 和 synthesis 两个生成节点共享同一个 hallucination_check 出口。检测内部三步——按句切、multi_recall 取证据、LLM 逐句给 YES/NO——汇到决策菱形retry 回边接回 critical最多两次否则强制 pass 并把 flagged 加[待验证]标记。四、回退循环 上限保护按句子拿到 YES/NO list 之后算个flagged_ratioflagged_ratiolen(flagged_sentences)/totaliftotal0else0.0passedflagged_ratio0.3阈值 0.3 是试出来的。低了比如 0.1几乎每次都触发回退用户体验劝退高了0.5等于没做。0.3 的实际含义是一段话里超过三分之一的句子没在原文里找到证据就值得重新生成。不通过怎么办——回退到生成节点critical / synthesis重跑。LangGraph 的条件边就是干这个的defcheck_hallucination_result(state):hcstate.get(hallucination_check,{})returnpassifhc.get(passed,True)elseretrygraph.add_conditional_edges(hallucination_check,_hallucination_route_unified,{retry:critical,# 幻觉过多 → 重跑 criticalquestioner:questioner,# deep_read 通过 → 追问end:END,},)注意几个细节iteration_count上限保护。没这个保护理论上能死循环——模型重写出来还是过不去又回退又重写……我们设max_iterations 2到达上限后强制 passnew_iterationiteration_count1ifnotpassedandnew_iterationmax_iterations:passedTrue这是 LangGraph 编排里的硬规矩任何条件回边都必须有终止条件否则一个 prompt 写得不好的 corner case 就能把后端打挂。强制 pass 的时候要给用户留痕迹。标记可疑句子不删加[待验证]前缀def_mark_flagged(text,flagged_sentences):resulttextforsentinflagged_sentences:resultresult.replace(sent,f[待验证]{sent},1)returnresult这是个产品决策宁可输出有瑕疵的内容并明确标出也不要让用户看不到。如果直接删可疑句子输出会变得断断续续如果不标记用户会以为整段都靠谱。[待验证]是个明确的契约——“这一句我自己也没把握”。前端拿到这种带[待验证]的输出后理论上还能渲染成带样式的提示框但是这部分没做下周做答辩材料再补。五、跟 source_verifier 的边界week3 写过 source_verifier跟这周的 hallucination_check 听上去都是验真容易混淆。区别清楚source_verifier (SP11)hallucination_check验什么[12]这种显式引用标记是否对应真实文献LLM 输出里隐式断言是否有原文支持用什么验Semantic Scholar APIRAG 检索 LLM错了怎么办在引用旁打 ⚠️ 标记触发回退重写循环频次每个 [N] 引用一次 S2 调用每 4 句一次 LLM 一次 RAG两者配合形成两层防线——source_verifier 兜显式引用hallucination_check 兜隐式主张。LangGraph 里 source_verifier 是单独的 intent 分支用户问引用对不对才走hallucination_check 是 deep_read / critical 出口处的强制检查点。六、本周顺手做的几件小事写到这里正题大致讲完了。本周还有几个小改动跟自审主题擦边一并提一下。code_executor 之前的 stdout / exit_code 只往 chat 流里推state 里不留痕。这周加了一段把它落到accumulated_results[code_output]accumulated[code_output]{stdout:.join(stdout_parts),stderr:.join(stderr_parts),exit_code:exit_code,error:error,}意义在哪Self-RAG 检查证据的时候证据种类多了一种——代码实际跑出来的输出。比如 LLM 说我算的准确率是 0.952如果对应的 code_executor 跑过print(accuracy)输出0.952这是比原文还硬的证据。RAG 检索那条路是软证据语义相似code_output 这条路是硬证据执行结果。这一项目前还没接到 hallucination_check 的 evidence 链里——是个明显的 follow-up但接进去 prompt 又得重新调留给下周。顺便把 dockerlogs(streamTrue)换成attach(streamTrue, demuxTrue, logsTrue)。一来 stdout / stderr 终于分流了二来 WSL2 下 named pipe 偶发挂起的老问题没了attach 用底层 stdio不走 logs buffer。后台线程 queue.get(timeout1)主循环每 5 秒没输出就发一个heartbeat事件给前端免得用户盯着进度条 30 秒没反应。另一件相关的是 citation_graph_node 的意图分类升级。之前是关键词路由_OUTGOING_KEYWORDS(引用了,引用的,参考,references,cites)_INCOMING_KEYWORDS(被引,被谁,incoming,cited by,被引用)这种规则识别不出related work 都引了谁这种间接问法。改成 LLM 分类 关键词降级def_classify_intent(query,llm_client):ifllm_clientisnotNone:try:rawllm_client.generate(_INTENT_PROMPT.format(queryquery[:500]),max_tokens8,temperature0.0)labelstr(raw).strip().lower()fortokenin(outgoing,incoming,both):iftokeninlabel:returntokenexceptExceptionasexc:logger.warning(LLM 意图分类失败降级关键词路由: %s,exc)# 关键词降级 ...这跟 hallucination_check 的设计模式其实是一致的LLM 当判断器但兜底永远在。LLM 失败时走关键词LLM 死循环时max_iterationsLLM 解析不出 YES/NO 时缺省 True。Agent 系统里 LLM 在哪都不能是单点。还有 chart_extractor 这周改成优先读 mineru 持久化的 figures——上传 PDF 后后台跑 mineru 抽图meta.json 里多了figures字段节点开门先看这个字段没有再 fallback 到 PDF 现场抽图路径 C 整页渲染。“有真证据就用没有就降级”——又是同一抽象。零零碎碎的 polish登录失败 5 次锁 10 分钟内存计数器、Argon2id 旁路 bcrypthash 前缀自动分派、Alembic baseline 配置不强制走 alembic留作下次 schema 变更的入口、docker-compose 把上周已经从 yml 清掉的 11 个 Dify 服务的容器实例也物理删了写了个幂等清理脚本。这些是为公开 demo 准备的防御性补强单独都不够撑一段。七、踩过的坑iteration_count这个状态字段一开始忘了在 initial state 里显式置 0结果.get(iteration_count, 0)拿到的是 NoneLangGraph state 用 TypedDict缺失的 key 不会触发 .get 的默认值跟整数 max_iterations 比较直接 TypeError。第一次复现时报错信息卡在 LangGraph 内部traceback 二十多层都是框架代码我以为是 langgraph 自己挂了查了二十分钟才发现是 None 比较。在 build_graph_v2 的 initial state 里加一行iteration_count: 0解决。flagged_ratio在total 0时直接 ZeroDivisionError——deep_read 偶尔会输出空文本prompt 里塞的 context 太长触发了 token 限制整篇切句切出来是 0 条。加一行if total 0 else 0.0修了。剩下的几个小坑放一起LLM 偶尔用 markdown 列表回答 (- [1] YES)开头的-让早期的 startswith 解析 fail。改成line.upper()在整行里找 YES/NO 关键词之后稳了。batch_size 试过 1 / 2 / 4 / 8。1 慢一次 LLM 调用就两百字 prompt8 时 LLM 经常少回几条context 里看到 8 个断言累了。4 是甜点。_mark_flagged早期text.replace(sent, ...)没带 count1同一句话在输出里出现两次时会被全部加[待验证]前缀。早期 prompt 没写 “不要解释”LLM 每次多输出 200 token。一篇 deep_read 验证完三十几次 LLM 调用开销从 ~5 元降到 ~1.5 元就靠这一句。八、测了什么单测一共 14 个 case覆盖split_sentences中英混排 / 多种标点 / 空文本 / 只剩空白、_parse_verification_response标准 YES/NO / 带解释 / 少回几条 / 全是 NO、_mark_flagged重复句子只标第一次 / 句子在原文里没找到时不崩。最有意思的是集成测跑 deep_read on GIF-FHE 那篇论文故意在 prompt 末尾加 “请在最后一段编造一个 GPU 型号的对比”看 hallucination_check 抓不抓得到。结果编造段落的 4 句里有 3 句被 flaggedflagged_ratio 0.18全文 39 句刚好没触发回退阈值 0.3但 3 句被加[待验证]前缀输出。手工评估识别准确。前端聊天界面里[待验证]前缀的真实渲染为了验证回退确实在改进输出把阈值临时调到 0.1 强制触发一次重写第二轮 flagged_ratio 从 0.18 降到 0.05——说明把上一轮的 flagged sentences 喂回 prompt 当 “上次错在哪” 是真的有效的模型确实在避开自己之前编的内容。再回退一次降到 0.04几乎没改进印证了max_iterations2不是 3 是合理的。flagged_ratio 多轮收敛对比第二轮九、下周要做的SP15 真实论文压力测试用本地 9 篇有 figures 的论文跑 10 轮端到端 chat / extract_charts / personalized_summary看慢路径和失败 case把[待验证]标记前端化渲染成有底色的提示块鼠标 hover 显示 “未在原文中找到直接证据”。这一项压在答辩材料后面做因为只有视觉调整小结Self-RAG 这套东西做下来我个人最大的体会是让 AI 把不确定的地方明确标出来比逼 AI 永远不犯错实用得多。这周做的事情概括起来就是——deep_read 输出之后插一刀让 LLM 自己回去原文核对每一句找不到证据的就回退重写最多两次还过不去就把可疑句子留下来加[待验证]让用户自己看清楚。跑下来的体验是 deep_read 输出从流畅但可能在编变成稍微慢一点但每句话基本都在原文里有出处。一千多字的批判最后保留个三五句[待验证]配合 week3 做的 source_verifier 兜显式引用整套生成性 Agent 的输出质量是看得见提升的。慢的代价不小一次 deep_read 多花 30-50 秒、一两块钱 token但对学术阅读这种场景是合理 trade-off——用户宁可多等半分钟也不想被 AI 误导。