<!-- 文档分类: IMPL-RPT-020 | 阶段: 实现 | 原路径: docs/指南/网关传输韧性修复-2026-05-29.md -->
# 网关断链问题修复记录

> 修复日期：2026-05-29
> 涉及模块：`codexAdapter.js` · `cliToolAdapter.js` · `aiGateway.js` · `proxyServer.js` · `sseBackpressure.js` · `sseKeepalive.js`

---

## 1. 问题背景

近期 KHY 的 AI 网关在使用 Codex / CLI 桥接通道时，存在明显的“经常断链”体感：

- 长思考、长工具调用时被过早中止
- 一次瞬时通道抖动后，后续几十秒到数分钟持续失败
- 有时流没有完全断开，但最后几段内容或结束事件丢失

这不是单点故障，而是三类问题叠加：

1. 多层独立 idle watchdog 同时存在，且阈值偏紧
2. 短暂传输抖动被升级为 process 故障，触发 fast-fail / circuit breaker
3. SSE 背压队列在结束阶段没有强制 flush，导致尾包丢失

---

## 2. 用户可见症状

### 2.1 请求在正常执行中突然中断

典型场景：

- Codex 正在长时间思考
- CLI 工具正在执行较慢的本地命令
- 模型调用外部工具但短时间没有 stdout/stderr 输出

用户侧表现为：

- 响应在 45s 到 120s 左右突然结束
- CLI / Web 端显示像“断链”或“通道中断”

### 2.2 一次抖动放大为长时间不可用

典型 stderr / error 文案：

- `Reconnecting`
- `channel closed`
- `failed to record rollout items`

这些原本更接近“暂时性传输抖动”，但旧逻辑把它们当成 `process` 故障处理，导致：

- 本次请求立即失败
- 后续请求命中 fast-fail
- 连续失败后进入 cooldown 和指数退避熔断

### 2.3 流式输出尾部缺失

在 SSE 背压场景下，末尾几段内容可能仍留在 `_queuedLines` 中。如果连接进入 `done()` / `stop()` 时没有主动 `flushAll()`，用户会看到：

- 最后一小段内容消失
- `done` 事件没有按预期送达
- 前端表现像“输出被截断”

---

## 3. 根因分析

### 3.1 多层超时策略互相叠加

修复前，至少存在三层独立的空闲判死逻辑：

| 层级 | 文件 | 旧行为 |
|---|---|---|
| Codex 适配层 | `backend/src/services/gateway/adapters/codexAdapter.js` | 默认 45s，无子进程输出即强杀 |
| 通用 CLI 桥接层 | `backend/src/services/gateway/adapters/cliToolAdapter.js` | 120s idle timeout；非流式还使用 `spawn(..., { timeout })` 硬墙钟 |
| 代理流转发层 | `backend/src/services/gateway/proxyServer.js` | 默认再叠一层 90s stream idle abort |

这三层不是同一个统一策略，而是各自独立判死。结果是：

- 同一请求会被多次抢着中止
- 上游并不知道下游是否仍在健康推进
- 正常的长耗时任务很容易被误杀

### 3.2 错误分类过于激进

修复前，`aiGateway.classifyError()` 会把下列文案归类为 `process`：

- `reconnecting`
- `channel closed`
- `failed to record rollout items`
- `adapter ... queue timeout`
- `queue task timeout`

而 `process` 在网关内部会触发更激进的失败路径：

- 纳入 fast-fail
- 可能进入 cooldown
- 连续失败后打开 circuit breaker

这会把一次短暂通道抖动放大成一段时间内持续不可用。

### 3.3 SSE 收尾未冲刷背压队列

`sseBackpressure.write()` 在高频写入场景下会把部分 payload 暂存到 `_queuedLines`。但修复前：

- `writeSync()` 不会先冲刷旧队列
- `sseKeepalive.done()` / `stop()` 不会主动 `flushAll()`

因此只要最后几段事件刚好落在队列中，就可能永远发不出去。

---

## 4. 修复方案

### 4.1 统一方向：从“固定墙钟强杀”改为“滑动空闲超时”

本次修复没有简单地“把所有 timeout 调大”，而是改成：

- 只在“长时间无活动”时才中止
- 每次 stdout / stderr / stream chunk 都刷新活动时间
- 减少多层重复判死

