DeepSeek V3 模型各子模块参数量精算

网上很多文章一般只提到 DeepSeek V3 模型的总参数量,很少有人分析各子模块的参数量。我试着让各 AI 根据配置计算一下,没有一个靠谱的,只能自己算了。(也许本文的内容后续会变成 AI 回答本问题的 RAG 养料)

下面是根据 DeepSeek V3 开源仓库 https://huggingface.co/deepseek-ai/DeepSeek-V3,对 DeepSeek V3 各子模块参数量进行的精算,在计算复杂的 TP、DP、EP 拆分时可以用作基数参考。如有错误,烦请评论指出。

嵌入层 Embedding

"vocab_size": 129280, // Token 字典大小
"hidden_size": 7168,

DeepSeek V3 的嵌入层参数量是:

129280 * 7168 = 926,679,040 (~0.9B)

MLA

"hidden_size": 7168,
"num_key_value_heads": 128,
"v_head_dim": 128,
"kv_lora_rank": 512,

"num_attention_heads": 128,
"q_lora_rank": 1536,

"qk_nope_head_dim": 128,
"qk_rope_head_dim": 64,

"num_hidden_layers": 61,

单层 MLA 中 Q 的 LoRA 参数量是:

7168 * 1536 + 1536 + 1536 * 128 * (128 + 64) = 48,760,320

单层 MLA 中 KV 的 LoRA 参数量是:

7168 * (512 + 64) + 512 + 512 * 128 * (128 + 128) = 20,906,496

单层 MLA 中 WO 的参数量是

128 * 128 * 7168 = 117,440,512

pre 和 post attention layernorm 的参数量是:

7168 * 2 = 14336

所以 DeepSeek V3 的 MLA 部分共 61 层的总参数量是:

(48,760,320 + 20,906,496 + 117,440,512 + 14336) * 61 = 11,414,421,504 (~11B)

MoE

"num_hidden_layers": 61,
"hidden_size": 7168,
"moe_intermediate_size": 2048, // 路由专家 MLP 的中间维度
"n_shared_experts": 1, // 共享专家数量
"n_routed_experts": 256, // 路由专家数量
"first_k_dense_replace": 3, // 前几层使用dense替换MoE
"intermediate_size": 18432, // 前3层 (9*moe_intermediate_size)

每个专家的参数量是:

7168 * 2048 * 3 = 44,040,192

路由 Gate 的参数量是:

256 * 7168 + 256 = 1,835,264

前 3 层 dense(固定激活 8 路由专家),前 3 层参数量是:

44,040,192 * 9 * 3 = 1,189,085,184

后 58 层稀疏(动态激活 8 路由专家),后 58 层参数量是:

(44,040,192 * 257 + 1,835,264) * 58 = 656,569,547,264

所以 DeepSeek V3 的 MoE 部分的总参数量是:

1,189,085,184 + 656,569,547,264 = 657,758,632,448 (~657B)

每次计算激活 1 个共享专家,8 个路由专家,所以 DeepSeek V3 MoE 部分的激活参数量是:

44,040,192 * 9 * 61 + 1,835,264 * 58 = 24,284,510,720 (~24B)

Layer 维度

前 3 层是 dense,没有 gate,基于上面的计算,DeepSeek V3 前 3 层每层的参数量是:

(48,760,320 + 20,906,496 + 117,440,512 + 14336) + (44,040,192 * 9) = 583,483,392

后 58 层是 MoE 稀疏激活专家,基于上面的计算,DeepSeek V3 后 58 层每层的参数量是:

(48,760,320 + 20,906,496 + 117,440,512 + 14336) + (44,040,192 * 257 + 1,835,264) = 11,507,286,272

输出层

DeepSeek V3 输出层的 RMSNorm 和 Linear 参数量是:

7168 和 129280 * 7168 = 926,686,208 (~0.9B)

总参数量

核对一下 DeepSeek V3 总参数量是否为 671B:

583,483,392 * 3 + 11,507,286,272 * 58 + 926,679,040 * 2 + 7168 = 671,026,419,200 (~671B)

核对一下 DeepSeek V3 激活参数量是否为 37B:

11,414,421,504 + 24,284,510,720 + 926,679,040 * 2 + 7168 = 37,552,297,472 (~37B)

这个与 README_WEIGHT.md 中提到的 36.7B 不同,我还没找到计算错误的地方。我理解也许是考虑到 embedding 层只是查表,并不是矩阵乘,所以实际激活参数是:

11,414,421,504 + 24,284,510,720 + 926,679,040 + 7168 + 7168 = 36,625,625,600 (~36.6B)

PS (20250208)

DeepSeek 更新了 README_WEIGHT.md ,激活参数量修正成了 36.6B,也去掉了包含 0.9B 输入 embedding 的注释。

MTP

DeepSeek V3 MTP 的 ebedding 和输出 head 与主模型共享,enorm 和 hnorm 的权重是:

7168 + 7168 = 14336

eh_proj 线性变换的权重规模是:

7168 * 14336 = 102,760,448

增加了一层 hidden layer,即第 61 层:

(48,760,320 + 20,906,496 + 117,440,512 + 14336) + (44,040,192 * 257 + 1,835,264) = 11,507,286,272

加起来 DeepSeek V3 MTP 的总参数量是:

11,507,286,272 + 102,760,448 + 14336 = 11,610,061,056 (~11.6B)

DeepSeek V3 MTP 的激活参数量是:

11,610,061,056 - 44,040,192 * (256 - 8) + 926,686,208 * 2 = 2,541,465,856

这个规模比 README_WEIGHT.md 中提到的 11.5B 独立参数,和 2.4B 激活参数都略大一点。

PS (20250208)

DeekSeek 更新了 README_WEIGHT.md ,MTP 的激活参数量由 2.4B 改成了 1.5B,可能跟上面的激活参数一样,都减去了 embedding 层。但在我的计算里,这个应该是 1.6B :),还是略有不同。

11,610,061,056 - 44,040,192 * (256 - 8) + 926,686,208 = 1,614,779,648 (~1.6B)

DeepSeek V3:AI 大模型 infra 基建新高度

AI 工程化

2021 年初,贾扬清在阿里云开始推广 AI 工程化这个概念,我非常认同。我在 21 年中做技术规划的时候,提出“AI 到生产力的转化,需要更高的工程化能力”,并且将 AI 工程化的实施总结为几大方向:

  • 语义索引场景化
  • 算力调度混合化
  • 模型研发标准化
  • 优化技术普惠化
  • 模型超大规模化
  • 架构系统智能化

我的 AI 工程化团队在这些方向上也取得了许多成果。

The AI Model

但 2022 年底 LLM 大流行以后,情况发生了一些变化。原因主要是 LLM 让 AI models 变成了 The AI model,虽然这个 model 很大,也多多少少有一些变种,但从工程实践的角度来看,它并不“复杂”。

很多时候,工程架构解决的是复杂性问题。

比如,TensorFlow、PyTorch、PaddlePaddle 这些训练框架简化了搭建和训练神经网络的复杂度,在一段时间内,各种结构的网络层出不穷,大部分都是依托这些框架来实现的。

而对于 LLM 来说,模型结构相对固定,虽然也使用了框架的一些外围能力,但是模型结构核心部分已经逐渐变成全手写以达成最佳性能,典型的实现包括 FlashAttention、TRT-LLM 等。

而且 LLM 的接口调用是自然语言,因而也变得极其简单,所有的 LLM 模型几乎可以使用同一套 API。

当时看起来 LLM 不需要太多的架构基建工作。

Prefix Caching 和 Kimi

我的这个认知在思考 prefix-caching 作用的时候,有了一些改变。

在《应该把 Prefix Caching 当作一种效果优化技术》这篇博客中,我提到 Prefix Cache Aware Scheduling 是一件非常值得做的事情。而且从 Kimi 发表的论文来看,他们已经在实践了,但其它的技术报告提到这些工程架构工作的不多。

DeepSeek V3

前几天 DeepSeek AI 发布了 DeepSeek V3 版本,我一边在吐槽这 670B 的模型参数太大,下载太慢,一边在阅读它的技术报告。结果发现他们在模型的部署上,玩得更高端,给了我一些新的震撼。

首先,prefilling 和 decoding 分开部署。prefilling 4 机 32 卡,decoding 40 机 320 卡。这样一来,我之前《LLM 推理优化 Continuous Batching 及其实现》这篇博客中提到的 Continuous Batching 就不再需要了。两阶段分开后,prefill 的计算过程(长度)是确定的,其算力利用是充分的,不再需要中间停下来插入新的请求。其实 prefilling 能够分开部署,跟 DeepSeek 以前的研究也是分不开的,DeepSeek V2 引入的 MLA 对 KV Cache 做了大幅度的低秩压缩,可以显著降低 KV Cache 从 prefilling 节点传递到 decoding 节点的带宽和延迟。

其次,MoE 专家分开部署。因为 MoE 专家的激活是 Token 级别的,也就是说每个 Token 会决定走哪个专家进行计算,分开部署就可能会带来负载均衡问题:有些专家太忙,有些专家太闲。DeepSeek V3 为了解决这个问题,还做了复杂的负载均衡策略。例如:快速识别较忙的专家,部署冗余的专家副本以承担压力;重新调整专家在不同节点的布局,尽量利用跨 GPU 带宽而减少跨节点带宽(因为 IB 比 NVLink 要慢);单卡冗余部署多专家,但通过全局的路由计算来优化专家的动态激活数量。

DeepSeek V3 还实现了计算和通信重叠。为了掩盖分布式计算过程中进行集合通信时的开销,将计算任务分为微批。一个微批进行集合通信时,进行下一个微批的计算。

此外,DeepSeek V3 在推理时还将 TP(Tensor)、DP(Data)、SP(Sequence)、EP(Expert)不同维度的并行化融合到了一起。单拿出来一种并行化方法也许现在的框架还是支持的,但这些方法组合在一起,我怀疑目前也没有什么推理加速框架能直接处理。

从技术报告中揭露的这些细节可以看出,为了发挥出模型的极致性能,DeepSeek 在 AI 大模型的分布式部署上花费了很大的心思。这也让 DeepSeek V3 成为目前公开资料可以看到的最复杂、最精巧的大模型 infra 设计

这些 idea 以前也许不是没有人想到,但是 infra 的演进是有很高研发和试错成本的。当 DeepSeek 将这些路走通以后,也许未来的很多大模型公司,大模型框架,都会往沿着这个方向继续演进。

技术领导的杠杆和支点

不知不觉间,在互联网软件开发这个行当,已经做了快 15 年了。偶尔和兄弟们酒酣畅聊,回想起当初懵懵懂懂闯进软件开发这个职业,才发现开始总是比后来单纯和美好!

做个好的软件工程师

虽然我职业身份一直自称是互联网软件工程师,但事实上我最早从事的,是芯片行业。

2006 年到 2008 年,我课余做了一年多的芯片工具链开发,独自完成了修改 GDB 调试器支持一种新体系结构的 CPU。初生牛犊非常虎,在各种 mailinglist 里灌水,现在搜一下,还能找到:

那时候让我想未来,我觉得我应该是希望成为 Sun Chan 那样,20多年在编译器这个行当里,经历过 MIPS、SGI、Intel 这些传奇公司,经历过日新月异的 CPU 技术变革,还在应对手机SoC 和多核的挑战。

那时候的我,只想做一个好的软件工程师。

认识杠杆

在真正进入职场以后,我才发现:当你有更大的野心时,只作一个好的软件工程师是不够的。

当我的领导第一次告诉我,公司里 C++ 工程师的平均日编码量大约在 100 行左右时,我是有点惊讶的。后来我查阅了各种数据,发现这还真是一个产业界的客观水平。后来想想这不难理解,因为你不可能投入 100% 的时间在开发工作上。有需求沟通,有调研和设计,有讨论和评审,有修改和调试,有值班和运维,等等等等。

只用代码量来衡量一个人,是愚蠢的。

但代码量说明了,一个人的单打独斗,很难做什么大事。

我的师兄徐宥在一篇博客中给出了他观察到的模型:

做出 MapReduce 框架的和写琐碎 MapReduce 程序的工程师之间的差距并不是他们的工具和编程效率,也往往不是教育背景或者经验,而是他们各自的杠杆:所带领的团队。

在企业中,表现优异的员工,是容易获得杠杆的。

可能先从带实习生开始,然后做新员工的 mentor,然后慢慢有自己的小团队。然后就是传统的管理工作,决策、计划、分工、控制,可能也会做一些战略和激励,与其他行业的管理工作没有本质不同。

但回顾我十几年的实践过程,又模模糊糊觉得这些不太足够。

像师兄那篇文章中讲的那样,我也尝试总结一下自己积累的管理模型,比如扩展一下师兄的杠杆理论,我把它叫做“技术领导的杠杆和支点”。

杠杆和支点模型

其实团队杠杆理论有很多人讨论,但大部分讨论都是求诸于外,比如怎么分工、怎么授权、怎么激励,最终是提升效益。这不是我想讨论的部分,因为这和其它类型团队的管理方法没什么不同。

我想讨论的是技术领导的求诸于内。

大部分技术团队的领导,都是技术专家出身。但我观察到一些技术专家在成为领导之后,反而会选择弱化自己的专长,甚至停止了技术思考,变成“需求和问题驱动型领导”。只有提到他面前的需求,或者执行中出现了问题,才能看到他的工作。

还有更多的技术领导,将所有工作全部分派授权下去,不再做任何具体的工作。这可能因为实在太忙没时间做到;可能因为累了想躺一躺;可能因为环境的影响,例如“是否还需要写代码”成为衡量你是否仍是牛马的标志;也可能因为对具体工作就没有那么的热爱。

我不否认这样的技术领导依然有可能做得很成功,但这样做的技术专家放弃了一个自己最大的竞争优势:技术上的专业性。

技术的专业性可以作为非常有效的杠杆效应放大器。

在解决需求和问题的同时,趁机对系统做一些治理;在短期目标与长期不一致时,做更优雅的妥协;在团队厌倦于平庸的工作时,提出更有挑战的目标;在对代码屎山忍无可忍时,做更有远见、更有可持续性的重构设计;在无人拍板而内耗时,做大胆的决策,并往往做对;与大家讨论时,不以地位而是靠逻辑来说服人。这样的团队,能够更容易地形成技术共识,达成对目标和路径的清晰理解,也更有生产力。

当你做每件事时,不仅从技术之外,还能从技术本身去思考它对现在的作用,对未来的意义,对每个参与者的影响,并能够据此去调整自己的决策。这本身就是一种比较优势,技术领导不要轻易放弃这种优势。

解决 hard and dirty work 可以抬高杠杆的支点。

我也曾见过这样的技术领导,系统设计理念非常先进,时髦的概念都能包含,目标更是远大宏观。但遇到问题的时候,他总会说,你们再试试,你们再想想,你们再调研一下。最终搞得团队放弃也不敢,继续干又干不下去,有苦而不敢言。

客观来说,有时候这些主张未必是错的,但可能团队不具备这样的能力。最近有人常说:“世界就是一个巨大的草台班子。”那认识不到自己的团队是草台班子,是领导的责任;解决不了这样的困难,也意味着领导没有认识到自己的草台本质。

没有什么能比亲手去解决阻塞团队的问题而实现自己吹过的牛皮,更能增强团队对一个技术领导的信心;没有什么能比亲自下场去干脏活累活,更能赢得团队对你的爱戴。

《Team Geek》书里有这样的管理建议:

Delegate, but get your hands dirty.

授权,但仍然亲手做些事。

不过如何在授权和自己做事之间平衡,依然是一个技术领导要长期考虑的问题。拿我自己来说,虽然最近几年基本还能保持一万行每年的亲手编码工作,但去年就没有做到。因为去年我花了太多时间在学习 LLM 的论文,看各种实现的代码。AI 的爆发让很多人措手不及,我也是其中一个,但我不想因为自己的认知限制团队的上限。

这就是我的“杠杆和支点模型”,希望对你也有些用。

长期主义和高认知闭合

认知闭合需要

