Gliding Horse L2 作战地图深度优化:给多 Agent 上下文装上"精准导航"
摘要:本文深入解析 Gliding Horse 多 Agent 系统中 L2 作战地图的上下文污染问题,提出一套基于多维索引(角色、周期、节点类型)的上下文隔离方案。通过 Filtered Query 与 L3 投影引擎的双级回退机制,实现每个 Agent 只获取与其角色相关的精准上下文,Token 消耗降低 60% 以上,彻底解决多角色并行时的注意力混淆问题。适合对多 Agent 系统、Rust 工程化、语义上下文管理感兴趣的开发者阅读。
关键词:Gliding Horse;多 Agent 系统;上下文隔离;L2 作战地图;多维索引;Filtered Query;投影引擎;Agent 调度;Rust;PDCA 循环
在上一篇文章中,我们拆解了 Gliding Horse 的 L2 共享黑板如何化身一张"作战地图",让调度器 SA 实时掌握所有 Agent 的状态、资源锁和协调消息。那张地图解决了"谁能干什么、谁在干什么"的问题,但随着系统承载的任务越来越复杂,一个新的问题浮出水面:多个 Agent 在同一个任务里来回读写,上下文会不会互相污染?
答案是:会,而且非常严重。本文将详细解析我们如何发现这个"上下文污染"问题,以及如何通过一套多维索引和上下文隔离机制,让 L2 从"能看"进化到"能精准筛选",为每个 Agent 提供零噪音的上下文。
一、问题浮现:当"全量查询"遇上"多角色并行"
Gliding Horse 的任务由 PA(计划)、DA(执行)、CA(检查)、AA(决策)四种角色接力完成,同一任务可能经历多轮 PDCA 循环。最初的 L2 查询设计非常直接:所有跟某个任务相关的节点全部存入 task_nodes[task_iri],查询时一股脑返回。
flowchart LR A"write_node(node_iri, json_ld)" --> B"提取 task_iri" B --> C"task_nodes\[task_iri.push(iri)"] D"dispatch_agent(role=DA)" --> E"query_nodes(task_iri)" E --> F"返回 ALL nodes\
(PA 计划 + DA 历史 + CA 审查 + AA 决策)" F --> G"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 的基础上,建立一套多维二级索引,让查询可以按角色、周期、节点类型精准过滤。
graph TB subgraph "dispatch_agent() 优化后路径" SA"SA 调度器" --> Choose{"优先路径"} Choose -->|"角色特定投影帧"| L3"L3 Projection Engine" Choose -->|"降级路径"| L2F"L2 Filtered Query" end subgraph "L2 多维索引层" direction LR RoleIdx"role_index\
按 (task, role) 索引" CycleIdx"cycle_index\
按 (task, cycle) 索引" TypeIdx"type_index\
按 (task, node_type) 索引" BaseStore"task_nodes\
(全量存储)" end L2F --> RoleIdx L2F --> CycleIdx L2F --> TypeIdx RoleIdx --> BaseStore CycleIdx --> BaseStore TypeIdx --> BaseStore
三、多维度索引设计
我们在 Blackboard 结构体中新增了三张内存 HashMap 索引,分别覆盖 Agent 角色、PDCA 周期和节点类型。
rust
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 结构体:
rust
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 并处理查询结果:
rust
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 的内部实现如下------它利用三维索引做交集运算,避免全量扫描:
rust
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% 以上,而任务的完成质量却因为注意力集中而明显提升。
八、结语
L2 作战地图是 Gliding Horse 多 Agent 协作的基石。它从最初的"全局监控"进化到现在的"精准投送",背后是我们对 Agent 系统工程化的一次深刻反思:只解决"看得见"还不够,必须解决"只看该看的"。通过多维索引、上下文过滤和投影引擎深度集成,Gliding Horse 的每个 Agent 终于拥有了一个干净、专注的工作空间,这或许就是 AI 协作从"草台班子"走向"专业团队"的关键一步。
Gliding Horse 已在 GitHub 开源:https://github.com/doiito/gliding_horse