1. 先说说那个1000行的rk3588_config.json是怎么把我逼疯的
我手里这块RK3588开发板,最初是拿来做边缘AI盒子原型验证的。工程里躺着一个叫config.json的文件,一开始只有几十行,存模型路径、视频流地址、推理置信度阈值这些,清爽得很。但项目跑了几个月之后,这个文件膨胀到上千行,每次打开编辑器都要等JSON格式化插件转半天,改一个参数要在几十层嵌套里翻来翻去找路径。
真正把我逼疯的不是文件大,而是**“不敢改”**。现场的设备不是只有一台,同一个RK3588核心板,配过五六种不同底板,有的底板把PWM风扇接在GPIO4上,有的接在GPIO7上;有的现场用USB摄像头,有的用RTSP海康球机;有的项目部署YOLOv8跑人形检测,有的项目要跑车牌识别。所有差异全压在一个JSON里之后,就变成了一场灾难:
- 板卡驱动参数和模型算法参数堆在一起,换板子时要小心翼翼滚动页面找对应片段;
- 摄像头分辨率、RTSP地址、推理输入尺寸、anchors这种完全无关的东西混在同一个对象里,改错一个字段整个链路静默出错;
- 团队三个人同时改这个文件,Git合并时冲突区全是JSON大括号,谁都不敢动别人的字段;
- 最要命的是,配置里没有注释,当时写
"mode": 1的人三个月后自己都忘了mode=1是"连续抓拍"还是"运动检测"。
这不是我一个人的问题。边缘AI项目的典型特征是硬件平台固定但外设多变、模型固定但部署场景多变、算法框架固定但业务需求多变。一旦把"可变的东西"和"不应变的东西"全锁进一个文件,这个文件就会变成项目的单点故障源。后来我花了两个晚上把整个配置体系重构了一遍,拆完后再回头想这件事,其实核心问题就一句话:配置体系的设计边界,应该跟上项目真实变化的维度,而不是把所有变化都塞进一个筐里。
这一篇就把整套思路和落地步骤完整写出来。面向的读者是:正在用RK3588做边缘AI项目、部署过或者准备部署YOLO系列模型、对项目维护成本和长期可扩展性有要求的开发者。哪怕你现在只有一个原型Demo,我也建议尽早按这个思路搭配置体系,后面省下的是成倍的Debug时间。
2. 边缘AI配置该按什么维度拆:板卡、模型、服务三层解耦
重构配置体系的第一步,不是急着写文件,而是先想清楚一件事:你的项目里到底有哪些"变化维度"。拆错维度比不拆更难受,等于把所有JSON的缺点换成了YAML的缺点继续犯。
2.1 我最终确定的三个配置域
反复梳理我自己的项目之后,我把所有可变参数归成了三类:板级硬件参数、模型算法参数、服务业务参数。这个划分不是随手拍的,而是跟代码生命周期严格对应。
第一类是板级硬件配置。它描述的是"代码跑在一块什么样的硬件上"。RK3588这块SoC非常特殊,它不只是CPU算力强,还集成了6 TOPS的NPU、8K视频编解码的VPU/MPP、以及非常丰富的IO资源。但同样是RK3588,在不同底板上的引脚定义、外设型号、供电策略、散热方案完全不同。这类参数包括PWM风扇的GPIO编号、温控阈值、摄像头 sensor 型号、视频输出接口、以太网PHY配置、外接陀螺仪/I2C地址,以及RK3588特有的NPU调度策略、AMP双系统核心分配等。这类配置的特点是变了就必须重新编译或者至少重启驱动层,且不会随业务逻辑频繁变化。
第二类是模型算法配置。它描述的是"当前跑的是什么模型、推理时怎么预处理和后处理"。RK3588上部署RKNN模型时,你需要告诉Rockchip的RKNN Runtime:模型文件路径、输入图像的mean/std归一化参数、输入分辨率、量化类型(INT8/FP16)、NPU核掩码、以及模型输出后处理要用的anchors或者类别名列表。这些参数跟硬件没关系,同一个RKNN模型既可以在RK3588上跑,也可以在RK3568上跑,只要NPU驱动版本兼容。但它跟业务也不直接相关,换一个模型就等于换一套算法参数。这类配置的特点是变更频率中等,且变更时一般要连模型文件一起换。
第三类是服务业务配置。它描述的是"这套代码在当前场景下要干什么活"。包括视频流来源(RTSP地址/USB设备节点)、检测区域ROI坐标、报警灵敏度、告警推送的Webhook地址、MQTT topic、HTTP服务端口、日志级别、录像存储路径和时长策略等。这类配置是现场实施人员和甲方业务方最爱改的,也是每个设备之间差异最大的部分。它的特点是变更频率最高,且变更时一般不需要重启整个程序而是期望热生效。
这三类配置之间是有依赖关系的,但依赖关系应该是单向的、清晰的:
- 服务业务配置引用模型算法配置里定义的"使用哪个模型";
- 模型算法配置引用板级硬件配置里定义的"NPU用哪几个核";
- 板级硬件配置不依赖任何上层配置。
2.2 按"变更频率"和"影响范围"两个轴验证拆分是否合理
拆完之后怎么验证自己没有拆错?我的经验是用两个轴来量:变更频率和影响范围。
| 配置域 | 典型变更频率 | 变更影响范围 | 典型变更人 |
|---|---|---|---|
| 板级硬件 | 极低(换底板/换散热方案才变) | 驱动、系统初始化 | 硬件工程师/系统工程师 |
| 模型算法 | 中(迭代模型、调精度才变) | 推理引擎、前后处理 | 算法工程师 |
| 服务业务 | 高(现场调参、业务调整) | 应用逻辑、输出行为 | 实施人员/后端开发 |
如果你发现某个参数在"模型算法"里改了之后需要同步改"服务业务"里另一个地方才能生效,说明这两个参数之间应该有引用关系而不是各自维护一份拷贝。这也是配置体系最常见的坑:参数不重复,一个参数只能有一个真源(Single Source of Truth)。比如模型输入分辨率,算法配置里定义一次,业务配置里通过${model.input_size}引用它,而不是在业务配置里再写一个224x224。
按这个维度拆完,前面提到的那一堆痛点基本迎刃而解。换底板时只需要动板级硬件配置;换模型时只需要动模型算法配置,并确认业务配置里引用的模型ID没有失效;现场调整报警阈值时只需要改业务配置,永远不会误碰驱动参数。
3. 为什么我最终放弃JSON改用YAML:这不是矫情是刚需
把配置拆成三个文件之后,我本来想继续用JSON。毕竟RK3588的很多官方SDK和rknn-toolkit2的例子都是JSON风格,能用现成的最省事。但真正上手写之后发现,JSON作为一种数据交换格式非常好,作为一种人写人读的配置格式非常差。这不是感觉问题,是硬伤。
3.1 JSON在纯配置场景下的三个硬伤
硬伤一是没有注释。也许有人会说JSON不需要注释,靠字段名自解释。在配置只有50行的时候确实可以,但边缘AI项目的配置里到处是需要上下文才能理解的数值:"threshold": 0.5到底是NMS阈值还是置信度阈值?"core_mask": 0x7为什么是这个掩码值?"stream": 0对应的是MIPI-CSI0还是CSI1?字段名根本解释不了这些东西。我强烈建议给每个配置项写清设计意图,而JSON的格式规范禁止注释,社区里那些加_comment字段的玩法又丑又不安全。
硬伤二是不支持锚点引用。边缘AI配置里大量存在"同一份参数被多处复用"的情况:三个摄像头都走同一套RTSP认证信息、四个模型的预处理参数都是同一种归一化方式。JSON里你只能复制粘贴,一旦要改就得全局替换,替换漏了就是隐蔽Bug。YAML的锚点(&和*)能够优雅地解决这个问题。
硬伤三是键名被引号包裹导致可读性极差。JSON的每个键必须用双引号包起来,配置嵌套超过四层之后,肉眼扫读的成本急剧上升。YAML用缩进表达层级,同样的内容少了一堆引号和大括号,排查差异的时候一眼就能看出问题。
3.2 三款主流配置格式的横向对比
我其实也认真考虑过TOML,毕竟它在INI基础上扩展了很多能力,Python生态里tomllib也是标准库。但TOML的嵌套结构表达能力比YAML弱一些,表达RK3588这类多级硬件配置时会出现很深的下划线拼接表名,阅读体验并不好。下面是实际对比:
| 维度 | JSON | YAML | TOML |
|---|---|---|---|
| 支持注释 | 否 | 是 | 是 |
| 支持多文档 | 否 | 是(---分隔) | 否 |
| 支持锚点/引用 | 否 | 是(&/*) | 否 |
| 嵌套表达 | 清晰 | 清晰 | 一般 |
| 类型支持 | 基础类型 | 基础类型+日期等 | 基础类型 |
| Python生态支持 | 标准库 | PyYAML/ruamel.yaml | 标准库tomllib |
| 人为书写容错性 | 低(缺逗号即报错) | 中(缩进敏感) | 较高 |
最终选YAML,最重要的两个理由就是注释和锚点。缩进敏感的问题可以通过好用的编辑器插件和CI阶段的格式校验来兜底,后面我会展开讲。
3.3 老JSON配置不浪费:写个转换脚本平滑过渡
可能你已经有一堆写好的JSON配置了,不要急着删。我在重构时写了一个几十行的小脚本,把原来那个千行JSON按照提前定义好的字段白名单拆成三个YAML文件。做法是给每个字段打标(board、model、service),然后按标签分别导出。
#!/usr/bin/env python3 """json_to_yaml_split.py - 将旧版单一config.json拆分为三个YAML配置""" import json import sys import yaml # 字段归属映射:旧JSON里的key -> 新配置域 FIELD_MAP = { # 板级硬件域 "pwm_fan_gpio": "board", "thermal_threshold": "board", "camera_sensor": "board", "npu_core_mask": "board", "amp_cpu_partition": "board", # 模型算法域 "model_path": "model", "mean": "model", "std": "model", "input_size": "model", "anchors": "model", # 服务业务域 "rtsp_url": "service", "roi": "service", "alert_webhook": "service", "mqtt_topic": "service", "log_level": "service", } def split_config(src_path: str): with open(src_path, "r", encoding="utf-8") as f: raw = json.load(f) grouped = {"board": {}, "model": {}, "service": {}} unknown_keys = [] for key, value in raw.items(): target = FIELD_MAP.get(key) if target is None: unknown_keys.append(key) continue grouped[target][key] = value # 未知字段单独导出,避免静默丢失 if unknown_keys: print(f"[WARN] 以下字段未被映射: {unknown_keys}", file=sys.stderr) with open("unknown_keys.json", "w", encoding="utf-8") as f: json.dump({k: raw[k] for k in unknown_keys}, f, indent=2, ensure_ascii=False) for domain, content in grouped.items(): with open(f"config/{domain}.yaml", "w", encoding="utf-8") as f: yaml.safe_dump(content, f, allow_unicode=True, sort_keys=False) if __name__ == "__main__": split_config("legacy_config.json")这个脚本的价值不在于它有多聪明,而在于把拆分过程从"手动复制粘贴"变成了"可重复执行的确定性流程"。迁移完之后我把这个脚本留在仓库的tools/目录里,后来又有新设备接入时,如果对方只给了一个单文件JSON,我还能用它做一次快速导入。整个过程大概两小时,其中半小时是写脚本,一个半小时是在给未知字段归类。
4. 写一个"长不歪"的配置加载器:目录规范、校验与热加载
配置文件整理好了,如果没有一个靠谱的加载器,一切都白搭。所谓"长不歪",指的是这套机制能在项目跑了两三年、配置从三个文件扩展到三十个文件之后,仍然保持结构清晰、排错容易。这一节讲我最终确定的加载方案。
4.1 目录规范与加载顺序:默认值、板级覆盖、环境变量覆盖
我把所有配置放在项目的config/目录下,内部结构固定为:
config/ ├── base/ # 出厂默认值,只随代码版本更新 │ ├── board.yaml │ ├── model.yaml │ └── service.yaml ├── boards/ # 不同底板/产品型号的板级覆盖 │ ├── rk3588-evb.yaml │ ├── rk3588-industrial.yaml │ └── rk3588-nas.yaml ├── models/ # 不同模型算法配置 │ ├── yolov8s_people.yaml │ ├── yolov8s_plate.yaml │ └── nanogpt_chat.yaml ├── runtime/ # 现场业务配置,由实施人员在设备上修改 │ └── service.local.yaml ├── schema/ # 各配置域的JSON Schema │ ├── board.schema.json │ ├── model.schema.json │ └── service.schema.json └── manifest.yaml # 声明当前设备用哪套板卡配置、哪个模型配置加载顺序的设计是整个体系的关键,我严格遵守"从通用到具体"的覆盖原则:
- 加载
base/下三个默认配置文件; - 读取
manifest.yaml,根据其中的board_id加载boards/下对应的板级覆盖文件,深合并到默认硬件配置上; - 根据
model_id加载models/下对应的模型算法配置; - 加载
runtime/service.local.yaml,覆盖默认服务业务配置; - 最后再读环境变量,比如
RK3588_BOARD=industrial,RKAI_SERVICE_CAMERA_ID=2,环境变量优先级最高,方便在容器部署和调试时快速覆盖指定字段。
这套覆盖机制解决的实际场景是:同一份代码部署到10台设备上,每台设备只需要复制一份runtime/service.local.yaml改几个业务参数,其余配置全部复用。更重要的是,你永远不会改到"模板"里的东西,下次OTA升级代码时base/被覆盖也不会丢失现场个性化设置。
4.2 用JSON Schema在启动的第一秒就报错
配置如果写错了,最怕的不是报错,而是不报错然后带病运行。有一次我把input_size的height和width写反了,640x640变成640x360,程序正常启动、模型正常加载,但检测框全部偏移。这种问题排查起来非常费时。所以在加载器里增加Schema校验是非常必要的。
我用JSON Schema来描述每个配置域的结构要求。YAML加载进来之后先转成Python字典,再用jsonschema库做校验,不通过就拒绝启动:
import yaml import jsonschema from jsonschema import validate def load_validate(path: str, schema_path: str) -> dict: with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) with open(schema_path, "r", encoding="utf-8") as f: schema = json.load(f) try: validate(data, schema) except jsonschema.ValidationError as e: raise RuntimeError( f"配置校验失败: {path}\n" f"字段路径: {list(e.path)}\n" f"错误信息: {e.message}" ) from e return data以模型算法配置的Schema为例,关键的约束是:
{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "model_id": { "type": "string" }, "model_path": { "type": "string" }, "input_size": { "type": "object", "properties": { "width": { "type": "integer", "minimum": 64, "maximum": 4096 }, "height": { "type": "integer", "minimum": 64, "maximum": 4096 } }, "required": ["width", "height"] }, "mean": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }, "std": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 } }, "required": ["model_id", "model_path", "input_size", "mean", "std"] }有了这套校验之后,启动阶段就能拦截大部分低级错误。但这里要提醒一句:Schema校验要渐进式来,不用第一天就写到滴水不漏。先覆盖必填字段和类型,那些"边界值合理性"检查(比如threshold必须在0到1之间)留到后面慢慢补,否则重构期间天天对着报错日志改Schema,很快你就烦了。
4.3 运行时热加载与配置变更事件
边缘AI设备一旦部署到现场,业务配置肯定是希望"改了立刻生效"的,总不能每次调个ROI区域都重启一次进程。我在加载器上做了一层热加载机制,原理很简单:用文件系统的mtime/事件通知来触发重新加载,重载完成后发一个变更事件,让各业务模块按需响应。
import time from pathlib import Path import threading import yaml class ConfigWatcher(threading.Thread): def __init__(self, config_path: str, reload_callback, poll_interval: float = 2.0): super().__init__(daemon=True) self.config_path = Path(config_path) self.reload_callback = reload_callback self.poll_interval = poll_interval self._last_mtime = self.config_path.stat().st_mtime self._running = True def run(self): while self._running: time.sleep(self.poll_interval) try: mtime = self.config_path.stat().st_mtime except FileNotFoundError: continue if mtime != self._last_mtime: self._last_mtime = mtime with open(self.config_path, "r", encoding="utf-8") as f: new_conf = yaml.safe_load(f) self.reload_callback(new_conf) def stop(self): self._running = False业务侧怎么响应?我的做法是:检测算法流水线在收到model域变更事件时,会把当前推理任务排空,重新初始化RKNN上下文再恢复;视频流模块在收到service域变更事件时,会重新连接RTSP;IO控制模块监控的是板级配置但不响应热加载——因为硬件参数变更最好还是重启进程更安全,强行热拔插驱动很容易把外设搞到半死状态。
板级参数不容许热加载,这是我踩过坑之后加的原则。曾经试过通过热加载去改PWM风扇的GPIO编号,改完后驱动层没有重新导出sysfs节点,旧GPIO还在输出,新GPIO不生效,风扇转速完全失控,机器当场过热死机。硬件参数就老实走"改配置-重启-验证"的流程,别贪图热加载的便利。
4.4 按产品型号选择配置:manifest.yaml 是入口
最后说说manifest.yaml。它的作用相当于"这桶配料用哪张菜单":
# manifest.yaml device_id: box-2024-001 board_id: rk3588-industrial model_id: yolov8s_people service_profile: site-aboard_id对应boards/rk3588-industrial.yaml,model_id对应models/yolov8s_people.yaml,service_profile则匹配runtime/site-a.service.local.yaml。每台设备只需要维护一个几行的小文件,其他配置全部自动装配。这样新上线一台设备时,实施人员不用理解整个配置目录的细节,只需要回答三个问题:硬件型号是什么?跑什么模型?现场项目是哪套?然后改manifest即可。
5. RK3588上那些"藏进JSON里的特殊配置":NPU、AMP与硬件外设
这一节单独拿出来讲,是因为RK3588这颗芯片真的不是一颗普通处理器。它的异构架构导致配置维度比一般的ARM Linux板卡多出好几个层次。如果你拿一个树莓派的配置思维去套RK3588,大概率会漏掉关键参数,然后在运行阶段被各种"幽灵问题"折磨。
5.1 NPU与RKNN模型的配置:归一化、量化与核掩码
RK3588的NPU有3个核心,理论算力6 TOPS。用rknn-toolkit2导出RKNN模型时,很多算子相关的参数已经固化在模型文件里了,但运行时仍然有一批参数需要配置。最常见的几个是:
mean/std:图像进入NPU前的归一化参数,必须在配置里写清楚。同一个YOLOv8模型,训练时用的归一化方式跟rknn-toolkit2推理时的默认值不一定一致,写错会导致检测精度骤降,但程序不会报错。quantized_dtype:模型的量化类型(INT8/FP16),决定了NPU推理时的计算模式,也影响精度和速度。npu_core_mask:允许模型跑在哪几个NPU核上。这个参数在单模型场景下无所谓,但多模型并发时非常关键。边缘AI盒子上经常同时跑两个模型(比如一个做区域入侵检测、一个做人脸抓拍),如果不配置核掩码,两个模型可能会抢占同一个核,导致推理延迟剧烈抖动。我的经验是给实时性要求高的主模型分配2个核,给辅助模型分配1个核。rknn_batch_size:由于RKNN模型固定输入shape,多路视频流要拼batch推理时,一次送几张图也是配置项。
这些参数放在模型算法配置里再合适不过。示例:
# config/models/yolov8s_people.yaml model_id: yolov8s_people model_path: /opt/edge-ai/models/yolov8s_people.rknn input_size: width: 640 height: 640 mean: [0.0, 0.0, 0.0] std: [255.0, 255.0, 255.0] npu_core_mask: 0x7 # 使用全部3个NPU核 quantized_dtype: int8 rknn_batch_size: 4 class_names: ["person", "bicycle", "car", ...] confidence_threshold: 0.35 nms_threshold: 0.455.2 AMP模式下CPU与MCU核的分工配置
RK3588支持AMP(Asymmetric Multi-Processing),即一个芯片上同时跑标准的Linux(A核)和一个实时操作系统RTOS(M核)。这在工业控制、机器人场景里非常常见:A核跑Linux和AI推理,M核跑实时运动控制和IO采样。AMP模式下,配置体系里必须有一个独立的块来描述核心分区和通信机制。
我的做法是在板级硬件配置里单独开一个amp段:
# config/boards/rk3588-industrial.yaml amp: enabled: true linux_cpuset: "0-3" # 4个A核跑Linux rtos_cpuset: "4-7" # 4个核心跑RTOS rpmsg: endpoint: "/dev/rpmsg_ctrl0" shared_mem_size: 1048576 # 1MB共享内存,用于A核和M核快速交换数据 startup_policy: "linux_first" # Linux先启动,再引导RTOS为什么AMP配置必须独立出来而不是写在服务业务里?因为分区改动直接决定代码运行环境。如果A核只剩4个CPU,你的多线程推理调度策略就完全不同了。我见过一个项目,把AMP的cpu_partition写在了某个临时文件里,有一次重刷系统时被覆盖成默认值,RTOS核和Linux核抢资源,系统卡到SSH都连不上,最后只能串口进uboot恢复。从此这类配置全放进板级域,并且写死只随系统镜像版本走。
5.3 PWM温控风扇与电源策略的配置:别和算法参数混在一起
RK3588满负载跑YOLOv8时发热量非常可观,散热方案是量产边缘AI盒子绕不开的一环。常见做法是用PWM驱动风扇,配合温度传感器做闭环调速。PWM调速涉及的参数有:风扇GPIO编号、PWM频率、占空比与温度对应的曲线点、甚至要不要加滞后避免风扇频繁启停。
我遇到过最坑的一次是,一个项目的风扇控制函数里直接硬编码了GPIO4_PWM0,后来换了一个底板,风扇接在GPIO3_PWM1上,代码层面完全感知不到,风扇转速上不去导致频繁降频。这个经验让我把所有硬件IO相关的配置全部收敛到板级配置里,并且要求硬件变更时必须同步更新配置和Schema。
# config/boards/rk3588-industrial.yaml fan: enabled: true pwm_chip: "/sys/class/pwm/pwmchip0" pwm_channel: 1 gpio_enable: 7 temperature_curve: - [45, 0] # 45度时占空比0 - [60, 80] # 60度时80% - [75, 255] # 75度及以上满转 hysteresis: 3 # 3度滞后,防止风扇在阈值附近反复启停电源策略同理。RK3588支持DVFS动态调压调频,板厂会根据散热和供电条件设置不同的CPU最高频率和NPU最高频率。不合理的频率配置会触发瞬时过流掉电。这些参数放到板级配置中,并且与power_governor策略(performance/ondemand/schedutil)一起维护。
5.4 MPP视频编解码通道参数单独管理
RK3588自带强大的VPU硬件编解码能力,很多边缘AI盒子会做"硬编码视频流推送到平台"的功能,也就是基于RK3588硬编码的实时视频监控系统。这时候MPP编解码的参数也值得单独立块。注意,它和业务层的"要不要录像""录像存几天"要分开:
- MPP硬件参数放板级域:编码格式(H.264/H.265)、码率控制模式(CBR/VBR)、GOP间隔、编码profile/level;
- 业务录像策略放服务域:录像时长、轮转策略、存储路径、断网补录开关。
# 板级硬件配置里的MPP编解码参数 mpp: encoder: codec: "h264" bitrate: 4096 framerate: 25 gop: 50 rc_mode: "cbr" profile: "high" level: 4.2 decoder: max_width: 3840 max_height: 2160 buffer_count: 4把MPP参数放在板级域的原因很直接:它受限于硬件能力,一块底板上的内存带宽、DDR频率、VPU跑多快,直接决定了能不能支撑4K/60fps编码。业务层再怎么改需求,也不能绕过硬件物理极限。
6. 老项目迁移实录:从单JSON到配置体系的完整步骤
最后分享完整的迁移路径。如果你手头已经有一个跑着的项目,不要推倒重来,按下面四步走,每一步都小步可验证。
6.1 盘点所有被硬编码的"隐形配置"
在拆配置之前,先在整个代码库里搜一遍,找出所有不该出现在代码里的魔法值。常见的几类:
- 音频设备路径:
/dev/snd/pcmC0D0c,换USB声卡后设备号会变; - I2C地址:
0x68、0x50,接陀螺仪或EEPROM时每个板子可能不一样; - 串口波特率:
/dev/ttyS9+ 115200,但有些RS485外设要用9600; - 各类超时时间:
timeout=30,现场弱网环境下需要调整。
我当时做了一件事:在代码里给所有硬编码打LOG_WARNING,跑一轮完整流程,把日志里所有"疑似魔法值"过了一遍。这个步骤很枯燥但极其重要,因为魔法值才是配置体系最大的敌人,不把它们全部暴露出来,后面配了体系也还是叠床架屋。
6.2 按"横向切分三步法"拆分配置
所谓"横向切分三步法",是我自己总结的经验,核心是一次只动一个维度,避免大爆炸式重构。
第一步,先按配置域拆文件,不改变内部字段名和层级。也就是第二章的json_to_yaml_split.py做的事。这一阶段的目标是让三个YAML文件能完整还原旧JSON的语义,程序加载逻辑暂时还可以用旧代码的JSON读取方式,或者写一个临时兼容层把三个YAML合并成旧结构的dict。
第二步,再调整字段归属。把那些分配错域的字段挪到正确的位置,每次挪一个字段就跑一遍全链路验证。重点检查有没有代码在读取旧路径的位置,比如原来代码是config["rtsp"]["url"],现在变成了config["service"]["rtsp"]["url"],所有读取点的路径都要同步更新。
第三步,引入Schema校验和引用关系。把重复值的复制粘贴改成锚点引用,把业务字段对模型字段的依赖改成配置内引用,然后开启启动时校验。这一步做完,整个体系才算真正闭环。
6.3 兼容旧JSON:两套加载方式并行一个月
我强烈建议在切换期保留旧的legacy_config.json,并且做一个"双读校验":每次启动时同时加载旧JSON和新YAML,比对关键字段是否一致,不一致就打印警告。这意味着你在迁移过程中任何配置改错了,旧路线都能帮你兜底,同时日志会告诉你哪里对不上。我当时并行跑了整整两周,现场设备稳定后才彻底移除旧JSON。
# 双读校验逻辑的简化版本 def load_with_compat_check(legacy_path: str, yaml_paths: dict): legacy = json.load(open(legacy_path)) new_conf = {k: yaml.safe_load(open(p)) for k, p in yaml_paths.items()} # 挑几个关键字段逐个比对 checks = [ ("rtsp_url", legacy["rtsp_url"], new_conf["service"]["rtsp"]["url"]), ("model_path", legacy["model_path"], new_conf["model"]["model_path"]), ("pwm_gpio", legacy["pwm_fan_gpio"], new_conf["board"]["fan"]["gpio_enable"]), ] for name, old_val, new_val in checks: if old_val != new_val: print(f"[COMPAT][WARN] {name}: old={old_val} new={new_val}")6.4 迁移中一定会踩的几个坑
YAML的Tab键和缩进。这是新手最容易踩的坑,YAML强制用空格缩进,任何Tab都会导致解析失败。团队协作时最好统一编辑器配置,让tab键自动输出空格。
锚点不能跨文件复用。YAML的锚点只在当前文件内生效,如果你在board.yaml里定义的&fan_params想在model.yaml里引用,对不起做不到。我的做法是抽一个common.yaml,里面放全局共享的默认值片段,然后通过加载器先加载common再加载具体配置,深合并。或者,更简单的做法是在需要复用的时候,直接用配置加载器里的变量展开,比如${board.fan.pwm_channel}。
Schema校验大爆炸。刚开始把全量Schema加起来那天,项目所有环境全部启动失败,因为我们之前的JSON里很多字段是可选的但Schema里写成了required。解决方法是先用宽松的Schema(只查类型不查必填),运行一段时间稳定后再逐步收紧,避免一次性落地太多规则。
热加载NPU模型上下文崩溃。如果业务侧监控的是模型配置域,热加载时千万不能直接创建一个新的RKNN上下文去覆盖旧的。RKNN驱动在Rockchip的平台上有资源申请和释放的固定顺序,正确做法是:停止推理线程 -> 显式释放旧context -> 加载新模型 -> 重建推理线程。漏掉任何一步都可能导致NPU上下文泄漏或coredump。
日志里要带配置版本号。我在每个配置文件头部加了一个revision字段,加载之后打进启动日志。现场排查问题时,看到日志第一行的配置版本就能确认设备实际在跑哪一套参数,而不是靠猜。
写在最后:几个让我"真香"的小习惯
整个配置体系跑通之后,有几个操作习惯让我切实觉得"回不去了",这里分享给你。
第一个是配置文件里写变更记录。每个YAML文件头部加history数组,每次改动填一行时间、作者、改动原因。这在多团队协作时尤其有用,Git blame能查到是谁改的,但Git blame查不到"这个人当时为什么这么改",而history能记录当时的现场约束。
第二个是现场设备改配置之前先打包旧配置。在设备上执行任何配置变更前,自动执行一个tar czf backup_config_$(date +%s).tar.gz config/,遇到问题随时回滚。投入的成本极小,但在现场事故中能救命。
第三个是用Git管理一份配置"种子仓库"。所有新设备出厂时从种子仓库克隆一套默认配置,每台设备有自己的runtime/分支。这样总部审计现场配置版本时,直接比对分支差异就能知道哪些设备做了个性化调整、调整了什么,不用一台一台登录上去看。
配置体系这个东西,说到底不是为了炫技,也不是为了强迫症,而是为了让你三个月后重新打开一个项目时,还能在一杯茶的时间内搞清楚"这台设备为什么会这样工作"。RK3588本身就是一个配置维度非常丰富的平台,不让它的复杂性爆炸,唯一的路就是把复杂性隔离在清晰的边界之后。按照上面的思路去搭,至少我的项目目前从"人人不敢碰的config.json"变成了"新人半天就能上手的配置库",这就够了。