ZZBX / NOTES ← 全部笔记
AutoSAR · Agent Engineering · Python

如何为 AI Agent 设计一个
工程级 Skill

以 AUTOSAR DBC 通信矩阵审计为例,完整走一遍设计过程:模型与代码的边界划在哪、skill 该长什么形态、以及如何证明「模型真的用得对」。零依赖,61 个测试。

2026-11-04 ◆ 约 14 分钟 ◆ Python / stdlib ◆ 源码开放
61个单元测试
11类结构检查
14个差异代码
0第三方依赖
一个 DBC 文件进去,101 条发现出来的处理流程:解析保留元数据、六类确定性检查、按级别输出
两份真实矩阵进去,101 条发现出来 —— 其中最关键的两条,正是文本 diff 叫不出名字的那两条

边界:什么该交给模型

大模型擅长判断,不擅长数数。问它「一个没有值表的 2 bit 信号大概是什么意思」,它能给出有用的推断;让它核对 379 个信号在 256 bit 的帧里有没有两位重叠,你会得到一个看起来像答案的东西。

这就是整个设计前提。工程任务天然分两类,混在一起两边都会退化:

该交给模型 该交给代码
判断这几种检查里哪个更要紧 两个信号是否占同一位
把一条发现翻译成它导致的现象 遍历 379 个信号比对位区间
看出 changelog 和文件对不上 从注释里提取 CRC DataID
决定先跑什么后跑什么 每次都算出同一个正确答案

Skill 就是这条缝。它要提供足够结构,让模型知道该做什么;又要足够确定,让数字永远不取决于模型。把这条缝划准,才是真正的工程活 —— 下面的内容基本都是围绕它。

设计前提

必须可复现的 → 进代码。需要上下文和判断的 → 留给模型。一个让模型去做算术的 skill 不算 skill,只能算祈祷。

领域知识落成确定性代码

第一个决策不是「写个解析器」,而是「审计到底需要哪些数据」。这个问题决定架构,而答案把最省事的现成方案排除掉了。

cantools 是个好库,我在别处也用。但它是为解码设计的,而它丢弃的恰好是审计需要的四类数据:

审计需要的数据 cantools .messages 本工具
报文周期 GenMsgCycleTime✗✓
值表 VAL_(枚举文字含义)部分✓
注释 CM_✗✓
报文/信号属性 BA_✗✓

丢掉这些不是细节。没有值表,2 bit 信号只读到数字 2,你不知道它代表 On 还是 Fault;没有周期,就没法和时间预算核对。在审计里,元数据就是正文。

所以解析器手写:纯标准库,格式定义的每个字段都保留。

把规范里最容易错的那部分写进代码

DBC 看着像按行组织的格式,其实不是。对正确性影响最大的一处细节是 Motorola 位序:39|8@0+ 不是「从 39 位往后读」,起始位指的是最高位,游标朝 LSB 方向走。

BYTE 1 ← byte 1 bit 15 14 13 12 11 10 9 8 SG_ Bus_Off_Count : 15|4@0+ start bit 15 = MSB walk → LSB (bit 12) pos + 15 if pos % 8 == 0 pos - 1 otherwise 当成 LSB 索引读, 每个信号都会落错位
把规范的约定写对,重叠检测才可信 —— 位索引错了,会在一片物理错误的布局上给出「通过」

这是领域经验真正兑现的地方。这类知识模型并非完全不知道,而是你不能承受它每次运行时重新推导、且可能推得不一样。

Skill 形态:为加载它的读者而写

工具能跑了,然后是一个和代码无关的设计问题:skill 文件不是文档。它的读者是模型,而模型在特定时刻、特定预算下加载它。

CONTEXT COST — WHAT LOADS WHEN description 常驻 用户开口 触发词命中 SKILL.md 触发时加载 refs 按需 分工原则 「告诉 agent 该做什么」→ SKILL.md 「向人解释为什么」→ README / references 形态 Step 0 · 判定模式 Step 1 · 探规模 Step 2 · 修编码 Step 3 · 执行 Step 4 · 汇报 + 工具查不出来的 → 强制人工交叉核对 + error→现象对照表 → 用户不关心编号
三层内容、三类读者、三份预算。混在一起,skill 就变成一本没人读完的说明书

核心是 Step 0 — 判定模式:一张「用户原话 → 子命令」的对照表,并明确写上不要反问。这是整个文件里杠杆最高的一段,因为它省掉的是每一次使用都要付的一轮往返。

# 用户原话                                  → 子命令
# ──────────────────────────────────────────────────────────────────────
「审计 / 检查 / 有没有问题」               → lint
「对比 / 升级改了什么 / 两版差异」          → diff
「解这帧 / 这串字节是什么值」               → decode
「乱码 / GBK / 中文变问号」                 → fix-encoding

# 「检查」和「对比」同时出现 → 先跑 diff
# 已经上线的东西变了,比还没上线的东西有瑕疵严重

