一次 RAG 不够怎么办:Anchr 迭代 Agent RAG 的过程

Anchr 最开始只有一条比较传统的 RAG 链路:用户提出问题,系统搜索相关内容,对候选结果重新排序,再把前几个片段交给模型,最后生成带引用的回答。

1
用户提问 → 检索 → 重排 → 构造上下文 → 生成回答

对大量事实型问题,这条链路已经够用。比如问“文档重新解析后,旧索引怎么处理”,只要知识库里有对应说明,一次检索通常就能找到主要材料,后面只需要把它们整理成一段连贯的回答。

但另一类问题不太一样:

找出几份设计文档里所有与任务恢复有关的内容,再比较进程重启、网络断开和主动取消三种情况下分别会发生什么。

这种问题很少能靠一个现成片段回答。第一次搜索可能只找到 Agent 运行恢复的说明;读完后才发现还涉及异步任务(Task);继续查 Task,又会遇到进程内状态和持久化状态的区别。等到核对部署约束时,对“恢复”的理解可能已经和第一次检索时不同。

下一步搜什么,开始取决于上一步读到的内容。Anchr 的 Agent RAG 就是从这个问题开始的。

TopK 调得再大,也猜不到下一步要搜什么

固定 RAG 的检索发生在生成之前,而且通常只执行一次。材料不够时,一个直接的办法是把一次检索保留的候选数,也就是 TopK,从 10 调到 30 或 50,希望一次多拿一些片段。

这对“相关内容本来就在首次召回范围里,只是数量不够”有用。前面的任务恢复问题却不完全属于这种情况。用户的原问题里未必出现“租约”、“运行快照”或“单实例”这些后续概念,第一轮搜索也没有理由为它们预留位置。

候选数增加后,无关内容也会跟着进入上下文。一份长文档里的相邻段落可能同时命中,看起来拿到了 30 个结果,其中大半却在重复同一件事。模型需要在更多噪声里挑出材料,却没有因此得到新的查找方向。

我需要的是两个分开的动作:先用搜索找到入口,再阅读上下文;如果读到了新线索,就再发起一次搜索。

1
2
3
4
5
6
7
8
9
搜索“Agent 恢复”

找到 Agent 运行说明

继续阅读,发现异步 Task

改查 Task 状态和恢复方式

再到部署文档确认运行边界

所以 Agent 有两个不同的工具:搜索用来确定“内容在哪里”,顺序阅读用来补齐“这个位置前后还说了什么”。比如搜索已经命中:

新一代索引(generation)完成写入并通过可见性检查后,再把它设为当前生效版本。

这时更有用的做法往往是继续读前后段落:上一段可能解释为什么不能直接覆盖,下一段可能说明旧版本什么时候清理。如果再做一次全局语义搜索,反而可能跳到文档里另一个提到索引版本的位置。

顺序阅读能补齐命中位置附近的上下文,但它也要有边界。整份长文档可能有几百个片段,需要分批归纳后再合并,不适合一直占着在线问答。这类工作会交给独立的后台任务。在线 Agent 只负责判断是否要转入长任务,不会自己无限翻下去。

Tool Calling 很快能跑起来,规则却会越来越多

第一版 Agent 的主循环并不复杂。模型拿到用户问题和工具说明,决定下一步是搜索、阅读还是回答。后端执行工具,把结果放回模型上下文,再让它做下一轮判断。

1
模型判断 → 执行工具 → 追加结果 → 回到模型判断

循环跑起来以后,问题很快从“能不能调工具”变成“多个判断同时发生时该怎么办”。

模型可能在一次响应中返回多个工具调用。如果立即并行执行,后面几个调用使用的仍然是模型作出这批决定时的旧上下文。第一个搜索如果已经找到新线索,后面的调用是否还值得执行,并不能完全照旧计划判断。Anchr 会先把调用排队,一次执行一个,并在每次执行前重新检查预算、重复调用和顺序阅读限制。

调用改成串行后,还要避免模型重复执行同一个工具。当前实现优先使用 Tool Call ID,也就是模型为每次工具调用附带的唯一标识。没有 ID 时,系统再比较工具名和参数。

这里不适合只靠语义相似度去重。两轮模型分别搜索 agent recoveryhow agent run recover,虽然意思接近,却可能是一次有用的查询改写。如果再调一次模型判断它们是否重复,既要花时间和调用费用,也不一定判断得准。所以系统只拦截可以确定的重复调用。如果模型只是换着说法反复搜索,最后由整体预算让它停下来。

去重只是其中一条规则。预算用完时,队列里可能还有工具调用;第一个工具失败后,后面的调用可能已经没有价值;模型已经给出答案,后端还可能发现引用不合法。再加上用户取消,这些规则很快会散落在模型调用、工具执行和最终状态提交的前后。

后来我把这一部分改成了显式状态机。这不是因为 Agent 必须用状态机,而是这次运行已经有了需要单独验证的执行顺序和最终状态规则。

状态机每次只处理三件事:现在处于什么状态,刚刚发生了什么,下一步应该做什么。调模型、搜索和阅读文档这些有副作用的操作放在状态机外面,执行结束后,再把结果作为新事件送回来。

