Gliding Horse L2 作战地图深度优化:给多 Agent 上下文装上“精准导航”
问题浮现:当“全量查询”遇上“多角色并行”
Gliding Horse 的任务由 PA(计划)、DA(执行)、CA(检查)、AA(决策)四种角色接力完成,同一任务可能经历多轮 PDCA 循环。最初的 L2 查询设计非常直接:所有跟某个任务相关的节点全部存入task_nodes[task_iri],查询时一股脑返回。
write_node(node_iri, json_ld)
提取 task_iri
task_nodes[task_iri].push(iri)
dispatch_agent(role=DA)
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 Engine
L2 Filtered Query
role_index
按 (task, role) 索引
cycle_index
按 (task, cycle) 索引
type_index
按 (task, node_type) 索引
task_nodes
(全量存储)
三、多维度索引设计
我们在Blackboard结构体中新增了三张内存 HashMap 索引,分别覆盖 Agent 角色、PDCA 周期和节点类型。
pub struct Blackboard { // 原有全量存储 task_nodes: RwLock<HashMap<String, Vec<String>>>, // 新增二级索引 /// role_index[(task_iri, role)] → Vec<node_iri> role_index: RwLock<HashMap<(String, AgentRole), Vec<String>>>, /// cycle_index[(task_iri, cycle_id)] → Vec<node_iri> cycle_index: RwLock<HashMap<(String, String), Vec<String>>>, /// type_index[(task_iri, node_type)] → Vec<node_iri> type_index: RwLock<HashMap<(String, String), Vec<String>>>, }为什么用内存 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: Option<AgentRole>, pub cycle_id: Option<String>, pub node_type: Option<String>, }查询逻辑是先获取全量 IRI 列表,然后与各项过滤条件的索引结果取交集,最后从节点缓存中读取实体。当 DA 请求上下文时,传入role=DA, cycle_id=当前周期,就能得到仅包含本周期 DA 轮次的节点,彻底隔绝其他角色的信息。
下面是一个完整的调用示例,展示如何实例化QueryFilter、调用query_nodes_filtered并处理查询结果:
use std::sync::Arc; use tokio::sync::RwLock; /// 假设已有 Blackboard 实例 let blackboard: Arc<Blackboard> = /* 从调度器获取 */; /// 构造查询过滤器:只查询 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: Vec<String> = 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, ) -> Result<Vec<String>, 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: Vec<HashSet<String>> = 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: HashSet<String> = candidate_sets .into_iter() .reduce(|a, b| a.intersection(&b).cloned().collect()) .unwrap_or_default(); // 5. 只保留属于该任务的节点(安全兜底) let task_set: HashSet<String> = all_nodes.into_iter().collect(); intersection.retain(|iri| task_set.contains(iri)); Ok(intersection.into_iter().collect()) } }这段代码的核心思路是:先按每个非空过滤条件从对应索引中取出候选 IRI 集合,再对所有候选集取交集。这样当 DA 同时指定role=DA和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% 以上,而任务的完成质量却因为注意力集中而明显提升。