第三章 从0搭建企业级HarnessAgent项目-多 Agent 协作 + RAG + 工具生态
阶段三:多 Agent 协作 + RAG设计 + 工具生态
这是 LingNova 机器人行业智能管家项目的第三篇阶段性记录。前两阶段,我先跑通了 Spring AI 对话和工具调用,又用 AgentScope HarnessAgent 补齐了会话、记忆、工作区这些工程底座。到了阶段三,问题开始变成:一个 Agent 能不能把复杂任务做稳?回答能不能基于真实资料?工具偶尔失败时,整条对话会不会直接崩掉?
不出意外的话预计会在下个阶段整理完整项目代码进行开源
文章目录
- 阶段三:多 Agent 协作 + RAG设计 + 工具生态
- 一、阶段三所解决的问题
- 1. 一个 Agent 什么都做,复杂任务容易散
- 2. 模型不能只靠“记忆”回答专业问题
- 3. Tool 失败不应该拖垮整次对话
- 4. 多 Agent 需要防止“互相绕圈”
- 二、阶段三的整体架构
- 三、多 Agent 协作
- 1. Supervisor 分工
- 2. 防止无限调用工具
- 四、RAG设计
- 1. 文档如何进入知识库
- 2. 混合检索
- 3. RRF 融合
- 4. 冲突检测和上下文压缩
- 五、工具生态:内部可复用,外部也能接入
- 六、工具可靠性:让一次失败不等于整次失败
- 七、 RAG 评估集和指标计算
- 八、结语
- 1. 别把多 Agent 当作“更强的单 Agent”
- 2. RAG 最难的不是接向量库
- 3. 可靠性逻辑要统一,不要散落在每个工具里
模型只是“会思考”的部分,真正拉开差距的,是给它配上可协作的分工、可靠的知识入口和不容易出故障的工具通道。
一、阶段三所解决的问题
阶段二已经有了一个可持久化的 HarnessAgent:能恢复会话,也有短期上下文和长期记忆。但如果只停在这一层,它依然更像一个“功能多一点的聊天机器人”。
实际使用时,我主要遇到四类问题。
1. 一个 Agent 什么都做,复杂任务容易散
“推荐一款焊接机器人”这种问题,一个主 Agent 完全能处理。但如果用户说:“按 50 万预算调研几款焊接机器人,比较参数,再给我写一份选型报告”,它既要检索、又要比对、还要写作,推理链会变长,也更难约束。
阶段三把不同类型的工作拆给更专注的子 Agent:
product-advisor:做产品选型和对比;troubleshooter:做故障排查;news-aggregator:整理行业资讯;writer:把资料整理成结构化报告。
主 Agent 仍然是 Supervisor,也就是总调度。它不必亲自做完一切,而是判断要不要委派、委派给谁、最后如何汇总。
2. 模型不能只靠“记忆”回答专业问题
机器人领域有两类典型信息:一类是“电机过热”和“关节温度过高”这类同义表达;另一类是E-203、产品型号、标准编号这类精确关键词。单纯让模型凭已有知识回答,很容易过时或者编造;只靠一种检索方式,也容易漏掉其中一类信息。
所以阶段三的 RAG 不只是“向量库查一下”,而是一条完整链路:先改写问题,再同时做语义检索和关键词检索,合并结果后重排、检测冲突、压缩上下文,最后再交给 Agent 回答。
3. Tool 失败不应该拖垮整次对话
业务服务总会有瞬时超时、网络抖动,模型还可能重复请求同一个只读工具。如果每个 Tool 自己处理异常,代码会越来越散,行为也很难保持一致。
阶段三把超时、重试、熔断、缓存和请求追踪收敛到一个统一包装器里。业务工具只关心“查什么”,可靠性逻辑交给公共层处理。
4. 多 Agent 需要防止“互相绕圈”
多 Agent 最让人头疼的不是创建子 Agent,而是异常情况下可能反复委派同一个任务。即使设置最大迭代次数,也只是限制“能走几步”;如果两次结果完全一样,继续走本身就没有意义。
这一阶段新增了结果指纹检测:同一个会话里,同一个子 Agent 连续返回相同结果两次,就停止继续委派,并提示人工介入。
二、阶段三的整体架构
用户请求 -> SessionController / SessionService -> HarnessGateway(同一 session 串行) -> HarnessAgent / Supervisor ├─ 子 Agent:选型、排障、资讯、写作 ├─ HarnessToolAdapter │ -> ToolExecutionWrapper(超时、重试、熔断、缓存) │ -> 产品 / 文章 / 知识库业务工具 └─ queryKnowledge -> RAG Pipeline -> 向量检索 + PostgreSQL 全文检索 -> 融合、重排、冲突检测、上下文压缩 外部 MCP 客户端 -> MCP Server -> 同一套产品、文章、知识库工具项目仍然保留了阶段一的/api/ai/chat/*轻量对话入口,它走的是 Spring AIChatClient;真正走 HarnessAgent 的入口是/api/agent/v1/sessions/*。现在保留两条链路是为了兼容和对比,后续会逐步统一,避免前端调用错入口。
三、多 Agent 协作
1. Supervisor 分工
子 Agent 的定义很像给团队成员写岗位说明:描述清楚它擅长什么、能使用哪些工具、最多允许推理多少步。以产品顾问为例:
privateSubagentDeclarationproductAdvisorDeclaration(StringmodelName){returnSubagentDeclaration.builder().name("product-advisor").description("机器人产品选型顾问,擅长根据预算、场景、品牌做产品推荐和对比分析。").model(modelName).maxIters(10).workspaceMode(WorkspaceMode.SHARED).tools(List.of("searchRobots","queryRobots","getRobotDetail","getRecommendedRobots","queryKnowledge")).build();}这里我刻意没有把所有工具都给每个子 Agent。产品顾问不需要文章发布能力,资讯助手也不该拥有和选型无关的工具。这是最小权限原则,在 Agent 项目里比传统接口更重要,因为模型会根据描述自行尝试调用工具。
WorkspaceMode.SHARED的作用是让子 Agent 能共享主 Agent 的领域知识和长期记忆。比如用户已经说明过产线、预算和品牌偏好,写作 Agent 不必再让用户重复交代一遍。
2. 防止无限调用工具
maxIters是硬保护:例如产品顾问最多推理 10 步,防止无限调用工具。但它没法判断这 10 步是不是有效工作。
因此我又加了一层SubagentLoopGuard。它用“会话 ID + 子 Agent ID”作为隔离键,先把输出里的空白格式统一,再计算 SHA-256 指纹。若连续两次指纹相同,就认为这不是新进展:
/** * 记录子 Agent 的一次输出,并检测是否构成循环。 * * <p>调用方(通常是 {@link SubagentResultLoopMiddleware})在子 Agent 完成任务后调用此方法。 * 方法内部会:</p> * <ol> * <li>计算当前输出的指纹</li> * <li>与同一会话+子Agent的上一次指纹比较</li> * <li>若相同则连续计数 +1,否则重置为 1</li> * <li>连续计数达到阈值时返回 terminate=true 的决定</li> * </ol> * * @param sessionId 会话ID,用于多轮对话隔离 * @param subagentId 子 Agent 标识(如 "writer"、"troubleshooter") * @param result 子 Agent 的输出文本 * @return 决定对象,包含是否终止、连续重复次数、指纹和提示消息 */publicDecisionrecord(StringsessionId,StringsubagentId,Stringresult){Keykey=newKey(sessionId,subagentId);Stringfingerprint=fingerprint(result);Statestate=states.compute(key,(ignored,previous)->{if(previous!=null&&previous.fingerprint().equals(fingerprint)){returnnewState(fingerprint,previous.consecutiveCount()+1);}returnnewState(fingerprint,1);});booleanterminate=state.consecutiveCount()>=2;Stringmessage=terminate?"HUMAN_INTERVENTION_REQUIRED: repeated subagent result":"CONTINUE";returnnewDecision(terminate,state.consecutiveCount(),fingerprint,message);}- 为什么要先归一化再计算哈希?
因为“结果 A”和“结果 A”只是排版不同,不应该被误判为两份新结果。 - 为什么按会话和子 Agent 双维度保存?
因为不同用户、不同专业 Agent 的输出本来就不应该互相影响。
这让我认识到一个很实际的事实:多 Agent 不是把任务拆开就结束了,终止条件同样是协议的一部分。否则系统只是在更快地烧 Token。
四、RAG设计
RAG 是 Retrieval-Augmented Generation 的缩写,翻译成“检索增强生成”。不需要把它想得太玄:回答前先从自己的资料库找相关内容,把找到的片段塞进上下文,再让模型回答。
它解决的不是“模型会不会说话”,而是“模型有没有依据”。对于机器人产品参数、故障码、操作手册这类会变化、需要准确引用的信息,RAG 比单纯相信模型记忆可靠得多。
1. 文档如何进入知识库
项目支持 PDF、Word、Markdown 等资料。KnowledgeFileProcessor负责解析文本、切片并加上元数据;KnowledgeDirectoryIndexer扫描workspace/knowledge,根据内容哈希判断文件有没有变化,只索引新增或修改的文件。
原始文档 -> 文本解析(PDF / Word / Markdown) -> 分片(chunk)+ 少量重叠 -> 添加 source、contentHash、chunkIndex 等元数据 -> 写入 PGVector分片之间保留少量重叠并不是形式主义。一条故障原因和解决步骤很可能刚好落在两个分片边界;没有重叠时,检索到的内容容易断句、缺上下文。
内容哈希则有两个实际用途:一是增量索引,文件没变就不重复入库;二是在后面的混合检索中做去重。
2. 混合检索
向量检索擅长理解语义。例如用户说“机器人过热”,它能找到“关节电机温度异常”的资料;
全文检索擅长精确命中,例如用户直接问E-203错误码。
两者各有盲区,因此 RAG 管线同时使用 PGVector 和 PostgreSQL 全文检索:
用户问题 -> 查询改写 -> 向量检索(理解意思) -> 全文检索(命中型号/错误码) -> RRF 融合 + 去重 -> 轻量重排 -> 冲突标记 -> 上下文压缩 -> Agent 基于资料回答RagPipeline把这条链路明确地串起来:
/** * 执行完整 RAG 检索流程。 * * <p>先扩大召回范围(topK*2),再通过重排精选 topK 条, * 兼顾召回率和精确度。最终生成可供大模型直接使用的上下文文本。</p> * * @param query 原始问题 * @param topK 最终返回的文档数量 * @param docType 可选的文档类型过滤(如 "manual"、"article") * @return RAG 检索结果,包含文档列表、上下文文本和冲突标记 */publicRagResponseretrieve(Stringquery,inttopK,StringdocType){Stringrewritten=queryRewriter.rewrite(query);List<ScoredDocument>candidates=hybridRetriever.retrieve(rewritten,Math.max(topK*2,topK),docType);List<ScoredDocument>reranked=reranker.rerank(rewritten,candidates).stream().limit(topK).toList();returnnewRagResponse(query,rewritten,reranked,contextCompressor.compress(reranked),conflictDetector.hasConflict(reranked));}这段代码背后有一个小策略:先召回topK * 2个候选,再重排取最终topK。如果一开始只取很少的候选,后面再好的重排器也无从选择。
3. RRF 融合
向量相似度和全文检索的分数不是同一把尺子,直接相加很容易失真。这里采用 RRF(Reciprocal Rank Fusion,倒数排名融合),只看文档在各个通道中的名次。
privatevoidmerge(Map<String,ScoredDocument>fused,List<ScoredDocument>ranked){for(intindex=0;index<ranked.size();index++){ScoredDocumentcandidate=ranked.get(index);doublerrfScore=1.0/(rankConstant+index+1.0);Stringkey=identity(candidate);fused.merge(key,newScoredDocument(candidate.document(),rrfScore,candidate.channels()),(existing,incoming)->existing.merge(incoming.score(),incoming.channels()));}}一个片段同时被向量检索和全文检索找到,分数会累加,说明它既“语义相关”又“关键词精确”;如果两个通道各自命中不同内容,也能增加整体召回覆盖面。
当前重排是轻量级的词法重排,优点是低成本、可解释、方便本地运行。它不是最终形态,后续可以替换为 Cross-Encoder 或云端 Rerank 模型,但管线接口不需要改。
4. 冲突检测和上下文压缩
知识库并不总是正确、一致的。不同版本手册可能对同一个错误码给出不同说明。当前ConflictDetector会对可疑结果打标,而不是假装自动裁判真伪;最终回答可以据此提示用户“资料存在差异,建议核对设备版本”。
ContextCompressor则负责限制最终上下文长度。RAG 的目标不是把所有文档塞进 Prompt,而是把最有用、且模型放得下的资料交给模型。越长不代表越好,很多时候只会增加成本和干扰。
五、工具生态:内部可复用,外部也能接入
阶段三对工具做了两件看起来相近、实际方向不同的事。
第一件是内部适配:HarnessToolAdapter把已有的机器人、文章、知识库业务方法适配给 HarnessAgent 调用。
第二件是对外发布:通过 Spring AI MCP Server,把同一批只读能力暴露为 MCP 工具:
/** * 注册 MCP 工具提供者。 * * <p>使用 Spring AI 的 {@link MethodToolCallbackProvider} 扫描带有 {@code @Tool} 注解的方法, * 将其注册为 MCP 工具。MCP 客户端连接后可以列出所有可用工具并按需调用。</p> * * @param robotProductTool 机器人产品查询工具 * @param robotQueryTool 文章查询工具 * @param knowledgeQueryTool 知识库检索工具 * @return 工具回调提供者 */@BeanpublicToolCallbackProviderrobotMcpTools(RobotProductToolrobotProductTool,RobotQueryToolrobotQueryTool,KnowledgeQueryToolknowledgeQueryTool){returnMethodToolCallbackProvider.builder().toolObjects(robotProductTool,robotQueryTool,knowledgeQueryTool).build();}MCP(Model Context Protocol)可以理解成 AI 工具的“通用插座”。当工具按协议发布后,支持 MCP 的其他 Agent、IDE 插件或客户端都可以发现并调用它,不必为每个调用方再写一套专属接口。
在这个项目里,LingNova 同时具备两种角色:
- MCP Client:可以调用外部 MCP 工具;
- MCP Server:把自己的机器人、文章、知识库查询能力提供给外部。
这比“工具只服务于当前这个 Agent”更接近平台化设计。
六、工具可靠性:让一次失败不等于整次失败
工具调用是 Agent 连接现实世界的地方,也是最容易出问题的地方。阶段三把通用保护抽到ToolExecutionWrapper中:
/** * 在统一可靠性策略下执行只读工具。 * * <p>执行流程:</p> * <ol> * <li>生成唯一 requestId 并写入 MDC</li> * <li>检查幂等缓存,命中则直接返回(标记 replayed=true)</li> * <li>依次应用超时(5s)、重试(3次)、熔断保护</li> * <li>成功后缓存结果,失败则抛出 {@link ToolExecutionException}</li> * </ol> * * @param toolName 工具名称,用于隔离重试和熔断配置 * @param arguments 调用参数,同时作为缓存键 * @param supplier 实际工具执行逻辑 * @return 包含 requestId、结果和是否缓存命中的结果对象 */public<T>ToolResult<T>execute(StringtoolName,List<?>arguments,Supplier<T>supplier){StringrequestId=UUID.randomUUID().toString();StringcacheKey=toolName+':'+arguments;CacheEntrycached=idempotencyCache.get(cacheKey);if(cached!=null&&!cached.isExpired(cacheTtl)){returnnewToolResult<>(requestId,(T)cached.value(),true);}try{Callable<T>timed=TimeLimiter.decorateFutureSupplier(TimeLimiter.of(toolName,TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5)).cancelRunningFuture(true).build()),()->executor.submit(supplier::get));Callable<T>retrying=Retry.decorateCallable(retry(toolName),timed);Tvalue=circuitBreaker(toolName).executeCallable(retrying);idempotencyCache.put(cacheKey,newCacheEntry(value,System.nanoTime()));returnnewToolResult<>(requestId,value,false);}catch(Exceptionexception){thrownewToolExecutionException(toolName,requestId,exception);}}这段代码统一实现了五个点:
- 每次调用生成
requestId,便于串联日志; - 相同只读请求五分钟内直接复用缓存结果;
- 单次调用超过五秒自动取消;
- 短暂异常最多重试三次,间隔 200ms;
- 单个工具近期失败率过高时熔断,30 秒后再尝试恢复。
这里必须强调一个边界:当前缓存和自动重试只适用于只读工具。对于“发送邮件”“写数据库”“发起支付”之类有副作用的操作,不能直接照搬这套策略,必须先设计真正的幂等键、审计和人工确认。这会是下一阶段治理能力的重点。
七、 RAG 评估集和指标计算
阶段三还补了 RAG 评估集和指标计算。评估集放在evaluation/rag-cases.jsonl,每条包含问题和期望命中的资料;评估服务会逐条跑 RAG 管线,计算:
Recall@5:正确资料有没有出现在前 5 条里;MRR:第一条正确资料排得有多靠前;NDCG:整体排序是否把更相关的资料排在前面。
这些指标只说明“资料找得怎么样”,不能直接证明最终模型回答一定正确。回答质量还需要结合忠实度、完整性、工具选择准确率等指标评估。能把“检索不好”和“模型组织语言不好”拆开看,是这套评估最有价值的地方。
项目也为 RAG、索引、循环检测等核心逻辑补了 JUnit 测试。例如混合检索测试会人工构造两路结果,验证去重和 RRF 融合顺序;循环守卫测试验证不同会话、不同子 Agent 之间不会互相误判。
八、结语
1. 别把多 Agent 当作“更强的单 Agent”
一开始很容易觉得,多加几个 Agent,能力就会自动叠加。实际不是这样。分工不清、输入输出不明确、终止条件缺失时,多 Agent 只会把错误放大。
我这次给每个子 Agent 限定工具集合和最大迭代次数,再加结果指纹检测,本质上是在给协作建立一套最小协议。后面如果做更复杂的工作流,还需要补任务状态、结构化结果和人工介入机制。
2. RAG 最难的不是接向量库
第一次接 PGVector 时,很快就能“搜到东西”,但搜到的东西不一定对。真正花时间的是:怎么切片、如何保留元数据、精确词和同义词如何兼顾、两路结果怎么融合、上下文给多长合适。
所以我把 RAG 拆成小组件:改写器、检索器、融合器、重排器、冲突检测器、压缩器。这样即使以后替换其中一个实现,也不必推倒整条链路。
3. 可靠性逻辑要统一,不要散落在每个工具里
如果在searchRobots、queryKnowledge、searchArticles里分别写超时和重试,早晚会出现配置不一致、某个新工具忘记加保护的问题。公共包装器能把横切逻辑收拢起来,业务代码会干净很多。
但这也提醒我:抽象不是一劳永逸。当前缓存是进程内缓存、线程池也是简单实现;真正多实例部署时需要迁到 Redis,并改成 Spring 托管的有界线程池。先完成一版可用的边界,再为生产化留下清晰的升级路径,比一开始过度设计更合适。
这次最让我满意的不是又接入了多少框架,而是每一个组件都有明确要解决的问题:协作要有终止条件,检索要有评估,工具要有失败策略。Agent 项目要走向真实业务,靠的正是这些看起来不那么“炫”的工程细节。
