「从零到 AI 应用工程师」专栏 · 第 5 篇
到现在为止,每次对话都是「问完就忘」。刷新一下、换个客户端,什么都没有。
聊天产品要有历史;排障也要有历史;以后做多轮上下文,更要有历史。
今天目标:用 PostgreSQL 把每次问答落库,并提供 GET /history 分页查询。
有一件事要先说清楚:历史是业务事实,不是缓存的副产品。 后面做 Redis 缓存时,命中缓存也照样写库——只是跳过昂贵的模型调用。
一、表怎么设计(够用的最小集)
至少记下这些:
| 字段 | 含义 |
|---|---|
id | 主键 |
user_id | 用户 |
session_id | 会话(同一轮对话的分组) |
user_content | 用户问题 |
ai_content | 模型回答(可能为空) |
msg_status | 状态:处理中 / 完成 / 失败 |
created_at / updated_at | 时间 |
示例 DDL:
CREATE TABLE chat_messages (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
session_id VARCHAR(64) NOT NULL,
user_content TEXT NOT NULL,
ai_content TEXT,
msg_status SMALLINT NOT NULL DEFAULT 2, -- 2处理中 1完成 0失败
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 按用户查历史:条件 + 排序一起覆盖
CREATE INDEX idx_chat_messages_user_created
ON chat_messages (user_id, created_at DESC, id DESC);
为什么要有状态?
因为调模型是外部 I/O,可能超时、可能失败。如果「成功才插入」,中间挂了你什么痕迹都没有。更稳的做法是:
先写入一条 processing
→ 调模型
→ 成功:补上 ai_content,改为 completed
→ 失败:改为 failed(可选记下失败原因到日志)
二、在分层里它放哪
routers/chat.py
→ services/chat_service.py
→ repositories/chat_repository.py # 真正碰数据库
→ clients/llm_client.py
Router 依然很薄;Service 负责「先落库再调模型再更新」;Repository 只做增删改查。
1. 数据库会话:请求结束一定要关
# db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "postgresql+psycopg2://app_user:密码@127.0.0.1:5432/chat_db"
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
yield + finally:成功失败都归还连接,避免连接池被掏空。
2. Repository:只谈表
# repositories/chat_repository.py
from sqlalchemy.orm import Session
from db.models import ChatMessage
STATUS_FAILED = 0
STATUS_COMPLETED = 1
STATUS_PROCESSING = 2
def create_processing(
db: Session, user_id: str, session_id: str, message: str
) -> ChatMessage:
row = ChatMessage(
user_id=user_id,
session_id=session_id,
user_content=message,
msg_status=STATUS_PROCESSING,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def mark_completed(db: Session, row: ChatMessage, answer: str) -> None:
row.ai_content = answer
row.msg_status = STATUS_COMPLETED
db.commit()
def mark_failed(db: Session, row: ChatMessage) -> None:
row.msg_status = STATUS_FAILED
db.commit()
def list_by_user(db: Session, user_id: str, page: int, page_size: int):
q = db.query(ChatMessage).filter(ChatMessage.user_id == user_id)
total = q.count()
items = (
q.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return items, total
排序为什么是 created_at DESC, id DESC?
同一秒内多条记录时,只按时间排序顺序会飘;带上 id 才稳定,翻页才不乱。
3. Service:先记一笔,再打电话
# services/chat_service.py(关键片段)
from core.response import BusinessError
from clients import llm_client
from repositories import chat_repository as repo
async def reply(db, user_id: str, session_id: str, message: str) -> dict:
if len(message) < 2:
raise BusinessError("问题过短", status_code=400)
row = repo.create_processing(db, user_id, session_id, message)
try:
answer = await llm_client.generate(message)
if not answer or not answer.strip():
repo.mark_failed(db, row)
raise BusinessError("模型未返回有效内容", status_code=503)
repo.mark_completed(db, row, answer)
return {"answer": answer, "from_cache": False, "message_id": row.id}
except BusinessError:
raise
except Exception:
repo.mark_failed(db, row)
raise
要点:
- 短事务创建 processing,再调外部模型,避免长时间占着数据库事务;
- 失败一定把状态改掉,别让历史永远停在「处理中」。
4. 历史接口
# routers/chat.py
from fastapi import Depends, Query
from sqlalchemy.orm import Session
from db.session import get_db
from core.response import success, BusinessError
from repositories import chat_repository as repo
@router.get("/history", dependencies=[Depends(verify_token)])
async def history(
request: Request,
user_id: str = Query(min_length=1, max_length=64),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db),
):
items, total = repo.list_by_user(db, user_id, page, page_size)
data = {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": x.id,
"session_id": x.session_id,
"user_content": x.user_content,
"ai_content": x.ai_content,
"msg_status": x.msg_status,
"created_at": x.created_at.isoformat(),
}
for x in items
],
}
return success(data, request_id=request.state.request_id)
page_size 设上限(这里 50):没有上限的分页,是生产事故的温床。
三、验收
1. 发一条对话
curl -X POST http://127.0.0.1:8000/chat \
-H "Authorization: Bearer dev-token" \
-H "Content-Type: application/json" \
-d '{
"user_id": "history_demo",
"session_id": "session_history_01",
"message": "请记住这次对话"
}'
2. 查历史
curl "http://127.0.0.1:8000/history?user_id=history_demo&page=1&page_size=10" \
-H "Authorization: Bearer dev-token"
预期:items 里能看到刚才的问题与回答,msg_status 为完成。
3. 非法页码
curl -i "http://127.0.0.1:8000/history?user_id=history_demo&page=0&page_size=10" \
-H "Authorization: Bearer dev-token"
预期:400(或参数校验失败的统一响应),而不是返回怪数据。
四、常见坑
-
调模型期间一直不 commit,长事务拖着锁和连接。
先短事务写入 processing,再调外部服务。 -
失败后忘了改状态。
历史里全是「处理中」,排障时你会怀疑人生。 -
只按
created_at排序。
时间精度相同时翻页顺序不稳定。 -
page_size不设上限。
有人传 100000,数据库和带宽一起哭。 -
ORM 对象在 Session 关闭后再访问懒加载字段。
在 Repository/Service 内把需要的数据取干净,或转成 dict/schema 再返回。 -
深翻页用很大的 offset。
数据量大了会慢。早期够用;量大后再上游标分页(WHERE id < ?)。
五、带走这三条
- 聊天记录是业务事实:缓存可以没有,历史应当有。
processing → completed/failed让外部模型调用可审计、可排查。- 分页、排序、索引要一起设计,不是写完
.offset().limit()就结束。
下一篇讲省钱利器:用 Redis 缓存「重复问题」的答案。记住今天这句话——缓存命中,也要写历史。
这是专栏第 5 篇。对话开始有记忆了。两天一更,下篇见。