幽灵堵车:前面没事故,为什么车流还是自己堵成一串
高速上开着开着突然慢下来,挪过一段又莫名其妙畅通了——前面没有事故,没有施工,也没有红灯。这种堵车叫「幽灵堵车」,它的成因不是某个障碍,而是刹车这件事会自己传染:一个人因为任何理由轻点一下刹车,后面的人就得跟着刹得更多,这一小段减速会顺着车流往上游滚,最后滚成一串停停走走的车龙。
静态图讲不清两件事:它到底需要多高的车流密度才会自己长出来?把它里面的一辆车直接拿掉,拥堵会不会散? 所以我把这件事做成了一个能拖的网页:8 条互相独立的环形车道同屏,每辆车按 Nagel-Schreckenberg 规则逐格更新;下方是一张实测画出来的「基本图」(密度 → 流量),边上可以一键把整条曲线扫出来。
几条关键信息先摆出来:
- 源码:Demo Park 公开仓库 → https://atomgit.com/deli007/demo_park/tree/main/codearts-phantom-jam,
index.html可直接下载- 生成物:单个
index.html(约 30 KB),纯前端、零依赖、不联网、不上传,全部计算在本地完成- 本地运行:双击文件即可;或
python -m http.server 8000- 本地验证:1920×1200 视口下画布 1890×477;自检 2/2 通过;密度扫了 6 档,表在下面;实测流量峰值 0.691 落在 ρ≈0.18
它不是交通工程仿真。模型里没有换道、没有匝道汇入、没有驾驶员的反应时间差异,只有「加速—跟车减速—随机刹车—前进」四步,而且道路是环形的、第 i 辆车只看得见正前方那一辆。下面会如实写它的边界。

