TrafficManager: Injecting Traffic into the Simulation, Part 2
闭环评测里”车不动了”只是一个症状,至少对应两种完全不同的病。2026-08-31 T3.0 冒烟夜两种都抓到了,对比着看最清楚:
| 交通自锁 | 模型真实弱点 | |
|---|---|---|
| 症状 | 英雄车原地停等 120 s+(路线 10000,坐标 (65,28)) | 路口偏航 7 m 后卡死(坐标 (72,24),路线 10000/10007 同点位) |
| 控制信号 | 正常减速停等,前方有静止车 | 绿灯满刹 + 死舵(steer −0.38 / brake 1.0 持续饱和) |
| 因果链 | 前车被别的背景车挡,一层挡一层 | 左转汇入后偏航,面对目标点输出死舵,force_move 无效 |
| 归因 | 交通系统(BackgroundBehavior 密度 × 短路线) | 模型(SimLingo 在该路口构型下的真实弱点) |
判据的本质是控制信号签名:礼让停等的输出是”平稳的刹车”,死舵卡死的输出是”持续饱和的 steer/brake 组合”——前者是合理决策撞上了走不了的世界,后者是决策本身坏了。
两种病的修复路径完全相反:
如果把交通自锁误诊为模型缺陷,就会去改一个没病的模型;把模型弱点误诊为交通问题,就会把真实缺陷从数据里洗掉。两种情况都会让对比实验的结论失真。所以第一刀永远先分诊:这个不动,是世界不让它动,还是它自己不想动?
TM 是确定性规则系统:严格按路权优先级通行,不抢道、不博弈、不”赌一把”。这带来一个经典的并发失效模式——环形等待(circular wait):
A 在等 B 先走,B 在等 C,C 又在等 A。等待关系成环之后,每辆车的放行条件都依赖环上另一辆车先动,没有任何一辆能满足启动条件,环就永远锁死。
操作系统课里的 Coffman 条件(死锁四要素)逐条都能对上:
| Coffman 条件 | 路口自锁里的对应物 |
|---|---|
| 互斥(mutual exclusion) | 路口物理空间同一时刻只能被一辆车占用 |
| 占有且等待(hold and wait) | 每辆车占着自己的位置(堵住身后),同时等前方放行 |
| 不可抢占(no preemption) | 规则司机不会强行抢道、不会倒车让行 |
| 循环等待(circular wait) | 等待链闭合成环 |
TM 的无信号路口优先级是 FIFO + 固定等待时间(官方文档),没有任何随机打破死锁的机制——规则系统的美德(确定性)恰恰是它死锁时不自救的原因。
成环概率随密度和路口复杂度上升:车越密、路口越小,等待链越容易闭合成环。而本项目的口径恰好把两个参数都推向极端:
15 辆车怼进一个路口,等待链成环几乎必然。同一组默认值放在官方长路线上则相安无事——参数没有对错,只有与路线尺度的匹配与否。
闭环评测里两个时间要分清:**游戏时间(game time)**是仿真世界里的虚拟时钟,**墙钟时间(wall-clock time)**是现实里真正流逝的时间。两者的换算系数是实时倍率 :
表示与真实时间同步; 表示比实时慢(计算跟不上仿真节拍)。
SimLingo 是 VLM(视觉语言模型)逐帧生成式推理,闭环实测只有 (AGENTS.md T1.1 实测口径,约 0.7–1 s/帧)——一秒游戏时间要烧 15–25 秒墙钟。
代入事故数字:卡死路线游戏时间 120 s 以上,
即 31–50 分钟,40 分钟量级。而一条正常路线十几秒游戏时间完赛,同样按 折算墙钟约 4–6 分钟——一条卡死路线 ≈ 8 条正常路线的时间总和。
串行跑 160 个 run 时,总耗时由最慢的尾部决定(makespan 由 tail 主导):只要卡死路线不止一条,预算就不是线性超,而是被尾部拖到爆。任务卡写的执行窗口是 8–14 小时,按当时实测外推到 20–28 小时(docs/log/2026-08-31.md §4),超了约一倍——算力没加、模型没改,纯粹是背景交通配置把墙钟拖垮。
这也解释了为什么后续补的 blocked 紧缩(卡死判据从 0.1 收紧到 0.5 m/s)是”纯省墙钟不改语义”:让蠕动骗过计时器的卡死路线早点被判死、早终止,把尾部砍掉,分数语义不变。
卡死是按游戏时间计罚的(120 s 不动判超时),墙钟代价却按各模型的推理速度折算。两个模型的实测口径:
| SimLingo | AutoMoT | |
|---|---|---|
| 闭环实测 | 0.11–0.125× 实时(T3.0 冒烟) | 0.17 s/帧(同批冒烟) |
| 折算每帧墙钟(dt = 0.05 s) | 0.40–0.45 s/帧 | 0.17 s/帧 |
| 一条 120 s 卡死路线 | 16–18 分钟 | ≈ 6.8 分钟 |
(数据来源:docs/log/2026-08-31.md §3;SimLingo 官方原路径更慢,0.04–0.065× 实时,见 AGENTS.md T1.1,T1.2b FASTPATH 提速约 2.24× 后达到上表口径。)
同一条卡死路线,SimLingo 的墙钟是 AutoMoT 的 2.4 倍以上;用官方原路径口径算则差出 4–7 倍。所以”预算被卡死路线拖爆”这笔账,主要记在 SimLingo 头上。
SimLingo 的主干是 InternVL2-1B 视觉语言模型:每一帧都要跑完整的视觉编码 + 自回归(autoregressive)文本/轨迹 token 生成。自回归意味着 token 必须一个接一个地解码,无法并行——这是它与”一次前吐出整条轨迹”的模型在墙钟上的结构性差距。AutoMoT 虽同为 VLM 系(Qwen3-VL 双专家),但异步专家设计与更短的解码路径让它每帧只需 0.17 s。
选模型跑大规模闭环时,精度不是唯一指标:
补丁打在 vendored(就地复制的第三方副本)background_activity.py 的两个机制参数上,真实文件里的样子:
# background_activity.py L237 / L241(vendored 补丁)
self._junction_sources_max_actors = 3 # T3.0 MySim: 6->3 低密度口径
self._junction_source_perc = 30 # T3.0 MySim: 80->30 低密度口径
为什么这两个旋钮四两拨千斤?按 source 机制算期望压力:每个源的期望在场车上界从 降到 ,单源交通压力缩到原来的约 1/5。实测瞬时在场从 15 辆降到 约 7 辆(AGENTS.md T3.0 实测口径)——车流还在(评测语义不变),但路口密度降到了短路线能消化的水平,等待链难以成环。
# T3.0 MySim: 6->3 低密度口径——任务号、改动方向、理由三要素齐全,半年后翻代码一眼知道这里动过、为什么动;补丁后路线 10000 不再互堵,全量放飞得以继续。这条教训以规则形式进了项目坑录:改 vendored 必须留注释。配套的另一条:改完 vendored Python 必须 python3 -m py_compile 过一遍(曾有补丁把行尾注释插进括号内,导致参数整行被吞,两路 BLOCKED 各 3 次才定位——AGENTS.md T3.0 坑录)。
降密度补丁后,两侧的最终口径:
| UE4 侧(CARLA 0.9.15) | UE5 侧(CARLA 0.10) | |
|---|---|---|
| 机制 | 动态 source/sink(BackgroundBehavior 行为树) | 固定 spawn N 辆(bg_traffic.py) |
| 密度 | 瞬时约 7 辆(perc 30 / max 3) | N = 6(--bg-traffic 默认值) |
| 车型池 | 8 款(抄自 smoke 实测统计) | 11 款(2026-09-02 实探) |
| 行为种子 | traffic-manager-seed | 无法设置(0.10 崩溃),仅 bg-seed |
UE5 侧为什么不做动态系统?0.10 上没有同款官方 leaderboard 生态,约 2600 行的行为树搬不动也不该硬搬——复刻不了机制,就复刻可观测的口径:被测模型实际看到的,是”路上有几辆车、什么类型、会不会挡路”,这些用固定 N 就能对齐到同一量级。
这不是严格等价,而是工程上可执行的折中:
对比的公平性靠两侧一致保证;与官方 leaderboard 数字的可比性,靠声明差异换取。这两句话是本项目所有口径决策的通用模板——路线、系数集、dt、交通,全部按同一模板处理。
同机跑两个 CARLA server,需要错开的不只是主 RPC 端口:
| 服务 | UE5 侧 | UE4 侧 | 备注 |
|---|---|---|---|
| RPC | 2021/2022/2023 | 2141/2151 | winNAT 保留段三次漂移吞噬后迁至此(见下) |
| TM | 8000 | 8010 | 默认都是 8000,必须显式错开 |
背景:Windows 的 winNAT 会动态保留 TCP 端口段(netsh int ipv4 show excludedportrange tcp 可查),原约定的 2000–2002/2010–2012 被 1921–2020 段吞掉,之后 2031/2041 又被新漂移段(1937–2136)吞掉,UE4 端口最终迁到 2141/2151(AGENTS.md T0.5/T3.0 坑录)。RPC 端口吃一堑长一智登记了,TM 端口却漏在清单外——所有独立开端口的服务都要进登记表,这是这张清单”会生长”的原因(双 UE4 实例并发时还有第三个 TM 要独立端口)。
set_autopilot(True, tm_port) 的工作方式是按端口找 TM:不传端口默认连 8000。两个 server 的 TM 都监听 8000 时,脚本想连”自己的” TM,实际连上的是先抢到 8000 的那个——控制指令发进了另一个仿真世界的交通管理器:
串台不报错、不崩溃:TCP 连接合法建立,指令合法下发,每个组件单独看都”工作正常”。症状只剩一个——车辆行为莫名不对劲。这类”每个环节都对、整体行为诡异”的故障,第一反应应该是查拓扑(谁连了谁),而不是查代码逻辑。本项目沉淀的排查纪律:遇到诡异行为,先查端口表再谈别的;server 启动失败则先查 netsh 排除段再查显卡(crash XML 的 AdapterName 是烟雾弹)。
CARLA 0.10(UE5)里没有 vehicle.lincoln.mkz_2020——蓝图库(blueprint library)里的资源名整体换了一代:
| UE4 侧(0.9.15,8 款) | UE5 侧(0.10,11 款) | |
|---|---|---|
| 命名风格 | 全带年份后缀:lincoln.mkz_2020、dodge.charger_2020、mini.cooper_s_2021… | 无年份后缀 + 新车系:lincoln.mkz、dodge.charger、ambulance.ford、firetruck.actors… |
| 来源 | 抄自 2026-08-30 smoke 实测 Counter | 2026-09-02 实探 |
两侧池子的交集几乎为零——连英雄车都只能改用 vehicle.lincoln.mkz(T2.1 坑录)。
代码照抄旧名的后果是 blueprint_library.find(name) 找不到——而这类查找失败往往不响警报:返回 None 或抛个被上层吞掉的异常,脚本继续往下走,直到 spawn 才崩、或更糟地”少了几辆车但没人发现”。静默失败(silent failure)的特点是:故障发生的位置和暴露的位置可以隔很远,排查时从后往前倒推成本极高。
把硬编码资源名一律当会过期的外部依赖处理:
这条原则不只适用于车型:传感器属性、天气参数、地图名,跨 UE4/UE5 迁移时同此处理。
探测式(probe-based)资源池的思路:不假设任何资源存在,运行时拿名单逐个问,存在才收进池:
# tools/bg_traffic.py L83-93(真实代码)
def probe_vehicle_pool(world, names):
"""探测给定车型名哪些在当前 server 可用,返回可用子列表。"""
bpl = world.get_blueprint_library()
avail = []
for n in names:
try:
bpl.find(n)
avail.append(n)
except (IndexError, ValueError, RuntimeError):
pass # 不存在的直接弃掉
return avail
调用方(executor)把”探测”和”使用”绑在一起,并给空池一个响亮的失败:
# tools/ue5harness/route_executor.py L324-338(真实代码,节选)
from bg_traffic import BackgroundTraffic, UE5_POOL, probe_vehicle_pool
pool = probe_vehicle_pool(self.world, UE5_POOL)
if not pool:
raise RuntimeError("UE5 背景车型池全部不可用,先探测")
self.bg = BackgroundTraffic(
self.world, self.args.tm_port, n=n_bg, seed=seed, pool=pool,
hero_location=self.ego.get_location())
这里的 fail-fast(快速失败)是刻意的:池子为空说明环境/版本出了大问题,此时继续跑只会产出隐形缺车的脏数据——比起崩掉,带病跑完一个 160 run 的批次贵得多。
这与本系列处理相机曝光属性跨版本差异的手法(曝光属性探测)是同一个模式:跨版本差异一律运行时探测,不写死。模式三要素:
UE5_POOL 11 款,2026-09-02 实探)——探测的前提是知道”可能有什么”;背景交通的随机性有两层:spawn 抽样种子(--bg-seed)和 TM 行为种子(traffic-manager-seed,API 为 set_random_device_seed)。理想的对比实验要求两侧两层全部对齐,背景交通才算完全同源。
现实是 UE5 侧对不齐:CARLA 0.10 上调用 set_random_device_seed 会触发 C++ terminate 直接崩掉进程(M3 口径决策账实测记录)。executor 里的防御代码只能兜住 Python 层的异常:
# tools/ue5harness/route_executor.py(真实代码,节选)
tm_seed = getattr(self.args, "tm_seed", None)
if tm_seed is not None:
try:
self.tm.set_random_device_seed(tm_seed)
except (AttributeError, RuntimeError) as e:
print(f"[executor] TM seed 设置跳过: {e}", flush=True)
注意这个 try/except 的局限:std::terminate 这类 C++ 层异常进程直接被杀死,Python 的 except 根本没有执行机会。所以这不是”捕获后继续”能解决的问题,而是”干脆不能调”——UE5 侧最终只用 --bg-seed 控制 spawn 抽样,TM 行为保持未播种状态。
缺口的处理遵循本项目的口径铁律——能对齐的对齐,对不齐的声明:
--bg-seed 两侧同 seed 时布局可复现)、车辆数量与类型量级;traffic-manager-seed,UE5 侧无对应物,意味着跟车距离、变道时机这类驾驶风格在 UE5 侧不可精确复现;这是工程诚实的底线:声明过的差异是可讨论的 limitation,藏起来的差异是结论里的地雷。