UE5 Closed-Loop Infrastructure: Building an Evaluator from Scratch, Part 2
socket 架构从”连上”到”稳定”共迭代 12 轮,每轮按症状、排查、根因、修复完整留档(M3 复盘文档 §3)。全景表:
| # | 坑 | 根因 | 解 |
|---|---|---|---|
| 1 | server bind 冲突 | 执行器才是 bind 方,agent 是 client | agent server 改 connect |
| 2 | spawn 假失败 | 0.10 RPC 重试双效应(报错但实际已生成) | 预清理残留 hero + spawn 重试 |
| 3 | 同步设置回滚 | 0.10 在 client 断开时回滚 sync 模式 | 每条路线前重新 apply_settings |
| 4 | 握手超时 | 模型冷加载 90s > 单帧超时 30s | route_start 专用 300s 超时 |
| 5 | 连接窗口竞态 | 双方重试窗口同为 120s,擦肩而过 | agent 侧拉长到 10min |
| 6 | 基类构造连 CARLA | AutonomousAgent.__init__ 调 get_hero()(0.9.15 客户端连不了 UE5 server) | __new__ 跳过构造 + 手工补字段 |
| 7 | save_path 类型崩 | setup 的 "ckpt+route" 加号拼接分支约定 | 模拟 evaluator 的拼接调用 |
| 8 | viz 路径崩 | ROUTES 环境变量需含 data/benchmarks/ 前缀 | 伪路径 + touch 空文件 |
| 9 | HF 下载被触发 | pretrained 缓存按 cwd 相对解析 | 服务进程硬 chdir 到仓库根 |
| 10 | tick 全线崩 | 软重置把 turn_controller 建成普通 PID(step 签名不同) | 改用 LateralPIDController |
| 11 | AutoMoT 秒崩 | input_data['LIDAR'] 需 [frame, points] 元组包装 | 服务侧包元组 |
| 12 | 退出 abort 拖垮整批 | carla 客户端退出时 C++ abort(exit 134) | 逐路线子进程隔离 |
五个标色的案子(#4+#5 超时竞态、#6、#7、#10、#11)有普遍性——它们不是 CARLA 特有,任何”脱离原生框架复用大代码库”的自建系统都会遇到:隐式调用约定、超时预算、状态重建路径、接口包装一致性。
这张表本身就是项目产出:每一轮的”症状→排查→根因→修复”链条,比最终结果分数更有复用价值——M4 微调攻坚的排除法(8 嫌疑逐个实锤排除)用的就是同一套留档方法。
模型服务里 new 一个 LingoAgent,进程直接卡死——不是报错,是 __init__ 永远不返回。
一层层拆开:
LingoAgent 继承 leaderboard 框架的 AutonomousAgent。AutonomousAgent.__init__ 内部调用 get_hero()——它会创建一个 CARLA client 去连 server,找英雄车。这是框架的合理设计:在 leaderboard 里,agent 构造时评测器早已把世界准备好了。carla==0.9.15 客户端(SimLingo vendored 代码的硬依赖),而跑仿真的是 UE5 的 CARLA 0.10 server——两代 RPC 协议不兼容,连接既连不上也不痛快报错,就在 C 层recv 里干等。__init__ 卡死在”连接 CARLA”这一步,表面看像”模型加载慢”,实际是协议级不可能完成的任务。这类坑的识别特征:卡死而非崩溃。崩溃有栈可查,卡死只有”进程不动了”——排查时先看它卡在哪个系统调用上(strace / /proc/<pid>/stack),往往会指向一个你没想到的网络调用。
复用 leaderboard 系 agent 的价值就在于”推理代码一行不改”。如果为绕开构造副作用去 fork 改源码,对齐价值就没了。所以正确方向不是改 agent,而是改变创建它的方式——绕过有副作用的构造路径,保留全部功能路径(解法见 B05 的 __new__ 技巧)。
__new__ 绕过Python 创建对象实际经历两步:__new__ 分配对象(纯内存操作),__init__ 执行构造逻辑。平时 Cls(...) 是两步连调。冷知识在于:可以只调第一步——Cls.__new__(Cls) 得到一个空壳对象,不执行任何构造代码,自然也不会触发 get_hero() 连 CARLA。
然后手工补齐实例真正需要的字段。真实代码(t33_simlingo_agent_server.py 的 _new_agent):
agent = self.agent_cls.__new__(self.agent_cls) # 只分配,不构造
# 基类 __init__ 里本该设置的字段,手工补:
agent.track = Track.SENSORS
agent._global_plan = None
agent._global_plan_world_coord = None
agent.sensor_interface = SensorInterface()
agent.wallclock_t0 = None
agent.hero_actor = None # 英雄车:不存在,给 None
agent.get_metric_info = lambda: {} # 要连 hero 的方法:整个 stub 掉
# 最后只调真正需要的 setup() 装载权重
agent.setup(f"{self.ckpt}+t33_route_{self.route_counter:03d}")
要点是字段清单来自对基类源码的逐行审计:__init__ 里设了什么,哪些是纯数据(照抄),哪些有副作用(stub 或给 None),哪些由 setup() 自己会设(跳过)。
get_metric_info 为什么可以放心 stub?先问一句:这个函数在哪条流里。
hero_actor),属于观测流——只影响日志和画图。run_step 的控制流,不经过它。两条流互不干扰,丢弃观测不影响驾驶行为。反过来,如果 stub 掉的是控制流上的东西(比如路线规划器),车的行为就变了,实验结论随之作废。砍任何功能之前先问:它在控制流里,还是只在观测流里?
这是移植大代码库的通用招式:绕过副作用,保留功能。适用场景——框架的构造函数绑定了你不需要的外部资源(网络连接、文件锁、GUI 上下文),而你要的只是它肚子里的算法。代价是手工字段清单会随上游版本漂移,升级依赖时必须重新审计。
LingoAgent.setup() 的入参 path_to_conf_file 看着是”配置文件路径”,实际携带一个隐式拼接约定:leaderboard 的 evaluator 调用时传的是 "<ckpt路径>+<路线名>"——一个加号把两个信息塞进一个字符串。setup 内部按 + 切开,左右两边走不同分支。
照文档直觉传一个纯路径,就会走错分支:代码把字符串当 PosixPath 做拼接,PosixPath + str 直接抛 TypeError。连带约定还有一串:
route_index 要传 None(否则触发另一条 PosixPath 拼接路径,同样崩);ROUTES 环境变量的路径必须含 data/benchmarks/ 前缀——setup 第 258 行对它做字符串解析提取 route_type,只做字符串运算、不检查文件存在,但语义不完整后面 viz 保存会崩,所以要 touch 一个空文件兜底;chdir 到 bench2drive 仓库根,否则悄悄触发一次 HF 下载。真实调用(t33_simlingo_agent_server.py):
os.environ.setdefault("ROUTES", os.path.join(self.save_root,
"data/benchmarks/t33/ue5_aligned_routes_s0r10.xml")) # 前缀是约定,不是路径需要
os.makedirs(os.path.dirname(os.environ["ROUTES"]), exist_ok=True)
open(os.environ["ROUTES"], "a").close() # touch 兜底
# 对齐 evaluator 的 agent_config 拼接约定 "<ckpt>+<route_name>":
agent.setup(f"{self.ckpt}+t33_route_{self.route_counter:03d}")
它们写在 evaluator 的调用方式里——leaderboard_evaluator 怎么拼 agent_config、怎么传 route_index,就是 agent 接口的”真实规格说明书”。所以脱离 evaluator 复用 agent 只有一条路:读调用方源码,逐层模拟它的调用方式。这引出了下半集的通用教训一:leaderboard 系 agent 的大量约定是隐式的,接口的真实边界由调用方定义,而不是由被调方声明。
第一场:冷加载超过单帧超时。 首条路线的 route_start 握手会触发模型冷加载——读 InternVL2 权重、构图,约 90 秒。而 socket 上单帧问答的超时只有 30 秒(为 tick 设计)。用同一把超时尺子量两种操作,握手必死:执行器 30 秒没等到 ok 就判超时,模型其实还在好好加载。
第二场:双方重试窗口一样长。 更微妙。启动顺序无法严格编排,双方都要”等对方先就绪”:
t=0 executor 起 listen,等 agent 来连(窗口 120s)
t=0 agent 进程启动,先 import 模型栈(~90s 后才第一次 connect)
t=120 executor 等不到,放弃 ─┐
t=150 agent import 完,开始 connect(重试窗口也 120s)...
但 executor 已经走了 ──┘ 擦肩而过,一整轮评测白作废
竞态的本质:两个进程的重试窗口互为对方的可用性假设,窗口相等时,慢启动的一方(import 就要 90 秒)必然错过对方。它不崩、不报错,表现为”两边都在等,最后都放弃”——分布式系统里最典型的会合失败(rendezvous failure)。
这类竞态对时序敏感:机器快一点(import 80 秒)就碰巧连上,慢一点就错过。症状是”有时能跑有时不能”,极易被误判为网络抖动。定位靠的是把每一轮的等待/放弃时间戳打进日志,事后对齐时间线,而不是靠重跑碰运气。
修复见 B09 的等待窗口不对称原则。
两场竞态的修复都是同一思路——等待窗口必须不对称:
# 执行器侧:握手专用超时放宽到 300s(罩住 ~90s 冷加载)
ok = self.socket_server.send_tick({
"type": "route_start",
"route_id": route_config.route_id,
"keypoints": [...],
}, timeout=300.0) # 而 tick 问答仍用 30s
# 模型服务侧:连接重试窗口拉长到 10min
for i in range(120): # 120 × 5s = 600s = 10min
try:
conn.connect((args.host, args.port))
break
except (ConnectionRefusedError, socket.timeout):
_t.sleep(5) # 必须 > executor 的等待窗口
三处数字各有各的量级理由:tick 30s(单帧 VLM 推理约 0.5s,余量充足)、握手 300s(冷加载 90s 的 3 倍以上)、连接重试 10min(必须严格大于对方所有等待窗口之和)。
一句话:等待方的窗口,必须覆盖对方的最坏启动时间,再加余量。
两个互相等待的进程,窗口绝不能取同一个值——相等意味着任一方比预期慢一点就会合失败。这是分布式系统里超时预算(timeout budget)设计的最小案例:整条调用链的超时应该沿链路逐级递减,上游给下游留出的预算要大于下游自身可能消耗的全部时间;反过来,重试方的预算要大于被等方的放弃阈值,否则重试永远打在空窗期上。
实操推论:改任何一处的超时前,先把链路里所有超时/重试/放弃阈值列在一张表上,检查偏序关系是否还成立。12 轮修坑里超时类占了两轮(#4、#5),都是单个值”看起来合理”、放到系统里互相矛盾。
软重置要重建两个控制器。速度控制器用普通 PID,这没错;转向控制器顺手用了同一个类,这就错了——SimLingo 里转向是另一个类,构造和 step 的签名都不同:
import transfuser_utils as t_u
from team_code.nav_planner import LateralPIDController
# 速度:普通 PID(位置式,k_p/k_i/k_d/n 四参)
a.speed_controller = t_u.PIDController(
k_p=a.config.speed_kp, k_i=a.config.speed_ki,
k_d=a.config.speed_kd, n=a.config.speed_n)
# 转向:必须是 LateralPIDController——step 签名不同,
# 软重置曾用错类致 tick 全线崩(12 轮修坑 #10)
a.turn_controller = LateralPIDController(inference_mode=False)
“望文生义”在这里的具体形态:两个属性都叫 *_controller,直觉上是一对同类对象。实际一个是通用位置式 PID(误差进、控制量出),一个是车辆横向控制专用的 PID(签名里带 inference_mode,内部按航向误差和前瞻距离计算)。签名不同意味着调用点一进来就抛 TypeError,整条 tick 循环崩溃。
这是这个坑最阴险的地方:
setup() 内部用正确的类构建控制器,一切正常;也就是说,错误只在状态重建路径上暴露,而测试时最自然的做法是”先跑通一条路线看看”。首条正常 ≠ 软重置正确。发现时已经跑完一批,27 条路线结果全部是脏数据——处置很干脆:全部作废,修复后重跑,脏数据不进任何统计口径。
教训落成一条回归测试规则:凡是被改动的路径都要被测试覆盖,尤其是”第二条路线”这种只在重复执行时才走到的分支。复刻对象图(手工重建一个对象的内部结构)必须逐类核对,不能望文生义。
接 AutoMoT(第二模型,动作分支含 LiDAR)时一接就秒崩。根因:agent 期望 input_data 的每个值都是 (帧号, 数据) 二元组——它内部按 lidar[1] 索引取点云。服务侧最初给 LIDAR 键塞了裸数组,元组解包直接崩。
讽刺的是 rgb、gps、imu、speed 全都按元组包装了,只有 LIDAR 是新加的键,忘了套同一个约定。修复后(t34_automot_agent_server.py):
n = msg.get("lidar_n", 0)
if n > 0:
lidar = np.frombuffer(base64.b64decode(msg["lidar_b64"]),
dtype=np.float32).reshape(n, 4).copy()
else:
lidar = np.zeros((0, 4), dtype=np.float32)
input_data = {
"CAM_FRONT": (frame, rgb),
"LIDAR": (frame, lidar), # [frame, points] 包装(lidar_utils 按 lidar[1] 索引)
"GPS": (frame, list(msg["gps"])),
"IMU": (frame, [0.0] * 6 + [float(msg["compass"])]),
"SPEED": (frame, {"speed": float(msg["speed"])}),
"bev": (frame, np.zeros((512, 512, 4), dtype=np.uint8)), # viz stub,不进模型
}
接口约定要对每一个键成立,不是对大多数键成立。“大部分键都包装了”恰恰是这类 bug 的温床——约定越接近普遍,漏网的那个键越难被代码审查发现。
执行器侧的 LiDAR 回调里还有一层少有人知的清洗(route_executor.py):
def on_lidar(ev):
pts = np.frombuffer(ev.raw_data, dtype=np.float32).reshape(-1, 4)
pts = pts[np.isfinite(pts).all(axis=1)] # 0.10 未命中 sentinel 清洗
self.agent_data["lidar"] = (ev.frame, pts)
CARLA 0.10 的 LiDAR 对未命中光线会写入非有限的哨兵值(与 0.9.15 行为不同),不过滤就把 NaN/Inf 直接喂进模型。另外 LiDAR 参数(32 通道、range 10m、56000 点/秒、上下视场 10°/−30°)按 0.9.15 训练口径显式 set——0.10 的默认值差异巨大,不设就是对不齐的输入分布。
CARLA 的 Python 客户端(0.9.15 和 carla-ue5-api 0.10 都有)在正常退出时会触发一个 C++ 层 abort:进程退出码 134 = 128 + 6,即收到了 6 号信号 SIGABRT。它源自 C++ 运行时的 std::terminate / abort(),发生在客户端析构、向 server 发”恢复异步设置”的收尾阶段——对 server 无害,但必然杀死自己的进程。
关键性质:这不是 Python 异常。try/except 完全无效,因为它不经过 Python 的异常机制——C 扩展在解释器脚底下直接调了 abort(),进程当场死亡,连 finally 都不执行。
批跑 40 条路线,如果每条路线的收尾都在主进程里做 CARLA 客户端断开,第一条路线结束时的 abort 就把整个批跑脚本带走——后面 39 条根本没机会跑。
解法是逐路线子进程隔离:
主循环(不 import carla)
├─ subprocess: 跑路线 1 → exit 134 → 只死子进程
├─ subprocess: 跑路线 2 → exit 134 → 主循环无感
└─ ...
主循环只负责编排和收尸,绝不自己碰 CARLA 客户端。子进程的退出码里 134 被视为”预期内的脏退出”,只要结果 JSON 已落盘就算成功(所以脚本要先落盘再恢复异步设置)。
带 C 扩展的依赖,都按”随时可能带崩进程”来设计。 Python 层的防御(try/except、信号处理)对 C 层的 abort、段错误、死锁全部无效——SIGALRM 都打不断一个卡在 C 层 recv 里的调用(信号 handler 不回解释器)。唯一的可靠隔离边界是操作系统进程:子进程隔离 + subprocess timeout,而不是线程或协程。
自建链路最危险的不是崩溃,而是假成功:T3.3 真的被骗过一轮——路线全部完成、分数漂亮,但控制根本不是模型出的。机制就藏在执行器的回退逻辑里:
if not self.socket_server.wait_for_agent(timeout=300.0):
print("[executor] 无 agent 连接,回退 TM autopilot", flush=True)
use_socket = False # ← 静默降级:TrafficManager 接管驾驶
...
ok = self.socket_server.send_tick({...}, timeout=300.0)
if not ok or not ok.get("ok"):
use_socket = False # ← 握手失败同样回退 autopilot
回退是为鲁棒性设计的善意功能,但它把”模型评测”悄悄变成了”autopilot 评测”。每一层”能跑”的背后,都可能藏着一层假成功。
wall/game 比 = 墙钟时间 ÷ 仿真时间(tick 记录里 timestamp 与 sim_time 本来就是分开记的):
物理含义是”仿真跑得多慢”: 是实时, 是比实时慢(模型推理拖的), 是比实时快。各控制源的期望量级完全不同:
| 控制源 | wall/game 比 | 原因 |
|---|---|---|
| TM autopilot | < 1(UE5 同步 2.1× 实时,约 0.5) | 不跑模型,仿真满速跑 |
| SimLingo | ~10×(UE4 冒烟实测曾达 ~19×) | 每帧视觉-语言生成约 0.5s |
| AutoMoT | 1.4–5.8× | 异步双专家架构,与架构自洽 |
如果一次”SimLingo 评测”的 落在 0.5 量级,一票否决:控制根本没走模型。反过来 也不能证明行为正确,但能证明链路是真的——这是必要条件的廉价筛查。
它不需要改任何代码、不需要读模型内部:两个时间戳本来就在每条路线结果里(duration_system / duration_game)。它抓住的是一个无法伪造的物理量——VLM 推理必然要花时间,autopilot 装不出 10 倍的慢。更强的版本是逐帧看控制签名:autopilot 的 throttle/steer 平滑规则,模型的输出有 VLM 特有的抖动模式;但 wall/game 比作为一票验证已经够用。