一次 RAG 不够怎么办:Anchr 迭代 Agent RAG 的过程
Anchr 最开始只有一条比较传统的 RAG 流程:用户提出问题,系统搜索相关内容,对候选结果重新排序,把前几个片段交给模型,最后生成带引用的回答。
1 | 用户提问 → 检索 → 重排 → 构造上下文 → 生成回答 |
对大量事实型问题,这条链路已经够用。比如问“文档重新解析后旧索引怎么处理”,只要知识库里存在对应说明,一次检索通常就能拿到主要材料。后面的工作,主要是让模型把这些材料整理成一段连贯的回答。
后来我开始遇到另一类问题:
找出几份设计文档里所有关于任务恢复的内容,再比较进程重启、网络断开和主动取消三种情况下分别会发生什么。
这类问题很少有一个现成片段能直接回答。第一次搜索可能只找到 Agent 运行恢复的说明;读完才发现这里还提到了异步 Task;继续查 Task,又会遇到进程内状态和持久化状态的区别。等到查部署约束时,对“恢复”这个词的理解可能已经和第一次检索时不一样了。
下一步应该搜什么,开始依赖上一步读到的内容。Anchr 的 Agent RAG 就是从这个问题开始的。
把 TopK 调大,解决不了“读完才知道下一步搜什么”
传统 RAG 的检索发生在生成之前,而且通常只执行一次。遇到材料不够时,一个直接办法是把 TopK 从 10 调到 30 或 50,希望一次多拿一些片段。
这个方法对“相关结果本来就在首次召回范围里,只是数量不够”的问题有用。可上面的任务恢复问题不完全是这种情况。用户的原始问题里未必出现“租约”、“运行快照”或“单实例”这些后续概念,搜索系统在第一轮也没有理由为它们保留位置。
候选数增加后,无关内容也会跟着进入上下文。一份长文档中的相邻段落还可能同时命中,表面上拿到了 30 个结果,实际上其中大半都在重复同一件事。模型需要在更多噪声中找有用内容,但并没有因此获得新的查找方向。
这里需要的其实是两种不同的行为:先用搜索找到入口,然后读上下文;如果上下文中出现新线索,再发起下一次搜索。
1 | 搜索“Agent 恢复” |
所以在设计 Agent 的工具时,我还是把“搜索”和“阅读”拆成了两个动作,而不是只给它一个“搜索更多”的工具。它可以先找相关文档,再搜具体内容;已经定位到某个片段时,则按文档原始顺序继续读。搜索解决“内容在哪里”,顺序阅读解决“这个位置前后还说了什么”。
比如搜索已经命中:
新 generation 完成写入并通过可见性检查后,再切换 active generation。
此时有用的下一步往往是继续看前后段落:上一段可能解释为什么不能直接覆盖,下一段可能说明旧版本何时清理。再做一次全局语义搜索,反而可能跳到文档里另一个提到 generation 的位置。
整份长文档的通读和总结又不适合一直占着这个在线循环。它可能需要读几百个片段,分批归纳再合并,因此会被交给单独的后台任务。在线 Agent 做的是判断这次问题是否需要转入长任务,而不是自己无限翻下去。
Tool Calling 很快就能跑起来,难的是几个判断同时发生
第一版 Agent 的主循环并不复杂。模型拿到用户问题和工具说明,决定下一步是搜索、阅读还是回答。后端执行工具,把结果放回模型上下文,再让它做下一轮判断。
1 | 模型判断 → 执行工具 → 追加结果 → 回到模型判断 |
这个循环已经能跑通不少场景。问一个跨文档问题,模型先搜索,从结果里选一份可疑的设计文档继续读,材料够了再提交答案。但一旦运行轮数变多,原来直观的循环会开始遇到一些不能独立处理的情况。
模型可能在一次响应中返回多个工具调用。如果立即并行执行,后面几个调用使用的仍然是模型做出这批决定时的旧上下文。第一个搜索如果已经找到新线索,第二个调用是否还值得执行,并不总能按原计划回答。Anchr 会先把这些调用排队,一次执行一个,并在每个调用前重新检查预算、重复调用和顺序阅读限制。
重复调用也不能只靠“这两句话看起来很像”判断。当前实现优先用 Tool Call ID 识别同一调用,没有 ID 时再使用工具名和参数。两轮模型分别搜索 agent recovery 和 how agent run recover,语义虽然接近,仍然可能是一次有意义的查询改写。如果再调一次模型判断它们是否重复,去重本身也变成了一个不稳定、有成本的 Agent 问题。所以现在只拦能确定的重复,语义层面的循环由整体预算来截断。
其他条件也会互相碰到。预算已经耗尽,但模型这一轮同时返回了两个工具调用,是直接结束,还是把已经收到的调用执行完?第一个工具失败了,后面的工具是否还有执行价值?模型已经给出答案,后端检查发现引用不合法,这次运行是完成、失败,还是应该让模型修一次?如果用户此时又发起取消,还要再加一个终态竞争。
继续往一个 while 循环里加 if 当然也能工作,但规则会被分散到模型调用前后、工具调用前后和终态处理里。后来我把这部分改成了显式状态机。原因不在于“Agent 天然应该用状态机”,而是这次运行已经有了需要被单独检查的顺序和终态规则。
状态机每次只回答三个问题:现在处在什么状态,刚刚发生了什么,下一步应该执行哪一个动作。模型请求、搜索和读取文档这些有副作用的操作放在外面,执行完再把结果作为新事件送回来。
1 | 当前状态 + 新事件 |
这么拆以后,“已经执行 8 次工具、当前有有效证据、待执行队列里还有一个搜索”可以直接构造成一个测试状态,再检查下一步是否转入收尾。不需要真的调八次模型和搜索接口,也不需要期待模型在测试中刚好走到这条分支。状态机没有让分支变少,它只是把规则从外部调用里抽了出来。
模型“看过”哪些材料,必须由后端记住
Tool Calling 返回了结构化参数,它看起来比自然语言可靠,但对后端来说,这仍然是一份外部输入。
假设用户这次只选了“系统设计”和“Agent 设计”两份文档。模型在某一轮生成了另一份文档的 ID,后端不能因为这是模型发起的 Tool Call 就直接读取。它还要检查文档是否存在、当前用户能否访问,以及是否属于这次问答明确选定的范围。
这些范围也会出现在 Prompt 里,用来帮助模型少做无效决定,但权限不依赖 Prompt 维护。原来接口参数由浏览器传入,现在其中一部分改成了模型生成,输入校验、资源权限和文档版本过滤仍然在服务端执行。
引用遇到的问题更麻烦。普通 RAG 一次只给模型一组固定上下文,最终引用大体可以从这组内容中选。Agent 则可能经过多轮查找:
1 | 第一次搜索 → A、B、C |
到最后,模型已经接触过八个片段,它的消息历史里还可能有以前对话中出现的引用。如果最终答案只返回一个编号,后端必须知道它指向的是当前 Run 的某个真实片段,还是历史引用、其他 Run 的证据,甚至是模型自己编出来的 ID。
所以每次工具返回文档片段时,后端会把它登记到当前 Run 的证据集合里。代码中把这份记录叫做 Evidence Registry,理解成“这次 Agent 真正看过的 Segment”就可以。最终的知识型回答只允许引用这份集合中的内容。
模型提交答案以后,它也不会直接返回给前端。后端会检查回答类型是否与引用一致、引用 ID 是否都属于当前 Evidence Registry,以及正文里的引用标记是否合法。如果模型实际只有三个可用片段,却在答案里引用了第五个,这个答案不会直接通过。
首次检查失败时,系统会把具体错误送回模型,让它基于已有证据修复一次。如果再次无法生成合法答案,就收敛到安全的无证据结果,不会为了修复引用继续无限调用模型。
这套校验只能保证引用确实来自本次查找,不能证明模型已经正确理解原文。它仍然可能引用一段真实材料,却忽略其中的限定条件。Citation 做的是让用户能返回原文检查,而不是给自然语言结论盖一个“已证明”的章。
到了上限就报错,会把已经找到的材料一起丢掉
传统 RAG 一般只有一次检索和一次生成,Agent 却可以连续调用多轮模型和工具。如果没有明确上限,一次在线问答很容易变成一个不知道何时结束的研究任务。
现在 Anchr 会限制模型最多决策多少轮、工具最多执行多少次,以及整个 Run 最长运行多久。默认值是 12 个模型步骤、8 次工具调用和 90 秒总时限,单次模型调用最多 30 秒。这些值不是在开始时检查一次就结束,每次准备发起模型或工具调用前,都会重新计算剩余预算。
比如整个 Run 最多 90 秒,现在已经过去 84 秒,即使单次模型的常规超时是 30 秒,这一轮也不能再拿到完整的 30 秒。否则“90 秒总时限”只是一个配置项,实际执行仍然可能在最后一次外部调用里超出很多。
顺序阅读也有单独限制。已经拿到证据后,同一 Run 最多真正执行两次连续阅读。第三次试图继续向后翻时,系统不再读取更多正文,而是转入基于已有证据的收尾。如果问题确实需要通读整份文档,它应该进入后台总结任务,而不是通过在线 Agent 不断翻页完成。
预算用完后的处理,是这里比“设置一个上限”更麻烦的部分。如果 Agent 已经搜索和阅读了几轮,Evidence Registry 里也有可用材料,只是模型还没主动提交答案,直接把整次执行判为失败,前面的有效工作也会一起丢掉。
我最后没有把“预算耗尽”统一当成失败,而是先看当前有没有证据:
1 | 预算耗尽 |
后一条路径在代码里叫 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 | 执行线程:检查未取消 → 准备提交 COMPLETED |
如果只在流程中某几个位置检查一个布尔值,“最后一次检查”和“提交终态”之间仍然留着竞态窗口。现在取消和非取消终态会共享同一个临界区:取消先被接受,尚未提交的完成或失败就不能再覆盖它;终态先完成了原子认领,迟到的取消也不会再改写结果。
从这里开始,页面连接是否存在、用户是否请求取消、后端最终提交了什么结果,已经是三件需要分开处理的事。
最后还是保留了两条执行路径
Agent 能够搜索、阅读,也能决定何时回答,从能力上看确实可以覆盖普通 RAG。但问“这份文档的发布日期是什么”时,一次检索就可能找到答案。如果走 Agent,至少要先调一次模型决定搜索,执行搜索后再调模型生成回答。同一个问题多了一轮模型调用,延迟和成本都更高。
所以目前两条路径一直并存。目标比较明确、一次搜索大概能找到答案的问题,直接走传统 RAG;跨文档查找、连续阅读,或需要根据中间结果调整搜索方向的问题,再由用户显式开启 Agent。两类问题的边界还没有清楚到可以完全交给自动路由,因此当前保留了显式选择。
原来的 RAG Pipeline 还承担另一个作用:Agent 引入了更多故障点。固定 RAG 主要要面对检索和生成失败,Agent 还可能遇到工具协议不合法、规划失败、工具边界异常和答案检查异常。在配置允许的情况下,这些未预期异常可以回退到传统路径,重新做一次路由、检索和生成。
这个回退并不会保证复杂问题仍然有同样的答案质量。一个本来就需要多轮查找的问题,回到单次检索以后,很可能只能找到其中一部分材料。它的意义是当 Agent 编排自身出问题时,普通知识问答还有一条更短、故障面更小的路径可以尝试,不代表两条路径完全等价。
现在这套 Agent 的范围仍然很窄。它只处理 Anchr 里已授权的文档,不访问互联网,也没有代码执行和浏览器操作。一个问题从开始到结束由同一套 Workflow 处理,没有再拆出 Research Agent、Reviewer Agent 等多个角色。它当前要做好的,仍然是在一次检索不够时沿着文档继续查下去,同时保持知识范围、执行上限和引用来源都可以被后端检查。
从这一轮开发看,把 Tool Calling 跑起来只是开头。原来固定 RAG 的步骤很少回头,Agent 却让搜索、阅读和生成之间出现了前后关系。从那一刻开始,系统就需要记住已经执行过什么、当前拿到了哪些证据、还剩多少时间,以及最后该以完成、降级还是取消结束。这些问题很少出现在 Tool Calling 的演示里,却是我在 Anchr 从 RAG 走到 Agent RAG 的过程中花时间更多的地方。