FastAPI + LlamaIndex Agent 流式对话踩坑实录

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 对话只返回 startend(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 事件,后续的 messagesourcesend 等事件全部丢失。

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

BaseHTTPMiddlewarecall_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 解决方案

双保险策略

  1. 将清除标志的代码移到 setup 之后、事件循环之前
  2. 检测到取消后立即清除标志
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 流式响应排查清单

  1. 检查中间件链 :所有 BaseHTTPMiddleware 都可能缓冲流式响应
  2. 检查事件传递路径:Agent → Queue → yield → 中间件 → 客户端,每一环都可能丢失事件
  3. 检查 Redis 状态:取消标志、会话标志等是否残留
  4. 检查时序:异步场景下的竞争条件,特别是 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 流式对话的三个典型问题:

  1. BaseHTTPMiddleware 缓冲流式响应 → 转为纯 ASGI 中间件
  2. Queue 间接层导致事件丢失 → 直接流式传输
  3. Redis 取消标志残留 + 时序竞争 → 双保险清除策略

这三个问题独立存在但相互关联,任何一个都可能导致流式响应异常。排查时需要从中间件链、事件传递路径、Redis 状态、时序竞争等多个维度综合分析。

希望本文能帮助遇到类似问题的同学快速定位和解决。如果觉得有用,欢迎点赞、收藏、转发!

相关推荐
云烟成雨TD1 天前
LlamaIndex 系列【5】智能体开发:大语言模型接入与基础调用
ai·agent·rag·llamaindex
卷无止境1 天前
Linux + Docker + FastAPI 工程化实践指南
后端·python·fastapi
皮卡丘不断更2 天前
手机优先的 Personal Ledger:把账目、学习和复盘放到同一个入口
数据库·sqlite·fastapi·开源项目·个人效率
智购科技自动售卖机厂家2 天前
2026自动售货机OTA升级系统设计:从全量升级到差分升级的带宽优化工程实践~YH
大数据·人工智能·numpy·pyqt·fastapi
Broccoli523026652 天前
FastAPI 中间件
中间件·fastapi
云烟成雨TD2 天前
LlamaIndex 系列【3】上下文增强驱动的 LLM 应用开发框架
ai·agent·rag·llamaindex
云烟成雨TD2 天前
LlamaIndex 系列【4】入门案例(阿里云百炼适配)
ai·agent·rag·llamaindex
刘新洲3 天前
我以为 AI Agent 只是调模型,直到我亲手补上审批、Outbox 和故障恢复
python·agent·fastapi
(((φ(◎ロ◎;)φ)))牵丝戏安3 天前
fastapi-auth-template — JWT 认证模板
运维·服务器·fastapi