为什么值得看:堵车的种子是一次随机刹车,而它会自己长大
Nagel-Schreckenberg 模型每个时间步只做四件事,每辆车各自执行:
1. 加速:v ← min(v + 1, vmax)
2. 跟车减速:v ← min(v, 与前车的空格数)
3. 随机刹车:以概率 p,v ← max(v - 1, 0)
4. 前进:车向前移动 v 格
第 1 步让车想跑快,第 2 步保证不追尾(这里面已经藏着「一慢就传染」的机制:前车慢,后车被迫跟着慢),第 3 步是关键——没有任何外部原因,纯粹是概率上的随机减速。这三步合起来的结果,就是自由流会自己退化成一串一串的停停走走。
真正值得做成能拖的页面的,是下面两个量:
- 流量 Q = 实际密度 ρ × 平均速度 v̄。堵得很死的路和空得很的路,流量都不高;中间那个凸起的峰值才是路能送走的最大车流。页面里的「基本图」就是把每档密度跑出来的 (ρ, Q) 连成一条曲线,峰值标在曲线上。
- 拥堵比例:速度 ≤ 1 的车占全场的百分比。它把「堵」这个感觉变成了一个可以盯着看的数字——我这次实验里它能在 1 秒内从 33.5% 涨到 34.4%,也能一直挂在 84%。
页面里有三个实现决定值得说清楚:
- 随机数自己实现,种子可复现。 用的是 xorshift32,种子显示在状态条上(默认 12345)。同一个种子重置后重跑,每一步每辆车的速度序列逐位一致——这不是嘴上说的,页面里那两条自检断言之一就是干这个的。
- 每个格子是「空」或「一辆 0~vmax 的车」,速度用整数存。 环形道路 160 个格点、8 条独立环线,一辆车就是一个整数,整场 1280 个格子每步全量更新,所以它跑得动。
- 状态条上的「流量」用的是实测密度,不是滑杆上的密度。 落车是按每个格点独立掷骰子撒的,滑杆填 0.20 时实际落下来的是 0.2227(285 辆 / 1280 格),页面老老实实按 0.2227 算 Q。这一点看下面的表就明白了。
也说几处真实的边界:
- 只有单车道,没有换道:现实中「换道加塞」既是拥堵的原因之一,也是消散途径之一,这里完全没有。
- 每辆车只看正前方一辆车,没有任何预判;随机刹车概率 p 是全场统一的,不分车型、不分驾驶员。
- 道路是环形的,所以堵车波绕一圈会回来;这也是它能稳定停在一个「有堵但不全堵」状态的原因之一,和真实高速出口下游的排队不完全一样。
任务描述
请用单个 index.html 文件(内联 CSS 和 JS,不依赖任何外部库、不发任何网络请求)做一个【幽灵堵车实验室】网页应用,深色科技风、全屏自适应:上方是路网画布,下方是实时图表和控制面板。要求:
1) 用 Nagel-Schreckenberg 元胞自动机模型模拟单车道环形道路上的车流……每一步按四步更新(加速 +1 到不超过 vmax;与前车距离不够就减速到与距离相等;以概率 p 随机减速 1;按当前速度向前移动 v 格)。
2) 同时跑 8 条互相独立的环形车道,每条默认 160 个格点,各自独立随机初始化……车辆画成圆角矩形,颜色按速度映射(0 = 红 → vmax = 青绿),速度 ≤ 1 的车额外加一层红色发光。
3) 控制面板提供五个控件:车流密度(0.02~0.60)、最大速度 vmax(1~7 的整数)、随机减速概率 p(0~0.50)、车道数量(1~12)、每帧步数(1~20);三个按钮「暂停 / 继续」「重置」「单步」;再加一个「固定随机种子」输入框(1~999999)和一个「p = 0 确定性模式」快捷按钮。
4) 随机数必须可复现:自己实现一个可复现的伪随机数发生器(例如 xorshift32)……同一个种子下重置后重跑,每一步每辆车的速度序列必须逐位一致。
5) 顶部状态条实时显示:迭代步数、车辆总数、平均速度、流量(= 密度 × 平均速度,单位车/步)、拥堵车比例(速度 ≤ 1 的车辆占比)、最长连续拥堵长度(格点数)、当前种子。
6) 底部画一张「基本图」……把「扫描基本图」按钮扫出来的实测 (密度, 流量) 点连成曲线,标出峰值流量落在哪个密度上。
7) 内置【自检】按钮:(a) 把 p 设为 0、密度设为 0.05 跑 300 步,稳态下每辆车速度都应等于 vmax,平均速度误差 < 1e-9;(b) 用同一个种子跑 2000 步两次,比较两次记录的平均速度序列是否逐位一致。
8) 输入校验:密度、vmax、p、车道数、种子任意一个超出范围或不是数字时,在对应输入框下方用中文红字提示「请输入 X ~ Y 之间的数字(当前保留上一次的有效值)」,并保留上一次的有效值继续运行。
9) 鼠标在画布上点一下,可以移除点击位置附近(同一车道前后各 3 个格点内)最近的一辆车,用来观察「拿掉一辆车之后拥堵怎么消散」。
完成后打开预览,让我直接看到运行效果。
码道 Web 交回的是 /workspace/index.html 单文件,自己起了内置预览。下面的数字全部来自我在本机浏览器里对这个文件的重跑,不是转述它的总结。
它实际长成了什么样:密度扫一遍,看拥堵怎么长出来
先把随机刹车概率固定在 p=0.10、vmax=5、8 条车道、种子 12345,每档都按「重置」重新撒车、等 14 秒再读状态条:
| 滑杆密度 | 实测密度 ρ | 车辆数 | 平均速度 v̄ | 流量 Q | 拥堵比例 | 最长拥堵段 |
|---|---|---|---|---|---|---|
| 0.06 | 0.064 | 82 | 4.89 | 0.313 | 0.0% | 0 格 |
| 0.10 | 0.116 | 148 | 4.82 | 0.558 | 0.3% | 4 格 |
| 0.15 | 0.166 | 212 | 4.05 | 0.671 | 10.3% | 19 格 |
| 0.20 | 0.223 | 285 | 2.83 | 0.630 | 31.9% | 28 格 |
| 0.25 | 0.274 | 351 | 2.15 | 0.591 | 49.0% | 33 格 |
| 0.35 | 0.373 | 478 | 1.38 | 0.514 | 66.8% | 58 格 |
这张表有两个地方值得盯:
- 流量先涨后落。 从 0.313 涨到 0.671,然后掉到 0.514。页面里的「扫描基本图」扫了 30 档(0.02→0.60)得到更细的结论:峰值 0.691 落在 ρ≈0.18,和我手扫的这两档(0.166 时 0.671、0.223 时 0.630)围出来的区间对得上。
- 拥堵比例的抬头比流量转折更陡。 ρ 从 0.166 到 0.223 只多了 0.057,平均速度却从 4.05 掉到 2.83,拥堵比例从 10.3% 跳到 31.9%。也就是说车没多多少,路却塌了一大半——这段陡坡就是「幽灵堵车」的相变区。

