FastAPI + LlamaIndex Agent 流式对话踩坑实录:取消标志残留、中间件缓冲、事件丢失全记录
文章目录
- [FastAPI + LlamaIndex Agent 流式对话踩坑实录:取消标志残留、中间件缓冲、事件丢失全记录](#FastAPI + LlamaIndex Agent 流式对话踩坑实录:取消标志残留、中间件缓冲、事件丢失全记录)
-
- 前言
- 一、项目背景
- [二、问题一:BaseHTTPMiddleware 导致流式响应被缓冲](#二、问题一:BaseHTTPMiddleware 导致流式响应被缓冲)
-
- [2.1 问题现象](#2.1 问题现象)
- [2.2 原因分析](#2.2 原因分析)
- [2.3 解决方案](#2.3 解决方案)
- [2.4 关键结论](#2.4 关键结论)
- [三、问题二:asyncio.Queue 间接层导致事件丢失](#三、问题二:asyncio.Queue 间接层导致事件丢失)
-
- [3.1 问题现象](#3.1 问题现象)
- [3.2 原因分析](#3.2 原因分析)
- [3.3 解决方案](#3.3 解决方案)
- [3.4 关键结论](#3.4 关键结论)
- [四、问题三:Redis 取消标志残留导致新对话被误判(核心问题)](#四、问题三:Redis 取消标志残留导致新对话被误判(核心问题))
-
- [4.1 问题现象](#4.1 问题现象)
- [4.2 原因分析](#4.2 原因分析)
- [4.3 解决方案](#4.3 解决方案)
- [4.4 为什么这样修复有效](#4.4 为什么这样修复有效)
- [4.5 关键结论](#4.5 关键结论)
- 五、完整修复代码
-
- [5.1 agent_service.py(核心修改)](#5.1 agent_service.py(核心修改))
- [5.2 agent_controller.py(传入 Redis 实例)](#5.2 agent_controller.py(传入 Redis 实例))
- [5.3 中间件修复(纯 ASGI 实现)](#5.3 中间件修复(纯 ASGI 实现))
- 六、排查经验总结
-
- [6.1 流式响应排查清单](#6.1 流式响应排查清单)
- [6.2 关键日志模板](#6.2 关键日志模板)
- [6.3 最佳实践](#6.3 最佳实践)
- 七、总结
前言
最近在基于 FastAPI + LlamaIndex 构建 Agent 智能对话系统时,遇到了一个典型的流式响应问题:Agent 对话只返回 start 和 end(cancelled) 事件,中间的内容全部丢失。经过多轮排查和修复,最终定位到三个独立但相互关联的问题。本文完整记录问题现象、排查过程和解决方案,希望能帮助遇到类似问题的同学少走弯路。
一、项目背景
- Web 框架:FastAPI + Uvicorn
- AI 框架:LlamaIndex(FunctionAgent)
- 流式协议 :NDJSON(
application/x-ndjson),每行一个 JSON 对象{"type": "xxx", "payload": {...}} - 取消机制 :通过 Redis 标志位实现,用户点击取消时设置
agent_cancel:{session_id} = "1",Agent 事件循环中轮询检测
事件类型
| 事件类型 | 说明 |
|---|---|
start |
对话开始,携带 sessionId |
message |
AI 回答内容增量片段 |
sources |
检索来源切片列表 |
end |
对话结束 |
error |
异常信息 |
二、问题一:BaseHTTPMiddleware 导致流式响应被缓冲
2.1 问题现象
Agent 流式接口返回的 StreamingResponse 在客户端只能收到 start 事件,后续的 message、sources、end 等事件全部丢失。
2.2 原因分析
项目中有两个中间件使用了 Starlette 的 BaseHTTPMiddleware:
python
# 中间件 1:上下文清理
class ContextCleanupMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
RequestContext.clear_all()
return response
# 中间件 2:响应头追加
class ApiResponseHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
# 追加自定义响应头...
return response
BaseHTTPMiddleware 的 call_next() 内部会拦截响应流 ,导致 StreamingResponse 的 NDJSON chunk 被缓冲或提前终止。这是 Starlette 的已知问题,BaseHTTPMiddleware 不适合处理流式响应。
2.3 解决方案
将两个中间件从 BaseHTTPMiddleware 转为纯 ASGI 实现 ,直接传递 send 函数,不拦截响应流:
python
# 纯 ASGI 中间件:上下文清理
class ContextCleanupMiddleware:
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
try:
await self.app(scope, receive, send)
finally:
RequestContext.clear_all()
python
# 纯 ASGI 中间件:响应头追加
class ApiResponseHeaderMiddleware:
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
request = Request(scope, receive)
api_response_headers = getattr(request.state, 'api_response_headers', None)
if not api_response_headers:
await self.app(scope, receive, send)
return
async def send_with_headers(message) -> None:
if message['type'] == 'http.response.start':
headers = dict(message.get('headers', []))
for key, value in api_response_headers.items():
headers[key.encode('utf-8')] = value.encode('utf-8')
message = {**message, 'headers': list(headers.items())}
await send(message)
await self.app(scope, receive, send_with_headers)
2.4 关键结论
凡是涉及流式响应(SSE、NDJSON、WebSocket 等)的 FastAPI 项目,中间件必须使用纯 ASGI 实现,不能使用
BaseHTTPMiddleware。
三、问题二:asyncio.Queue 间接层导致事件丢失
3.1 问题现象
中间件修复后,流式接口仍然只返回 start 事件。后端日志显示 Agent 正常执行(RAG 检索命中、LLM 调用成功),但事件无法送达客户端。
3.2 原因分析
之前的架构使用了 asyncio.Queue + 后台任务来解耦 Agent 执行和 HTTP 流:
Agent 事件 → queue.put() → queue.get() → yield → 中间件 → 客户端
这个架构在 Agent 事件和 HTTP 流之间增加了间接层,导致事件在 queue 传递过程中丢失或阻塞。
3.3 解决方案
彻底去掉 Queue,改为直接流式传输:
python
# 修复后的架构
Agent 事件 → yield → 中间件 → 客户端
python
@classmethod
async def chat_stream(cls, db, request, user_id, app_redis=None):
# ... setup 代码 ...
# 创建 Agent 并运行
agent = AgentFactory.create_agent(...)
handler = agent.run(user_msg=request.query, chat_history=llama_messages)
yield cls._ndjson('start', {'sessionId': actual_session_id})
# 直接从 Agent 事件流转发给客户端
async for event in handler.stream_events():
event_type = type(event).__name__
if event_type == 'AgentStream':
delta = getattr(event, 'delta', '')
if delta:
full_answer += delta
yield cls._ndjson('message', {'content': delta})
elif event_type == 'ToolCallResult':
# 提取来源...
pass
yield cls._ndjson('end', {})
3.4 关键结论
对于 LlamaIndex Agent 的流式场景,直接从
handler.stream_events()yield 事件给客户端即可,不需要 Queue 间接层。 Queue 架构适合需要复杂的生产者-消费者模式,但在简单的流式转发场景中反而增加了不必要的复杂度和出错概率。
四、问题三:Redis 取消标志残留导致新对话被误判(核心问题)
4.1 问题现象
用户先取消一次对话,然后在同一个会话 中再次提问,新对话立即返回 {"type": "end", "payload": {"cancelled": true}},完全没有内容。
4.2 原因分析
取消机制使用 Redis 标志位:
python
# 取消接口
await redis.set(f'agent_cancel:{session_id}', '1', ex=3600)
# 事件循环中检测
flag = await redis.get(f'agent_cancel:{session_id}')
if flag == '1':
yield cls._ndjson('end', {'cancelled': True})
return
问题 1:标志残留
取消标志的 TTL 是 3600 秒(1 小时)。用户取消后,标志留在 Redis 中。同一会话的后续请求会检测到这个残留标志,被误判为"已取消"。
问题 2:时序竞争
即使在新对话开始时清除标志,仍然存在时序竞争:
时间线:
13.638 → 请求 2 开始 setup(保存问题、构建记忆、创建 Agent...)
14.228 → 用户点击取消(仍在请求 2 的 setup 阶段)
14.467 → 取消标志被写入 Redis
14.500 → 请求 2 的 setup 结束
14.504 → 请求 2 的事件循环检测到标志 → 误判!
如果把清除标志的代码放在 setup 之前:
- clear 在 13.638 执行 → 标志还不存在,查了个空
- cancel 在 14.467 设置标志
- 事件循环在 14.504 检测到标志 → 误判
4.3 解决方案
双保险策略:
- 将清除标志的代码移到 setup 之后、事件循环之前
- 检测到取消后立即清除标志
python
@classmethod
async def chat_stream(cls, db, request, user_id, app_redis=None):
actual_session_id = request.session_id or str(uuid.uuid4())
# 获取 Redis 实例
redis = app_redis or await cls._get_redis(db)
# ===== Setup 阶段 =====
# 1. 保存用户问题
# 2. 构建会话记忆
# 3. 创建 Agent
# 4. 启动 Agent 运行
# ======================
yield cls._ndjson('start', {'sessionId': actual_session_id})
# ★ 关键修复 1:setup 完成后、事件循环开始前,清除残留的取消标志
if redis:
cancel_key = f'agent_cancel:{actual_session_id}'
old_flag = await redis.get(cancel_key)
if old_flag:
await redis.delete(cancel_key)
logger.info(f'已清除残留取消标志: {cancel_key} (旧值={old_flag})')
try:
async for event in handler.stream_events():
# 检查用户是否已取消
if redis and await cls._is_cancelled(redis, actual_session_id):
# ★ 关键修复 2:检测到取消后立即清除标志
try:
await redis.delete(f'agent_cancel:{actual_session_id}')
except Exception:
pass
logger.info(f'用户已取消对话: session_id={actual_session_id}')
await cls._save_cancelled_answer(...)
yield cls._ndjson('end', {'cancelled': True})
return
# 处理事件...
4.4 为什么这样修复有效
修复后的时序:
时间线(修复后):
13.638 → 请求 2 开始 setup
14.228 → 用户点击取消
14.467 → 取消标志被写入 Redis
14.500 → setup 结束,清除取消标志 ← 标志被清除!
14.504 → 事件循环开始 → 没有标志 → 正常运行 ✓
清除标志的代码从 setup 之前 移到 setup 之后,确保了:
- 即使取消请求在 setup 期间到达并设置了标志
- clear 也会在事件循环开始前把它清掉
- 事件循环开始时看到的永远是干净的状态
4.5 关键结论
取消标志的生命周期应该与请求绑定,而不是与会话绑定。 每次新请求开始时清除旧标志,每次取消被处理后也立即清除标志,确保标志不会残留影响后续请求。
五、完整修复代码
5.1 agent_service.py(核心修改)
python
@classmethod
async def chat_stream(
cls, db: AsyncSession, request, user_id: int, app_redis=None
) -> AsyncGenerator[str, None]:
"""Agent 流式对话入口"""
from module_rag.service.rag_chat_history_service import RagChatHistoryService
actual_session_id = request.session_id or str(uuid.uuid4())
# 0. 获取 Redis 实例(优先使用应用级 Redis)
redis = app_redis
if not redis:
try:
redis = await cls._get_redis(db)
except Exception:
redis = None
# ===== Setup 阶段(保存问题、构建记忆、创建 Agent 等)=====
# ... 省略 setup 代码 ...
# 启动 Agent
agent = AgentFactory.create_agent(...)
handler = agent.run(user_msg=request.query, chat_history=llama_messages)
yield cls._ndjson('start', {'sessionId': actual_session_id})
# ★ 修复:setup 完成后、事件循环前,清除残留取消标志
if redis:
try:
cancel_key = f'agent_cancel:{actual_session_id}'
old_flag = await redis.get(cancel_key)
if old_flag:
await redis.delete(cancel_key)
logger.info(f'已清除残留取消标志: {cancel_key} (旧值={old_flag})')
except Exception as clear_err:
logger.warning(f'清除取消标志失败: {clear_err}')
try:
async for event in handler.stream_events():
if redis and await cls._is_cancelled(redis, actual_session_id):
# ★ 修复:检测到取消后立即清除标志
try:
await redis.delete(f'agent_cancel:{actual_session_id}')
except Exception:
pass
await cls._save_cancelled_answer(...)
yield cls._ndjson('end', {'cancelled': True})
return
event_type = type(event).__name__
if event_type == 'AgentStream':
delta = getattr(event, 'delta', '')
if delta:
yield cls._ndjson('message', {'content': delta})
yield cls._ndjson('end', {})
# 后台保存回答(fire-and-forget)
cls._post_chat_tasks(...)
except Exception as stream_err:
yield cls._ndjson('error', {'message': str(stream_err)})
5.2 agent_controller.py(传入 Redis 实例)
python
@agent_controller.post('/chat/stream')
async def agent_chat_stream(
request: Request,
chat_req: AgentChatRequestModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> StreamingResponse:
user_id = current_user.user.user_id
# 获取应用级 Redis 实例(与取消接口使用同一个连接)
app_redis = request.app.state.redis if hasattr(request.app.state, 'redis') else None
event_stream = AgentService.chat_stream(
query_db, chat_req, user_id, app_redis=app_redis
)
return StreamingResponse(
content=event_stream,
media_type='application/x-ndjson'
)
5.3 中间件修复(纯 ASGI 实现)
python
# context_middleware.py
class ContextCleanupMiddleware:
"""上下文清理中间件(纯 ASGI 实现)"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
try:
await self.app(scope, receive, send)
finally:
RequestContext.clear_all()
六、排查经验总结
6.1 流式响应排查清单
- 检查中间件链 :所有
BaseHTTPMiddleware都可能缓冲流式响应 - 检查事件传递路径:Agent → Queue → yield → 中间件 → 客户端,每一环都可能丢失事件
- 检查 Redis 状态:取消标志、会话标志等是否残留
- 检查时序:异步场景下的竞争条件,特别是 setup 阶段和事件循环之间的时间窗口
6.2 关键日志模板
python
# 请求开始
logger.info(f'[AGENT][STREAM] 开始: session_id={actual_session_id}')
# 清除标志
logger.info(f'[AGENT][STREAM] 已清除残留取消标志: {cancel_key} (旧值={old_flag})')
# 事件循环
logger.info(f'[AGENT][STREAM] 事件 #{event_count}: {event_type}')
# 取消检测
logger.info(f'[AGENT] _is_cancelled: key={cancel_key}, flag={flag}')
6.3 最佳实践
| 场景 | 推荐做法 | 避免做法 |
|---|---|---|
| 流式中间件 | 纯 ASGI 实现 | BaseHTTPMiddleware |
| Agent 流式传输 | 直接 yield 事件 |
asyncio.Queue 间接层 |
| 取消标志管理 | 请求开始时清除,取消后立即清除 | 依赖 TTL 自动过期 |
| Redis 实例共享 | 使用 request.app.state.redis |
每次创建新连接 |
七、总结
本文记录了 FastAPI + LlamaIndex Agent 流式对话的三个典型问题:
- BaseHTTPMiddleware 缓冲流式响应 → 转为纯 ASGI 中间件
- Queue 间接层导致事件丢失 → 直接流式传输
- Redis 取消标志残留 + 时序竞争 → 双保险清除策略
这三个问题独立存在但相互关联,任何一个都可能导致流式响应异常。排查时需要从中间件链、事件传递路径、Redis 状态、时序竞争等多个维度综合分析。
希望本文能帮助遇到类似问题的同学快速定位和解决。如果觉得有用,欢迎点赞、收藏、转发!