# 解耦重型 IO 与 UI 终态：消除桌面 Agent 输入框假死与流式卡顿

Source: https://blog.ferstar.org/posts/desktop-agent-streaming-lifecycle-reconciliation/





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

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

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

```mermaid
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` 给前端。
```typescript
// 以前的做法：重型 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_end`、`error`、`cancelled`）跟普通文本增量不一样，优先级应该最高。我们在 IPC 层加了 Bypass 通道：
- 终态事件直接绕过普通的顺序队列，第一时间推给渲染进程；
- 哪怕后台的数据库写入和日志归档还在排队，前端的状态机也能立刻就位。

### 3. 10 秒低频对账心跳

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

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

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

---

## 3. 总结

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

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

