目前的架构

技术选型:

  • 前端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("请先新建或选择一个对话")
}
}