上一期我们用 LangChain 的 SQL Agent 实现了可视化分析,主要依托于 LangChain 和 OpenAI,更偏向于 Demo 能力,问题还不少,连原型都算不上。今天带大家实现一个 LLM 时代的 BI 原型,包含数据表问答,自动装载可视化样式,可视化样式切换。
首先看下最终效果:
视频展示:
架构
今天实现功能的逻辑架构是这样的:
因为 LLM 擅长的是文本理解和生成,所以我们只把他擅长的部分交给它,SQL 执行、复杂 UI 结构和展示我们还是交给更可控的程序来。
后端解析
同样我们定义一个 DatabaseFetchTools,根据前端的工具名称动态加载。
工具集注册:
tools = {
'colors': ColorsTool(),
'random-color': RandomColorTool(),
'balabala': Balabala(),
'mail': [MailRefreshTool(), MailCheckUser()],
'gitlab': [GitBlockUserTool()],
'database': [DatabaseFetchTool()]
}
定义工具类
未来可能有很多数据库相关的工具,我们先定义一个基础类。
class BaseDatabaseTool(BaseTool, ABC):
pass
然后我们定义这次的 DatabaseFetchTool 类,在说明里面描述我们工具的用途,以便选择权能够选择合适的工具。
class DatabaseFetchTool(BaseDatabaseTool, ABC):
name: str = "database-fetch"
description: str = (
"使用这个工具来查询和分析数据。"
" 参数是需要执行查询分析的自然语言描述。"
" 返回查询和分析结果。"
)
return_direct = True
提前定义好数据库连接,这次使用 postgres:
conn = psycopg2.connect(
database="postgres",
host="127.0.0.1",
user="postgres",
password="yourpassword",
port=5432
)
SQL 生成
这次的重点就是,使用我们的逻辑来实现 SQL 生成和查询,而不是让 LangChain 去做。
为了让 SQL 生成更加准确,我们要把元数据告诉 AI,下面是完整的 Prompt 结构:
template = '''
Postgres 数据库的开发任务表(`tasks`)的字段如下 CSV 所示:
Name,Type,Comment
id,int4,
title,varchar,任务名称标题
scroe,int4,任务评分
department,varchar,部门
auther,varchar, 任务指派人
date,date, 完成时间
dev_spend,int2,开发耗时小时
test_spend,int2, 测试耗时
spend,int2, 总耗时
根据上面表信息生成查询 SQL, 以及SQL 返回的列名称,使用中文回答。
回答的格式请严格遵循下面格式:
"""
{
"columns": ["字段中文名", ...],
"sql": " select ..."
}
"""
问题为: %s
'''
当然你也可以使用 sql 客户端读取所有表的机构,生成表结构的描述,动态拼接:
tables_desc = [
('table1', 'name1', '''Name,Type,Comment... '''),
('table2', 'name2', '''Name,Type,Comment... '''),
('table3', 'name3', '''Name,Type,Comment... '''),
('...', '...', '''Name,Type,Comment... '''),
]
subtemplate = '''Postgres 数据库的%s表(`%s`)的字段如下 CSV 所示:\n\n%s\n'''
subprompt = ''.join([subtemplate % (name, table, fields) for (table, name, fields) in tables_desc])
template = '''%s\n
根据上面表信息生成查询 SQL 以及返回的列名称,使用中文回答。
回答的格式请严格遵循下面格式:"""
{
"columns": ["字段中文名", ...],
"sql": " select ..."
}
"""
问题为: %s
如果上下文太长超出限制的话,可以让 AI 根据你的问题来选择相关的表结构以满足 token 限制。
生成和执行 SQL
有了正觉的提示,我们实现工具的执行方法就很简单了,先获取 sql,再执行,然后返回,如下:
def _run(self, question) -> dict:
answer = openai.predict(self.template % (self.subprompt, question))
j = json.loads(answer)
cursor = conn.cursor()
cursor.execute(j['sql'])
return {
'type': 'echarts',
'fields': j['columns'],
'values': cursor.fetchall()
}
细心的同学,发现上面我不仅反悔了 values,还返回了数据列名。这个数据列名称是为了方便前端可视化用的。
测试接口
接下来我们首先测试下邮件表。
输入问题:最近 3 个月发邮件最多的人、数量、最后发件时间
AI 返回的 JSON 是:
{
"columns": ["发件人名称", "数量", "最后发件时间"],
"sql": "SELECT from_name as 发件人名称, COUNT(*) as 数量, MAX(date_beijing) as 最后发件时间 FROM emails WHERE date_beijing >= now() - interval '3 months' GROUP BY from_name ORDER BY 数量 DESC LIMIT 1"
}
执行 SQL,可以看到 SQL 和结果都正确:

输入问题:最近一周每天完成的任务数
AI 返回的 JSON 是:
{
"columns": ["日期", "完成任务数"],
"sql": "SELECT date_trunc('day', date) AS 日期, COUNT(*) AS 完成任务数 FROM tasks WHERE date >= current_date - interval '7 days' GROUP BY date_trunc('day', date) ORDER BY date_trunc('day', date)"
}
执行 SQL,可以看到 SQL 和结果都正确:
数据返回
由于每个 Tool 返回的数据结构不尽相同,我们约定工具如果不是返回的字符串,那么就要指定消息类型, 这样可以在 FastAPI 路由时候判断answer['type']的值来统一消息格式:
if active_tools:
agent = initialize_agent(active_tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, verbose=True)
answer = agent.run(item.prompt)
logger.info(answer)
if type(answer) is dict:
if answer['type'] == 'echarts':
return {
'content': '%s如下:' % item.prompt,
'llm': item.llm,
'echarts': answer
}
return {
'content': answer,
'llm': item.llm
}
前端设计
由于我们最终还是使用消息的样式来完成前端,首先用比较人性化的内容来回答,然后跟上图表。
图表展现类型
对于不同的数据会有不同的展示形式,常见的有柱状图、折线图、饼图、气泡图。
我们兼容下这几种常见的形式,并且让用户鼠标移动到图标上可以在前端切换可视化样式。
提供前端切换图表样式还有一个因素,就是让 AI 确定正确的可视化样式以及与之匹配的数据结构是很复杂的一件事儿,如果用 gpt3.5 接口,可能很容易出错。
前端实现思路
判断是普通消息还是图表消息
我们实现消息类型系统,简单通过响应有没有 echarts 属性来判断显示不显示图表。
多种图表格式的构建
依然使用最简单的方法,if语句判断:
export const buildOption = (type: string, titles: Array<string>, source: Array<Array<any>>): any => {
if (!titles) {
return null;
}
if (type == "scatter") {
return ...
} else if (type == "bar") {
return ...
} else if (type === "pie") {
return ...
} else if (type == "line") {
return ...
}
}
在 Vue3 的 setup 函数构建 option:
const chartType = ref("bar");
const rebuild = () => buildOption(
chartType.value,
props.message.echarts?.fields,
props.message.echarts?.values
);
const opts = ref(rebuild());
这样就可以在消息内容里面显示 Chart 了:
<v-chart class="chart" :option="opts" autoresize />
回顾下最终效果
视频展示:
--- END ---

