做桌面端或 Web 端的 Agent 客户端时,经常会遇到一个很烦人的体验问题:

> 模型在屏幕上已经把最后一个字输出完了,但输入框依然置灰,显示“任务执行中...”,光标点不进去。等上几秒甚至十几秒它才慢慢解锁;如果中途切到别的窗口,有时候直接永久卡死在 loading 状态。

这个现象看起来是前端状态没绑好,但顺着调用链往下排查,本质是后端的异步持久化和事件分发顺序没理顺。
描述

flowchart TD
  subgraph Backend[Agent 主进程 / 运行时]
    M[收到最后文本片段] --> MC[发送 MessageComplete]
    MC --> P[重型异步持久化: SQLite 落盘与文件归档]
    P --> AE[发送 AgentEnd 终态事件]
  end

  subgraph Legacy[传统串行模式]
    L1[等待持久化完成] --> L2[通过长队列 IPC 通知前端]
    L2 --> L3[清空 isStreaming / 用户感知严重卡顿]
  end

  subgraph Optimized[解耦与对账优化]
    MC -->|乐观判定: 无后续工具| UI1[立即解禁前端输入框]
    AE -->|Bypass 终态快轨| UI1
    T[10s 周期性 Stale 对账心跳] -.->|极端丢包或失焦兜底| UI1
  end


---

1. 为什么输入框会卡住?


消息从大模型产出到写进磁盘,通常要过三层:
1. 采样运行时:收模型 SSE 流,吐出 token delta 和 MessageComplete
2. 宿主主进程(Node.js / IPC 层):负责跨进程通信、写 SQLite 数据库、截屏图片落盘、写 JSONL;
3. 渲染进程(前端 UI):React/Vue 管理 isStreaming 和输入框禁用。

排查日志后,卡顿主要来自三个地方:

原因一:终态事件排在重型 IO 的 await 后面


以前主进程的写法很直白:等当前轮次的所有持久化搞定之后,才发 agent_end 给前端。

// 以前的做法:重型 IO 阻塞了终态通知
await persistTurnToSqlite(turnData);      // 50~200ms
await materializeImagesToDisk(images);    // 500ms~2s
await appendSessionJsonl(largePayload);   // 100~500ms

// 全部写完才通知前端解禁
emitToRenderer('agent_end', session);

模型输出完毕时,主进程还在后台忙着写磁盘。在长会话或机械硬盘上,这一步动辄卡几秒,输入框就跟着一直锁着。

原因二:IPC 队列堵塞


为了保序,前端通常有个顺序事件队列(Emit Chain)。如果模型收尾时顺手吐了一堆诊断日志,真正的 agent_end 会被排在这些大包后面,产生排队延迟。

原因三:窗口失焦丢包


用户在生成中途切到别的软件,Chromium 会降低后台定时器频率;若此时跨进程事件处理出现小抖动,前端把 agent_end 丢了,输入框就会一直卡死在“执行中”。

---

2. 怎么解决?


我们在 IPC 通信和前端状态管理上改了三点:

1. 乐观解禁(Optimistic Unlocking)


输入框能不能用,不应该等磁盘写完。只要前端收到 MessageComplete 且满足三个条件,立刻解禁输入框
- 回复是 Assistant 的终点(EndTurn);
- 没有未完成的后台工具在跑;
- 用户没有点过停止。

这样用户感知到的输入框解锁延迟直接变成了零。

2. Bypass 终态快轨


生命周期事件(agent_enderrorcancelled)跟普通文本增量不一样,优先级应该最高。我们在 IPC 层加了 Bypass 通道:
- 终态事件直接绕过普通的顺序队列,第一时间推给渲染进程;
- 哪怕后台的数据库写入和日志归档还在排队,前端的状态机也能立刻就位。

3. 10 秒低频对账心跳


为了防范窗口失焦或极端丢包导致的孤儿状态,前端加了个 10 秒周期的对账循环:


useEffect(() => {
  const timer = setInterval(() => {
    // 检查是否有超过 10 秒无新数据却依然挂着 streaming 标志的会话
    reconcileStaleStreamingSessions();
  }, 10_000);
  return () => clearInterval(timer);
}, []);


只要发现有挂死的 session,主动校准并清理标记。

---

3. 总结


改完之后,任务一结束输入框就能立即打字,切窗口导致卡死的问题也没再出现过。

做客户端应用,前台交互要尽可能乐观敏捷,耗时的持久化放后台异步跑,中间靠定期对账兜底状态一致性。