认知闭合需要是一个心理学术语,英文是 need for cognitive closure[1],有时候会缩写为 NFCC 或者 NCC,它描述个体在面临模糊、不确定或者复杂的情况时,强烈渴望获得一个明确、确定的答案或结论的心理需要(或动机)。

我是在光华管理学院张志学教授的讲座上第一次听到这个词,对这个词的思考给我带来一些新认知。

高认知闭合对人的影响

心理学研究认为[2]:认知闭合需要对个人的信息处理过程有显著的影响,进而可以影响到人们的判断决策。具体来说,高认知闭合的人对信息的处理倾向于简略,倾向于过度利用某些线索,不愿意吸收与原有信息不一致的新信息。因而在判断决策时,会表现出较强的启发式思维的特点,容易基于首因效应刻板印象进行判断,容易受到参照点人员身份的影响,并且为了避免再次面对不确定性,不愿接受新信息,也难以被说服。

通俗点儿来说,高认知闭合的人倾向于快速地收集信息,而不管收集到的信息是否全面;快速地做决策,往往根据第一面的印象、刻板印象来快速决策,而不顾情况的变化,往往倾向于采取经验指导的方法,而不愿意冒风险;在决策之后不愿意再面对不确定性,因而不接受新信息,也不愿重新决策。

这对我来说有点反常识,我一直认为快速决策是一个优秀的能力。从这些理论研究来看,仅仅追求快速本身是有很大缺陷的。

长期主义理念 v.s. 高认知闭合决策

我经常还遇到这类人(有时候也包括自己):在设定目标时非常理想化,也会将坚持长期主义挂在嘴边,但是在制定实现路径时,却往往只取短期收益,看不出这个路线是否能够走到长期目标。我戏称为“长期主义的短期化执行”。

我以前不太明白为何会这样?你不能说他们不是一个长期主义者或者理想主义者,因为很多时候这些目标都是正确的、困难的、值得去追求的,定下这些目标也是有挑战的。但是在执行的时候,表现出来的却是对短期计划和收益的看重,对长期计划、全面因素和隐患的忽视。

看到认知闭合理论后,我在想,也许他们不是不愿意追求长期目标,而是过于着急想为长期目标找一个解法。这个着急也许来自于环境的压力,也许来自于上级高认知闭合的压力,也许来自自己高认知闭合的心态,然后在未获得足够信息的情况下,拍脑袋做一堆决策和激进承诺,然后又僵化地去执行这些决策。

常见的情况包括:很多决策会有一个 deadline,但往往这个 deadline 是不做决策那个人拍的;很多目标需要一个达成路径,但往往这个达成路径做目标的那个人不思考;很多时候非要做长期的战略,但长期的战略反而没有经过长期的思考。这是分工的必然,也是管理者要思考解决的问题。

认知闭合理论给我们一个提醒,要警惕高认知闭合。在收集信息和决策时,要多问一下自己和团队,现在的信息是否足够支撑计划或决策了?现在的计划或决策是否要随着新情况的变化要做调整?是否有哪些新的信息被忽视了?

引用

[1] Wikipedia: Closure (psychology) , https://en.wikipedia.org/wiki/Closure_(psychology)
[2] 刘雪峰, 张志学. (2009) 认知闭合需要研究评述. 心理科学进展,Vol.17,No.1,51-55.

应该把 Prefix Caching 当作一种效果优化技术

我在 4 月份写的博客《LLM 推理优化 Prefix Caching 及其实现》带来了不少流量,这篇博客在 Google 搜索“Prefix Caching”时排在比较靠前的位置,也给我的微信公众号“边际效应”带来了超过 100 个关注者,由此可以看到大家对这项技术的广泛兴趣。

新认知

最近关于 Prefix Caching 我又产生了一次认知升级:在一个 Eureka 时刻,我忽然领悟到 Prefix Caching 实际上可以是一种效果优化手段——但让其充分发挥效能需要跟长上下文语言模型 Long-Context LLM 和基于 Prefix Cache 的调度技术相结合

RAG v.s. Super Long Domain Specific Prefixes

为了减少模型的幻觉,提升时效性,我们往往会采取 RAG 技术来增强模型。召回的结果本身会增加 Prompt 的长度,会加大 Context 的计算量,会影响模型的推理时长,因而召回结果的补充也是谨慎克制的,不会让 Prompt 变得非常长。

但是 Prefix Caching 优化技术给我们开了个口子:如果把信息放在 Prompt 的共享 Prefix 中,加长 Prompt 的代价就由计算和时延,转化到了存储。而存储代价可以通过 Cache 复用来摊薄,这就是一笔很划算的经济账。

如果把 RAG 召回的结果当作 Prompt 里的变量,那么 Prefix 就是 Prompt 里的常量,当增加变量信息的规模受到限制时,增加常量信息的规模也可以提升生成的效果。

举个例子:如果你做 K12 领域的模型,把 12 个年级的语文知识点都放在 Prefix 里,然后再叠加 RAG,然后回答用户语文方面的提问,肯定能更好地保证生成的效果。

所以,LLM 技术后续的一个新发展分支,也许会是超长特定领域专用的前缀设计

Inference Time Scaling

最近讨论很多的一个技术方向,是从基于预训练提升效果的 Scaling Law 转向基于 reasoning at inference time 的 Scaling Law,使用更多的推理计算来获得更好的效果。

但这些 Scaling Law 还是基于“computing”的 Scaling,我认为也许会存在基于“memory”的 Scaling Law,即更长的共享 Domain Specific Prefix 带来更好的效果。因为这里的“memory”是计算的缓存,memory 的共享本质上是计算的共享。

Long Context Large Language Model

在无法共享计算的场景下,长上下文的大语言模型应用往往由于其计算成本或显著延迟而无用武之地。但如果基于 memory 的 Scaling Law work 的话,那长上下文大语言模型将成为必经之路

这也许是 Moonshot 和 Gemini 早早投入百万 Token 级别长上下文模型的背后逻辑

Prefix Cache Aware Scheduling

在没有理想的大一统 Prefix 之前(大概率也不可能),共享 Prefix 只能是 Domain Specific 才最合适。那就意味着会有很多个共享 Prefix,而 Prefix Cache 占用的空间又是不可忽视的,所以不可能在每张卡/每台机器上都存储所有的共享 Prefix。

这就要求在用户请求调度时,根据 Prefix 分配到不同的卡/机器/集群上。Prefix Cache 也需要能够根据请求的热度在不同的服务器间弹性扩散或者收缩,甚至在显存、内存、SSD 之间调度。其实 Kimi 的论文“Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving”就是在解决这个问题,只是我之前没有意识到这个技术的广泛重要性

北京-山西长城1号路、黄河1号路1600公里自驾游

北京-山西长城1号旅游公路、黄河1号旅游公路 1600 公里自驾游地图执行版

每到长假都发愁怎么安排,哪哪儿都是人,机票和酒店都飞涨,拖家带口的选择困难。22 年夏天去内蒙自驾体验还不错,加上今年对山西兴趣比较大,于是五一就规划了一趟山西自驾游。没错,这是一篇从五一拖到十一的游记 (^_^)

原计划是往大同古城、云岗石窟、悬空寺等热门景点转一圈,在查询了酒店价格以后放弃了。大部分酒店的价格都是平日的 3 倍以上,比如平日 200+ 的汉庭五一期间是 900+。即使能接受这个溢价,这价格后面反映出来的需求也是恐怖的。清明假期云岗石窟已经限流了,五一期间可能会更严重。

北京-山西长城1号旅游公路、黄河1号旅游公路 1600 公里自驾游计划版

前年内蒙玩的时候,很多好看的风景都在路上。于是考虑看山西有没有风景优美的公路,看到了长城1号路和黄河1号路的建设新闻,就萌生了规划一条途经长城和黄河1号路的自驾路线的想法。在我之前,还没有看到有北京出发的同志将这两条线路连起来的规划,于是我在小红书上发布了一个路线概要设计,没想到真收到很多有价值的建议。这个路线在小红书上也累计超过了 5000 多次浏览。

实际执行路线跟最初的设计略有偏离,不过大致还是执行了下来,整体体验比内蒙克旗那次要更棒。整个行程 5 天,总里程 1647 公里,车上时间 38 个小时。

4月30日 北京-张家口 190km 3小时

孩子们中午都放假了,所以提前出发半天,躲一下五一出京的高峰。第一站选择了张家口,因为预期后面几天住的酒店都不会太好,计划在张家口住一家有泳池的豪华酒店,带孩子们玩玩水。实际上回头看,给长城1号路东线留的时间稍微短了点。

开高速错过了京礼的出口,一直走京藏多堵了一会儿。路过了鸡鸣山,在某一段正对着高速,像是平地起了座山,以后有空了可以去爬一爬。

应该是鸡鸣山吧

选的酒店是张家口北辰五洲皇冠酒店。酒店整体来说很不错,入住送水果、甜品和大堂吧饮料。泳池也挺干净的,人也少。忘了给女儿带游泳圈,租了个泳圈 10 元一次,在豪华酒店里面算亲民价了。

游泳池

晚饭在酒店附近吃了顿烀饼子,味道还不错,一家五口吃饱花了 130,蛮实惠了。

5月1日 张家口-李二口村&长城博物馆-得胜堡-丰镇 240km 7小时

我在网上搜到的天镇县长城1号路是从平远头村开始,但在平远头没有看到任何旅游公路的宣传。走到李二口村才发现长城1号旅游公路特有的红色和蓝色地面油漆,以及起点还有跨县边界的标志牌。这两个村中间还隔着座山(汗)。如果你想拍到天镇县的长城1号旅游公路的起点照片,那么最好导航到“大同长城博物馆”或者“李二口村”。

长城1号旅游公路天镇县 0 公里点
李二口村牌坊

其实从平远头村开始,公路旁边已经有明长城遗址的墩段了,也有山西省文保碑,只是公路表面没有刷漆,路边也没有旅游公路的标志牌。整个长城1号路的东段都是与长城平行的,有的离路较远,有的较近。

李二口村东边有一个建好没多久的博物馆,叫做大同长城博物馆,非常值得一看。我是在博物馆里才知道长城防线不是一条孤零零的边墙,而是由好几条边墙加墩堡组成的体系防线。大同的这道长城是比较靠外的边墙,雁门关是比较靠内的边墙。博物馆除了长城展出之外,还有一些当地的文物展,楼顶还有一个挺大的图书馆。图书馆的设计挺有特色的,人少的时候拍照应该比较出片。

大同长城博物馆
大同长城博物馆顶楼图书馆
万里长城全图

李二口村有一段依山而建的长城,可惜家人们不愿意爬山,没上去走走。村口卫生间和长城博物馆都有较大的停车场,五一去时都是免费的。

从李二口村往西到得胜堡就是网上常说的“长城一号公路东线”,这一路穿越了三个县:天镇县、阳高县和新荣区。在每个县区交界处都会有显眼的标志牌和地面涂刷,适合拍照。但要注意不要完全沿着导航行驶,而是关注路面上长城 1 号公路的标记。如果沿导航行驶,很容易沿 G512 行驶到内蒙区域内。

远望长城

东线的前半部分是山脚平原,从公路上往北看,每隔几百米就有一个长城的墩,多为夯土建造在山脚下,这个山脉是阴山的一个支脉。过了阳高县后,阴山支脉有一部分向南延伸,所以东线的后半部分需要穿过这个山脉,有一段可以俯瞰平原的山路。整个东线非常幽静,虽然经过的村子不少,但大部分村子都在路的北侧一段距离,路上遇到的车辆都很少。

山上的一座桥

东线会路过几个堡,守口堡、镇边堡、得胜堡等。这些景点只有少量的旅游开发,大部分堡里面还住着居民。感兴趣的可以下车转一转,或者放飞无人机拍一圈。

镇边堡
得胜堡

由于五一期间大同酒店太贵,选择了去内蒙的丰镇市住宿,距离得胜堡也不远。在丰镇吃了顿冰煮羊,便宜是挺便宜,但是羊肉不像義公子冰煮羊那样切的方方正正,后来上的羊肉卷也觉得味道怪怪的,不太推荐。

5月2日 拒门口-八台子-老牛湾-关河口 343km 9小时

从丰镇出来开了一段乡村小路到拒门口,这一路墩台较为不明显。因为这段的墩台距离山脉较远,已经在耕种区。从拒门口到镇宁空心箭楼,就是网上常说的“长城一号公路西段”。这段路穿越了新荣区和左云县两个县。

助马堡

西段我只走到八台子教堂,因为有时间焦虑,担心天黑前赶不到老牛湾景区。这段路中间有个威鲁堡,北侧有个月华池景区值得一看,因为旁边建着一个长城阁,可以登高望远。

长城阁

八台子教堂有两条路,一条路是穿过村子开到沟对面,远眺教堂,一条路可以开到教堂附近。

八台子教堂

整体来说,大同境内的长城一号公路自驾体验非常好,我自己认为超过内蒙克旗的达达和热阿线。因为牧区都被牧民圈地占满了,公路沿线全是低矮的铁丝网,非常煞风景。而且草原风光比较单一,长城1号公路既有人文,又有自然风光。车很少,还没有牛粪味。有机会的话,我可能还会再走一遍。

长城1号旅游公路-大同段

去老牛湾的路上经过一段高速,高速下来没多远就是偏关县,然后我又看到了熟悉的长城 1 号旅游公路。原来,从老牛湾景区到水泉镇,也改名叫做了长城1号旅游公路。这段公路都是盘山道,风景也不错,路过了建在小山包顶上的堡,还有很多大风车,很感兴趣,但没时间下车看了。

老牛湾是在入口买票,然后再开一段山路上去。我是大约 4 点到的,很多车都已经走了,所以很顺利地停到了距离老牛湾堡入口最近的停车场。看那狭窄的山路,如果高峰期来应该会堵不短时间。

老牛湾长城黄河交汇处
老牛湾堡

本来买的是老牛湾和乾坤湾联票,但时间不够了,孩子们也饿了,就没有往乾坤湾拐弯。从老牛湾往关河口驿站走就是黄河 1 号旅游公路,路上有一些观景台风景也不错,但路线离黄河比较远,路上风景一般。这一点跟加州的沿海 1 号公路差距比较大。

黄河1号旅游公路

关河口驿站是个地名,也是个酒店的名字。这个酒店位置非常赞,是偏关河入黄河的河口,悬崖上面是酒店,下面是关河口村。可惜的是假期人太多,没能住到河口的宾馆,而是安排到了往里几百米的游客集散中心。

关河口驿站和关河口村全景

5月3日 关河口-黄河1号路-五坝线-朔州 363km 9小时

从关河口往下走,两岸与黄河的落差越来越小。黄河 1 号路比较好的是路边有一些比较用心的观景台,比如硫磺窑观景台。但最刺激的观景台还是石径禅院。又叫弥佛洞,是建在黄河悬崖中的一个小寺院,很小很土,从悬崖上往下走又陡峭,但是风景真的绝美。两岸都是悬崖,黄河在这绕了个小弯,身在悬崖中间。

硫磺窑观景台
石径禅院

再往下走风景就越来越一般了,大车也越来越多,走到保德县吃了顿饭,就拐向了五寨县,准备走一下穿越芦芽山的五坝线。顺便说一下,石径禅院和五坝线都是小红书网友的推荐,听劝,非常赞!

走到五坝线入口的时候,其实已经快 4 点了。孩子又在游客中心的大停车场玩了好大一会儿无人机,固定翼泡沫无人机。北京没办法玩,出来看到大的场地就想玩会儿。本来期待不是很高,没想到越往里走越惊艳。先是河谷公路伴随着潺潺的小溪,后是碧绿的缓坡草甸,然后是挡住天空的,密密麻麻的松林,太棒了!就是道路太窄,也就是防火道的宽度,五一天气下部分路段还有结冰。上升的过程中温度降了得有 10 度,特别担心车会不会坏在路上。还好一个多小时的行程,天黑之前已经穿出了山路。

五坝线河谷
五坝线草甸
五坝线松林

晚上住在朔州的一个汉庭,早餐非常好。不知道朔州也有古城,第二天走的时候才看到,错过了。