ρ=0.20 这个默认状态我还盯了一段连续演化(每 5 秒采一次):前 5 步平均速度只有 2.04、拥堵比例 39.5%,之后稳定在 2.75~2.85、33.4%~36.1%。也就是说拥堵不是慢慢长出来的,而是几秒钟内就成形,然后卡在这个水平上下浮动。下面是第三次采样点附近的样子:
把里面一辆车拿掉,拥堵会散吗? 我做了这个实验:跑到拥堵稳定在 32.9%~33.5%、最长拥堵段 29 格时按「暂停」,在路面上点掉一辆红车(车辆总数 285 → 284,点中有效),再按「继续」:
| 时间 | 车辆数 | 平均速度 | 流量 | 拥堵比例 | 最长拥堵段 |
|---|---|---|---|---|---|
| 删车前(基线) | 285 | 2.86 | 0.637 | 33.5% | 29 格 |
| 删车后 +0 秒 | 284 | 2.78 | 0.617 | 34.4% | 34 格 |
| 删车后 +8 秒 | 284 | 2.78 | 0.617 | 34.4% | 34 格 |
| 删车后 +20 秒 | 284 | 2.78 | 0.616 | 34.5% | 34 格 |
少了一辆车,拥堵比例没有降,反而略涨,最长拥堵段从 29 格变成 34 格。这不是程序出错,而是这个模型最想说明的事:堵车不是一个「卡在那里的东西」,它是一个持续被随机刹车重新生产出来的过程。你拿掉的是结果,不是原因,所以下一脚随机刹车又会把它长回来。
再看两个极端档,边界就清楚了:
- ρ=0.05、p=0 的「自由流」:64 辆车,平均速度 5.00(= vmax),拥堵比例 0.0%,最长拥堵段 0 格。
- ρ=0.45、p=0.30 的「大堵车」:591 辆车,平均速度 0.69,流量 0.317,拥堵比例 84.4%,最长拥堵段 98 格——整条环路上三分之一还多的车连成一队。
- 一个容易误解的点:p=0 的「确定性模式」不是「不堵车」。 ρ=0.30、p=0 时实测平均速度 1.72、拥堵比例 56.8%、最长拥堵段 37 格。随机刹车只是堵车的催化剂之一,初始撒车的不均匀照样能长出拥堵,只是重跑同一个种子会得到一模一样的堵法。

踩坑与边界:四处真实的不一致
一、滑杆写 0.20,状态条说 0.223。 密度滑杆只是「撒车时每个格点的落车概率」,实际密度由落下来的车数决定:densityNow() = 车辆总数 / (格点数 × 车道数),285 / (160 × 8) = 0.2227。基本图上的高亮点、峰值标注用的都是这个实测密度,所以滑杆和读数对不上是设计使然。但这确实容易被误读成 bug——如果需求里写一句「同时显示设定密度与实测密度」,这个歧义就不会存在。
二、暂停只冻结推进,不冻结点击。 「暂停」按钮改的是一个 running 标志,主循环里只有运行时才推进格点;但画布上的 pointerdown 处理函数完全不看这个标志。我上面那次删车就是在暂停状态下完成的:读数一步不动,车却真的少了一辆。要修的话,在 pointerdown 开头加一句 if (!running) return; 就行——这一版我没改,因为截图和上面的数字都是在这个行为下取的。
三、自检只验了两件事,不验曲线形状。 页面自检(我本机重跑)结果是:
✓ 通过
(a) 自由流 ✓ p=0、ρ=0.05、300 步后 57 辆车全部 v = vmax = 5
(b) 确定性 ✓ 同种子两次各 2000 步,平均速度序列 2000/2000 逐位一致
两条都是「机制对不对」,没有一条验证「基本图的峰值应该出现在中等密度」。所以「自检 2/2 通过」不能当成「交通流结果可信」——曲线形状是我自己扫出来的,也只能自己判断。
四、帧率不是 60。 主循环是朴素的 requestAnimationFrame,每帧推进「每帧步数」个时间步,没有节流也没有降采样:本机 1920×1200(路网画布 1890×477、8 条车道 × 160 格)实测 6.03 秒推进 630 步 = 每帧 5 步、约 21 帧/秒。车少的时候也差不多快(82 辆和 478 辆都是约 105 步/秒),说明开销主要花在每帧重绘整幅画布上,而不是花在逐格更新上。这个数字看机器和窗口大小,不当性能结论。

失败路径是这份需求里写得最细的一条,也是它第一次就完全达标的地方。 六个输入框(密度、vmax、p、车道数、每帧步数、种子)共用同一个校验器:先解析成数字,再判断是否要求整数,最后判断是否落在范围内;任何一步不过,就在对应输入框下方写一行中文红字,并把输入框回退到上一次生效的值。我逐条试了四个边界:
| 输入 | 提示原文 |
|---|---|
密度填 0.9 |
请输入 0.02 ~ 0.6 之间的数字(当前保留上一次的有效值) |
p 填 0.9 |
请输入 0 ~ 0.5 之间的数字(当前保留上一次的有效值) |
车道数填 3.5 |
请输入 1 ~ 12 之间的数字(当前保留上一次的有效值) |
| 种子留空 | 请输入 1 ~ 999999 之间的数字(当前保留上一次的有效值) |
四种都命中红字、输入框回退、页面不白屏、不静默忽略。

