squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
15 KiB
B-260616-21 工具卡片重复渲染排查方案
排查性质(session-role-diagnose-only):仅走查定位确切根因 + 产出两层修复方案,未改任何代码。 现象:对话记录里
读取 .../api/ai.ts出现两次——read_file两张卡:一「0 行 · 7.1KB · running」、一「183 行 · 7.1KB · completed」。 关联:docs/todo.md B-260616-21;前端流转 useAiEvents.ts;后端 emit audit.rs。
1. 现象与候选根因回顾(已在 todo 登记)
登记在 docs/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_accby 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, _> 键是流式 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_useblock 复用同一 id 字符串(或一次正常 + 一次重传 delta 残留)。 stream_recv.rs:225entry(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」)
useAiEvents.ts:204-205lastMsg.toolCalls.push(info)无findToolCall(event.id)守卫 → 后端真 emit 两次同 id Started,前端真 push 两张。aiShared.ts:42findToolCall反向扫命中首个——Completed 到达时,反向遍历先撞后 push 的那张(尾部)。故 Completed update 的是第二张(后 push),第一张(先 push)永驻running,显示0 行 · 7.1KB(ToolCard.vue:72parsed?.lines||0running 态无 result 兜底 0)。- 更正 §2.1 表「Completed 只更第一张」表述:实测 findToolCall 反向命中尾部,故被更的是后 push 的那张,残留 running 的是先 push 的。现象「一 0 行 running、一 183 行 completed」与「先 push 残留 running / 后 push 被 Completed 更」一致。
- 即使后端不重复 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) 守卫:
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:
取证步骤(不改代码,加临时日志或读现有日志):
- 在
audit.rs:541emit Started 前加tracing::info!(临时):打印draft.id+draft.name+tc_list.len()+ 该 id 在 tc_list 中出现次数。复现后看是否单次process_tool_calls调用内同 id 出现 ≥2 次(A1),还是跨调用出现(A2)。 - 在
stream_recv.rs:226draft.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 层去重是兜底)。
- 选项 a:
-
若 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-209Started 分支加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 |
| ToolCallDraft derive Default | mod.rs:286 #[derive(Debug, Clone, Default)],id 默认空串 |