5月4日 朔州-代县-平型关-涞源湖 303km 6小时

代县的确是个宝藏县城,文庙的门口就是停车场,边靖楼一圈就是个环岛,而且可以在缝隙里停车,阿育王塔在县政府后院里面,开车还能直接进。

代州文庙
边靖楼

年纪大了,觉得看看古建还挺有意思的,小娃们不太懂。

从代县去平型关的路上,又看到了长城1号旅游公路,不过这次是灵丘县的长城1号旅游公路,起点在天神线向平型关转向的路口。

平型关大捷纪念馆还是值得一看的,主要是了解从 1937 年七七事变到 9 月份平型关大捷,日本侵华的动向和国共两党合作、动员、联合作战的过程。这短短的两个多月在很多历史书上也就是一笔带过,但是平型关大捷纪念馆中有比较详细的记录。

平型关大捷纪念馆

最让人震撼的是最后一项展览:从平型关大捷中走出来的将帅。元帅、大将、上将、中将的照片有几面墙,少将都不配有照片(227位)。

平型关大捷纪念馆是依山而建,前、后和侧面都有停车场。后广场还有武器展出,战斗机就停放在停车场中间。这可能是我的车唯一一次能和飞机合影的机会了。

车和飞机合影

涞源湖没啥好说的,就是一个休息的中间点。

5月5日 涞源湖—北京 212km 4.5小时

最后一天就直接返京了,进京检查站秒过,没想到最后在杜家坎堵了快1小时。

Google Search 淘气三千问: Q7~Q9

专题目录

Q7: Google 是怎么做线上实验的?

在我 18 年写的这篇博客《ABTest 平台设计 - 如何进行流量分桶》里,我就引用了 Google 2010 年 KDD 发表的层叠并行实验平台论文《Overlapping Experiment Infrastructure: More, Better, Faster Experimentation》。国内很多公司的在线实验平台,可能都是通过参考这篇论文而起步的,所以实验平台这块不再多说,感兴趣的可以看下论文,或者我之前的系列文章。

这里我想说的是实验变量的控制。在上一篇博客《Google Search 淘气三千问: Q6》中提到了一些特征变量,在看原始文档时你会发现它的类型可能会是 QualityNsrVersionedFloatSignal,其实这就是一个带版本的浮点数组。

简单看规律,那就是如果一个特征是 ground truth 类统计特征,它在协议中就只是一个数值类型;如果一个特征是模型打分类特征,它可能就会是一个带版本的数值数组。

这也就是说,Google 在设计协议的时候,就已经考虑到了哪些特征需要做实验,而哪些不用。这会让他们的线上实验更加容易和系统化。

BTW,在 Google ContentWarehouse API 中读到 Google 把线上实验称作 Live Experiment,简称 LE,这将对我们阅读其它参考资料时有所帮助(见Q8)。

GoogleApi.ContentWarehouse.V1.Model.QualityNsrExperimentalNsrTeamData
Experimental NsrTeam data. This is a proto containing versioned signals which can be used to run live experiments. This proto will not be propagated to MDU shards, but it will be populated at query time by go/web-signal-joins inside the CompressedQualitySignals subproto of PerDocData proto. See go/0DayLEs for the design doc. Note how this is only meant to be used during LEs, it should not be used for launches.

Q8: Google 做 Live Experiment 时关注哪些核心指标?

在美国司法部起诉 Google 的案子中,一个由 Pandu Nayak 起草的标题为《Ranking Newsletters » 2014 Q3 Ranking Newsletters » Aug 11 - Aug 15, 2014》的文件被作为证据提供,里面提到了 Google 2014 年在做 LE 时观察的几个核心指标:

  • CTR: 点击率,这个可能不用解释
  • Manual Refinement: 手工优化(Query)的平均次数,当你对搜索结果不满意时,你可能会手工修改 Query 内容再次发起搜索
  • Queries Per Task: 单任务 Query 数,解决一个需求时的平均搜索次数
  • Query lengths (in char): Query 平均长度,以字符为单位
  • Query lengths (in word): Query 平均长度,以单词为单位
  • Abandonment: 平均放弃(次数?),当你在搜索完成后不再继续搜索时,被视为一次放弃
  • Average Click Position: 平均点击位置,在搜索结果页中用户可能会点击多条结果,对多条结果的位置进行平均。
  • Duplicates: 重复搜索行为,可能是因为网络、速度等问题,导致用户重试

这肯定不是 Google 做 LE 时观察的所有指标,但肯定是其中最重要的几个。因为这份文件讨论的内容是 2014 年 Google 用户在桌面和手机端的行为和意图差异,以决定 Google 在两端的工作计划,这在当时应该是非常重要的一件事。

Q9: Google 怎么衡量 Query 的用户满意度?

在《Ranking Newsletters » 2014 Q3 Ranking Newsletters » Aug 11 - Aug 15, 2014》这个文件里,还提到一个很关键的信息,就是 Google 怎么衡量搜索 Query 的用户满意度(在 one-box 直接满足的场景)。这可是搜索引擎的核心问题,因为你只有知道用户对什么满意,才能保证你的产品方向是对的。

衡量 Query 满意度的第一个指标,是 singleton abandonment。singleton abandonment 是指一次孤立的搜索行为,即在这次搜索前用户没有搜索任何 Query,在这次搜索后用户也没有更换 Query 进行第二次搜索。

文件里提到一个有意思的点:Google 之前的一个研究发现,非 singleton abandonment (换了很多 Query 后放弃了继续搜索)能更好的刻画不满意率,但不适合刻画满意率。虽然 singleton adandonment 不能孤立的作为一个强正向信号(用户不满意也可能放弃继续搜索),但在有直接答案的情况下,比如结果页内就能满足,比如天气、词典等,Google 认为它是一个足够的正向信号。

衡量 Query 满意度的第二个指标,被涂黑了。但这是唯二的两个指标,我非常感兴趣,所以我进行了一个大胆的猜测。我把原文片段和猜测的部分放到了下面:

第一个涂黑的地方太短,又很重要,因为文件说 Google 把它当成一个明确的正向信号。所以我猜测是 CTR,但又不确定有什么修饰词。Page CTR 这个词大家不常说,但是 Google AdSense Help 里对这个术语有定义。第二个涂黑的地方涂得不完全,露出了一些字母边缘,虽然长但还是能硬猜一下。希望不要误导大家。

把 CTR 当成一个满意信号很好理解:如果 Query 通过摘要满足了,那用户就没有其他行为了,就是 singleton abandonment;如果摘要没满足,但是用户点击消费了搜索结果,然后也没有继续搜,那就是 singleton CTR

也就是说,在衡量 One-Box(天气、词典等摘要满足的结果) 对用户 Query 满意度的影响方面,Google 使用了 singleton abandonment 和整页级别的点击率作为指标。

Google Search 淘气三千问: Q6

专题目录

Q6: Google 为站点设计了哪些特征?

在 Google ContentWarehouse API 中有很多字段以 nsr 开头,有人说它代表 Neural Search Ranking,我觉得这种说法不对,因为 nsrDataProto 字段的注释是 Stripped site-level signals:

GoogleApi.ContentWarehouse.V1.Model.PerDocData

* nsrDataProto (type: GoogleApi.ContentWarehouse.V1.Model.QualityNsrNsrData.t, default: nil) - Stripped site-level signals, not present in the explicit nsr_* fields, nor compressed_quality_signals.

说明 NSR 应该是站点信号,考虑到 QualityNsrNsrData 中还有一个 nsr 字段,我猜测应该是 New Site Rank,或者 Normalized Site Rank。也许这个字段以前只是一个简单的代表站点质量的信号,后来扩展成了一系列信号的组合,但是沿用了 nsr 这个前缀。

其中字段注释非常简单,这里我尝试把他们汇编并且扩展解读一下,看看 Google 的站点特征体系都包括哪些。

首先要解释一点,Nsr 虽然是站点信号,但是它的信号汇聚粒度并不全是站点范围,而是有个 sitechunk 的概念。我猜测 sitechunk 可能会代表一个子域,或者一个比较关键的路径前缀,比如一个网站有新闻、博客或者论坛,那就会有不同的 sitechunk。这样允许 Google 针对同一个域名下的不同的频道、标签做不同的分析。所以下面讨论到所有的站点信号,都应该理解成是 sitechunk 信号。

QualityNsrNsrData 中,有以下这些特征:

  • smallPersonalSite:为个人博客小站提权的分数。我一直觉得谷歌对博客网站很友好,果然在站点特征体系中有专门的提权打分。
  • siteAutopilotScore:如果一个网站的内容都是自动生成的,它会被称为是一个 Autopilot Website。这个分数是描述这个站点下所有页面自动生成方面评分的一个汇总值。
  • isVideoFocusedSite:如果站点有超过一半的页面是视频播放页面,而且它又不是一些知名的视频网站,那么这个特征就是 true。
  • ugcScore:与上面分数相似,可能是这个站点每个页面是否为 UGC 内容的评分的一个汇总值。
  • videoScore:与 ugcScore 相似,可能是这个站点每个页面是否为视频播放页的评分的汇总。
  • shoppingScore:与 ugcScore 相似,可能是这个站点每个页面是否为商品购买页的评分的汇总。
  • localityScore: 与 ugcScore 相似,可能是这个站点每个页面是否为 LBS 服务页的评分的汇总,不过这里提到了一个叫做 LocalAuthority 的模块/策略,希望以后能弄懂它。
  • articleScoreV2: 与 ugcScore 相似,可能是这个站点每个页面是否为文章页的评分的汇总。
  • healthScore: 与 ugcScore 相似,可能是这个站点每个页面是否为医疗健康页的评分的汇总。
  • ymylNewsV2Score:无注释,YMYL 是 Your Money Your Life 的缩写,这里可能是指这个站点每个页面是否为敏感(健康、金融相关的)新闻页的评分的汇总。
  • clutterScore: 判断站点是否加载了很多乱七八糟的内容,比如加载了很多不同来源的广告之类。
  • clutterScores: 带版本的 clutterScore。
  • racterScores: 站点级别 AGC 分类打分;
  • titlematchScore:大概是这个网站每个网页的标题,能匹配上多少 Google Query 的一个综合评分;
  • siteQualityStddevs: 站点质量标准差,从名字判断,可能来自于站点所有网页的站点质量得分的统计。从这些方差指标可以看出,Google 很在乎站点内容的一致性,可能对页面质量参差不齐的站点有打压。
  • chromeInTotal:站点级别的 Chrome 访问量;
  • impressions:站点在 Google 搜索结果中的展现次数;
  • chardEncoded: 有人说 chard 代表 CHrome AveRage Duration,站点平均停留时间,我本来猜测可能是 CHrome Average Returning Days,或者 CHrome Average Retention Data。核心就是我觉得这是一个留存指标,留存比时长更能体现网站的受欢迎程度。但注释中又说它是 site quality predictor based on content,所以也许我的理解是错的,也许 c 是 Content?但是 hard 是什么,我实在猜不出来了。
  • chardVariance:站点(首页) chard 的方差。
  • chardScoreEncoded: 站点中所有页面的 chard 得分;
  • chardScoreVariance:站点所有页面 chard 得分的方差。
  • nsrVariance: 站点首页 nsr 与站点所有页面质量均值的差;
  • siteQualityStddev: 站点所有页面质量的方差,与 nsrVariance 不同,它衡量的是页面之间的方差;
  • tofu: 与 chard 一样,都是基于 content 算出来的一个得分。tofu 是豆腐块的意思,在网页里可能代表了页面内有多少个豆腐块区域,或者有多少个豆腐块广告。
  • pnavClicks:PNAV 大概是指 Primary Navigation,即站点的主要导航链接。这个值是对主要导航链接点击数的一个分母,可能在某个地方记录了这个站点每个导航链接的点击数,这样就能算出来哪些导航更受欢迎,也许是用在搜索结果页中展示站点的关键导航上;
  • pnav: 一个分位值,可能是主要导航链接占页面链接数比例?
  • vlq: 视频低质量模型的打分,猜测 LQ 代表 Low Quality。
  • vlqNsr: 针对低质量视频站点设计的一个额外的 nsr 打分,有可能是为了避免这些站点 nsr 得分过低,导致一些用户需求不满足(例如某类视频)。
  • siteLinkOut: 这个站点所有外链的平均得分;
  • siteLinkIn: 这个站点所有内链(反向链入的页面)的平均得分;
  • siteChunkSource: sitechunk 来源,可能是记录怎么分的 chunk;
  • spambrainLavcScores:这个没有注释,看起来是 Google 有一个 spambrain,会给站点一个 Lavc 分数,应该表示网站是否有 spam 行为的打分;
  • sitePr:站点的 PageRank。
  • nsr: 也许是最原始的 Normalized Site Rank,用一个分值代表站点质量。
  • versionedData: 实验版本的 nsr 值,当算法升级后 nsr 计算逻辑与以前不同时,先拿它用来做实验;
  • priorAdjustedNsr: 先验调整 nsr,用于判断当前站点的 nsr 在它所属的 slice 里比平均 nsr 高还是低;
  • ketoVersionedData: 带版本的 keto 数据,包括站点得分和站点得分在所有站点中的分位值。keto 可能代表了一个策略,含义未知。
  • nsrOverrideBid: 用来干预 nsr,当它的值提供并且大于 0.001 时,直接覆盖掉 nsr。也就是说可以通过人工干预调高或者调低某个站点的 nsr。
  • nsrEpoch: nsr 最早的获取时间;
  • siteChunk: nsr 对应的主 sitechunk,即分析出来的 sitechunk 对应的文档 URL;但文档中提到在一些稀有情况下,它可能基于网页中的一个标记。我猜测像一些 Single Page Application,URL 全部使用 # 页内标记,这种情况下只能使用页内标记来标记 sitechunk。
  • secondarySiteChunk: nsr 对应的二级 sitechunk,如果存在的话,划分比 sitechunk 粒度更细。
  • i18nBucket: 属于哪(几)种语言,这是一个 int 值,也许会是一个 bitmap,可以把站点放入多个语言桶中。
  • language: 站点的语言,暂不清楚与 i18nBucket 的差异,因为它也是一个 int 值。
  • isCovidLocalAuthority: 是否为 Covid 本地官方网站,也许是在疫情期间对官方网站消息的提权;
  • isElectionAuthority: 是否为(美国)选举相关的官方网站;
  • directFrac: 无注释,我猜测是 Chrome 输入 URL 直接访问的 PV 占所有访问量的占比。
  • site2vecEmbedding: 看起来像是将上面的每个站点 nsr 特征,综合起来表达成了一个稀疏的 embedding,可能是 one-hot 编码那种,也可能是稀疏模型编码;
  • site2vecEmbeddingEncoded:这里是一个压缩版本的 embedding,主要用于 SuperRoot。
  • nsrdataFromFallbackPatternKey: 如果为真,代表以上的 nsr 特征都来自于其它站点;
  • url:站点的 URL;
  • host: 站点的域名或者主机名;
  • clusterId:站点所属站群的 ID,被一个叫做 Tundra 的生态项目所使用,这个项目在文档中也出现过多次,希望后面能弄清楚它的含义。站群一般是指页面互相之间有链接的一批站点,会被用来做 SEO 提升 pagerank,看起来 Google 对这种行为是有识别的。
  • clusterUplift: 与上面提到的 Tundra 项目有关,主要看站群是不是小站,是不是本地站,可能是用于站点的提、降权;
  • metadata: 记录了一些在不同系统里查找 nsr 数据的 key,或者一些数据的生成时间。

Google Search 淘气三千问:Q1~Q5

专题目录

前言

两个多月前(2024年5月27日),Google 的一份名为 GoogleApi.ContentWarehouse 的 API 文档受到 SEO 圈的关注,由于这份文档的内容和 Google Search 副总裁 Pandu Nayak 在 2023 年美国司法部(DOJ)起诉 Google 的案件中的证词和 Google 的一些专利高度一致,因此其真实性被广泛认可。

后续有媒体称 Google 发言人回应了文档泄露的问题(没有承认、也没有否认):

A Google spokesperson sent us the following statement:

