大模型项目:后端优化意图识别,历史对话
一、功能总览
1. 系统架构
┌────────────────────────── 前端 (my-vue-app) ──────────────────────────┐
│ Login.vue(邮箱验证码登录) │
│ │ 登录成功 → 跳转 │
│ ▼ │
│ Chat.vue(对话页面) │
│ ├── 左侧:历史记录菜单栏(queryHistoryMenu) │
│ ├── 右侧:SSE 流式聊天(EventSource) │
│ └── 底部:输入框 + 发送按钮 │
└──────────────────────────┬──────────────────────────────────────────┘
│ HTTP / SSE
▼
┌────────────────────────── 后端 (stu_fastapi) ─────────────────────────┐
│ main.py ── 注册子路由 + 静态资源 + 跨域 │
│ ├── users 模块 ── 发送邮件 / 验证码登录(Day6 升级) │
│ ├── chat 模块 ── 对话聊天(SSE)/ 保存会话结果 │
│ └── history 模块 ── 历史记录菜单 / 对话记录 │
│ ai 模块(模型统一封装) │
│ ├── LoadModel ── 千问大模型 LLM │
│ ├── LoadEmbeddingModel ── BGE 向量化模型(含检索指令) │
│ ├── LoadRerankerModel ── 重排序模型 │
│ └── LoadChroma ── Chroma 向量数据库连接 │
│ create_data 模块 │
│ └── LawDataBuild ── 法律知识库构建脚本 │
└──────────────────────────┬──────────────────────────────────────────┘
│
▼
MySQL(用户/历史记录) Redis(验证码) Chroma(向量库) Ollama(意图识别小模型)
2. 对话业务流程图
用户输入问题
│
▼
【ChatController.chat】 SSE 流式接口
│
▼
【ChatService.chat】 核心业务
├── ① 判断 historyId:0 = 新对话,否则加载历史记录
├── ② 意图识别:Ollama 小模型判断问题是否与法律相关
│ ├── 不相关 → 直接 LLM 回复(不检索知识库)
│ └── 相关 → 进入 RAG 检索流程
├── ③ 检索流程(Day9 详讲):
│ 向量检索 top-10 → BM25 检索 top-10 → RRF 融合 → 重排序取 top-3
├── ④ 拼接提示词(历史记录 + 参考资料 + 问题)
└── ⑤ LLM 流式生成 → 逐 token 返回前端
│
▼
【前端 Chat.vue】 EventSource 逐字接收 → 渲染打字效果
│
▼
【保存会话】 ChatService.save_conversation_result → MySQL history 表
│
▼
【历史记录】 queryHistoryMenu 刷新左侧菜单栏
3. 涉及技术栈(相比 Day5-Day7 新增)
| 技术 | 用途 | 说明 |
|---|---|---|
| Element Plus | Vue 3 UI 组件库 | 卡片、表单、按钮、图标、消息提示,美化前端页面 |
| EventSource | 前端 SSE 接收 | 浏览器原生 API,监听服务器流式推送的数据 |
| sessionStorage | 浏览器会话级存储 | 登录后保存用户名,跳转页面后仍能读取 |
| ChatOllama | 本地小模型调用 | 通过 Ollama 启动的本地模型做意图识别(qwen3:1.7b) |
| LangChain LCEL 管道 | 后端 RAG 流程编排 | RunnableParallel + RunnableLambda 串联检索 |
| 事务管理 | MySQL 数据一致性 | 新增会话记录 commit/rollback + lastrowid |
| BGE 检索指令 | 非对称检索优化 | query 端加"为这个句子生成表示以用于检索相关文章"前缀 |
二、小模型加载封装
1. .env 新增配置
新增 AI 相关配置:
# Ollama 本地小模型(意图识别用)
OLLAMA_MODEL_NAME="qwen3:1.7b"
OLLAMA_BASE_URL="http://localhost:11434"
三、法律知识库create_data/LawDataBuild.py
对话系统的数据基础是法律知识库。create_data/LawDataBuild.py 负责把法条数据存入 Chroma 向量数据库,只需在开发阶段执行一次。
# create_data/LawDataBuild.py
import os
import sys
# 将项目根目录加入 sys.path,保证无论从哪个目录运行都能 import ai 包
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
from langchain_core.documents import Document
from langchain_chroma import Chroma
from ai import LoadEmbeddingModel
load_dotenv()
# 数据集路径:数据文件与脚本在同一目录,按脚本位置定位(不依赖运行目录)
database_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "法律数据集.txt")
# 向量数据库存储路径 / 集合名称:统一读 .env,与检索端保持一致
vector_database_path = os.getenv("CHROMA_PATH")
collection_name = os.getenv("COLLECTION_NAME")
# 按行读取:数据文件是"一行一条法条",直接每行作为一个 chunk,语义最完整
# 去掉空行,并用 dict 保序去重,避免重复法条重复入库
with open(database_path, encoding="utf-8") as f:
lines = list(dict.fromkeys(line.strip() for line in f if line.strip()))
documents = [Document(page_content=line, metadata={"source": database_path}) for line in lines]
print(f"数据文件共 {len(documents)} 条法条")
# 关键:Chroma.from_documents 只会追加、不会覆盖。必须先删除旧 collection 再重建,
# 否则早期分割的旧长串 chunk 会一直残留,检索仍会命中旧数据。
client = Chroma(
collection_name=collection_name,
persist_directory=vector_database_path,
embedding_function=LoadEmbeddingModel.load_embedding_model(),
)
client.delete_collection()
print("已删除旧 collection,开始重建...")
# 重建并入库
try:
Chroma.from_documents(
documents=documents, # 数据:按行切分后的法条
embedding=LoadEmbeddingModel.load_embedding_model(), # 向量化模型
persist_directory=vector_database_path, # 存储路径
collection_name=collection_name, # 集合名称
collection_metadata={"hnsw:space": "cosine"}, # 匹配规则
)
print("数据存入成功")
except Exception as e:
print(f"数据存入失败:{e}")
关键点:
| 技巧 | 作用 |
|---|---|
sys.path.insert | 把项目根目录加进模块搜索路径,保证脚本在任意目录都能 import ai |
os.path.abspath(__file__) | 按脚本自身位置定位数据集,不依赖"在哪运行" |
| 一行一条法条 = 一个 chunk | 法律文本语义完整,不需要再按长度切割,直接按行切分最合理 |
dict.fromkeys(...) | 用字典的 key 天然去重,同时保持插入顺序 |
client.delete_collection() | ⚠️ 必须先删除旧集合再重建 |
⚠️ 为什么必须删除旧 collection?
Chroma.from_documents只会追加、不会覆盖。如果之前用不同的分割方式(如 chunk_size=100)入库过,旧的长串 chunk 会一直残留在集合里,检索时仍会命中旧数据。所以每次重新构建知识库,务必先delete_collection()再重建,保证数据干净。
四、意图识别 — chat/utils/IntentionUtil.py
对话系统第一个关键决策:用户的问题到底要不要走 RAG 检索? 比如问"今天心情不好"(闲聊)就不需要检索法律知识库。这里用本地小模型(Ollama 启动的 qwen3:1.7b)做意图识别,成本低、速度快。
# chat/utils/IntentionUtil.py
import json
import re
from langchain_ollama import ChatOllama
import os
from dotenv import load_dotenv
load_dotenv()
def intention_recognition(question):
llm = ChatOllama(
model=os.getenv("OLLAMA_MODEL_NAME"),
base_url=os.getenv("OLLAMA_BASE_URL"),
)
intention_prompt = """
你是一个专业的法律领域意图识别专家。你的任务是判断用户的输入是否包含【法律相关】内容。
# Definition: 什么是"法律相关"
包括但不限于以下范畴:
1. 法律法规咨询(刑法、民法、劳动法、婚姻法、知识产权等)
2. 合同纠纷、违约责任、债务追讨
3. 诉讼、仲裁、行政复议、报案流程
4. 律师咨询、法律援助、公证遗嘱
5. 交通事故/工伤/医疗事故的赔偿责任认定
6. 消费者权益保护、维权投诉
7. 公司合规、股权架构、破产清算
# Definition: 什么是"不相关"
1. 纯情感倾诉且未提及任何权益/纠纷/规则
2. 纯粹的道德伦理讨论(不涉及法律评价)
3. 日常生活闲聊、技术问题、娱乐八卦
# Output Format
仅输出一个JSON对象,不要包含任何其他解释文字:
{
"is_legal": True/False,
"confidence": "high/medium/low",
}
# Examples
User: "我和房东签了合同但他不退押金怎么办"
Assistant: {"is_legal": true, "confidence": "high"}
...
"""
rs = llm.invoke([
{"role": "system", "content": intention_prompt},
{"role": "user", "content": question}
])
return parse_intention(rs.content)
def parse_intention(content):
"""
把 Ollama 返回的文本稳健地解析成 {"is_legal": bool, "confidence": str}。
Ollama 小模型偶尔会在 JSON 外包裹 ```json 代码块,或夹杂解释文字,
直接 json.loads 会抛异常导致整个 SSE 流中断,这里做容错处理。
"""
if not isinstance(content, str):
content = str(content)
# 去掉 markdown 代码块标记 ```json / ```
content = re.sub(r"```(?:json)?", "", content, flags=re.IGNORECASE).strip()
# 截取第一个 { 到最后一个 } 之间的内容
start, end = content.find("{"), content.rfind("}")
if start != -1 and end > start:
content = content[start:end + 1]
try:
data = json.loads(content)
if isinstance(data, dict) and "is_legal" in data:
return data
except Exception as e:
print(f"意图识别 JSON 解析失败,原文:{content!r},错误:{e}")
# 解析失败时保守兜底:按"法律相关"走 RAG 检索,保证问答流程不中断
return {"is_legal": True, "confidence": "low"}
关键点:
| 概念 | 说明 |
|---|---|
| ChatOllama | LangChain 连接本地 Ollama 服务的加载器,base_url 指向 http://localhost:11434 |
| 意图识别 Prompt | Few-shot 设计:给出"法律相关/不相关"定义 + 正反例,模型按格式输出 JSON |
is_legal 返回 | True = 法律相关走 RAG;False = 直接 LLM 回复 |
| JSON 容错解析 | 小模型可能输出 ```````json ````代码块或夹杂解释文字,做清洗后再 json.loads |
| 保守兜底 | 解析失败时返回 is_legal: True,宁可多检索也不让问答中断 |
为什么用本地小模型做意图识别? 意图识别只是"是/否"二分类判断,用 qwen3:1.7b 这类小模型就够用,调用千问大模型会浪费成本和时间。这也是 RAG 系统中常见的"路由"思想:先用轻量模型分流,再决定是否进入重检索流程。
五、对话聊天后端 — chat 模块【核心】
1. ChatController — SSE 流式聊天接口
# chat/controller/ChatController.py
import json
from fastapi import APIRouter
from starlette.responses import StreamingResponse
from chat.service import ChatService
from chat.entity.ConversationResultEntity import ConversationResultEntity
chat_router = APIRouter()
@chat_router.get(
path="/chat",
summary="聊天",
description="""
聊天
访问路径:http://localhost:8001/chat/chat
请求参数:
question:用户问题
返回值:
流式输出 sse
"""
)
def chat(question: str, historyId: int):
# 流式输出处理
def generator():
for chunk in ChatService.chat(question, historyId):
yield f"data: {json.dumps({'content': chunk})}\n\n"
yield f"data: {json.dumps({'content': '[DONE]'})}\n\n"
return StreamingResponse(
content=generator(),
media_type="text/event-stream"
)
@chat_router.post(
path="/saveConversationResult",
summary="保存会话结果",
description="""
保存会话结果
访问路径:http://localhost:8001/chat/saveConversationResult
"""
)
def save_conversation_result(conversationResultEntity: ConversationResultEntity):
return ChatService.save_conversation_result(conversationResultEntity)
关键点:
| 关键点 | 说明 |
|---|---|
question + historyId | GET + k=v 传参。historyId=0 表示新对话,否则为已有会话 id |
| 流式输出 | 向前端发送chunk字段,chat聊天字段才如同流水一样打印 |
[DONE] 结束标记 | 无论成功失败,最后都要发结束标记,前端才能 es.close() |
| 保存会话用 POST | 涉及数据库写入,属于状态变更操作,用 POST + JSON 请求体 |
2. ConversationResultEntity — 保存会话请求体
# chat/entity/ConversationResultEntity.py
from pydantic import BaseModel, Field
class ConversationResultEntity(BaseModel):
question: str = Field(..., description="问题")
username: str = Field(..., description="用户名")
parentId: int = Field(..., description="历史会话id")
answer: str = Field(..., description="答案")
关键点:
parentId:所属会话的 id。新对话的首条记录parentId=0,之后追问的记录parentId=首条记录id,形成"父子"链- 前端保存会话时,如果当前是新对话(currentChatId=0),保存成功后会把返回的 historyId 赋值给 currentChatId,后续追问都以它为父 id
3. ChatService.chat — 对话核心业务
# chat/service/ChatService.py
from ai import LoadChroma, LoadRerankerModel, LoadModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnableParallel, RunnablePassthrough, RunnableLambda
from chat.dao import ChatDao
from chat.service import HistoryService
from chat.utils import IntentionUtil, BM25Util, RRFUtil
def chat(question, historyId):
# 如果historyId=0,表示一个新对话
if historyId == 0:
history = []
else:
# 查询对话记录 --- 没有考虑只查询最新的几条记录
history = HistoryService.conversation_log(historyId)['data']
"""
使用一个小模型来做意图识别,根据结果判定是否走 RAG 检索
"""
is_legal = IntentionUtil.intention_recognition(question)['is_legal'] # 意图识别
llm = LoadModel.load_model()
# 判断用户的问题是否需要走RAG检索
if not is_legal:
history.append({"role": "user", "content": question})
# 直接LLM回复
for chunk in llm.stream(history):
if chunk.content:
yield chunk.content
return
# 提示词文本内容
template = """
你是一个基于知识库的AI助手。请根据RAG检索内容回答用户问题。
规则:
- 仅基于提供的知识回答,不使用外部知识补充。
- 检索内容不足时,说明信息不足,不要猜测。
- 优先提炼关键答案,避免冗长解释。
- 保持回答自然、简洁、有帮助。
- 输出结果的时候,不允许输出根据提供的参考资料这样的内容
- 输出结果的时候,如果没有参考的上下文信息,请给出一个友好的回复信息
历史记录:
{history}
参考资料:
{context}
问题:
{question}
答案:
"""
# 创建提示词对象
prompt = PromptTemplate(
template=template,
input_variables=["history", "context", "question"],
)
# 创建检索器对象
vector_db = LoadChroma.load_chrome_conn()
vector_retriever = vector_db.as_retriever(search_kwargs={"k": 10})
# 打印召回的结果
def print_recall(docs):
for doc in docs:
print(doc.page_content)
print("-" * 20)
return docs
# 重排序
def reranker_func(data):
print("开始进行重排序...")
docs = data['context']
question = data['question']
history = data['history']
reranker = LoadRerankerModel.load_rerank_model()
scores = reranker.compute_score([(question, doc.page_content) for doc in docs])
reranker_dict = []
for index, item in enumerate(scores):
reranker_dict.append({docs[index].id: item})
# 排序
reranker_dict.sort(key=lambda x: list(x.values())[0], reverse=True)
reranker_result = [doc.page_content for index, item in enumerate(reranker_dict) for one in item.keys() for
doc in docs if doc.id == one][:3]
context = "\n\n".join(
f"[来源{index}] {item}" for index, item in enumerate(reranker_result, start=1)
)
print(f"重排序后的结果:\n{context}")
return {
"context": context,
"history": history,
"question": question,
}
# 创建langchain链
qa_chain = (
# 并行执行器 --- 处理问题和基于问题检索文档
RunnableParallel(
{
"context": vector_retriever | RunnableLambda(print_recall), # 上下文,内容就是检索的文档
"history": RunnableLambda(lambda _: history), # 对话记录
"question": RunnablePassthrough(), # 透明传递
}
)
| RunnableLambda(reranker_func) # 重排序
| prompt # 提示词
| llm # 大模型对象
| StrOutputParser() # 把llm输出的结果转为字符串输出
)
for chunk in qa_chain.stream(question):
yield chunk
关键点:
| 环节 | 说明 |
|---|---|
| historyId 判断 | historyId == 0 新对话,历史记录为空;否则从 conversation_log 加载历史对话 |
| 意图识别分流 | is_legal=False 时直接 llm.stream(history) 回复,跳过检索,省钱又省时 |
| 提示词新增历史记录 | 模板变量从 2 个变为 3 个:{history} + {context} + {question},让模型能结合上文上下文 |
| RunnableParallel | 并行取三路:检索上下文、历史记录、原问题 |
| 重排序后格式化 | 把 top-3 文档拼成 [来源1] xxx\n\n[来源2] xxx,让 LLM 明确区分每条参考来源 |
| 流式生成 | qa_chain.stream(question) 逐 chunk yield,供上层 SSE 逐 token 推送 |
💡 本段代码中的
retriever_func(向量 + BM25 + RRF 混合检索)是 Day9 的核心内容,今天先了解整体流程,Day9 会深入拆解BM25Util、RRFUtil的实现原理。
4. 保存会话结果 — ChatService + ChatDao
# ChatService.py 中的保存方法
def save_conversation_result(conversationResultEntity):
# 取出conversationResultEntity对象中的数据内容
question = conversationResultEntity.question
username = conversationResultEntity.username
parent_id = conversationResultEntity.parentId
answer = conversationResultEntity.answer
# 存储
history_id = ChatDao.save_conversation_result(question, username, parent_id, answer)
if history_id != 0:
return {
"code": 200,
"msg": "保存成功",
"data": history_id, # 返回新增记录的主键id,前端用来记录"当前会话id"
}
return {
"code": 500,
"msg": "保存失败",
"data": None,
}
# chat/dao/ChatDao.py
from common import MySQLUtil
"""
查询操作不需要做事务管理
增删改操作需要事务管理
操作成功 --- commit 提交事务:执行当前操作
操作失败 --- rollback 回滚事务:不执行当前操作
"""
def save_conversation_result(question, username, parent_id, answer):
conn = MySQLUtil.get_mysql_conn()
cur = conn.cursor()
try:
sql = "insert into `history` values(null, %s, %s, %s, %s, now())"
cur.execute(sql, [question, username, parent_id, answer])
conn.commit() # 提交事务
# 返回当前新增数据的主键自增ID -- history_id
return cur.lastrowid
except Exception as e:
print(f"新增会话结果失败:{e}")
conn.rollback() # 回滚事务
return 0
finally:
MySQLUtil.close_mysql_conn(cur, conn)
关键点:
| 概念 | 说明 |
|---|---|
| 事务管理 | 增删改操作必须显式 commit() 才真正生效;失败时 rollback() 撤销 |
lastrowid | 插入后获取自增主键 id,返回给前端作为"当前会话 id" |
now() | MySQL 函数,插入当前时间戳,自动记录会话创建时间 |
finally | 无论成功失败都关闭连接,防止连接泄漏 |
| history 表结构 | (history_id, question, username, parent_id, answer, create_time) |
MySQL 建表语句参考:
CREATE TABLE `history` (
`history_id` int NOT NULL AUTO_INCREMENT COMMENT '历史记录id',
`question` varchar(255) COMMENT '用户问题',
`username` varchar(255) COMMENT '用户名',
`parent_id` int COMMENT '所属会话id,新会话首条为0',
`answer` text COMMENT 'AI回答',
`create_time` datetime COMMENT '创建时间',
PRIMARY KEY (`history_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话历史记录表';
六、历史记录功能 — history 模块
对话页面左侧的历史记录菜单,由 history 模块提供数据。
1. HistoryController — 两个查询接口
# chat/controller/HistoryController.py
from fastapi import APIRouter
from chat.service import HistoryService
history_router = APIRouter()
@history_router.get(
path="/queryHistoryMenu",
summary="查询历史记录菜单栏",
)
def query_history_menu(username: str):
return HistoryService.query_history_menu(username)
@history_router.get(
path="/conversationLog",
summary="查询某一条详细对话记录",
)
def conversation_log(historyId: int):
return HistoryService.conversation_log(historyId)
2. HistoryService — 包装返回格式
# chat/service/HistoryService.py
from chat.dao import HistoryDao
# 查询历史记录菜单栏
def query_history_menu(username):
# 获取结果
results = HistoryDao.query_history_menu(username)
# 包装结果
data_list = []
for item in results:
data_list.append({
"historyId": item['history_id'],
"title": item['question'],
"time": item['create_time'].strftime("%Y-%m-%d %H:%M:%S"),
"active": "false"
})
return {
"code": 200,
"msg": "查询成功",
"data": data_list
}
# 查询某一条详细对话记录
def conversation_log(historyId):
results = HistoryDao.conversation_log(historyId)
data_list = []
for item in results:
data_list.append({
"role": "user",
"content": item['question'],
})
data_list.append({
"role": "assistant",
"content": item['answer'],
})
return {
"code": 200,
"msg": "查询成功",
"data": data_list
}
关键点:
- Service 层负责把 Dao 返回的数据库字段名(
history_id)转成前端需要的字段名(historyId),即"字段映射" query_history_menu只返回父会话(parent_id=0)作为菜单项,每个菜单项显示"问题标题 + 时间"conversation_log把一问一答转成{role: user/assistant, content: ...}列表,前端直接按 role 渲染气泡
3. HistoryDao — SQL 查询
# chat/dao/HistoryDao.py
from common import MySQLUtil
# 查询历史记录菜单栏
def query_history_menu(username):
conn = MySQLUtil.get_mysql_conn()
cur = conn.cursor()
sql = "SELECT history_id, question, create_time FROM history WHERE username=%s AND parent_id=0;"
cur.execute(sql, [username])
results = cur.fetchall()
MySQLUtil.close_mysql_conn(cur, conn)
return results
# 查询某一条详细对话记录
def conversation_log(historyId):
conn = MySQLUtil.get_mysql_conn()
cur = conn.cursor()
sql = "SELECT question, answer FROM history WHERE history_id=%s OR parent_id=%s ORDER BY history_id ASC;"
cur.execute(sql, [historyId, historyId])
results = cur.fetchall()
MySQLUtil.close_mysql_conn(cur, conn)
return results
关键点:
| SQL 语句 | 说明 |
|---|---|
WHERE username=%s AND parent_id=0 | 只查当前用户的父会话,作为左侧菜单项 |
WHERE history_id=%s OR parent_id=%s ORDER BY history_id ASC | 查父会话本身 OR 它的所有子会话,按时间正序拼接完整对话 |
父子会话数据模型:一次对话以
parent_id=0的记录开头(history_id 例如 5),后续每一轮追问都以parent_id=5存储。查询时用history_id=5 OR parent_id=5就能把整个对话链路按顺序取出来。这个"主表 + 外键自关联"的思想是聊天历史记录的经典设计。
七、前端 Element Plus 集成
1. main.js 注册组件库
// src/main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus' // 导入组件库
import 'element-plus/dist/index.css' // 导入组件库样式(必须!)
// 创建对象
const app = createApp(App)
// 注册路由对象
import router from './router'
app.use(router)
// 注册 Element Plus —— 全局注册所有组件,模板中可直接使用 el-xxx 标签
app.use(ElementPlus)
// axios 全局配置
import axios from 'axios'
axios.defaults.baseURL = 'http://localhost:8001/'
axios.defaults.headers.post['Content-Type'] = 'application/json'
axios.defaults.headers.put['Content-Type'] = 'application/json'
app.config.globalProperties.$axios = axios
app.mount('#app')
安装命令:
npm install element-plus @element-plus/icons-vue
| 依赖 | 作用 |
|---|---|
element-plus | Element Plus 组件库本体 |
@element-plus/icons-vue | 官方图标库(ChatDotRound 等) |
关键点:
- 这部分前端样式相关可以直接用AI介入即可
八、Chat.vue 对话页面【核心】
这是本次项目最核心、代码量最大的前端页面。左侧历史记录栏 + 右侧聊天区。
1. 页面布局结构
┌──────────────────────────────────────────────────────────┐
│ Chat.vue │
│ ┌───────────────────────┬──────────────────────────────┐ │
│ │ Sidebar 左侧历史栏 │ Main 主聊天区 │ │
│ │ ├─ 头部:logo + 新对话 │ ├─ Header:标题 + 清空按钮 │ │
│ │ ├─ 搜索框 │ ├─ Message-area 消息列表 │ │
│ │ ├─ 历史记录列表(循环) │ │ ├─ 空状态 + 快捷提示词 │ │
│ │ │ v-for item in historyList │ └─ 消息气泡(user/assistant)│ │
│ │ └─ 底部:用户信息头像 │ └─ Footer:输入框 + 发送按钮 │ │
│ └───────────────────────┴──────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
2. 核心逻辑 — SSE 流式聊天【重点】
// 定义响应式数据
let messages = ref([]); // 聊天消息列表 [{role, content}]
let question = ref(""); // 输入框内容
let isLoading = ref(false); // 是否正在聊天(禁用输入框)
let currentChatId = ref(0); // 当前会话 id(0 = 新对话)
function chat() {
isLoading.value = true; // 1. 标记正在聊天
let myQuestion = question.value.trim(); // 2. 取输入并去空格
question.value = ""; // 清空输入框
if (myQuestion.length === 0) { ElMessage.warning("请输入内容"); return; }
// 3. 先本地渲染:用户消息 + AI 占位
messages.value.push({role: 'user', content: myQuestion});
messages.value.push({role: 'assistant', content: 'AI正在努力的生成回复ing~~~'});
// 4. 构造 SSE 请求
let urlSearchParams = new URLSearchParams({
question: myQuestion,
historyId: currentChatId.value,
});
let es = new EventSource("http://localhost:8001/chat/chat?" + urlSearchParams.toString());
// 5. 拼接结果的累加器
let s = "";
// 6. 监听服务器推送
es.onmessage = (e) => {
let data = JSON.parse(e.data);
if (data.error) { // 后端异常 → 友好提示
messages.value[messages.value.length - 1].content = s || "回答生成失败,请稍后重试";
isLoading.value = false;
es.close();
return;
}
let content = data.content;
if (content === "[DONE]") { // 结束标记 → 保存会话并关闭连接
saveConversationResult(myQuestion, s);
isLoading.value = false;
es.close();
return;
}
s += content; // 累加 token
messages.value[messages.value.length - 1].content = s; // 覆盖占位符,实时更新
};
// 7. 监听错误
es.onerror = (e) => {
console.log("SSE发生错误:", e)
es.close();
isLoading.value = false;
ElMessage.error("连接中断,回答可能不完整,请重试");
};
}
SSE 工作原理逐步拆解:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 1 | new EventSource(url) | 创建 SSE 连接,GET 请求,参数拼在 URL 上 |
| 2 | es.onmessage | 服务端每推送一条 data: 就触发一次 |
| 3 | JSON.parse(e.data) | 解析服务端发来的 {"content": "字"} JSON |
| 4 | s += content | 把 token 累加到字符串 |
| 5 | 覆盖最后一条消息的 content | messages[...].content = s,触发 Vue 响应式更新 |
| 6 | content === "[DONE]" | 判断流结束,保存会话、关闭连接 |
| 7 | es.onerror | 网络中断时关闭连接,复位 isLoading |
💡 EventSource vs 普通 Axios 的区别:Axios 一次请求一次性返回完整响应;
EventSource是持续连接,服务端边生成边推送,前端onmessage不断触发,实现逐字"打字机"效果。这是 SSE 与普通 HTTP 最大的区别——连接保持打开,数据分多次到达。
3. 历史记录相关函数
// 查询历史记录菜单栏
function query_history_menu() {
proxy.$axios({
url: 'history/queryHistoryMenu',
method: 'get',
params: { username: username.value }
}).then(res => {
historyList.value = res.data.data;
});
}
// 点击某条历史 → 加载完整对话
function conversationLog(historyId) {
currentChatId.value = historyId; // 记录当前会话 id(后续追问属于这个会话)
proxy.$axios({
url: 'history/conversationLog',
method: 'get',
params: { historyId: historyId }
}).then(res => {
messages.value = res.data.data; // 直接覆盖消息列表,渲染历史气泡
});
}
// 保存对话结果
function saveConversationResult(question, answer) {
proxy.$axios({
url: 'chat/saveConversationResult',
method: 'post',
data: JSON.stringify({
question: question,
username: username.value,
parentId: currentChatId.value, // 关键:新会话传0,追问传当前会话id
answer: answer
})
}).then(res => {
// 只有当currentChatId=0时,才把新增记录的historyId赋给它【表示一个新对话的开始】
if (currentChatId.value === 0) {
currentChatId.value = res.data.data;
}
query_history_menu(); // 刷新历史菜单
});
}
// 新对话:重置会话id并清空消息
function newChat() {
currentChatId.value = 0;
messages.value = [];
}
currentChatId 的状态流转:
初始状态:currentChatId = 0
│
▼ 用户发第一条消息并完成回答
saveConversationResult 执行成功
│ parentId = 0 → 服务端生成新的 history_id(如 8)
│ 前端把 currentChatId 更新为 8
▼
后续追问:currentChatId = 8
│ parentId = 8 → 追加为 history_id=8 的子会话
▼
点击历史记录"第8条":currentChatId = 8 → 加载该会话全部记录
│
▼
点击"新对话":currentChatId = 0 → 清空消息,开启新一轮
4. onMounted 初始化
onMounted(() => {
// 用 || "" 兜底:未登录直接访问 /chat 时 sessionStorage 中没有 username,
// getItem 会返回 null,直接赋 null 会在模板里调用 username.charAt(0) 时崩溃
username.value = sessionStorage.getItem("username") || "";
// 获取历史记录菜单栏
query_history_menu();
})
关键点:
onMounted是 Vue 3 的生命周期钩子,组件挂载完成后自动执行sessionStorage.getItem("username")读取登录时存下的用户名,|| ""兜底防止 null 崩溃
5. 模板中的消息渲染
用AI描述你想要的AIChat界面即可
九、登录流程
<script setup>
import {ref, getCurrentInstance} from "vue";
import {useRouter} from "vue-router";
import {ElMessage} from "element-plus";
import {Message, Key} from "@element-plus/icons-vue";
let router = useRouter(); // 编程式路由跳转
function sendEmail() {
proxy.$axios({
url: 'users/sendEmail',
method: 'get',
params: { email: email.value },
}).then(res => {
if (res.data.code === 200) {
isCode.value = !isCode.value;
// 后端返回 data = 用户名,存入 sessionStorage,作为聊天页展示
sessionStorage.setItem("username", res.data.data);
ElMessage.success("成功发送验证码");
} else {
ElMessage.error(res.data.msg);
}
});
}
function checkCode() {
proxy.$axios({
url: 'users/checkCode',
method: 'post',
data: JSON.stringify({ email: email.value, code: code.value })
}).then(res => {
if (res.data.code === 200) {
ElMessage.success("登录成功");
// 延迟1秒跳转到 /chat 对话页
setTimeout(() => {
router.push("/chat");
}, 1000);
} else {
ElMessage.error(res.data.msg);
}
});
}
</script>
关键点:
| 相关内容 | 说明 |
|---|---|
| Element Plus 组件 | <el-card>、<el-form>、<el-input>、<el-button> 替代原生标签 |
| ElMessage 提示 | 替代 alert(),更美观的顶部弹出提示 |
useRouter | 编程式导航,登录成功 router.push("/chat") 跳转对话页 |
sessionStorage | 存用户名,跨页面共享;Chat.vue 用 getItem 读取 |
| 图标组件 | <el-icon><Message/></el-icon> 前缀图标美化输入框 |
路由配置:
/chat路由已在router/index.js中注册,访问http://localhost:8080/chat进入对话页。
十、完整数据流(登录 → 聊天 → 保存 → 回看)
【登录阶段】
用户输入邮箱 → sendEmail → 后端发验证码 + 返回 username
→ sessionStorage 存 username → 输入验证码 → checkCode → 登录成功
→ router.push("/chat")
│
▼
【聊天阶段】Chat.vue onMounted
├── username = sessionStorage.get("username")
├── query_history_menu() → GET history/queryHistoryMenu → 渲染左侧菜单
└── 用户输入问题 → chat()
│
▼
EventSource → GET chat/chat?question=xxx&historyId=0
│
▼
【后端】ChatService.chat
├── 意图识别 → is_legal
├── RAG 检索:向量 + BM25 + RRF → 重排序 top-3(Day9 详讲)
├── 提示词拼接 → qa_chain.stream
└── 逐 token yield → SSE data: {"content":"..."} 推送
│
▼
【前端】onmessage 逐字累加 → 打字机效果
│
▼ 收到 [DONE]
saveConversationResult → POST chat/saveConversationResult
→ MySQL history 表新增记录 → 返回 history_id
→ 若为新会话,currentChatId = history_id
→ query_history_menu() 刷新菜单
│
▼
【回看阶段】点击历史记录
conversationLog → GET history/conversationLog?historyId=x
→ 按 role 渲染完整对话气泡
十一、总体实践
- 实现意图识别:用 Ollama 启动本地小模型,实现
IntentionUtil,测试"法律问题 vs 闲聊"能正确分流 - 实现聊天接口:
chat/chatSSE 流式接口 +chat/saveConversationResult保存接口 - 实现历史记录:
history/queryHistoryMenu+history/conversationLog,理解父子会话(parent_id)设计 - 实现 Chat.vue:左右布局 + SSE 流式接收 + 历史记录加载 + 保存会话 + 新对话
- 登录跳转:登录成功存入 sessionStorage,跳转
/chat
🔧 准备工作检查清单
- Element Plus 安装:
npm install element-plus @element-plus/icons-vue -
ai包四个工具类已封装,.env中模型路径配置正确 - 法律知识库已构建(Chroma
law_chroma目录有数据) - Ollama 已启动,
qwen3:1.7b模型已下载(常见报错:httpx.ConnectError: [WinError 10061] 由于目标计算机积极拒绝,无法连接。 ) - MySQL
history表已创建 - 前后端同时启动,8080(跨域写好的前端)/ 8000(自己的后端)
⚠️ 常见问题
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 前端无样式 | 未导入 Element Plus 的 CSS | 在 main.js 导入 element-plus/dist/index.css |
es.onerror 频繁触发 | 后端 SSE 生成器抛异常未捕获 | 检查 Controller 是否 try/except 包裹 |
| 历史记录空白 | username 未传入(未登录) | 确认 sessionStorage 有 username 值 |
| 保存会话失败 | history 表不存在或字段名不对 | 核对建表 SQL 与 Dao 的 SQL |
| 检索不到数据 | 知识库未构建或集合名不一致 | 检查 COLLECTION_NAME / CHROMA_PATH 是否一致 |
| BGE 检索效果差 | 未使用 BGEQueryEmbeddings(缺检索指令) | 使用封装后的 LoadEmbeddingModel |
核心要点
- ai 模块统一封装:LLM / 向量化 / 重排序 / Chroma 四类模型加载集中到
ai包,配置走.env,一行代码调用 - BGE 检索指令:query 端加"为这个句子生成表示以用于检索相关文章"前缀,doc 端不加,否则效果严重退化
- 知识库重建铁律:
Chroma.from_documents只追加不覆盖,重建前必须先delete_collection() - 意图识别路由:本地小模型(Ollama)判断是否走 RAG,非知识库内容的聊天直接 LLM 回复,节省成本
- SSE 异常捕获:生成器 try/except +
[DONE]结束标记,避免流中断导致浏览器报错 - 父子会话设计:
parent_id=0开新会话,追问parent_id=history_id,查询用history_id=xxx OR parent_id=xxx - 事务管理:增删改用
commit()/rollback(),插入后lastrowid拿自增主键 - Element Plus:
app.use(ElementPlus)全局注册 + 导入 CSS,组件即插即用 - SSE 前端:
new EventSource(url)→onmessage逐字累加 →[DONE]后关闭连接、保存会话
最终思考:后端慢慢有了 AI 模型统一封装、知识库构建、意图识别、会话保存、历史记录;前端也搭建了 Element Plus 美化、SSE 流式对话、历史菜单交互。登录 → 聊天 → 保存 → 回看,全链路已经跑通。后续内容将进一步优化检索效果:向量检索虽然能理解语义,但对"关键词/名词"关注度不高,需要引入 BM25 关键词检索,并用 RRF 算法把两种检索结果融合,实现更精准的混合检索。
更多推荐
所有评论(0)