# B-260616-21 工具卡片重复渲染排查方案 > 排查性质(session-role-diagnose-only):仅走查定位确切根因 + 产出两层修复方案,**未改任何代码**。 > 现象:对话记录里 `读取 .../api/ai.ts` 出现两次——`read_file` 两张卡:一「0 行 · 7.1KB · running」、一「183 行 · 7.1KB · completed」。 > 关联:[docs/todo.md](../todo.md) B-260616-21;前端流转 [useAiEvents.ts](../../src/composables/ai/useAiEvents.ts);后端 emit [audit.rs](../../src-tauri/src/commands/ai/audit.rs)。 --- ## 1. 现象与候选根因回顾(已在 todo 登记) 登记在 [docs/todo.md](../todo.md) `### 🔧 2026-06-16 aichat 工具卡片重复渲染排查` 段,两候选: - **候选 A(最贴合现象)**:同一 `tool_call_id` 被**重复 emit Started** → 前端 push 两张卡 → `AiToolCallCompleted` 只更首张(`findToolCall` 命中首个)→ 次张卡永驻 `running`(`parsed?.lines||0` 兜底显 0 行)。与「0 行 + 183 行」现象完全吻合。 - **候选 B**:agent loop 多轮真读两次(不同 id)→ 应两卡皆 183 行 completed,与现象不符 → **排除为主因**。 本文深化走查定位候选 A 的确切重复 emit 环节,并给出两层修复方案。 --- ## 2. 链路走查(file:line 独立 grep/Read 核验) ### 2.1 前端流转(已核验) | 位置 | 行为 | 关键事实 | |---|---|---| | `useAiEvents.ts:195-209` | `AiToolCallStarted` 分支:构造 `info`(`id/name/args/status:'running'`)→ `lastMsg.toolCalls.push(info)` | **直接 push,无 id 幂等守卫**。对比同文件 `startToolSlowTimer:58` 有 `if (_toolTimers.has(callId)) return` 守卫——Started 漏了同款判重。 | | `useAiEvents.ts:212-223` | `AiToolCallCompleted` 分支:`findToolCall(event.id)` → 命中则 `tc.status='completed'; tc.result=event.result` | 只 update 一张卡。 | | `aiShared.ts:41-49` | `findToolCall`:尾部反向扫,**命中首个** `tc.id===id` 返回 | 同 id 被 push 两次时,Completed 只更第一张(reverse 先撞尾部 = 后 push 的那张?见 §3.1 注)。 | | `useAiEvents.ts:57-58` | `startToolSlowTimer`:`if (_toolTimers.has(callId)) return` 幂等守卫 | 后端若重 emit 同 id Started,第二张卡仍会 push(无守卫),但慢执行计时器不重建——**计时器侧有守卫,卡片侧没有**,前端守卫不一致。 | ### 2.2 后端 emit 点(已核验) | 位置 | emit | 触发条件 | |---|---|---| | `audit.rs:541-546` | `AiToolCallStarted { id: draft.id.clone(), ... }` | `process_tool_calls` 内 `drafts` 迭代,**每个 draft 一次** | | `audit.rs:532-533` | `tc_list: Vec<_> = tool_calls_acc.into_iter().collect()` + `sort_unstable_by_key` | `tc_list` 来源是 `tool_calls_acc: HashMap`(**键是流式 index u32,非 id 字符串**) | | `audit.rs:573-577` | F-05 高危去重命中 → emit `AiToolCallCompleted`(`continue` 跳过审批) | **仅 Completed,不重 emit Started**——安全 | | `commands.rs:304-308` | 审批通过 → 执行后 emit `AiToolCallCompleted` | **仅 Completed,不重 emit Started**——安全 | ### 2.3 id 来源(已核验) | 位置 | 行为 | |---|---| | `stream_recv.rs:223-229` | LLM 流式 chunk 的 `tool_calls` delta → `tool_calls_acc.entry(tc_delta.index).or_default()` → `if let Some(id) = &tc_delta.id { draft.id = id.clone(); }` | | `mod.rs:286-291` | `ToolCallDraft { id: String, name, args }` derive Default(`id` 默认空串) | | `crates/df-ai/src/anthropic_compat.rs:186-191` | Anthropic 流:`id` 直接取 LLM 返回的 `tool_use.id`(`Option`,可能 None → draft.id 保持空串) | | `crates/df-ai/src/openai_compat.rs:177-182` | OpenAI 流:`id: tc.id`(LLM 返回值,可为 None) | **关键结论**:`draft.id` **完全由 LLM stream 提供**,`process_tool_calls` / `agentic.rs` **不重新生成也不克隆复用** id。`process_tool_calls` 对 `tc_list` 每 draft emit 一次 Started,**单次调用内不重复**。 ### 2.4 process_tool_calls 调用频次(已核验) | 位置 | 调用 | |---|---| | `agentic.rs:474-477` | `let pending_count = { let mut session = ...; process_tool_calls(&mut session, tool_calls_acc, ...) }` | - `tool_calls_acc` **by value**(move)传入,用完即消费,**每轮 iteration 调一次**,不重入同轮。 - `run_agentic_loop` 主循环(`agentic.rs:176 for iteration in start_iteration..max_iterations`)每轮一次。 --- ## 3. 确切根因:候选 A 成立,重复 emit 源在 LLM 同 id 复用 ### 3.1 重复 emit 的两可能环节 **所有 emit Started 的代码路径只剩 `audit.rs:541` 一处**(grep `AiToolCallStarted` 全仓仅此一处 + mod.rs 枚举定义 + 注释)。要在该处对同一 id emit 两次,必须满足:**`tc_list`(= `tool_calls_acc.into_iter()`)含两条 `ToolCallDraft`,其 `id` 字段值相同**。`HashMap` 键是流式 index(u32),**两条 draft 可并存**——只要它们 index 不同但 id 字符串相同。 两条可能的产生路径: #### 路径 A1:单次流式内 LLM 在不同 index 上复用同一 tool_use.id(最贴合) - LLM(尤其 GLM 经 anthropic_compat 端点,已知 id 生成不稳,见 todo `### 🔴 anthropic_compat 多轮工具调用` 段 B-260614-AC1/AC2)在**同一次 stream**内,两个 `tool_use` block 复用同一 id 字符串(或一次正常 + 一次重传 delta 残留)。 - `stream_recv.rs:225` `entry(tc_delta.index).or_default()`——按 index 分桶,两条不同 index 的 draft 各自累积,若 LLM 给两条不同 index 的 tool_use 都写了同一 id → `tool_calls_acc` 含两条 id 相同的 draft。 - → `process_tool_calls` 对两条各 emit 一次 Started(同 id)→ 前端 push 两张卡。 #### 路径 A2:跨 iteration LLM 重发同一 tool_use.id - 主 loop(`agentic.rs:176`)跨 iteration 时,LLM 在不同轮次对相同语义的工具调用复用同一 id 字符串(provider 侧缓存/重传)。 - 跨 iteration 的 Started 会落到**同一条 assistant 消息**的 `toolCalls`(`useAiEvents.ts:202` 取 `state.messages[length-1]`,若 `AiAgentRound` 未先新建 assistant 消息则累积同消息;即使新建消息也跨消息污染)。 > **判断**:A1(单次流式内同 id 不同 index)比 A2 更贴合,因 A2 跨轮通常伴 `AiAgentRound` 新建 assistant 消息(`useAiEvents.ts:154-171`),重复卡会分属不同消息气泡,用户报「同一对话记录里出现两次」更可能 A1(同一消息内两张)。但两者**前端症状一致**(同 id 双卡、Completed 只更其一),**前端守卫可一并治标**。 ### 3.2 前端放大缺陷(为何重复 emit 后只剩「0 行 running」) 1. `useAiEvents.ts:204-205` `lastMsg.toolCalls.push(info)` 无 `findToolCall(event.id)` 守卫 → 后端真 emit 两次同 id Started,前端真 push 两张。 2. `aiShared.ts:42` `findToolCall` **反向扫命中首个**——Completed 到达时,反向遍历先撞**后 push 的那张**(尾部)。故 Completed update 的是**第二张**(后 push),**第一张**(先 push)永驻 `running`,显示 `0 行 · 7.1KB`(`ToolCard.vue:72` `parsed?.lines||0` running 态无 result 兜底 0)。 - **更正 §2.1 表「Completed 只更第一张」表述**:实测 findToolCall 反向命中尾部,故被更的是后 push 的那张,残留 running 的是先 push 的。现象「一 0 行 running、一 183 行 completed」与「先 push 残留 running / 后 push 被 Completed 更」一致。 3. 即使后端不重复 emit,**理论上 findToolCall 反向命中 + Started 无守卫**的组合本身就在「同 id 两次 Started」时产出「一卡永久 running」坏形态——这是前端层独立缺陷。 ### 3.3 与 F-260616-05 的区别(已在 todo 标注) - F-05(batch53):**不同 tool_call_id** 的同 tool_name+args 重复调用去重(High risk 进审批门前 `find_cached_high_risk_result` 反向扫 messages)。治的是「LLM 真调两次同命令」。 - B-260616-21:**同一 tool_call_id** 被重复 emit Started。治的是「LLM 给两个 tool_use block 复用同一 id 字符串」。 - 两者维度不同,F-05 的去重逻辑(按 args 匹配、跳过审批)**不能拦截**本条(本条 id 相同、index 不同,F-05 走的是 draft.id 维度的审批插桩,不防同 id 多 emit)。 --- ## 4. 修复方案(两层) ### 方案 ① 前端幂等守卫(治标·确定性低风险·推荐立即落地) **改动**:`src/composables/ai/useAiEvents.ts:195-209` `AiToolCallStarted` 分支,push 前加 `findToolCall(event.id)` 守卫: ```ts case 'AiToolCallStarted': { // B-260616-21: id 幂等守卫——后端若对同一 tool_call_id 重复 emit Started // (LLM 在不同 index 复用同 id / 跨轮同 id),仅保留首张卡,避免「同 id 双卡、 // Completed 只更其一、另一张永驻 running 显 0 行」。对齐 startToolSlowTimer:58 守卫风格。 if (findToolCall(event.id)) { // 已存在同 id 卡:补挂慢执行计时器(防首张 timer 被中途清后此 emit 不重建), // 但不重复 push 卡片。startToolSlowTimer 自身有 _toolTimers.has 守卫,重复调安全。 startToolSlowTimer(event.id, event.name) break } const info: AiToolCallInfo = { id: event.id, name: event.name, args: event.args, status: 'running', } const lastMsg = state.messages[state.messages.length - 1] if (lastMsg && lastMsg.role === 'assistant') { lastMsg.toolCalls = lastMsg.toolCalls || [] lastMsg.toolCalls.push(info) } startToolSlowTimer(event.id, event.name) break } ``` **生效语义**: - 后端重复 emit 同 id Started → 第二次起命中 `findToolCall` → 不 push 新卡 → Completed(无论命中首张或尾部,因只有一张卡)正确 update → 无残留 running 卡。 - 同 id 跨 assistant 消息(A2 场景,不同消息的 toolCalls)也守得住——`findToolCall` 扫**全部消息**(`aiShared.ts:42` 从尾反向遍历 `state.messages`),跨消息同 id 也会命中。 - 风险:若 LLM 真用同 id 调两次**不同语义**的工具(id 冲突但语义不同,极罕见),第二次工具的卡片会被吞 → 用户看不到第二次调用。但**这种 id 冲突本身就是 LLM 协议违规**(同 id 必同语义,tool_result 按 id 配对),后端 `ChatMessage::tool_result(&draft.id, ...)` 也只按 id 配对一个结果——故前端吞掉第二张是正确行为,与后端语义一致。 **改动范围**:单文件单 case,~6 行净增,`findToolCall` 已 import(`useAiEvents.ts:20`)。对齐同文件 `startToolSlowTimer:58` 守卫模式,无新机制。`vue-tsc` 应 0 err(纯前端逻辑,无类型变动)。 **与 startToolSlowTimer 守卫的一致性**:本修复使 Started 的卡片侧与计时器侧都具备 id 幂等守卫,消除「计时器有守卫、卡片没有」的前端守卫不一致(§2.1)。 ### 方案 ② 后端治本(定位重复 emit 源·需进一步取证) 前端守卫是兜底,根因在后端真发了同 id 两次 Started。治本需先**确认是 A1 还是 A2**: **取证步骤**(不改代码,加临时日志或读现有日志): 1. 在 `audit.rs:541` emit Started 前加 `tracing::info!`(临时):打印 `draft.id` + `draft.name` + `tc_list.len()` + 该 id 在 tc_list 中出现次数。复现后看是否单次 `process_tool_calls` 调用内同 id 出现 ≥2 次(A1),还是跨调用出现(A2)。 2. 在 `stream_recv.rs:226` `draft.id = id.clone()` 处加日志:打印 `tc_delta.index` + `id`,看 LLM 流式是否给不同 index 同 id。 **取证后治本方向(A1 vs A2 分支)**: - **若 A1(单次流式内同 id 不同 index)**:根因在 LLM provider 层(anthropic_compat / openai_compat)id 生成不稳。治本点在 `stream_recv.rs:225` 或 provider 解析层—— - 选项 a:`process_tool_calls` 内对 `tc_list` 按 `draft.id` 去重(同 id 保留首个 index,丢弃后续)。 - 选项 b:provider 层 id 缺失/冲突时生成占位 id(对齐已落地的 B-260614-AC1/AC2 占位 id 机制 `tool_missing_{idx}` / `tool_use_{idx}`)。 - 倾向 b(provider 层根治,process 层去重是兜底)。 - **若 A2(跨 iteration 同 id)**:根因在 LLM 跨轮复用 id。治本点在 `process_tool_calls` 入口校验 `draft.id` 是否已在 `session.messages` 历史 tool_result 中存在(跨轮去重)——但这与 F-260616-05 去重机制部分重叠,需谨慎区分(F-05 按 args 去重 High risk 跳审批;本条按 id 去重跨轮 Low/Med/High 一致)。倾向:跨轮同 id 视为 LLM 协议违规,前端守卫兜底即可,后端不额外处理(避免与 F-05 去重逻辑耦合)。 **治本建议**:先落地方案 ① 前端守卫(立即消除用户可感坏形态),取证步骤并行进行,据 A1/A2 结论再决定后端治本是否必要(若 A1 频发,provider 层补占位 id 生成;若 A2 罕见,前端守卫足矣)。 --- ## 5. 风险评估 | 项 | 方案 ① 前端守卫 | 方案 ② 后端治本 | |---|---|---| | 行为变更 | 同 id 第二次 Started 不再 push 卡(吞掉重复) | 视 A1/A2 分支而定 | | 兼容性 | LLM 协议合规(同 id 必同语义)下零影响;协议违规下吞掉违规第二张,与后端 tool_result 按 id 配对语义一致 | 需配套测试,避免误伤合法重发 | | 回归面 | 单 case,无类型/数据结构变动 | provider 层 / process 层,触及多文件 | | 建议优先级 | **P2 立即落地**(治标兜底,消除用户可感坏形态) | **取证后再定**(可能不必要,若 A1 罕见) | --- ## 6. 待办(回写 docs/todo.md) - [ ] B-260616-21 方案 ① 前端 `useAiEvents.ts:195-209` Started 分支加 `findToolCall(event.id)` 幂等守卫(~6 行,对齐 `startToolSlowTimer:58` 守卫,确定性低风险,立即落地治标)。 - [ ] B-260616-21 方案 ② 取证:临时日志确认 A1(单次流式内同 id 不同 index)/ A2(跨 iteration 同 id),据结论决定后端治本(provider 占位 id 生成 / process 层去重 / 不处理)。**依赖** ① 落地后再做(① 兜底后现象消失,取证需临时去 ① 守卫复现)。 --- ## 7. 核验证据汇总(防上下文污染·独立 grep/Read) | 结论 | 证据 | |---|---| | AiToolCallStarted 全仓唯一 emit 点 | `grep AiToolCallStarted` → `audit.rs:541`(emit)+ `mod.rs:95`(枚举)+ `mod.rs:19`(注释) | | F-05 高危去重不重 emit Started | `audit.rs:573-577` 仅 emit `AiToolCallCompleted` + `continue` | | 审批执行不重 emit Started | `commands.rs:304-308` 仅 emit `AiToolCallCompleted` + `AiApprovalResult` | | draft.id 完全来自 LLM stream | `stream_recv.rs:226` `draft.id = id.clone()`(id 取自 `tc_delta.id`,LLM 提供) | | process_tool_calls 不重生成/克隆 id | `audit.rs:538-549` 仅 `draft.id.clone()` 透传给 emit,无新 id 生成 | | tc_list 键是 index 非 id | `audit.rs:532` `tool_calls_acc.into_iter()`,`tool_calls_acc: HashMap`(u32 = 流式 index) | | 前端 Started 无守卫 | `useAiEvents.ts:202-206` 直接 push,无 `findToolCall` 判重 | | 前端 findToolCall 命中首个(反向) | `aiShared.ts:42-49` 从 `state.messages.length-1` 反向遍历,命中即 return | | startToolSlowTimer 有守卫(对比) | `useAiEvents.ts:58` `if (_toolTimers.has(callId)) return` | | ToolCard running 兜底 0 行 | `ToolCard.vue:72` `parsed?.lines || 0`(running 态无 result) | | ToolCallDraft derive Default | `mod.rs:286` `#[derive(Debug, Clone, Default)]`,id 默认空串 |