准备环境:进入码道 Web
浏览器打开码道 Web 版:https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwsf&sourcead=dmzntgwwbwz,登录后就能在对话窗口输入需求,不需要装软件。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
本地复现
生成物只有一个 index.html,复现没有依赖:
- 从 Demo Park 本案例目录下载
index.html。 - 双击打开。默认密度 0.20、vmax 5、p 0.10、8 条车道、每帧 5 步、种子 12345,跑十几秒就能看到拥堵比例稳在 33%~36%。
- 想走静态服务也行:
python -m http.server 8000,然后访问http://localhost:8000。 - 把窗口拉到一个较大的视口(我用的是 1920×1200),路网画布会按宽度铺满、8 条车道自动分行;画面右侧和下方不留黑边,就说明画布尺寸重建对了。
下面这些数是我本地逐条跑出来的,都能自己复验:
- 画布尺寸:视口 1920×1200 时,路网画布实测 1890×477,基本图 1052×598,
devicePixelRatio= 1。 - 自检:点「🔬 自检」,两条断言都通过(2/2 通过),面板直接把实测数值写出来(上图 4)。
- 密度扫描:上表 6 档,ρ=0.064 时空路很畅,ρ=0.373 时平均速度掉到 1.38、拥堵比例 66.8%。
- 峰值:点「📈 扫描基本图」自己扫 30 档,峰值流量 0.691 落在 ρ=0.18。
- 删车干预:暂停中点掉一辆车(285 → 284),继续跑 20 秒,拥堵比例不降反略升(见上表)。
- 帧率:1920×1200 下实测约 21 帧/秒(每帧推进 5 步、约 105 步/秒),这个数随机器和窗口大小变化。
使用码道体会
- 把模型的更新规则写进需求正文,而不是描述画面。 需求把「加速 +1 → 跟车减速 → 随机减速 1 → 前进」四步逐条写死,还点名了 vmax、p 的含义,它交回来的就是货真价实的 Nagel-Schreckenberg 元胞自动机,而不是「一堆方块来回动」的动画。本文能列出密度表和基本图,前提就是模型本身是对的。
- 要求它把中间量显示出来。 「状态条实时显示流量、拥堵比例、最长拥堵段」这一条回报最大:拥堵比例从 10.3% 跳到 31.9% 那段陡坡,就是盯着这个数字看出来的。
- 把可复现性写成硬指标。 需求里点名「同一个种子重置后每一步速度序列逐位一致」,它就自己实现了 xorshift32 并把种子显示在状态条上——这条后来直接变成了自检断言 (b)。
- 没写进需求的一致性,默认不会被检查。 密度滑杆写 0.20、状态条却读到 0.223,这处歧义需求里没提,它就没管。下次我会补一句「同时显示设定密度与实测密度」。
- 验收要自己动手。 它自检 2/2 通过,但两条都只验「机制对不对」,不验曲线形状;峰值该落在哪个密度上,是我自己扫出来、自己判断的。Agent 说完成不算验收。
亲手点三下
页面打开就能验,三下够了:
- 看相变:把「车流密度」滑杆从 0.05 慢慢推到 0.40,盯住状态条里的「平均速度」和「拥堵比例」。低密度下平均速度贴着 5、拥堵接近 0;过了 0.16 左右,速度开始往下掉、拥堵比例往上抬,这就是幽灵堵车的相变区。
- 扫曲线:点「📈 扫描基本图」,等它把 30 档跑完,看那条先升后降的曲线。峰值 0.691 标在 ρ=0.18 上;再把视线移到峰值右边的高亮点,那里密度更高、流量反而更低——路已经堵到自己送不出车了。
- 看失败路径:在密度框里填
0.9回车,必须看到一行中文红字提示、输入框退回上一次的有效值、页面不白屏;再填0.35回车,提示消失、画面按新密度重撒车。
直接下载试玩
- Demo Park 仓库:https://atomgit.com/deli007/demo_park
- 本案例目录:https://atomgit.com/deli007/demo_park/tree/main/codearts-phantom-jam
总结
把「幽灵堵车」做成一个能拖的网页之后,最值得记住的不是那 8 条环形车道,而是第 3 步那一次「没有理由的随机刹车」:它不停地往车流里投新的减速种子,再被「前车慢、后车更慢」这条规则放大成一段一段的拥堵带。所以堵车不是一个卡在那里的东西,而是一个被持续生产出来的过程——你把里面一辆车拿掉,下一脚随机刹车又会把它长回来。想接着玩,可以试试把随机刹车概率 p 设成 0(确定性模式),看初始的不均匀会长出什么样的堵法;把车道扩到 12 条,比较不同随机种子的差异;或者给模型加一条「换道」规则,看看现实里既是拥堵成因、又是消散途径的那件事,会怎样改写这条基本图。
- 点赞
- 收藏
- 关注作者
评论(0)