Gliding Horse L2 作战地图深度优化:给多 Agent 上下文装上“精准导航”

📅 2026/7/7 7:25:56
Gliding Horse L2 作战地图深度优化:给多 Agent 上下文装上“精准导航”
问题浮现当“全量查询”遇上“多角色并行”Gliding Horse 的任务由 PA计划、DA执行、CA检查、AA决策四种角色接力完成同一任务可能经历多轮 PDCA 循环。最初的 L2 查询设计非常直接所有跟某个任务相关的节点全部存入task_nodes[task_iri]查询时一股脑返回。write_node(node_iri, json_ld)提取 task_iritask_nodes[task_iri].push(iri)dispatch_agent(roleDA)query_nodes(task_iri)返回 ALL nodes(PA 计划 DA 历史 CA 审查 AA 决策)join() 成上下文这个“全量返回”的后果是DA 在编码时上下文里塞满了 PA 的策略讨论、CA 的挑错记录甚至 AA 的拍板结论。这些信息不仅 Token 白白浪费更严重的是让 LLM 产生注意力混淆——DA 可能被 PA 的试探性假设带偏CA 可能被 DA 的中间错误记录误导。与此同时我们还发现了另外两个关联问题L3 投影引擎被旁路SA 的dispatch_agent直接走 L2 全量查询没有调用按角色裁剪的投影帧导致投影系统的语义过滤能力在关键路径上失效。SessionSummary IRI 不规范跨会话归档时使用随机 UUID IRI导致extract_task_iri无法将历史摘要关联回原任务进一步加剧了上下文关联的混乱。二、优化目标给每个 Agent 只属于它的视角我们要实现的核心能力是当 DA 请求上下文时只返回历史 DA 轮次的内容不混入 PA/CA/AA 的数据当进入第二轮 PDCA 时第二轮的计划者也只能看到本轮及前一周期相关角色的信息避免跨周期干扰。这就需要在原有task_nodes的基础上建立一套多维二级索引让查询可以按角色、周期、节点类型精准过滤。L2 多维索引层dispatch_agent() 优化后路径角色特定投影帧降级路径SA 调度器优先路径L3 Projection EngineL2 Filtered Queryrole_index按 (task, role) 索引cycle_index按 (task, cycle) 索引type_index按 (task, node_type) 索引task_nodes(全量存储)三、多维度索引设计我们在Blackboard结构体中新增了三张内存 HashMap 索引分别覆盖 Agent 角色、PDCA 周期和节点类型。pub struct Blackboard { // 原有全量存储 task_nodes: RwLockHashMapString, VecString, // 新增二级索引 /// role_index[(task_iri, role)] → Vecnode_iri role_index: RwLockHashMap(String, AgentRole), VecString, /// cycle_index[(task_iri, cycle_id)] → Vecnode_iri cycle_index: RwLockHashMap(String, String), VecString, /// type_index[(task_iri, node_type)] → Vecnode_iri type_index: RwLockHashMap(String, String), VecString, }为什么用内存 HashMap 而不是 SPARQL 查询因为dispatch_agent是高频调用路径SPARQL 查询涉及序列化/反序列化延迟较高。二级索引是 O(1) 查找且与 Oxigraph 中的数据同步更新兼顾性能与一致性。索引构建在write_node时除了写入全量存储和 Oxigraph同步更新三维索引如果传入role和task_iri则加入role_index如果传入cycle_id则加入cycle_index如果节点包含type则加入type_index。这要求调用write_node的地方传递role和cycle_id我们沿着调用链一路回溯在 Agent 每轮 ReAct 循环写入AgentTurn节点时附带了这些元数据。四、Filtered Query上下文查询的精准投送新增的query_nodes_filtered方法接受一个QueryFilter结构体pub struct QueryFilter { pub role: OptionAgentRole, pub cycle_id: OptionString, pub node_type: OptionString, }查询逻辑是先获取全量 IRI 列表然后与各项过滤条件的索引结果取交集最后从节点缓存中读取实体。当 DA 请求上下文时传入roleDA, cycle_id当前周期就能得到仅包含本周期 DA 轮次的节点彻底隔绝其他角色的信息。下面是一个完整的调用示例展示如何实例化QueryFilter、调用query_nodes_filtered并处理查询结果use std::sync::Arc; use tokio::sync::RwLock; /// 假设已有 Blackboard 实例 let blackboard: ArcBlackboard /* 从调度器获取 */; /// 构造查询过滤器只查询 DA 角色、当前周期的节点 let filter QueryFilter { role: Some(AgentRole::DA), cycle_id: Some(cycle-2026-07-02-001.to_string()), node_type: None, // 不过滤节点类型 }; /// 执行过滤查询 let node_iris blackboard .query_nodes_filtered(iri://task/task-42, filter) .await .expect(过滤查询失败); /// 从节点缓存中读取完整实体数据 let mut context_parts: VecString Vec::new(); for iri in node_iris { if let Some(node) blackboard.node_cache.read().await.get(iri) { // 将节点序列化为 JSON-LD 片段加入上下文 context_parts.push(serde_json::to_string_pretty(node).unwrap()); } } /// 组装成最终上下文按时间戳排序保证顺序 let context context_parts.join(\n---\n); /// 输出统计信息 println!( DA 角色上下文组装完成共 {} 个节点{} 字符, node_iris.len(), context.len() );query_nodes_filtered的内部实现如下——它利用三维索引做交集运算避免全量扫描impl Blackboard { pub async fn query_nodes_filtered( self, task_iri: str, filter: QueryFilter, ) - ResultVecString, BlackboardError { // 1. 获取该任务的全量节点列表 let all_nodes self.task_nodes.read().await .get(task_iri) .cloned() .unwrap_or_default(); // 2. 如果没有任何过滤条件直接返回全量 if filter.role.is_none() filter.cycle_id.is_none() filter.node_type.is_none() { return Ok(all_nodes); } // 3. 收集所有非空过滤条件的索引结果 let mut candidate_sets: VecHashSetString Vec::new(); if let Some(role) filter.role { let key (task_iri.to_string(), role.clone()); let set self.role_index.read().await .get(key) .cloned() .unwrap_or_default() .into_iter() .collect::HashSet_(); candidate_sets.push(set); } if let Some(cycle_id) filter.cycle_id { let key (task_iri.to_string(), cycle_id.clone()); let set self.cycle_index.read().await .get(key) .cloned() .unwrap_or_default() .into_iter() .collect::HashSet_(); candidate_sets.push(set); } if let Some(node_type) filter.node_type { let key (task_iri.to_string(), node_type.clone()); let set self.type_index.read().await .get(key) .cloned() .unwrap_or_default() .into_iter() .collect::HashSet_(); candidate_sets.push(set); } // 4. 取所有候选集的交集 let mut intersection: HashSetString candidate_sets .into_iter() .reduce(|a, b| a.intersection(b).cloned().collect()) .unwrap_or_default(); // 5. 只保留属于该任务的节点安全兜底 let task_set: HashSetString all_nodes.into_iter().collect(); intersection.retain(|iri| task_set.contains(iri)); Ok(intersection.into_iter().collect()) } }这段代码的核心思路是先按每个非空过滤条件从对应索引中取出候选 IRI 集合再对所有候选集取交集。这样当 DA 同时指定roleDA和cycle_id当前周期时只有同时满足两个条件的节点才会被返回实现了精准的上下文隔离。五、dispatch_agent 上下文组装重写原来的dispatch_agent中有一段硬编码逻辑为了避免 AA 看到大量无关数据直接跳过了 L2 查询用一个单独的prev_agent_summary代替。这种“打补丁”的方式显然不够优雅。优化后的路径采用双级回退优先走 L3 投影引擎scheduler.on_context_request(role, task_iri)会加载该角色专属的投影帧如pa_init、da_input利用 SPARQL 和语义增强生成高度结构化的上下文摘要。降级走 Filtered Query若投影引擎未命中或不可用则使用query_nodes_filtered按角色和周期精准拉取 L2 节点自行组装上下文。兜底使用历史摘要万一前两级都失败返回之前保存的精简摘要作为最低保障。这样每个 Agent 获得的上下文都经过了“角色过滤 周期过滤 类型过滤”三重筛选噪音降到最低。六、关联修复让数据“认得家”我们还修复了两个导致上下文混乱的细节cycle_id贯穿全链路从TaskContext到AgentTurn节点再到dispatch_agent查询cycle_id作为明确定位符一路携带确保每个节点都清楚自己属于哪个周期。SessionSummary IRI 规范化跨会话归档的 IRI 从iri://memory/{uuid}改为iri://task/{task_id}/session/{session_id}让归档摘要可以正确关联回原任务不再游离在外。七、优化成效从“大杂烩”到“分子料理”指标优化前优化后DA 单轮上下文大小包含 PA DA CA AA 全部历史约 8000 tokens仅本周期 DA 历史 任务目标约 2000 tokens跨周期干扰第二轮 PA 能看到第一轮 CA 的审计细节严格隔离每个周期初始只保留上一周期决策摘要投影引擎命中率0%被硬编码旁路 90%优先路径Agent 注意力准确性DA 可能被 PA 假设误导CA 可能被 DA 中间错误干扰每个 Agent 只看到自己角色的上下文决策更加专注在实际测试中修复后的多角色任务执行不再出现“DA 突然开始质疑计划”或“CA 重复 DA 的工作”这类怪事。上下文 Token 消耗降低了 60% 以上而任务的完成质量却因为注意力集中而明显提升。