第二个设计动作,是大多数 skill 都缺的一节:工具查不出来的东西。头部声明了却一条都不发的节点;changelog 声称新增、文件里却没有的报文;被批量填成同一个值的属性。lint 永远报不出来 —— 而不写下来,「全绿」就会被读成「矩阵没问题」。

设计原则

一个 skill 的价值不只是它自动化了什么,还在于它多精确地告诉模型自动化到哪为止。这条边界决定了它是工具还是拐杖。

能力设计:朴素 diff 表达不了什么

第二个能力是版本对比。最直觉的实现是按 CAN ID 建索引再遍历两张表 —— 它能跑,也确实产出输出。真正的问题是:这份输出能不能表达真正会让整车网络失效的那种改动。

NAIVE · key by frame id 0x457 NM_Autosar_FLM_R − message removed 0x46B NM_Autosar_FLM_R + message added 真因埋进 101 条发现里 NAME-FIRST · pair across ids NM_Autosar_FLM_R DIF-ID 0x457 → 0x46B 那条误导性增删被抑制 1 条发现,原因在前 WHY IT MATTERS 路由表和验收过滤器仍指向 0x457 → 这帧没人收;网络管理机制整体失效
一删一加看起来像日常改动,真正的缺陷是地址变了

所以比对分两趟:先按名字跨 ID 配对,报一条 DIF-ID,并抑制那对误导性的增删。同一套推理也得出「信号重命名按位定义配对」—— 因为名字正是变了的东西,位定义是唯一还能锚定它的线索。

藏在自由文本里的那条发现

校验和的 DataID 不是结构化字段,它躺在信号注释的自由文本里:

CM_ SG_ 466 VIU_Info_Checksum "CRC16 checksum (DataID: 0x61D2)";

结构化比对看到的是「注释变了」,归档为 info。但接收方用这个 ID 校验 CRC:改了它,每一帧都被拒收。现象不是报错,而是没反应 —— 模块干脆不理你,然后发送方被查了一整天。所以工具用正则把 ID 提出来,一变就升到 error。

RE_DATAID = re.compile(r"data\s*id\s*[::]?\s*(0x[0-9A-Fa-f]+)", re.I)

# 两边都有 DataID 且不同 → 不是 DIF-CMT,升为 DIF-CRC (error)
if old_id and new_id and old_id != new_id:
    return DiffFinding("DIF-CRC", "error", ...)
可迁移的判断

两个案例是同一条洞察:工程产物里最危险的信息往往不在 schema 里,而在注释、字符串、命名约定里。只读 schema 的工具,会在坏文件上报成功。

质量体系:让结论可被验证

工具的结论如果没人能验证,那它就不值得信任,只是「很有自信」而已。三个机制,各自针对一类失效:

失效类型 机制
代码算错 测试建立在答案已知的样例上,而不是「它跑起来了」
文档和代码脱节 双向断言:文档写了没实现 → 红;实现了没写文档 → 也红
模型用错 隔离安装、清空会话、自然语言提问,再查它实际执行的命令

用断言代替承诺

文档会静默漂移。一张写着「12 类检查」而代码只有 11 个的表,比没有表更糟,因为它让报告看起来很完整。所以这个数字被断言,而且是双向的:

documented  = set(re.findall(r"`([A-Z]{3}-\d{3})`", skill_md))
implemented = set(re.findall(r'code="([A-Z]{3}-\d{3})"', source))

assert documented - implemented == set()   # 文档超前 → 红
assert implemented - documented == set()   # 文档滞后 → 红

同一条规则也覆盖文档里关于示例的每个数字:严重度分布、缺陷类别数。一旦某个检查改动导致示例表现变化,CI 直接红,而不是让文档悄悄变错。

def test_broken_covers_ten_codes(self):
    _, findings = self._lint(EXAMPLES / "demo_broken.dbc")
    codes = {f["code"] for f in findings}
    self.assertEqual(len(codes), 10)       # 数字对不上就红
    self.assertEqual(codes, EXPECTED_SET)  # 集合也要相等

样例兼作环境自检

两个示例 DBC 随 skill 一起分发,所以 agent 在碰真实文件之前可以先自检环境:

python3 <skill>/scripts/dbc_analyze.py lint <skill>/examples/demo.dbc         # 期望 0 error 0 warn
python3 <skill>/scripts/dbc_analyze.py lint <skill>/examples/demo_broken.dbc # 期望 10 类缺陷全报

干净样例的位分配由生成器按算法计算而非手填,破损样例则刻意命中每一族检查。前者不再干净、或后者不能全报,就说明有回归 —— 不需要人去看。

验收:让模型真的去用

以上这些都只证明脚本能跑,不证明 skill 可用。因为 skill 不等于脚本 —— 它是脚本、加上指令、加上模型对指令的理解。

所以验收放在隔离 profile 里、清空会话、用自然语言驱动,然后检查 agent 实际执行了哪些命令:

# 隔离 profile:状态进 ~/.openclaw-skilltest,workspace 也独立
openclaw --profile skilltest skills install <skill-dir>

