《从0搭建AI聊天后端:FastAPI+OpenAI+SSE流式输出》

很多 AI 聊天示例只展示了"调用模型 API 并打印回答"。真正做成一个聊天后端,还要处理会话管理、消息持久化、多轮上下文、流式传输,以及模型调用失败时的数据一致性。本文记录我基于 FastAPI 搭建 AI 聊天后端的过程,以及实现中的取舍和踩坑。

一、项目介绍

这个项目实现了一个简单的 AI 聊天后端,主要功能包括:

  • 创建、查询、重命名、暂停会话、重新生成和删除会话
  • 查询会话下的历史消息
  • 调用兼容 OpenAI API 的模型服务
  • 通过 SSE 将模型回答逐段推送给前端
  • 将用户消息和 AI 回答保存到数据库

项目的核心不是"把问题转发给模型",而是建立一条完整的数据流:

二、技术栈

  • FastAPI:提供 REST API 和依赖注入
  • Pydantic:体验请求体
  • SQLAlchemy Async ORM:异步访问数据库
  • SQLite:本地存储会话和消息
  • OpenAI Python SDK:调用兼容 OpenAI API 的模型服务
  • SSE:将模型生成的文本分段推送给浏览器
  • CORSMiddleware:开发阶段处理跨域请求

模型服务地址和 API Key 不应该写死在代码里。项目从环境变量读取 DASHSCOPE_API_KEY,部署时也应确保密钥只存在于服务端环境中,不要放进前端代码或提交到代码仓库。

三、核心数据模型

项目将会话和消息拆成两张表:

  • Session:保存会话标题、创建时间和更新时间
  • Messages:保存会话 ID、角色、内容和创建时间、消息状态、暂停原因

这样做是为了让会话列表与消息历史可以分别查询,也便于按会话删除消息。

消息角色使用枚举表示:

python 复制代码
class RoleEnum(str, enum.Enum):
    SYSTEM = "system"
    USER = "user"
    ASSISTANT = "assistant"

把角色限制在明确的曲直范围内,比直接在数据库里存任意字符串更容易发现无效数据。不过,接口层的请求模型也应进行角色校验,避免错误值直接写入数据库时才暴露。

四、核心难点

1. SSE 流式输出

为什么使用 SSE

如果等模型生成完整回答后再返回,用户只能在模型完成后看到整段文本。SSE 允许服务端通过一个 HTTP 响应持续推送事件,前端可以边接收边显示内容。

目前后端把每段生成内容封装成 SSE 事件:

python 复制代码
yield f"data: {json.dumps({'content': delta}, ensure_ascii=False)}\n\n"

事件以空行分隔,生成结束时再发送结束标记:

python 复制代码
yield "data: [DONE]\n\n"

这里需要注意:SSE 不是简单地"返回一个字符串"。服务端需要保持响应连接,并按 SSE 格式发送数据;前端也要按事件流解析,而不是把整个响应当作普通 JSON。

服务器返回 Content-Type: text/event-stream → 保持 TCP 连接不中断 → 随着 LLM 生成内容,持续向该连接写入 data: ...\n\n 格式的数据块 → 直到发送 [DONE] 才主动关闭连接。这是一个长连接、单向推送的流式模型。

一个容易忽略的异步问题

OpenAI SDK 的同步流如果直接在 async def 中用普通 for 遍历,遍历过程仍可能阻塞事件循环。即使创建流的调用放进了线程池,也不代表后续读取每个 chunk 自动变成异步。

⚠️ 踩坑反思:初版实现中我使用了 asyncio.to_thread 包装同步 SDK 的流创建调用,误以为这样就实现了全链路异步。但实际上 for chunk in stream 仍然是同步阻塞的------to_thread 只解决了"建连"的阻塞,没有解决"消费"的阻塞。当模型响应较慢时,这个同步迭代会卡住整个事件循环,影响其他请求。

