第09章 - 边缘推理与端云协同部署
把模型放进机房是软件问题,把模型放到杆塔、机巢和无人机上是工程问题。同一套权重,换到 15 W 的被动散热盒子里,能跑多久、能同时解码几路、断网了怎么办,答案和训练时完全无关。这一章按交付顺序走一遍:先把现场约束翻译成设计参数,再选硬件、转引擎、搭多路流水线,然后切分端云任务、做模型分发与灰度、补监控,最后用 DeepSeek Harness(CLI 名 dsh)把重复的交付动作脚本化。读完你应能把一个「实验室里 mAP 0.72」的模型,变成一条能在现场连续跑 30 天、出问题能定位的产线。
学习目标:能把功耗、算力、内存带宽、散热、可靠性五类约束翻译成可执行参数;能独立完成 PyTorch → ONNX → TensorRT 11 的转换并做精度回归;能设计一条「丢帧优先于堆积」的多路视频流水线与端云切分方案;能写出可灰度、可回滚的模型包 manifest;能用 dsh 把部署自检与排障固化成脚本和技能。
前置知识:PyTorch 推理与 ONNX 导出、Linux 命令行与 systemd 基础;读过 第 02 章 的 2.3 节导出链路与 第 07 章 的蒸馏思路;会用
nvidia-smi、tegrastats观察资源。
9.1 边缘部署的五个约束
9.1.1 为什么先排约束
机房里的三件事到了现场全部反过来:算力不够可以加卡,现场供电是定死的;温度高了可以加空调,现场散热是结构件决定的;断网了还有专线,现场 4G 会掉、光纤会被挖断。边缘部署的失败案例里,模型不准只占少数,大多数是约束没算清:整机功率超过 POE 上限,相机一上电就整机重启;四路解码同时跑,LPDDR 带宽被吃满,帧间隔从 33 ms 抖到 200 ms;密封机箱里跑满功耗,正午机内温度到 80℃ 触发降频,帧率掉一半而告警没人看。
所以这一节不给框架清单,只给一条判断顺序:先把「这台设备被什么卡住」排出来,再谈网络结构。下面五类约束按现场出现频率排序,每一类都给出可测量的判据——判据必须是数字,否则它只是形容词。
9.1.2 五个约束的现场表现
先把五类约束对应到你能在日志和仪表上看到的现象。看不清现象,后面的选型和调参就是猜;能说清现象,问题基本已经定位了一半。
| 约束 | 现场表现 | 不满足的后果 | 先测什么 |
|---|---|---|---|
| 功耗 | 整机峰值超过供电上限;太阳能机巢夜间掉电重启 | 反复重启、电池循环寿命骤降、相机初始化失败 | 整机峰值功率、启动涌流、待机功耗 |
| 算力 | 多路并发时帧率只剩 1/3;告警延迟从 2 s 涨到 20 s | 队列堆积、丢帧、告警失去时效 | 目标模型在目标分辨率下的 P95 推理时延 |
| 内存带宽 | 解码与推理争抢 LPDDR;帧间隔抖动大 | 延迟毛刺、批处理效率下降、看似「模型变慢」 | 满负载下的内存带宽占用与解码吞吐 |
| 散热 | 满载 30 分钟后频率下降;夏天正午降频最明显 | 白天算力腰斩、热保护关机、寿命缩短 | 满载持续 30 分钟的核心温度与频率曲线 |
| 可靠性 | 无人值守第 20 天失联;eMMC 写坏;断电后起不来 | 需要派人上山、数据丢失、交付验收失败 | 看门狗配置、日志写入量、断电重启测试 |
这张表最容易被跳过的是「先测什么」一列。工程上正确的顺序是:在一台样机上用最终要上线的模型和最终要用的机箱,把五个量各测一遍,再回头改设计。样机阶段改一个电源或加一个风扇的成本,是现场返工的两个数量级以下。
9.1.3 约束到设计决策的映射
每类约束都对应一组可执行的设计决策。下表给的是「判据 → 决策 → 反例」,判据里的系数是工程经验值,换平台必须重新实测。
| 约束 | 量化判据(示例) | 设计决策 | 常见反例 |
|---|---|---|---|
| 功耗 | 整机峰值 ≤ 供电上限的 70% | 选低功耗档、限制并发路数、锁频、空闲降频 | 60 W 级模块配 45 W 电源,靠「平均功耗不高」说服自己 |
| 算力 | P95 推理时延 ≤ 帧间隔的 80% | 降输入分辨率、改 FP16/INT8、批处理、模型瘦身 | 四路 1080p 全帧率喂一张入门级卡 |
| 内存带宽 | 解码加推理占用 ≤ 总内存的 70% | 减少并发、ROI 裁剪、NV12 直送避免 RGB 全帧拷贝 | 每路都做一遍全图 resize 与色彩转换 |
| 散热 | 满载 30 分钟后频率不低于标称的 90% | 主动散热、按温度降并发、选更低功耗档位 | 密封盒里跑满功耗,只测了开机前十分钟 |
| 可靠性 | 连续 30 天无人工干预 | 硬件看门狗、只读根文件系统、A/B 分区、日志限额 | 日志写满 eMMC 导致整机挂死,现场只能换盘 |
约束之间会冲突,取舍顺序建议是:可靠性 > 时延 > 算力 > 功耗 > 成本。理由是前三项决定系统能不能用,后两项决定用起来贵不贵。唯一例外是机载设备,电池和重量是硬上限,此时功耗会升到第一位,代价是主动放弃一部分并发能力。
9.1.4 把约束算成预算
把上面的判据写成算式,选型评审时逐项代入,比开会争论有效。下面四个式子是全章后面反复用到的预算工具:
可用功耗预算 = 供电上限 × 0.7 − 板卡基础功耗 − 外设峰值功耗
持续算力预算 = 标称算力 × 持续系数(被动散热 0.5~0.7,主动散热 0.8~0.95)
单路时延预算 = 抽帧间隔 − 解码 − 预处理 − 排队等待 − 后处理
并发上限 = floor(持续算力 × (1 − 余量) / 单路推理开销),同时不得超过硬件解码通道数
把这四个式子写成脚本,评审时改参数就能出结论,避免「我觉得应该能跑」。下面这份脚本还顺手把启动电流和时延预算的告警算出来:
# edge_budget.py —— 把现场约束折算成可执行的配置上限
# 用法:python3 edge_budget.py(内置示例场景,改参数后可直接用于选型评审)
# 注意:所有系数都是工程经验值,必须用目标板实测校准后才能写进交付文档
from dataclasses import dataclass
@dataclass
class Device:
name: str
supply_w: float # 供电上限:POE、电池放电能力、机巢供电三者取最小值
board_base_w: float # 板卡空载功耗(SoC + 内存 + 载板)
peripheral_w: float # 相机、风扇、4G、SSD 等外设峰值
peak_tops: float # 标称 AI 算力,以厂商数据手册为准
sustained_ratio: float # 持续算力系数:被动散热 0.5~0.7,主动 0.8~0.95
nvdec_channels: int # 硬件解码通道数,以 SoC 规格为准
def power_headroom(dev: Device, startup_ratio: float = 1.5, rail_v: float = 12.0) -> dict:
"""按 70% 负载率反推可用功耗,并估算启动涌流。"""
budget = dev.supply_w * 0.7
static = dev.board_base_w + dev.peripheral_w
usable = budget - static
if usable <= 0:
return {
"ok": False,
"usable_w": round(usable, 1),
"advice": "供电不足:减外设、换更高功率电源,或改用低功耗档",
}
return {
"ok": True,
"usable_w": round(usable, 1),
"sustained_tops": round(dev.peak_tops * dev.sustained_ratio, 1),
"startup_current_a": round(dev.supply_w * startup_ratio / rail_v, 2),
}
def stream_limit(dev: Device, sustained_tops: float, per_stream_tops: float, reserve: float = 0.25) -> int:
"""并发上限取「算力允许」与「解码通道允许」的较小值。"""
by_compute = int(sustained_tops * (1 - reserve) // per_stream_tops)
return max(1, min(by_compute, dev.nvdec_channels))
def latency_budget(frame_interval_ms: float, decode_ms: float, preprocess_ms: float,
queue_ms: float, postproc_ms: float) -> dict:
"""反推留给推理的时延预算;结果为负说明配置本身不可行。"""
inference_ms = frame_interval_ms - (decode_ms + preprocess_ms + queue_ms + postproc_ms)
return {
"inference_ms": round(inference_ms, 1),
"ok": inference_ms > 0,
"advice": "按此预算压模型与后处理" if inference_ms > 0 else "预算为负:先降分辨率或抽帧,再谈模型",
}
if __name__ == "__main__":
dev = Device("Orin NX 16G(示例)", 48.0, 9.0, 6.0, 100.0, 0.7, 2)
p = power_headroom(dev)
print(p)
if p["ok"]:
print("并发上限(路):", stream_limit(dev, p["sustained_tops"], per_stream_tops=18.0))
print(latency_budget(40.0, 6.0, 3.0, 4.0, 3.0))
以这份示例输出为例:48 W 供电、板卡与外设 15 W,按 70% 负载率剩 18.6 W 给 AI 负载;持续算力约 70 TOPS,单路经验开销 18 TOPS,则算力上允许 2 路,而硬件只有 2 个解码通道,结论就是 2 路封顶。25 fps 的流给了 40 ms 的帧间隔,解码 6 ms、预处理 3 ms、排队 4 ms、后处理 3 ms,留给推理 24 ms——如果你的模型 FP16 引擎要 35 ms,那问题不在模型精度,而在配置根本不成立。
9.1.5 可靠性:无人值守的四个硬要求
现场设备一年上门两三次是常态,可靠性设计的目标是「不派人也能自愈」。四条要求按优先级排列,前两条不做,后两条没有意义:
- 硬件看门狗:SoC 内置 WDT 必须启用,且不能只靠应用层定时器——进程假死但线程还在喂狗是最典型的失效形态。
- 服务级看门狗:用 systemd 的
WatchdogSec让主循环定期上报,超时强杀重启;重启次数与时间写入持久化日志。 - 只读根文件系统:根分区挂
ro,可写数据全部落到独立分区或 overlay,避免异常掉电把系统盘写坏。 - 掉电安全的更新与数据落盘:升级走 A/B 双分区,数据库用 WAL 或追加写,关键状态写入后
fsync,宁可慢一点也不能写出半个文件。
一个最小可用的 systemd 看门狗配置如下。注意 Restart=always 与 WatchdogSec 要一起用,只配重启不配看门狗,等于没有覆盖「进程活着但不干活」的情况:
# /etc/systemd/system/edge-agent.service.d/watchdog.conf
[Service]
# 主循环每 5 s 上报一次;超过 20 s 未上报,systemd 判定卡死并重启
WatchdogSec=20
Restart=always
RestartSec=3
# 只读根文件系统下,只有 /data 允许写
ReadWritePaths=/data
Environment=EDGE_DATA_DIR=/data/edge-agent
9.1.6 把约束写进交付文档
约束只有变成交付文档里的验收项才有约束力。每一台设备、每一个点位,交付包里至少要有下面五项,缺一项就意味着排障时只能猜:
- 供电实测:整机峰值功率、启动涌流、待机功率,附测量仪表与时间。
- 热实测:满载 30 分钟的温度与频率曲线,附环境温度。
- 时延实测:P50/P95/P99 端到端时延,标注采样时段与并发路数。
- 可靠性实测:断电重启 20 次、断网 10 分钟、看门狗注入故障各一次的结果。
- 资源基线:CPU/GPU/内存/磁盘/网络的空载与满载占用,作为后续告警阈值的依据。
坑:第一,拿厂商数据手册的 TOPS 直接当可用算力,忽略持续系数与内存带宽,上线后多路并发立刻掉帧。第二,只测空载功耗,忽略启动涌流,POE 供电在相机上电瞬间整机复位,日志里只留下一行无意义的 kernel panic。第三,看门狗只配了应用层定时器,进程死锁后定时器线程仍在喂狗,设备失联三天无人知晓。
9.2 硬件选型
选型的第一个动作不是看参数表,而是把 9.1 的约束按优先级排好,然后用它们去淘汰平台。最常见的错误是先挑一块算力最大的板子,再围绕它改供电、改机箱、改散热——那是给硬件做适配,不是给业务做适配。
9.2.1 余量从哪来:三个必须看的硬指标
厂商首页写的是峰值算力,真正决定能跑几路的是另外三个数:持续算力(散热允许的稳定输出)、内存带宽(多路解码与推理同时争抢的共享资源)、硬件解码通道数(不是软件并发数)。三者任一先到顶,加算力没有意义。
9.2.2 平台分档与适用边界
下表按「高端新平台 / 存量主力 / 低成本 SoC / 机房侧」分四档。算力、功耗、带宽均为公开规格量级与工程经验值,必须用自己的模型在目标板实测后再写进方案;不同批次、不同功耗模式差异很大,尤其注意标称算力多为INT8 稀疏口径,而检测模型实际多用 FP16 密集算力。
| 平台 | 算力档位(厂商口径,需实测折算) | 功耗档 | 内存与带宽 | 可跑模型规模 | 典型并发(经验值,需实测) |
|---|---|---|---|---|---|
| Jetson AGX Thor(JetPack 7.2.x) | 新高端的数量级提升,FP4/FP8 口径与 FP16 可用算力差别大 | 40~130 W 级 | 大容量统一内存,带宽高于 Orin | 多路检测 + 分割 + 4B 级量化多模态 | 6~12 路 1080p 抽帧检测 |
| Jetson AGX Orin 64G | 约 275 TOPS(INT8 稀疏) | 15~60 W | 64 GB,约 200 GB/s 级 | 检测 + 分割 + 本地检索,可带小 VLM | 3~8 路 1080p |
| Jetson Orin NX 16G | 约 100 TOPS(INT8 稀疏) | 10~25 W | 16 GB,约 100 GB/s 级 | 单模型检测,可选轻量分割 | 2~4 路 1080p |
| Jetson Orin Nano 8G | 约 40 TOPS(INT8 稀疏) | 7~15 W | 8 GB,约 60 GB/s 级 | 单模型轻量检测(n 级) | 1~2 路,或单路低帧率 |
| RK3588(6 TOPS NPU) | 约 6 TOPS(INT8) | 5~10 W | 8~16 GB,共享带宽 | 小分辨率检测(320~640) | 1 路定点位抽帧 |
| x86 + 单张独显 | 20~80 TFLOPS FP16 级 | 150~400 W | 独立显存带宽充足 | 任意,可多模型并存 | 8~20 路(机房侧转拉) |
选型时按下面的顺序逐条淘汰,任何一条不过就直接换平台,不要试图用软件补:
① 功耗:整机峰值是否能被现场供电覆盖(含启动涌流)?
├─ 否 → 换低功耗档,或把 AI 负载挪到机房侧
└─ 是 → ②
② 内存带宽:同时解码 N 路 + 推理时,带宽占用是否 ≤ 70%?
├─ 否 → 减并发或降分辨率;仍不行则换高带宽平台
└─ 是 → ③
③ 解码通道:N 路是否 ≤ 硬件解码通道数?
├─ 否 → 抽帧 + 轮转解码,或改用软件解码兜底(CPU 占用要另算)
└─ 是 → ④
④ 生态:目标框架(TensorRT / RKNN)是否支持你的算子与量化精度?
├─ 否 → 算子裁剪、换等价结构,或换平台
└─ 是 → ⑤
⑤ 供货:模组、载板、散热件的交期是否覆盖交付窗口?
三个高频误判值得单独点名。误判一:拿「支持 8 路解码」当「能跑 8 路 AI」——解码通道是 VPU 的能力,AI 推理是 GPU/DLA 的能力,两者不是一回事。误判二:只在 25℃ 实验室测持续帧率,没测机箱内 60℃ 环境下的降频曲线。误判三:忽略统一内存的共享特性,解码缓冲、推理张量、模型权重都在同一块 LPDDR 上,带宽一满,所有环节一起变慢。
9.2.3 新板子到手 30 分钟自检清单
新板子到手不要先跑模型,先跑一遍自检:驱动、CUDA、TensorRT、硬件解码、推理、网络时钟,六项逐条确认。这份清单的价值在于把「环境问题」和「模型问题」在第一天就分开,否则后面每次掉点你都要重新怀疑一遍环境。
| 检查项 | 判据 | 不通过的典型原因 |
|---|---|---|
| 系统与 JetPack | 能读到 L4T 版本,nvpmodel 输出目标功耗档 |
刷了非官方镜像,或 JetPack 组件不完整 |
| CUDA | nvcc --version 有输出,Python 侧 torch.cuda.is_available() 为真 |
PATH 未加入 /usr/local/cuda/bin,或只装了运行时 |
| TensorRT | Python 绑定可导入并能打印版本,与 JetPack 7.2.x 配套 | 版本错配,常见于手动 pip install tensorrt 覆盖系统包 |
| 硬件解码 | GStreamer NVDEC 插件存在,样片能 30 秒内解完且 CPU 占用低 | 缺 nvidia-l4t-gstreamer,或样片编码格式不被支持 |
| 推理 | trtexec 能在目标功耗档下跑满 30 秒不降频 |
散热未装、功耗档被锁在低档、模型动态 shape 未给 profile |
| 网络与时钟 | 能连通云侧端点,NTP/chrony 已同步,偏差小于 100 ms | 无 RTC 电池、容器内未挂宿主机时钟、4G 拨号未完成 |
对应的自检脚本只读、不装包、不改配置,可以放心在现场跑。脚本里的路径与版本按你的镜像调整:
#!/usr/bin/env bash
# edge_selftest.sh —— 新板子到手 30 分钟自检(以 Jetson + JetPack 7.2.x 为例)
# 原则:只读、不装包、不改配置;每项输出 PASS/FAIL 与下一步建议
set -u
PASS=0
FAIL=0
report() {
if [ "$1" = "PASS" ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
echo "[$1] $2 :: $3"
}
echo "== 1. 系统与 JetPack =="
if [ -f /etc/nv_tegra_release ]; then
report PASS "L4T" "$(head -1 /etc/nv_tegra_release)"
else
report FAIL "L4T" "读不到 /etc/nv_tegra_release,确认是否官方镜像"
fi
if command -v nvpmodel >/dev/null 2>&1; then
report PASS "nvpmodel" "$(nvpmodel -q 2>/dev/null | head -2 | tr "\n" " ")"
else
report FAIL "nvpmodel" "缺少功耗模式工具,JetPack 组件可能不完整"
fi
echo "== 2. CUDA =="
if command -v nvcc >/dev/null 2>&1; then
report PASS "nvcc" "$(nvcc --version | tail -1)"
else
report FAIL "nvcc" "CUDA toolkit 不在 PATH,检查 /usr/local/cuda/bin"
fi
echo "== 3. TensorRT =="
if python3 -c "import tensorrt" >/dev/null 2>&1; then
report PASS "TensorRT" "$(python3 -c "import tensorrt; print(tensorrt.__version__)")"
else
report FAIL "TensorRT" "Python 绑定不可用;不要用 pip 覆盖系统包,重刷 JetPack 组件"
fi
echo "== 4. 硬件解码 =="
if gst-inspect-1.0 nvv4l2decoder >/dev/null 2>&1; then
report PASS "NVDEC" "nvv4l2decoder 插件可用"
else
report FAIL "NVDEC" "缺少 NVDEC 插件,检查 nvidia-l4t-gstreamer"
fi
if [ -f /data/selftest/sample.h264 ]; then
if gst-launch-1.0 -q filesrc location=/data/selftest/sample.h264 ! h264parse ! nvv4l2decoder ! fakesink 2>/dev/null; then
report PASS "解码样片" "30 秒内能解完,CPU 占用应低于 30%"
else
report FAIL "解码样片" "解码失败,检查样片编码与容器格式"
fi
else
echo "[SKIP] 解码样片 :: /data/selftest/sample.h264 不存在,拷一段现场录像再测"
fi
echo "== 5. 推理 =="
if command -v trtexec >/dev/null 2>&1 && [ -f /data/selftest/model.onnx ]; then
trtexec --onnx=/data/selftest/model.onnx --fp16 --duration=30 --warmUp=5000 >/dev/null 2>&1 \
&& report PASS "trtexec" "30 秒推理完成,用 --verbose 日志取实际吞吐" \
|| report FAIL "trtexec" "推理失败,先看是否缺 profile 或算子不支持"
else
echo "[SKIP] trtexec :: 缺少 trtexec 或 model.onnx"
fi
echo "== 6. 网络与时钟 =="
if ping -c 3 -W 2 223.5.5.5 >/dev/null 2>&1; then
report PASS "外网" "3 个包全通或部分通"
else
report FAIL "外网" "检查 4G 拨号、路由与 DNS"
fi
if command -v timedatectl >/dev/null 2>&1 && timedatectl show -p NTPSynchronized 2>/dev/null | grep -q "yes"; then
report PASS "时钟" "NTP 已同步,偏差需再用 chronyc tracking 看"
else
report FAIL "时钟" "时钟未同步,事件时间戳会错;检查 RTC 与 chrony"
fi
echo "== 汇总 =="
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ] || exit 1
脚本跑完不等于验收通过,还要在最终机箱、最终功耗档、最终并发数下补三项实测量:满载 30 分钟的 tegrastats 记录(温度、频率、功耗)、四路解码时的内存带宽占用、以及连续 24 小时的空跑稳定性。这三项才决定能不能发货。
9.2.4 采购与交付注意
硬件选型不止是技术判断,还有供应链判断。核心板、载板、散热件、相机模组往往来自不同供应商,任何一个交期跳票都会把整条线拖住。实际交付里要额外确认四件事:同一型号的分批次模组是否同料(内存颗粒、散热片、风扇型号);载板厂商是否提供长期供货承诺;功耗档是否需要改硬件跳线;以及备件策略——现场故障是整机更换还是板卡更换,决定你备什么货。
坑:第一,把厂商首页的峰值 TOPS 当预算,忽略 INT8 稀疏与 FP16 密集的差距,检测模型实际可用算力常常只有标称的一半以内。第二,用开发套件的散热方案直接量产,套件的均热板与风扇在密封机箱里完全不够,满载十分钟就开始降频。第三,自检只测「能跑」,不测「满载 30 分钟能跑多久」,把热问题留到夏天现场暴露。
9.3 模型转换与优化
训练拿到的是 .pt,设备要的是能在目标板上稳定跑几十毫秒的引擎。中间这条链路有三个必须做对的地方:导出时的图结构、编译时的精度与动态 shape、以及转换后的精度回归。任何一环跳过,最后的表现都是「模型换了个环境就掉点」,而且很难定位。
9.3.1 三段链路与四个校验点
先看整条链路和每个环节的校验点。校验点不是走过场,每个都对应一类真实故障:误差大说明导出有问题,解析失败说明算子不支持,构建慢说明 shape 给错了,mAP 掉说明精度策略不适用。
best.pt ──①导出──▶ edge.onnx ──②解析──▶ network ──③构建──▶ edge_fp16.engine
│ │ │ │
├─ 校验①:PyTorch 与 ONNX 原始输出 max_abs_diff < 1e-3
│ ├─ 校验②:onnx.checker 通过 + 输入输出名/形状/Dynamic 标记正确
│ └─ 校验③:不支持算子清单为空或已用插件/等价结构替换
└─ 校验④:引擎与 ONNX 在 500 张真实图上 mAP 回落 ≤ 阈值
9.3.2 ONNX 导出:opset、动态 shape 与前后处理
导出参数里有四个决定成败的选项,建议固定成模板,不要每次现调:
| 选项 | 建议值 | 理由 | 常见错误 |
|---|---|---|---|
| opset | 17 起,按 TensorRT 与框架支持矩阵选 | 太旧缺算子,太新解析器不支持 | 直接用框架默认新版本,解析器报 Unsupported operator |
| 空间尺寸 | 固定(如 640×640) | 固定 H/W 才能充分做层融合与内核选择 | H/W 都动态,引擎变慢还更占内存 |
| batch | 动态,由 profile 覆盖 1~N | 线上并发会变,固定 batch 浪费设备 | 全部固定成 1,多路时只能串行 |
| 前后处理 | 留在图外 | 归一化、letterbox、NMS 放在 Python/C++ 侧好调 | 把 NMS 塞进图里,换 TensorRT 版本后输出全空 |
导出的最小脚本如下。注意 model.float().eval() 不能省,导出时 BN 与 Dropout 的行为必须与推理一致:
# export_onnx.py —— 固定空间尺寸、动态 batch 的导出模板
import torch
from ultralytics import YOLO
PT = "runs/detect/edge/weights/best.pt"
OUT = "edge.onnx"
IMGSZ = 640
wrapper = YOLO(PT).model.float().eval()
dummy = torch.rand(1, 3, IMGSZ, IMGSZ)
with torch.no_grad():
ref = wrapper(dummy)
ref = (ref[0] if isinstance(ref, (list, tuple)) else ref).numpy()
torch.onnx.export(
wrapper,
dummy,
OUT,
opset_version=17,
input_names=["images"],
output_names=["output0"],
dynamic_axes={"images": {0: "batch"}, "output0": {0: "batch"}},
do_constant_folding=True,
)
print("exported:", OUT, "ref max:", float(ref.max()))
导出后立刻做一次结构与数值检查,不要等引擎构建失败再回头:
# 结构检查:opset 版本、输入输出名与形状
python -c "import onnx; m = onnx.load(\"edge.onnx\"); onnx.checker.check_model(m); print(\"opset\", m.opset_import[0].version); print([(i.name, [d.dim_param or d.dim_value for d in i.type.tensor_type.shape.dim]) for i in m.graph.input])"
# 数值检查:ONNX Runtime 输出与 PyTorch 输出逐元素对比
python -c "import numpy as np, onnxruntime as ort; s = ort.InferenceSession(\"edge.onnx\", providers=[\"CPUExecutionProvider\"]); print(s.get_inputs()[0].name, s.get_outputs()[0].shape)"
dynamic_axes 只开 batch 是最稳的做法。空间尺寸动态会迫使 TensorRT 为每个 profile 编译更多内核,常见结果是引擎体积变大、P95 时延反而比固定尺寸差 20% 以上。真需要多分辨率输入,就为 640 与 1280 各出一个引擎,按场景切换,而不是让一个引擎通吃。
9.3.3 TensorRT 11 引擎构建:FP16 与 INT8
构建前先用 trtexec 做一次可行性探针,不写引擎也能看到不支持的算子、各层耗时与显存峰值。这一步能把「模型结构问题」和「脚本问题」分开。注意 TensorRT 10 起工作区改用内存池参数,旧的 --workspace 已属遗留写法:
trtexec --onnx=edge.onnx --fp16 \
--minShapes=images:1x3x640x640 \
--optShapes=images:4x3x640x640 \
--maxShapes=images:8x3x640x640 \
--memPoolSize=workspace:4096MiB \
--verbose 2>&1 | tee trtexec_fp16.log
# 看三处:Unsupported operators / Engine built / Throughput
正式构建建议用 Python API,把 profile、精度、校准集固化成可入库的脚本。下面这份脚本同时支持 FP16 与 INT8,INT8 的校准集按「真实帧预处理后的 .npy」组织:
# build_engine.py —— ONNX → TensorRT 11 引擎(FP16 / INT8 二选一)
# 依赖:tensorrt、pycuda、numpy;Jetson 镜像通常已带 tensorrt,pycuda 用 apt 或 pip 安装
# 版本要求:TensorRT 版本必须与 JetPack 7.2.x 配套,跨版本引擎不能混用
import argparse
import os
import numpy as np
import pycuda.autoinit # noqa: F401 初始化 CUDA context
import pycuda.driver as cuda
import tensorrt as trt
class EntropyCalibrator(trt.IInt8EntropyCalibrator2):
"""INT8 熵校准:校准图必须是真实帧,且按现场分布采样。"""
def __init__(self, paths, batch_size, cache_path):
super().__init__()
self.paths = paths
self.batch_size = batch_size
self.cache_path = cache_path
self.index = 0
self.device_buffer = None
def get_batch_size(self):
return self.batch_size
def get_batch(self, names):
if self.index + self.batch_size > len(self.paths):
return None
chunk = self.paths[self.index:self.index + self.batch_size]
batch = np.stack([np.load(p) for p in chunk]).astype(np.float32)
self.index += self.batch_size
if self.device_buffer is None:
self.device_buffer = cuda.mem_alloc(batch.nbytes)
cuda.memcpy_htod(self.device_buffer, batch)
return [int(self.device_buffer)]
def read_calibration_cache(self):
if os.path.exists(self.cache_path):
with open(self.cache_path, "rb") as f:
return f.read()
return None
def write_calibration_cache(self, cache):
with open(self.cache_path, "wb") as f:
f.write(cache)
def build(onnx_path, engine_path, precision, batch, imgsz, calib_dir=None):
logger = trt.Logger(trt.Logger.WARNING)
builder = trt.Builder(logger)
network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(network, logger)
with open(onnx_path, "rb") as f:
if not parser.parse(f.read()):
for i in range(parser.num_errors):
print("parse error:", parser.get_error(i))
raise SystemExit("ONNX 解析失败:先补插件或改等价结构")
config = builder.create_builder_config()
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 << 30)
input_name = network.get_input(0).name
profile = builder.create_optimization_profile()
profile.set_shape(input_name,
(1, 3, imgsz, imgsz),
(batch, 3, imgsz, imgsz),
(batch, 3, imgsz, imgsz))
config.add_optimization_profile(profile)
if precision == "fp16":
config.set_flag(trt.BuilderFlag.FP16)
elif precision == "int8":
config.set_flag(trt.BuilderFlag.INT8)
config.set_flag(trt.BuilderFlag.FP16)
if not calib_dir:
raise SystemExit("INT8 必须提供真实帧校准集目录")
paths = sorted(os.path.join(calib_dir, n) for n in os.listdir(calib_dir) if n.endswith(".npy"))
config.int8_calibrator = EntropyCalibrator(paths, batch, "calib.cache")
serialized = builder.build_serialized_network(network, config)
if serialized is None:
raise SystemExit("构建失败:查 trtexec 日志里不支持的算子与显存是否够")
with open(engine_path, "wb") as f:
f.write(serialized)
print("engine saved:", engine_path)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--onnx", required=True)
ap.add_argument("--engine", required=True)
ap.add_argument("--precision", choices=["fp16", "int8"], default="fp16")
ap.add_argument("--batch", type=int, default=4)
ap.add_argument("--imgsz", type=int, default=640)
ap.add_argument("--calib-dir")
args = ap.parse_args()
build(args.onnx, args.engine, args.precision, args.batch, args.imgsz, args.calib_dir)
校准集的采样规则比算法本身更影响结果:按现场分布抽 500~2000 帧,覆盖白天/夜间、晴天/逆光、远/近景,小目标帧至少占 10%。用随机噪声或只用晴天大目标校准,INT8 的 scale 会严重偏移,上线后典型症状是「大目标正常、小目标成片消失」,而日志里引擎工作完全正常。
9.3.4 Jetson 侧 TensorRT 10 → 11 的迁移点
JetPack 7.2.x 配套的 TensorRT 已进入 11.x,存量项目大多从 8.x/10.x 迁过来。迁移的主要工作量不在构建,而在推理时代码:10.x 起旧的 bindings 与 enqueueV2 接口逐步移除,11.x 继续沿用新接口。写代码时直接学新接口,不要照抄旧博客。
| 旧写法(8.x / 10.x 早段) | 新写法(10.x 起,11.x 沿用) | 影响 |
|---|---|---|
engine.get_binding_name(i) |
engine.get_tensor_name(i) + engine.get_tensor_mode(name) |
需要区分输入/输出张量 |
context.set_binding_dimensions(...) |
context.set_input_shape(name, shape) |
动态 shape 必须先设再执行 |
bindings 数组 + executeV2 |
context.set_tensor_address(name, ptr) + execute_async_v3(stream) |
统一走地址 API |
enqueueV2(...) |
execute_async_v3(stream_handle) |
旧接口已移除 |
config.set_max_workspace_size(n) |
config.set_memory_pool_limit(MemoryPoolType.WORKSPACE, n) |
工作区改为内存池 |
trtexec --workspace=4096 |
trtexec --memPoolSize=workspace:4096MiB |
命令行参数同步变化 |
迁移检查清单只有三步:先用 dpkg -l | grep -i tensorrt 确认板上版本;再把推理代码里所有旧接口名全局搜一遍替换;最后用同一份 ONNX 分别构建、跑同一批图,比对引擎输出。引擎文件与 TensorRT 版本、GPU 架构强绑定,不能把 x86 上构建的引擎拷到 Jetson,也不能跨 JetPack 版本复用。具体废弃清单以 NVIDIA 的 TensorRT 11.x 迁移指南与 Release Notes 为准,JetPack 侧还要对照 L4T 版本说明。
9.3.5 转换后的精度回归
精度回归分两级做,缺一级都会漏问题。张量级看引擎输出与 ONNX 输出的最大绝对误差与分布偏移,用于定位「转换引入的数值偏差」;检测级看同一批标注图上的 mAP、小目标召回与置信度中位数,用于判断「业务能不能接受」。只做张量级会漏掉后处理与量化对框的影响,只做检测级则定位不到根因。
# precision_regression.py —— 张量级回归:引擎 vs ONNX,逐图比对原始输出
import argparse
import glob
import numpy as np
import onnxruntime as ort
import pycuda.driver as cuda
import tensorrt as trt
def load_engine(path):
logger = trt.Logger(trt.Logger.ERROR)
with open(path, "rb") as f:
engine = trt.Runtime(logger).deserialize_cuda_engine(f.read())
if engine is None:
raise SystemExit("引擎反序列化失败:TensorRT 版本或 GPU 架构不匹配")
return engine
def run_engine(engine, x):
context = engine.create_execution_context()
in_name = engine.get_tensor_name(0)
context.set_input_shape(in_name, x.shape)
out_name = engine.get_tensor_name(1)
out_shape = tuple(context.get_tensor_shape(out_name))
out = np.empty(out_shape, dtype=np.float32)
d_in = cuda.mem_alloc(x.nbytes)
d_out = cuda.mem_alloc(out.nbytes)
cuda.memcpy_htod(d_in, x)
context.set_tensor_address(in_name, int(d_in))
context.set_tensor_address(out_name, int(d_out))
stream = cuda.Stream()
context.execute_async_v3(stream_handle=stream.handle)
cuda.memcpy_dtoh_async(out, d_out, stream)
stream.synchronize()
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--engine", required=True)
ap.add_argument("--onnx", required=True)
ap.add_argument("--npy-dir", required=True)
ap.add_argument("--tol", type=float, default=1e-2)
args = ap.parse_args()
engine = load_engine(args.engine)
sess = ort.InferenceSession(args.onnx, providers=["CPUExecutionProvider"])
in_name = sess.get_inputs()[0].name
files = sorted(glob.glob(args.npy_dir + "/*.npy"))
if not files:
raise SystemExit("没有找到 .npy 样本")
diffs = []
for p in files:
x = np.load(p).astype(np.float32)
if x.ndim == 3:
x = x[None, ...]
a = run_engine(engine, np.ascontiguousarray(x))
b = sess.run(None, {in_name: x})[0]
diffs.append(float(np.abs(a - b).max()))
diffs = np.asarray(diffs)
print("samples:", len(diffs), "max:", round(float(diffs.max()), 6),
"p95:", round(float(np.percentile(diffs, 95)), 6), "tol:", args.tol)
if diffs.max() > args.tol:
raise SystemExit("张量级超差:先查预处理、输出节点与量化精度策略")
if __name__ == "__main__":
main()
检测级回归用与第 10 章相同的评测脚本,喂 500 张带标注的真实图,对比 FP32 基准、FP16 引擎、INT8 引擎三组结果。经验阈值如下(指标为 mAP@0.5,具体模型与场景必须以自己测得的数据为准):
| 精度档 | 相对 FP32 加速(经验值) | mAP@0.5 允许回落 | 小目标召回允许回落 | 使用建议 |
|---|---|---|---|---|
| FP32 | 1.0× | — | — | 首次验收基准,不作生产默认 |
| FP16 | 1.6~2.2× | ≤ 0.5 个百分点 | ≤ 1.0 个百分点 | 生产默认档,绝大多数检测模型可用 |
| INT8(真实校准集) | 2.5~3.5× | ≤ 1.5 个百分点 | ≤ 2.0 个百分点 | 算力吃紧时用,超阈值就退回 FP16 |
| INT8(随机/单一场景校准) | 不采用 | 常见掉 5~15 个百分点 | 不可用 | 禁止上线,先补校准集 |
回落超阈值时的处理顺序是:先补校准集(INT8 场景)→ 再检查预处理一致性(letterbox 与归一化参数)→ 再试混合精度(保留检测头 FP32)→ 最后才考虑改模型结构。不要一上来就换模型,多数情况问题在数据与前后处理。
坑:第一,把 NMS 导出进 ONNX,引擎换版本后 EfficientNMS 插件不匹配,输出数量全为 0,而推理本身「成功」。第二,INT8 校准集用随机张量或只取晴天样本,小目标在量化后成批消失。第三,只比 PyTorch 与 ONNX,不比引擎与 ONNX,TensorRT 的层融合与精度策略差异到引擎侧才暴露。第四,空间尺寸与 batch 同时动态,引擎为每种组合编译内核,体积与时延双输。
9.4 多路视频流水线
单路跑通不等于多路能跑。多路场景下系统从「算一道题」变成「调度一个车间」:解码、预处理、推理、后处理、编码五道工序共享 GPU、内存带宽和 CPU,任何一道工序慢下来,压力都会沿着队列往上游传。设计目标不是让每一帧都处理,而是让延迟有界、负载可控、过载时优雅降级。
9.4.1 流水线骨架与背压
先把数据流画清楚。下图中每个方框之间都是队列,队列必须有界;没有上界的队列在过载时只会把内存吃光,然后把进程 OOM 掉,表现比丢帧糟糕得多。
RTSP/文件流(N 路)
│ 每路一个抓帧线程或进程
▼
[硬件解码 NVDEC] ──▶ [有界帧队列 maxsize=4~8]
│ 满 → 丢最旧帧(保新弃旧)
▼
[凑批 BATCH_WAIT_MS] ──▶ [TensorRT 推理]
│ │
│ ▼
│ [后处理 + 跟踪]
│ │
▼ ▼
[结果队列(有界)] ──▶ [告警/推流/落盘]
关键不变式:端到端时延 ≈ 解码 + 排队 + 推理 + 后处理 + 网络,其中排队项必须 ≤ 1 个批次的时长
「丢帧优先于堆积」是这条流水线的第一原则。视频是实时数据,第 100 帧的检测结果对第 300 帧没有价值;如果队列已经积压 2 秒,正确动作是丢掉旧帧、只处理最新的,让端到端延迟回到秒级以内。反过来说,如果业务要求「一帧都不能丢」,那就不能靠队列硬扛,必须降分辨率、降帧率或加设备——这是容量问题,不是代码问题。
9.4.2 解码、预处理与凑批
解码尽量交给硬件。Jetson 上用 GStreamer 的 NVDEC 插件,x86 上可以用 NVDEC 或 QSV;解码后的帧保持在 NV12 或 GPU 内存里,避免每帧一次 cvtColor 到 RGB 的全图拷贝。四路 1080p 每秒各 25 帧,全图色彩转换的 CPU 开销足以吃掉一个核。
| 环节 | 主要资源 | 经验开销(1080p,单路) | 调优手段 |
|---|---|---|---|
| 硬件解码 | VPU 通道数 | 2~5 ms/帧,CPU 占用 < 10% | 用 NVDEC;限制单进程解码路数;断流重连加退避 |
| 预处理 | GPU / 内存带宽 | resize 到 640 约 1~3 ms | 用 GPU 侧 resize;固定 letterbox 参数;避免 CPU 全图拷贝 |
| 推理 | GPU 算力 | 视模型,FP16 引擎 8~30 ms/批 | 凑批;固定 shape;INT8(先过精度回归) |
| 后处理 | CPU | 2~8 ms/帧 | NMS 阈值与 top-k 收敛;用向量化实现 |
| 跟踪 | CPU | 检测耗时的 5%~15% | 只对高置信框做关联;按需跳帧跟踪 |
| 编码推流 | VPU / 网络 | 2~6 ms/帧 | 只推 ROI 或降码率;用硬件编码 |
凑批是提升吞吐最直接的手段,但会引入等待。批等待上限取 5~15 ms 比较合适:太小凑不满,太大时延抖动明显。下面的多进程骨架把「抓帧/解码」「凑批推理」「结果消费」拆开,每路队列有界,队列满时丢最旧帧:
# pipeline_mp.py —— 多路视频流水线骨架(抓帧 → 有界队列 → 凑批推理 → 结果消费)
# 说明:骨架用 OpenCV 抓帧便于本地复现;上板后把 capture_worker 换成 GStreamer/NVDEC 管道,
# 把 engine.infer 换成 TensorRT execute_async_v3,其余结构不变。
import multiprocessing as mp
import time
import cv2
import numpy as np
FRAME_Q = 4 # 每路帧队列上限:满了丢最旧帧
RESULT_Q = 64 # 结果队列上限:满了丢结果并计数,绝不阻塞推理
BATCH_WAIT_MS = 10 # 凑批等待上限
BATCH_MAX = 8
URLS = ["rtsp://cam-01/live", "rtsp://cam-02/live", "rtsp://cam-03/live", "rtsp://cam-04/live"]
def capture_worker(url, out_q, every_n=2, drop_counter=None):
cap = cv2.VideoCapture(url)
idx = 0
while True:
ok, frame = cap.read()
if not ok:
cap.release()
time.sleep(1.0) # 断流重连退避,不要忙轮询
cap = cv2.VideoCapture(url)
continue
idx += 1
if idx % every_n:
continue # 抽帧:25 fps 抽到 12.5 fps,推理压力减半
item = (time.time(), frame)
try:
out_q.put_nowait(item)
except Exception:
try:
out_q.get_nowait() # 保新弃旧
except Exception:
pass
try:
out_q.put_nowait(item)
except Exception:
pass
if drop_counter is not None:
drop_counter.value += 1
def inference_worker(qs, result_q, engine, batch_wait_ms=BATCH_WAIT_MS):
pending = []
while True:
for q in qs:
try:
pending.append(q.get_nowait())
except Exception:
continue
if not pending:
time.sleep(batch_wait_ms / 1000.0)
continue
batch, pending = pending[:BATCH_MAX], pending[BATCH_MAX:]
t0 = time.time()
frames = np.stack([cv2.resize(f, (640, 640)) for _, f in batch])
out = engine.infer(frames) # 目标板:TensorRT 动态 batch 引擎
infer_ms = (time.time() - t0) * 1000
for (ts, _), boxes in zip(batch, out):
try:
result_q.put_nowait((ts, boxes, infer_ms, len(batch)))
except Exception:
pass # 结果堆积时丢结果,保推理节拍
if __name__ == "__main__":
ctx = mp.get_context("spawn")
qs = [ctx.Queue(maxsize=FRAME_Q) for _ in URLS]
result_q = ctx.Queue(maxsize=RESULT_Q)
drop = ctx.Value("i", 0)
procs = [ctx.Process(target=capture_worker, args=(u, q, 2, drop), daemon=True)
for u, q in zip(URLS, qs)]
procs.append(ctx.Process(target=inference_worker, args=(qs, result_q, engine), daemon=True))
for p in procs:
p.start()
while True:
ts, boxes, infer_ms, batch_n = result_q.get()
e2e_ms = (time.time() - ts) * 1000
# 这四行就是最小埋点集合:端到端时延、推理时延、批大小、丢帧累计
print(f"e2e={e2e_ms:.0f}ms infer={infer_ms:.0f}ms batch={batch_n} drops={drop.value}")
骨架里有两个刻意的设计。第一,抓帧进程与推理进程分离:cap.read() 是阻塞调用,放在同一进程里会让推理被 IO 拖慢。第二,结果队列满了直接丢结果而不是阻塞,推理节拍不能被下游的推流或落盘拖住。上板后建议把帧数据放到共享内存或 GPU 显存池,避免 multiprocessing.Queue 的序列化开销——1080p 一帧约 6 MB,pickle 一次就是十几毫秒。
9.4.3 跟踪、推流与存储
多路场景里跟踪不只是「给目标一个 ID」,还承担去重和告警抑制:同一处异物在连续 30 帧里被检出,只应该产生一条告警。常用做法是检测后接轻量跟踪器(ByteTrack、BoT-SORT 一类),按轨迹生命周期(连续命中 N 帧才确认)过滤抖动。跟踪的输入是检测框,因此检测抖动会直接传导成 ID 跳变,检测侧的后处理阈值要调稳。
推流与存储按「必要性」分级:原始视频只在事件前后各留 10~20 秒的环形缓冲,检测到事件才切片上传;实时预览用低码率低分辨率推流,不占推理带宽;结构化结果(框、类别、置信度、时间戳、设备 ID)单独走消息通道。这样做的好处是网络带宽主要花在事件数据上,而不是 24 小时不间断的视频流。
9.4.4 压测与容量确认
上线前必须做一次阶梯压测,确认「能跑几路」是实测出来的,不是算出来的。方法:从 1 路开始,每 5 分钟加一路,记录 P95 端到端时延、丢帧率、GPU 利用率、温度、内存;当丢帧率超过 2% 或 P95 时延超过业务上限时,上一档就是容量上限。压测时输入要用真实码流(含断流、花屏、分辨率变化),不要用循环播放的本地文件。
坑:第一,用无界队列「保证不丢帧」,结果内存一路涨到 OOM,重启后丢的是整段数据。第二,把解码和推理放在同一个线程里,解码阻塞直接拖慢推理,帧率看起来「莫名只有一半」。第三,只看平均时延,不看 P95/P99,告警延迟在峰值时超标而看板一切正常。第四,多进程里每个进程各自初始化 CUDA context,显存被重复占用,四路就 OOM——CUDA context 要按进程规划,推理集中在一个进程内做。
9.5 端云协同的任务切分
端和云不是「谁替代谁」,而是两种成本结构:边缘的算力贵、带宽便宜(本地总线)、时延确定;云端的算力便宜、带宽贵、时延不确定但可扩展。切分的本质是把每个任务放到它的成本最低处,同时满足时延与合规约束。
9.5.1 谁干什么:一条默认分界线
默认分界线是:边缘做实时、闭集、可穷举的判断;云端做开放语义、跨设备关联和存档。闭集指类别在训练时就定死(人、车、安全帽、绝缘子破损),边缘用检测模型几十毫秒就能出结果;开放语义指类别要用自然语言临时指定(「找出所有遮挡消防通道的物体」),这需要 SAM 3.1 这类可提示概念分割或视觉语言模型,只能放云端。
以低空巡检为例,一个完整的端云链路是:边缘对每路视频做 YOLO26 级检测与初筛(置信度 + 跟踪确认 + ROI 裁剪),把疑似事件裁剪图与结构化元数据上传;云端用 SAM 3.1 按文本概念做复核与精细掩码,跨设备做轨迹关联与空间叠加,最后把确认事件、原图、掩码和评测记录一起存档,回流成训练数据。第 02 章讲的巡检链路,落地时就是这条分界线的具体形态。
9.5.2 四维切分决策表
每个任务都过一遍四个维度:时延、带宽、成本、合规。四列里任何一列出现「不可接受」,就换位置;都不冲突时,按成本最低处放。
| 任务 | 时延要求 | 带宽特征 | 成本特征 | 合规约束 | 建议位置 |
|---|---|---|---|---|---|
| 闭集目标检测 | 毫秒~百毫秒级 | 只传结构化结果,小 | 边缘算力贵但一次投入 | 原始视频不出场可加分 | 边缘 |
| 多目标跟踪与去重 | 与检测同节拍 | 不额外占带宽 | 边缘 CPU 即可 | 无 | 边缘 |
| 概念级分割复核 | 秒级可接受 | 传 ROI 裁剪图,中等 | 云端按量付费,峰值可扩 | 敏感区域需脱敏 | 云端 |
| 跨设备轨迹关联 | 分钟级 | 传轨迹摘要,小 | 云端数据库与计算便宜 | 轨迹属敏感数据 | 云端 |
| 大模型报告生成 | 分钟级 | 传结构化事件,小 | 云端 | 报告可能外发 | 云端 |
| 原始视频归档 | 不要求实时 | 极大,必须限流 | 边缘存储贵,云端对象存储便宜 | 按数据分级决定 | 云端(脱敏后) |
| 紧急制动/避障类决策 | 十毫秒级 | 不出设备 | 只能边缘 | 不允许上云 | 边缘(禁止上云) |
这张表里最容易被忽略的是最后一行:安全相关的闭环决策不允许依赖网络。任何「等云端确认再动作」的设计,在断网时都会变成事故。云端只能做复核、记录和优化,不能进实时控制回路。
9.5.3 带宽预算:先算再传
带宽是端云方案最先撞上的墙。上传带宽按下面的式子估算,注意峰值出现在告警风暴时,不是平均值:
持续上传带宽 = 路数 × 抽帧率 × 上传比例 × 单帧字节数
峰值上传带宽 = 持续带宽 × 告警风暴系数(经验值 3~5)
单帧字节数 = 宽 × 高 × 通道 × 压缩比(JPEG 质量 85 时约 0.15~0.30 MB / 1080p 全图)
ROI 裁剪后 = 按 ROI 面积占比折算(通常为全图的 5%~15%)
判据:峰值上传带宽 ≤ 链路可用上行带宽 × 0.8
一个 20 路、25 fps、每 30 帧传 1 帧、传全图的算例:持续带宽约为 20 × 25/30 × 0.25 MB/s ≈ 4.2 MB/s(约 33 Mbps),告警风暴时峰值按 4 倍算就是 133 Mbps——大多数 4G 上行(实际 10~30 Mbps)根本扛不住。改成只传 ROI 裁剪图(占全图 10%)后,持续带宽降到约 3.3 Mbps,峰值约 13 Mbps,才进入可实施区间。这就是「边缘初筛」在带宽维度的真实价值:省下的不是算力费,是链路费。
9.5.4 事件打包与断点续传
边缘上传的单位不是视频,是事件包:裁剪图 + 结构化元数据 + 哈希 + 时间戳。事件包小、可重试、可去重,断网时先落盘,恢复后按顺序补传。下面是最小实现:
# event_uploader.py —— 事件包落盘 + 断网补传(至少一次语义,靠 event_id 去重)
import hashlib
import json
import os
import time
import uuid
import cv2
SPOOL = "/data/edge/spool"
def pack_event(device_id, camera_id, frame, boxes, ts=None):
"""把一次告警打包成可上传的事件目录:image.jpg + meta.json"""
os.makedirs(SPOOL, exist_ok=True)
event_id = uuid.uuid4().hex
event_dir = os.path.join(SPOOL, event_id)
os.makedirs(event_dir, exist_ok=True)
image_path = os.path.join(event_dir, "image.jpg")
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 88])
if not ok:
raise RuntimeError("JPEG 编码失败")
with open(image_path, "wb") as f:
f.write(buf.tobytes())
digest = hashlib.sha256(buf.tobytes()).hexdigest()
meta = {
"event_id": event_id,
"device_id": device_id,
"camera_id": camera_id,
"captured_at": ts or time.time(),
"boxes": boxes,
"sha256": digest,
"schema": "edge-event/1",
}
with open(os.path.join(event_dir, "meta.json"), "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False)
return event_dir
def flush_spool(upload_fn, limit=50):
"""按时间顺序补传;上传成功后删目录,失败保留等下次。"""
if not os.path.isdir(SPOOL):
return 0
entries = [os.path.join(SPOOL, d) for d in os.listdir(SPOOL)]
dirs = sorted(entries, key=os.path.getmtime)
sent = 0
for d in dirs[:limit]:
try:
upload_fn(d) # 幂等:服务端按 event_id 去重
except Exception:
break # 网络未恢复,保持顺序,先不跳过
for name in os.listdir(d):
os.remove(os.path.join(d, name))
os.rmdir(d)
sent += 1
return sent
两个细节决定这套机制能不能在野外活下来。第一,event_id 在打包时生成并复用,服务端按它做幂等去重,避免「上传成功但响应丢失」导致的重复告警。第二,落盘目录要有总量上限(如 2 GB)和淘汰策略(按时间删最旧),否则断网三天会把磁盘写满,连带影响系统。
9.5.5 云端复核的接口约定
端云之间要固定三样东西,否则每次联调都像重新对接:标签体系(边缘的类别 ID 与云端概念词表的映射表,随模型包一起分发)、坐标系(像素框 + 相机内参 + 位姿,或直接用经纬度,按第 08 章的时空基准约定)、时间基准(统一 UTC 毫秒,设备侧必须 NTP 同步,偏差超 500 ms 拒绝入库)。
坑:第一,边缘与云端各自维护一套类别表,云端复核把「绝缘子破损」映射成了「异物」,错误率看着高其实是标签对不上。第二,上传不设总量上限,断网三天写满磁盘,系统服务被拖死。第三,事件时间用设备本地时间且未同步,跨设备关联时同一目标被判成两个。第四,把原始视频全量上云,带宽费远超算力费,而业务其实只需要告警片段。
9.6 模型分发与灰度
模型上线不是「替换一个文件」。现场设备散落在不同网络、不同硬件批次、不同 JetPack 版本上,一次全量替换,出问题就是全网回滚。分发系统要解决四件事:包可验证、版本可回滚、断网可续传、上线可灰度。
9.6.1 模型包结构
一个模型包 = 引擎文件 + 元数据 + 校准与精度信息 + 签名。元数据必须包含设备矩阵(哪些硬件、哪个 TensorRT 版本可用),因为引擎与平台强绑定,跨平台复用必然失败。推荐结构如下:
model-pkg/
├── manifest.json # 版本、设备矩阵、哈希、依赖、指标门槛
├── model_fp16.engine # x86 与 Jetson 各自一个包,不混装
├── labels.txt # 类别 ID → 名称,必须与云端映射表一起版本化
├── preprocess.json # 输入尺寸、归一化参数、letterbox 规则
├── calibration.json # INT8 校准集描述与统计(无 INT8 可省略)
├── accuracy.json # 离线评测结果:mAP、小目标召回、各场景分组
└── SIGNATURE # 对以上文件清单的签名(minisign / ed25519)
manifest 是分发系统的契约,字段少了会在灰度阶段付出代价。下面是一份可用的示例,注意 hardware_matrix 与 min_agent_version 两道闸门,以及 rollback_to 的显式声明:
{
"schema": "edge-model-manifest/1",
"name": "insulator-defect-det",
"version": "1.4.2",
"created_at": "2026-11-02T08:00:00Z",
"task": "detect",
"hardware_matrix": [
{"platform": "jetson-orin-nx", "jetpack": "7.2.x", "tensorrt": "11.x", "engine": "model_fp16.engine"},
{"platform": "jetson-agx-orin", "jetpack": "7.2.x", "tensorrt": "11.x", "engine": "model_fp16.engine"}
],
"min_agent_version": "2.3.0",
"input": {"shape": [1, 3, 640, 640], "dynamic_batch_max": 8, "color": "RGB", "mean": [0, 0, 0], "std": [255, 255, 255]},
"classes": ["insulator", "defect_flashover", "defect_broken"],
"metrics": {"map50": 0.741, "small_recall": 0.612, "eval_set": "field-2026q3-500"},
"artifacts": [
{"path": "model_fp16.engine", "sha256": "5f1c...9a", "bytes": 48234496},
{"path": "labels.txt", "sha256": "b73e...41", "bytes": 96}
],
"rollback_to": "1.4.1",
"gray": {"strategy": "device_hash", "percent": 5, "shadow": false}
}
9.6.2 版本、回滚与灰度
版本号用语义化版本,但含义要落到模型行为上:主版本变类是标签体系或输入规范变了(必须重跑下游适配);次版本是模型能力提升(可灰度);修订号是修复(可全量)。回滚必须离线可用:设备本地始终保留上一版引擎与 manifest,回滚指令到达时直接切符号链接,不依赖重新下载。
灰度按设备而不是按请求分桶。设备 ID 做哈希取模,同一个设备始终落在同一桶,避免 A/B 之间频繁切换导致指标不可比。灰度期间同时观察四类指标:新模型的推理时延、事件量变化、告警准确率抽检、以及崩溃与重启次数。任何一类异常超过阈值,自动切回 rollback_to 版本并告警。
影子模式(shadow)是灰度的一种特殊形态:新模型与旧模型并行推理,但新模型的结果只写日志、不产生告警。它适用于「无法用离线评测判断线上效果」的改动,代价是设备要多跑一份推理,因此只在算力有余量的点位上开,且设置自动退出时间(例如 72 小时)。
| 灰度阶段 | 设备比例 | 观察时长 | 放量判据 | 回滚判据 |
|---|---|---|---|---|
| 影子 | 5%~10% | 72 小时 | 结果分布与旧模型一致,无异常崩溃 | 崩溃率上升或推理时延 P95 超预算 20% |
| 小流量 | 5% | 7 天 | 告警准确率抽检不低于旧版,事件量波动 < 15% | 抽检准确率下降超 3 个百分点 |
| 扩量 | 30% → 100% | 每档 3 天 | 指标平稳,无新增现场工单 | 任一档出现异常即回退 |
9.6.3 断网续传与完整性校验
野外设备下载几百 MB 的引擎包,中途断网是常态。分发方案必须支持 HTTP Range 续传、分块校验与原子切换:下载到临时目录、校验 sha256 与签名、解压到新版本目录、最后原子切换符号链接。任何一步失败都保留旧版本继续服务。
设备侧建议用「拉」而不是「推」:设备定期按版本号查询、按需下载,云端不需要维护长连接,NAT 后面也能工作。拉取间隔按 5~30 分钟随机抖动,避免上千台设备在同一秒请求打垮分发服务。
坑:第一,把 TensorRT 引擎当普通文件跨平台分发,Jetson 与 x86、不同 JetPack 版本的引擎互相不兼容,加载即失败。第二,回滚依赖重新下载旧版本,断网时就只能眼看着新版本持续出错。第三,灰度按请求比例分桶,同一台设备在新旧模型间来回切换,指标彻底失真。第四,只校验 sha256 不验签名,分发通道被劫持时无法发现。
9.7 监控与运维
现场设备最怕的不是坏,而是坏得无声无息。监控设计的目标只有一条:在用户投诉之前,让问题在运维面板上可见。为此要把指标压到最小集,采集本身不能吃掉算力,断网时数据不能丢。
9.7.1 最小指标集
指标不在多,在能形成因果链。下面八项覆盖「设备活着 → 还能算 → 算得及时 → 结果送达」四段链路,缺任何一段都会出现「看板全绿但现场没数据」的情况。
| 指标 | 采集方式 | 告警阈值(经验值,需按点位校准) | 处置动作 |
|---|---|---|---|
| GPU 利用率 | tegrastats 的 GR3D_FREQ |
持续 10 分钟 > 95% | 降并发、抽帧,查是否有异常进程 |
| 核心温度 | tegrastats 的 tj |
> 85℃ 预警,> 90℃ 停机保护 | 查风扇与机箱,按温度降频降并发 |
| 推理队列长度 | 进程内计数 | 持续 5 分钟 > 2 个批次 | 降输入分辨率或抽帧,查下游阻塞 |
| 端到端时延 | 帧时间戳到结果时间戳 | P95 > 业务上限 × 1.2 | 分环节拆解:解码、排队、推理、后处理 |
| 丢帧率 | 抓帧计数与入队计数之差 | 5 分钟内 > 5% | 查解码错误、网络抖动、队列配置 |
| 告警时延 | 事件产生到云端可见 | P95 > 10 秒 | 查上传队列与云端入库链路 |
| 磁盘占用 | df 与 spool 目录大小 |
> 85% | 清 spool、限日志、查是否有大文件堆积 |
| 设备心跳 | 每分钟一次上报 | 连续 3 个周期缺失 | 电话确认现场,再查网络与电源 |
看板按四块组织,与上面的因果链一一对应:资源块(GPU 利用率、温度、功耗、磁盘)、流水线块(队列长度、丢帧率、批大小、端到端时延 P50/P95)、业务块(事件量、告警时延、确认率)、分发块(版本分布、灰度进度、回滚次数)。每块只留 4~6 个指标,超过就说明还没想清楚哪个指标能触发动作。看板要能按点位、型号、版本三个维度下钻,否则「平均正常」会长期掩盖单点故障。
9.7.2 采集与上报:先落盘再上传
采集进程要满足三个约束:开销小于 1% CPU、不依赖云端可用、数据按时间顺序可补传。做法是本地追加写 JSONL,按分钟聚合后推送,成功后清空,失败保留。下面的脚本只做骨架,真实项目建议接 OpenTelemetry 或 Prometheus 生态:
# telemetry.py —— 最小指标采集与上报(1 分钟一包,断网保留本地)
import json
import os
import time
import urllib.request
METRICS_PATH = "/data/edge/metrics.jsonl"
ENDPOINT = os.environ.get("EDGE_TELEMETRY_URL", "https://cloud.example.com/v1/edge/metrics")
def parse_tegrastats(line):
"""从 tegrastats 一行里取 RAM、GPU 频率、结温;字段缺失就跳过。"""
parts = line.split()
out = {}
for i, tok in enumerate(parts[:-1]):
if tok == "RAM":
cur, total = parts[i + 1].replace("MB", "").split("/")
out["ram_mb"] = int(cur)
out["ram_total_mb"] = int(total)
elif tok == "GR3D_FREQ":
out["gpu_pct"] = int(parts[i + 1].replace("%", ""))
elif tok.startswith("tj@"):
out["tj_c"] = float(tok.replace("tj@", "").replace("C", ""))
return out
def snapshot(device_id, pipeline_stats, tegrastats_line):
rec = {"ts": int(time.time() * 1000), "device_id": device_id, "schema": "edge-metrics/1"}
rec.update(pipeline_stats) # queue_len / drop_rate / infer_p95_ms / alarm_delay_ms
rec.update(parse_tegrastats(tegrastats_line))
with open(METRICS_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
return rec
def flush():
if not os.path.exists(METRICS_PATH):
return
with open(METRICS_PATH, encoding="utf-8") as f:
rows = [line for line in f if line.strip()]
if not rows:
return
body = ("[" + ",".join(rows) + "]").encode("utf-8")
req = urllib.request.Request(ENDPOINT, data=body, headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=5).read()
os.remove(METRICS_PATH) # 成功才清空,失败保留等下一分钟
except Exception:
pass
if __name__ == "__main__":
while True:
stats = {"queue_len": 0, "drop_rate": 0.0, "infer_p95_ms": 0, "alarm_delay_ms": 0}
with open("/tmp/tegrastats.txt", encoding="utf-8") as f:
last = f.read().splitlines()[-1]
snapshot("edge-001", stats, last)
flush()
time.sleep(60)
注意两个实现细节:写文件用追加模式并定期轮转,避免单文件无限增长;上报失败不清空,宁可重复上报(服务端按 ts + device_id 去重),也不要丢数据。
9.7.3 处置手册要点
告警发出来只是开始,处置手册决定恢复时间。手册按「先排除环境、再排除容量、最后怀疑模型」的顺序写,每一条都要给可直接执行的命令:
- 先看心跳与时钟:设备离线先查电源与 4G,时钟偏差大于 100 ms 先修 NTP,否则事件时间不可信。
- 再看温度与频率:
tegrastats里 tj 与 GR3D_FREQ 是否同时异常,先解决散热与功耗档,再调软件。 - 再看队列与丢帧:队列持续满说明入口过载,先降抽帧率与分辨率,不要先改模型。
- 模型相关问题优先回滚:最近 7 天做过分发或灰度的,先切回
rollback_to,保留现场再分析。 - 每次处置留「最后遗言」:重启前把最近 200 行日志、当前配置、引擎哈希写到一个固定文件,下次开机自动上报。
坑:第一,只看平均时延,P95 已经超标而均值正常。第二,采集脚本每分钟
nvidia-smi全量查询,自己就吃掉几个点的 CPU。第三,告警阈值按实验室数据设定,现场夏天普遍触发,运维直接关闭告警。第四,日志与指标都只存本地,设备失联后无从判断最后状态。
9.8 用 DeepSeek Harness 管边缘交付
边缘交付里有大量重复动作:写自检脚本、核对镜像与模型版本、按固定顺序排障、整理交付报告。这些动作规则明确、输出可验收,适合交给 DeepSeek Harness(dsh)执行。它的定位是 agent harness:在仓库目录里读写文件、跑命令、按验收标准自检;仍是 developer preview,会有破坏性变更,脚本里调用的参数要以 dsh --help 为准。
9.8.1 生成部署与自检脚本
不要对 agent 说「帮我写个部署脚本」。把约束、路径、验收命令一次写清,产出才能直接用。下面这份任务书是 9.2.3 那份自检脚本的实际生成方式:
在 tools/ 下生成 edge_selftest.sh,要求:
1. 只读:不安装任何包、不修改系统配置、不使用 sudo;
2. 依次检查 L4T 版本、JetPack 组件、CUDA、TensorRT、NVDEC 插件、模型引擎 sha256、NTP 同步;
3. 每项输出 PASS/FAIL 与一句可执行的下一步排查命令,最后汇总 PASS/FAIL 数量并以非零退出码表示失败;
4. 兼容 Jetson Orin 与 Thor,路径用变量集中在文件头部;
5. 生成后自己用 bash -n 做语法检查,再贴出脚本行数与检查结果。
验收标准要可执行,不能是「看着不错」。对这类脚本,验收命令就是它自己:在目标板上跑一遍,失败项必须能和现场实际对上;跑不通的项要有 SKIP 分支而不是直接崩溃。
9.8.2 headless 做镜像与模型一致性校验
「镜像里的 TensorRT 版本」与「模型引擎构建时的版本」不一致,是边缘部署最高频的隐性故障。这类检查规则固定、输出结构化,最适合放进 CI:镜像构建阶段导出环境信息,模型构建阶段导出 manifest,再用 headless 模式让 dsh 做交叉核对,只输出 JSON 交给流水线判定。
# ci_edge_contract.sh —— CI 中用 headless 模式核对镜像与模型的一致性
set -euo pipefail
MODEL_DIR="dist/model"
IMAGE_REPORT="dist/image/env.json" # 镜像构建阶段生成:platform / jetpack / tensorrt / cuda / arch
dsh --headless --json --permission-mode read-only --add-dir . "
读取 ${MODEL_DIR}/manifest.json 与 ${IMAGE_REPORT},逐项核对:
1. manifest.hardware_matrix 是否覆盖镜像的 platform + jetpack + tensorrt;
2. manifest.artifacts 的 sha256 是否与 ${MODEL_DIR} 下的文件一致;
3. 引擎构建时记录的 GPU 架构是否与镜像目标架构一致。
只输出 JSON:{pass: bool, mismatches: [{field, expected, actual}]};不要修改任何文件。" \
> dist/contract_check.json
python -c "import json,sys; d=json.load(open(\"dist/contract_check.json\")); sys.exit(0 if d.get(\"pass\") else 1)"
这段脚本的价值在于把「人肉核对版本号」变成流水线闸门:版本对不上,构建直接失败,而不是等现场加载引擎时报一句看不懂的错误。注意 --permission-mode read-only 是故意的——只读模式让它无法改文件,也限制了误操作面。
9.8.3 把排障流程沉淀成技能
排障顺序每次口口相传,迟早会走样。把 9.7.3 的判定顺序写成技能,下次会话直接加载,agent 就按同一套顺序查。技能放在 $DSH_HOME/skills 下的独立目录,入口文件是 SKILL.md:
---
name: edge-triage
description: 边缘设备掉帧、时延升高、失联时的固定排障顺序
---
# 边缘排障
## 输入
- 现场反馈的现象(一句话)
- tegrastats 最近 200 行、metrics.jsonl 最近 1 小时、最近一次分发记录
## 判定顺序
1. 心跳与时钟:在线?NTP 偏差是否小于 100 ms?
2. 温度与频率:tj 是否大于 85℃?GR3D_FREQ 是否被限?
3. 功耗档:nvpmodel 当前模式是否与交付基线一致?
4. 队列与丢帧:queue_len、drop_rate 是否持续增长?
5. 模型版本:近 7 天是否灰度,引擎哈希是否变化?
## 输出格式
- 结论:一句话 + 证据行号
- 下一步:一条可直接执行的命令
- 是否回滚:是/否 + 理由
技能的判定顺序必须来自真实故障复盘,不是拍脑袋;每加一条,都要注明它对应哪次现场问题。这样技能才会随项目变厚,而不是变成一份没人看的文档。
坑:第一,让 dsh 直接改生产配置或执行危险命令,正确做法是只读模式产出脚本与报告,人工确认后再执行。第二,任务书里没有验收命令,agent 交出「看起来能跑」的东西,实际缺分支处理。第三,把几十万行日志整段塞进上下文,真正有用的就两百行,先本地筛再喂。第四,脚本里硬编码 dsh 的最新参数,升级后失效且不报错,改成先查
dsh --help或固定版本。
9.9 本章小结
- 边缘部署先排约束再谈方案:功耗、算力、内存带宽、散热、可靠性五项都要有数字判据,判据不能是形容词。
- 硬件选型看三个硬指标:持续算力(含散热折损)、内存带宽、硬件解码通道数;厂商 TOPS 是峰值口径,不能直接当预算。
- 新板子先跑 30 分钟自检:驱动、CUDA、TensorRT、NVDEC、推理、网络时钟逐项确认,把环境问题与模型问题在第一天分开。
- 转换链路三段四校验:PyTorch 到 ONNX 看 opset 与动态 shape,ONNX 到引擎看算子与 profile,引擎到业务看精度回归。
- 精度回归分两级:张量级看 max_abs_diff,检测级看 mAP 与小目标召回;FP16 回落 ≤ 0.5 个点、INT8 ≤ 1.5 个点是经验阈值。
- TensorRT 10 到 11 的迁移重点是推理接口:bindings 改张量名、
enqueueV2改execute_async_v3、工作区改内存池;引擎跨平台、跨版本不可复用。 - 多路流水线的第一原则是丢帧优先于堆积:队列有界、保新弃旧、凑批等待 5~15 ms、结果不阻塞推理节拍。
- 端云切分按四维判断:时延、带宽、成本、合规;安全闭环决策禁止依赖网络,带宽预算要按告警风暴峰值算。
- 模型分发要可验证、可回滚、可续传、可灰度:manifest 带硬件矩阵与哈希,回滚离线可用,灰度按设备哈希分桶,影子模式先跑 72 小时。
- 监控只采八项最小集,先落盘再上传;排障按「环境 → 容量 → 模型」顺序,优先回滚而不是现场调参。
- dsh 的用法是「人定规则、机器执行、脚本验收」:生成自检脚本、CI 里做镜像与模型一致性闸门、把排障顺序沉淀成
SKILL.md。
9.10 动手练习
练习 1:约束预算表
输入:一块 Orin NX 16G、48 W 供电、4 路 1080p 25 fps 相机、最终机箱为无风扇密封盒。
要求:用 9.1.4 的脚本算出可用功耗、持续算力、并发上限与推理时延预算;给出至少两处必须改的设计。
验收:每一项都有数字与来源(规格书或实测),并指出哪一项先到顶(功耗 / 算力 / 解码通道)。
练习 2:三段转换与精度回归
输入:一个训练好的检测模型与 500 张带标注的现场图。
要求:完成 ONNX 导出、FP16 与 INT8 引擎构建(INT8 用真实帧校准),跑张量级与检测级回归。
验收:张量级 max_abs_diff 小于 1e-2;FP16 mAP 回落 ≤ 0.5 个点、INT8 ≤ 1.5 个点;不达标时能给出定位结论(校准集、预处理或算子)。
练习 3:多路流水线压测
输入:9.4 的多进程骨架 + 至少 3 路真实码流。
要求:做阶梯压测(1 路起,每 5 分钟加一路),记录 P95 时延、丢帧率、GPU 利用率、温度。
验收:给出容量上限结论与瓶颈环节;丢帧率超过 2% 时,系统仍能保持端到端时延有界而不是持续增长。
练习 4:模型包与灰度
输入:练习 2 的引擎与一份设备清单(不同 JetPack 版本)。
要求:写 manifest、打包含签名、实现按设备哈希分桶的灰度与离线回滚,并用影子模式跑一轮。
验收:错误设备矩阵的包被拒绝安装;断网中断下载后能续传并通过 sha256 校验;回滚在断网状态下完成且版本可查。
练习 5:排障技能
输入:一次真实的掉帧或失联事件日志(可用压测数据构造)。
要求:按 9.7.3 的顺序写一份 SKILL.md,并用 dsh headless 跑一遍,要求输出结论、证据行与下一步命令。
验收:结论能被日志证据支持;下一步命令真实可执行;把该技能用在第二次同类事件上,定位时间比第一次缩短一半以上。
9.11 延伸阅读
- NVIDIA Jetson 官方文档(L4T、功耗模式与多媒体)
- JetPack SDK 下载与组件说明
- TensorRT 开发者文档(构建、精度与插件)
- TensorRT 开源仓库(示例与工具)
- ONNX 官网(规范与工具链)
- NVIDIA DeepStream SDK 文档(多路视频分析参考架构)
- Ultralytics YOLO26 文档
- DeepSeek Harness 文档站