资讯动态

大模型JSON压缩:TOON如何降低Token成本与延迟

发布时间:2026/10/17 7:40:21 来源:尧图企业网站定制
在做大模型工具调用与结构化输出相关项目的时候我碰到了一个很实在的麻烦模型每次返回的 JSON 结构几乎一样但整段文本每次都得原样生成。那些重复了几十遍的字段名、引号、冒号和缩进空格每个字符都要折算成 TokenToken 就是钱也是延迟。尝试了 gzip 这类通用压缩方案之后发现它们和分词器配合并不好。后来我逐步摸索出一个专门面向大模型场景的 JSON 压缩型数据结构起名 TOON。这篇就把 TOON 的设计动机、完整原理、实现细节和调优记录全部摊开讲希望给同样被 JSON Token 成本困扰的人一个可落地的参考。1. 为什么大模型场景需要JSON压缩1.1 算一笔Token账先说一个容易被忽视的事实Token 不是按“语义”计算的而是按文本片段计算的。绝大多数常见分词器会把一段英文按子词切分一个普通英文单词大约消耗 1 到 2 个 Token一个中文字大概消耗 0.5 到 1 个 Token。也就是说JSON 里那个反复出现的customer_shipping_address字段名本身可能就要吃掉两三个 Token。如果模型一次返回 50 个对象的数组每个对象都有相同的字段结构那么这份 JSON 里同样的 key 字符串就会重复出现 50 次。这还不是最夸张的最夸张的是 Function Calling 场景里模型输出的工具调用参数往往结构非常固定比如{action: get_weather, args: {city: 北京, date: 2025-01-15}}。如果连续调用 20 次action、args、city、date这几个 key 就被重复了 20 遍。按平均每个 key 加标点占 10 到 15 个字符估算这 20 次调用光是结构性重复字符就有几千个全都白白喂给了分词器。拿我实际遇到的一个订单查询场景举例一个批处理任务里模型要连续输出 30 条订单记录每条记录包含订单号、客户名、商品列表、金额字段。完整 JSON 大约 18KB但其中有 14KB 是纯结构重复内容。直接发送给下游接口时按当前主流模型的价格粗略折算一次任务光在“键盘上重复敲相同字段”这件事上就要多花约 40% 的 Token 费用。延迟也同步上升因为模型输出阶段是按 Token 逐个生成的输出 Token 越多首字延迟和总耗时越长。1.2 冗余到底藏在JSON的哪个位置JSON 的冗余可以拆成四层来看第一层是语法噪声。花括号、方括号、双引号、冒号、逗号、换行、缩进。这些字符没有任何语义增量但占比不低。尤其是加了美化缩进之后空白字符能占到全文的 15% 上下。第二层是键名重复。也就是上面说的结构固定的场景里key 字符串被反复书写。这一层是大头。如果一个项目里 JSON schema 长期不变那 key 就是一套“永远不变的常量”完全没有必要每次传输都带着。第三层是结构形状重复。数组里 30 个对象长短不一但字段组合基本一致。其实只需要描述一次后面的对象都可以按“引用”的方式填充值。这类重复比 key 重复更隐性因为每个对象虽然字段名一样但值的类型和长度各有不同人眼不容易察觉结构层面的重复。第四层是默认值与空值。有些字段在绝大多数情况下都是相同的值比如status: active、version: 1、tags: []。如果这一批 JSON 里 95% 的对象都携带同样的默认值那这些值对下游而言是有用信息但对模型 Token 消耗来说属于“高频冗余”。通用压缩算法如 gzip 能处理第一层和第二层的部分重复但由于压缩算法作用于字节级冗余它并不理解 JSON 的结构。换句话说gzip 可以把重复字符串压缩得很好但解压后下游拿到的仍然是一大段文本这段文本如果要进入模型的上下文窗口依旧要被重新分词、重新计算 Token。压缩发生在传输层而 TOON 要解决的是“模型输出表示层”的问题。1.3 通用压缩为什么不够用我用 gzip、zstd、lz4 都做过对比实验。结论是这些算法在压缩率上确实优秀有的甚至能把 18KB 压到 5KB。但流式场景下有个致命问题——你必须等完整输出结束才能做压缩没法边生成边发送。大模型是逐 Token 流式输出的下游想要低延迟通常希望一边接收一边处理而 gzip 这类算法需要积累足够的窗口才能产生较好的压缩比切片压缩反而会大大削弱效果。更麻烦的是重新分词问题。即便压缩后在网络上传输的字节数变少了接收方拿到之后仍然要解压成完整 JSON 文本再进入模型上下文Token 数一点没省。而我关心的是上下文窗口占用和输出 Token 计费不是单纯带宽。TOON 的思路完全不同它直接把 JSON 的“结构部分”与“值部分”分离结构部分用唯一字典表达值部分按类型高效编码。这样模型输出的 Token 数本身就大幅度减少因为它输出的已经不再是完整 JSON 文本而是紧凑的 TOON 字节流。同时 TOON 支持按需解码接收端不需要把整个结构全部展开就能定位到任意字段。提示如果你的瓶颈在网络带宽直接上 zstd 就好如果你的瓶颈在 Token 计费、上下文长度和模型生成延迟才需要考虑 TOON 这类专门结构。2. TOON的核心设计思路2.1 先明确边界TOON到底压缩了什么TOON 不是一个推翻 JSON 的通用格式它只是一个编码层。你可以把它理解为“JSON 的预编码版本”最终解压后仍然是标准 JSON。所以下游所有兼容 JSON 的解析器、数据库、前端组件都可以无感使用不需要改造。TOON 压缩的核心目标是三个字去重复。去的是三样东西键名重复、结构形状重复、默认值重复。它不追求把随机文本压到极限因为大模型结构化输出本身就不是随机文本它是高度模式化的。设计数据结构的第一原则是匹配数据特性而不是匹配通用场景的熵编码理论。我定义 TOON 的适用边界有三条结构是已知且稳定的或者结构种类有限。比如 Function Calling 的参数、表单提取结果、批量实体识别输出。同一批次内存在大量同构对象。比如 50 条订单、100 条日志、20 个工具调用。下游需要低延迟处理并且可能只关心部分字段。比如网关需要路由到某个字段但不关心整个负载。反过来如果 JSON 是高度异构的比如模型自由生成的散文转成 JSON每条结构都不一样那 TOON 的字典收益就很小此时直接用通用压缩就好。我的经验是当同构对象比例低于 40% 时TOON 相比原始 JSON 的压缩优势就会明显缩水。2.2 三大核心机制模式提取、字典映射、路径编码TOON 整个结构由三部分组成头部声明、字典区、数据区。头部声明的职责是说明 TOON 的版本号、字典条目数量、是否有默认值表。这一部分是给解码器判断用的。字典区的职责是把每个 JSON 路径映射为一个短整数 ID。这里的路径指的是从根到某个 key 的完整路径例如customer.address.city。如果同一条路径在数据集中出现了很多次它就在字典中出现一次。字典区本身还带有一个类型标志位用来表示该路径上值的类型比如 string、number、boolean、null、object、array。数据区的职责是按字典 ID 直接编码值。因为 ID 通常是个不超过 65535 的整数占用 2 字节比原始 key 字符串少了一个数量级。值本身再按类型做二进制编码字符串仍然保留 UTF-8 字节但带长度前缀数字可以选用定长整型或浮点布尔只用 1 字节null 只需 1 字节类型标记。路径编码是 TOON 的一个关键设计。想象你有一棵树每个节点都有自己的完全路径。TOON 的字典并不是简单地存 key 字符串而是存“完整路径字符串”与 ID 的映射。例如items[].price被存为一条items[].quantity存为另一条。这样当一个数组里有 100 个商品对象时每个商品的值区只需要写[ID][类型][值]而不用反复写price和quantity。还有一个容易被忽略的设计点数组元素模式的复用。TOON 遇到同构数组时会先分析第一个元素的路径集合生成子字典后续元素直接按相同路径顺序编码值。这个模式在代码里处理起来稍复杂但对压缩率的贡献非常大。实测在同构数组占比高的场景这一项能再额外降低 20% 的体积。2.3 设计取舍一次解析多处复用TOON 的另一个核心思想是“一次解析多处复用”。这里的“多处”指的是同一份 TOON 数据可以被多个下游消费者各自独立解码并且各自只解码自己关心的部分。举个例子网关收到一个 TOON 消息它只需要判断action字段来决定路由那它可以直接解析头部 字典再根据字典里action的 ID 去数据区定位值整个过程不涉及任何其他字段的解码。这就比先把完整 JSON 展开再读取字段高效得多。这个设计在原文的 JSON 里做不到。JSON 的解析是整体的你要访问items[2].price解析器通常要么先把整个字符串解析成内存树要么做流式扫描。流式扫描时虽然可以不构建全树但你必须从头把字符逐个扫过去扫过的 Token 全部要被词法分析处理。而 TOON 的定位访问是真正的 O(1) 跳转。因为数据区按字典 ID 存储每个字段的值长度前缀明确解码器可以直接跳到目标 ID 对应的偏移量。这在大规模批处理场景里特别有价值比如一条消息有 200 个字段下游只关心 3 个按需解码的 CPU 开销可以降到全量解码的十分之一以下。这个取舍是有代价的字典区本身占用一定空间。当数据集很小时比如只有一条简单 JSONTOON 反而比原 JSON 更大因为字典还没有产生收益。所以我通常建议批量积累到一定规模再使用 TOON或者为高频场景预制固定字典。这在 4.3 节会详细讲。3. 从零实现一个TOON原型3.1 格式约定与二进制布局我在实验阶段用 TypeScript 实现了一套最小可用版本下面直接展示它的格式约定。这个约定未必最优但胜在清晰方便你理解后自己调整。TOON 文件/消息的整体布局如下| 魔数 4B | 版本号 1B | 标志位 2B | 字典区 | 数据区 |魔数固定为TOON的 ASCII 字节解码器用它快速识别消息类型。版本号目前为 0x01。标志位用位图表示是否启用默认值表、是否启用按需解码索引、是否启用同构数组优化。字典区布局| 字典条目数 2B | [字符串长度 2B 全路径字符串][类型标志 1B] ... |每个字典条目包含一个完整路径字符串和类型标志。完整路径统一用点分隔数组用[]表示比如items[].price。类型标志取值如下标志值类型0x01string0x02number0x03boolean0x04null0x05object0x06array数据区布局| [路径ID 2B][类型 1B][值载荷] ... |字符串的值载荷是[UTF-8字节长度 2B][UTF-8字节]。数字的值载荷我默认用[数值类型 1B][数据字节]数值类型 0x01 表示 int320x02 表示 float640x03 表示 decimal 字符串为了避免浮点精度损失。布尔的值载荷固定 1 字节。null 没有值载荷。对象和数组的值载荷特殊一些。TOON 对象节点会先写[子节点数量 2B]然后按字典序写子节点的路径 ID 与值。数组节点会先写[元素数量 4B]如果标志位开启了同构数组优化则只写一次元素路径模板随后每个元素按模板顺序写值否则每个元素独立写完整路径 ID 序列。3.2 压缩端从JSON到TOON压缩端的核心流程分四步解析 JSON、扫描结构、构建字典、编码数据区。第一步是解析 JSON生成树结构。这一步没有什么特别的直接用标准 JSON.parse 就可以因为 TOON 并不需要自己写一个 JSON 解析器。第二步是扫描结构。我需要遍历这棵树收集所有出现过的完整路径。为了处理数组我取第一个元素的路径作为数组的路径模板后续元素的路径不再新增字典条目而是复用模板。这一步结束后我得到的是一个路径集合。每个路径附带一个类型标志表示该路径上出现的值的类型。第三步是构建字典。我把路径集合按出现频率排序高频路径排在前面。这样做的原因是让常用路径 ID 尽量小ID 越小字节占用越少。然后为每条路径生成 ID写字典区。第四步是编码数据区。遍历树对每个节点先写路径 ID再写类型标志按类型写值载荷。对数组元素按模板顺序递归编码。下面是我抽出的核心压缩函数function encodeToon(root, pathFrequencyMap) { const dict buildDict(pathFrequencyMap); const body encodeNode(root, dict); return concatBuffers(TOON_MAGIC, VERSION, FLAGS, encodeDict(dict), body); } function encodeNode(node, dict) { const path node.path; const type typeOf(node.value); const id dict.getId(path); const header Buffer.alloc(3); header.writeUInt16LE(id, 0); header.writeUInt8(type, 2); if (type TYPE_STRING) { const value Buffer.from(node.value, utf8); const lenBuf Buffer.alloc(2); lenBuf.writeUInt16LE(value.length, 0); return Buffer.concat([header, lenBuf, value]); } if (type TYPE_NUMBER) { // 根据数值类型决定编码方式 } if (type TYPE_ARRAY) { if (flags.sameStructArray) { return encodeSameStructArray(node, dict); } return encodeGenericArray(node, dict); } // ... 其他类型 }提示构建字典前先统计路径频率是我实测很有用的一步。看似多了一次遍历但能让高频路径的 ID 从几千降到几十ID 从 2 字节变成更短的变长编码整体体积能再降 5% 到 8%。3.3 解压端从TOON到JSON解压端流程是压缩端的逆过程同样分成三步读头部、构建路径表、解码数据区。读头部时我验证魔数、版本号解析标志位和字典条目数。构建路径表时我把每条字典记录的 ID 映射为完整路径字符串。注意这里是从 ID 到路径的正向映射和字典的原始顺序一致即可。解码数据区时我按顺序遍历每一条记录。每条记录先读路径 ID 和类型标志然后按类型读取值载荷。遇到对象节点我会递归读取子节点遇到数组节点我会按元素数量循环读取。由于路径 ID 已经通过字典转为完整路径字符串我可以一边解码一边组装 JSON 树最后用 JSON.stringify 输出。为了让解码效率更高我做一个优化解码时直接操作字典里的路径树而不是字符串拼接。比如路径customer.address.city在字典中解析成三层嵌套的节点结构解码器拿着 ID 直接定位到树节点并赋值最后只需对树做一次序列化。3.4 类型边界与精度问题这是最容易踩坑的地方。JSON 里数字的范围很宽但二进制编码必须提前定好边界。我的方案是先判断数字是否为整数。如果是整数且绝对值小于 2^31用 int32 编码如果是非整数优先用 float64如果数字的小数位数超过 6 位或者看起来像业务关键精度金额、坐标等就回退到 decimal 字符串编码。判定的逻辑可以看下面这段function encodeNumber(value) { if (Number.isInteger(value) Math.abs(value) 2 ** 31) { return encodeInt32(value); } if (Number.isFinite(value)) { if (Math.abs(value) 1e6 hasLimitedDecimals(value)) { return encodeFloat64(value); } return encodeDecimalString(value.toString()); } if (Number.isNaN(value)) { return encodeNull(); // JSON 本来没有 NaN这里做兼容处理 } return encodeError(); }这里有个很重要的教训JSON.parse 默认把数字解析为 IEEE 754 双精度浮点。如果原 JSON 里有一个超过 2^53 的整数解析回 JSON 时就会丢精度。我实测在订单号、物流单号这类场景中经常遇到 16 到 18 位数字直接走 float64 编码再解回来会变成错误订单号。所以我强烈建议所有业务 ID 类字段在 TOON 字典里强制声明为 decimal 字符串类型或者干脆在压缩时保留原始文本。4. 实测效果与调优记录4.1 典型场景压测数据我在三个典型场景上做了压测。场景一是一个模拟电商订单列表结构固定有 30 个同构对象完整 JSON 约 18KB。场景二是一个模拟日志聚合结果有嵌套数组和多种字段类型完整 JSON 约 32KB。场景三是模拟自由文本抽取结果JSON 结构每一条都不太一样完整 JSON 约 12KB。TOON 压缩后的体积、压缩率与原始 JSON 对比如下场景原始JSONTOON体积压缩率同构对象比例电商订单列表18.4KB7.2KB60.9%100%日志聚合32.1KB22.6KB29.6%55%自由文本抽取12.3KB10.1KB17.9%30%可以看到同构对象比例越高压缩效果越好。这也印证了我 2.1 节的判断TOON 的高度模式化场景优势明显异构场景优势有限。更关键的指标是 Token 数。我把同一个数据分别用原 JSON 和 TOON 喂给分词器统计 Token 数TOON 场景下 Token 数平均减少了 45% 到 65%。这个差距不是简单的体积换算因为 TOON 数据区大量使用短 ID 和二进制类型标记分词器对这些字节的切分粒度更粗每个 Token 能吃下更多信息。4.2 与通用压缩方案的横向对比我同时用 gzip -6、zstd -3 对同样三份数据做了压缩再做了一组对比。为了公平TOON 和 gzip 各自都不做流式限制都是整块压缩。方案订单列表体积日志聚合体积自由抽取体积是否天然流式友好原始 JSON18.4KB32.1KB12.3KB否gzip -65.6KB12.1KB6.8KB否zstd -35.2KB11.5KB6.4KB部分TOON 独立7.2KB22.6KB10.1KB是TOON zstd3.1KB9.8KB7.9KB部分这张表信息量很大。单看体积TOON 单独使用不如 gzip 或 zstd但 TOON 天然支持流式输出和随机字段访问这两点是通用算法做不到的。而把 TOON 压缩后的字节流再喂给 zstd体积能压到比单独 zstd 更小。原因很简单TOON 先去做结构级去重zstd 再去做字节级去重二者是互补关系不是替代关系。我实际项目里采用的方案是TOON 做主编码传输层根据数据大小决定是否叠加 zstd。小消息在 1KB 以下不叠加因为 zstd 的帧头开销不值得大消息超过 8KB 就叠加压缩收益明显。4.3 调优心得与固定字典方案TOON 调优有三个关键旋钮字典构建频率、ID 分配策略、默认值表开关。第一个旋钮是字典构建频率。最省事的方式是每条 JSON 都重新构建字典但这样字典本身也要传输小数据不划算。折中方案是每批构建一次字典批内复用。更进一步如果 JSON Schema 是固定的我可以离线构建一份固定字典运行时只编码不建字典这样字典区的体积可以降到几乎为零。固定字典方案在 Function Calling 场景里尤其好用因为工具入参的 schema 本来就是提前定义好的。第二个旋钮是 ID 分配策略。默认按频率排序分配高频 ID 小。但如果后续要对齐固定字典就必须按照固定字典的 ID 顺序来。此时压缩率会略降但换来了字典区的归零。这个取舍要看项目里“字典传输开销”和“ID 对齐收益”哪个更大。第三个旋钮是默认值表。默认值表会把一个路径的“最常见值”单独存一份数据区不再写该值的完整载荷只写一个引用标记。比如status: active如果出现 50 次默认值表里登记一次数据区每次写 1 字节引用。这个优化对高基数重复值场景很有用对低基数场景有轻微副作用因为要额外维护默认值表。调优流程可以按下面这个顺序跑先关闭所有高级选项跑一遍基准。打开同构数组优化看体积变化。加频率排序的 ID 分配看体积变化。统计路径值分布决定是否开默认值表。做流式模拟看按需解码和全量解码的耗时差异。这套流程我每次上新场景都会走一遍基本能把压缩率稳定在原始 JSON 的 35% 到 60% 区间。5. 常见问题与排查技巧实录5.1 字典丢失与版本不兼容TOON 最大的坑就是字典不一致。如果压缩端用了固定字典 A解压端却用了固定字典 B那么 ID 到路径的映射就错位了解码出来的 JSON 完全不可读。排查方法是先看头部魔数之后是否带有版本字段然后对比两侧的标志位。我在协议里加了一个 2 字节的字典指纹字段压缩端在构建字典时对所有路径做一次哈希把哈希前 16 位作为指纹写入头部。解压端先算自己的字典指纹对上才继续解码对不上直接报错。这个指纹的成本极低但避免了一次“静默解出错误数据”的灾难。如果你在线上遇到解码后出现字段名漂移或者值错位优先怀疑字典版本不一致而不是怀疑解码逻辑。实测中这个原因占到了 TOON 类协议故障的七成以上。5.2 数字精度缺失这个我在 3.4 节提过但值得单独展开。JSON.parse 只能保证 53 位整数精度。很多业务数字超过这个范围而 TOON 如果把数字编码到 float64 里解码回来就错了。举一个真实发生过的例子某条订单号是 151231234567890123456789。解析时 JavaScript 把它变成了 151231234567890120000000精度丢失。TOON 再编码解出来已经是错的订单号。这个 bug 很隐蔽因为前 15 位看起来都对只差最后几位。我的排查建议是在压缩端扫描所有数字字段时如果发现整数绝对值超过 2^53或者小数位超过 6 位直接打一条警告日志。同时把这类字段在字典里强制标记成 decimal 字符串类型压缩和解压全程走字符串不做数值转换。这样虽然牺牲了一点体积但换回了正确性。5.3 对SSE和流式输出的适配大模型最常见的输出方式是 SSE 流式返回。原来的 JSON 方案是边生成边拼 JSON 文本等完整闭合再做 parse。TOON 如果要流式输出需要把协议改成“分块可自解码”的模式。我的做法是给 TOON 加一个分片扩展头。每个分片有自己的长度前缀分片内部必须自包含当前字段的路径 ID 和值不依赖上一个分片的状态。这样模型每次吐出一个完整字段编码器就把这个字段用一个分片发出。解码端可以边收边拼树无需等待全部完成。实现流式版本时要注意数组元素的对齐。如果同一数组元素的不同字段散落在多个分片里解码端需要按路径 ID 重组。我的解法是分片内携带三元组[数组行号][路径ID][值]行号用于组装数组元素。这个信息量实际很小但极大改善了流式体验。跑完流式压测后TOON 流式方案相比 JSON 流式方案的首 Tok 延迟几乎没有增加但整体发送字节少了一半以上下游感知到的接口响应也明显更轻。5.4 排查工具与调试建议我给 TOON 写了一个诊断模式可以输出三张表字典表、数据结构树、每个字段的编码前后体积对比。调试时最常用的是第三张表它能直接看出哪个字段浪费最多空间。排查流程可以按这个来先用诊断模式 dump 字典确认字典内容符合预期。再 dump 数据区和原始 JSON 做字段级对照。最后跑一遍随机字段按需解码确认 O(1) 定位正确。如果发现体积异常用符号化方式把数据区的字节流按路径 ID 分组逐个检查。这套工具虽然简陋但足够支持我完成了一整套 TOON 原型的开发和上线验证。最后的落地建议TOON 不是万能的。如果你的 JSON 是典型瘦接口、短小一次性返回直接原样 JSON 就好不要在它上面再做一层 TOON徒增复杂度。但如果你的业务符合三个特征——结构固定、批量同构、下游关心低延迟和 Token 成本那 TOON 能带来的收益是非常直接的。从我个人的实操体会来看最有价值的是那个固定字典方案。它真正把 TOON 从“压缩工具”变成了“结构化传输协议”schema 定下来标签和索引都不必每次传了。最后再分享一个小技巧固定字典也可以兼容动态扩展在头部加一个“扩展字典段”标记新出现的路径追加到扩展段已存在的路径仍然用固定 ID。这样老客户端不解新字段也能正常工作新客户端拿到扩展文件就能完整还原。这个兼容策略在实际演进中帮了我大忙。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