FastAPI + MySQL 实现 AI 对话记录持久化
系列:Python + FastAPI 大模型应用基础(第 8 篇)
目标:使用 FastAPI、SQLAlchemy 2.x 和 MySQL 保存多轮对话,并正确处理用户隔离、短事务、并发请求、模型失败和分页查询。
1. 为什么不能只把对话放在 Python 列表里
入门程序经常这样保存历史:
python
messages = [
{"role": "user", "content": "什么是装饰器?"},
{"role": "assistant", "content": "装饰器本质上是......"},
]
它只存在于当前进程内存中:
- FastAPI 重启后数据消失;
- 多个工作进程看不到同一份数据;
- 用户无法查看历史会话;
- 无法按用户、时间或会话查询;
- 无法审计哪次模型调用失败;
- 无法可靠统计 Token 用量;
- 无法执行数据保留和删除策略。
持久化的第一性目标不是"把字典插进数据库",而是保证:
text
数据属于正确用户
↓
一次模型调用拥有明确状态
↓
失败和中断不会伪装成成功
↓
并发请求不会破坏会话顺序
↓
查询数量受到分页限制
2. 为什么选择 Conversation + ConversationTurn
本文使用两张核心表:
text
conversations
└── conversation_turns
├── 用户问题
├── 模型回答
├── pending / completed / failed
├── 模型信息
└── Token 用量
conversations 表
表示一个用户创建的会话,例如"学习 Python 装饰器"。
conversation_turns 表
一行表示一轮用户问题和模型回答:
text
user_content = 用户问题
assistant_content = 模型回答,生成前为 NULL
status = pending / completed / failed
这样设计适合当前"用户提问---模型回答"的基础对话。
如果以后需要保存 Tool Message(工具消息)、多个 Assistant Message、图片或复杂 Agent 步骤,应升级为独立 messages 表或事件表,不能无限向 conversation_turns 增加字段。
3. 数据表关系
text
conversations
├── id Primary Key(主键),内部使用
├── public_id 对外公开的 UUID
├── owner_id 会话所属用户
├── title
├── created_at
└── updated_at
│
│ 1 对多
▼
conversation_turns
├── id Primary Key(主键)
├── public_id 对外公开的 UUID
├── conversation_id Foreign Key(外键)
├── user_content
├── assistant_content
├── status
├── provider / model
├── Token 用量
├── error_code
├── created_at
└── completed_at
为什么同时使用 id 和 public_id:
- 自增
id适合数据库索引、排序和内部关联; - 随机 UUID 不容易暴露业务数据量,也更难被顺序枚举;
- 对外 API 使用
public_id,数据库 Foreign Key(外键)继续使用高效的整数id。
UUID 不是权限系统。即使 ID 难猜,每次查询仍必须同时校验 owner_id。
4. 为什么模型调用不能放进数据库长事务
错误流程:
text
BEGIN
锁定会话
调用模型并等待 30 秒
保存回答
COMMIT
模型生成期间,数据库连接和行锁一直被占用。并发增加后可能导致:
- 连接池耗尽;
- 锁等待;
- 死锁概率上升;
- 事务超时;
- 其他请求无法更新会话。
本文使用三个短阶段:
text
短事务 A:创建 pending 轮次并提交
↓
事务外调用大模型
↓
短事务 B:成功则写入回答并标记 completed
或失败则标记 failed
数据库事务不能覆盖外部模型调用,因此这里不是一个天然的原子操作。我们通过显式状态和恢复策略管理不一致,而不是假装它不存在。
5. 创建项目
项目结构:
text
ai_chat_persistence/
├── app/
│ ├── __init__.py
│ ├── database.py
│ ├── models.py
│ ├── repository.py
│ ├── model_client.py
│ └── main.py
├── tests/
│ └── test_repository.py
├── init_db.py
└── requirements.txt
本文使用 Python 3.11 及以上版本。
requirements.txt:
text
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
sqlalchemy>=2.0,<3
asyncmy>=0.2,<1
httpx>=0.27,<1
pydantic>=2.7,<3
# 下面三个依赖只用于本地自动化测试
aiosqlite>=0.20,<1
pytest>=8,<9
pytest-asyncio>=0.23,<1
创建环境并安装依赖:
powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
6. 配置 SQLAlchemy 异步数据库
新建 app/database.py:
python
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""所有 ORM 模型的基础类。"""
def utc_now_naive() -> datetime:
"""返回去掉时区标记的 UTC 时间。
MySQL DATETIME 本身不保存时区,因此项目约定所有 DATETIME 都存 UTC。
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
def create_database_runtime(
database_url: str,
*,
echo_sql: bool = False,
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
"""创建异步 Engine(引擎)和 Session 工厂。"""
if not database_url.strip():
raise ValueError("DATABASE_URL 不能为空")
engine = create_async_engine(
database_url,
echo=echo_sql,
# 使用连接前先检查连接是否仍然有效
pool_pre_ping=True,
# 避免长期空闲连接被 MySQL 关闭后继续复用
pool_recycle=1800,
)
session_factory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
# 提交后保留已加载字段,方便返回 DTO
expire_on_commit=False,
)
return engine, session_factory
MySQL 连接字符串示例:
text
mysql+asyncmy://应用用户:URL编码后的密码@127.0.0.1:3306/ai_chat?charset=utf8mb4
不要把真实密码写进代码、CSDN 或 Git。密码包含 @、:、/ 等字符时需要进行 URL 编码。
7. 定义数据库模型
新建 app/models.py:
python
from datetime import datetime
from uuid import uuid4
from sqlalchemy import (
BigInteger,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base, utc_now_naive
# SQLite 测试时使用 Integer,确保内存数据库支持自增主键
id_type = BigInteger().with_variant(Integer(), "sqlite")
# MySQL 使用 LONGTEXT,SQLite 测试时退化为普通 Text
content_type = Text().with_variant(LONGTEXT(), "mysql")
class Conversation(Base):
"""一个用户拥有的 AI 会话。"""
__tablename__ = "conversations"
id: Mapped[int] = mapped_column(
id_type,
primary_key=True,
autoincrement=True,
)
public_id: Mapped[str] = mapped_column(
String(36),
unique=True,
nullable=False,
default=lambda: str(uuid4()),
)
owner_id: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
index=True,
)
title: Mapped[str] = mapped_column(
String(200),
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=utc_now_naive,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=utc_now_naive,
onupdate=utc_now_naive,
)
__table_args__ = (
Index("ix_conversation_owner_created", "owner_id", "created_at"),
)
class ConversationTurn(Base):
"""会话中的一轮用户问题和模型回答。"""
__tablename__ = "conversation_turns"
id: Mapped[int] = mapped_column(
id_type,
primary_key=True,
autoincrement=True,
)
public_id: Mapped[str] = mapped_column(
String(36),
unique=True,
nullable=False,
default=lambda: str(uuid4()),
)
conversation_id: Mapped[int] = mapped_column(
id_type,
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
)
user_content: Mapped[str] = mapped_column(
content_type,
nullable=False,
)
assistant_content: Mapped[str | None] = mapped_column(
content_type,
nullable=True,
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
)
provider: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
)
model: Mapped[str | None] = mapped_column(
String(200),
nullable=True,
)
input_tokens: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
)
output_tokens: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
)
total_tokens: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
)
error_code: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=utc_now_naive,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'completed', 'failed')",
name="ck_conversation_turn_status",
),
Index(
"ix_turn_conversation_id_id",
"conversation_id",
"id",
),
Index(
"ix_turn_conversation_status",
"conversation_id",
"status",
),
)
数据库约束是最后一道防线,应用层仍然必须校验状态。不同 MySQL 版本对 CHECK 约束的支持存在差异,部署时要验证实际数据库版本和建表结果。
8. 实现短事务 Repository(仓储层)
新建 app/repository.py:
python
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.database import utc_now_naive
from app.models import Conversation, ConversationTurn
class ConversationNotFoundError(RuntimeError):
"""会话不存在,或者不属于当前用户。"""
class ConversationBusyError(RuntimeError):
"""当前会话已有一个 pending 请求。"""
class TurnStateConflictError(RuntimeError):
"""轮次状态不允许当前更新。"""
@dataclass(frozen=True)
class PreparedTurn:
"""创建 pending 轮次后返回给业务层的数据。"""
turn_public_id: str
history: list[ConversationTurn]
class ConversationRepository:
"""所有数据库事务都封装在仓储层。"""
def __init__(
self,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
self.session_factory = session_factory
async def _get_owned_conversation(
self,
session: AsyncSession,
public_id: str,
owner_id: int,
*,
for_update: bool = False,
) -> Conversation:
"""同时使用 public_id 和 owner_id,防止越权读取。"""
statement = select(Conversation).where(
Conversation.public_id == public_id,
Conversation.owner_id == owner_id,
)
if for_update:
statement = statement.with_for_update()
conversation = await session.scalar(statement)
if conversation is None:
# 不区分"不存在"和"不属于你",减少信息泄露
raise ConversationNotFoundError("会话不存在")
return conversation
async def create_conversation(
self,
owner_id: int,
title: str,
) -> Conversation:
"""使用短事务创建会话。"""
async with self.session_factory() as session:
async with session.begin():
conversation = Conversation(
owner_id=owner_id,
title=title,
)
session.add(conversation)
await session.flush()
return conversation
async def prepare_turn(
self,
conversation_public_id: str,
owner_id: int,
user_content: str,
*,
context_turn_limit: int = 10,
) -> PreparedTurn:
"""锁定会话、检查并发、读取历史、创建 pending 轮次。"""
async with self.session_factory() as session:
async with session.begin():
conversation = await self._get_owned_conversation(
session,
conversation_public_id,
owner_id,
for_update=True,
)
# 同一会话只允许一个正在生成的轮次
pending_statement = select(ConversationTurn.id).where(
ConversationTurn.conversation_id == conversation.id,
ConversationTurn.status == "pending",
).limit(1).with_for_update()
pending_turn_id = await session.scalar(pending_statement)
if pending_turn_id is not None:
raise ConversationBusyError(
"当前会话正在生成回答,请稍后再试"
)
# 只取最近完成的有限轮次,避免上下文无限增长
history_statement = (
select(ConversationTurn)
.where(
ConversationTurn.conversation_id == conversation.id,
ConversationTurn.status == "completed",
)
.order_by(ConversationTurn.id.desc())
.limit(context_turn_limit)
)
history_desc = list(
(await session.scalars(history_statement)).all()
)
turn = ConversationTurn(
conversation_id=conversation.id,
user_content=user_content,
status="pending",
)
session.add(turn)
conversation.updated_at = utc_now_naive()
await session.flush()
# 查询时是倒序,传给模型前恢复为时间正序
history = list(reversed(history_desc))
return PreparedTurn(
turn_public_id=turn.public_id,
history=history,
)
async def complete_turn(
self,
turn_public_id: str,
assistant_content: str,
provider: str,
model: str,
input_tokens: int | None,
output_tokens: int | None,
total_tokens: int | None,
) -> None:
"""使用独立短事务把 pending 更新为 completed。"""
async with self.session_factory() as session:
async with session.begin():
statement = (
select(ConversationTurn)
.where(ConversationTurn.public_id == turn_public_id)
.with_for_update()
)
turn = await session.scalar(statement)
if turn is None or turn.status != "pending":
raise TurnStateConflictError(
"轮次不存在或状态不允许完成"
)
turn.assistant_content = assistant_content
turn.provider = provider
turn.model = model
turn.input_tokens = input_tokens
turn.output_tokens = output_tokens
turn.total_tokens = total_tokens
turn.error_code = None
turn.status = "completed"
turn.completed_at = utc_now_naive()
async def fail_turn(
self,
turn_public_id: str,
error_code: str,
) -> None:
"""模型失败后把仍处于 pending 的轮次标记为 failed。"""
async with self.session_factory() as session:
async with session.begin():
statement = (
select(ConversationTurn)
.where(ConversationTurn.public_id == turn_public_id)
.with_for_update()
)
turn = await session.scalar(statement)
# 如果已经完成,不允许失败流程覆盖成功结果
if turn is None or turn.status != "pending":
return
turn.status = "failed"
turn.error_code = error_code
turn.completed_at = utc_now_naive()
async def list_turns(
self,
conversation_public_id: str,
owner_id: int,
*,
limit: int = 20,
before_id: int | None = None,
) -> list[ConversationTurn]:
"""按游标分页查询当前用户的会话轮次。"""
if not 1 <= limit <= 100:
raise ValueError("limit 必须位于 1 到 100 之间")
async with self.session_factory() as session:
conversation = await self._get_owned_conversation(
session,
conversation_public_id,
owner_id,
)
statement = select(ConversationTurn).where(
ConversationTurn.conversation_id == conversation.id
)
if before_id is not None:
statement = statement.where(
ConversationTurn.id < before_id
)
statement = statement.order_by(
ConversationTurn.id.desc()
).limit(limit)
turns_desc = list((await session.scalars(statement)).all())
return list(reversed(turns_desc))
为什么 prepare_turn() 要锁定会话行
如果两个请求同时检查 pending,它们可能都看见"当前没有 pending",随后各自创建一条 pending 记录。
SELECT ... FOR UPDATE 锁定同一会话行后,请求会串行完成"检查并发 + 创建轮次"的短事务,从而保证每个会话最多一个生成任务。
这种策略会禁止同一会话并行提问,但不会阻止不同会话并行调用模型。
9. 定义统一模型客户端
新建 app/model_client.py:
python
from dataclasses import dataclass
from typing import Any, Protocol
import httpx
from app.models import ConversationTurn
class ModelClientError(RuntimeError):
"""模型调用统一异常。"""
@dataclass(frozen=True)
class ModelAnswer:
"""保存回答、模型标识和可选 Token 用量。"""
content: str
provider: str
model: str
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
class ModelClient(Protocol):
"""业务层依赖的模型客户端契约。"""
async def chat(
self,
history: list[ConversationTurn],
current_user_message: str,
) -> ModelAnswer:
...
def read_usage_int(raw_usage: object, key: str) -> int | None:
"""从 usage 中读取非负整数,无法确认时返回 None。"""
if not isinstance(raw_usage, dict):
return None
value = raw_usage.get(key)
if isinstance(value, bool) or not isinstance(value, int):
return None
return value if value >= 0 else None
class CompatibleModelClient:
"""常见 /chat/completions 结构的最小异步客户端。"""
def __init__(
self,
http_client: httpx.AsyncClient,
base_url: str,
api_key: str,
provider: str,
model: str,
) -> None:
self.http_client = http_client
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.provider = provider
self.model = model
async def chat(
self,
history: list[ConversationTurn],
current_user_message: str,
) -> ModelAnswer:
"""把已完成历史和当前问题转换成模型消息。"""
messages: list[dict[str, str]] = [
{
"role": "system",
"content": "你是一名严谨的 Python 助手。",
}
]
for turn in history:
# history 只包含 completed,但仍然防御空回答
if not turn.assistant_content:
continue
messages.append(
{"role": "user", "content": turn.user_content}
)
messages.append(
{
"role": "assistant",
"content": turn.assistant_content,
}
)
messages.append(
{"role": "user", "content": current_user_message}
)
try:
response = await self.http_client.post(
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": self.model,
"messages": messages,
"temperature": 0.2,
},
)
except httpx.TimeoutException as exc:
raise ModelClientError("模型请求超时") from exc
except httpx.HTTPError as exc:
raise ModelClientError("模型网络请求失败") from exc
if not 200 <= response.status_code < 300:
raise ModelClientError(
f"模型服务返回异常状态码:{response.status_code}"
)
try:
data: dict[str, Any] = response.json()
content = data["choices"][0]["message"]["content"]
except (ValueError, KeyError, IndexError, TypeError) as exc:
raise ModelClientError("模型响应结构不正确") from exc
if not isinstance(content, str) or not content.strip():
raise ModelClientError("模型返回了空内容")
raw_usage = data.get("usage")
return ModelAnswer(
content=content.strip(),
provider=self.provider,
model=self.model,
input_tokens=read_usage_int(raw_usage, "prompt_tokens"),
output_tokens=read_usage_int(
raw_usage,
"completion_tokens",
),
total_tokens=read_usage_int(raw_usage, "total_tokens"),
)
只把最近 10 个已完成轮次放入上下文,是为了防止历史无限增长。生产系统还需要按 Token 预算截断,而不能只按轮次数量截断。
10. 创建 FastAPI 接口
新建 app/main.py:
python
import asyncio
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
from app.database import create_database_runtime
from app.model_client import CompatibleModelClient, ModelClientError
from app.models import ConversationTurn
from app.repository import (
ConversationBusyError,
ConversationNotFoundError,
ConversationRepository,
)
logger = logging.getLogger(__name__)
class CreateConversationRequest(BaseModel):
title: str = Field(min_length=1, max_length=200)
@field_validator("title")
@classmethod
def title_must_not_be_blank(cls, value: str) -> str:
cleaned_value = value.strip()
if not cleaned_value:
raise ValueError("title 不能为空白字符串")
return cleaned_value
class CreateTurnRequest(BaseModel):
user_message: str = Field(min_length=1, max_length=10_000)
@field_validator("user_message")
@classmethod
def message_must_not_be_blank(cls, value: str) -> str:
cleaned_value = value.strip()
if not cleaned_value:
raise ValueError("user_message 不能为空白字符串")
return cleaned_value
class ConversationResponse(BaseModel):
id: str
title: str
class TurnResponse(BaseModel):
internal_cursor: int
id: str
user_content: str
assistant_content: str | None
status: str
provider: str | None
model: str | None
input_tokens: int | None
output_tokens: int | None
total_tokens: int | None
error_code: str | None
def required_env(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise RuntimeError(f"缺少环境变量:{name}")
return value
async def get_demo_user_id(
x_demo_user_id: Annotated[str | None, Header()] = None,
) -> int:
"""仅用于本地教学的身份模拟,不能用于生产认证。"""
if os.getenv("APP_ENV") != "development":
raise RuntimeError(
"生产环境禁止使用 X-Demo-User-Id,请接入真实认证系统"
)
if x_demo_user_id is None or not x_demo_user_id.isdigit():
raise HTTPException(
status_code=400,
detail="本地测试必须提供 X-Demo-User-Id 正整数",
)
user_id = int(x_demo_user_id)
if user_id <= 0:
raise HTTPException(
status_code=400,
detail="X-Demo-User-Id 必须大于 0",
)
return user_id
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
database_url = required_env("DATABASE_URL")
engine, session_factory = create_database_runtime(database_url)
http_client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=5.0,
read=60.0,
write=10.0,
pool=5.0,
)
)
model_client = CompatibleModelClient(
http_client=http_client,
base_url=required_env("LLM_BASE_URL"),
api_key=required_env("LLM_API_KEY"),
provider=required_env("LLM_PROVIDER"),
model=required_env("LLM_MODEL"),
)
app.state.repository = ConversationRepository(session_factory)
app.state.model_client = model_client
yield
await http_client.aclose()
await engine.dispose()
app = FastAPI(
title="AI 对话记录持久化",
version="1.0.0",
lifespan=lifespan,
)
@app.exception_handler(ConversationNotFoundError)
async def handle_not_found(
request: Request,
exc: ConversationNotFoundError,
) -> JSONResponse:
return JSONResponse(status_code=404, content={"detail": str(exc)})
@app.exception_handler(ConversationBusyError)
async def handle_busy(
request: Request,
exc: ConversationBusyError,
) -> JSONResponse:
return JSONResponse(status_code=409, content={"detail": str(exc)})
@app.exception_handler(ModelClientError)
async def handle_model_error(
request: Request,
exc: ModelClientError,
) -> JSONResponse:
"""统一转换模型错误,避免路由函数返回两种类型。"""
return JSONResponse(status_code=502, content={"detail": str(exc)})
async def mark_turn_failed_safely(
repository: ConversationRepository,
turn_public_id: str,
error_code: str,
) -> None:
"""尽力清理 pending,但不能用清理错误覆盖原始模型错误。"""
try:
await repository.fail_turn(turn_public_id, error_code)
except Exception:
# 生产日志应带 request_id,且不能记录用户问题全文
logger.exception("更新失败轮次状态时发生数据库异常")
@app.post(
"/api/v1/conversations",
response_model=ConversationResponse,
)
async def create_conversation(
body: CreateConversationRequest,
request: Request,
owner_id: int = Depends(get_demo_user_id),
) -> ConversationResponse:
repository: ConversationRepository = request.app.state.repository
conversation = await repository.create_conversation(
owner_id=owner_id,
title=body.title,
)
return ConversationResponse(
id=conversation.public_id,
title=conversation.title,
)
@app.post(
"/api/v1/conversations/{conversation_id}/turns",
response_model=TurnResponse,
)
async def create_turn(
conversation_id: str,
body: CreateTurnRequest,
request: Request,
owner_id: int = Depends(get_demo_user_id),
) -> TurnResponse:
repository: ConversationRepository = request.app.state.repository
model_client: CompatibleModelClient = request.app.state.model_client
# 短事务 A:校验所有权、读取有限历史、创建 pending
prepared = await repository.prepare_turn(
conversation_public_id=conversation_id,
owner_id=owner_id,
user_content=body.user_message,
context_turn_limit=10,
)
try:
# 数据库事务外调用模型,避免长时间占用数据库连接和行锁
answer = await model_client.chat(
history=prepared.history,
current_user_message=body.user_message,
)
except asyncio.CancelledError:
# 客户端断开时尽力释放 pending 状态,然后继续传播取消信号
await asyncio.shield(mark_turn_failed_safely(
repository,
prepared.turn_public_id,
error_code="CLIENT_CANCELLED",
))
raise
except ModelClientError:
await mark_turn_failed_safely(
repository,
prepared.turn_public_id,
error_code="MODEL_CALL_FAILED",
)
raise
except Exception:
await mark_turn_failed_safely(
repository,
prepared.turn_public_id,
error_code="UNEXPECTED_MODEL_ERROR",
)
raise
# 短事务 B:保存回答和 Token 用量
# 如果这里失败,保留 pending 供补偿任务恢复,不能伪装成模型失败
await repository.complete_turn(
turn_public_id=prepared.turn_public_id,
assistant_content=answer.content,
provider=answer.provider,
model=answer.model,
input_tokens=answer.input_tokens,
output_tokens=answer.output_tokens,
total_tokens=answer.total_tokens,
)
# 为了返回最终数据库状态,分页查询后定位当前轮次
turns = await repository.list_turns(
conversation_public_id=conversation_id,
owner_id=owner_id,
limit=100,
)
turn = next(
item for item in turns if item.public_id == prepared.turn_public_id
)
return turn_to_response(turn)
def turn_to_response(turn: ConversationTurn) -> TurnResponse:
"""示例中显式映射 ORM 字段,避免意外序列化其他列。"""
return TurnResponse(
internal_cursor=turn.id,
id=turn.public_id,
user_content=turn.user_content,
assistant_content=turn.assistant_content,
status=turn.status,
provider=turn.provider,
model=turn.model,
input_tokens=turn.input_tokens,
output_tokens=turn.output_tokens,
total_tokens=turn.total_tokens,
error_code=turn.error_code,
)
@app.get(
"/api/v1/conversations/{conversation_id}/turns",
response_model=list[TurnResponse],
)
async def list_turns(
conversation_id: str,
request: Request,
owner_id: int = Depends(get_demo_user_id),
limit: Annotated[int, Query(ge=1, le=100)] = 20,
before_id: Annotated[int | None, Query(ge=1)] = None,
) -> list[TurnResponse]:
repository: ConversationRepository = request.app.state.repository
turns = await repository.list_turns(
conversation_public_id=conversation_id,
owner_id=owner_id,
limit=limit,
before_id=before_id,
)
return [turn_to_response(turn) for turn in turns]
模型异常由 FastAPI Exception Handler(异常处理器)统一转换为 502,因此路由函数的成功返回类型始终保持为 TurnResponse。
11. 本地创建表
新建 init_db.py:
python
import asyncio
import os
from app.database import Base, create_database_runtime
# 必须导入模型,SQLAlchemy Metadata 才知道有哪些表
from app import models # noqa: F401
async def main() -> None:
database_url = os.getenv("DATABASE_URL", "").strip()
if not database_url:
raise RuntimeError("缺少环境变量:DATABASE_URL")
engine, _ = create_database_runtime(
database_url,
echo_sql=True,
)
try:
async with engine.begin() as connection:
# create_all 仅适合本地首次学习
await connection.run_sync(Base.metadata.create_all)
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())
设置环境变量:
powershell
$env:APP_ENV = "development"
$env:DATABASE_URL = "mysql+asyncmy://应用用户:URL编码后的密码@127.0.0.1:3306/ai_chat?charset=utf8mb4"
$env:LLM_BASE_URL = "https://替换为模型服务地址/v1"
$env:LLM_API_KEY = "替换为真实密钥"
$env:LLM_PROVIDER = "替换为服务商内部名称"
$env:LLM_MODEL = "替换为真实模型标识"
创建表并启动:
powershell
.\.venv\Scripts\python.exe init_db.py
.\.venv\Scripts\python.exe -m uvicorn app.main:app --reload
生产环境不要依赖 create_all() 修改表结构,应使用 Alembic 等迁移工具生成、审查和执行数据库变更。
12. 调用接口
创建会话:
powershell
$headers = @{ "X-Demo-User-Id" = "10001" }
$body = @{ title = "学习 Python 装饰器" } | ConvertTo-Json
$conversation = Invoke-RestMethod `
-Method Post `
-Uri "http://127.0.0.1:8000/api/v1/conversations" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
$conversation
发送一轮问题:
powershell
$turnBody = @{
user_message = "什么是 Python 装饰器?"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "http://127.0.0.1:8000/api/v1/conversations/$($conversation.id)/turns" `
-Headers $headers `
-ContentType "application/json" `
-Body $turnBody
查询历史:
powershell
Invoke-RestMethod `
-Method Get `
-Uri "http://127.0.0.1:8000/api/v1/conversations/$($conversation.id)/turns?limit=20" `
-Headers $headers
X-Demo-User-Id 只是本地教学占位。任何人都可以伪造请求头,因此生产环境必须替换成经过签名验证的登录 Token、Session 或企业身份系统。
13. 使用 SQLite 运行仓储层自动化测试
数据库业务逻辑可以先在 SQLite 内存库验证,再在真实 MySQL 测试环境执行集成测试。
新建 tests/test_repository.py:
python
import pytest
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.database import Base
from app.repository import (
ConversationBusyError,
ConversationRepository,
)
@pytest.mark.asyncio
async def test_conversation_turn_lifecycle() -> None:
"""验证 pending、并发拦截、completed 和历史读取。"""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
repository = ConversationRepository(session_factory)
conversation = await repository.create_conversation(
owner_id=10001,
title="测试会话",
)
first = await repository.prepare_turn(
conversation_public_id=conversation.public_id,
owner_id=10001,
user_content="第一个问题",
)
assert first.history == []
# 第一个轮次仍是 pending,同一会话不能并行创建第二个
with pytest.raises(ConversationBusyError):
await repository.prepare_turn(
conversation_public_id=conversation.public_id,
owner_id=10001,
user_content="并发问题",
)
await repository.complete_turn(
turn_public_id=first.turn_public_id,
assistant_content="第一个回答",
provider="fake",
model="fake-model",
input_tokens=10,
output_tokens=20,
total_tokens=30,
)
second = await repository.prepare_turn(
conversation_public_id=conversation.public_id,
owner_id=10001,
user_content="第二个问题",
)
assert len(second.history) == 1
assert second.history[0].assistant_content == "第一个回答"
await repository.fail_turn(
turn_public_id=second.turn_public_id,
error_code="TEST_FAILURE",
)
turns = await repository.list_turns(
conversation_public_id=conversation.public_id,
owner_id=10001,
limit=20,
)
assert [turn.status for turn in turns] == [
"completed",
"failed",
]
finally:
await engine.dispose()
运行测试:
powershell
.\.venv\Scripts\python.exe -m pytest -q
SQLite 测试不能完全替代 MySQL 集成测试,因为两者在锁、隔离级别、字段类型、约束和并发行为上存在差异。上线前必须连接真实版本的 MySQL 测试环境验证。
14. 为什么查询必须同时带 owner_id
危险查询:
python
# 错误:只要猜到 public_id,就可能读取其他用户的会话
select(Conversation).where(Conversation.public_id == public_id)
本文查询:
python
select(Conversation).where(
Conversation.public_id == public_id,
Conversation.owner_id == current_user_id,
)
这用于防止 IDOR(Insecure Direct Object Reference,不安全的直接对象引用)。
owner_id 必须来自已经验证的登录身份,不能直接相信请求体或可伪造的普通请求头。
15. 为什么会话只允许一个 pending 轮次
如果同一会话同时提交两个问题:
text
问题 A 和问题 B 同时读取相同历史
问题 B 先完成
问题 A 后完成
此时哪个回答应该排在前面、问题 B 是否应该看到问题 A,都不再明确。
本文选择简单且可验证的规则:同一会话只有一个生成中的轮次。需要并行分支对话时,应显式建立 Parent Turn(父轮次)或 Branch(分支)关系,而不是让完成时间暗中决定顺序。
16. pending 永久残留怎么办
如果应用在模型调用过程中崩溃,数据库已经保存 pending,但没有机会写入 failed。后续请求会一直被阻止。
生产系统需要恢复任务:
text
定时查询 status = pending
并且 created_at 早于安全阈值
↓
确认没有仍在运行的任务
↓
标记为 failed 或重新进入任务队列
不能简单把所有超过 60 秒的任务都判定失败,因为长回答可能仍在生成。更可靠的方案是使用任务 ID、Worker 心跳和消息队列状态。
17. 为什么保存回答失败不能假装成功
可能出现以下情况:
text
模型已经成功生成回答
↓
MySQL 更新时网络中断
↓
用户没有得到可持久化的成功状态
这时不能仅把模型文本返回给用户并假装一切正常,否则刷新页面后回答消失。
可选方案:
- 使用任务队列和幂等 Turn ID 重试持久化;
- 将模型结果写入可靠消息队列;
- 返回明确的"回答生成成功但保存失败";
- 通过后台补偿任务恢复;
- 记录告警并保留 Request ID。
数据库事务无法自动回滚已经发生的外部模型调用,这属于分布式一致性问题。
18. 数据安全与保留策略
对话内容可能包含敏感数据,至少考虑:
- 数据库磁盘加密和备份加密;
- 数据库账号最小权限;
- 用户和租户隔离;
- 管理员操作审计;
- 敏感字段脱敏;
- 会话删除和保留期限;
- 导出与删除请求;
- 禁止把完整内容写入普通日志;
- 测试环境不能复制未经脱敏的生产数据。
是否需要应用层字段加密取决于威胁模型、合规要求和检索需求,不能只因为数据库支持加密就认为权限问题已经解决。
19. 对抗性审查:当前实现还有哪些边界
19.1 本地演示身份可以被伪造
X-Demo-User-Id 只能用于开发环境。生产系统必须接入真实认证,并从验证后的身份中提取用户 ID。
19.2 SQLite 不能证明 MySQL 锁正确
SQLite 会忽略或以不同方式处理 FOR UPDATE。自动化测试验证了业务生命周期,但 MySQL 并发和隔离级别必须单独测试。
19.3 上下文只按轮数截断
不同轮次 Token 数差异很大。真正的上下文管理应按模型上下文窗口和 Token 预算截断,并保留 System Prompt 和当前问题。
19.4 没有数据库迁移记录
create_all() 不能管理已有表的安全变更。生产项目需要 Alembic 迁移文件、代码审查和回滚方案。
19.5 没有幂等键
客户端超时后重复提交,可能创建两个不同轮次。可以要求客户端提交幂等键,并在数据库建立 (owner_id, idempotency_key) 唯一约束。
19.6 失败原因只有错误代码
数据库只保存稳定错误代码是为了避免泄露内部异常。完整堆栈应进入脱敏日志,并通过同一 Request ID 关联,不能直接写进用户可见表。
19.7 表结构不支持复杂 Agent 消息
工具调用、工具返回、图片和多 Agent 事件不适合放进一行 Turn。需要时应升级为 Message/Event 模型,并设计消息类型和排序规则。
20. 本篇总结
本文完成了 FastAPI + MySQL 对话持久化闭环:
- 使用 Conversation 和 ConversationTurn 两张表;
- 内部自增主键与对外 UUID 分离;
- 每次查询同时校验
public_id和owner_id; - 模型调用放在数据库事务外;
- 使用
pending/completed/failed表达真实状态; - 锁定会话行,阻止同一会话并发生成;
- 只读取有限历史,避免无限加载;
- 使用游标分页查询对话;
- 保存模型标识和 Token 用量;
- 使用 SQLite 自动化测试验证基本生命周期;
- 明确 MySQL 集成测试、迁移、认证和恢复任务仍然必需。
下一篇将使用 Redis 保存会话上下文,并分析数据库持久化与缓存之间的职责边界。
21. 练习题
- 增加会话列表接口,并按
owner_id + created_at分页; - 增加客户端幂等键和数据库唯一约束;
- 编写 MySQL 并发测试,验证同一会话只能创建一个 pending;
- 增加后台任务,处理超过安全时间的 pending;
- 使用 Alembic 生成第一次迁移,而不是依赖
create_all(); - 将上下文限制从"最近 10 轮"改成 Token 预算;
- 验证用户 10002 无法读取用户 10001 的会话;
- 设计会话软删除、数据保留和彻底删除流程。