“We would caution against making inaccurate assumptions about Search based on out-of-context, outdated, or incomplete information. We’ve shared extensive information about how Search works and the types of factors that our systems weigh, while also working to protect the integrity of our results from manipulation.”

此前大部分讨论仅限于猜测 API 文档中的各种信号在排名算法中的作用,以及对谷歌是否在排名算法上欺骗了大家。很少人意识到,这篇 2500 页的文档可以作为以往 Google 公开论文的补充,一本叫做《如何构建一个世界级(成功的)搜索引擎》的武功秘籍撕下来的几页。

而偏偏我对这本武功秘籍非常好奇,试图在这几页上再加一些批注,就有了 《Google Search 淘气三千问》这个系列。这个系列会有几篇文章,我不知道,主要看我能想到多少问题。

为什么叫“淘气三千问”这个名字?可以把它看成是一种传承吧,懂的都懂,不懂的也不影响阅读。

为避免误导读者,这个系列所有的回答里,来自公开信息的我都会标注来源,没有标注来源的,你可以认为是 inaccurate assumptions about Search based on out-of-context, outdated, or incomplete information。当未来有了更新的信息,我有可能回到博客来更新这些 assumptions(公众号文章无法更新)。

如果你有新信息可以给我,或者纠正文中的错误,欢迎评论或者到公众号“边际效应”私信,谢谢!

Q1: Google 的索引分了几层(Tier)?依据什么?

Google 在 2012 年的论文《Indexing the World Wide Web: The Journey So Far 》中提到产业实践中大规模索引都是会分成多个桶(tier),一般按照更新频率来分:

The way we have described search indices so far makes a huge assumption: there will be a single unified index of the entire web. If this assumption was to be held, every single time we re-crawled and re-indexed a small set of fast-changing pages, we would have to re-compress every posting list for the web and push out a new web index. Re-compressing the entire index is not only time consuming, it is downright wasteful. Why can we not have multiple indices -- bucketed by rate of refreshing? We can and that is what is standard industry practice. Three commonly used buckets are:

1. The large, rarely-refreshing pages index
2. The small, ever-refreshing pages index
3. The dynamic real-time/news pages index

...

Another feature that can be built into such a multi-tiered index structure is a waterfall approach. Pages discovered in one tier can be passed down to the next tier over time.

在 Google ContentWarehouse API 里有这样一段 :

GoogleApi.ContentWarehouse.V1.Model.PerDocData

* scaledSelectionTierRank (type: integer(), default: nil) - Selection tier rank is a language normalized score ranging from 0-32767 over the serving tier (Base, Zeppelins, Landfills) for this document. This is converted back to fractional position within the index tier by scaled_selection_tier_rank/32767.

可以看到,Google 仍然是把索引分了 3 层,现在我们有了它们的名字,分别是:Base(基础)、Zeppelins(飞艇) 和 Landfills(垃圾填埋场)。在每一层之内,scaledSelectionTierRank 这一归一化分数决定了它所在位置的分位数。分位数最大值是 32767,猜测也许是 Google 在索引存储里只给它留了 15 bits(2^15=32768)。

但从索引分层的名字来看,这三层并不(全)是按照时效性分的,至少第三层,看着是按照文档质量分的。因为你把文档放到“垃圾填埋场”中,大概率因为它的质量较差而不是不再更新。那么 scaledSelectionTierRank 也许就代表了层内的文档质量等级。

网友 avanua 对这三层的命名提供了一个解读,我觉得非常合理,因为我一直困惑第二层为什么叫做 Zeppelins:

我觉得 Tier 命名和质量无关,可能只是用来描绘更新频率:
​Zeppelins 在气流中起起伏伏
Base
​Landfills 几乎不会再翻动,上下层叠关系是固定的

Q2: Tier 内的 scaledSelectionTierRank 有什么作用?

在 《Indexing the World Wide Web: The Journey So Far》中提到,在倒排拉链中最好按照文档实际的影响力对文档列表进行排序。如果仅仅是这样,那么只需要知道文档 0 比文档 10000 更重要即可,那么额外记录一个打分的目的,其实是可以让这个分数参与排序过程。文档在某个 Query 下的得分,是文档影响力得分乘以文档在 Query-term 下的权重。

Since it made sense to order the posting lists by decreasing term frequency, it makes even more sense to order them by their actual impact. Then all that remains is to multiply each posting value by the respective query term weight, and then rank the documents. Storing pre-computed floating-point document scores is not a good idea, however, since they cannot be compressed as well as integers. Also, unlike repeated frequencies, we can no longer cluster exact scores together. In order to retain compression, the impact scores are quantized instead, storing one of a small number of distinct values in the index.

从上文中有理由认为,scaledSelectionTierRank 就是文中提到的量化以后的文档影响力得分,量化就是将其归一化到 32768 个分档之中。

Q3: Google 搜索系统主要分成几个部分?

通过 API 和其它公开文档,目前我能够分析出来的搜索系统组成部分有以下这些。随着阅读的深入,可能还会有新的部分加进来。

爬虫系统:Trawler

在 Google ContentWarehouse API 中有一系列 API 以 Trawler 为前缀,并且从上下文中看出来 Trawler 是一个实体系统并且有一个研发团队。

GoogleApi.ContentWarehouse.V1.Model.TrawlerCrawlTimes
GoogleApi.ContentWarehouse.V1.Model.TrawlerFetchReplyData
GoogleApi.ContentWarehouse.V1.Model.TrawlerHostBucketData

* TotalCapacityQps (type: number(), default: nil) - The following four fields attempt to make things simpler for clients to estimate available capacity. They are not populated yet as of 2013/08/21. Even after they are populated, they may change. So talk to trawler-dev@ before you use the fields. Total qps for this hostid

去重系统:WebMirror

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.CompositeDocAlternateName

Alternate names are some urls that we would like to associate with documents in addition to canonicals. Sometimes we may want to serve these alternatenames instead of canonicals. Alternames in CompositeDoc should come from WebMirror pipeline.

每个 CompositeDoc 都有一些替代的 URL,这些 URL 来自 WebMirror 流水线,那么 WebMirror 应该是识别重复文档的一套系统。

离线索引构建系统:Segindexer + Alexandria

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.AnchorsAnchor

* sourceType (type: integer(), default: nil) - ... In the docjoins built by the indexing pipeline (Alexandria), ...

所以 Alexandria 应该是建库系统。而 Segindexer 和 Alexandria 曾经并行出现过:

GoogleApi.ContentWarehouse.V1.Model.ClassifierPornClassifierData

* imageBasedDetectionDone (type: boolean(), default: nil) - Records whether the image linker is run already. This is only used for Alexandria but NOT for Segindexer.

考虑到关键的表示原始文档内容的 compositedoc.proto 在 Segindexer 目录下:

GoogleApi.ContentWarehouse.V1.Model.NlpSaftDocument

* bylineDate (type: String.t, default: nil) - Document's byline date, if available: this is the date that will be shown in the snippets in web search results. It is stored as the number of seconds since epoch. See segindexer/compositedoc.proto

从名字和上述信息有理由怀疑 Segindexer 是在 Alexandria 之前,决定了索引分层,或者分 vertical 的一个分类模块。

在线索引服务系统:Mustang 和 TeraGoogle

在 《Indexing the World Wide Web: The Journey So Far》中我们知道,TeraGoogle 是 Google 在 2005 年实现的一套 large disk-based index 服务系统。而在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.CompressedQualitySignals

A message containing per doc signals that are compressed and included in Mustang and TeraGoogle.

这里将 Mustang 和 TeraGoogle 并列,有理由认为 Mustang 是 2005 年之后 Google 开发的一套替代或者部分替代 TeraGoogle 的在线索引服务系统。

实时索引服务系统:Hivemind(muppet)

Realtime Boost 这篇文档中有一张图,揭示了一个新网页进入索引的过程,其中实时索引的系统叫做 Hivemind。但 Hivemind 应该是个集群名,实际的索引系统方案可能是叫做 muppet。因为在 RealtimeBoost Events - DesignDoc 这篇文档中也提到了一个用 muppet 支持的系统,叫做 ModelT,这是一个索引实时事件的索引系统。

实时索引的建库系统可能与非实时索引不同,叫做 Union。

查询汇聚系统:SuperRoot

在 Google ContentWarehouse API 中多次出现 SuperRoot 这一模块,而在 Jeff Dean 2009 年 WSDM 的 《Challenges in Building Large-Scale Information Retrieval Systems》 分享第 64 页,SuperRoot 被描述为聚合 Web、Images、Local、News、Video、Blogs 和 Books 所有检索子系统的汇聚模块,这个定位也许没有变。

Query 改写模块:QRewrite

RealtimeBoost Events - DesignDoc 这篇文档中说:

Currently in production RealtimeBoostServlet runs in QRewrite detects spikes on news documents published that match the SQuery (including syns). It does so by issuing one or two RPCs to Realtime-Hivemind.

The RealtimeBoostResponse containing the Spike is sent down to Superroot in the QRewrite response and it is currently used by a few Search features (such as TopStories) to trigger faster and rank fresher documents for queries that are spiking for a given news event.

所以 QRewrite 是会被 SuperRoot 调用的一个 Query 改写模块,它会将用户实际发起的搜索 Query 改写成 SQuery,S 可能是代表 Superroot。而到真正发起检索时,会进一步转成检索 Query,比如发给 muppet 的 Query 叫做 MQuery。

摘要模块:SnippetBrain

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.MustangReposWwwSnippetsSnippetsRanklabFeatures

* displaySnippet (type: GoogleApi.ContentWarehouse.V1.Model.QualityPreviewRanklabSnippet.t, default: nil) - Snippet features for the final chosen snippet. This field is firstly populated by Muppet, and then overwriten by Superroot if SnippetBrain is triggered.
  • 看起来 SnippetBrain 是一个可选的摘要生成模块。

入口服务:GWS

Google Web Server,这个大家都知道,还有 Wikipedia 词条

Q4: TeraGoogle 是怎样一套系统?

根据论文《Indexing the World Wide Web: The Journey So Far》和专利《US7536408B2: Phrase-based indexing in an information retrieval system》,TeraGoogle 应该有以下几个属性:

  • Disk-based Index:索引存储在磁盘上,在需要的时候读入到内存中,而且往往不需要全部读入,针对重要的文档有一些优化;
  • Phrase-based Indexing:构建索引的时候不仅仅有 term 索引,还会建设多 term 的短语索引,这样索引库里会有更多的倒排链;
  • Document-Partitioned Index:将索引分 Shard 的时候,按照文档进行分片,即同一批文档的所有拉链放在同一个 Shard 上,这样每个 Shard 上有所有的拉链,查询在一个节点内即可完成。在论文中只对比了 Document-Partitioned 和 Term-Partitioned 二者的差异,在 Jeff Dean 2009 年 WSDM 的 《Challenges in Building Large-Scale Information Retrieval Systems》 分享第 17 页确认了 Google 的选择。

Q5: Google 的文档是什么概念?

从 Google ContentWarehouse API 里:

GoogleApi.ContentWarehouse.V1.Model.CompositeDoc
Protocol record used for collecting together all information about a document. 

可以看到 CompositeDoc 是在系统里非常重要的概念,它定义了一个文档的所有信息。在它的所有字段中我们发现,url 又是一个可选的字段,这也就是说,文档并不一定需要是一个网页。像 localinfo,看起来就像是一个 POI 信息。也就是说在 Google 的系统里,不一定只有网页索引,可能每个 POI 点、图片、商品也是一种文档,所以它使用 CompositeDoc (复合文档) 而不是 WebPage 作为整个系统里对文档的刻画。

估值最高的 AI 搜索独角兽 Perplexity 使用倒排索引做 RAG

3 月份我曾写了一篇博客《在 LLM 时代我们是否还需要倒排索引? 》,探讨了在向量数据库崛起的情况下倒排索引仍然存在的价值。

也许仍然有人对传统搜索技术弃如敝履,但前几天我正好看到一个 Lex Fridman 对 Perplexity CEO Aravind Srinivas 的采访,他表示 Perplexity 仍然使用(可能不是仅使用)倒排索引和 BM25 传统算法进行全网搜索。作为估值 30 亿美元的 AI 搜索独角兽公司,在全网搜索上却也选择了倒排索引,而且访谈中非常强调倒排的重要性,这应该算是印证了我的观点吧。

他的这些思考对我也有一些启发,为了保持互联网的记忆,我将这些相关节选的原文和翻译记录如下:

Aravind Srinivas 2024年6月20日采访节选

这个 Podcast 有 3 个小时,可以从 2:03:52 开始看。

Lex Fridman (02:03:52)

这是一个通过(将内容)嵌入到某种向量空间实现的完全的机器学习系统吗?
Is that a fully machine learning system with embedding into some kind of vector space?

Aravind Srinivas (02:03:57)

它并不是纯粹的向量空间。并不是说内容一旦被获取,就会有某种 BERT 模型在所有内容上运行并将其放入一个巨大的向量数据库中进行检索。
It’s not purely vector space. It’s not like once the content is fetched, there is some BERT model that runs on all of it and puts it into a big, gigantic vector database which you retrieve from.

这并不是那样的,因为将网页的所有知识打包到一个向量空间表示中是非常困难的。首先,向量嵌入在处理文本时并没有想象中的那么神奇。理解一个文档是否与一个特定检索词相关非常困难。它应该是与检索词中的人物有关,还是与检索词中的特定事件有关,更或者它是基于对检索词更深层次的理解,使得检索词可以应用到另外一个人物,他也需要被检索到?什么才是(向量)表示应该捕捉的内容?大家可能会不断争论。而让这些向量嵌入具有不同维度,彼此解耦并捕捉不同语义是非常困难的。
It’s not like that, because packing all the knowledge about a webpage into one vector space representation is very, very difficult. First of all, vector embeddings are not magically working for text. It’s very hard to understand what’s a relevant document to a particular query. Should it be about the individual in the query or should it be about the specific event in the query or should it be at a deeper level about the meaning of that query, such that the same meaning applying to a different individual should also be retrieved? You can keep arguing. What should a representation really capture? And it’s very hard to make these vector embeddings have different dimensions, be disentangled from each other, and capturing different semantics.

顺便说一句,这只是排序部分的内容。还有索引部分——假设你有一些处理后的 URL,排序模块会基于你提问的检索词,从索引中召回相关的文档和一些打分。这就是为什么,当你的索引中有数十亿个页面,而你只需要前 K 个结果时,你必须依赖近似算法来获取这些前 K 个结果。
This is the ranking part, by the way. There’s the indexing part, assuming you have a post-process version for URL, and then there’s a ranking part that, depending on the query you ask, fetches the relevant documents from the index and some kind of score. And that’s where, when you have billions of pages in your index and you only want the top K, you have to rely on approximate algorithms to get you the top K.

Lex Fridman (02:05:25)

所以这就是排序,但是将页面转换为可以存储在向量数据库中的内容,这一步似乎非常困难。
So that’s the ranking, but that step of converting a page into something that could be stored in a vector database, it just seems really difficult.

Aravind Srinivas (02:05:38)

它并不必须全存在向量数据库中。你可以使用其他数据结构和其他形式的传统检索。有一种算法叫做 BM25,它正是为此设计的,是 TF-IDF 的一个更复杂的版本。TF-IDF 是词频乘以逆文档频率,是一种非常老派的信息检索系统,实际上即使在今天也仍然非常有效。BM25 是它的一个更复杂的版本,仍然在许多排名中击败大多数嵌入。当 OpenAI 发布他们的嵌入时,围绕它有一些争议,因为在许多检索基准测试中,它甚至没有击败 BM25,这并不是因为他们做得不好,而是因为 BM25 太好了。这就是为什么仅靠纯粹的嵌入和向量空间并不能解决搜索问题。你需要传统的基于词项的检索,你需要某种基于 NGram 的检索。
It doesn’t always have to be stored entirely in vector databases. There are other data structures you can use and other forms of traditional retrieval that you can use. There is an algorithm called BM25 precisely for this, which is a more sophisticated version of TF-IDF. TF-IDF is term frequency times inverse document frequency, a very old-school information retrieval system that just works actually really well even today. And BM25 is a more sophisticated version of that, that is still beating most embeddings on ranking. When OpenAI released their embeddings, there was some controversy around it because it wasn’t even beating BM25 on many retrieval benchmarks, not because they didn’t do a good job. BM25 is so good. So this is why just pure embeddings and vector spaces are not going to solve the search problem. You need the traditional term-based retrieval. You need some kind of NGram-based retrieval.

