1. 从报表到“可对话”的大屏,这个项目到底在做什么
先说说我接这个项目时的背景。地铁公司的运营数据一直不少——客流、车次、准点率、能耗、售票、设备故障,分散在十几个系统里,平时靠运营人员手动导Excel再做日报,等数据整理出来,往往已经过了决策窗口。领导想看的是“现在”的情况,不是上周的报表。所以这个项目的核心需求很直白:把散落的地铁运营数据拉通、清洗、入库,用可视化大屏让调度和运营人员一眼看懂现状,再叠加一层AI大模型,直接对着数据说话。
项目定的技术栈是Python + Django + Vue + ECharts,后端负责数据接入和接口,前端是大屏展示,AI大模型负责自然语言问答、异常归因和预测分析。这套系统跑通之后,能做的事情大致有三类:一是基础的可视化监控,比如线路客流热力、列车准点率、断面拥挤度;二是运营辅助分析,比如对比不同线路的客运强度、找出客流突增的站点;三是AI交互,直接问“今天早高峰哪条线最挤”“过去一周准点率最低的线路是哪条”,大模型会翻译成SQL或调用数据接口,把答案以流式输出的方式渲染到大屏上。
这篇博客不是讲概念,而是把整个项目的设计思路、核心模块、后端接口写法、前端大屏适配、以及AI大模型接入的完整链路都拆开。适合正在做交通数据项目、想用Django+Vue做全栈可视化、或者准备给自己的系统加一个“能聊数据”的大模型入口的开发者参考。
2. 技术选型:为什么不是Spring Boot,也不是纯前端方案
2.1 数据侧和展示侧的天然分工
先说后端为什么选Django。地铁运营数据的特征有几个:结构化强、更新频率高、查询经常涉及多表聚合。Django自带的ORM在做这类复杂查询时,配合annotate、aggregate、Prefetch可以写出相当清晰的数据聚合逻辑;Django REST Framework(DRF)让接口开发效率极高,ModelSerializer + ViewSet几行代码就能出一个规范的REST API。
你可能想问,Spring Boot在国内交通行业也很常见,为什么选Python?两个原因。第一是这个项目后续要做AI大模型能力,Python生态离模型推理、数据处理最近,本地部署GGUF格式的量化模型、做Embedding、写Agent逻辑,Python环境最顺。第二是团队现有成员的技能栈以Python为主,快速交付的压力下,没必要为了“企业级”三个字去硬切Java。
前端选Vue而不选React,更多是团队习惯和生态匹配的问题。Vue + Element Plus做后台管理页面非常快,而大屏部分用ECharts,本身就是独立库,跟框架无关,Vue里做数据响应式驱动图表配置项,比手写DOM操作省心得多。
2.2 核心数据存储设计
数据层我做了比较明确的分层。原始数据表——比如每趟列车的运行记录、每个闸机的通过记录——全部落库,保持完整历史。但查询接口不直接打原始表,而是用两类依赖:预聚合表和冷热分区。
预聚合表按“线路编号 + 站点编号 + 时间粒度(5分钟/小时/日)”三个维度存放客流、车次、准点率等指标。这样做的好处不用多说,大屏端的查询时间是毫秒级,而不是在几千万行的原始表上做实时聚合。代价是ETL链路要写两套逻辑:实时接入的增量聚合,和每天凌晨的全量重算。重算的目的不是数据算不出来,而是要在第二天发现前一天的数据有修正时,把预聚合表刷成一致状态。
数据分区也是必须做的。一张客流明细表如果按年到年底会有上亿行,不加分区的话,索引再优化也很难扛住多天的联动查询。按月份做RANGE分区,日常查询最多触达两三个分区,性能提升非常明显。
2.3 可视化为什么选ECharts
ECharts在这个项目里几乎是唯一选择。地铁运营场景下最常用的是折线图、柱状图、热力图、桑基图、地图散点,ECharts对这些图表类型的支持非常成熟。尤其是大屏上最常见的线路客流热力图,可以基于GeoJSON画线路拓扑,再把站点和区间映射到坐标系上,这样能根据地铁路线图做站点维度的颜色映射,比纯散点图直观得多。
另外一个原因是数据异步更新机制。ECharts的setOption支持传入notMerge参数,大屏数据刷新时不需要销毁图表实例,直接更新series数据就能实现平滑过渡。这对调度大屏来说非常关键,因为大屏上同时有十几个图表组件,如果每次刷新都重建实例,内存和CPU消耗都会明显上升。
2.4 项目目录与模块边界
metro_ops/ ├── backend/ │ ├── apps/ │ │ ├── traffic/ # 原始数据接入与清洗 │ │ ├── metrics/ # 预聚合指标计算 │ │ ├── api/ # DRF接口 │ │ └── assistant/ # 大模型对话、工具调用、SSE输出 │ ├── datahub/ # Django项目配置、数据库配置 │ └── scripts/ └── frontend/ ├── src/ │ ├── views/ │ ├── components/ │ └── api/把assistant独立成一个app很重要。大模型对话不是简单调一次接口就结束,它涉及历史会话管理、工具注册、提示词模板、流式输出队列,如果和业务接口混在一起,后面扩展会很难受。我在项目里把大模型能力完全抽象成“工具服务”,业务接口只管数据,AI问答只是数据的另一种消费方式。
3. 核心数据模型与可视化功能剖析
3.1 四大数据域,覆盖地铁运营主要场景
地铁系统的业务数据可以归纳为四个域:客流域、行车域、服务域、设备域。
客流域的数据源包括AFC闸机记录、车站人数统计、列车拥挤度数据。核心指标有进站量、出站量、换乘量、断面客流、满载率、客运强度。行车域的数据主要来自信号系统和列车日志,指标包括发车间隔、旅行速度、准点率、实际开行列次。服务域关注的是乘客体验,比如列车延误时长、车站服务事件、投诉工单。设备域则包括电扶梯、屏蔽门、AFC设备的运行状态和故障记录。
模型设计上,每个域至少有对应的明细表、聚合表和维度表。举个例子,客流域我设计了这样几张表:
passenger_flow_record:明细表,每条记录是一次闸机通过事件,包含线路、站点、闸机编号、时间戳、进出方向。passenger_flow_agg_5min:按5分钟聚合的进站/出站人数。passenger_flow_agg_daily:按日聚合的进站总量、峰值时段、客运强度。station_dim:维度表,包含站点名称、所属线路、经纬度、换乘关系。
REST API直接依赖聚合表,明细表只给后台导出和AI做深度分析用。这样设计的原因很简单:大屏上看到的每一个数字,都不能等查完明细再算。
3.2 最有代表性的可视化场景
第一个是客流热力大屏。这里的热力不是地图上那种经纬度热力,而是线路拓扑上的热力——沿线站点按进站量映射成不同颜色,区间按断面客流映射成粗细不同的连线。早高峰7点半到8点半,你会直观看到哪几个站像“红灯”一样亮起来,哪几个区间是深红色。ECharts里实现方式用的是custom系列的渲染回调,把客流数值映射成颜色和线宽,再叠加在canvas绘制的线路图上。
第二个是准点率趋势曲线。准点率按日/周/月维度聚合后,用折线图展示,同时叠加“早高峰时段”和“晚高峰时段”的参考线。这张图最核心的价值是发现规律:比如某条线路每周五下午的准点率明显下滑,进一步下钻到运休数据和信号故障记录,就能定位原因。
第三个是车辆运用甘特图。地铁的列车排班高度复杂,车辆从车场出库到正线运行、到回库检修都有严格计划。用甘特图展示每一列车一天的时间轴,能看到运行交路的覆盖情况、高峰时段加车的位置、以及备用车在哪个站点待命。
第四个是异常预警看板。这个看板不以图表为主,而是列表+点位标注的组合。当某个站点的客流在连续5分钟内超过阈值,或某区间列车停留时间异常,系统会实时推送预警卡片,同时在地图相应位置弹出一个红色标记。
3.3 接口设计的统一约定
前后端联调最怕的是各写各的,接口风格不统一。我在项目里做了几个约定:所有列表接口返回{ code, data, message }结构,分页统一用page和page_size参数,时间范围统一用start_time和end_time,时间格式统一是ISO 8601。大屏端的数据刷新由前端定时轮询,不引入WebSocket。这样设计的原因我会在性能优化部分详细说明。
4. 全栈实操:从Django ORM到ECharts大屏
4.1 时间是花在SQL上,不是花在Python上
我先给一个查询实例。需求是“按线路统计过去7天每天的平均客运强度”。客运强度的定义是某线路日均客运量除以线路长度。这个指标如果直接用ORM对明细表做group by,数据量大时会让数据库CPU跑满。正确做法是直接查预聚合表:
from django.db.models import Sum, F from metrics.models import PassengerFlowAggDaily rows = ( PassengerFlowAggDaily.objects .filter(line_code__in=["L1", "L2", "L3"], biz_date__gte="2025-01-15") .values("line_code", "biz_date") .annotate(total=Sum("passenger_count")) .order_by("biz_date") )这段代码看起来平平无奇,但实际上PassengerFlowAggDaily表里已经有按线路+日期聚合好的数据,单条SQL只扫很少的数据量。有同学会问,既然已经有聚合表,为什么还要在ORM里做Sum?因为“线路在某天的总客流”需要把该线路所有站点的数据再汇总一次,这一步做在SQL里比拉到Python内存里快得多。
在ORM查询上,我踩过最大的坑是直接用filter做多表外键关联查询时,Django会默认生成LEFT JOIN,导致结果行数翻倍。解决方法是明确用select_related或Prefetch控制JOIN策略,或者干脆在预聚合表里冗余需要的维度字段,避免JOIN。
4.2 预聚合任务的调度实现
预聚合任务我用Django自带的management command写成脚本,再配合系统Cron定时执行:
class Command(BaseCommand): def handle(self, *args, **options): end = timezone.now().replace(minute=0, second=0, microsecond=0) start = end - timezone.timedelta(minutes=5) rows = ( PassengerFlowRecord.objects .filter(create_time__gte=start, create_time__lt=end) .values("line_code", "station_code") .annotate(cnt=Count("*")) ) bulk_rows = [ PassengerFlowAgg5min( line_code=r["line_code"], station_code=r["station_code"], time_bucket=end, passenger_count=r["cnt"], ) for r in rows ] PassengerFlowAgg5min.objects.bulk_create(bulk_rows, batch_size=2000)这个逻辑里最关键的细节是time_bucket用当前时间截断到5分钟整数,保证同一时间段的聚合行主键唯一,方便后续做去重更新。我建议给聚合表加unique_together约束,否则重跑任务时会插入重复数据。
4.3 Vue前端大屏数据驱动的写法
大屏端的核心是数据驱动。我在Vue组件里定义了一个updateAll方法,通过Promise.all同时请求多个接口,再依次更新图表:
async function refreshDashboard() { const [flowData, punctualityData, trainData] = await Promise.all([ api.getPassengerFlow({ lineCode: currentLine.value }), api.getPunctualityTrend({ days: 7 }), api.getTrainSchedule({ date: today }), ]); flowChart.setOption({ series: [{ data: flowData.stations.map(s => ({ name: s.stationName, value: s.passengerCount, itemStyle: { color: getHeatColor(s.passengerCount) } })) }] }); punctualityChart.setOption({ series: [{ data: punctualityData.trend }] }); trainChart.setOption({ series: [{ data: trainData.schedule }] }); }ECharts在Vue里的正确打开方式是ref绑定实例,而不是每次渲染都重新init。我在onMounted里初始化一次,之后只调用setOption。高频刷新场景要打开animation: false,否则动画队列堆叠会卡死。
大屏自适应是我花了比较多时间的一个点。项目里我用了rem单位 + 设计稿1920x1080作为基准,然后监听resize事件动态调整根字体大小。这样在不同分辨率的拼接屏、LED屏上都能保持整体比例。
4.4 大屏性能优化的关键
大屏上的图表同时刷新时,浏览器会有明显的帧率下降。我的优化步骤是:第一,降低轮询频率,将5秒刷新改成15秒,运营数据本身变化没这么快,15秒足够实时;第二,定时器全部放在window.setInterval里,页面隐藏时清除;第三,用requestAnimationFrame合并同帧内的多次setOption调用;第四,ECharts实例在页面销毁时必须dispose,否则会内存泄漏。
另外,大屏上不要用太多高消耗的可视化类型,比如3D地球、大量散点的动画。地铁数据可视化最重要的是信息密度和实时性,不是视觉炫技。
5. 接入AI大模型:本地部署+SSE流式输出
5.1 模型选型:本地部署还是API调用
我在这个项目里用的方案是本地部署量化模型。原因有两个:一是运营数据有数据安全要求,站点客流、列车运行情况属于企业内部数据,不适合把明细发给外部API;二是本地部署的延迟可控,内网环境下可以直接用。硬件的选择上,一张消费级显卡就能跑7B量级的量化模型,部署成本并不高。
格式上我选了GGUF,这个格式在llama.cpp生态里兼容性最好,还能配合CPU/GPU混跑。Python端可以用llama-cpp-python直接加载:
from llama_cpp import Llama llm = Llama( model_path="/models/qwen2.5-7b-instruct-q4_k_m.gguf", n_ctx=8192, n_gpu_layers=35, verbose=False, )这里n_gpu_layers需要根据显卡显存调整,设少了走CPU推理速度会明显下降,设多了超过显存会直接OOM。实测下来,7B模型q4量化大概需要6GB显存,n_gpu_layers=35时左右速度已经能满足对话场景。
5.2 SSE流式输出的Django实现
大模型回答如果不做流式输出,用户要等十几秒才能看到完整回复,体验很差。SSE(Server-Sent Events)是这种情况下最合适的方案——后端通过一个长连接持续推送增量内容,前端实时渲染。
Django里实现SSE有两种思路。一种是直接用StreamingHttpResponse返回:
from django.http import StreamingHttpResponse def stream_chat(request): question = json.loads(request.body)["question"] def event_stream(): for chunk in llm.create_chat_completion( messages=[{"role": "user", "content": question}], stream=True, ): delta = chunk["choices"][0]["delta"].get("content", "") if delta: yield f"data: {json.dumps({'content': delta}, ensure_ascii=False)}\n\n" return StreamingHttpResponse( event_stream(), content_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, )X-Accel-Buffering: no这个header很重要。如果前端通过Nginx代理访问,Nginx默认会缓冲响应,导致流式效果被吞掉,加了这行就告诉Nginx不要缓冲。这是我在实际部署时被坑过的地方。
另一种更优雅的方案是使用channels的AsyncHttpConsumer,支持异步流式输出,Django的异步视图配合async for可以避免阻塞worker。但如果整个项目还是同步Django为主,用StreamingHttpResponse已经够用,唯一要注意的是单个worker在同一时间只能处理一个流式请求,所以部署时要给大模型问答接口单独开一个worker池。
5.3 前端如何接收并渲染流式内容
前端用fetch而不是axios,因为fetch可以更方便地处理ReadableStream:
const response = await fetch('/api/assistant/stream/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question: questionText.value }), }); const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const json = JSON.parse(line.slice(6)); answerText.value += json.content; } } }用TextDecoder时要记得传{ stream: true },否则多字节字符(比如中文)可能会在流边界处被截断,产生乱码。这个问题我在联调时遇到好几次,前端收到的内容总是末尾多一个带�的字符。
如果你的对话界面还需要支持“停止生成”,前端不能简单break掉循环,而是要配合AbortController:
const controller = new AbortController(); fetch(url, { signal: controller.signal }); // 点击停止时调用 controller.abort();后端收到客户端断开连接时,StreamingHttpResponse的生成器会抛GeneratorExit,需要在生成器里做清理逻辑——关闭数据库连接、释放模型推理的显存上下文等。
5.4 让大模型会“查数据”:Agent + 工具调用
单纯把问题扔给大模型,它只能靠知识库里的内容回答,对实时运营数据无能为力。我建的方案是给大模型注册工具,让它能调用项目里已有的数据API。
在提示词里,我先定义出一份工具清单:
可用的数据工具: 1. get_line_flow(line_code, date) —— 查询某线路某日的客流总量 2. get_station_flow(station_code, start_time, end_time) —— 查询某站点某时段进出站客流 3. get_punctuality(line_code, days) —— 查询某线路近N天准点率 4. get_train_status(line_code) —— 查询某线路当前运营列车数然后在大模型生成回复前,先让模型判断是否需要调用工具。如果问题里包含“哪个站”“多少客流”“准点率”这类指标关键词,就要求模型先输出一个JSON格式的tool_call,后端解析后执行工具函数,把结果再回传给大模型生成最终答案。
def handle_tool_call(tool_call): name = tool_call["name"] args = tool_call["arguments"] if name == "get_line_flow": return get_line_flow(args["line_code"], args["date"]) if name == "get_station_flow": return get_station_flow(args["station_code"], args["start_time"], args["end_time"]) ...实测下来,这一步是整个AI模块正确率最高的地方。只要工具定义清晰、函数名见名知意,模型很容易学会“什么时候该调工具、传什么参数”。反而是一些开放性问题,比如“为什么今天客流比昨天高”,模型会给出比较泛泛的解释,这时候可以让它先查数据再归因。
5.5 提示词模板与数据感知
要让大模型回答得更像内部运营分析助手,我在系统提示词里注入了大量上下文:
你是地铁运营数据分析助手。你可以访问实时客流、列车运行、准点率等指标数据。 回答要求: 1. 涉及具体数字时,必须引用工具返回的数据,不得编造。 2. 回答尽量简洁,面向运营人员,避免大段叙述。 3. 如果数据有异常(如客流突增、准点率下降),先指出异常,再给出可能原因。这里最核心的原则是“不得编造数字”。大模型的幻觉问题在数据类场景中非常致命,所以我在流式输出的每个tool_call结果里都会附加一个数据来源标记,前端渲染时会把数字高亮,同时显示“数据来源:2025-01-16 08:30 聚合任务”。这样用户能看到回答是基于真实数据的,而不是模型编出来的。
6. 踩过坑、性能优化与部署上线
6.1 典型问题排查实录
记录几个这个项目里最有代表性的问题,供大家少走弯路。
第一个是时间字段的时区混乱。Django默认使用UTC时间,而地铁运营数据全部是北京时间。我在模型里用DateTimeField(auto_now_add=True)记录创建时间,前端ECharts时间轴显示时刚开始差了8个小时。排查后发现是Django的USE_TZ=True导致所有时间都以UTC存储,解决方法是统一在setting里设置TIME_ZONE = 'Asia/Shanghai',并在写入数据前显式转换时区。
第二个问题在运营数据对接时,不同系统的时间戳格式不统一,有的是10位秒级、有的是13位毫秒级,有的还是字符串。这个问题的坑不在于转换代码难写,而在于很难发现,因为只是个别数据错乱。我加了一层ETL校验:每次写库前检查时间戳落在合法范围内(比如2020年到2030年之间),不在范围内的直接拒掉并计入错误日志。
第三个问题是查询接口偶发超时,排查后指向数据库连接池耗尽。原因是数据库配置里CONN_MAX_AGE设置为0时,每个请求都新建连接,并发一高就崩了。调成60秒连接复用之后,数据库CPU和连接数都明显下降。
第四个是前端大屏偶尔出现空白图。排查发现ECharts实例是在v-if条件渲染的DOM上初始化的,组件切换一会儿后DOM销毁了,但实例没有同步销毁。除了解除绑定,更稳妥的做法是让大屏图表所在的容器始终渲染,不要用v-if去控制显隐,改成CSS的v-show。
6.2 性能瓶颈通常不在Python本身
这个项目的性能优化实践可以总结成一个原则:90%的数据库问题都出在查询计划上,而不是Python代码。大屏数据接口要快,核心思路是减少扫描行数、减少往返次数、减少序列化开销。
减少扫描行数靠预聚合和分区表,上面已经说了。减少往返次数的方法是批量查询:前端一次性请求多天的数据,而不是循环请求。Django的ORM里尤其要注意N+1问题,比如查询站点信息后逐条查询线路信息。序列化开销方面,DRF的Serializer在高频接口上可以考虑直接用values()返回字典而不是序列化模型实例,实测性能差距能到2到3倍。
Redis在这个项目里不是用作缓存主数据库,而是作为二级缓存层:把5分钟聚合表的最近24小时数据缓存到Redis,查询热接口时优先读缓存,缓存过期再回源数据库。这样数据库压力能被压得很低,大屏刷新基本没有感知不到延迟。
6.3 部署与上线经验
部署架构上,我用了一台应用服务器和一台数据库服务器,应用服务器上用Nginx + Gunicorn + Daphne(只有AI模块用到的时候需要)。Gunicorn的worker数量按CPU核心数乘以2再加1来设置,实测在4核8G的机器上配9个worker比较稳定。
大屏访问高峰期在早晚高峰时段,流量集中且可预判,所以我还加了一层按时间段的预热任务——早上7点前把当日早高峰时段预测的聚合数据提前算好,用户打开大屏时直接起量。这个方法对运营场景特别实用,因为在固定时段往往能提前预判到访问峰值。
数据库备份和监控也不能省。我用了PostgreSQL的pg_dump做每日全量备份,加上WAL日志做增量归档;监控方面重点盯四个指标:数据库慢查询数、API接口响应时间P95、Redis命中率、Gunicorn worker处于BUSY状态的时间占比。这几个指标直接决定了大屏是否“卡”。
写在最后的实操建议
如果这个项目你也要复刻,我最想强调的是三件事。第一,预聚合表是整个系统的地基,地基不稳后面全是补丁;前期花一周时间把聚合任务、调度、重算、校验做扎实,远比后期调优省时间。第二,AI大模型接入不要一步到位,先从“工具调用”开始,让模型能准确查数据就够了,回答得“像不像内部专家”是第二阶段的事。第三,大屏交付不等于项目结束,运营人员会不断提新的指标需求,所以接口设计和数据模型要留出扩展空间,不要让加一个图表就要改数据表结构。这个项目做完我最大的体会就是:技术栈并不复杂,难的是把数据链路、业务理解、前端呈现、AI能力这几块严丝合缝地咬合在一起。