1
2
3
4
5
当前状态 + 新事件

一次状态迁移

下一状态 + 一个待执行动作

拆开以后,“已经执行 8 次工具、当前已有有效证据、队列里还有一个搜索”可以直接构造成测试状态,再检查下一步是否转入收尾。测试不用真的调八次模型和搜索接口,也不用期待模型刚好走到这条分支。状态机没有减少分支,只是让这些分支可以独立检查。

状态机解决了“下一步该做什么”,但 Agent 还要记住另一类状态:它在这次运行里到底看过哪些材料。

模型“看过”哪些材料,由后端记账

Tool Calling 返回的虽然是结构化参数,对后端来说仍然是一份外部输入,不能直接相信。

假设用户这次只选了“系统设计”和“Agent 设计”两份文档。模型在某一轮生成了另一份文档的 ID,后端不能因为它来自 Tool Call 就直接读取。它还要检查文档是否存在、当前用户能否访问,以及它是否属于这次问答选定的范围。

这些范围也会放进 Prompt,帮助模型少做无效决定,但权限不依赖 Prompt 维护。原来由浏览器传入的参数,现在有一部分由模型生成,输入校验、资源权限和文档版本过滤仍然由服务端执行。

权限检查解决了“能不能读”,接下来还要解决“最后能引用什么”。固定 RAG 一次只给模型一组上下文,最终引用可以从这组内容里挑选。Agent 则可能经过多轮查找:

1
2
3
4
第一次搜索 → A、B、C
读取 A 的前后文 → D、E
第二次搜索 → F、G
继续读取 F → H

到最后,模型已经接触过八个片段,它的消息历史里还可能带着以前对话中出现的引用。如果最终答案只返回一个编号,后端必须知道它对应的是本次 Agent 执行(Run)真正读过的片段,还是历史引用、其他 Run 的证据,甚至是模型自己生成的 ID。

所以每次工具返回文档片段时,后端都会把它登记到当前 Run 的证据集合里。代码中把它叫作 Evidence Registry,可以理解成“这次 Agent 真正看过的 Segment 账本”。最终的知识型回答只允许引用这份账本中的内容。

模型提交答案以后,后端还会检查回答类型是否与引用一致、引用 ID 是否都属于当前 Evidence Registry,以及正文中的引用标记是否合法。模型实际只有三个可用片段,却在答案里引用了第五个,这份答案就不会直接通过。

首次检查失败后,系统会把具体错误送回模型,让它使用已有证据修复一次。如果第二次仍然无法生成合法答案,这次运行就收敛到安全的无证据结果,不会为了修复引用无限调用模型。

这套校验只能保证引用确实来自本次查找,不能证明模型已经正确理解原文。它仍然可能引用一段真实材料,却忽略其中的限定条件。Citation 只是让用户可以返回原文检查,不代表自然语言结论已经被证明。

有了证据账本,后端已经知道最终答案可以使用哪些材料。但 Agent 可以不断搜索和阅读,还需要解决另一个问题:这次执行最多能找多久?

到了上限就报错,会连已经找到的材料一起丢掉

固定 RAG 一般只有一次检索和一次生成,Agent 却可能连续调用多轮模型和工具。如果没有明确上限,一次在线问答很容易变成一个不知道何时结束的研究任务。

现在 Anchr 默认允许 12 个模型步骤、8 次工具调用和 90 秒总运行时间,单次模型调用最多 30 秒。预算不是在开始时检查一次就结束,每次准备调模型或执行工具前,系统都会重新计算剩余预算。

比如整个 Run 最多运行 90 秒,现在已经过去 84 秒,即使单次模型的常规超时是 30 秒,这一轮也不能再获得完整的 30 秒。否则 90 秒只是配置上的总时限,实际执行仍然可能在最后一次外部调用中超出很多。

顺序阅读也有单独限制。已经拿到证据后,同一个 Run 最多真正执行两次连续阅读。第三次试图继续往后翻时,系统不再读取更多正文,而是转入基于已有证据的收尾。如果问题确实需要通读整份文档,它应该进入后台总结任务,不该靠在线 Agent 不断翻页完成。

设置上限只解决了“什么时候必须停”,没有回答“停下时已经找到的材料怎么办”。如果 Agent 已经搜索和阅读了几轮,Evidence Registry 里也有可用材料,只是模型还没有主动提交答案,直接把整次执行判为失败,前面的有效工作也会被一起丢掉。

所以预算耗尽后,系统会先看当前有没有证据:

1
2
3
预算耗尽
├─ 没有证据 → 明确返回未找到足够材料
└─ 已有证据 → 关闭工具,只基于现有材料生成答案

后一条路径在代码里叫 Evidence Finalizer。它不再把任何工具交给模型,也不允许重新规划查找路径,输入只有当前 Run 已经登记的证据。它要收尾的是“已经找到材料,但没在预算内正常结束”的运行,不会把没有证据的问题强行补成一个完整答案。