### 4.2 `codexAdapter.js`

关键调整：

1. 将默认空闲等待窗口提升到更适合长任务的级别
2. 增加 `resolveExecIdleTimeoutMs()`，支持 env 和最小下限保护
3. 对 `reconnecting / channel closed / failed to record rollout items` 不再立即 `SIGKILL`
4. 将这类错误最终归类为 `network`，而不是 `process`

新增 / 使用的环境变量：

- `GATEWAY_CODEX_IDLE_TIMEOUT_MS`
- `KHY_CODEX_IDLE_TIMEOUT_MS`
- `GATEWAY_CODEX_MIN_IDLE_TIMEOUT_MS`

设计意图：

- 允许 Codex 长时间思考或执行工具
- 瞬时传输抖动优先等待恢复，而不是立刻判死

### 4.3 `cliToolAdapter.js`

关键调整：

1. 增加 `resolveToolIdleTimeoutMs()`，统一 CLI 工具空闲超时解析
2. 删除非流式分支的 `spawn(..., { timeout })` 硬墙钟
3. 非流式分支改为 sliding idle timeout
4. `Codex` 出现 transient transport 文案时不再立即强杀
5. `classifyCliErrorType()` 将相关文案改归为 `network`

新增 / 使用的环境变量：

- `GATEWAY_CLI_TOOL_IDLE_TIMEOUT_MS`
- `KHY_CLI_TOOL_IDLE_TIMEOUT_MS`
- `GATEWAY_CLI_TOOL_MIN_IDLE_TIMEOUT_MS`
- `GATEWAY_<TOOL>_IDLE_TIMEOUT_MS`

设计意图：

- 所有 CLI 桥接工具共用“有活动就续命”的模型
- 彻底移除违反 Rule 3 的固定执行超时

### 4.4 `aiGateway.js`

关键调整：

1. `reconnecting / channel closed / failed to record rollout items` 改归类为 `network`
2. `adapter ... queue timeout / queue task timeout` 改归类为 `timeout`
3. `network / timeout / rate_limit / overloaded / cancelled` 不再参与 circuit breaker 打开条件

这意味着：

- transient 错误仍会有短冷却，避免立刻重试打爆通道
- 但不会再被放大成“连续数十秒到数分钟都像断链”

### 4.5 `proxyServer.js`

关键调整：

1. 新增 `mergeAbortSignals(...)`
2. 代理层默认关闭额外的 stream idle abort
3. 如显式启用，代理层只补一层可选保护，不再覆盖上游 `abortSignal`

新增环境变量：

- `PROXY_STREAM_IDLE_ABORT_ENABLED`
- `PROXY_STREAM_IDLE_TIMEOUT_MS`

设计意图：

- 代理层不再与适配器层重复抢着判死
- 保留可选保护能力，但默认优先让上游统一管理任务生命周期

### 4.6 `sseBackpressure.js` 与 `sseKeepalive.js`

关键调整：

1. `writeSync()` 在直写前先尝试冲刷排队内容
2. `SSEKeepalive.done()` 在发送 `done` 前后都执行 `flushAll()`
3. `SSEKeepalive.stop()` 在销毁前执行 `flushAll()`
4. `close` 回调改为异步 stop，避免直接丢尾包

设计意图：

- 把 SSE 队列里的最后几段内容真正送出去
- 降低前端把“尾包丢失”感知成“断链”的概率

---

## 5. 变更文件

| 文件 | 修复内容 |
|---|---|
| `backend/src/services/gateway/adapters/codexAdapter.js` | 放宽 Codex idle timeout；transient transport 不再立即强杀；错误分类降级为 `network` |
| `backend/src/services/gateway/adapters/cliToolAdapter.js` | 移除非流式硬墙钟 timeout；统一 sliding idle timeout；CLI transient transport 改判为 `network` |
| `backend/src/services/gateway/aiGateway.js` | 调整 `classifyError()`；transient 错误不再打开 circuit breaker |
| `backend/src/services/gateway/proxyServer.js` | 合并 abort signal；默认关闭代理层附加 stream idle abort |
| `backend/src/services/sseBackpressure.js` | `writeSync()` 先 flush 队列 |
| `backend/src/services/sseKeepalive.js` | `done()` / `stop()` 结束前强制 flush 背压队列 |