正确做法 是使用 OpenAI SDK 原生的 AsyncOpenAI 客户端配合 async for,实现从建连到消费的完整异步链路。

2. 多轮对话上下文

多轮对话的核心是将历史消息拼接成 messages 数组传给 LLM。但这里有一个隐蔽的时序问题:

  • 用户发送消息 -> 需要先存入 DB
  • 查询该 session 所有历史消息 -> 构造 context
  • 调用 LLM 流式生成 -> 边生成边推送给前端
  • 流结束后 -> 把 AI 的完整回复存入 DB 陷阱:在 StreamingResponse 的生成器执行时,外层的 FastAPI 请求上下文已经结束,依赖注入的 session 已被关闭。如果直接在生成器里用外层 session 保存 AI 回复,会报 SessionClosed 错误。

解决方案:生成器内部独立创建 Session

python 复制代码
# ✅ 流结束后,新开一个独立的 session 来保存 AI 回复
async with AsyncSessionLocal() as save_session:
    ai_msg = Messages(
        session_id=session_id,
        role=RoleEnum.ASSISTANT,
        content=full_reply,
    )
    save_session.add(ai_msg)
    
    # 同时更新会话的 update_at 时间戳
    target = (await save_session.execute(
        select(Sessions).where(Sessions.id == session_id)
    )).scalar_one()
    target.update_at = datetime.now()
    
    await save_session.commit()

设计亮点:

  • 用户消息在流开始前就 commit,确保即使 AI 调用失败,用户的输入也不会丢失。
  • AI 回复在流结束后才 commit,保证存入的是完整内容,而非碎片。
  • 使用独立的 AsyncSessionLocal() 彻底解耦请求生命周期与流式响应生命周期。

3. 重新生成对话

3.1. 如何保留多次生成结果

重新生成时,不能简单覆盖原来的 AI 回复:这样既无法保留历史版本,也无法展示或比较不同回答。

当前做法是:每次重新生成,都创建一条新的 AI 消息,用 parent_id 指向对应的用户消息,用 version 记录这是第几版。

例如:

用户消息 AI 回复版本 含义
"解释一下异步" 1 第一次回答
"解释一下异步" 2 第一次重新生成
"解释一下异步" 3 第二次重新生成

关键点在于: "重新生成"是在同一条用户消息下增加新版本,而不是覆盖旧回复。

3.2. 如何构造不会重复、又符合对话顺序的上下文

发送给模型的上下文必须包含此前对话和当前问题,但要避免重复。

重新生成时,当前用户消息本来已经存在数据库中,如果历史查询把它也取出来,然后再追加一次,就会导致同一个问题被发送两遍。因此实现中查询当前用户消息之前的已完成历史,再把当前用户消息单独追加到末尾。

此外,旧 AI 回复版本虽然留在数据库中,但不能把所有版本都传给模型,否则同一轮对话会出现多条 AI 回答。代码通过_build_messages_payload按用户消息的 parent_id 分组,只选择最高版本的已完成 AI 回复作为上下文。

这里的难点是要区分:

  • 数据库中保留的历史:可以包含多个 AI 回复版本;
  • 发给模型的有效对话上下文:每轮只应该包含选定的一个 AI 回复版本。

3.3. 如何处理停止生成,而不丢失已生成内容

这里的"暂停"实际上是取消当前生成任务,不是从模型的某个 token 位置真正续传。

停止时会调用 task.cancel(),让生成协程收到 asyncio.CancalledError。取消处理中,生产者要:

  1. 将状态设置为 PAUSED;
  2. 将已生成内容保存到数据库;
  3. 完成保存后,再传播取消信号。

这里需要注意:收到取消信号后,清理工作本身也可能被取消。如果数据库写入也被中断,就会出现前端已经看到部分文本,但数据库没有保存的情况。因此代码使用 asyncio.shield(...) 保护持久化任务,并等待保存完成。

