docs(solutions): document board chat_stream first-chunk hang fix

Captures the private board streaming hang regression (commit 36b0296)
and its fix (commits ed1e289, 0daa4d2, d0fe661). Key learnings:
- All `async for chunk in stream` calls need first-chunk timeout
  (DashScope/DeepSeek via LiteLLM occasionally never emit first chunk)
- Mid-stream break must not trigger full-content re-broadcast
  (return partial content instead of double-broadcasting)
- Diagnostic pattern: monkey-patch + per-stage timeout to locate hang

Track: bug
Category: runtime-errors
Overlap: low (related streaming docs exist but different root cause)
This commit is contained in:
Chiguyong 2026-07-06 15:55:37 +08:00
parent 8063f4efe0
commit af2de993d8
1 changed files with 195 additions and 0 deletions

View File

@ -0,0 +1,195 @@
---
module: experts/board
date: 2026-07-06
problem_type: runtime_error
component: board_orchestrator
severity: high
symptoms:
- "私董会 @board 触发后moderator 开场发言正常,但进入 expert 发言环节后整体 hang无错误、无超时、无新 chunk"
- "前端 progress 1/5 后无限等待,无后续 expert 发言、无 moderator 小结、无最终总结"
- "服务端日志无异常 — LLM 请求已发出但响应流永远不返回首个 SSE chunk"
root_cause: missing_timeout
resolution_type: code_fix
related_components:
- experts/board_orchestrator
- llm/gateway
- llm/providers/litellm_provider
tags:
- board
- streaming
- litellm
- dashscope
- deepseek
- timeout
- fallback
- async-generator
---
# 私董会 chat_stream 首 chunk hang + 双重广播修复
## Problem
私董会(`@board`触发后moderator 开场发言正常输出(走 `gateway.chat()` 非流式),但进入 expert 发言环节后整体卡死:前端 `progress 1/5` 后无限等待5 个专家一个都不发言,无 moderator 小结、无最终总结。服务端日志无异常,重启服务后复现。
## Symptoms
- `@board` 请求后 moderator 开场正常30s 内返回 367 字)
- 进入 expert 发言后**永久 hang**(等 90s+ 无任何输出)
- 服务端日志无 error、无 timeout、无 warning — LLM 请求已发出但 `async for chunk in stream` 永远不进入循环体
- 重启服务、换 topic 均复现
- 同一 LLM provider 下,非流式 `gateway.chat()` 正常工作
## What Didn't Work
1. **怀疑 `_handle_llm_generate` 降级** — 看到 `llm_generate_no_client` 占位文本,以为是 `ConfigDrivenAgent` 的 LLM 客户端注入问题。修复 `_llm_gateway` 优先逻辑后commit `d0fe661`),单测通过但 board hang **依旧**。原因board 路径**根本不调** `ConfigDrivenAgent.handle_task`,而是 orchestrator 直接调 `gateway.chat_stream()`
2. **怀疑 PII filter 接入导致** — commit `cb278bb`PII filter 接入 gateway 生产路径)改了 `gateway.py` +29 行。检查后发现 PII filter 默认禁用(`AGENTKIT_PII_FILTER_ENABLED` 未设),不是直接原因。
3. **怀疑 handoff 队列阻塞**`InProcessHandoffTransport.send()``await queue.put(message)` 没设 timeout前端 WebSocket 慢/断了会永久阻塞。检查后发现 hang 位置在 LLM 调用之前,不是 broadcast。
## Root Cause
**commit `36b0296`Jul 2引入的回归** — 该 commit 把 expert 发言从 `gateway.chat()`(非流式)改为 `gateway.chat_stream()`(流式)以实现"逐个输出"UI 体验:
```python
# 改前commit 36b0296 之前)— 非流式,慢但能用
response = await gateway.chat(messages=[...], model="default")
# 改后 — 流式DashScope/DeepSeek via LiteLLM 偶尔 hang
async for chunk in gateway.chat_stream(messages=[...], model="default"):
...
```
**为什么 hang**DashScope (Qwen) + DeepSeek 通过 LiteLLM 的 `acompletion(stream=True)` 调用时,**偶尔**接受请求但永不返回首个 SSE chunk。`async for chunk in stream_obj` 无限阻塞,没有 error、没有 timeout、没有 warning — board 整体死锁。
**为什么 moderator 正常**moderator 的 opening / summary / final 都走 `gateway.chat()`(非流式),不受影响。只有 expert 发言走 `chat_stream()`,所以"moderator 能说话但 expert 卡死"是精确的症状特征。
**为什么以前能用**commit 36b0296 之前所有调用都是 `gateway.chat()` 同步等待 — 慢但能用。该 commit 引入 streaming 时没给"获取首 chunk"加超时。
## Solution
### Fix 1首 chunk 30s 超时 + fallbackcommit `ed1e289`
[board_orchestrator.py `_stream_expert_speech`](file:///Users/chiguyong/Code/Fischer-agentkit/src/agentkit/experts/board_orchestrator.py) — 给 `chat_stream` 的首 chunk 加 30s 超时,超时则 fallback 到非流式 `gateway.chat()` + `_replay_stream` 切片(保留"逐个输出"UX
```python
_STREAM_FIRST_CHUNK_TIMEOUT_S: float = 30.0
async def _stream_expert_speech(self, expert, round, prompt):
gateway = self._get_llm_gateway(expert)
total = ""
stream_obj = None
try:
stream_obj = gateway.chat_stream(messages=[...], model="default")
if asyncio.iscoroutine(stream_obj):
raise TypeError("chat_stream returned a coroutine, not an async generator")
# 关键:首 chunk 30s 超时
first_chunk = await asyncio.wait_for(
stream_obj.__anext__(),
timeout=self._STREAM_FIRST_CHUNK_TIMEOUT_S,
)
# 首 chunk 拿到 → 剩余 stream 信任能正常流
await _emit(first_chunk)
async for chunk in stream_obj:
await _emit(chunk)
return total.strip()
except asyncio.TimeoutError:
# 30s 没首 chunk → 关闭 streamfallback 到非流式
if stream_obj and hasattr(stream_obj, "aclose"):
try: await stream_obj.aclose()
except Exception: pass
except StopAsyncIteration:
# 空 stream → fallback
...
except Exception as e:
# stream 中途断 → fallback
...
# Fallback非流式 + 切片重播
response = await gateway.chat(messages=[...], model="default")
content = (response.content or "").strip()
if content:
await self._replay_stream(expert, round, content, delay=0.05, chunk_size=12)
return content
```
### Fix 2中间断流时避免双重广播commit `0daa4d2`review 发现)
Code review 发现 Fix 1 的 fallback 路径有 P2 bug`first_chunk` 成功并已广播,但后续 `async for` 中间抛异常时fallback 调 `gateway.chat()` 返回**完整内容**并再调 `_replay_stream` 广播 → UI 看到部分流式内容 + 完整非流式内容 = **内容重复**
修复fallback 路径中,如果 `total` 已有部分内容(来自 first_chunk直接返回部分内容不调 `gateway.chat()`
```python
# 如果已经广播了部分内容,不要重复广播完整内容
if total:
logger.info(
f"Expert '{expert.config.name}' stream broke after {len(total)} chars "
f"of partial content — returning partial (avoid double broadcast)"
)
return total.strip()
# 只有 total 为空时才 fallback 到非流式
response = await gateway.chat(...)
```
### Fix 3独立 commit `d0fe661``_handle_llm_generate` 优先用 gateway
[config_driven.py `_handle_llm_generate`](file:///Users/chiguyong/Code/Fischer-agentkit/src/agentkit/core/config_driven.py) — `AgentPool` 注入 `_llm_gateway` 但不设 `_llm_client`,原代码只检查 `_llm_client is None` 导致错误降级。改为优先用 `_llm_gateway`
**注**:这个 fix 与 board hang 无关board 路径不经过 `ConfigDrivenAgent.handle_task`),但解决了"因 LLM 不可用无法发言"占位文本的独立 bug。
## Why This Works
1. **首 chunk 超时** 把"永久 hang"降级为"30s 延迟 + fallback" — 即使 DashScope/DeepSeek 流式不返回30s 后自动切到非流式路径board 能继续执行
2. **`__anext__()` + `wait_for`** 而非 `async for``async for` 无法对单次迭代加超时,显式 `__anext__()` 可以
3. **fallback 用 `_replay_stream` 切片** 而非一次性广播完整内容 — 保留"逐个输出"UX 体验
4. **部分内容优先于重复内容** — 中间断流时返回部分内容(已广播),不调 `chat()` 重新广播完整内容
## Prevention
### 模式:所有 `async for chunk in stream` 必须给首 chunk 加超时
任何调用 `async for chunk in provider.chat_stream(...)` 的地方,都应该用 `__anext__()` + `asyncio.wait_for` 给首 chunk 加超时。国产 OpenAI-compat endpointDashScope/DeepSeek/Qwen的 LiteLLM 流式适配偶尔不返回首 chunk`async for` 会永久阻塞且无 error。
```python
# ❌ 危险 — 永久 hang 无 error
async for chunk in gateway.chat_stream(...):
...
# ✅ 安全 — 30s 超时后 fallback
stream = gateway.chat_stream(...)
try:
first = await asyncio.wait_for(stream.__anext__(), timeout=30.0)
except asyncio.TimeoutError:
# fallback to non-streaming
...
await _emit(first)
async for chunk in stream:
await _emit(chunk)
```
### 测试覆盖
必须覆盖 5 个 streaming 场景(见 `TestStreamExpertSpeechFallback`
1. 正常流式 — chunks 正常返回
2. 首 chunk 超时 — `_HungStream` 永久阻塞,验证 fallback
3. 空 stream — `StopAsyncIteration`,验证 fallback
4. stream 抛异常 — `RuntimeError`,验证 fallback
5. **中间断流** — 首 chunk 成功但后续抛异常,验证不双重广播
### 诊断脚本模板
当 board/团队功能 hang 时,用加超时 + 阶段打点的诊断脚本定位(见 `diag_board_trace.py` 模式):
- 每个 `await``asyncio.wait_for` 超时
- monkey-patch 关键方法打印 ENTER/EXIT 时间戳
- 先跑端到端复现,再用超时定位 hang 位置
## Cross-References
- [streaming-event-whitelist-and-accumulation.md](file:///Users/chiguyong/Code/Fischer-agentkit/docs/solutions/runtime-errors/streaming-event-whitelist-and-accumulation.md) — 前端流式双累积 bug不同根因同 streaming 子系统)
- [streaming-event-contract-residuals.md](file:///Users/chiguyong/Code/Fischer-agentkit/docs/solutions/integration-issues/streaming-event-contract-residuals.md) — streaming 事件契约残留
- Commit 36b0296 — 引入 streaming 回归的 commit
- Commit ed1e289 — Fix 1首 chunk 超时)
- Commit 0daa4d2 — Fix 2避免双重广播review 发现)
- Commit d0fe661 — Fix 3`_handle_llm_generate` gateway 优先,独立 bug