---

## 6. 新增与调整的测试

### 6.1 新增测试

- `backend/tests/gateway/transportResilience.test.js`
- `backend/tests/services/sseKeepalive.test.js`

覆盖点：

- reconnect / channel closed / rollout record failure 改判为 transient
- queue timeout 改判为 `timeout`
- Codex / CLI idle timeout 下限符合新策略
- transient network 失败不会打开 circuit breaker
- SSE queued tail content 在 `done()` / `stop()` 时会被 flush

### 6.2 更新测试

- `backend/tests/aiGateway.stability.test.js`
- `backend/tests/gatewayAdapters.stability.test.js`

更新原因：

- 旧测试把 `reconnecting session` 当作 `process`
- 修复后它属于 `network`

---

## 7. 验证结果

本次修复后已完成以下验证：

### 7.1 语法检查

已通过 `node -c` 检查：

- `codexAdapter.js`
- `cliToolAdapter.js`
- `aiGateway.js`
- `proxyServer.js`
- `sseBackpressure.js`
- `sseKeepalive.js`

### 7.2 针对性测试

已通过：

```bash
npm test -- --runInBand \
  tests/gateway/transportResilience.test.js \
  tests/services/sseKeepalive.test.js \
  tests/aiGateway.stability.test.js \
  tests/gatewayAdapters.stability.test.js
```

结果：

- 4 个 test suite
- 93 个测试
- 全部通过

### 7.3 回归测试

已通过：

```bash
npm test -- --runInBand \
  tests/gatewayManage.portDrift.integration.test.js \
  tests/gatewayManage.apiDisplay.test.js \
  tests/services/serviceDefaults.test.js \
  tests/routes/aiGatewayAdmin.modelSlots.test.js \
  tests/services/proxyBaseUrl.test.js \
  tests/cli/daemon.handler.test.js \
  tests/daemonManager.runtimePort.test.js
```

结果：

- 7 个 test suite
- 17 个测试
- 全部通过

### 7.4 规则检查

已执行：

```bash
node scripts/check-agent-rules.js \
  backend/src/services/gateway/adapters/codexAdapter.js \
  backend/src/services/gateway/adapters/cliToolAdapter.js \
  backend/src/services/gateway/aiGateway.js \
  backend/src/services/gateway/proxyServer.js \
  backend/src/services/sseBackpressure.js \
  backend/src/services/sseKeepalive.js
```

结果：

- `0 error`
- 仍有少量既有 warning，集中在与本次核心修复无关的旧代码位置

---

## 8. 修复后的行为变化

### 修复前

- 长任务容易被多层 watchdog 叠加误杀
- 一次 transient transport 抖动可能被放大成长时间 fast-fail / circuit-open
- SSE 末尾内容可能丢失

### 修复后

- 任务只会在“持续无活动”时被判 idle timeout
- transient transport 抖动优先按 `network` / `timeout` 处理
- circuit breaker 不再因为短暂网络抖动被轻易打开
- SSE 结束前会冲刷尾部队列，减少输出截断

---

## 9. 后续建议

### 9.1 建议保留的观察项

后续可以继续观察以下指标：

- Codex / CLI 请求的平均时长与超时率
- `network` 与 `process` 错误类型的分布变化
- 前端侧 `done` 事件缺失率是否下降

### 9.2 仍可继续优化的点

本次修复优先解决“断链”主路径，但仍有进一步优化空间：

1. 将更多流式适配器统一到同一套 idle timeout helper
2. 为 SSE flush 增加更细的集成测试覆盖真实 `writableLength` 背压场景
3. 清理规则检查里现存的少量 warning

---

## 10. 结论

这次修复的核心不是“把某一个 timeout 调大”，而是把整条链路从“多层固定墙钟 + 激进故障放大 + 尾包丢失”修正为：

- **活动感知的空闲超时**
- **transient 错误与 permanent 错误分流**
- **流结束阶段的显式 flush**

因此，KHY 常见“断链”问题的三个主要放大器已经被同时削弱，后续如果仍出现个别失败，更容易定位为单一适配器或单一网络环境的问题，而不是整条网关链路的系统性误判。
