Files
DevFlow/docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md
绝尘 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00

194 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<u32, ToolCallDraft>`**键是流式 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<u32, _>` 键是流式 indexu32**两条 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-05batch53**不同 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_compatid 生成不稳。治本点在 `stream_recv.rs:225` 或 provider 解析层——
- 选项 a`process_tool_calls` 内对 `tc_list``draft.id` 去重(同 id 保留首个 index丢弃后续
- 选项 bprovider 层 id 缺失/冲突时生成占位 id对齐已落地的 B-260614-AC1/AC2 占位 id 机制 `tool_missing_{idx}` / `tool_use_{idx}`)。
- 倾向 bprovider 层根治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, ToolCallDraft>`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 默认空串 |