目前的架构
技术选型:
- 前端Vue3 + TypeScript + vite构建
- 后端FastAPI + SQLAlchemy + MySQL + LangChain
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| ChatBot/ ├── frontend/ │ ├── src/ │ │ ├── App.vue # 主界面 │ │ ├── components/ # 组件 │ │ │ ├── AssistantMessage.vue │ │ │ ├── ConversationChoice.vue │ │ │ ├── InputMessage.vue │ │ │ └── UserMessage.vue │ │ └── main.ts │ └── package.json │ ├── backend/ │ ├── agent/ # AI 代理层 │ │ └── Agent.py │ ├── models/ # 数据模型层 │ │ ├── Conversation.py # 对话表 │ │ └── Message.py # 消息表 │ ├── routers/ # 路由层 │ │ └── Conversation.py # REST API 端点 │ ├── database.py # SQLAlchemy 异步引擎 │ ├── main.py # FastAPI 入口 │ └── pyproject.toml │ └── .gitignore
|
需求
需求其实很简单,就是一个网页聊天机器人,当前已集成功能:
- 带上下文的连续性对话
- 聊天记录持久化存储
- 多会话隔离
- 渲染LLM返回的Markdown文本
未来还需要实现的功能:
- token登录鉴权和用户隔离
- 日志系统
- 图片/文件上传接口
- 模型选择/自定义base_url和api_key
- agent的时间感知
- 强化agent工具调用,联网搜索支持
- 知识库构建,检索增强生成
Q & A
要做多会话隔离,数据库的表结构怎么设计
如果将所有会话的消息全部揉在一张表里,那“隔离”就不存在了
如果每个会话单独建一张表,代码实现层面上,SQLAlchemy是以一个类对应数据库的一张表的,新建对话时通过代码新建一个类难以实现,而且FastAPI只有在每次启动时才会自动建表,每次新建对话就重启一次后端服务显然不合适;数据库层面上,一旦会话数量上去,MySQL的启动、备份速度都会受影响,大量的表在维护时也是噩梦般的存在
解决方案
只用两张表messages conversations存储所有数据,其中messages表新增一个字段conversation_id,与conversations表的唯一主键id对应,以conversation_id作为不同会话的区分以此实现多会话隔离,查询时传入conversation_id参数即可筛选出特定对话的全部消息:
1 2 3 4 5
| @router.get("/{conversation_id}/messages") async def get_all_messages(conversation_id: int, db: AsyncSession = Depends(get_db)): orm_obj = await db.execute(select(Messages).where(Messages.conversation_id == conversation_id)) messages = orm_obj.scalars().all() return messages
|
前端的对话选择性/存在性校验和拦截
我们要求用户必须在一个具体的对话里收发消息,如果没有conversation,前端就需要拦截用户发送的messages并提醒用户新建一个conversation,这个功能看上去很简单,但我的实现却是一波三折

经过三次commit才彻底解决这个问题
第一想法当然是在发送消息前从数据库查询是否有conversation存在:
1 2 3 4 5 6 7 8 9
| function sendUserMessage(content: string){ getConversations() if(conversations.value.length !== 0){ axios({...}).then(...) } else{ alert("请先新建或选择一个对话") } }
|
这个方案的问题是没有校验conversation_id,即没有判断用户是否选择了某一个确定的对话,而conversation_id的初始化值为0
假如有多个会话存在(符合if判断),而用户没有选择任意一个会话(conversation_id === 0),前端还是会将请求发出去,如果后端也没有做校验,那么数据库中messages表会多出conversation_id字段0的列,而conversations表里根本没有id为0的会话,虽然能正常与agent对话,但这个conversation_id为0的会话已经跳出三界之外、不在五行之中了,前端不可达,后端也不与conversations表对应
于是有了第二版,:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| let conversation_id = ref<number>(conversations.value[0]?.id ?? 0)
function sendUserMessage(content: string) { getConversations() if (conversations.value.length !== 0 && conversation_id.value !== 0 ) { messages.value.push({id: null, type: 'user', content}) axios({ ... }).then(response => { ... }) } else { alert("请先新建或选择一个对话") } }
|
但这时并没有真正解决问题,用户还是可能向conversation_id为0的幽灵会话中发送消息
因为getConversation()里的axios请求是异步的,也就是说,sendUserMessage里做if判断时,getConversations并没有执行完成,conversations.value列表事实上还是旧的会话列表
最终修复版,用async修饰getConversations和sendUsrMessage函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| async function getConversations() { axios({ method: "GET", url: BASE_URL + `/conversations` }).then(response => { conversations.value = [...response.data] }) }
getConversations()
let conversation_id = ref<number>(conversations.value[0]?.id ?? 0)
async function sendUserMessage(content: string) { await getConversations() if (conversations.value.length !== 0 && conversation_id.value !== 0 ) { messages.value.push({id: null, type: 'user', content}) axios({ method: "POST", url: BASE_URL + `/${conversation_id.value}/send_message`, data: { conversation_id:conversation_id.value, type: "user", content } }).then(response => { messages.value.push({ id: response.data.assistant_message.id, type: 'assistant', content: response.data.assistant_message.content, }) }) } else { alert("请先新建或选择一个对话") } }
|