Aravind Srinivas 2023年12月14日采访节选

其实在去年 Unsupervised Learning 的 Jacob Ephron 采访 Aravind Srinivas 时,他也有过类似的表述,但是没有像最新一次采访那样强调倒排的重要性。这个 Podcast 也很长,可以从 28:08 开始看。

Aravind Srinivas (28:08)

很多人认为,既然我们在网页搜索的 RAG 方面如此擅长,那么 Perplexity 就能轻松搞定内部搜索。不,这是完全不同的两回事。
A lot of people think that because we are so good at RAG for web search, Perplexity will just nail internal search. No, it's two completely different entities.

Google Drive 的搜索之所以糟糕是有原因的。你可能会想,Google作为网页搜索的王者,怎么会这么差劲?他们之所以差劲,是因为索引机制完全不同,需要训练的嵌入模型也完全不同。这不仅仅是嵌入模型的问题,还包括你如何对页面进行摘要、如何进行文本召回——如何使用传统的基于TF-IDF的倒排索引来构建弹性索引,这些都大不相同。
There is a reason why search on Google Drive sucks. Like would you expect Google the king of web search to be so bad? They're bad because of a reason that the indexing is so different, the embeddings that you got to train are so different. Not just the embeddings, but even the way you snippet a page, your text retrieval——the elastic index that you're building with traditional TF-IDF based inverted indexes are so different.

所以需要某家公司专注于这种场景,就像我们专注于网页搜索场景一样。RAG 是一项非常艰巨的任务,在生成式 AI 之外还有很多工作需要完成。这不仅仅是训练一个大型的嵌入模型就能解决的问题。
That you need a company to just obsessively focus on that use case. Just like how we are obsessively focused on the web search use case. So RAG is going to be pretty hard and there's a lot of work that needs to be done outside of generative AI. It's not just training a large embedding model and you're done.

我记得当 OpenAI 发布嵌入 API 时,Sam Altman(OpenAI的CEO)在推特上说,下一个万亿公司可能只需要接入这个 API 就可以建立起来。这虽然是一种很好的营销方式,但事实并非如此。他当时说的是一万亿美元。所以,当听到有人声称他们已经解决了 RAG 问题时,我会非常谨慎。他们可能只是在某个场景下做得很好。
I remember like when OpenAI releases embedding API, Sam Altman tweeted the next 1 trillion company can just plug into this API and be built. That's not true. It's a good way to market it. But that's not true at all. Sorry, he said 1 trillion dollars. So I think that's why I would be very careful when somebody makes claims that they've solve RAG. They probably can do it really well for one use case.

此外,在排名中还有很多其他因素需要考虑,才能使答案真正出色。即使 LLM 最终决定了哪些链接用于答案,你也不能仅仅将一些垃圾信息放入 prompt 中,就指望它能神奇地只选择最相关的链接,并在答案中给出引用。
You know and also there are so many more things to handle in the ranking. That'll make the answer really good. Cuz even though the LLMs are finally the ones that are taking which links to use for the answer, it's not like you can just dump garbage into the prompt and it'll just be magically so good that it'll only take the top most relevant links in the answer and give you the citations with them.

事实上,你向这些长上下文模型提供的信息越多,最终出现幻觉的可能性就越大。因此,你实际上需要在检索模块上投入大量工作,不仅仅是嵌入向量,在索引、嵌入向量和排序上都要投入。排序也应该包含很多除了向量内积之外的其他信号,具体是哪些信号事实上取决于你的场景。
In fact the more information you throw at these really long context models, the more chances that you have a hallucination at the end. So you actually have to do a lot of work in the retrieval component, not just the embeddings, the indexing, the embeddings and the ranking. Ranking should also have a lot of signals outside of just the vector dot products. And then what is those signals are really depend on your use case.

分析(在2024年8月)

从以上不同时期 Aravind 的公开披露的信息分析,几乎可以说 Perplexity 在当前时间点,在召回阶段主要(如果不是全部的话)依赖倒排索引,在排序阶段会用到嵌入向量和其它信号,并且他们很重视除了嵌入向量之外的其它信号。

LLM 推理优化 Prefix Caching 及其实现

Prefix Caching

上文中提到,Prompt 计算(Prefill 阶段)和生成阶段的计算特性很不相同。为避免重复计算,所有框架的 prefill 阶段主要作用就是给迭代的生成阶段准备 KV Cache。但这些 KV Cache 仅仅是为单次生成式请求服务的,那很自然的一种想法就是,KV Cache 能不能跨请求复用?

在某些场景下,多次请求的 Prompt 可能会共享同一个前缀(Prefix),比如拟人 Agent 的人物设定,文档阅读理解时的文档内容等。这些情况下,很多请求的前缀的 KV Cache 计算的结果是相同的,像一般互联网服务的请求缓存一样,可以被缓存起来,给下一个请求复用。

限制

但 KV Cache 跟其它服务缓存不一样的地方是,它太大了,以至于(目前)很难通过 Redis/Memcache 这种分布式缓存服务存取。比如对 13B LLM 模型来说,在 FP16 精度下单 token 的 KV Cache 大约是 1MB,假设要缓存的前缀有 500 个 token(大约800多个汉字),那就是 500MB。一般来说,我们不会每次请求去从分布式系统里读取/传输 500MB 的缓存,甚至都不会每次请求从内存往显存中拷贝 500MB 的缓存,所以大部分情况下,prefix cache 都会放在显存里。

这也就意味着,如果你想命中 prefix cache,必须把相同 prefix 的请求发到同一张 GPU卡上才行。

实现

由于不是普遍需求,加上前面说的限制,prefix caching 作为一个加速特性,不是很受关注,一般也不是默认开启的。各框架的实现和配置略有差异,这里简单做下记录,便于回顾。

刚开始 vLLM 的实现是给 generate 接口增加一个 prefix_pos 参数,通过 prefix_pos 输入参数为每个请求指定 prefix 长度,为 prefix 建一个带淘汰的哈希缓存。后来觉得这样做使用上不够便利,升级成了自动前缀缓存,即将 prompt 的 kv cache 分成 block,然后为 block 建设 LRU 缓存机制,这样就不必在接口上使用 prefix_pos 指定哪部分是 prefix 了。自动前缀缓存功能默认是不开启的,开启的配置项为 --enable-prefix-caching

TensorRT-LLM 与 vLLM 后来的实现类似,也是实现了 block kv cache,配置项是 enableBlockReuse,默认也是不开启的。代码未开源,无法看到实现。

Lmdeploy 的 PythonTurboMind C++ 版本的 prefix caching 功能都已经有了 PR,但现在(20240425)看还没有合入主干。有意思的是它没有使用 hash block 对应 token_id 子串的所有 token_id 前缀然后组成哈希表的方式,而是用 hash 当前 block 对应的 token_id 子串然后组成 trie 树的缓存管理结构。默认的参数名与 vLLM 相同,也叫做 --enable-prefix-caching。

HuggingFace TGI 现在看起来还没实现 Prefix Caching 功能。

Prompt Caching

除了 Prefix Caching 这种比较直观的工程优化,现在也有一些研究在看 Prompt 的其它缓存机制。比如设计一种机制让 prompt 模块化,不仅可以复用 prefix,还能复用中间的部分;或者通过 query 的相似性复用其它 query 的 prompt

但目前看实现上都过于复杂,比如第一种要求模型使用不连续的 poition_id,这样就可以插入 token,但这种方式对 attention 的计算机制有一定的影响,难以说明它对效果的影响。

LLM 推理优化 Continuous Batching 及其实现

原理

Continuous Batching 是 LLM 推理优化的一项技术,作为这篇文章的知识背景不再赘述,目前流传最广的参考资料是这篇:《How continuous batching enables 23x throughput in LLM inference while reducing p50 latency》。它也有中文翻译,感兴趣可以搜一下,先看看。

图片来自:Anyscale 博客

虽然这篇资料介绍了它的主要原理,尤其是这张简单易懂的图,但是实现与原理是存在差异的,因为工程实现要解决很多现实问题。

(Re)scheduling 开销

如原文所说,Continuous batching 还有个别名,叫做:batching with iteration-level scheduling,这里的 iteration 就是指一次 decode 计算。也就是说在每次 decode 的迭代过程中,做 batch 的调度调整。

但调度本身不是无代价的,它可能涉及到接收和处理新的输入请求,重新组织输入数据的形状,甚至各种状态的重新初始化,这些都需要消耗 CPU 时间。这也就意味着在这段时间里,GPU 是闲着的,GPU 没有得到充分利用。

所以在实现时,程序并不会真的每个 iteration 都做 scheduling,目前看到有两种做法:

  • 合理间隔调度。比如每 16 次 decode 计算后,检查一下是否有新的输入,以及是否有空闲的槽位,然后对 batch 做一次调度调整。这能够显著降低调度的开销(TGIlmdeployvLLM)。
  • 排队比例调度。比如当前 batch 中有 10 个请求的 decode 正在进行,而排队中有 12 个请求,超过了排队比例 1.2,那么就启动一次调度调整(TGI)。

KV Cache 重读

如果真的像图中那样,每个生成 Token 的 decode iteration 与一个 prompt token 的计算对齐,那 KV Cache 的利用就会很糟糕。因为它们需要在 Global Memory 与 Shared Memory 之间反复搬运,而且每次搬进来以后只被使用一次。

这本质上是 prefill 阶段(prompt 计算)与生成阶段的计算特性不同导致的,prefill 阶段并不适合 one-by-one 的 token 计算。

所以在实现时,程序并不会真的做 prefill 和生成的 token 对齐调度。目前看到的调度方法有三种:

  • 在重调度时,如果有新的请求进来,那么将新请求的 prefill 计算和要进行的 decode 计算做合并(Orca、vLLM-prev)。
  • 在重调度时,如果有新的请求进来,那么先对新的请求做 prefill 计算,然后再合并所有进行中的请求做 decode 计算(TGI、vLLM、lmdeploy)。
  • 先根据 decode 耗时估算出来每次 decode 同时能做 prefill 的 token 数量,在重调度时,如果有新的请求进来,对新请求的 prefill 按照上面的估算进行分块,然后将分块的 prefill 和其它请求的 decode 计算融合在一起,一定程度上减少了 KV Cache 重读的次数,又避免先做 prefill 计算带来的 Token 生成延时增加(Sarathi-Serve+vLLM)。

可调优能力

LLM 推理服务往往用于生产环境,而生产环境面临的情况是复杂多样的。

  • 对于做阅读理解的应用来说,Prompt 可能会非常长,但生成的内容可能会非常短,开发人员可能会更追求吞吐;
  • 对于聊天应用来说,Prompt 可能较短,生成的内容也不会太长,开发人员可能会更追求延迟;
  • 对于创作类应用来说,Prompt 可能很短,生成的内容会更长,开发人员可能会更追求首 Token 延迟。

对 Continuous Batching 实现来说,就要求它调度策略尽量清晰,并且参数可调。所以更灵活的实现,未来可能会更受欢迎。

Logits of API-Protected LLMs Leak Proprietary Information

看到一篇挺有意思的论文,大开脑洞,没想到还能这么玩,做一下粗读的笔记。

论文

标题:《Logits of API-Protected LLMs Leak Proprietary Information》,链接: https://arxiv.org/pdf/2403.09539.pdf

假设条件

典型 LLM 需要将最后一个 Transformer 块输出的嵌入向量转成要输出的 Token,这一步往往通过一个线性变换加 softmax 获取那个最大概率的 tokenid。

线性变换的权重是一个 vocabulary size * hidden size 的矩阵,比如 llama2-7B 的词表大小是 32000,hidden size 是 4096,那么线性变换权重矩阵的尺寸就是 32000x4096。这个矩阵再与 4096x1 的嵌入向量相乘,得到的就是 32000x1 的 logits 向量,其中每一个元素对应着一个词表中的 token 作为最终输出的概率。

上面这只是假设,也许 GPT 使用的是一个非线性变换,那论文内容可能就不成立了。

数学原理

这个线性变换将一个 4096 维的向量映射到了一个 32000 维的向量,从线性代数的角度来看,这是一个低维向高维的映射,所以它肯定不是一个满射(onto mapping)。也就是说,这个映射的像空间(image)只是 32000 维实数空间的一个子空间(subspace),而且这个像空间的秩(rank)最多是 4096。

这意味着可以找到不多于 4096 个线性无关的基向量(basis),使得这个像空间的每一个元素都能表示为这些基向量的线性组合。假设能采集到 4096 个线性无关的输出 logits,那这些 logits 就构成了像空间的一组基向量。

反过来想,如果你不知道 LLM 的 hidden size,那么你可以通过采集足够多的输出 logits,以保证有足够多的线性无关的向量。然后对矩阵进行奇异值分解(singular value decomposition),可以通过非 0 的奇异值个数推导出矩阵的秩。这个秩应该接近于模型的 hidden size。

逆向恢复 logits

遗憾的是,很多模型的 API 并没有输出完整的 logits 矩阵,但幸运的是,OpenAI 的 API 支持输出最多 top 5 个 token 的 logprobs,并且支持 logit_bias 干预 token 的输出。那就给了反复通过 API 调用来逆向恢复 logits 向量的可能。

但是具体方法我没看,粗读嘛,知道能做到就行了,有用到的时候再看吧。还有另一篇文章《Stealing Part of a Production Language Model》分析了在没有 logit_bias 甚至没有 logprobs 时该如何恢复 logits,我也没看,记录下链接 https://arxiv.org/pdf/2403.06634.pdf

无法输出的 Token

这篇论文还介绍了很多其它应用,太长没有看。比较有意思的一个引用是,在将嵌入向量映射到 logits 的过程中,如果一个 token 的嵌入向量在其它 token 的嵌入向量组成的凸包的内部,它就永远不可能被输出。扫了一眼引用的论文,证明没看懂,大致意思是 softmax 权重矩阵的低秩特性导致了可能输出 token 的排列在线性变换后不会出现在子空间里?实话说我感觉不像是很严谨的数学证明。。。

怎么用向量指令计算多个元素尾部 0 的个数?

大家都知道 gcc 提供了__builtin_ctz 这种内建函数计算整数尾部 0 的个数,它在大部分情况下会转译成 CPU 的内建指令,比如 x86 平台上的 tzcnt 指令。

但如果你想同时计算多个元素尾部 0 的个数,就会发现有些指令集,比如 SSE/AVX,并没有提供类似的向量指令。而这种指令有时候在写向量化代码时又是需要的,我自己针对 8-bits 单字节整数做了一些粗糙的实现,如果你通过搜索引擎来到这里,希望对你能有所帮助。

函数功能

输入一个整数向量(16 或者 32 个 uint8),返回一个同类型整数向量,其中输出的每一个整数表示对应位置输入整数末尾 0 的个数。

SSE 版本

#include <smmintrin.h>
__m128i tzcnt_epi8(__m128i a) {
    __m128i ctz_table = _mm_setr_epi8(
        4, 0, 1, 0, 2, 0, 1, 0,
        3, 0, 1, 0, 2, 0, 1, 0
    );
    __m128i shuffle_mask = _mm_set1_epi8(0x0F);
    __m128i v_4 = _mm_set1_epi8(0x4);
    __m128i a_low = _mm_and_si128(a, shuffle_mask);
    __m128i ctz_low = _mm_shuffle_epi8(ctz_table, a_low);
    __m128i a_high = _mm_and_si128(_mm_srli_epi16(a, 4), shuffle_mask);
    __m128i ctz_high = _mm_shuffle_epi8(ctz_table, a_high);
    ctz_high = _mm_and_si128(ctz_high, _mm_cmpeq_epi8(ctz_low, v_4));
    return _mm_add_epi8(ctz_low, ctz_high);
}

