概要
前些日子,我在博客上写了关于db_gpt中有关excel对话的内容,那是我的初次梳理,感觉还是有点生硬,在随着我的不断跟进,又有了新的体会和感受,在看之前梳理的博客,感觉对于研究源码来说还是不太友好,所以我又再次梳理了一次有关这个部分的内容,希望这次可以帮到大家。
当然如果有需要看与数据库对话的宝子们可以看我之前写的博客chat_with_db
因为我个人觉得这个基于excel对话的功能还是挺不错得,对于模型的要求也不算高,这里我用的是Qwen2.5的32B,特别有应用价值,所以想在这里分享给大家。
功能介绍
dbgpt_excel
针对于chat_excel部分,可以分为两块内容
在看了上面的视频之后,我们可以发现对于excel对话内容其实是分为两块内容:
- excel文件上传
- 基于上传的文件进行问答
excel文件上传
@router.post("/v1/chat/mode/params/file/load")
async def params_load(
conv_uid: str, # 会话唯一标识
chat_mode: str, # 聊天模式
model_name: str, # 模型名称
user_name: Optional[str] = None, # 用户名称(可选)
sys_code: Optional[str] = None, # 系统代码(可选)
doc_file: UploadFile = File(...), # 上传的文件
):
print(f"params_load: {conv_uid},{chat_mode},{model_name}")
try:
if doc_file:
# 保存上传的文件
upload_dir = os.path.join(KNOWLEDGE_UPLOAD_ROOT_PATH, chat_mode) # 构建上传目录路径
os.makedirs(upload_dir, exist_ok=True) # 创建目录(如果不存在)
upload_path = os.path.join(upload_dir, doc_file.filename) # 构建文件上传路径
print('文件上传地址' + upload_path)
async with aiofiles.open(upload_path, "wb") as f: # 以二进制写入模式打开文件
await f.write(await doc_file.read()) # 异步写入上传的文件内容
# 准备聊天会话
dialogue = ConversationVo(
conv_uid=conv_uid, # 会话唯一标识
chat_mode=chat_mode, # 聊天模式
select_param=doc_file.filename, # 选择的文件名
model_name=model_name, # 模型名称
user_name=user_name, # 用户名称
sys_code=sys_code, # 系统代码
)
# 创建聊天实例并准备聊天
chat: BaseChat = await get_chat_instance(dialogue)
resp = await chat.prepare() # 准备聊天内容
print('-------------------------')
rres = Result.succ(get_hist_messages(conv_uid)) # 获取会话历史消息
print(rres)
# 刷新消息
return rres
except Exception as e:
logger.error("excel load error!", e) # 记录错误日志
return Result.failed(code="E000X", msg=f"File Load Error {str(e)}") # 返回失败信息
# 有excel_reader参数,读取了excel的内容
chat: BaseChat = await get_chat_instance(dialogue)
对于这块内容我们需要alt+左键点击进去再看get_chat_instance方法
async def get_chat_instance(dialogue: ConversationVo = Body()) -> BaseChat:
logger.info(f"get_chat_instance:{dialogue}") # 记录获取聊天实例的日志
if not dialogue.chat_mode:
dialogue.chat_mode = ChatScene.ChatNormal.value() # 如果没有聊天模式,默认设置为正常模式
if not dialogue.conv_uid:
# 如果没有会话唯一标识,创建一个新的会话
conv_vo = __new_conversation(
dialogue.chat_mode, dialogue.user_name, dialogue.sys_code
)
dialogue.conv_uid = conv_vo.conv_uid # 将新会话的唯一标识赋值给对话对象
# 验证聊天模式是否有效
if not ChatScene.is_valid_mode(dialogue.chat_mode):
raise StopAsyncIteration(f"Unsupported Chat Mode,{dialogue.chat_mode}!") # 如果无效,抛出异常
# 准备聊天参数
chat_param = {
"chat_session_id": dialogue.conv_uid, # 会话唯一标识
"user_name": dialogue.user_name, # 用户名称
"sys_code": dialogue.sys_code, # 系统代码
"current_user_input": dialogue.user_input, # 当前用户输入
"select_param": dialogue.select_param, # 选择的参数
"model_name": dialogue.model_name, # 模型名称
}
# 异步调用阻塞函数以获取聊天实现
chat: BaseChat = await blocking_func_to_async(
get_executor(), # 获取执行器
CHAT_FACTORY.get_implementation, # 获取聊天实现的方法
dialogue.chat_mode, # 聊天模式
**{"chat_param": chat_param}, # 聊天参数
)
return chat # 返回聊天实例
其中CHAT_FACTORY.get_implementation, 是获取chat实例的方法
class ChatFactory(metaclass=Singleton):
@staticmethod
def get_implementation(chat_mode, **kwargs):
# 懒加载(Lazy loading)
from dbgpt.app.scene.chat_dashboard.chat import ChatDashboard # 导入聊天仪表盘类
from dbgpt.app.scene.chat_dashboard.prompt import prompt # 导入提示类
from dbgpt.app.scene.chat_data.chat_excel.excel_analyze.chat import ChatExcel # 导入Excel聊天类
...
# 获取所有的聊天类(从BaseChat继承的类)
chat_classes = BaseChat.__subclasses__()
implementation = None # 初始化实现变量
for cls in chat_classes:
# 检查当前类的聊天场景是否与请求的聊天模式匹配
if cls.chat_scene == chat_mode:
metadata = {"cls": str(cls)} # 准备元数据以供追踪
with root_tracer.start_span(
"get_implementation_of_chat", metadata=metadata # 开始追踪
):
implementation = cls(**kwargs) # 创建匹配类的实例
if implementation is None:
# 如果没有找到合适的实现,抛出异常
raise Exception(f"Invalid implementation name:{chat_mode}")
return implementation # 返回找到的聊天实现
在implementation = cls(**kwargs)代码中,当根据chat_mode拿到对应的聊天的对象时候,自动会执行该类下的init方法,这里是拿到了ChatExcel类
class ChatExcel(BaseChat):
"""一个用于分析Excel数据的类"""
chat_scene: str = ChatScene.ChatExcel.value() # 聊天场景类型
keep_start_rounds = 1 # 保留的起始轮次
keep_end_rounds = 2 # 保留的结束轮次
def __init__(self, chat_param: Dict):
"""初始化Chat Excel模块
Args:
- chat_param: Dict
- chat_session_id: (str) 聊天会话ID
- current_user_input: (str) 当前用户输入
- model_name: (str) LLM模型名称
- select_param: (str) 文件路径
"""
chat_mode = ChatScene.ChatExcel # 设置聊天模式为Excel模式
# 从chat_param中获取必要的参数
self.select_param = chat_param["select_param"] # 选择的参数(文件路径)
self.model_name = chat_param["model_name"] # 模型名称
chat_param["chat_mode"] = ChatScene.ChatExcel # 更新chat_param中的聊天模式
# 检查选择的文件路径是否有效
if has_path(self.select_param):
# 如果路径有效,使用该路径创建Excel读取器
self.excel_reader = ExcelReader(self.select_param)
else:
# 如果路径无效,构建完整路径并创建Excel读取器
self.excel_reader = ExcelReader(
os.path.join(
KNOWLEDGE_UPLOAD_ROOT_PATH, chat_mode.value(), self.select_param
)
)
self.api_call = ApiCall() # 初始化API调用对象
super().__init__(chat_param=chat_param) # 调用父类构造函数
在这里同样的ExcelReader(self.select_param)在创建ExcelReader对象的时候,会自动执行他的init方法,来读取excel的内容并存入chat的excel_reader属性中
class ExcelReader:
def __init__(self, file_path):
# 获取文件名
file_name = os.path.basename(file_path) # 从文件路径中提取文件名
self.file_name_without_extension = os.path.splitext(file_name)[0] # 获取不带扩展名的文件名
encoding, confidence = detect_encoding(file_path) # 检测文件编码
logger.info(f"Detected Encoding: {encoding} (Confidence: {confidence})") # 记录编码信息
self.excel_file_name = file_name # 保存完整文件名
self.extension = os.path.splitext(file_name)[1] # 获取文件扩展名
# 读取Excel文件
if file_path.endswith(".xlsx") or file_path.endswith(".xls"):
# 如果是Excel文件,读取数据
df_tmp = pd.read_excel(file_path, index_col=False) # 先读取一次以获取列数
self.df = pd.read_excel(
file_path,
index_col=False,
converters={i: csv_colunm_foramt for i in range(df_tmp.shape[1])}, # 设置转换器
)
elif file_path.endswith(".csv"):
# 如果是CSV文件,读取数据
df_tmp = pd.read_csv(file_path, index_col=False, encoding=encoding) # 先读取一次以获取列数
self.df = pd.read_csv(
file_path,
index_col=False,
encoding=encoding,
# csv_colunm_foramt 可以修改更多,只是针对美元人民币符号,假如是“你好¥¥¥”则会报错!
converters={i: csv_colunm_foramt for i in range(df_tmp.shape[1])}, # 设置转换器
)
else:
raise ValueError("Unsupported file format.") # 抛出不支持的文件格式异常
# 将空字符串替换为NaN
self.df.replace("", np.nan, inplace=True)
# 删除所有内容为空的“Unnamed”列
unnamed_columns_tmp = [
col
for col in df_tmp.columns
if col.startswith("Unnamed") and df_tmp[col].isnull().all()
]
df_tmp.drop(columns=unnamed_columns_tmp, inplace=True) # 删除这些列
# 仅保留验证后的列
self.df = self.df[df_tmp.columns.values]
self.columns_map = {} # 初始化列映射字典
for column_name in df_tmp.columns:
self.df[column_name] = self.df[column_name].astype(str) # 将列转换为字符串类型
self.columns_map.update({column_name: excel_colunm_format(column_name)}) # 更新列映射
try:
# 尝试将列转换为日期格式
self.df[column_name] = pd.to_datetime(self.df[column_name]).dt.strftime(
"%Y-%m-%d"
)
except ValueError:
try:
# 尝试将列转换为数值类型
self.df[column_name] = pd.to_numeric(self.df[column_name])
except ValueError:
try:
# 如果失败,将列保持为字符串类型
self.df[column_name] = self.df[column_name].astype(str)
except Exception:
print("Can't transform column: " + column_name) # 打印无法转换的列名
# 清理列名,去除空格并替换为下划线
self.df = self.df.rename(columns=lambda x: x.strip().replace(" ", "_"))
# 连接DuckDB
self.db = duckdb.connect(database=":memory:", read_only=False) # 创建内存中的DuckDB连接
self.table_name = "excel_data" # 设置表名
# 在DuckDB中写入数据
self.db.register(self.table_name, self.df)
# 获取结果并打印表结构信息
result = self.db.execute(f"DESCRIBE {self.table_name}") # 描述表结构
columns = result.fetchall() # 获取所有列信息
for column in columns:
print(column) # 打印每列的信息
此时在我们的聊天对象实例中就有了excel的内容了,并且根据chat_model=chat_excel也确实是在聊天实例chat中添加了模板,过程如下
在class ChatFactory(metaclass=Singleton):中根据聊天场景匹配聊天实例的时候implementation = cls(**kwargs) # 创建匹配类的实例,这里是创建的ChatExcel对象,在其init初始化方法中调用了父类的init方法super().init(chat_param=chat_param)
在其父类中的初始化方法中有这么一段代码,根据聊天场景选择提示词模板
self.prompt_template: AppScenePromptTemplateAdapter = (
CFG.prompt_template_registry.get_prompt_template(
self.chat_mode.value(),
language=CFG.LANGUAGE,
model_name=self.llm_model,
proxyllm_backend=CFG.PROXYLLM_BACKEND,
)
)
之后再执行这段代码的时候
resp = await chat.prepare(),debug进入的是ChatExcel对象的prepare方法
async def prepare(self):
logger.info(f"{self.chat_mode} prepare start!") # 记录准备开始的日志
if self.has_history_messages(): # 检查是否有历史消息
return None # 如果有历史消息,直接返回None
# 准备聊天参数
chat_param = {
"chat_session_id": self.chat_session_id, # 当前聊天会话ID
"user_input": "[" + self.excel_reader.excel_file_name + "]" + " Analyze!", # 用户输入,包含Excel文件名
"parent_mode": self.chat_mode, # 当前聊天模式
"select_param": self.excel_reader.excel_file_name, # 选择的参数(Excel文件名)
"excel_reader": self.excel_reader, # Excel阅读器实例
"model_name": self.model_name, # 模型名称
}
# 创建Excel学习实例
learn_chat = ExcelLearning(**chat_param)
result = await learn_chat.nostream_call() # 异步调用学习方法
return result # 返回结果
learn_chat = ExcelLearning(**chat_param) 在创建ExcelLearning对象时候
class ExcelLearning(BaseChat):
chat_scene: str = ChatScene.ExcelLearning.value()
def __init__(
self,
chat_session_id,
user_input,
parent_mode: Any = None,
select_param: str = None,
excel_reader: Any = None,
model_name: str = None,
):
chat_mode = ChatScene.ExcelLearning
""" """
self.excel_file_path = select_param
self.excel_reader = excel_reader
chat_param = {
"chat_mode": chat_mode,
"chat_session_id": chat_session_id,
"current_user_input": user_input,
"select_param": select_param,
"model_name": model_name,
}
super().__init__(chat_param=chat_param)
if parent_mode:
self.current_message.chat_mode = parent_mode.value()
可以看到有如下的两行代码
chat_mode = ChatScene.ExcelLearning,
super().init(chat_param=chat_param)
重置了chat_model,调用了父类的构造方法,此时加载的提示词模板是
_DEFAULT_TEMPLATE_ZH = """
下面是用户文件{file_name}的一部分数据,请学习理解该数据的结构和内容,按要求输出解析结果:
{data_example}
分析各列数据的含义和作用,并对专业术语进行简单明了的解释, 如果是时间类型请给出时间格式类似:yyyy-MM-dd HH:MM:ss.
将列名作为属性名,分析解释作为属性值,组成json数组,并输出在返回json内容的ColumnAnalysis属性中.
请不要修改或者翻译列名,确保和给出数据列名一致.
针对数据从不同维度提供一些有用的分析思路给用户。
请一步一步思考,确保只以JSON格式回答,具体格式如下:
{response}
"""
result = await learn_chat.nostream_call()
然后才调用的模型,所以最后用到的提示词是上面的这个,之前的excel_analyze中的提示词模板其实是没有用到的。
async def nostream_call(self):
# 构建模型请求的有效负载
payload = await self._build_model_request()
# 开始追踪
span = root_tracer.start_span(
"BaseChat.nostream_call", metadata=payload.to_dict() # 记录请求的元数据
)
logger.info(f"Request: \n{payload}") # 记录请求的有效负载信息
payload.span_id = span.span_id # 将span_id添加到有效负载中
try:
# 进行无流式调用,并重试
ai_response_text, view_message = await self._no_streaming_call_with_retry(payload)
# 添加AI返回的消息和视图消息到当前消息中
self.current_message.add_ai_message(ai_response_text)
self.current_message.add_view_message(view_message)
# 调整消息
self.message_adjust()
span.end() # 结束追踪
except BaseAppException as e:
# 如果捕获到应用程序异常,记录视图消息
self.current_message.add_view_message(e.view)
span.end(metadata={"error": str(e)}) # 结束追踪并记录错误信息
except Exception as e:
# 捕获其他异常,创建错误视图消息
view_message = f"<span style='color:red'>ERROR!</span> {str(e)}"
self.current_message.add_view_message(view_message)
span.end(metadata={"error": str(e)}) # 结束追踪并记录错误信息
# 存储当前会话
await blocking_func_to_async(
self._executor, self.current_message.end_current_round
)
# 获取当前AI响应
rest = self.current_ai_response()
return rest # 返回响应
async def _no_streaming_call_with_retry(self, payload):
with root_tracer.start_span("BaseChat.invoke_worker_manager.generate"):
model_output = await self.call_llm_operator(payload) # 调用 LLM 操作符,发送 payload 并接收模型输出
ai_response_text = self.prompt_template.output_parser.parse_model_nostream_resp(
model_output, self.prompt_template.sep # 解析模型输出,生成 AI 响应文本
)
prompt_define_response = (
self.prompt_template.output_parser.parse_prompt_response(ai_response_text) # 解析生成的 AI 响应文本
)
metadata = {
"model_output": model_output.to_dict(), # 将模型输出转为字典形式保存
"ai_response_text": ai_response_text, # 保存 AI 响应文本
"prompt_define_response": self._parse_prompt_define_response(
prompt_define_response # 解析提示定义的响应内容
),
}
print('base_chat.py--->' + metadata) # 打印调试信息
with root_tracer.start_span("BaseChat.do_action", metadata=metadata):
result = await blocking_func_to_async(
self._executor, self.do_action, prompt_define_response # 执行指定的操作
)
speak_to_user = self.get_llm_speak(prompt_define_response) # 获取用于与用户对话的文本
view_message = await blocking_func_to_async(
self._executor,
self.prompt_template.output_parser.parse_view_response,
speak_to_user,
result,
prompt_define_response,
)
return ai_response_text, view_message.replace("\n", "\\n") # 返回 AI 响应和格式化的视图消息
chat with excel
直接进对话框
@router.post("/v1/chat/completions")
async def chat_completions(
dialogue: ConversationVo = Body(),
flow_service: FlowService = Depends(get_chat_flow),
):
#这里是选择的进行流式输出
return StreamingResponse(
stream_generator(chat, dialogue.incremental, dialogue.model_name),
headers=headers,
media_type="text/plain",
)
async def stream_generator(chat, incremental: bool, model_name: str):
"""
生成流式响应。
目标是生成兼容 OpenAI 的流式响应。
当前增量响应是兼容的,未来将对完整响应进行转换。
参数:
chat (BaseChat): 聊天实例。
incremental (bool): 用于控制内容是增量返回还是每次返回完整响应。
model_name (str): 模型名称。
产出:
流式响应。
"""
span = root_tracer.start_span("stream_generator") # 开启追踪 span,记录执行时间等元数据
msg = "[LLM_ERROR]: llm server has no output, maybe your prompt template is wrong." # 错误信息的初始值
previous_response = "" # 记录上一次的响应内容,用于计算增量输出
async for chunk in chat.stream_call(): # 异步调用 chat 的 stream_call 方法,逐块获取模型响应
if chunk: # 如果当前块有内容
msg = chunk.replace("\ufffd", "") # 移除可能的无效字符 (如 `\ufffd`)
if incremental: # 如果使用增量模式
incremental_output = msg[len(previous_response) :] # 计算本次增量内容
choice_data = ChatCompletionResponseStreamChoice(
index=0,
delta=DeltaMessage(role="assistant", content=incremental_output), # 将增量内容设置为响应体
)
chunk = ChatCompletionStreamResponse(
id=chat.chat_session_id, choices=[choice_data], model=model_name # 创建兼容 OpenAI 的流式响应
)
json_chunk = model_to_json(
chunk, exclude_unset=True, ensure_ascii=False # 将响应序列化为 JSON 格式
)
yield f"data: {json_chunk}\n\n" # 将增量响应作为流式输出的一部分返回
else:
# TODO: 生成一个兼容 OpenAI 的完整流式响应
msg = msg.replace("\n", "\\n") # 处理换行符以确保格式兼容
yield f"data:{msg}\n\n" # 返回完整响应数据
previous_response = msg # 更新上一次的响应内容为当前响应
await asyncio.sleep(0.02) # 添加短暂延迟,模拟实时流式输出
if incremental: # 如果是增量模式,最后返回 `[DONE]` 表示流结束
yield "data: [DONE]\n\n"
span.end() # 结束追踪 span
"""
功能:
stream_generator 方法生成流式响应,适用于兼容 OpenAI 的增量返回模式。在增量模式下,每次仅返回与上次不同的新增内容;否则将返回整个响应。它通过异步生成器逐步返回数据,模拟实时响应的效果。
重要部分:
增量模式计算新内容并返回。
async for 循环用于逐块读取模型响应。
使用短延迟来模拟连续的流式数据传输。
"""
# 这个方法是 BaseChat 类中的方法,它负责向 LLM(大语言模型)发送请求并处理返回的流式响应。
async def stream_call(self):
# TODO: 在服务器连接错误时重试
payload = await self._build_model_request() # 构建模型请求的 payload
logger.info(f"payload request: \n{payload}") # 记录请求日志信息
ai_response_text = "" # 初始化 AI 响应文本为空
span = root_tracer.start_span(
"BaseChat.stream_call", metadata=payload.to_dict() # 开启 span,追踪请求的元数据
)
payload.span_id = span.span_id # 将 span ID 赋值给请求的 payload
try:
async for output in self.call_streaming_operator(payload): # 异步调用流式操作符,逐块接收模型输出
# 结果生成时调用插件(如某种处理逻辑)
msg = self.prompt_template.output_parser.parse_model_stream_resp_ex(
output, 0 # 解析模型返回的流式响应
)
# 给出答案
view_msg = self.stream_plugin_call(msg) # 调用插件,处理响应内容
view_msg = view_msg.replace("\n", "\\n") # 将换行符替换为 "\\n" 以符合输出格式
yield view_msg # 逐块返回处理过的视图消息
self.current_message.add_ai_message(msg) # 将模型生成的响应文本添加到当前消息对象
view_msg = self.stream_call_reinforce_fn(view_msg) # 执行进一步的流式响应强化
self.current_message.add_view_message(view_msg) # 将最终生成的视图消息添加到当前消息对象
span.end() # 结束 span 追踪
except Exception as e: # 捕获异常
print(traceback.format_exc()) # 打印异常堆栈信息
logger.error("model response parse failed!" + str(e)) # 记录错误日志
self.current_message.add_view_message(
f"""<span style=\"color:red\">ERROR!</span>{str(e)}\n {ai_response_text} """ # 展示错误信息
)
### 存储当前对话
span.end(metadata={"error": str(e)}) # 在 span 中记录错误元数据
await blocking_func_to_async(
self._executor, self.current_message.end_current_round # 异步执行,结束当前消息轮次
)
"""
功能:stream_call 方法负责向 LLM 发送请求并异步接收流式响应。
响应会通过插件进行处理,并以符合格式的视图消息形式返回。
该方法还包含异常处理逻辑以应对可能的解析或网络错误。
重要部分:
call_streaming_operator 方法用于异步接收流式数据。
插件处理 stream_plugin_call 和强化 stream_call_reinforce_fn。
详细的异常处理,捕获并展示错误信息。
使用追踪 span 监控性能。
"""
# 这个方法负责真正与模型的流式接口进行通信,发送请求并接收流式输出。
async def call_streaming_operator(self, request: ModelRequest) -> AsyncIterator[ModelOutput]:
llm_task = build_cached_chat_operator(self.llm_client, True, CFG.SYSTEM_APP) # 创建缓存的聊天操作符
async for out in await llm_task.call_stream(call_data=request): # 异步调用流式接口,发送请求并逐块接收输出
yield out # 逐块返回模型输出
"""
功能:call_streaming_operator 方法负责调用底层的语言模型接口,通过流式方式返回响应。它利用了异步生成器,将每一块数据逐步返回给调用者。
重要部分:
build_cached_chat_operator 创建了一个缓存的聊天操作符,用于减少重复请求的开销。
使用 async for 异步迭代器来逐步接收 LLM 的流式输出。
每次接收到模型输出时,使用 yield 返回给调用方,保证了响应的流式特性。
"""