第三章 从0搭建企业级HarnessAgent项目-多 Agent 协作 + RAG + 工具生态

发布时间:2026/7/29 19:14:52
第三章 从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 万预算调研几款焊接机器人比较参数再给我写一份选型报告”它既要检索、又要比对、还要写作推理链会变长也更难约束。阶段三把不同类型的工作拆给更专注的子 Agentproduct-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连续计数达到阈值时返回 terminatetrue 的决定/li * /ol * * param sessionId 会话ID用于多轮对话隔离 * param subagentId 子 Agent 标识如 writer、troubleshooter * param result 子 Agent 的输出文本 * return 决定对象包含是否终止、连续重复次数、指纹和提示消息 */publicDecisionrecord(StringsessionId,StringsubagentId,Stringresult){KeykeynewKey(sessionId,subagentId);Stringfingerprintfingerprint(result);Statestatestates.compute(key,(ignored,previous)-{if(previous!nullprevious.fingerprint().equals(fingerprint)){returnnewState(fingerprint,previous.consecutiveCount()1);}returnnewState(fingerprint,1);});booleanterminatestate.consecutiveCount()2;Stringmessageterminate?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){StringrewrittenqueryRewriter.rewrite(query);ListScoredDocumentcandidateshybridRetriever.retrieve(rewritten,Math.max(topK*2,topK),docType);ListScoredDocumentrerankedreranker.rerank(rewritten,candidates).stream().limit(topK).toList();returnnewRagResponse(query,rewritten,reranked,contextCompressor.compress(reranked),conflictDetector.hasConflict(reranked));}这段代码背后有一个小策略先召回topK * 2个候选再重排取最终topK。如果一开始只取很少的候选后面再好的重排器也无从选择。3. RRF 融合向量相似度和全文检索的分数不是同一把尺子直接相加很容易失真。这里采用 RRFReciprocal Rank Fusion倒数排名融合只看文档在各个通道中的名次。privatevoidmerge(MapString,ScoredDocumentfused,ListScoredDocumentranked){for(intindex0;indexranked.size();index){ScoredDocumentcandidateranked.get(index);doublerrfScore1.0/(rankConstantindex1.0);Stringkeyidentity(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();}MCPModel Context Protocol可以理解成 AI 工具的“通用插座”。当工具按协议发布后支持 MCP 的其他 Agent、IDE 插件或客户端都可以发现并调用它不必为每个调用方再写一套专属接口。在这个项目里LingNova 同时具备两种角色MCP Client可以调用外部 MCP 工具MCP Server把自己的机器人、文章、知识库查询能力提供给外部。这比“工具只服务于当前这个 Agent”更接近平台化设计。六、工具可靠性让一次失败不等于整次失败工具调用是 Agent 连接现实世界的地方也是最容易出问题的地方。阶段三把通用保护抽到ToolExecutionWrapper中/** * 在统一可靠性策略下执行只读工具。 * * p执行流程/p * ol * li生成唯一 requestId 并写入 MDC/li * li检查幂等缓存命中则直接返回标记 replayedtrue/li * li依次应用超时5s、重试3次、熔断保护/li * li成功后缓存结果失败则抛出 {link ToolExecutionException}/li * /ol * * param toolName 工具名称用于隔离重试和熔断配置 * param arguments 调用参数同时作为缓存键 * param supplier 实际工具执行逻辑 * return 包含 requestId、结果和是否缓存命中的结果对象 */publicTToolResultTexecute(StringtoolName,List?arguments,SupplierTsupplier){StringrequestIdUUID.randomUUID().toString();StringcacheKeytoolName:arguments;CacheEntrycachedidempotencyCache.get(cacheKey);if(cached!null!cached.isExpired(cacheTtl)){returnnewToolResult(requestId,(T)cached.value(),true);}try{CallableTtimedTimeLimiter.decorateFutureSupplier(TimeLimiter.of(toolName,TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5)).cancelRunningFuture(true).build()),()-executor.submit(supplier::get));CallableTretryingRetry.decorateCallable(retry(toolName),timed);TvaluecircuitBreaker(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 管线计算Recall5正确资料有没有出现在前 5 条里MRR第一条正确资料排得有多靠前NDCG整体排序是否把更相关的资料排在前面。这些指标只说明“资料找得怎么样”不能直接证明最终模型回答一定正确。回答质量还需要结合忠实度、完整性、工具选择准确率等指标评估。能把“检索不好”和“模型组织语言不好”拆开看是这套评估最有价值的地方。项目也为 RAG、索引、循环检测等核心逻辑补了 JUnit 测试。例如混合检索测试会人工构造两路结果验证去重和 RRF 融合顺序循环守卫测试验证不同会话、不同子 Agent 之间不会互相误判。八、结语1. 别把多 Agent 当作“更强的单 Agent”一开始很容易觉得多加几个 Agent能力就会自动叠加。实际不是这样。分工不清、输入输出不明确、终止条件缺失时多 Agent 只会把错误放大。我这次给每个子 Agent 限定工具集合和最大迭代次数再加结果指纹检测本质上是在给协作建立一套最小协议。后面如果做更复杂的工作流还需要补任务状态、结构化结果和人工介入机制。2. RAG 最难的不是接向量库第一次接 PGVector 时很快就能“搜到东西”但搜到的东西不一定对。真正花时间的是怎么切片、如何保留元数据、精确词和同义词如何兼顾、两路结果怎么融合、上下文给多长合适。所以我把 RAG 拆成小组件改写器、检索器、融合器、重排器、冲突检测器、压缩器。这样即使以后替换其中一个实现也不必推倒整条链路。3. 可靠性逻辑要统一不要散落在每个工具里如果在searchRobots、queryKnowledge、searchArticles里分别写超时和重试早晚会出现配置不一致、某个新工具忘记加保护的问题。公共包装器能把横切逻辑收拢起来业务代码会干净很多。但这也提醒我抽象不是一劳永逸。当前缓存是进程内缓存、线程池也是简单实现真正多实例部署时需要迁到 Redis并改成 Spring 托管的有界线程池。先完成一版可用的边界再为生产化留下清晰的升级路径比一开始过度设计更合适。这次最让我满意的不是又接入了多少框架而是每一个组件都有明确要解决的问题协作要有终止条件检索要有评估工具要有失败策略。Agent 项目要走向真实业务靠的正是这些看起来不那么“炫”的工程细节。