fischer-agentkit/docs/solutions/runtime-errors/board-chat-stream-first-chu...

196 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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