Files
DevFlow/docs/02-架构设计/B-260616-21排查方案-2026-06-16.md

15 KiB
Raw Blame History

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 命中首个)→ 次张卡永驻 runningparsed?.lines||0 兜底显 0 行。与「0 行 + 183 行」现象完全吻合。
  • 候选 Bagent loop 多轮真读两次(不同 id→ 应两卡皆 183 行 completed与现象不符 → 排除为主因

本文深化走查定位候选 A 的确切重复 emit 环节,并给出两层修复方案。


2. 链路走查file:line 独立 grep/Read 核验)

2.1 前端流转(已核验)

位置 行为 关键事实
useAiEvents.ts:195-209 AiToolCallStarted 分支:构造 infoid/name/args/status:'running')→ lastMsg.toolCalls.push(info) 直接 push无 id 幂等守卫。对比同文件 startToolSlowTimer:58if (_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 startToolSlowTimerif (_toolTimers.has(callId)) return 幂等守卫 后端若重 emit 同 id Started第二张卡仍会 push无守卫但慢执行计时器不重建——计时器侧有守卫,卡片侧没有,前端守卫不一致。

2.2 后端 emit 点(已核验)

位置 emit 触发条件
audit.rs:541-546 AiToolCallStarted { id: draft.id.clone(), ... } process_tool_callsdrafts 迭代,每个 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 AiToolCallCompletedcontinue 跳过审批) 仅 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 Defaultid 默认空串)
crates/df-ai/src/anthropic_compat.rs:186-191 Anthropic 流:id 直接取 LLM 返回的 tool_use.idOption,可能 None → draft.id 保持空串)
crates/df-ai/src/openai_compat.rs:177-182 OpenAI 流:id: tc.idLLM 返回值,可为 None

关键结论draft.id 完全由 LLM stream 提供process_tool_calls / agentic.rs 不重新生成也不克隆复用 id。process_tool_callstc_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 valuemove传入用完即消费每轮 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

  • 主 loopagentic.rs:176)跨 iteration 时LLM 在不同轮次对相同语义的工具调用复用同一 id 字符串provider 侧缓存/重传)。
  • 跨 iteration 的 Started 会落到同一条 assistant 消息toolCallsuseAiEvents.ts:202state.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.1KBToolCard.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) 守卫:

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 已 importuseAiEvents.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 解析层——

    • 选项 aprocess_tool_calls 内对 tc_listdraft.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 AiToolCallStartedaudit.rs:541emit+ 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.idLLM 提供)
process_tool_calls 不重生成/克隆 id audit.rs:538-549draft.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 直接 pushfindToolCall 判重
前端 findToolCall 命中首个(反向) aiShared.ts:42-49state.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 默认空串