# 真实用户措辞,不是命令
openclaw --profile skilltest agent --local --agent main \
  -m "帮我检查一下 hood_v14.dbc 这个通信矩阵,有什么问题"
提问 agent 自己选的命令
「检查这个矩阵有什么问题」 info → fix-encoding → lint → messages → signals --frame
「这两个有什么不同」 两边都转码,再 diff 旧 新,基线顺序正确

第二行才是关键。Step 2 写的是「编码先于一切」,agent 把这条原则自己推广到了两个文件 —— 而文档里从没写「diff 时两边都要转码」。这就是「照着清单做」和「照着规则做」的区别。

验收标准

装在仓库旁边什么都证明不了 —— 相对路径可能碰巧能通。唯一有意义的测试,是未来用户会做的那一次:干净环境,仓库不在旁边。

成果与可复用的方法论

用同一车型网络、相隔一版的两份真实矩阵实测:10 error / 49 warn / 42 info —— 反向对比报出同样的 10 个 error,说明这份比对是对称的,不是单边偏斜。

$ dbc_analyze.py diff EHX_Car_FLM_v1.0.dbc EHX_VIU1_HOOD_v1.4.dbc

old  21 messages / 379 signals
new  26 messages / 297 signals

[E] DIF-CRC  VIU_Info_Checksum: checksum data id changed   (0x1D2)
      0x61D2 -> 0x41D2
[E] DIF-ID   NM_Autosar_FLM_R: CAN id changed   (0x457 -> 0x46B)
[E] DIF-ID   NM_Autosar_FLM_L: CAN id changed   (0x45C -> 0x46A)
[E] DIF-REN  signal renamed   VIU1_SourceNodeIdentifier -> VIU1_NodeAddress

summary: 10 error, 49 warn, 42 info, 101 total
测试数量随迭代增长 33 v1.0 37 +文档守卫 58 +diff 61 +验收 真实比对产出的 101 条发现 10 error 49 warn 42 info 反向对比 → 同样 10 个 error 方向对称 人工交叉核对 ≈ 30 min 本工具 0.1 s 可重复、 且可直接进 CI
两个方向都是同样十个 error;其中最关键的三条,普通 diff 完全看不见

最危险的三条在普通 diff 里全都看不见:一个变了的 CRC DataID,和两条名字没变、地址变了的网络管理报文。所有接收方都得重新编译。这就是「为领域设计」而不是「为格式设计」的回报。

方法论,五行

步骤 要回答的问题
1 · 切边界哪些必须可复现,哪些需要判断?
2 · 落代码领域里有什么,是模型每次都会推得不一样的?
3 · 定形态先写 Step 0。模型需要什么才不用反问?
4 · 划边界它永远看不见什么,该由谁去核?
5 · 证明文档里每个结论有测试断言吗?模型在干净环境里选得对命令吗?

这五步里没有一步是「多写代码」。其中四步都是在决定什么不该自动化,以及如何让剩下的结论可被验证。这部分才是能迁移到下一个 skill 的东西。

代码、测试与完整设计

纯标准库,MIT 许可。示例矩阵全部合成 —— 不含任何真实车型、供应商或客户矩阵里的人名。

github.com/autosar-skills/autosar-agent-skills

常见问题

skill 和一段 prompt 的区别在哪?

prompt 是说服,skill 是约束。skill 有 Step 0,先判定走哪个分支再动手; 有带代价说明的执行顺序(颠倒会怎样);有「要么被测试断言、要么明确列为查不出来」的输出。 如果去掉模型结果也不变,那它本来就是个工具,该落到代码里。

为什么不干脆每次让模型现写解析器?

因为模型每次都会把同一批领域事实重新推一遍,结论不一样。 位几何公式、PduR 路由链、哪家厂商用哪个容器名 —— 这些是事实,不是判断。 写进代码一次,模型就不会在某个周二推错;错了也是测试失败,而不是一段看起来对的话。

以说明文字为主的 skill 怎么测?

能机械校验的部分用断言,其余的明确列出来。 文档里的检查码必须与代码能实际产生的码双向相等 —— 这条抓到过一个「文档写了但代码从未 emit」的码。 测试覆盖不到的,必须单列成「查不出来、需人工核对」,而这恰恰是多数 skill 文档会漏掉的一段。

为什么 SKILL.md 写成执行手册,而不是 README?

因为它的读者是模型,不是人。模型不需要被告知设计为什么好,它需要知道走哪个分支、按什么顺序。 说明性内容下放到 README.md 或 references/,按需加载。 一个快速判断法:SKILL.md 里找不到 Step 0,八成是写给人看的。

Harry Zhao
汽车嵌入式 · AutoSAR BSW / 平台工具链

12 年汽车电子底层软件经验,从 ECU 底层驱动做到 AutoSAR CP 平台架构。 给「编译期不报错、联调才现形」的那类问题写 agent skill:与 ECU 配置对不上的通信矩阵, 以及没人能手工复现的交付流水线。

手写 HTML / CSS · Cloudflare Workers ← 全部笔记 me.tech-harry.top