优化: dag拓扑O(V+E)+前端findToolCall反向遍历

- FR-D1 dag.rs topological_layers + executor.rs run 建 adjacency 索引, O(V·E)→O(V+E)(14测试全绿, 含executor3测)
- FR-R5 useAiEvents findToolCall 正向 O(n²) 改反向遍历(命中最近, 放弃Map索引防陈旧引用)
- AR-6 核查确认已于 f82dd8b 落地(Low失败emit AiToolCallCompleted), 零改动
This commit is contained in:
2026-06-14 22:56:06 +08:00
parent 4a95f6a1e2
commit 4b5f096d1c
3 changed files with 57 additions and 17 deletions

View File

@@ -81,18 +81,29 @@ impl Dag {
/// 拓扑排序 — 返回按执行顺序排列的节点 ID 层级 /// 拓扑排序 — 返回按执行顺序排列的节点 ID 层级
/// ///
/// 返回 Vec<Vec<NodeId>>,每层内的节点可以并行执行 /// 返回 Vec<Vec<NodeId>>,每层内的节点可以并行执行
///
/// 复杂度O(V+E)。一次性遍历边构建邻接索引(出边表)与入度表,
/// BFS 分层只走索引查询(每条边仅被访问一次),不再为每个节点全表扫描边集合。
pub fn topological_layers(&self) -> Result<Vec<Vec<NodeId>>> { pub fn topological_layers(&self) -> Result<Vec<Vec<NodeId>>> {
let node_ids: HashSet<NodeId> = self.nodes.keys().cloned().collect(); let node_ids: HashSet<NodeId> = self.nodes.keys().cloned().collect();
let mut in_degree: HashMap<NodeId, usize> = HashMap::new(); let mut in_degree: HashMap<NodeId, usize> = HashMap::new();
// 邻接出边表source → 直接后继列表(仅含两端均在 nodes 内的有效边)
let mut adjacency_out: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
// 初始化入度 // 初始化入度(确保每个节点都有表项,便于后续 O(1) 修改)
for id in &node_ids { for id in &node_ids {
in_degree.insert(id.clone(), 0); in_degree.insert(id.clone(), 0);
} }
// 单次 O(E) 遍历:同时构建入度表与出边表
for edge in &self.edges { for edge in &self.edges {
// 仅收录两端均为已注册节点的边,跳过野节点
if node_ids.contains(&edge.source) && node_ids.contains(&edge.target) { if node_ids.contains(&edge.source) && node_ids.contains(&edge.target) {
*in_degree.entry(edge.target.clone()).or_insert(0) += 1; *in_degree.get_mut(&edge.target).unwrap() += 1;
adjacency_out
.entry(edge.source.clone())
.or_default()
.push(edge.target.clone());
} }
} }
@@ -111,11 +122,14 @@ impl Dag {
for _ in 0..layer_size { for _ in 0..layer_size {
if let Some(id) = queue.pop_front() { if let Some(id) = queue.pop_front() {
layer.push(id.clone()); layer.push(id.clone());
for succ in self.successors(&id) { // 出边已索引O(出度) 遍历而非 O(E) 全表扫描
let deg = in_degree.get_mut(&succ).unwrap(); if let Some(succs) = adjacency_out.get(&id) {
for succ in succs {
let deg = in_degree.get_mut(succ).unwrap();
*deg -= 1; *deg -= 1;
if *deg == 0 { if *deg == 0 {
queue.push_back(succ); queue.push_back(succ.clone());
}
} }
} }
processed += 1; processed += 1;

View File

@@ -57,6 +57,16 @@ impl DagExecutor {
tracing::info!("DAG 执行开始,共 {} 层", layers.len()); tracing::info!("DAG 执行开始,共 {} 层", layers.len());
// 预建入边索引target → 直接前驱列表O(E) 一次构建)
// 避免在内层按节点循环中调用 dag.predecessors()(每次 O(E) 全表扫描,整体 O(V·E))。
let mut adjacency_in: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
for edge in &dag.edges {
adjacency_in
.entry(edge.target.clone())
.or_default()
.push(edge.source.clone());
}
for (layer_idx, layer) in layers.iter().enumerate() { for (layer_idx, layer) in layers.iter().enumerate() {
tracing::info!("执行第 {} 层,共 {} 个节点", layer_idx, layer.len()); tracing::info!("执行第 {} 层,共 {} 个节点", layer_idx, layer.len());
@@ -76,11 +86,13 @@ impl DagExecutor {
self.state_machine.set_running(node_id.clone())?; self.state_machine.set_running(node_id.clone())?;
// 构建节点上下文 // 构建节点上下文:通过入边索引 O(入度) 取前驱,而非 O(E) 全表扫描
let mut inputs = HashMap::new(); let mut inputs = HashMap::new();
for pred_id in dag.predecessors(node_id) { if let Some(preds) = adjacency_in.get(node_id) {
if let Some(out) = outputs.get(&pred_id) { for pred_id in preds {
inputs.insert(pred_id, out.clone()); if let Some(out) = outputs.get(pred_id) {
inputs.insert(pred_id.clone(), out.clone());
}
} }
} }

View File

@@ -59,12 +59,26 @@ export function flushCurrentText() {
if (last && last.role === 'assistant') last.content = state.currentText if (last && last.role === 'assistant') last.content = state.currentText
} }
/** 在全部消息中查找指定 id 的工具调用卡片(用于状态流转) */ /**
* 在全部消息中查找指定 id 的工具调用卡片(用于状态流转)
*
* 扫描策略:从尾部反向遍历并提前退出。tool_result/approval/completed
* 通常对应最近触发的 tool_use(agent 顺序执行工具并尽快回填结果),
* 反向扫描使其命中落在尾部 → 平均 O(1)、最坏 O(n)。(原正向全量扫描为 O(n²),
* 每条 tool_result 都从头扫整条历史。)
*
* 注:未引入 Map<id, tc> 索引。state.messages 在 newConversation /
* switchConversation / deleteConversation 等处被整体替换(且这些操作散落在
* useAiEvents 之外),独立索引极易与响应式数组失配形成"陈旧引用"——状态卡
* 片读到已不存在的对象。反向扫描零额外状态、改动最小且行为完全一致。
*/
export function findToolCall(id: string): AiToolCallInfo | undefined { export function findToolCall(id: string): AiToolCallInfo | undefined {
for (const msg of state.messages) { for (let i = state.messages.length - 1; i >= 0; i--) {
if (msg.toolCalls) { const toolCalls = state.messages[i].toolCalls
const tc = msg.toolCalls.find(t => t.id === id) if (!toolCalls) continue
if (tc) return tc // 同一消息内工具调用通常很少,沿用正向扫描;命中即返回
for (let j = 0; j < toolCalls.length; j++) {
if (toolCalls[j].id === id) return toolCalls[j]
} }
} }
return undefined return undefined