☰
第 27-2 篇 推理引擎的出证帧规范——body_sha 与输出文本的字节绑定
2026/10/2 5:56:37 网站建设 项目流程

> 上一篇:27-1《三问三防:权重、记录、出处》| 下一篇:27-3《端到端取证:VLLM_ATTEST=1 → 收证 → verify_attest.py》

> **源码精读篇**:本文为源码/方法论精读,无独立实测;文中数字均引述仓库 docs 的板端实测记录

> **一句话导读**:推理引擎的出证帧规范:把证什么钉成字节级契约——拆 vatt_seal_json 的长度前缀帧 F(x)=u32be(len)‖x 怎么把请求、参数、计数、输出文本、时间戳绑成一条 SM3 摘要,讲 body_sha 为何让验证方无需 tokenizer 即可离线复算核验。

> **关键词**:手搓推理引擎、大模型推理、出证帧规范、body_sha、字节绑定、attestation、可验证推理

1. 知识点:帧规范——把"语义"翻译成"字节序列"

attest 摘要(schema=3)的规范写在 vllm_attest.h 8–26 行:

digest = SM3( VLLM-AT-3 || F(model_fp) || F(model_id) || F(device_id) || F(user) || F(params) || F(n_prompt_tokens) || F(n_gen_tokens) || F(finish) || F(ts) || F(body_sha) || F(output_text) ) F(x) = u32be(len(x)) || x # 按字节

为什么必须 F(x)=长度前缀 + 原字节,而不是直接拼接?因为裸拼接有歧义:"ab"+"cd"和"a"+"bcd"会产生相同字节流,字段边界丢失。长度前缀让每个字段自描述,且字节级无歧义。这条规则一视同仁地作用于:

  • 自由文本(user、output_text):按 UTF-8 原始字节喂入,不做任何"清洗/折叠"——凭证绑定的是引擎真正输出的那串字节;
  • 数字(n_prompt_tokens / n_gen_tokens / ts):先格式化成十进制 ASCII("%d"/"%ld")再入帧,防止"同一数值不同写法"造成的复算分叉;
  • body_sha:SM3(客户端原始请求体字节)的 64-hex 字符串。它让验证方用自己的请求原文复算 SM3 即可比对——不需要 tokenizer、不需要聊天模板、不需要引擎,这正是"字节绑定"换来的可独立验证性。

还有一个常被忽略的字段:model_fp(模型指纹)。它在模型加载成功后一次性固化(vatt_model_ready),锚定 config.json + 模型目录文件清单(name+size)——防"响应声称来自模型 A、实际跑的是模型 B"这种事后偷换。

2. 对应代码:vatt_seal_json 的帧组装(vllm_attest.c 266–314)

#define DF(p, n) do { if (dump_safe && dn + (n) < cap) memcpy(dbuf+dn,(p),(n)); dn += (n); vc_sm3_update(&c, (p), (n)); } while (0) #define DFLEN(p, n) do { uint8_t _h[4]; vatt_u32be(_h, (uint32_t)(n)); /* u32be(len) */ DF(_h, 4); DF((p), (n)); } while (0) /* || x */ DF(VATT_MAGIC, ...); /* "VLLM-AT-3" */ DFLEN(g_ctx->attest_fp, ...); /* model_fp */ DFLEN(model_id, ...); DFLEN(device_id, ...); /* 模型/设备标识 */ DFLEN(user, ...); char params[128]; vatt_params_str(r, params, ...); /* "t=..,p=..,m=..,k=..,mt=..,th=.." */ DFLEN(params, ...); snprintf(npt, ..., "%d", r->n_prompt_tokens); /* 数字 → 十进制 ASCII */ DFLEN(npt, ...); DFLEN(ngt, ...); DFLEN(finish, ...); DFLEN(ts, ...); DFLEN(body_sha, ...); /* 请求体 SM3 的 64-hex */ DFLEN(r->text, r->text_len); /* 输出文本原始字节 */ vc_sm3_final(&c, d); /* 314: digest 就绪 */

DFLEN宏就是规范里的F(x):先喂 4 字节大端长度,再喂内容本身。整个 seal 函数只有一件事——按固定顺序把 12 个字段的字节流喂进同一个 SM3。随后vatt_sign(345–355)用设备私钥对这个 digest 做 SM2 签名(ID=VLLM-ATTEST-1,k 每次现取——25-3 的纪律在这里同样生效),r‖s 与 digest 一起装进凭证 JSON 发给调用方。

3. 改动后果:为什么"响应改一个字"必然验签失败

验证方(verify_attest.py)做的事与 seal 完全对称:读凭证里的字段 → 用自己的--request复算 body_sha、用自己的--output喂最后一个字段 → 重算 digest → 与凭证里的 digest 比对 → SM2 验签。只要喂进去的任何一个字节与签名时不同,digest 就不等,验签必然失败。

归档侧改动影响的帧字段验签结果
输出文本改 1 字F(output_text)FAIL(digest 变)
请求原文被替换F(body_sha) 复算不符FAIL(body_sha 不一致)
时间戳被改F(ts)FAIL
声称的 token 数与实际不符F(n_prompt_tokens)/F(n_gen_tokens)FAIL
换一个模型目录的档案F(model_fp)FAIL
把params里的k=(top-k)或th=(thinking)改成别的档位F(params)FAIL(schema=3 起)

推演改动后果:若实现时图省事把output_text先做 JSON 转义再入帧(vatt_seal_json的 JSON 字段确实转义,但摘要入帧的是原始字节,两处必须分清)——验证方若不知道"引擎对转义文本做摘要还是对原文做摘要",复算必然分叉。所以帧规范必须写清"入帧字节 = 原始字节",且实现与验证脚本(verify_attest.py的 14–26 行注释)严格同口径。这也是 Day 18 分词器教训的延伸:凡跨进程/跨实现比对,一律按字节定契约。

4. 学员调试任务

  • A 档(动手):读verify_attest.py主验证函数(复算 digest 部分),对照vllm_attest.c266–314 逐字段核对顺序与编码;手动把--output换成输出文本的转义版(如把换行换成\n两字符)重跑验证,观察 FAIL(体会"字节契约"的严格)。
  • B 档(纯读源码):回答:①F(x)=len‖x的长度前缀为什么能消除"ab+cdvsa+bcd"的歧义?若没有长度前缀,攻击者能否构造一个"摘要不变但字段边界不同"的替换?(提示:SM3 抗碰撞下不能构造,但规范歧义会导致合法验证失败)② 数字字段为什么先转十进制 ASCII 再入帧,而不是直接喂 4/8 字节定长?(提示:两端语言无关、避免字节序歧义)③body_sha字段本身是 64-hex 字符串,为什么还要再套一层F()?(提示:与其它字段统一帧格式,防边界歧义)

预期输出:字段序清单(12 项)+ 一次"转义文本导致 FAIL"的对照实验,能讲清"凭证强度 = 帧规范严格度"。

收尾

  • 本篇源码点名:vllm_attest.c(帧组装 266–314、签名 345–355)、vllm_attest.h(schema=3 注释 8–26)、verify_attest.py(纯 Python 复算)
  • 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
  • 下篇预告:规范讲完了,跑一次真的:27-3 板端端到端取证——VLLM_ATTEST=1启动 → 发一次请求 → 收下attest凭证 → 用verify_attest.py离线验签 PASS,再把输出改一个字验出 FAIL。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询