1. 从报表到“可对话”的大屏这个项目到底在做什么先说说我接这个项目时的背景。地铁公司的运营数据一直不少——客流、车次、准点率、能耗、售票、设备故障分散在十几个系统里平时靠运营人员手动导Excel再做日报等数据整理出来往往已经过了决策窗口。领导想看的是“现在”的情况不是上周的报表。所以这个项目的核心需求很直白把散落的地铁运营数据拉通、清洗、入库用可视化大屏让调度和运营人员一眼看懂现状再叠加一层AI大模型直接对着数据说话。项目定的技术栈是Python Django Vue ECharts后端负责数据接入和接口前端是大屏展示AI大模型负责自然语言问答、异常归因和预测分析。这套系统跑通之后能做的事情大致有三类一是基础的可视化监控比如线路客流热力、列车准点率、断面拥挤度二是运营辅助分析比如对比不同线路的客运强度、找出客流突增的站点三是AI交互直接问“今天早高峰哪条线最挤”“过去一周准点率最低的线路是哪条”大模型会翻译成SQL或调用数据接口把答案以流式输出的方式渲染到大屏上。这篇博客不是讲概念而是把整个项目的设计思路、核心模块、后端接口写法、前端大屏适配、以及AI大模型接入的完整链路都拆开。适合正在做交通数据项目、想用DjangoVue做全栈可视化、或者准备给自己的系统加一个“能聊数据”的大模型入口的开发者参考。2. 技术选型为什么不是Spring Boot也不是纯前端方案2.1 数据侧和展示侧的天然分工先说后端为什么选Django。地铁运营数据的特征有几个结构化强、更新频率高、查询经常涉及多表聚合。Django自带的ORM在做这类复杂查询时配合annotate、aggregate、Prefetch可以写出相当清晰的数据聚合逻辑Django REST FrameworkDRF让接口开发效率极高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 可视化为什么选EChartsECharts在这个项目里几乎是唯一选择。地铁运营场景下最常用的是折线图、柱状图、热力图、桑基图、地图散点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__gte2025-01-15) .values(line_code, biz_date) .annotate(totalSum(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(minute0, second0, microsecond0) start end - timezone.timedelta(minutes5) rows ( PassengerFlowRecord.objects .filter(create_time__gtestart, create_time__ltend) .values(line_code, station_code) .annotate(cntCount(*)) ) bulk_rows [ PassengerFlowAgg5min( line_coder[line_code], station_coder[station_code], time_bucketend, passenger_countr[cnt], ) for r in rows ] PassengerFlowAgg5min.objects.bulk_create(bulk_rows, batch_size2000)这个逻辑里最关键的细节是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_ctx8192, n_gpu_layers35, verboseFalse, )这里n_gpu_layers需要根据显卡显存调整设少了走CPU推理速度会明显下降设多了超过显存会直接OOM。实测下来7B模型q4量化大概需要6GB显存n_gpu_layers35时左右速度已经能满足对话场景。5.2 SSE流式输出的Django实现大模型回答如果不做流式输出用户要等十几秒才能看到完整回复体验很差。SSEServer-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}], streamTrue, ): delta chunk[choices][0][delta].get(content, ) if delta: yield fdata: {json.dumps({content: delta}, ensure_asciiFalse)}\n\n return StreamingHttpResponse( event_stream(), content_typetext/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可以更方便地处理ReadableStreamconst 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掉循环而是要配合AbortControllerconst 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_addTrue)记录创建时间前端ECharts时间轴显示时刚开始差了8个小时。排查后发现是Django的USE_TZTrue导致所有时间都以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里尤其要注意N1问题比如查询站点信息后逐条查询线路信息。序列化开销方面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能力这几块严丝合缝地咬合在一起。
阅读完成 · 觉得有帮助?