再次点击重新生成时,会发起全新的模型请求,创建下一个版本;不会接着之前的部分回答继续生成。

3.4. 如何避免同一会话出现相互冲突的生成任务

如果同一会话的两个请求同时开始生成,它们可能会:

  • 同时创建回复版本;
  • 互相覆盖 active_tasks 中的任务记录;
  • 让停止接口无法明确应该取消哪一个任务。

当前实现用会话级预留状态和异步锁,在创建生成任务前检查该会话是否已经有任务运行。任务正式登记后,再释放预留状态。生成结束时则移除任务记录。

这样能解决单进程应用中的会话级并发问题,但它不是跨多个进程或 Uvicorn worker 的分布式锁。

3.5. 为什么要为客户端断开连接设计清理逻辑

SSE 连接可能中途断开。如果消费者停止读取队列,生产者却继续调用模型,就会白白消耗资源;队列中的数据也可能不断累积。

因此消费者在流没有正常完成、但客户端已经中断时,会请求取消生成任务,并等待任务结束和保存结果。这里体现了一个重要原则:

流式响应结束和后台任务结束不是天然绑定的,需要应用代码明确处理任务的取消与清理。

这套"重新生成"功能的难点,不只是再次调用一次模型 API,而是要同时处理回复版本、上下文构造、异步流式传输、任务并发、取消后的数据持久化,以及客户端断开时的资源清理。

五、踩坑记录与改进方向

1. Pydantic 请求对象的字段层级

请求体是:

json 复制代码
{
    "messages": [
        {
            "role": "user",
            "content": "你是谁"
        }
    ],
    "model": "qwen3.7-flash"
}

content 属于 messages 中的单条消息,不属于整个请求对象。因此应从 payload.messages 中获取消息,而不是访问 payload.content。

这次排查让我更明确地认识到:看起来 JSON 里存在某个字段,不代表它就在请求模型的顶层;需要对照 Pydantic 模型的结构判断字段路径。

2. 流结束块不一定包含 choices

阿里云 DashScope 的 OpenAI 兼容接口在流末尾会发送不包含 choices 的统计信息块,直接访问 chunk.choices[0] 会触发 IndexError。

  • 修复:每次取值前加 if not chunk.choices: continue 防御性判断。

后续优化方向

  • 向量检索 (RAG):当前仅支持纯对话,下一步计划接入 ChromaDB 实现知识库问答。
  • Token 计数与截断:当历史消息过长时,按 Token 数裁剪上下文,避免超出模型窗口。
  • Redis 缓存热点会话:减少数据库查询压力。
  • 结构化日志与监控:接入 Prometheus 追踪流式响应的首字延迟 (TTFT) 和吞吐量。

源码链接:gitee.com/tingyun-dl/...

相关推荐
sorry3551 小时前
从零实现 nanoGPT(七):融合 QKV,让模型实现向原版靠拢
人工智能
后海天行健1 小时前
实测多款无限画布:协作、AI 生成、大文件加载差异
人工智能
Mark White1 小时前
什么是秩融合(Rank Fusion)-基于一个kaggle比赛的实践理解
人工智能·机器学习
moonsims1 小时前
面向高危现场作业空间安全智能节点AiBrainBox-S:多模态空间融合、动态风险区、身份-视觉轨迹关联、设备级实时安全推理
人工智能·数码相机·无人机
code2cat1 小时前
【随笔】LLM裁判如何打分:先写评分标准,再用人工案例校准
java·人工智能·llm·agent·评估·langsmith
深圳磐链科技1 小时前
Web钱包开发需要哪些核心技术?解析多链集成、私钥管理与区块链交互方案
后端
别乱装1 小时前
Claude Code安装使用教程:终端AI编程助手入门与实操详解
人工智能
guslegend1 小时前
模型调用失败分类、重试与 Model Adapter
人工智能
华普微HOPERF1 小时前
BLE信道探测方案,开启物联网设备的“距离感知”时代
人工智能·物联网