AVX 版本

#include <immintrin.h>
__m256i tzcnt_epi8(__m256i a) {
    __m256i ctz_table = _mm256_setr_epi8(
        4, 0, 1, 0, 2, 0, 1, 0,
        3, 0, 1, 0, 2, 0, 1, 0,
        4, 0, 1, 0, 2, 0, 1, 0,
        3, 0, 1, 0, 2, 0, 1, 0
    );
    __m256i shuffle_mask = _mm256_set1_epi8(0x0F);
    __m256i v_4 = _mm256_set1_epi8(0x4);
    __m256i a_low = _mm256_and_si256(a, shuffle_mask);
    __m256i ctz_low = _mm256_shuffle_epi8(ctz_table, a_low);
    __m256i a_high = _mm256_and_si256(_mm256_srli_epi16(a, 4), shuffle_mask);
    __m256i ctz_high = _mm256_shuffle_epi8(ctz_table, a_high);
    ctz_high = _mm256_and_si256(ctz_high, _mm256_cmpeq_epi8(ctz_low, v_4));
    return _mm256_add_epi8(ctz_low, ctz_high);
}

NEON 版本

#include <arm_neon.h>
uint8x16_t vctzq_u8(uint8x16_t a) {
    return vclzq_u8(vrbitq_u8(a));
}

在 LLM 时代我们是否还需要倒排索引?

近些年,EBR(基于文本嵌入向量的召回)的强大语义召回能力让基于属性索引的传统倒排索引技术黯然失色,即使对专业搜索引擎来说,EBR 的应用也是越来越广泛 [1,2,3] 。尤其在 LLM(大语言模型)激起的 RAG(检索增强生成)技术框架下,大部分人似乎已经忘记了倒排索引,向量数据库成为 RAG 的标配。

但在 LLM 时代倒排索引真的没有用武之地了吗?我尝试列一下自己的思考。

  • Embedding 向量缺少 ground truth,但是倒排有。你无法通过直接观察或者测量,来明确一个向量指代的一段文本是否一定包含某些信息。但是如果这段文本在某个 term 的倒排拉链里,你可以从一定程度上明确这段文本包含了一些相关信息。
  • term 命中也是一种 attention。在训练模型时,我们总是希望 LLM 能关注到 context 中应该关注的信息,而忽略其它无关内容。那跟用户问题或者指令中某些 term 相关的内容,应该需要更多的关注。其实也可以类比人类肉眼的信息查找,人们也总是会扫到某些关键词,然后再仔细阅读它的上下文篇章。
  • 不基于倒排做召回,仍可以用倒排做粗筛。倒排作为一种可以查询 term 命中的高效结构,对 EBR 也许可以起到补充作用。例如对于某些 EBR 效果不够理想,误召回概率较高的场景下,对得分比较低的文档用命中信息作一次粗筛,能显著提升送给模型的 context 质量,也能减少对 LLM 计算资源的浪费。
  • term 命中的位置信息和权重不再重要。对于 LLM 来说,它会自行关注到 context 中需要关注的信息,不再需要位置信息或者权重来指示文本中哪些部分更重要。也就是说,倒排只需要解答 term 在文本中是否出现的问题,而不需要回答出现几次、在哪里出现的问题。
  • 也许倒排不再用 term,而是 token。term 依赖于切词,有一定的语义含义,term 集合空间一般有百万甚至千万的量级。但现在 LLM 大部分使用 BPE(Byte-Pair Encoding)分词器,token 集合空间只有几万到十几万的量级。使用 token 将显著减少倒排链的数量而优化其性能,但 token 存在没有归一化、分词边界不对齐的问题,是否可用还有待验证。

参考

[1] Guo, Ruiqi, et al. "Accelerating large-scale inference with anisotropic vector quantization." International Conference on Machine Learning. PMLR, 2020

[2] Huang, Jui-Ting, et al. "Embedding-based retrieval in facebook search." Proceedings of the 26th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. 2020.

[3] Liu, Yiding, et al. "Pre-trained language model for web-scale retrieval in baidu search." Proceedings of the 27th ACM SIGKDD Conference on Knowledge Discovery & Data Mining. 2021.

配置 dcgmi 遇到的问题

dcgmi 是 Nvidia datacenter-gpu-manager 的命令行程序,可以用来采集 GPU 各类子资源的利用率数据,揭示的数据比 nvidia-smi 更详细,也更便于对接监控系统(比如 Prometheus)。这次我主要想用它来看模型训练过程中的 NVLink 带宽使用情况。

在一个已经完成 Nvidia 训练环境配置的服务器上安装 dcgmi,主要有以下几步:

1. 如果服务器上有 NVSwitch,比如 A100/800,或者部分 V100,需要先安装 nvidia-fabric-manager,注意 nvidia-fabric-manager 的版本要和 GPU 驱动版本严格一致,包括小版本号(nvidia-smi 显示的驱动版本)。

2. 如果服务器上有 NVSwitch,还需要安装 libnvidia-nscq,版本也要和驱动严格一致。

3. 安装 datacenter-gpu-manager

4. 启用并启动 nvidia-fabricmanager 服务,注意服务名(nvidia-fabricmanager)、包名(nvidia-fabric-manager)和进程名(nv-fabricmanager)的区别。根据启动成功与否,可检查 /var/log/fabricmanager.log 中的错误信息。

systemctl enable nvidia-fabricmanager
systemctl start nvidia-fabricmanager

5. 启用并启动 nvidia-dcgm服务,注意包名(datacenter-gpu-manager)、服务名(nvidia-dcgm)和实际命令/进程名(nv-hostengine)的区别。根据启动成功与否,可检查 /var/log/nv-hostengine.log 中的错误信息。

systemctl enable nvidia-dcgm
systemctl start nvidia-dcgm

但是整套配置下来,我还是遇到了不少问题,有些是配置问题,有些是文档未明确说明的问题。甚至我找到 Nvidia 的售后工程师咨询,他们都没法给我答案,后来还是我自己摸索出来了,做个记录。

1. nvidia-dcgm 服务启动成功,但又没完全成功

启动服务时候没有报错,但是执行监控命令时出错,其实启动的时候 /var/log/nv-hostengine.log 就已经出现无法加载 nscq 库的错误,只不过没影响服务的启动。因为 nscq 是对 nvswitch 的查询,用户如果不需要查询 nvswitch 的数据,可以接受这个错误。

ERROR [13862:13862] [[NvSwitch]] Could not load NSCQ. dlwrap_attach ret: Can not access a needed shared library (-79): If this system has NvSwitches, please ensure that the package libnvidia-nscq is installed on your system and that the service user has permissions to access it. [/workspaces/dcgm-rel_dcgm_3_3-postmerge/modules/nvswitch/DcgmNvSwitchManager.cpp:798] [DcgmNs::DcgmNvSwitchManager::AttachToNscq]
ERROR [13862:13862] [[NvSwitch]] AttachToNscq() returned -25 [/workspaces/dcgm-rel_dcgm_3_3-postmerge/modules/nvswitch/DcgmNvSwitchManager.cpp:632] [DcgmNs::DcgmNvSwitchManager::Init]
ERROR [13862:13862] [[NvSwitch]] Could not initialize switch manager. Ret: DCGM library could not be found [/workspaces/dcgm-rel_dcgm_3_3-postmerge/modules/nvswitch/DcgmModuleNvSwitch.cpp:34] [DcgmNs::DcgmModuleNvSwitch::DcgmModuleNvSwitch]

虽然我已经安装了 libnvidia-nscq 库,但 nv-hostengine 就是找不到 NSCQ 对应 so。我在这个问题上困扰了很久,Nvidia 的工程师也没法给我解释原因,只是一遍一遍地让我去看文档,甚至重装系统。

我觉得他的建议不靠谱,后来还是自己找到了原因,那就是 datacenter-gpu-managerlibnvidia-nscq 也有隐式的版本依赖。虽然在文档和包依赖中完全没有体现这种依赖关系,但我通过降级 datacenter-gpu-manager 到与 libnvidia-nscq 时间上更接近的版本解决了这个找不到 so 的问题。

之所以想到去尝试降级版本,还是因为 Nvidia 各种环境和驱动版本的强绑定,让我免不了去怀疑一下这个。

2. 查询部分指标 DCGM_FI_PROF_* 时出错

在执行 dcgmi dmon -e 449,1011,1012 时,命令行显示如下错误:

Error setting watches. Result: The third-party Profiling module returned an unrecoverable error

查看 /var/log/nv-hostengine.log,有如下错误日志:

ERROR [225876:233276] [[Profiling]] [ProfModule][PerfWorks] Got status 1 from NVPW_DCGM_PeriodicSampler_BeginSession() on deviceIndex 0 [/workspaces/dcgm-rel_dcgm_2_3-post
merge/dcgm_private/modules/profiling/DcgmLopGpu.cpp:351] [DcgmLopGpu::BeginSession]
ERROR [225876:233276] [[Profiling]] EnableMetrics returned -37 The third-party Profiling module returned an unrecoverable error [/workspaces/dcgm-rel_dcgm_2_3-postmerge/dc
gm_private/modules/profiling/DcgmModuleProfiling.cpp:2461] [DcgmNs::Modules::Profiling::DcgmModuleProfiling::ReconfigureLopGpu]
ERROR [225876:233276] [[Profiling]] Unable to reconfigure LOP metric watches for GpuId {0} [/workspaces/dcgm-rel_dcgm_2_3-postmerge/dcgm_private/modules/profiling/DcgmModu
leProfiling.cpp:2545] [DcgmNs::Modules::Profiling::DcgmModuleProfiling::ChangeWatchState]

导致这个错误的原因是对于这些 Profile 指标(1001-1014 ),NV 的 Profiler 对每个硬件使用了一个唯一锁。当你启动了超过 1 个的 nv-hostengine (包括内建的),比如使用 NCGM-Exporter 容器时,已经启动了一个内建的 nv-hostengine,然后又在主机上又启动了一个 nv-hostengine 服务,在访问这些指标时,就会出现这种访问失败。解决方案就是一台服务器仅启动一个 nv-hostengine 服务,然后所有的客户端都用本地或者远程的方式去访问它(5555 端口)。

这类问题在云场景下可能更常见,因为云服务商可能已经在租用的 GPU 服务器上部署了 DCGM 监控,你再去部署就可能遇到硬件锁的问题。

3. DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL 的单位

我最早看到这个指标是在 NCGM-Exporter 的输出指标中,但是无论是文档、配置文件还是输出接口中,都没有写明这个指标的单位和计算逻辑。我问 Nvidia 的售后工程师,他告诉我这个指标的单位是 B/s,LoL!

后来仔细查 NCGM 的文档,发现 NCGM-Exporter 的所有指标其实都源自 NCGM 的接口,但还是没看到这个指标的单位和计算逻辑。

最后使用 dcgmi dmon -e 449,1011,1012 做了一下对比,才发现其输出头中有个不起眼的 MB 字样:

#Entity   NBWLT                       NVLTX                       NVLRX
ID       MB/

我做了一下数字的校验,基本可以明确 DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL[449] 约等于 (DCGM_FI_PROF_NVLINK_TX_BYTES[1011] + DCGM_FI_PROF_NVLINK_RX_BYTES[1012]) / 1048576,所以单位应该是 MB/s 。

4. NCGM-Exporter 的采集延迟和精度

用 docker 跑 NCGM-Exporter 镜像时,发现指标的输出非常慢,输出的值看起来也很奇怪。后来自己构建了一下,研究了一下命令行参数,才发现默认采集的周期是 30s 一次。这种采样精度下,输出的指标值能准确才怪了。正常 dcgmi 采样的频率是 1s 一次,最低可以配置到 100ms 一次。NCGM-Exporter 有命令行参数可以调整这个采样频率,但需要你自己用修改 Dockerfile 去重建镜像。或者可以考虑启动的时候将启动脚本映射到外部文件?没尝试。

GPT-2 Tokenizer 效率观察

对基于 Transformer 结构的 LLM (大语言模型)来说,模型的输入输出都是 Token(词元)。一段输入文本,首先要经过 Tokenizer(分词器)切分成 Token 再输入给模型。

不同的 Tokenizer 会把文本按不同的边界切分,那一段文本到底会被切成几个 Token 就体现了 Tokenizer 本身的效率,这本身也是信息论的讨论范畴。不过今天不做理论分析,也不介绍 Tokenizer 的训练算法,就看下 GPT-2 和 GPT-3 Tokenizer 的实际分词效率(GPT-2 和 GPT-3 使用的是同样的 Tokenizer)。

用 GPT-2 Tokenizer 编码一段中文

from transformers import GPT2Tokenizer

tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
input_str = '不同的 Tokenizer 会把文本按不同的边界切分,那一段文本到底会被切成几个 Token 就体现了 Tokenizer 本身的效率,这本身也是信息论的讨论范畴。'
tokenids = tokenizer(input_str)['input_ids']
raw_tokens = tokenizer.convert_ids_to_tokens(tokenids)
token_strs = [tokenizer.convert_tokens_to_string([token]) for token in raw_tokens]
print(len(input_str), len(tokenids), tokenids, token_strs)
82 121 [38834, 28938, 234, 21410, 29130, 7509, 220, 27670, 248, 162, 232, 232, 23877, 229, 17312, 105, 162, 234, 231, 38834, 28938, 234, 21410, 164, 122, 117, 45911, 234, 26344, 229, 26344, 228, 171, 120, 234, 165, 224, 96, 31660, 162, 106, 113, 23877, 229, 17312, 105, 26344, 108, 41753, 243, 27670, 248, 164, 95, 104, 26344, 229, 22755, 238, 49035, 254, 10310, 103, 29130, 10263, 108, 109, 19526, 241, 163, 236, 108, 12859, 228, 29130, 7509, 42164, 105, 164, 118, 104, 21410, 46763, 230, 163, 236, 229, 171, 120, 234, 32573, 247, 17312, 105, 164, 118, 104, 20046, 253, 42468, 46479, 94, 162, 223, 107, 164, 106, 118, 21410, 164, 106, 101, 164, 106, 118, 164, 234, 225, 45911, 112, 16764] ['不', '�', '�', '的', ' Token', 'izer', ' ', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '不', '�', '�', '的', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '一', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', ' Token', ' �', '�', '�', '�', '�', '�', '�', '�', '�', '�', ' Token', 'izer', ' �', '�', '�', '�', '�', '的', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '是', '�', '�', '�', '�', '�', '�', '�', '�', '的', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '�', '。']

输入的中英文夹杂的字符串长度为 82 个字符,其中有 6 个空格,23 个英文字符(3个单词),3个标点符号,50 个汉字字符。最终编码出来的 tokenids 长度是 121 个 Token,仔细检查可以看到,大部分汉字被编码成两个 Token,英文则是按照 subword 级别进行了编码。由此可见,GPT-2 Tokenizer 编码中文的效率是很低的

GPT-2 Tokenizer 词典中包含中文的 Token 数量

import re
tokens = [tokenizer.convert_tokens_to_string([key]) for key in tokenizer.get_vocab()]
cn_tokens = [t for t in tokens if re.search(u'[\u4e00-\u9fff]', t)]
cn_tokens.sort(key=len, reverse=True)
print(len(tokens), len(cn_tokens), cn_tokens)
50257 51 [' 裏覚醒', ' 裏�', '��士', '龍喚士', ' 裏�', '龍契士', '��極', ' 裏', '龍�', '�醒', '覚醒', 'の魔', '�士', '龍�', ' 神', '龍', '神', '士', '魔', '的', '人', '天', '王', '一', '大', '装', '田', '子', '戦', '生', '者', '不', '姫', '中', '上', '闘', '是', '女', '方', '作', '黒', '之', '使', '光', '代', '版', '三', '五', '武', '将', '機']

对 GPT-2 Tokenizer 词典中的 Token 进行分析,可以看到:在数量为 50257 大小的词典中,只有 51 个 Token 包含常见的中文字符。上一节中出现的“是”、“的”、“不”也在其中,但是大家随便一想就能想到的,常见的“你”、“我”、“他”并不在内。这些数据印证了它对中文分词的效率低下。

GPT-2 Tokenizer 词典中包含英文的 Token 数量

en_tokens = [t for t in tokens if re.search(u'[A-Za-z]', t)]
en_tokens.sort(key=len, reverse=True)
print(len(en_tokens), en_tokens[:10])
46949 ['rawdownloadcloneembedreportprint', 'BuyableInstoreAndOnline', 'cloneembedreportprint', ' RandomRedditorWithNo', ' telecommunications', ' disproportionately', ' guiActiveUnfocused', 'channelAvailability', ' Telecommunications', ' externalToEVAOnly']

但是针对英文字符进行统计,可以看到在数量为 50257 大小的词典中,有 46949 个 Token 包含 26 个英文字母。由于数量太多,这里仅输出了长度排前 10 的英文 Token。

从 Tokenizer 词典中不同语言字符的分布,我们可以验证 GPT-3 论文 “Language Models are Few-Shot Learners” 中提到的这句话:

... This could be a weakness due to reusing the byte-level BPE tokenizer of GPT-2 which was developed for an almost entirely English training dataset.

GPT-2 Tokenizer 词典中最长的 Token 是什么

tokens.sort(key=len, reverse=True)
print(len(tokens), tokens[:10])
50257 [' =================================================================', ' ----------------------------------------------------------------', '----------------------------------------------------------------', '................................................................', '================================================================', '________________________________________________________________', 'ÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂ', '--------------------------------------------------------', '------------------------------------------------', ' =================================']

可以看到,最长的 Token 居然是一些连续的标记,看起来像是论坛上的分割线。在“Natural Language Processing with Transformers” 一书中说,这可能是由于 GPT-2 的词典是用以 Reddit 为核心的语料训练的。

Codex Tokenizer 提升代码处理效率的 Trick

上面讲到 Tokenizer 效率对最终生成 Token 序列大小的影响,如果只关注最终的模型效果,可能并不在意中间过程的效率问题。

但从一个小点可以看到 OpenAI 还是很重视 Tokenizer 效率的。在训练 Codex 时,它在 50257 的词典之外新增了 23 个 Token,分别编码了:[2个连续空格, 3个连续空格, ..., 23个连续空格]。

大家都知道,代码中有很多锁进和对齐,如果每个空格都编码成一个 Token,训练和推理的效率就会极低;如果像常规语言模型那样忽视更多连续的空格,又会导致生成的代码不可读,甚至对 Python 这样的语言来说代码完全不可理解。所以 OpenAI 专门修改了 Tokenizer 的词典,提升了对连续空格的编码效率。

在中文数据集上训练的 Tokenizer 表现如何?

同样使用第一节的代码,我们换一个在中文数据集上训练的 Tokenizer,效果会如何呢?我随便在 HuggingFace 上找了一个模型 HuiHuang/gpt3-base-zh,使用它的 Tokenizer 对那段话进行重新分词。

from transformers import BertTokenizerFast

tokenizer = BertTokenizerFast.from_pretrained("HuiHuang/gpt3-base-zh")
input_str = '不同的 Tokenizer 会把文本按不同的边界切分,那一段文本到底会被切成几个 Token 就体现了 Tokenizer 本身的效率,这本身也是信息论的讨论范畴。'
tokenids = tokenizer(input_str)['input_ids']
raw_tokens = tokenizer.convert_ids_to_tokens(tokenids)
token_strs = [tokenizer.convert_tokens_to_string([token]) for token in raw_tokens]
print(len(input_str), len(tokenids), tokenids, token_strs)
82 65 [2, 215, 1750, 10574, 22090, 24330, 23635, 21748, 484, 5460, 6225, 6646, 5587, 215, 1750, 10574, 17027, 10262, 1233, 1232, 21124, 17261, 202, 7807, 6225, 6646, 1274, 4447, 484, 15221, 1233, 5338, 1194, 244, 22090, 24330, 3835, 541, 9850, 336, 22090, 24330, 23635, 21748, 6646, 16757, 10574, 6162, 9809, 21124, 17059, 6646, 16757, 297, 6393, 683, 4921, 16004, 10574, 15986, 16004, 13773, 10302, 160, 3] ['[CLS]', '不', '同', '的', 'to', '##ken', '##ize', '##r', '会', '把', '文', '本', '按', '不', '同', '的', '边', '界', '切', '分', ',', '那', '一', '段', '文', '本', '到', '底', '会', '被', '切', '成', '几', '个', 'to', '##ken', '就', '体', '现', '了', 'to', '##ken', '##ize', '##r', '本', '身', '的', '效', '率', ',', '这', '本', '身', '也', '是', '信', '息', '论', '的', '讨', '论', '范', '畴', '。', '[SEP]']

可以看到,82 个字符的输入被分成了 63 个 Token(除去[CLS]和[SEP]),比 GPT-2 Tokenizer 的分词数量 121 几乎少了一半。这就是 Tokenizer 训练数据集对分词效率的影响。

Tokenizer 的效率意味着什么

在以前,Tokenizer 的效率只是算法工程师们关注的话题。但是现在,LLM 逐渐成为云服务,Token 成为 API 调用的基本收费单元。例如:OpenAI gpt-3.5-turbo 的收费标准是 $0.002/1K tokens。

在这种情况下,Tokenizer 的效率就意味着成本。这意味着使用中文调用 OpenAI 的 chatGPT API,假设单 Token 成本一样的话,每次调用的实际成本可能是调用国内类似 chatGPT 云服务的 2 倍以上。

GPT-4 的 Bits Per Word 指标是什么意思

最近人人都在聊 ChatGPT,这里聊点儿更底层的技术细节。上周 OpenAI 发布了 GPT-4,里面有一张图引起了我的兴趣。在 OpenAI 内部的 Codebase 上,他们准确预测了 next word prediction 误差随着计算规模增加的下降曲线。让我感兴趣的不是这个曲线,而是这个曲线纵轴使用的指标:Bits Per Word (BPW)

图片来自:https://openai.com/research/gpt-4

乍一看我以为这是信息论里的概念,Bits Per Word 可能是指在某种语言里表达一个词所需要的最小位数,比如用于压缩时,这意味着压缩比的理论上限。但仔细看下指标接近 1.2 BPW,虽说英文字母的 Bits Per Character (BPC) 大约也在 1.2 左右,我不太相信单词的 BPW 能降到 1.2 这个规模。

后来才了解到,Bits Per Character (BPC) / Bits Per Word (BPW) 是 NLP 任务中对语言模型的评估指标之一。这些评估指标的详细解释可以先阅读这篇文章《Evaluation Metrics for Language Modeling》,但是我感觉这篇文章的说明在理论和应用之间跳过了一些环节,下面我以我的理解补全一下这些环节。推测的内容以黑字标出,如有谬误,欢迎指正。

原始 BPW 定义

The entropy is a statistical parameter which measures, in a certain sense, how much information is produced on the average for each letter of a text in the language. If the language is translated into binary digits (0 or 1) in the most efficient way, the entropy \(H\) is the average number of binary digits required per letter of the original language.

以上,是香农在《Prediction and Entropy of Printed English》一文中对语言的熵的定义。可以看到,语言的熵 \(H\) 其实就是 BPC (Bits Per Character) 的均值。但我们一般不会报告某个字符有几个 bits,因而通常所说的 BPC,就是整个语言(数据集)的平均 BPC,所以可以理解为语言的熵 \(H\) 就是 BPC。

但是在原始论文中,香农是用字符来建模的语言的熵,当用单词来建模语言时,语言的熵 \(H\) 就是 BPW (Bits Per Word)。

用 BPW 表示交叉熵

对于生成式语言模型来说,它的目标就是从样本中学习到一个分布 \(Q\),使这个分布尽量接近语言的经验分布 \(P\)。交叉熵 \(H(P, Q)\) 经常被用来衡量这种接近程度,

\[H(P, Q) = H(P) + D_{KL}(P \Vert Q) \]

根据 《Evaluation》 一文,交叉熵的两个因子用信息论的角度来理解:

  • \(H(P)\): 就是 \(P\) 的熵,即使用为分布 \(P\) 优化的编码器编码 \(P\) 中所有可能出现的字符的平均位数;
  • \(D_{KL}(P \Vert Q)\): 使用为分布 \(Q\) 优化的编码器编码 \(P\) 中所有可能出现的字符,所需要的平均额外位数;

与原始 BPC/BPW 的定义相同,如果用 \(BPC/BPW(P, Q)\) 表示交叉熵 \(H(P, Q)\),那么它意思应该是使用为分布 \(Q\) 优化的编码器编码 \(P\) 中所有可能出现的字符/单词所需要的平均位数。

上面这点理解,与知乎上一篇文章《一文搞懂Language Modeling三大评估标准》存在 diff(飘红部分):

也就是说BPC/BPW是cross-entropy对句子长度的平均,我们可以很容易地得出它的信息论含义:

基于预测的Q序列来编码P序列所需要的额外bit数在句子长度上的平均,也就是平均每一个字母/单词需要额外的bit数。

下面这一节,可能能解释 diff 的由来。

用 BPW 表示损失指标

但是在评估一个语言模型时,我们并不是评估模型输出的整个分布,而是评估模型的输出跟实际样本的不同。拿 GPT-2 模型来说,它是拿模型输出的预测下一个 Token 的 logits 与 label (样本中真实的下一个 Token)计算交叉熵:

The output from the decoder is passed to a language modeling head, which performs a linear transformation to convert the hidden states into logits. The label is the next token in the sequence, which are created by shifting the logits to the right by one. The cross-entropy loss is calculated between the shifted logits and the labels to output the next most likely token.

  • logits 是一个 \(Q(x)\) 的向量,表示下一个 Token 是 \(x\) 的概率;
  • label 表示样本中下一个 Token 是什么,它是一个 \(P(x)\) 的向量,但只有 x = 下一个 Token 时为 1,其它位置为 0,那么:

\[H(P) = -\sum_{x}P(x)\log{P(x)} = 0\]

从信息论也好理解,在计算损失时样本 \(P\) 是一个确定性分布,确定性分布的熵应该为 0。那么在计算交叉熵损失的场景下:

\[H(P, Q) = H(P) + D_{KL}(P \Vert Q) = 0 + D_{KL}(P \Vert Q)\]

可以看到,这时候交叉熵等于相关熵,那么 \(BPC/BPW(P, Q)\) 也就是 \(D_{KL}(P \Vert Q)\),或者更精确地说,是在数据集样本上平均的 \(D_{KL}(P \Vert Q)\)。那么在评估一个语言模型的场景下,把 BPC/BPW 损失指标的信息学含义解释成:“使用为分布 \(Q\) 优化的编码器编码 \(P\) 中所有可能出现的字符/单词所需要的平均额外位数”,也是合理的了。

GPT-4 的 BPW 指标

从 GPT-4 给出的指标来看,它的 BPW 指标只有 1.26 左右。除了信息论编码的概念,这代表什么意思呢?

这里我引入《Evaluation》 一文中提到的另一个指标 Perplexity (PPL) 困惑度和一个有趣的比喻。考虑到:

\[PPL(P, Q) = 2^{H(P, Q)} = 2^{BPW(P, Q)} = 2^{1.26} \approx 2.4\]

那么 GPT-4 语言模型的效果相当于:在给定的数据集上,提供一个样本的前缀串后,GPT-4 会制作一个包含下一个 Token 的 2.4 面的新骰子,然后掷骰子来预测下一个 Token 是什么。

2022 年 12 月

昨天儿子问我:“蒙古是什么样的?内蒙古是什么样的?”我说:“内蒙古咱们今年不是去过吗?”他说:“哦,感觉也不怎么样,不比咱们小区好多少!(我们住的是一个老破小区)”

然后我想仔细想想上次内蒙都去过哪几个地方,忽然觉得有些地名已经想不起来了——这才仅仅四个月。翻开博客看了看,有些事情还是记一下比较好。

12 月从居家办公开始,第一次感觉情况有些变化,是 2022 年 12 月 3 日,居家办公的一个周末。我骑电动车带娃去公园玩,发现周围四个核酸点全都关门了,仅存的一个排起了长队。虽然 12 月 3 日中午有个辟谣:“网传北京市明天起全面放开为不实消息。”但我觉得从动作上来看,肯定是要放开了。

但我这时候还没想起来囤药,因为我觉得再不济医院总不会没药,去医院开药还能走医保、商保。后来知道,这想法天真了。

5 号本来也是居家,但是 4 号公司发了一个模棱两可的通知,把原来要求 24 小时核酸改成了 48 小时核酸。然后考虑到晚上有个直属领导的月度会,侧面了解了下说领导一直都没居家,想了想还是去公司了。

正上着班,媳妇说家里被封单元了。晚上开完会回到家,先去超市买了些蔬菜,孩子喜欢吃的面包,拎着袋子进楼。门口有两个值班,问我进去就不能出来了,我说好。本来想再去买点药的,下班实在太晚,怕超市关门了就没去。

5 号夜里,赶紧京东下单了抗原检测试剂、芬必得、感冒药。事实上已经晚了,下完单都没法显示送达时间(这时候不知道外卖送药啥样,应该试试,忘了)。我看了下同事的情况,有的 12 月 2 日下单的芬必得也一直没发货。

实际上抗原是 12 月 9 日送到的,感冒药是 12 月 13 日送到的。因为我阳的比较晚,这俩还是起了作用的。但芬必得是 1 月 1 日送到的,这就没毛用了,都阳康了。家里还有两瓶美林,也没那么着急,但我还是在 12 日托儿子同学家长帮忙买了瓶杂牌的布洛芬缓释胶囊,50元/盒,19 日光远给了我两盒布洛芬缓释片。

6 号封控一天,做了一次上门核酸,7 号就解封了。然后我就又开始正常上班,就开始了相当魔幻的两周,看着周围的人成片地被感染,看着医院急诊被挤满,看着海淀医院药房挂出通知“布洛芬、感冒药都没有货”,看着美团外卖里的所有药房都下线不在营业时间,看着大家在各种群里抢购抗原、抢购布洛芬。

我不怕被感染,但我想感染得晚一点,因为我家里药都没备齐。所以我在公司里极为谨慎,回到工位就酒精湿巾擦手,全天戴口罩,中午在工位吃饭。

我媳妇单位比较奇葩,政策天天变。她先居家了几天,又因为是部门内少数自驾上班的,多值了几天班,然后又居家几天。前面防护得也挺好的,但是在 20 日周二她还是开始咳嗽了。刚开始测是阴性,22 日周四测出来是阳性。她一直也没发烧,所以刚开始也没用药,后来痰多后用了一些抗生素。

然后我就释然了,该来的总会来的。24 日是周六,我带俩孩子出去中关村广场公园转了一圈,我说赶在阳之前晒一次太阳。果不其然,当天晚上女儿就开始发烧,最高烧到 39 度,后来用美林降温,很有效果。第二天圣诞节周日,女儿白天蔫了一会儿,又用了次美林,晚上看着就退烧了。

27 日周二下午,我妈说午饭全吐了,我想大概率阳了。回家测了一下抗原,本来还担心测不出来,结果直接就是两条杠。她也没烧,刚开始也没用药,只是喝补液盐水补充电解质。

有人开玩笑说新冠是一个仁义的病毒,一家总会留一个人做饭。那就是我了,本来周末已经做了两天饭,现在早餐也得我做了。

结果没两天,周三下午我就开始咳嗽,周四 29 日早上做完早饭吃完,我觉得体温不太对,量了下超过 37 度,还是测下抗原吧。也是两条杠,那就躺平居家吧。

也不知道是我用的抗原更精准,还是我洗棉签时候搓得比较狠。除了我媳妇,我家都是刚有症状就能测出来阳性。有些同事都烧了两天了才测出来阳性,我这刚开始发烧就已经阳了。

这个病毒发作起来的确挺快,早上起床觉得温度还正常,上午11点多已经 39 度晕在床上了。吃了一片同事匀给我的布洛芬缓释片,好像没顶什么用,在床上继续晕。晕的时候我一直在想:“现在到底几度了/到底啥时候发挥药效啊/胳膊露在外面居然还不太冷/半天没看手机了,有没有同事找我审批/不会耽误了元旦上线的什么运营活动吧/去他娘的,我实在没力气看手机”。

一直晕到下午 2 点多还是 39 度,我觉得这药不行,硬让我儿子拿来美林,我自己倒了 10 ml 灌下去了。我媳妇还念叨我,你不能这么吃药,这么吃药会过量,赶紧把美林拿走了。我心里其实计算过,10ml 是 200mg,缓释片也是 200mg,而且距离吃缓释片已经 3 小时了。我自认为没啥问题,但没啥力气跟她争辩,反正已经喝了。

美林效果是真好,没过半小时,发了一身汗,温度降到了 38 度。不知道是原研药的功劳,还是混悬剂的功劳,起效就是比国产杂牌布洛芬缓释片强。一遇到抢购脱销,连这些平时卖不动的杂牌布洛芬都涨价卖,甚至想买还得找渠道。

能站起来了,也能看手机了,赶紧处理了一下如流消息和各种审批,然后再躺下。后来没有再烧到那么高,也没用退烧药,吃了两天有退烧成分的快克。一方面能把持续的低烧压下去一些,一方面压一压咳嗽,让我晕一点儿休息得好。第二天也就这么晕乎乎地过去了。

第三天周六,也是 12 月 31 日,躺在床上看朋友圈各种年终总结,辞旧迎新,实在没什么兴致参与。但起来发现不烧了,就是头略微还有点儿晕,又躺了一上午。到下午觉得精神还不错,周末不能就这么过去,就趁着最暖和的时段带着全家去海淀公园玩了会儿冰。

2022 年 12 月,就这么凑凑合合地过去了。

北京—内蒙赤峰克什克腾旗1800公里自驾游

暑假前看水木“自驾游”版对新疆的讨论十分火热,再加上看到 B 站“徐云流浪中国”到了新疆,原计划是约着几个要好的朋友到新疆自驾一圈。后来因为种种原因吧,集体出游未能成行,甚至全家出游的计划也未能成行。因为媳妇单位对出京严格管控,出京要经过总部的审批,其实就是不希望员工给单位添麻烦。

于是就选择了水木“自驾游”版的另一个目的地——内蒙古赤峰市克什克腾旗,自驾游版主 Borrego 人称“波姐”,在 2018、2019 年整理了多篇非常完善的克旗自驾攻略,发表在了自驾游版和公众号《老司机撸自驾游里》,比如《克什克腾旗完全介绍之自驾路线篇》。

我本想照着波姐的攻略逆时针走个北京到克旗的环线:北京—锡林郭勒盟太仆寺旗—赤峰克旗—承德塞罕坝—北京。但出发当天孩子有个比赛,下午三点才能走,另外感觉这样行程里会有多个地市,会增加遇到疫情的概率,所以就临时设计了一个只路经承德和赤峰两个地级市的行程方案,下面是行程全图:

北京-克什克腾旗自驾路线图,红点标注的是风景最赞的路段

整个行程 5 天半,总里程 1872 公里,车上时间 39 个小时。

7月30日 北京—承德 214km 3小时

因为下午三点多才能出发,第一天没法走太远,所以第一站定在了承德。很多年前参观过避暑山庄,也是人山人海,这次只是想把承德作为中转站。搜索中发现承德市区的豪华酒店并不算很贵,就按有泳池筛选最后选了元宝山假日酒店,想着起码能带娃游游泳。

订完酒店才发现旁边就是承德的《鼎盛王朝 康熙大典》实景演出所在地,很可惜当晚的票已经售空了。不过这个演出门口有一条很好的步行街,有不少美食,还有灯光喷泉表演,在步行街走走体验也不错。

承德元宝山假日酒店
承德元宝山美食街广场
承德元宝山美食街

7月31日 承德—乌兰布统 250km 5小时

本来第二天想去塞罕坝,结果发现乌兰布统并不远,就直接跳过了,计划回程的时候再走塞罕坝(最终还是错过了)。贪图于酒店的设施,上午游了次泳,到元宝山吃了午饭才出发往乌兰布统,没成想居然开了五个小时。

我是从承围高速然后转棋塞线进的塞罕坝,可能因为是周日下午,反方向很多返城的车,导致很难超车,一路速度并不快。棋塞线沿途的风景感觉也比较一般,路两侧都是带围栏的树林,从路上往外看不到啥。而且中间儿子腹痛拉肚子,停了几次车。

走到河北内蒙交界处就开始堵车了,堵了两公里。一个原因是交界处有个景区,沿途像集市一样车流比较乱,另一个是要查看核酸报告。但是进入内蒙届,景色和塞罕坝就迥然不同,一眼望去是连绵起伏的草原,不再有树木遮挡视线。

这天我犯了个错误,傍晚 5 点到酒店以后,气候非常宜人,我只在镇上(乌兰布统苏木)逛了一会儿,没有立即去景区里玩。主要因为没有做功课,不知道乌兰布统景区的收费模式。因为乌兰布统在几个月前才改了收费方式,百度、小红书搜到的都是错的,只有抖音上一个人讲明白了:乌兰布统不再沿路设卡,不过在旅游区内还是圈了几个成片的小景区,在小景区的入口查通票,通票有效期三天。所以我完全可以当天晚上去景区里转一圈,第二天继续玩。

还有一点儿个人经验是:在携程上只能买到 120 元的成人全票,在“克旗文旅公司—乌兰布统景区”公众号里可以买到老人和儿童的半价票,这样就可以刷身份证开车直接入园,不用当场排队去买票了。小景区的入口也有多个,离镇上最近的百草敖包最堵,堵车时可以绕行其它入口进景区。

但不得不吐槽的是,乌兰布统的酒店真坑啊,真是除了大一无是处。基本上拿携程预订价除以 3,相当于它的真实水平。建议去克旗旅游的同志们,能预定品牌连锁还是预定品牌连锁。

我只在乌兰布统住了一晚多兰假日酒店,面盆下水坏的、WiFi 坏的、沙发坏的、电视倒是有台,全是雪花和噪音,卫生间的卫生也不行。后面两天再也不提前预定酒店了,实地查看后还是住了两晚经棚的汉庭。汉庭的房间很小,但是卫生程度和设施还是秒杀这些非连锁酒店。

还有就是“乌兰布统之夜”,晚上 9 点多开始,噼里啪啦的烟花一直放到 11 点多,差不多 10 点是最高峰。也是功课没做好,不知道有这节目,反正也吵得睡不着,要知道倒不如去看看了……

8月1日 乌兰布统—景区—经棚(克旗县城)200km 11小时

酒店没早餐,门口的早餐摊一屉小笼包 20 块钱,比北京都贵。走远点儿有一些卖油条的,两块钱一根,价钱还算合理。

这一天大部分时间都在乌兰布统几个小景区里转,核心的问题就是太晒了,让我很后悔没有昨天傍晚入场。欧式风情区里还可以,距离也不是很远,开车走走停停,还算比较惬意。爬了下影视基地那个木栈道,本来挺后悔的,爬着很累,上面亭子里全是飞蚁,也没法休息。但后来看照片,还真属在木栈道上拍的风景比较漂亮。

影视基地木栈道俯视

早上 8 点却又晒又热,从木栈道上下来,我们就决定,只下车观景,不走路或者爬山了。

再说一下骑马。景区里骑马统一价是 100 元/次,如果抱着孩子骑要加 20 元。但是骑马的组织非常混乱,跟菜市场似的,我们在马场里等了半小时才坐到马上,但没想到后面仍然坑爹。一个师傅牵着两到三匹马,互相之间各种蹭,走到一半停下了,也不让下马,问大家要不要跑马,跑一次 200 元。说白了跟旅游购物一个意思,没人花钱大家就都别走了,有人花钱大家都得等着。断断续续有四五个人去跑了马(其实也就不大一圈,10分钟左右),在大太阳底下坐马上等了半个多小时才回去。我抱着女儿坐一匹马,马鞍前后非常窄,还不想挤着孩子,坐了一个多小时很不舒服。

回程问了才知道,100元/次的钱是景区收了,跑马的钱才是这些牵马的师傅挣的。很合理对吧?再也不体验了。

坐在马上干等中

公主湖景区和欧式风情区不在一起,而且隔着老远,有 20 公里左右,路又烂,把我都开疑惑了。中间找了块草坪跟娃一起玩小滑翔机,玩得非常开心。开到了公主湖旁边,发现又得在停车场停完车走进去,直接调头就走。为什么说又?因为将军泡子也是这情况,停车的地方离湖边老远了,得花钱骑马或者自己走进去,这么热的天,还是放弃了。

在乌兰布统景区转到下午3点,开车距离大概八九十公里吧,觉得也审美疲劳了,也不想在乌兰布统住酒店了,就沿着“经乌线(经棚—乌兰布统)”往经棚开了。经乌线前半段景色非常不错,也被标记了“中国北疆风景大道”,从盘山路上有高度的地方俯瞰草原丘陵,很美。

因为一直在景区转,中午没有正式的午餐,在几棵树底下停了一会儿(草原上找个荫凉地儿太难了),吃了些零食。晚上在经棚找了个蒙餐馆,我觉得味道还行,儿子觉得蒙古奶茶好喝。

乌兰布统景区内野餐

克什克腾旗人民医院有核酸检测,单人单管是 24 小时的,晚上去做了个核酸。

回到酒店发现,虽然抹了防晒霜(儿童版的),胳膊还是被晒红了,这是晒伤的前兆,赶紧用冷水冲洗。应该主要是玩小滑翔机时候晒伤的,儿子也被晒红了,只有女儿穿的纱长袖,没有大影响。

8月2日 经棚—阿斯哈图石林—达里湖—经棚 356km 9小时

鉴于昨天的暴晒体验,我们一致决定尽量开车游玩,不去深入走景区了。所以石林景区没打算进去,主要是方便导航路线。这样走基本上能走全“热阿线(热水镇—阿斯哈图石林)”和“达达线(达青宝拉格牧场—达里诺尔湖)”。

从热水镇往阿斯哈图石林方向走,前半段比较平凡,中段经过黄岗梁(大兴安岭最高峰,海拔2034米)前后,有点儿类似于“经乌线”中段的风光,就是从有盘山公路的高处俯瞰草原。

黄岗梁俯瞰

我个人最喜欢的,是后半段,大概距离阿斯哈图石林 40 多公里开始。前面走过的很多路都是弯弯曲曲,这里开始有长段的接近笔直但是有较大起伏的公路,有点儿新疆自驾游照片里那种一望无垠的感觉。路的两侧都是牧民的草场,远处是起伏的丘陵,非常让人心旷神怡。

热阿线无人机视角

走到这里总结出一个经验,在克旗比较美丽的自驾游路段,前后在路边或者路上总会印着“中国北疆风景大道”的标语。但是克旗的公路有一个缺点,就是缺少观景台(跟北京山区道路相比),所以虽说禁止游客在路上停车,但是在车少的路段还是很多人直接停在路上。

热阿线终点是阿斯哈图石林南门,但是在南门前一公里的位置有个分岔路,可以走到阿斯哈图石林西门,据说是最近新修的南门和西门的连接线。所以我就从那里直接转向西门去走达达线了。

达达线就少了热阿线后段那种笔直大道的风景,大多数是弯弯曲曲的。达达线也是阿斯哈图石林西门往外这几十公里风景最优美,而且公路和牧场的落差不大。牧民的草场有围栏不能进,但是围栏外面还是有一些草地,应该属于公路养护区,看到一些游客直接停车扎营。

达达线

过了巴彦查干苏木,达达线转向正南,风景就有些乏善可陈了。草原还是草原,但是不再有起伏,和平原差不多。

去往达里湖南岸的路是真的坑爹,路况很差各种炮弹坑,路还很窄,错车只能开到马路边上。还有一些鸡贼的车不往马路边上开,导致每次会车都要做一次心理交锋:你不让我也不让,看谁能挺到最后。多少年没开过这种烂路了,而且还长,有十几公里,抖得人身心俱疲。也是再也不来的体验。

到南岸没有进景区,按照攻略跟着碧海银沙景区门口岔路拦车的人,20 块钱带你找了个湖边进去。但是感觉仍然被坑了。带你进去的时候说里面是自己家开的农家乐,可以在湖边吃饭住宿,结果往里带了五六公里,还是个很荒凉的湖边,没有房子。往里走的时候发现,如果自己沿岔路开进去,多的是 10 块钱就带你到湖边的地方,而且离景区大门还更近一些。

但必须得承认的是,作为内蒙第二大咸水湖,达里湖还是很壮观的。而且沙滩很大,沙子的确是银色的,有很多候鸟,有的鸟看起来很像海鸥。如果有时间的话,可以尝试在湖边住一住,玩玩沙子散散步,感觉应该不错。

达里湖无人机视角

本来想在达里湖找个有特色的酒店住一晚,鉴于乌兰布统的经验,没有提前订。让我儿子参观了一下价位最高的将军府蒙古包,他觉得还是更喜欢现代化的汉庭,于是只能再回经棚。

这一天玩下来已经有些累了,也不知道去哪里好,直接回家吧又觉得不甘心。随便找了找发现玉龙沙湖看起来也不错,于是决定第二天去玉龙沙湖。

8月3日 经棚—西拉木伦峡谷—玉龙沙湖(翁牛特旗) 333km 6小时

西拉木伦河(西拉沐沦河)是非常长非常宽的一条河,发源于克什克腾旗。所谓的西拉木伦峡谷,是西拉木伦上游的河谷,它是一个很宽的河谷,沿峡谷的公路也并不是在谷底,有上有下,有的在峡谷外侧的高原上。总得来说,不太值得一去,和达里湖情况一样,路况太差,风景不如延庆的百里画廊。“经乌线”上有一个西拉木图峡谷驿站,也就是一个公路休息区,从驿站上远眺一下峡谷,可能是角度最好的地方了。

西拉木伦驿站
从驿站俯瞰西拉木伦峡谷

从克旗开往玉龙沙湖的丹锡高速(丹东—锡林浩特),车是真的稀少,路况也非常好。从经棚西面上了高速以后,差不多五六十公里的路上,只有我一辆车。丹锡高速这段就是沿着蜿蜒的西拉木伦河行进,一路上会看到西拉木伦河1号桥,2号桥,3号桥……

我们预定了玉龙沙湖的集装箱酒店。这个酒店有两种房型,三个位置。木屋房型在大湖边,但是离大湖很远,其实门口望出去是沼泽地,没啥风景;集装箱房型在小湖边,对岸就是沙山,风景比较好;但是集装箱房型也有临湖和不临湖的两排,个人感觉临湖的视角更棒。

集装箱房间位置

房间里苍蝇很多,我们提前在克旗的超市里买了雷达喷雾……先封闭喷了一下房间才入住,后来还是有苍蝇从门口进来,就直接用喷雾喷一下一会儿就挂了。

我们在临湖的阳台上搭了个帐篷,白天虽然很晒,晚上的气候非常宜人。帐篷有纱帐,没有苍蝇,我带两个娃开着露营灯在帐篷里玩到了九点多才回房间睡觉。

阳台湖景

8月4日 玉龙沙湖—北京 524km 8.5小时

本来准备上午热的时候去泡温泉,结果第一次去忘带温泉票了不让进(温泉位置很远,摆渡车要坐 15 分钟),第二次去说设备故障,还不知道啥时候能修好。去前台闹了一下,才知道是有大人物来视察,要提前排练,所以温泉不接客。很生气,但懒得跟他们纠缠了,拿温泉票退了点儿钱走人。

最后说一下,玉龙沙湖集装箱酒店的早餐自助餐真差,连汉庭的早餐都不如。这地方大概也不会来第二次了。

10点多出发回京,下午2点多到京承高速司马台进京检查站,然后就是漫长的堵车,两个多小时才过检查站。在太师屯服务区疲惫地吃了点儿饭,服务区空调还没开,热得一身汗。

吃完饭一出门,黑云压顶,在京承高速密云段一直狂风暴雨,甚至看不清路。结果开到五环,只下了一丢丢小雨。

太师屯服务区乌云压顶