RAG 开发入门(五):把检索工具交给 Agent
Agentic RAG 的关键,是把检索层封装成稳定工具,让 Agent 自己决定什么时候查、用哪些关键词查,以及是否继续追查。
第四篇最后留下了一个方法:
List<SearchHit> searchDocs(List<String> keywords, int topK);
检索层内部可以继续做 BM25、向量检索、多模态召回、元数据过滤、合并去重和 reranker。对上层来说,它最好只是一个稳定工具:输入关键词,返回排好序、带来源的候选证据。
这篇从这里进入 Agentic RAG。
1. 先把 Agent 当成黑盒
这篇先不展开 Agent 框架内部怎么实现。对 RAG 来说,可以先把 Agent 当成一个黑盒:它接收用户任务,能看到一组工具,调用工具后能观察结果,然后决定下一步动作。
这个黑盒里可能有 ReAct、function calling、tool calling、planner,也可能只是一次模型调用加上一段循环代码。Java 后端在第一版落地时,没必要一开始就把这些概念全部摊开。先把接口边界设计清楚,系统会更稳。
Agentic RAG 里的关键工具,就是检索。
第四篇已经把检索层讲到了混合召回和重排。现在要做的事,是把这些复杂度收在 searchDocs 里,让 Agent 通过这个工具拿证据。
2. Workflow RAG 容易卡在分支上
第二篇的最小 RAG 是一个固定流程:用户问题进来,系统检索一次,组装 prompt,再调用模型回答。这个流程很适合讲清楚 RAG 的基本链路,也适合一些边界很稳定的问答场景。
问题复杂以后,固定流程会开始长分支。
比如用户问:“出差回来,高铁票和酒店发票怎么报销?”系统可能要先查差旅制度,再查票据要求,再查报销入口,再查住宿标准和超标说明。第一次检索命中的内容,往往只能告诉系统下一步该查什么。
如果用 workflow 写,就要预先设计很多分支:查不到怎么办,查到报销入口怎么办,查到票据要求怎么办,查到住宿标准怎么办,证据冲突怎么办,是否还要换一组词继续查。问题类型越多,分支越难维护。
Agentic RAG 把这部分决策交给 Agent。检索工具仍然稳定,变化的是工具调用策略:先查什么,查到什么以后继续查什么,什么时候停下来回答。
3. 检索工具的输入就是关键词
这里要把工具边界收紧。searchDocs 的输入就是关键词。
import java.util.List;
public interface RagSearchTool {
List<SearchHit> searchDocs(List<String> keywords, int topK);
}
这里的 keywords 是 Agent 生成的。它根据用户任务和上一轮 SearchHit,决定这一轮要用哪些词继续查。检索层只消费这些关键词,不负责理解用户意图,也不负责改写问题。
不要把问题改写、过滤条件、知识库范围都塞进这个工具输入里。Agent 最容易做好的事,是根据当前任务和已看到的证据,生成下一组关键词。检索层要做好的事,是拿这些关键词稳定搜索。
权限、租户、系统范围、版本范围这类信息,应该从服务端会话上下文里来。Agent 调工具时只给关键词,后端在工具内部按当前用户、当前系统和当前租户做过滤。
这样设计有几个好处。
第一,Agent 的动作更容易审计。日志里只要记录每一轮关键词、返回结果和下一步动作,就能看清楚它为什么继续查。
第二,检索层可以独立演进。今天内部是 BM25,明天加向量,后天加 reranker,工具签名不用跟着变。
第三,权限边界更清楚。Agent 不能自己声明要查哪个租户、哪个知识库、哪个版本,系统从可信上下文里决定它能看到什么。
4. 一轮检索通常只能打开第一扇门
Agentic RAG 的价值,经常体现在多轮检索。
很多问题的第一轮检索,只能找到入口文档。入口文档会告诉你这个问题属于哪个制度、哪个流程、哪个系统,但往往不会把所有细节一次讲完。
用户的问题越接近日常表达,第一轮关键词越可能偏粗。Agent 拿到第一批 SearchHit 后,能从里面看到更具体的词:制度名称、字段名、票据类型、审批动作、异常原因。下一轮检索就可以围绕这些词继续查。
这里没有神秘的东西。Agent 做的就是根据证据继续补关键词。它每次都在缩小问题范围,也在补齐回答需要的证据链。下面用一个日常问答示例完整看一遍。
5. 一个迭代检索问答助手示例
可以把一次 Agentic RAG 运行记录成这样的轨迹。
用户问:
出差回来,高铁票和酒店发票怎么报销?
第一轮,Agent 先查最直接的线索。
Agent 思考:先找到差旅报销入口和票据总要求。
调用工具:searchDocs(["差旅报销", "高铁票", "酒店发票"], 5)
searchDocs 返回两条比较有用的证据:
1. 财务制度 / 差旅报销 / 报销入口
出差结束后,员工在财务系统发起差旅报销单,并关联出差申请。
2. 财务制度 / 差旅报销 / 票据要求
交通费和住宿费需要上传合规票据,票据信息要和报销单一致。
这轮结果让 Agent 知道要走差旅报销单,也知道交通费和住宿费都要票据。但“高铁票具体上传什么”还没有说清楚,所以继续查交通票据。
第二轮关键词变得更具体:
Agent 思考:需要确认高铁票报销需要哪种凭证。
调用工具:searchDocs(["高铁票", "电子客票", "报销凭证"], 5)
这次返回的证据变成:
1. 财务制度 / 交通费报销 / 高铁票
高铁票报销需上传铁路电子客票报销凭证,截图和购票记录只能作为辅助说明。
2. 财务制度 / 交通费报销 / 票据抬头
电子票据抬头应与公司开票信息一致,金额要和报销明细一致。
这轮已经能回答高铁票部分。用户还问了酒店发票,Agent 继续查住宿费。
第三轮:
Agent 思考:需要确认酒店发票、住宿标准和超标处理。
调用工具:searchDocs(["酒店发票", "住宿标准", "超标说明"], 5)
返回结果:
1. 财务制度 / 住宿费报销 / 酒店发票
酒店发票需包含公司抬头、纳税人识别号、住宿日期和金额。
2. 财务制度 / 住宿费报销 / 标准与超标
住宿金额超过城市标准时,需要补充超标说明或上传审批附件。
这时证据已经够了。Agent 可以生成回答:
可以按这几步处理:
1. 在财务系统发起差旅报销单,并关联对应的出差申请。
2. 高铁票上传铁路电子客票报销凭证。截图和购票记录可以作为辅助说明,
但报销凭证仍然要上传。
3. 酒店发票要核对公司抬头、纳税人识别号、住宿日期和金额。
4. 如果酒店金额超过当地住宿标准,补充超标说明或上传审批附件。
这个示例里,Agent 没有一次性把问题扔给模型回答。它先用粗关键词打开入口,再根据 SearchHit 里的制度名称、票据类型和流程名称继续补关键词。每一轮检索都留下了可回看的轨迹,最后回答也能对应到具体证据。
6. SearchHit 要让 Agent 看得懂
检索工具返回给 Agent 的结果,不能只是一段正文。
Agent 需要判断这条证据是否有用,至少要看到来源、标题路径、摘要、分数和引用信息。可以先把 SearchHit 设计成这样:
import java.util.Map;
public record SearchHit(
String chunkId,
String titlePath,
String snippet,
String sourceUri,
double score,
Map<String, String> metadata
) {
}
snippet 给 Agent 快速判断内容是否相关,titlePath 帮它理解这段内容属于哪个主题,sourceUri 用来保留引用,metadata 可以带上系统、版本、文档类型和更新时间。
返回结果要控制长度。每轮给 Agent 太多内容,它会被噪音淹没,调用成本也会上升。第一版可以从每轮 5 到 10 条 SearchHit 开始,再根据日志调。
更重要的是,每条结果要能回看。线上回答错了,要能看到 Agent 每一轮用了什么关键词,工具返回了哪些 SearchHit,Agent 为什么继续查,最后用了哪些证据回答。
7. Agent 需要清晰的停止条件
Agent 可以多轮检索,但系统要给它边界。
第一版可以设置几个简单规则:最多检索几轮,每轮最多返回多少条,证据不足时怎么回答,哪些工具调用必须记录日志,哪些来源可以进入最终回答。
比如:
public record AgentRagOptions(
int maxSearchRounds,
int topKPerRound,
boolean requireSource
) {
}
maxSearchRounds 控制成本和延迟。topKPerRound 控制每轮给 Agent 看的证据量。requireSource 表示最终回答必须基于可引用来源。
RAG 本来就是为了补模型参数里没有的知识,减少凭空猜测。Agentic RAG 也是一样。Agent 可以更主动地查资料,但回答仍然要落在证据上。查不到证据时,系统应该让它说清楚没查到,避免让模型继续猜。
8. System 提示词要约束检索行为
第一版可以先把 Agent 的检索行为写进 system 提示词里。比如:
你是一个企业知识库问答助手。你的任务是基于知识库资料回答用户问题。
你可以使用一个检索工具:
searchDocs(keywords, topK)
工具说明:
- keywords 由你生成,只能是一组关键词。
- keywords 不要写成完整句子。
- keywords 不要包含权限、租户、系统范围、知识库范围等过滤条件。
- 服务端会根据当前用户、租户、系统和权限范围自动过滤资料。
- 工具返回 SearchHit 列表,每条 SearchHit 包含 titlePath、snippet、sourceUri、score 和 metadata。
工作方式:
1. 如果用户问题需要制度、流程、说明文档或历史资料支撑,先调用 searchDocs。
2. 第一轮关键词可以宽一点,用来找到入口文档。
3. 观察 SearchHit 后,由你生成下一轮更具体的关键词。
4. 不要重复使用已经证明无效的关键词组合。
5. 最多检索 3 轮。证据足够时停止检索并回答。
6. 回答只能基于 SearchHit 中出现的资料,不要编造制度、流程或数字。
7. 如果证据不足,明确说明没查到足够依据,并列出已经检索过的关键词。
8. 最终回答要尽量给出可执行步骤,并保留来源信息。
这段提示词要把行为说清楚:关键词由 Agent 生成,检索工具只负责查,回答必须落在证据上。后端还要把每一轮关键词和 SearchHit 记日志,方便之后排查。
9. Java 里可以先做一个很薄的 Agent 循环
第一版 Agentic RAG 可以先不上复杂框架。Java 后端先写一个很薄的循环,把模型调用和工具调用串起来。
伪代码大概是这样。模型每一轮返回一条消息,后端根据消息类型决定下一步:如果是工具调用,就执行 searchDocs;如果是最终回答,就结束。
import java.util.List;
public class AgenticRagService {
private final ChatClient chatClient;
private final RagSearchTool searchTool;
public AgenticRagService(ChatClient chatClient, RagSearchTool searchTool) {
this.chatClient = chatClient;
this.searchTool = searchTool;
}
public RagAnswer answer(String userTask, AgentRagOptions options) {
AgentMemory memory = AgentMemory.start(userTask);
int searchRounds = 0;
while (searchRounds < options.maxSearchRounds()) {
// 让模型基于当前任务和已观察到的 SearchHit,生成下一条消息。
AgentMessage message = chatClient.nextMessage(memory);
if (message.type() == AgentMessageType.FINAL_ANSWER) {
// 模型认为证据已经够了,直接返回最终回答。
FinalAnswerMessage answer = (FinalAnswerMessage) message;
return RagAnswer.from(answer.content(), answer.sources());
}
if (message.type() != AgentMessageType.TOOL_CALL) {
throw new IllegalStateException("Unsupported agent message: " + message.type());
}
ToolCallMessage toolCall = (ToolCallMessage) message;
if (!"searchDocs".equals(toolCall.toolName())) {
throw new IllegalArgumentException("Unsupported tool: " + toolCall.toolName());
}
// 关键词由 Agent 生成,检索工具只负责拿关键词搜索。
List<SearchHit> hits = searchTool.searchDocs(
toolCall.keywords(),
options.topKPerRound()
);
// 把工具结果放回对话记忆,下一轮模型会基于这些证据继续判断。
memory = memory.withToolResult(toolCall, hits);
searchRounds++;
}
return RagAnswer.insufficientEvidence(memory.searchedKeywords());
}
}
public enum AgentMessageType {
TOOL_CALL,
FINAL_ANSWER
}
public interface AgentMessage {
AgentMessageType type();
}
public record ToolCallMessage(
String toolName,
List<String> keywords
) implements AgentMessage {
@Override
public AgentMessageType type() {
return AgentMessageType.TOOL_CALL;
}
}
public record FinalAnswerMessage(
String content,
List<String> sources
) implements AgentMessage {
@Override
public AgentMessageType type() {
return AgentMessageType.FINAL_ANSWER;
}
}
这段代码只是表达结构。真实的 function calling 或 tool calling 返回值里,通常也会有类似的消息类型:要么是工具调用消息,要么是最终回答消息。后端不要猜模型想干什么,按消息类型分发就行。
如果消息是工具调用,后端读取 Agent 生成的关键词,调用 searchDocs,再把 SearchHit 写回对话记忆。while 继续下一轮,模型看到这些证据后,再决定继续查还是回答。
真实项目里还要补日志、超时、异常处理、权限上下文、token 控制和引用格式。这里点到结构就够了,代码不继续展开。
10. 先把工具边界做稳
Agentic RAG 第一版不用急着选框架。先把检索层包成一个稳定工具,让 Agent 每一轮只提交关键词。
List<SearchHit> searchDocs(List<String> keywords, int topK);
这个接口背后可以继续接 BM25、向量检索、多模态召回和 reranker。Agent 不需要知道这些细节,它只需要根据用户任务和上一轮 SearchHit,决定下一轮该查哪些关键词。
真正要盯紧的是日志。
每一轮都应该能看到:Agent 生成了哪些关键词,searchDocs 返回了哪些 SearchHit,Agent 为什么继续查,或者为什么开始回答。线上回答错了,排查也从这里开始:是资料没处理好,还是召回没命中,还是重排把结果排偏了,还是 Agent 太早停了。
这一组 RAG 入门文章到这里结束。后面真的落代码,问题会回到几个具体接口:文档怎么入库,关键词怎么进入检索层,SearchHit 怎么记录,最终回答怎么保留来源。能看清每一轮发生了什么,比多接一个框架更重要。