给对话加记忆:用 PostgreSQL 存历史

4 阅读5分钟

「从零到 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(或参数校验失败的统一响应),而不是返回怪数据。


四、常见坑

  1. 调模型期间一直不 commit,长事务拖着锁和连接。
    先短事务写入 processing,再调外部服务。

  2. 失败后忘了改状态。
    历史里全是「处理中」,排障时你会怀疑人生。

  3. 只按 created_at 排序。
    时间精度相同时翻页顺序不稳定。

  4. page_size 不设上限。
    有人传 100000,数据库和带宽一起哭。

  5. ORM 对象在 Session 关闭后再访问懒加载字段。
    在 Repository/Service 内把需要的数据取干净,或转成 dict/schema 再返回。

  6. 深翻页用很大的 offset。
    数据量大了会慢。早期够用;量大后再上游标分页(WHERE id < ?)。


五、带走这三条

  1. 聊天记录是业务事实:缓存可以没有,历史应当有。
  2. processing → completed/failed 让外部模型调用可审计、可排查。
  3. 分页、排序、索引要一起设计,不是写完 .offset().limit() 就结束。

下一篇讲省钱利器:用 Redis 缓存「重复问题」的答案。记住今天这句话——缓存命中,也要写历史。

这是专栏第 5 篇。对话开始有记忆了。两天一更,下篇见。