这条收尾路径自己也可能失败,比如剩余时间不足,或模型仍然无法给出合法的带证据答案。这时系统会保留“已有证据,但收尾失败”的降级结果,不会伪装成正常完成。“没找到材料”、“找到了材料但没能完成回答”和“成功生成可验证回答”,是三个不同的结果。

这些限制保证了 Agent 不会无限运行,但一次执行仍然可能持续几十秒。执行时间变长后,页面与后端任务的关系也要重新处理。

问答变长以后,页面连接就不能代表任务本身

固定 RAG 的执行较短,用户发出问题后等待答案就可以。Agent 可能运行几十秒,中间经过多次搜索、阅读和模型决策。如果页面只显示一个 loading,用户很难分辨它是仍在查找,还是已经卡住。

Anchr 会通过 SSE(服务端事件流)把运行进度发到页面,比如正在做决策、搜索文档或读取上下文。可以安全展示的回答也会分段发到页面,完整结果通过检查后,再校准页面上的最终内容。

SSE 连接不是 Agent Run 本身。刷新页面、切换网络或电脑休眠,都可能让连接中断。如果一断线就自动取消后端执行,用户只是刷新页面,前面已经查了几十秒的任务也会一起消失。

现在的处理是,连接断开只停止向这个客户端继续发送实时事件,后端仍然会完成运行并保存会话和 Run 状态。用户重新进入页面后,可以通过已持久化的 Run、Task 或会话消息恢复已经发生的结果。Redis 里的运行快照用来辅助展示活动状态,MySQL 里的会话、Run、Step 和 Task 才是最终状态的来源。

在线 Run 和页面连接拆开以后,长文档总结可以更彻底地交给后台。Agent 判断需要通读时,会创建独立的后台任务,保存任务信息后就结束当前请求。后台任务再读取全文、分批归纳、合并结果并更新进度。用户离开页面后,它也不需要靠原来的 HTTP 连接维持生命周期。

断开连接不等于取消任务。用户真正想停止任务时,需要发起单独的取消操作。这又带来一个和 AI 没多少关系的并发问题:Agent 已经生成完答案,正准备提交完成;同一时刻,用户点了取消。

1
2
执行线程:检查未取消 → 准备提交 COMPLETED
用户请求: 发起 CANCELLED

如果只在流程中的几个位置检查一个布尔值,“最后一次检查”和“提交最终状态”之间仍然有竞态窗口。所以取消、完成和失败共用同一个临界区。如果取消先被接受,尚未提交的完成或失败就不能再覆盖它;如果完成或失败已经先提交,迟到的取消也不会再改写结果。

到这里,页面连接是否存在、用户是否请求取消、后端最终提交了什么结果,已经是三件需要分开处理的事。Agent 为了完成多轮查找,也因此多了不少运行成本和出错环节。这就带来下一个选择:有了 Agent 以后,还要不要保留原来的固定 RAG?

为什么最后还是保留了固定 RAG

Agent 可以搜索、阅读,也可以决定何时回答,从能力上看确实能覆盖固定 RAG。但问“这份文档的发布日期是什么”时,一次检索就可能找到答案。如果走 Agent,至少要先调一次模型决定搜索,执行搜索后再调模型生成回答。同一个问题多了一轮模型调用,延迟和成本都更高。

所以目前两条路径一直并存。目标明确、一次搜索大概能找到答案的问题,直接走固定 RAG;跨文档查找、连续阅读,或需要根据中间结果调整搜索方向的问题,再由用户显式开启 Agent。两类问题的边界还没有清楚到可以完全交给自动路由,所以现在仍然保留显式选择。

两条路径并存,还有一个原因:固定 RAG 可以作为 Agent 失败后的退路。固定 RAG 主要面对检索和生成失败,Agent 则多了工具协议不合法、规划失败、工具边界异常和答案检查异常等出错环节。在配置允许的情况下,未预期异常可以回退到固定路径,重新执行一次路由、检索和生成。

这个回退不能保证复杂问题仍然有同样的答案质量。一个本来就需要多轮查找的问题,回到单次检索以后,很可能只能找到其中一部分材料。回退只是在 Agent 编排自身出错时,让普通知识问答还能尝试一条更短、出错环节更少的路径,不代表两条路径完全等价。

现在这套 Agent 的范围仍然很窄。它只处理 Anchr 里已授权的文档,不访问互联网,也没有代码执行和浏览器操作。一个问题从开始到结束由同一套 Workflow 处理,没有再拆出 Research Agent、Reviewer Agent 等多个角色。它当前要做好的,仍然是在一次检索不够时沿着文档继续查下去,同时让知识范围、执行上限和引用来源都可以被后端检查。

把 Tool Calling 跑起来只是开头。固定 RAG 的步骤很少回头,Agent 却让搜索、阅读和生成之间出现了前后依赖。系统因此要记住已经执行过什么、当前拿到了哪些证据、还剩多少时间,以及最后该以完成、降级还是取消结束。这些问题很少出现在 Tool Calling 的演示里,却是 Anchr 从 RAG 走到 Agent RAG 时需要补上的主要部分。

项目源码https://github.com/ryanmeowy/anchr-app