前几天翻到一篇论文(arXiv 2608.02011,Roh 和 Han 两位写的),里面有个发现把我扎了一下:agent 检索增强系统里,一大堆错误答案根本不是推理推错的——agent 检索到了候选证据,压根没打开看就急着收尾答题了。作者管这叫证据前纪律失败。问题出在“收证据”这个动作序列上,不出在“想答案”上。
这种结论我是那种“从零搓一遍训练/推理循环就会立刻信”的。光看论文不过瘾,今天我们不调 LangChain 之类的现成轮子,自己从零搓一个最小 RAG agent 的轨迹控制层,把“搜索之后、回答之前必须读一下”这个约束——论文里叫 Read-Gate——亲手拼出来,跑一下看它到底拦住了什么。搓完这个,你看那些 agent 框架里的 retriever / reader 分层,就不再是黑盒了。
先把 agent 搓成一个纯函数
先给最糙的直觉:所谓 agent 的“轨迹”,就是一个动作列表——search 拿回一堆候选段落,read 打开某一段看内容,answer 收尾。LLM 在每一步决定下一个动作是什么。就这么点事。ReAct 也好、planner 也好,剥到骨头都是这个循环。
我们先搓个最简版。不接真 LLM,用一个写死的策略函数来扮演 LLM 的决策——整篇跑起来不花一分钱 API 钱,行为可控、可复现,正好用来看控制层本身:
import random
# 假的语料库:三段候选,只有 gold 是真正含答案的
DOCS = {
"d1": "巴黎是法国的首都。",
"d2": "法国的国土面积约为55万平方公里。",
"d3": " unrelated,这段是凑数的。",
}
def fake_llm_decide(step, state):
# 这一行干这件事:扮演一个"懒"的 LLM
# 搜完一步就直接想答,除非被外力拦住
if step == 0:
return ("search", "法国 首都")
elif state.get("forced_read_done"):
return ("answer", state["guess"])
else:
return ("answer", state["guess"]) # 没被拦就直接答
然后是轨迹循环本体。一个 while,每步问策略函数要动作,执行,记录到轨迹里:
def run_episode(policy, read_gate=False):
state = {"evidence": [], "guess": "巴黎", "forced_read_done": False}
traj = []
step = 0
while step < 10:
action, arg = policy(step, state)
traj.append((action, arg))
if action == "search":
state["evidence"] = ["d1", "d2", "d3"] # 检索命中了 gold
elif action == "read":
state["forced_read_done"] = True
state["evidence_text"] = DOCS[arg]
elif action == "answer":
return traj, arg
step += 1
return traj, None
跑一下,看看出来啥:
traj = [('search', '法国 首都'), ('answer', '巴黎')]
看,检索明明命中了 d1,轨迹里却没有一个 read。答案碰巧是对的(我们写死的 guess 太容易了),但把 guess 换成错的,你看到的就是论文里说的那种失败:证据在手边,没看,答错了,而且从答案侧看你会以为它“推理不行”——其实它压根没进推理那道门。这就是那篇论文最核心的观察:失败发生在读证据之前的话,你再怎么修推理侧都是修错地方。
搓 Read-Gate
论文里提的解法简单到有点侮辱各种复杂框架:一个运行时约束,search 之后、answer 之前,必须夹至少一次 read。我们把它搓出来,就几行:
def run_episode_gated(policy):
state = {}
traj = []
step = 0
while step < 10:
action, arg = policy(step, state)
# 这一行干这件事:Read-Gate 本体
if action == "answer" and any(a == "search" for a, _ in traj) \
and not any(a == "read" for a, _ in traj):
action, arg = "read", "d1" # 拦下来,强制先读
traj.append((action, arg))
if action == "read":
state["forced_read_done"] = True
elif action == "answer":
return traj
step += 1
return traj
跑一下:
traj = [('search', '法国 首都'), ('read', 'd1'), ('answer', '巴黎')]
丑是丑了点,但它真的拦住了。论文里的数字比我们的玩具吓人:强制读取让原本会跳过读取的轨迹上准确率提升 14.9 到 19.9 个百分点——注意这是“答案对了”的提升,而它改的只是动作序列,一个字推理逻辑都没动。相当于你没换发动机,只是强制司机看一眼路牌,事故率降了快二十个点。
这版糙在哪,真情况多出哪几层
诚实说一下我们这个玩具和真 agent 的差距,顺便把论文里几个我觉得最有意思的细节搬过来。
第一,我们写死的策略太配合了。真实 LLM 被 gate 拦下之后会乖乖去读吗?读了之后会真的用上读到的内容吗?论文里有个分类我很喜欢:它把错误答案的轨迹分成“证据前纪律失败”和“读了黄金证据之后仍然失败”两类,然后发现这两类基本不重叠——两种实体抽取器下同时踩中两类的轨迹只有 11.2% 到 13.1%。这个不重叠很关键:说明这是两种病,得分开诊断、分开治。混在一起看,你会得出“模型能力不行”这种糊成一团的结论,然后去换更大的模型——而论文里另一条诊断直接把这条路堵死了:给更大的隐藏思考预算,并不能换来更多的证据查看行为。模型想得更多,不等于看得更多。这一点我第一次读到时愣了一下,细想又觉得理所当然:思考预算加在“想”侧,病在“看”侧,两个器官。
第二,我们的 gate 是硬编码读 d1。真版本得让 gate 只强制“读这个动作发生”,读哪段还是 agent 自己挑——否则你就是在替它做检索后筛选,那就不是控制层了,是另一个模型了。论文把它叫“最小运行时约束”,“最小”这两个字是有讲究的:约束越薄,你越能确定性能提升来自“读了”,而不是你的约束偷偷干了别的。这也是我为什么喜欢这种论文——它把一个模糊的“agent 不靠谱”拆成了一个可以一行 if 拦住的具体病灶。
第三,也是我最想说的一个判断:论文把证据收集定义成轨迹层面的控制问题,和答案侧推理分开评估。这句话换个说法就是——别用最终准确率这一个数去评 agent。你拿准确率评,一个“没读证据瞎答错的”和一个“读了证据推理错的”,在报表里长得一模一样。而这两个东西,一个是控制 bug,一个是能力上限,修法完全不同。上面那个几十行的玩具已经能演示这一点了:guess 写错时,无 gate 版和有 gate 版答案都对不了,但轨迹一打印出来,你立刻知道前者根本不配被叫“推理失败”。
顺着这个我想多说一句跑题的:现在一堆 agent 评测挂在 benchmark 分数上,HotpotQA 之类的多跳问答集,2WikiMultiHopQA、MuSiQue 也好,都是“问题进、答案出、对没对打分”。这篇论文是拿 12,000 条配对轨迹(工具调用记录、检索证据、已读段落、最终答案四样对齐着看)才把两类失败拆开的——不看轨迹,这个结论出不来。所以下次看到“某某 agent 框架在多跳问答上涨了三个点”,我第一反应不是“变强了”,而是“它到底改了控制层还是推理层,拆开看了吗”。没拆开的涨分,你不知道买到的是什么。
收个尾
所以你看,RAG agent 的“不靠谱”拆开看,有一大块根本不是推理问题,是轨迹里少了一个 read 动作——而拦住它只需要一行 if。今天搓的这个玩具没有真 LLM、没有真检索、gate 也是写死的,但“动作序列决定答案质量”这件事,你跟着敲一遍就长在直觉里了。论文是 8 月初挂到 arXiv 的(第一版 8 月 3 日,第二天就修了一版),22 页,图有 7 张,值得自己翻一遍,尤其是那个失败分类的图。
边界照例划清楚:这版只是用来懂的,生产里别自己手搓 agent 循环,用现成框架,然后在框架的钩子里把 Read-Gate 这类约束挂上去——你从零搓过一遍,才知道钩子该挂在哪一层。
再往上其实还有一层:gate 只保证“读了”,不保证“读进去了”——读了证据但答案没用上它的轨迹,控制层是拦不住的,那得往注意力/上下文利用那侧去查。那个怎么搓个最小版出来看,留给「手搓系列」下一篇。我们下次见。
