阅读视图
以细节诠释专业,用成长定义价值——对话@孟同学 |得物技术
最近爆火的MCP究竟有多大魅力?MCP开发初体验|得物技术
大语言模型的训练后量化算法综述 | 得物技术
一
简介
在模型轻量化领域,量化是一种用于减少神经网络模型大小和计算量的技术,将模型参数(权重)或中间变量(激励)从高精度类型(FP32, FP16, BF16等)转换为低精度类型(int8, int4, fp8等)。 而近年来随着Transformer,MoE等架构的提出和大模型的兴起,使得神经网络模型能轻松突破几十亿甚至上万亿的规模参数,因此,我们需要一些适应于大模型的压缩技术,来降低模型的部署成本,并提升模型的推理效率。
从最初的GPTQ、AWQ等weight-only的量化算法开始,到现在LLM从训练、推理、轻量化、Agent等所有赛道都卷到飞起的时代,基于大模型的特性,在两年多时间里业内已有很多新的量化算法。
二
概念
以下介绍一些模型量化中的概念。
量化
-
量化感知训练(Quantization Aware Training, QAT):训练过程中插入伪量化算子,通过训练时统计输入输出的数据范围并动态调整量化参数,将量化过程结合到模型的训练过程中,适用于对模型精度要求较高的场景。
-
训练后量化(Post Training Quantization, PTQ):模型训练完成后对其参数进行量化,通常只需要少量校验数据或不需要校验数据,简单高效,不需要训练,但通常相比QAT精度损失略大。
由于LLM通常训练成本巨大,所以PTQ在LLM中通常是主要的量化选择,本文后续主要介绍各种PTQ的方案。
量化对象
-
Weight:即模型的权重,在LLM中主要指Linear算子的权重。权重量化可减少模型显存开销。
-
Activation:在模型前向计算过程中的输入输出变量,通常不会单独量化激励张量,而是结合权重量化一起。在LLM中激励矩阵的数值变化范围相比权重更大,有更多离群的异常值,因此相比权重量化更难。
-
KV Cache:除了权重和激活之外,在LLM的 KV Cache作为减少重复计算的特殊存在,会消耗不少的显存。 因此,KV Cache量化在LLM推理中减少显存开销,提升吞吐也很重要。
在LLM中,对Weight和Activation而言,通常有只量化权重的weight-only方法和weight & activation都量化的方法;另外为减少KV Cache的计算开销,也有对其进行量化。
细粒度
-
per-tensor量化:逐张量量化,或逐层量化,每个张量只有一个缩放因子。
-
per-channel 量化:逐通道量化,每个通道都有不同的缩放因子。
-
per-token 量化:主要对transformer中的激励矩阵而言,即逐行量化。在LLM中,常与权重per-channel 量化配合使用。
-
per-group:以组为单位,多个元素成组共享一个缩放因子,如GPTQ、AWQ常用的128个元素为一组进行量化,将通道划分为更小的子组,以实现更细粒度的精度控制。
其他维度
分类维度 | 类型 | 对比特点 | 适用范围 |
---|---|---|---|
按是否需要额外校验数据 | 静态量化 | 不需要,通常速度较快。 | 常用于权重量化 |
动态量化 | 需要额外校验集对模型进行前向推理或后向传播,根据推理结果动态计算量化参数;相比静态量化速度较慢。 | 适用于权重量化和激励量化 | |
按量化过程的时机 | 离线量化 | 在模型上线推理前,提前计算量化参数。 | 常用于权重量化和激励量化 |
在线量化 | 在推理过程中实时计算量化参数。 | 常用于LLM中的激励量化 | |
按量化步长是否均匀 | 线性量化 | 量化步长固定,表示的范围均匀。计算复杂度低,硬件友好。 | 常用于基于通用GPU的量化方案 |
非线性量化 | 量化步长不固定,表示范围更灵活。精度损失更小,但计算复杂度高。对硬件支持要求更高。 | 用于基于专用芯片的量化方案 | |
按量化范围是否对称 | 对称量化 | 量化数据范围以零值对称。零点值(zero-point)固定为0值,仅需考虑缩放(scale)参数。 | 用于权重量化和激励量化 |
非对称量化 | 量化数据范围为非对称。zero-point和scale参数都要计算。 | 权重量化和激励量化通常不会同时为非对称量化 |
三
量化方法摘要
GPTQ
GPTQ是一种weight-only的量化方法。它的特点是通过Hessian矩阵对每层权重做逐列量化,并在每列量化中通过反馈补偿之前的量化损失。它是LLM早期主要量化算法,因量化速度快和量化损失小,是早期在实践中被应用最广的算法。具体细节可参见之前的文章:
模型量化与量化在LLM中的应用。
GPTQ算法流程(图片来源:参考文献[1])
AWQ
AWQ(Activation-aware Weight Quantization) 也是一种weight-only的量化算法,也是早期主流的LLM量化算法,其特点是量化速度相较于GPTQ更快,且量化损失在多数量化方案和模型上相较于GPTQ也更小,到目前为止也是一种非常实用的量化方案。
AWQ出自深耕深度学习轻量化多年的HanSong团队,其主要原理是根据前向推理中的对应激励矩阵各个通道的数值,而非权重矩阵的通道数值来衡量权重矩阵各个通道的重要性,从而自动检索每个通道的缩放因子,并进而在优化损失函数中减小量化误差。具体细节也可参见之前的文章:模型量化与量化在LLM中的应用。
AWQ中的平滑过程(图片来源:参考文献[3])
HQQ
HQQ(Half-Quadratic Quantization)也是一种weight-only的量化方法,由其名称可知通过半二次优化的方法得到量化参数。相比AWQ和GTPQ,HQQ不依赖于校验数据集,不从最小化输出激励的角度优化,而是直接从权重本身优化量化前后的权重误差;而且其量化速度特别快,并且在低精度量化上有较好的量化误差。
优化目标如下,最小化原权重与量化反量化后的权重之间的误差,为
范数。
-
为量化参数(zero point和scale)
-
为量化、反量化过程。
-
损失函数为
范数,P<1, 相比于
范数的均方差,
范数更关注权重数值中的长尾奇异值(outliers),以及矩阵的稀疏性,然而其非凸(non-convex)的特性需要优化函数做一定的转化。
优化过程
基于上述问题,引入一个额外变量让主优化函数分割成2个子优化问题;同时,为了方便使用迭代更新的过程解题,我们固定尺度参数
,从而只优化零值
通过交替优化的方法,可以写出如下子问题,以及超参的更新,
sp1的形式是近端算子,对于范数,存在一个广义的阈值解如下,
sp2可以通过量化公式代入,得到如下,
并通过进一步简化(基于W的quantization grouping维度取均值),
表现性能
HQQ的量化耗时非常短,以Llama2-70B为例,在A100上相比于GPTQ和AWQ,耗时分别缩短为1/50和1/25,同时也有着不逊色于前两者的量化精度损失;而Llama2-7B模型的量化耗时更是缩短到1分钟左右。
Llama2-7B量化:GPTQ, AWQ, HQQ三者的耗时对比
(图片来源:参考文献[6])
Llama2-70B量化:GPTQ, AWQ, HQQ三者的耗时对比(图片来源:参考文献[6])
HQQ 在group-wise量化模式下与GPTQ, AWQ等的性能对比(图片来源:参考文献[6])
SmoothQuant
SmoothQuant 是LLM量化领域首个对weight和activation做全量化,并能保障良好的量化损失,从而在实际中有广泛应用的量化算法,并以被Nvidia和Intel集成到各自的大模型量化工具TensorRT-LLM和Neural-Compressor中。SmoothQuant 也是由HanSong团队提出,因此也可在算法中看到相似的通道缩放操作。
该方法直接聚焦LLM量化困难的最主要原因,即transformer推理过程中的激活值中的异常值(Outliers)。激励矩阵中的异常值指的是绝对值比大多数的值大得多的元素值,异常值一直是量化领域的难点,是量化损失的重要来源,而LLM中的异常值尤难处理,因其通常持续存在于部分通道中,且量化过程中对其直接截断处理会对模型的生成能力造成重大影响。
SmoothQuant中量化难度的迁移:激励矩阵中异常值的平滑(图片来源:参考文献[4])
该方法的核心是通过逐通道的缩放变换,使得Activation矩阵的绝对值幅度变得平滑,从而变得容易量化,而为了保障计算一致性,将反缩放因子作用到Weight中,稍微增加了Weight的量化难度;从而整体上使得模型的量化难度降低,且提高了量化精度。
量化过程
Transformer中常规的矩阵乘法表示为,SmoothQuant的矩阵乘法则表示如下,
激励矩阵在列维度上每个元素除以
, 权重矩阵
在行维度上每个元素乘以
,从而完成了对激励矩阵的平滑,以及保持整个乘法计算的一致性。
通道维度的缩放因子用对角矩阵表示,而如何对
取值呢?作者提出了几种方案,
-
一种是利用激励矩阵各个通道的绝对极值,即
-
一种是利用权重矩阵各个通道的绝对极值,即
本质上,缩放因子的大小取值表达了我们要将激励矩阵量化难度的多少转移给权重矩阵。而以上的前者,容易将激活的量化难度向权重过度转移,从而导致权重量化难度大大增加;而后者会直接导致权重各通道的极值都相同,而激励依旧很难量化。
因而,一种平衡的方式如下,用表示迁移强度,来控制激励量化难度迁移到权重的强度,
而当时,下图表示了乘法计算中的缩放平滑过程。X 和W 在各自对应的通道计算绝对极大值,随后通过
这两个向量计算得到缩放矩阵,再对X 和W 两个矩阵进行缩放变换,最后再对两个变换后的矩阵做乘法。
SmoothQuant矩阵乘法中的平滑过程示例(图片来源:参考文献[4])
表现性能
在量化模型的效果上,对比了同为Weight-activation量化的几种算法,SmoothQuant在多个数据集上的准确率表现突出。但作者没有对比同时代下的GPTQ、AWQ等weight-only的效果。
SmoothQuant与W8A8和LLM.int8()的量化效果比较(图片来源:参考文献[4])
而在吞吐上,作者用CUTLASS实现了INT8乘法kernel,并将SmoothQuant集成到Pytorch之后,以W8A8方案为例,实现了在OPT模型上相比于原FP16模型在速度上1.56倍,以及在显存上1.96倍的优势。
SmoothQuant经算子优化后与FP16和LLM.int8()的推理吞吐性能比较(图片来源:参考文献[4])
QuIP
在基于正交矩阵旋转优化大模型量化中的异常值(Outlier)问题的思路中,QuIP(Quantization with Incoherence Processing)是较早提出的一个方案。这种思路与SmoothQuant一样,都是在真正的量化步骤之前,通过对权重矩阵或激励矩阵做一定的前处理,使得该矩阵中的异常值改善或消失,使矩阵平滑,同时在整个前向推导中还能保持计算一致性。而与SmoothQuant直接对目标矩阵做尺度缩放不同,这种思路通常是通过对目标矩阵左乘、右乘正交矩阵,使得矩阵变得更容易量化。
该方案的主要主要亮点如下,
-
一是分析矩阵中元素的绝对值分布,并定义了不相干性,将一个矩阵的量化难易程度具象化。
-
二是提出了基于正交矩阵LDL分解的对权重矩阵的逐列量化方案,并证明了GPTQ也是该算法下的一种特殊情况。
-
最后在低比特量化情况下,该算法证明了其性能优于之前的方案。
矩阵的不相干性
作者定义了基于值的不相干性:当一个Hessian矩阵
可以通过特征分解得到
,且对所有的
满足如下,
那么我们说是的。
而对于权重矩阵, 则定义其 如下,
以上定义中,矩阵的最大绝对值受限于值,而
值越小,则越不相干,对应地,矩阵中的异常值就越少,也越容易量化。
量化过程
整体算法过程涉及到较为复杂的数学推导过程和大量定义和论证,其主要过程如下,
-
第一步,对权重矩阵
做不相干性的前处理,使
更容易量化,并作简单的量化处理;
-
第二步,对Hessian矩阵(用
计算,与GPTQ相同)做LDL分解;
-
第三步,对
进行逐列量化,每次量化当前列时,考虑前面所有已量化列的误差为反馈以缩小量化误差;
-
第四步,逆不相干处理,以及反量化。
QuIP算法的量化过程(图片来源:参考文献[5])
LDLQ
作者定义了一个基于LDL分解的最优化近似算法,自适应的近似过程可以是近似或随机(Near or Stochastic)。 根据以下公式逐层优化,
作者定义了一个基于LDL分解的最优化近似算法,自适应的近似过程可以是近似或随机(Near or Stochastic)。 根据以下公式逐层优化,表示浮点权重,
表示量化后的权重,
表示输入矩阵,
是其二阶矩阵,Hessian矩阵。
而对于每层Linear的,用如下形式作逐列量化,
表示第k列权重,而
表示第1到k-1列,
表示量化后的第k列权重,Q表示对应的Near或Stochastic近似方法选择。
表示某种序列的向量,也正是需要通过LDL分解求的校正项的系数。而整体的量化过程可以用矩阵的形式表示,用一个上三角矩阵表示LDL分解的系数矩阵,即
组成的矩阵,
具体的不相干性处理和逆处理的算法过程可以参考论文中给出的细节。
QuIP中不相干性的前处理和逆处理过程(图片来源:参考文献[5])
表现性能
下图是作者给出的对OPT模型权重层的处理前后,各层的元素值的不相干性的变化,可见在处理后,最大绝对值下降十分明显。
OPT-2.7B模型在不相干性处理前后异常值数量的变化(图片来源:参考文献[5])
而在量化效果上,对比了同为weight-only的主流算法OPTQ(即GPTQ)在同比特情况下,多个验证集的准确率。在对Llama2-70b模型的低比特量化中,尤其是2-bit和3-bit, QuIP的效果明显,且没有崩坏。
QuIP与OPTQ(GPTQ)在不同比特下的量化效果比较(图片来源:参考文献[5])
QuaRot
QuaRot(Quantization scheme based on Rotation)是基于旋转矩阵变换的一种量化方案,它的量化对象包括weight,activation以及KV cache。通过旋转矩阵,在保持一致性的前提下,去除中间变量的异常值,从而使量化更容易,这种模式应用于transformer中的Attention,KV cache和FFN中的激活值。
旋转矩阵
旋转变换利用的是正交矩阵先简单介绍一些相关的矩阵知识。
-
正交矩阵
是满足
的方阵。
-
旋转矩阵是正交矩阵。
-
Hadamard 矩阵是一个元素值都为{+1, -1}的正交矩阵。
-
Walsh-Hadamard矩阵是维度为
的Hadamard矩阵,
令,
是一个包含从{+1, -1}随机抽取的向量,可知
也是正交矩阵。
计算不变性
令是一个权重矩阵,出现在Attention或FFN Block的左侧 (FFN中的
,
,及Attention中的
,那么可以将左侧乘以正交矩阵
,并通过将输出矩阵(FFN中的
, 及Attention中的
)乘以
来消除这种影响。
上述的计算不变性在当两个Block之间有RMSNorm时也是成立的。因为从概念上讲, RMSNorm对输入矩阵的每一行做归一化(其尺度缩放的参数会被吸收到就近的Linear权重),正交矩阵应用于 activation 矩阵不会影响范数。
那么总的来说,对于一个Activation矩阵,右乘
,使得线性层的输入由
变为了
,被归一化之后送入下一个 Block,该Block 的输入权重现在是
;即原本的
,变成了
, 输出不变,保持一致。
量化过程
QuaRot总体分为2个阶段
- 第1阶段,对transformer的前向过程进行旋转变换,具体是在Attention和FFN过程中插入离线Hadamard变换**和额外的在线Hadamard变换。
- 第2阶段,利用现有的量化方法对weight进行量化,以及将量化过程加入前向过程使得对activation和cache进行在线量化。
第一阶段
第1阶段是对各个环节做Hadamard变换。
原Attention(包括RMSNorm): 实线箭头表示训练期间的变量流向,包括每个token的填充和推理
原FFN(包括RMSNorm):门控前馈网络
QuaRot版Attention:RMSNorm缩放alpha被吸收到输入矩阵,隐藏状态插入在线Hadamard变换进行旋转
QuaRot版FFN:RMSNorm缩放alpha被吸收到输入矩阵,降采样Linear前插入在线Hadamard变换进行旋转。
QuaRot 量化前对transformer各个模块的旋转变换(图片来源:参考文献[7]
阶段1a: 权重调整,遵循计算不变性原理对权重做正交变换
对权重矩阵,例如, 首先,前面的LayerNorm 或 RMSNorm 的线性部分将被融合进来,再左乘随机Hadamard矩阵**
,表示如下,
其中表示归一化op被吸纳的线性部分,而对应输入的激励居住,则变为了
该操作对应Attention中的和FFN中的
,而这样处理后对比处理前,激励不再包含异常值。
旋转变换前后激励矩阵中异常值数量的变化(图片来源:参考文献[7])
阶段1b: 对FFN的输出插入在线Hadamard变换
该操作是针对下采样乘法的输入激励的异常值的处理。由上图可知插入了一个在线Hadamard变换算子,同时对下采样矩阵的参数做了补偿,使得
。
同时为了保障下一个Block的激励输入是带变换的,所以还需右乘一个,使得最终的变换形式是
,保障FFN的输出为
,作为下一模块的输入。
阶段1c: 对Attention模块的注意力和Value的Hadamard变换
作者对注意力块同时应用了在线Hadamard变换和融入权重的离线Hadamard变换。 在计算注意力时,写成每个Head计算的维度,有如下形式,表示相应的Linear权重,
其中,
-
为softmax的输出,是一个维度为序列长度的方阵
-
是单个Head的value矩阵,
-
相乘后与
相乘,上式表示逐Head的Attention模块输出
的计算过程
首先对分别右乘和左乘,做Hadamard变换,带入上式,可知保持计算不变性。
而分别有每个Head维度的
concat而成,可以用单个Kronecker**乘法的形式表示对
的变换,
然后利用如下特性构建完整的Hadamard变换,
-
对
,先右乘了
之后,再进行一次Hadamard Head操作(即
,
表示注意力计算的输出),即相当于又右乘了
,即总体右乘了
。
-
对
,先左乘了
,再左乘了
,所以总体左乘了
。
综上,所以总体上整个过程的设计保持了计算不变性。
阶段1d: 对key的Hadamard变换
Key向量的计算也会收到异常值的影响,所以也需要引入Hadamard变换来消除这个问题。注意力矩阵 计算如下,
其中,,是 输入Softmax时的缩放尺度,
表示mask, 如最常用的Causal Mask,Pos 表示位置编码。
由于Pos的存在妨碍了直接将Hadamard矩阵融合到中,因此也使用了在线Hadamard Head操作来旋转
,对他们右乘
,
其中的相当于变成了
,整个计算过程保持了计算不变性。
第2阶段
第2阶段是在变换后的真正量化过程。
阶段2a: 权重的量化
采用现成的GPTQ,或者更直接、更快速的RTN。
阶段2b: 激励的量化
对输入input进行per-token维度的在线量化,而其中RMSNorm依旧保持FP32的精度。
阶段2c: 缓存的量化
对kv cache直接量化到低比特并存储,并在需要计算时提取并重新反量化到FP16精度,计算乘法。而过程中Query保持FP16, 并参考了类似Flash Attention中的在线Softmax计算方式。
所以,结合上述细节和上图,我们可以讨论整个过程的数据流转。
在Attention过程中,FP16的输入右乘变换后,经过RMSNorm归一化,量化到INT4形式,并与左乘变换并量化后的权重做INT乘法运算,随后再反量化回FP16,其中
经过位置编码(RoPE)计算,而
经过变换并量化保存为cache,且在做MHA时反量化并变换回来,最后到输出Linear时再经变换和量化,与已变换并量化的权重相乘,最终再反量化为FP16输出
。
在FFN过程中,FP16的输入右乘变换后,经过RMSNorm归一化,量化到INT4形式,分别与左乘变换并量化后的上采样权重和门控权重做INT乘法运算,并反量化回FP16,做点乘;最后经变换和量化到INT4,与变换并量化后的下采样权重做乘法,最终再反量化为FP16输出
。
表现性能
在对权重、激励和缓存的全4-bits量化效果对比中,QuaRot相对于SmoothQuant, QmniQuant和QuIK,在Llama模型上有性能优势;且应用了group-wise后,对比Atom也有性能优势。
QuaRot与其他量化算法的性能比较(图片来源:参考文献[7])
SpinQuant
SpinQuant也是一种在利用正交旋转矩阵减少异常值的思路上的量化方法。该量化方案也是一个全量化方案,其量化对象也是所有的权重,激励和KV缓存。
该方案中,作者分析了不同随机矩阵变换下,多次量化效果的稳定性。用普通随机矩阵做旋转变换的量化过程的量化效果,最好与最差之间相比差距多大13个点,而随机 Hadamard 矩阵优于随机旋转矩阵,但也仍有6个点的不可忽略的方差。而作者提出的Cayley优化矩阵,如下图对比,则能将最终量化性能的方差明显缩小。
Llama2-7B 在不同随机旋转矩阵量化到W4A4模型的性能分布。不同随机旋转矩阵(普通随机,Hadamard和Cayley优化矩阵)之间的方差(图片来源:参考文献[8])
插入旋转矩阵
作者提出了针对不同复杂度而定制两种旋转策略。
下图是在完整的transformer block中插入不同旋转矩阵的概图,有四类旋转矩阵:,根据是否能合并,分为两类,
-
2个可合并的旋转矩阵:产生旋转不变的全精度网络。
-
2个在线的Hadamard旋转矩阵:进一步减少极端activation, kv-cache量化的异常值。
由此,作者提出了两种量化方案
-
SpinQuant(NoHad): 仅使用了离线旋转矩阵
-
SpinQuant(Had): 使用了
SpinQuant整体的旋转变换(图片来源:参考文献[8])
R1R2
SpinQuant旋转矩阵的插入和应用与QuaRot大同小异。
由上图(a)(b)可知,和QuaRot中的1a一样,作用于每个Attention和FFN的输入处的激励矩阵,即Attention的Q、K、V Linear输入和FFN的上采样、门控Linear输入;具体到模块内部,其补偿矩阵
$会被吸收到各种的权重矩阵中。
则是Head-wise地将注意力机制的输出乘以
, 随后在输出output的投影矩阵
乘以
。这一操作类比于QuaRot中的1b,其旋转的计算一致性如下,
R3R4
类似于QuaRot中的1c,在注意力机制中插入了额外的在线Hadamard变换(),以及在FFN的降采样Linear之前插入了在线Hadamard变换(
),其旋转的计算一致性如下:
注意力机制中value矩阵的旋转变换(图片来源:参考文献[8])
FFN中下采样输入的变换(图片来源:参考文献[8])
Cayley优化旋转矩阵
本方案的一个主要贡献,是基于上述随机矩阵的方差分析,对旋转矩阵进一步做了基于最小化量化网络误差的优化。优化目标是上述的可合并的 ,而在线旋转
依旧使用了Hadamard随机矩阵,这也是两种方案命名为NoHad和Had的原因。
基于优化过程的损失函数如下,
这里,
-
表示 Stiefel 流形,是正交矩阵的集合,{
}。
-
是基于校准集的比较量化前后的任务损失,可以是交叉熵,是一个关于{
}的函数。
和
分别是权重矩阵和输入激励矩阵。
为了优化上述函数,作者采用了一种叫Cayley SGD的梯度方法,这是一种Stiefel流形上的高效算法。其本质是一个迭代更新的优化过程, 在每次迭代中,旋转矩阵基于梯度更新,
其中定义 ,是对矩阵
的Cayley变换,
是斜对称矩阵(
)。 而
由上述的损失函数的梯度
的一个投影计算得到,
通过矩阵的正交属性,推出Cayley变化得到的矩阵
的正交属性,从而保证更新后的旋转矩阵
的正交属性。通过上述的梯度计算的迭代过程,可以求解优化
,在这个过程中transformer的权重参数保持不变。
在具体实践中,作者基于WikiText2数据集作为校验集,用其中800个样本作前向推导,使用迭代更新次数为100次的Cayley SGD梯度优化结果作为新的旋转矩阵{},并在上述的随机矩阵量化结果分析中取得了最小方差和最优效果。
表现性能
在量化性能上,基于Llama2系列与SmoothQuant、OmniQuant等方案作了比较,也与weight-only的算法GPTQ, AWQ, QuIP等做了比较,有更低的PPL(困惑度)和更好的准确度。
量化效果对比(验证集:基于8个0-shot推理任务的平均准确度和基于WikiText2的PPL; 测试模型:Llama2)(图片来源:参考文献[8])
而且作者也对比了基于优化Cayley旋转矩阵和随机Hadamard矩阵的相同量化方案下的量化效果,体现了控制变量下的优化效果。
Llama3.2, Llama-3, Mistral等在8个0-shot任务下,Hadamard与Cayley优化矩阵的效果对比(图片来源:参考文献[8])
QQQ
QQQ(Quality Quattuor-Bit Quantization)是来自meituan的一个缝合了多种量化手段的方案。它吸收了自适应smooth技巧和Hessian-based权重量化算法,并重写了整型乘法的高效算子库,是一个针对weight和activation全量化的two-stage的量化算法。
QQQ算法的二阶段量化流程(图片来源:参考文献[9])
量化过程
自适应平滑
通过通道维度的缩放操作,使得激励矩阵的异常值变得平滑,从而降低量化难度,这是启发自smoothquant算法,为求最优的平滑系数,构建了如下最小化量化前后输出误差的优化函数,
为element-wise的除法和乘法。
权重量化
基于Hessian的逐列的权重量化算法,则是借鉴自GPTQ,
表示
第
行,
表示Hessian矩阵。
W4A8
为支持和加速不同比特位的整型乘法,重写了W4A8的GEMM算子,融合了整型转换和反量化的过程,如下图,包含了per-channel和per-group 两种方案。
-
Per-channel: INT4的权重
先通过精度转换变成INT8格式,再与INT8的激励
做GEMM, 最后反量化为FP16精度。
-
Per-group: INT4的权重
首先通过精度快速转换为FP16, 再加载group量化参数将权重反量化,随后再精度转换为INT8, 与INT8的激励做GEMM, 最后再反量化为FP16精度。
W4A8的 per-channel权重量化模式(图片来源:参考文献[9])
W4A8的 per-group权重量化模式(图片来源:参考文献[9])
表现性能
在量化效果上,以Llama2为例,基于Wikitext2的PPL和多个测试集的0-shot准确率和同等量化QoQ效果相当,而在PPL上与weight-only算法相比似乎稍有不足。
QQQ与其他算法的量化效果PPL对比(图片来源:参考文献[9])
QoQ与其他算法的量化效果Zero-shot准确率对比(图片来源:参考文献[9])
而在推理性能上,通过基于高性能算子库Marlin重写了GEMM并集成到推理引擎vLLM上,
在Llama2-13B上相比于FP16,SmoothQuant和AWQ,有着2.24×, 2.10×, 1.59×的速度优势。
QQQ量化后的模型与其他算法的推理吞吐性能比较(图片来源:参考文献[9])
QoQ
QoQ(Quattuor-Oct ̄o-Quattuor)是来自HanSong团队的W4A8KV4的低精度全量化方案。QoQ算法及其相关的Qserve推理系统,集成了包括量化过程和算子优化,与其说是量化算法,不如说是一套完整的端到端的模型轻量化推理引擎。
作者在量化比特的选择上对比了Weight-only的W4A16方案和per-channel weight量化和per-token激励在线量化结合的W8A8方案。
-
对于批处理较小的情况,LLM的GEMM主要是内存受限,W4A16有着更高的吞吐。
-
当批次变大时,GEMM就变成了计算受限,由于INT8 Tensor Core具有更高吞吐量而使W8A8显得更快。
-
而作者认为W4A8能兼顾两者,在内存密度和计算密度的场景下都能保持较高的吞吐。
-
而在解码阶段,由于token的逐个生成特性,Attention的计算密度相对较低,因而KVcache的量化有助于解决内存密度问题,对KVCache选择W4,相比与W8能获得更高的性能。
-
而对于更激进的W4A4,一方面由于准确性下降,另一方面也由于W4A4 GEMM在当前GPU架构(Ampere, Hopper)上并没有太显著的提升。
量化过程
QoQ的量化过程是众多量化算法中的技巧融合,通过不同手段来减小量化误差。
渐进分组量化
QoQ的渐进分组量化 (图片来源:参考文献[10])
渐进分组量化(Progressive Group Quantization)指的是对weight 先进行per-channel的INT8量化,再进行per-group的INT4量化。
给定权重,先用per-channel对称量化至INT8形式,
表示量化后得到的INT8的权重,是所使用的量化参数scale。
然后,对上述量化结果再使用per-grouup非对称量化至INT4形式,
表示最终的无符号4-bit量化权重,
分别是对应的量化参数zero-point和scale。
当推理的前向过程计算W4A8 GEMM时,INT4的权重 被加载后,先反量化为INT8的权重
,再执行W8A8的矩阵乘法。
另外,实际中,为了保护INT8反量化时饱和溢出,将INT8对称量化范围从[-127, 127]缩小到[-119, 119]。
平滑注意力
平滑注意力(SmoothAttention)借鉴了SmoothQuant中依靠通道缩放转移激励量化难度的思路,主要针对Key矩阵异常值较多且难量化的问题。
下图可视化了Value矩阵和经过RoPE的Key矩阵的元素值分布,可见Value矩阵的值较为平滑,而Key矩阵中有明显通道固定的异常值。
RoPE输出Key矩阵经smooth前后的异常值变化,以及Value矩阵的异常值变化(图片来源:参考文献[10])
借鉴SmoothQuant, 通过per-channel缩放因子缓解Key矩阵中的异常值范围,
可以通过激励矩阵简单计算,
, 而缩放强度超参
可以取经验值0.5。由上图可见,通过平滑后Key矩阵的异常值得到明显缓解。
而实际中,通常缩放矩阵的补偿矩阵会融合到前一层的权重中去,而LLM中Attention的Query和Key通常会经过RoPE处理。RoPE在每个Head中将通道
与通道
配对。因此为了使SmoothQuant在RoPE中可交换,作者附加了一个硬约束条件,令
,即
Qserve中的平滑缩放优化(图片来源:参考文献[10])
这样则可以通过 和
将缩放矩阵的补偿矩阵融合到Query和Key的Linear层权重中去了。
旋转矩阵
同样,借鉴自QuaRot,QuIP等,使用Hadamard矩阵做旋转变换,来抑制输入激励矩阵的异常值。
Qserve中的旋转矩阵优化(图片来源:参考文献[10])
通道重排
另外,参考AWQ, GPTQ等,提出了基于激励的通道重排序,使用激励矩阵逐通道的最大||值,来表征通道显著性,重新排序通道,使得具有相似显著性的通道在同一个量化组中。
Qserve中的通道重排优化(图片来源:参考文献[10])
Qserve吞吐优化
在通过各种量化技巧融合实现了W4A8KV4的量化流程后,为了保障其推理吞吐性能,作者又设计了一个Serving系统,命名为Qserve,将量化过程融合,设计GEMM kernel, 相当于一个高效的推理引擎。
下图是Qserve runtime示意图,其中所有的GEMM层都使用了W4A8输入并在INT8的TensorCore上执行,输出FP16格式,所有的Attention和LayerNorm都以FP16计算,且整体的LLM模块的输入输出都是FP16格式。
Qserve runtime推理流程中的精度变化(图片来源:参考文献[10])
-
算子融合
对于QKV投影和FFN第一个Linear,激励量化被融合到前面的 LayerNorm 中;FFN层第二个Linear的激励量化,则融合到前面的激活 Kernel 中。
-
KV-cache管理
参考了vLLM、TensorRT-LLM等的PagedAttention模式,相比这些搜索引擎,Qserve采用了逐Head的动态管理模式,因为其需要存放量化参数,以及动态更新。
-
W4A8 GEMM
GEMM是计算的主要开销,Qserve通过对权重重排、Per-channel反量化、Per-Group反量化等做了深度优化。
-
KV4** 缓存
结合KV的量化和反量化优化整体的Attention流程耗时。
表现性能
在量化模型的PPL指标上,基于Llama,Mistral等模型,作者对比了很多量化算法,在同等量化条件下,QoQ有一定的优势,和QuaRot相当,而相比于Weight-only算法稍有不如;而在0-shot的准确率上优于Atom和QuaRot算法。
QoQ与其他算法的量化效果PPL对比(图片来源:参考文献[10])
QoQ与其他算法的量化效果Zero-shot准确率对比(图片来源:参考文献[10])
在推理吞吐上,得益于其对Pipeline的深度优化,Qserve甚至表现得优于TRT-LLM这样的专业推理引擎。
Qserve量化后的模型与其他算法的推理吞吐性能比较(图片来源:参考文献[10])
FP8
FP8是以8-bit位表示的一种低精度浮点格式,Nvidia 的GPU从Hopper架构的显卡开始支持FP8的训练和推理格式。FP8有2种格式,以下是与FP16和BF16的数据格式对比,
FP8两种格式与FP16及BF16的比特位表示对比(图片来源:参考文献[13])
-
E4M3:包含1个符号位,4个指数位(exponent) 和 3个尾数位(mantissa),可以表示[-448, 448] 范围的数值和nan.
-
E5M2:包含1个符号位,5个指数位(exponent) 和 2个尾数位(mantissa). 可以表示[-57344, 57344],正负无穷(inf)和 nan.
FP8两种格式的具体表达范围(图片来源:参考文献[11])
符号位占一位,表示正负。
指数部分在浮点表示法当中,一般会减去一个偏移量,对于FP8 E4M3 而言,这个偏移量为-7,这使得指数的表示范围为[-7, 8]。对于 FP8 E5M2 而言,指数偏移量为 -15,指数表示范围为[-15, 16]。
底数从高位到低位,分别表示2的负k次幂;对于E4M3格式,使用3个比特表示底数,其分别对应2的负1, 2, 3次幂。对于E5M2格式,使用2个比特表示底数,分别对应2的负1, 2幂。底数表示时会额外加1,而当指数部分全为0时,则不额外加1。
浮点量化
相比于整型量化,浮点的量化属于非均匀量化,即浮点量化的步长是不固定的,由下图可知,相比于FP32到INT8的映射,浮点量化的步长随着指数部分的变大而变大。
FP8与INT8量化的量化步长对比(图片来源:参考文献[12])
量化精度
FP8与INT8量化孰优孰劣,只能说各有长短。FP8 相比INT8,有更大的表示范围,但在一定范围内,其精度表达能力相较INT8为差。如下图,从高斯分布随机抽样1000万个数字,分别使用 FP8 E4M3, FP8 E5M2, INT8 完成量化。在三者的量化中,应用不同的缩放参数来调整量化效果,画出量化误差(用MSE表示)。可以看到,FP8之间,E4M3的量化效果要好于E5M2, 而在选取合适的量化参数范围内,INT8的量化效果要好于FP8,而FP8具有更好的兼容性,对缩放参数的选择相对不敏感,更适合不需要校验集的量化。
FP8与INT8在不同量化参数下,对正态分布数据量化的精度损失对比(图片来源:参考文献[12])
四
总结
下面是对以上介绍的一些大模型量化方案的简要总结和对比,
量化算法名称 | 量化对象 | 特点和适用范围 |
---|---|---|
GTPQ | 权重 | 离线量化,支持4~8-bit,量化速度较快,支持模型较多,比较成熟 |
AWQ | 权重 | 离线量化,支持4~8-bit,量化速度较快,支持模型较多,比较成熟 |
HQQ | 权重 | 离线量化,支持1~8-bit,量化速度在所有算法中最快,量化精度与GTPQ,AWQ相当 |
SmoothQuant | 权重、激励 | 在线量化,支持8-bit,量化速度较快,支持模型较多,比较成熟,推理吞吐较快 |
QuIP | 权重 | 离线量化,支持2~4bit,量化速度较快,低精度(2-bit)下效果优于GPTQ |
QuaRot | 权重、激励、KV缓存 | 在线量化,支持4-bit, 8-bit,低精度(4-bit)下效果优于SmoothQuant |
SpinQuant | 权重、激励、KV缓存 | 在线量化,支持4-bit, 8-bit,低精度(4-bit)下效果优于SmoothQuant,GPTQ,量化速度较快,但略慢于GPTQ |
QQQ | 权重、激励 | 在线量化,支持4-bit, 8-bit,推理吞吐较快 |
QoQ | 权重、激励、KV缓存 | 在线量化,支持4-bit, 8-bit,推理吞吐较快 |
FP8 | 权重、激励、KV缓存 | 在线量化,支持FP8精度,依赖较新GPU,推理吞吐较快 |
综上,文章简要介绍了近期一些LLM后量化的算法和方案,当然还有众多算法未涉及和细讲,如SpQR,ZeroQuant, KIVI**,Atom, OmniQuant,AQLM等。
五
参考文献
- GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers
- AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration
- AWQ slides: hanlab.mit.edu/projects/aw…
- SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models
- QuIP: 2-Bit Quantization of Large Language Models With Guarantees
- HQQ: Half-Quadratic Quantization of Large Machine Learning Models.
- QuaRot: Outlier-Free 4-Bit Inference in Rotated LLMs
- SpinQuant: LLM quantization with learned rotations
- QQQ: Quality Quattuor-Bit Quantization for Large Language Models
- QServe: W4A8KV4 Quantization and System Co-design for Efficient LLM Serving
- Nvidia: FP8 Formats for Deep Learning
- FP8量化原理简介:zhuanlan.zhihu.com/p/574825662
- Nvidia Transformer Engine: Using FP8 with Transformer Engine
往期回顾
4.得物 iOS 启动优化之 Building Closure
文 / 旭囧
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
得物可观测平台架构升级:基于GreptimeDB的全新监控体系实践
得物业务参数配置中心架构综述
得物增长兑换商城的构架演进
得物自研DGraph4.0推荐核心引擎升级之路
如何合理规划Elasticsearch的索引|得物技术
DPP推荐引擎架构升级演进之路|得物技术
Cursor 在前端需求开发工作流中的应用|得物技术
一、引言
很高兴与大家分享现阶段 Cursor 在我的工作中的使用体验。首先是预期管理,本篇文章不会分享 x 个你可能不知道的小技巧,也不会让你拥有无需自行编码的能力,同时不涉及 Cursor 工程化方面内容。仅仅是围绕个人开发流程中的已有问题,分享如何使用 Cursor 来提升这部分的开发体验,在工作中持续保持好的节奏和状态。
TL;DR
-
列举 Cursor 的错误预期
-
相比过去的开发流程,使用 Cursor 后的变化
-
Cursor 在现状分析、方案设计和影响评估中的收益
二、就差一个程序员了
最近团队在大力推广 Cursor AI,随着几个版本迭代体验下来,其精准的自动补全深得我心,具体可以体现在 Tab 键的使用率已逐渐高于 Command + C/V。既然这么懂我,那么能否更进一步,根据 PRD 直接输出代码呢?
2.1 从需求到代码
Cursor 能够理解代码上下文,从而根据简短的描述生成符合上下文的代码,于是我尝试直接将 PRD 提供给 Cursor 来生成代码:
PRD → Cursor → Code(一步到位)
几个需求尝试下来,总的来说分为两类问题:
这就像你去理发店,希望 Tony 老师稍微剪短一点,结果却被剪得稍微短了点。而这需要我们在开始之前对齐认知,补充描述和参照。在这个前置阶段,即使发现对方理解有偏差,也还能及时纠正。俗称“对齐颗粒度”。
2.2 从规划到执行
Cursor 产出的代码由它所接收的上下文决定,如果没有准确描述需求意图,它会通过推断做出假设,导致产出不准确。因此我们在使用 Cursor 时,关键在于区分开发过程中的规划阶段和执行阶段。在这个分层的视角下,不管是自身的关注点还是 AI 的角色定位都变得更加清晰:
Cursor 在这个过程中,不应该被视为开发者的替代品,而是一面能够放大开发者能力的镜子:
-
对于已知的部分,Cursor 可以加速实现,减少重复劳动。
-
对于未知的部分,Cursor 可以协助探索,但不能替代开发者的判断。
在理解了 AI 的角色后,我们需要重构目前的开发工作流,让 AI 成为真正有效的助手。最关键的转变是:**不再试图让 AI 替代开发流程中的任何环节,而是让它协助完成每个环节。**这意味着不是把 PRD 扔给 AI,等待完整代码,而是和 AI 一起理解 PRD 和代码现状,共同设计方案,明确步骤,然后分步实现。
三、现有问题
作为前端开发,我们的日常工作流程中大多围绕需求文档进行代码产出。这需要介于
-
我们对业务需求的理解。
-
对所属业务和项目现状的认知。
-
从而进行方案设计和决策,整理思路将复杂问题分解成可执行的粒度。
但同时,这导致我们不得不面临着一个矛盾:方案设计对效率的影响。一方面,方案设计是保证质量的必要环节;另一方面,生成和维护这些产物又会显著降低开发效率。尤其是在快速迭代的项目需求中,这种矛盾更为突出。
有时即使是一个小需求,可能也需要经过大量前置分析,才能进入开发。举个例子,以下是某个小需求的前端方案截图,通过不同的颜色区分了各流程的占比。从图中可以看出,各模块中绿色和蓝色所对应的「现状分析」和「改动方案」后占据了主要的篇幅,与相应的时间占用成正比。
前端方案中的各环节分布
传统的解决方案通常是:
-
模板化方案设计,减少重复工作。
-
简化方案设计,减少不必要的细节描述。
-
提高团队熟练度,使得方案设计生成更加高效。
作为附加项,现在我们能在这些基础上借助 Cursor 进一步提升效能。
四、协作流程
4.1 反馈循环
在协作时,关键在于对 Cursor 补充上下文,并对 Cursor 提供的结论进行人工核验,两者构成反馈循环。前者是希望 Cursor 知道,后者是需要我们自己知道,从而保障产出的结果符合预期。
整体的 Cursor 协作流程分为规划和执行两个阶段。规划阶段专注于产出方案,执行阶段根据方案产出代码,两者交替执行。
4.2 流程对比
相较于以往,在使用 Cursor 后的工作模式差异如下:
乍一看使用 Curosr 后流程更加繁琐,而实际上也确实如此。
所以这里更推荐换一个心态来看待流程上的变化,不必为了使用工具而使用。过去我们面向 Google / GitHub / Stack Overflow 编程也并不是因为我们为了搜索而搜索,是因为在具体开发中遇到了不明确的信息需要确认,现在这个角色可以渐进地由 Cursor 替代,比起搜索引擎,Cursor 能充分地根据项目现状分析出更贴切的答案,如同行车的导航和选购的得物,为此不必有太多的心理负担。
五、场景应用
重新回到在需求开发工作中的问题,占据我代码之外的主要工作是“现状分析”、“改动方案”和“影响评估”,因此主要分享这三个场景中的 Cursor 使用体验。
关于提示词,可根据实际需要使用 notepads 或 rules 降低单次使用成本。
5.1 现状分析
在需求开发过程中,我们时常会接触到陌生的业务模块,如何理解现状往往是最耗时也最容易被忽视的部分。如果对现状不够了解,当需求相对复杂或者项目本身存在较多的历史债务时,我们很难输出符合预期的方案,更难以保证最终代码的质量。对于新接手项目的开发者而言,这一阶段常常伴随着无数次的"代码考古"和"问询前人"。
Cursor 离代码上下文更近,我们可以在它的协助下抽丝剥茧,快速了解业务主线。这是一个学习的过程,当知道的越多,在后续的设计和开发中就越能正确地引导 Cursor。
具体可以从需求的目标改动点开始,梳理其所属功能和实现方式,包含交互流程、数据管理和条件渲染等:
业务需求
├── 1. 功能
│ ├── 2. 实现
│ ... └── 3. 字段
...
目标 | 了解业务功能 | 了解代码实现 | 了解字段依赖 |
---|---|---|---|
提示词参考 | 当前功能如何运作,用户交互有哪些路径,具体数据流向是怎样的,请整理成 mermaid 时序图。 | 当前代码如何组织,核心模块有哪些,组件间如何通信,梳理组件关系图。 | 梳理当前表单字段的显隐关系、联动逻辑以及数据源。 |
效果 | 输出所属功能中的角色和角色之间的交互方式,能快速掌握业务模块的大体脉络。 |
输出组件职责和组件间的关系,以便在投入开发前以组件模块维度确定改动范围。 |
能直观地呈现表单字段间的联动说明。 |
通过对上述三个层面的不断往复,Cursor 提供的直观输入能帮助我们摆脱掉一知半解的状态,消除不确定性也就消除了焦虑。
5.2 改动方案
在了解了现状后,开始面向需求进行改动方案设计。
在问答中,Cursor 倾向于直接满足表面的需求,但可能会忽略一些深层的系统设计考虑。当遇到复杂的问题时,建议先让 Cursor 分析问题本身,而不是直接要求它给出解决方案。通过引导它进行更全面的思考,能防止 Cursor 胡编乱造,确保它理解需求,同时也能暴露自身的思考局限,减少返工。具体做法可以先提示 “在我让你写代码之前不要生成代码” 以及 “先逐步分析需求再说明你打算怎么做”;
另一方面,由于 Cursor 背后 LLM 的 Context Window 存在上下文长度限制,意味着 Cursor 跟我们一样都存在“短期记忆”,这体现在当对话超出范围后,Cursor 会在输出方案和代码时,遗忘此前的要求和结论,造成不准确。因此,为了将短期记忆转换成长期记忆,需要我们对复杂任务进行必要的拆解,每次只专注于单个粒度下的问答,当确认方案后,再让 Cursor 汇总并记录到外置文档,以便在后续的对话中补充上下文(也可以借助 @Summarized Composers 实现)。在面对下一个任务时,开启新的会话进行问答,多轮下来形成由不同模块组装而成的方案设计。
这样一来,在生成代码阶段,Cursor 所需要面对的只是局部复杂度中的改动,这能很大程度上减缓我们在代码审核和验证上的投入成本。Cursor 也能始终保持在长度限制范围内,面对精炼后的方案设计进行决策和产出。
因此在整体流程上:
1. 拆解需求,缩小关注范围
2. 明确目标,清晰表达需求描述
-
Cursor 提供方案
-
检查是否有理解偏差,并不断调整提示
-
在确认方案后,最终由 Cursor 汇总成果
3. 渐进开发,分模块由 Cursor 生成代码,及时验证效果和审核代码
提示词参考:
- 方案设计
我们先探讨方案,在我让你写代码之前不要生成代码
如果此处要加个 xxx 该怎么做,请先逐步分析需求
在想明白后向我说明为什么要这么设计
- 代码产出,在功能之外,留意识别边界场景以及控制影响面
在写代码时遵循最小改动原则,避免影响原先的功能
即使识别到历史问题也不要自行优化,可以先告知我问题描述和对当前需求的影响,不要直接改跟本次需求无关的代码
5.3 影响评估
除去开发之前的方案耗时,在完成开发后,我们所要解决的是如何保障自测质量的问题。对于研发而言,需要关注的是在这个需求迭代内,改动点所关联的调用链路,而在这个路径依赖下不断冒泡所涉及到的具体功能就是影响面。
因此可以从两个方面提高自测可信度
-
自下而上:基于改动代码和依赖项进行白盒测试,这需要研发自身投入必要的时间进行代码审核;
-
自上而下:识别改动最终涉及到的页面和功能进行黑盒测试,逐个回归和确认功能是否符合预期。
借助 Cursor 可以很低成本地分析改动,并按需产出测试用例,通过 @git 指令让 Cursor 参与到对当前功能分支或具体 commit 的评估:
目标 | 代码审查 | 功能验证 |
---|---|---|
提示词 | @git逐个文件分析并总结改动点,评估是否引入了新的问题。 | @git基于代码变更输出自测用例清单。 |
效果 | 在列举出每个文件的改动意图后,会告知潜在问题和修改意见。 |
围绕改动,生成新旧功能在不同场景中的测试用例。 |
六、小结
过去,成为一名优秀开发者需要经历漫长的积累:从反复查阅文档、在搜索引擎中筛选有效信息,到系统掌握编程语言、算法与网络原理,每一步都在构建扎实的「知识护城河」。而 AI 时代颠覆了这一逻辑 —— 当大模型能快速生成代码、解析技术方案时,开发者的核心能力似乎从“记忆与执行”转向成了“正确地提问,让 AI 提供答案”。
客观来看,AI 降低了信息获取的门槛,能更快地落地想法、验证思路。不变的是,好的答案源于好的问题,而提出好问题依旧需要积累专业领域下的知识,知道的越清楚才能在提问时描述得越清晰。
所有事都有吃力不讨好的部分,随着 Cursor 等 AI 工具在工程中的应用,我们可以逐渐将这部分职能分配出去,利用我们的知识储备,描述问题,引导过程,审核结果。工具的使用始终是为了节省人类体力和脑力的开销,从而在提升体验的同时提升生产力,以更充沛的精力聚焦在工作成果和个人成长上。
往期回顾
1.得物 iOS 启动优化之 Building Closure
3.从对话到自主行动:AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术
文 / 魏命名
关注得物技术,每周一、三更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
得物 iOS 启动优化之 Building Closure
分布式数据一致性场景与方案处理分析|得物技术
AI应用如何从 Chat 进化为 Agent?开源项目源码深度揭秘|得物技术
一、引言
从2022年12月份OpenAI发布ChatGPT产品至今已有2年多的时间,当大家已经习惯于在对话框中与AI交互,习惯于通过各种Prompt技巧让AI更好的理解并回答我们的问题,似乎默认这就是一种比较好与AI的交互方式了。
然而,这就是我们期盼的与AI交互的形式嘛?这是一种高效的方式嘛?
显然,这是不够的。
我们期望的是:告诉AI我们想要的目标或者任务,AI能够理解深度理解并分析我们的意图、自动的进行任务的拆解、自动的寻找可以使用的工具、自动的进行结果数据的汇总过滤、自动的呈现符合任务的展示形式。同时在任务处理过程中,可以自己完成异常的检测和修改。就如同一位优秀的同学,我们告诉他任务的目标,他可以自己寻找飞书文档、搜索网络知识、使用内部系统、自己编码验证方案可行性,并最终给一份好的解决方案。
二、以「对话为中心」的ChatBot
我们发送一条指令,AI被动的响应指令。即完成一轮人与AI的交互。
具体视频请前往“得物技术”微信公众号观看。
三、以「交付为中心」的多智能体Agent
我们发送一个任务,AI自动分析任务、调用可用的工具、分析结果、过滤数据并自动处理异常,最终呈现解决方案。
完成这样的一个任务,需要多智能体Agent间的协作以及对常用工具的调用。那什么是智能体Agent呢?
具体视频请前往“得物技术”微信公众号观看。
四、什么是智能体Agent
从Prompt到思维链
随着大模型的发展,Prompt工程已成为撬动大模型潜能的核心技术。即使我们普通用户在与大模型的交互中,也通过角色定义(如"资深工程师")或示例引导来优化输出效果,但这类简单提示往往难以突破模型固有的逻辑天花板——就像给赛车装自行车轮胎,再怎么调整也难以突破速度极限。
但偶然间,人们发现了一个神奇的咒语:只需要告诉大模型,你的 think 要 step by step。研究者发现只要加了这个prompt,就能极为显著地改善大模型做数学题的正确率。
大模型的数学与逻辑能力短板,是所有体验过其对话功能的用户都能直观感受到的痛点。这一缺陷严重制约了大模型的商业化落地进程,毕竟没有人敢轻易信任一个逻辑混乱的智能系统能输出可靠的决策结果。于是,提升大模型数学能力,被所有做基础模型的公司当作了第一目标。
研究者试图通过强化思维链来突破这一瓶颈。一个直观的思路是:让模型像人类解题时在草稿纸上推演那样,通过 "step by step" 的方式展开逻辑链条 —— 在这个过程中,包含假设、演绎、反思、纠错等一系列思维活动。既然人类通过这种结构化的思考方式能够有效解决数学问题,那么大模型是否也能通过类似机制实现能力跃迁?这一猜想推动着研究向纵深发展,最终形成了思维链技术的核心框架。这样的观念经过继续钻研,最终就构成了思维链,思维链是一个能以最小的代价,而非常显著提升模型智力水平(逻辑能力、解题能力、代码能力)的技术。
值得注意的是,2025 年春节期间引发广泛关注的 DeepSeek 大模型,正是思维链技术的成功实践典范。尽管 DeepSeek 并非首创者,但其通过创新性地融合混合专家(MoE)架构与强化学习技术,显著提升了思维链推理的计算效率与性能表现。这种技术优化使得 DeepSeek 在保持高精度推理的同时,大幅降低了计算成本,最终实现了屠榜级表现。
ReAct架构
如果说思维链(COT)是给 AI 装上了人类的 "草稿纸",那么 ReAct 框架就是为它配备了 "双手"—— 让 AI 不仅能在脑子里推演,还能主动采取行动获取信息。这种 "思考 + 行动" 的组合,正在把大模型从 "纸上谈兵" 的理论家,变成能解决现实问题的实干家。
ReAct 的核心在于将**推理(Reasoning)与行动(Action)**紧密结合。当模型面对复杂问题时,会先像人类一样拆解思考步骤,然后根据中间结果调用外部工具(如搜索引擎、数据库、计算器)获取实时数据,再把这些信息整合到后续推理中。
其实,实现一个ReAct很简单,只需要构建Prompt+提供工具+循环执行即可,笔者在这里不进行详细的介绍,只需要给一个Prompt例子,读者就能理解:
尽可能最好地为用户回答接下来的问题,你可以使用以下工具来辅助你:{tools} 使用以下格式:
- 问题:你需要回答的输入问题
- 思考:你需要持续思考下一步采取什么行动
- 行动:要采取的行动,应该是 [{tool_names}] 中的一个,以及该行动的输入内容
- 观察:行动并观测结果,并判断结果是否合理 ...(这个思考 / 行动 / 观察可以重复 N 次,直到你认为知道了最终答案
- 最终答案:原始输入问题的最终答案
开始!
- 问题:{input}
Tools支持开发者自定义,比如给予LLM一个查询天气的接口、计算器接口等。
ReAct架构实现了一种**"问题拆解-工具调用-结果整合"的闭环机制**,使得开发者仅需通过定义工具集(如天气API、计算器、知识图谱接口)和设计任务引导词,就能将大模型转化为可执行多步骤决策的智能体。最终可以使大模型突破纯文本推理的局限,真正具备了在动态场景中解决开放性问题的工程化能力。
Agent
Agent作为大模型技术的集大成者,通过整合思维链(CoT)的推理能力和ReAct框架的行动机制,构建了具备自主决策与执行能力的智能系统。其核心突破在于将**“大脑”与“四肢”**有机统一,标志着大模型从被动应答迈向主动干预现实的质变。
在架构上,Agent与ReAct差别不大,ReAct是Agent的核心实现范式之一,Agent进一步整合记忆存储、多智能体协作等模块,形成更完整的自主决策系统。下图是一个简单的Agent架构图:
Agent处理流程
1-4步会循环进行,直到LLM认为问题已被回答。
1.规划(Planning):
-
定义:规划是Agent的思维模型,负责拆解复杂任务为可执行的子任务,并评估执行策略。
-
实现方式:通过大模型提示工程(如ReAct、CoT推理模式)实现,使Agent能够精准拆解任务,分步解决。
2.记忆(Memory):
-
定义:记忆即信息存储与回忆,包括短期记忆和长期记忆。
-
实现方式:短期记忆用于存储会话上下文,支持多轮对话;长期记忆则存储用户特征、业务数据等,通常通过向量数据库等技术实现快速存取。
3.工具(Tools):
-
定义:工具是Agent感知环境、执行决策的辅助手段,如API调用、插件扩展等。
-
实现方式:通过接入外部工具(如API、插件)扩展Agent的能力,如ChatPDF解析文档、Midjourney文生图等。
4.行动(Action):
-
定义:行动是Agent将规划与记忆转化为具体输出的过程,包括与外部环境的互动或工具调用。
-
实现方式:Agent根据规划与记忆执行具体行动,如智能客服回复、查询天气预报、AI机器人抓起物体等。
Manus:一个Agent典型案例
在读完前一节关于智能体(Agent)的技术解析后,读者也许会认为这类系统的工程实现并非难事,实际上也确实是这样。近期爆火的 Agent 产品 Manus 便是典型案例。当用户提出 "定制 7 天日本旅行计划" 的需求时,Manus 能够基于目标,自主进行网络搜索并将信息整合,展现出高度拟人化的任务执行逻辑。
尽管 Manus 目前尚未向普通用户开放,且采用邀请制注册的封闭运营模式,但其通过官方演示视频呈现的强大智能化表现,已在技术圈引发广泛关注。值得关注的是,随着Agent技术的热度攀升,开源社区已迅速涌现出 OpenManus、OWL 等多个复刻项目。
因为Manus并非开源,我们很难了解其技术细节。但好在:
-
"Manus 的部分技术细节,包括其提示词设计、运行机制等内容被网友通过非官方渠道披露,感兴趣的读者可自行查阅相关公开资料。
-
我们可以了解一下大模型上下文协议(Model Context Protocol,MCP),这是 Anthropic (Claude) 主导发布的一个开放的、通用的、有共识的协议标准,虽然Manus不一定用了这个协议,但目前一些相关开源项目也是基于MCP的,本文会在下面介绍MCP。
-
目前已有复刻的开源项目Openmanus,笔者会在接下来的章节剖析其源码。
大模型上下文协议(MCP)
MCP是做什么的?
MCP(Model Context Protocol)作为一项开放协议,旨在为应用程序与大型语言模型(LLMs)之间的上下文交互提供标准化框架。其设计理念可类比为数字时代的 "USB-C 接口"—— 正如 USB-C 统一了设备与外设的连接标准,MCP 通过标准化的上下文交互接口,实现了 AI 模型与多样化数据源、工具之间的无缝对接。
如下图所示,图中的MCP server都可以看成一个个工具(如搜索引擎、天气查询),通过“接口”连接到MCP clients(大模型)上,大模型可以使用各种MCP server来更好地处理用户的问题。
此外,下游工具的开发者也可以更好的开发其工具,目前在MCP官网即可了解其各种编程语言的SDK和相关概念。
MCP架构
MCP 的核心采用客户端-服务器架构,其中 host 可以连接到多个服务器,读者简单看看即可:
-
MCP 主机(MCP Hosts):指需要通过 MCP 协议获取数据的应用程序,涵盖 AI 开发工具(如 Claude Desktop)、集成开发环境(IDEs)等智能应用场景。
-
MCP 客户端(MCP Clients):作为协议的执行者,每个客户端与对应的 MCP 服务器建立一对一的专属连接,负责协议层面的通信交互。
-
MCP 服务器(MCP Servers):轻量化的功能载体,通过标准化的 Model Context Protocol 对外开放特定能力,可视为连接模型与工具的智能桥梁。
-
本地化数据源(Local Data Sources):包括服务器可安全访问的本地文件系统、数据库及专有服务,构成数据交互的近端生态。
-
远程服务(Remote Services):通过互联网连接的外部系统,例如各类 API 接口服务,拓展了模型的能力边界。
为什么要用MCP?
从技术演进视角看,MCP 的诞生是提示工程(Prompt Engineering)发展的必然产物。研究表明,结构化的上下文信息能显著提升大模型的任务表现。在传统提示工程中,我们往往需要人工从数据库筛选信息或通过工具检索相关内容,再手动将这些信息注入提示词。然而,随着复杂任务场景的增多,这种手工注入信息的操作变得愈发繁琐且低效。
为解决这一痛点,主流大模型平台(如 OpenAI、Google)先后引入了函数调用(Function Call)机制。该机制允许模型在推理过程中主动调用预定义函数获取数据或执行操作,极大提升了自动化水平。然而,函数调用机制存在显著局限性:其一,不同平台的函数调用 API 存在较大差异,例如 OpenAI 与 Google 的实现方式互不兼容,开发者在切换模型时需重新编写代码,徒增适配成本;其二,该机制在安全性、交互性及复杂场景的扩展性方面仍存在优化空间。
在此背景下,MCP 协议通过标准化的上下文交互接口,为大模型构建了更具普适性的工具调用框架。它不仅解耦了模型与工具的依赖关系,还通过统一的协议规范解决了跨平台兼容性问题。更重要的是,MCP 将上下文管理提升到系统架构层面,为大模型在复杂业务场景中的深度应用提供了可扩展的技术底座。这种从碎片化的提示工程到体系化的上下文协议的演进,标志着大模型应用正在向更高效、更规范的方向迈进。
四、智能体Agent实现的源码剖析(OpenManus项目)
OpenManus 是一个基于 MCP 协议的开源智能体实现项目,旨在通过标准化的上下文协议实现大模型与工具的高效协同。当前项目仍处于快速迭代阶段,本文以其 2025 年 3 月 12 日的版本为分析对象。选择该项目的原因如下:
-
团队背景与代码质量:项目作者来自MetaGPT,具备深厚的工程经验,代码结构清晰且注释完善,兼顾了技术实现与可读性。
-
部署便捷性:只需通过虚拟环境安装依赖并配置大模型 API Key(如 OpenAI 的 API 密钥),即可快速启动,降低了技术门槛。
-
技术前沿性:项目紧跟大模型技术发展,且目前仍在不断迭代的过程中。
在经过前面对相关概念的讨论,我们可以得知实现Agent有几个关键的点,读者可以带着问题在项目中寻找答案:
-
Prompt:其结构化的Prompt是什么样的?通过Prompt可以对其架构有一个初步认识。
-
OpenManus:怎么通过大模型思考和处理问题?
-
工具相关:怎么进行工具注册、工具管理的?工具执行逻辑是什么的?
准备
项目地址:
构建环境
创建一个python=3.12的虚拟环境
-
笔者测试了一下,非3.12版本会有一个package不兼容。
-
可以用conda或python内置的uv,项目文档提供了详细的指令。
安装playwright
- 如果第一次使用,需要安装playwright。
playwright install
## 或者
python -m playwright install
## 以上命令会安装所有浏览器,如果只需要安装一个浏览器比如firefox
python -m playwright install firefox
配置大模型API Key
-
可以用DeepSeek或通义千问的API Key,其中通义有免费额度,DeepSeek虽然收费但价格便宜,测试一次使用约1000token,成本不到0.01元。
-
根据项目文档配置cofig.yaml即可,但项目调用大模型是使用基础的OpenAI API,如果使用其他大模型,可能需要基于对应的官方文档小改一下。
代码
OpenManus客户端
Python OpenManus/main.py即可在终端运行OpenManus,读者也可以尝试其Web版本。
- 具体会调用20行代码,执行Manus类的方法run()。
进入OpenManus/app/agent/manus.py查看Manus类,可以发现它继承了ToolCallAgent类,再进入会发现又是继承,有点复杂,这里我画一张关系图。
-
act()执行时使用execute_tools()进行具体的工具执行。
-
总体来说,Manus类定义了Prompt和可使用的工具。
-
Base类定义了run(),在run()中会循环执行ReAct类的方法step(),直到Finish或达到max_step。
-
step()类会顺序执行ToolCallAgent类的think()和act()。
当然,这里只罗列了重要的组件和方法,一些方法没有画在图中。
Prompt
一般来说,输入给LLM的prompt分为两种:1)系统 prompt,用于定义模型的角色定位和行为规则;2)用户 prompt(OpenManus称为Next Step Prompt),用于传达具体的任务指令或信息需求。
在OpenManus/app/prompt/manus.py中即可看到Manus的Prompt,这里展示一下中文版,读者基于此可对OpenManus架构有一个初步认识:
-
系统Prompt(SYSTEM_PROMPT):“你是 OpenManus,一个全能的人工智能助手,旨在解决用户提出的任何任务。你拥有各种可使用的工具,能调用这些工具高效地完成复杂的请求。无论是编程、信息检索、文件处理还是网页浏览,你都能应对自如。”
-
下一步Prompt(NEXT_STEP_PROMPT):“你可以使用 PythonExecute 与计算机进行交互,通过 FileSaver 保存重要的内容和信息文件,使用 BrowserUseTool 打开浏览器,并使用 GoogleSearch 检索信息。根据用户的需求,主动选择最合适的工具或工具组合。对于复杂的任务,你可以将问题分解,逐步使用不同的工具来解决它。在使用完每个工具后,清晰地解释执行结果并给出下一步的建议。
当然,在实际执行时会对prompt有进一步优化,不过核心的系统定位与任务指导原则是不会改变的。
Manus类
我们先看一下OpenManus拥有的工具,工具也支持自定义,会在后文进行介绍。
-
PythonExecute:执行 Python 代码以与计算机系统交互、进行数据处理、自动化任务等等。
-
FileSaver:在本地保存文件,例如 txt、py、html 等文件。
-
BrowserUseTool:打开、浏览并使用网络浏览器。如果你打开一个本地 HTML 文件,必须提供该文件的绝对路径。
-
GoogleSearch:执行网络信息检索。
-
Terminate:如果LLM认为回答完毕,会调用这个工具终止循环。
Base类
run()
- 首先,输入的request就是用户输入的提问。
状态管理
- 执行时首先检查代理的当前状态是否为
IDLE
(空闲状态)。如果不是空闲状态,会抛出RuntimeError
异常,因为只有在空闲状态下才能启动代理的执行。
- 当进入循环时前,使用
state_context
上下文管理器将代理的状态临时切换到RUNNING
(运行状态)。在上下文管理器中执行的代码块会在进入时将状态切换为指定状态,在退出时恢复到之前的状态。如果在执行过程中发生异常,会将状态切换为ERROR
。
Memory管理
我们调用大模型的API,本质是向大模型提供方发http请求,http请求是无状态的。
- 也就是说,服务端不会保留任何会话信息。对于每次都完成一个独立的任务,无状态是没有任何问题的。但对持续聊天来说,就会出现对之前会话一无所知的情况。
所以为了让大模型持续与用户的对话,一种常见的解决方案就是把聊天历史告诉大模型。
- 因此,在OpenManus中会进行Memory的管理。
-
用户提供的
request
参数,调用update_memory
方法将该请求作为用户消息添加到代理的Memory中。 -
除了这个函数,Manus也在进行think()、act()时也会更新Memory,同时Memory容量也不是无限大的,容量满时需要删除老的Message。
主循环
agent本质就是循环执行。
-
step实现参考react step。
-
循环结束条件:max_steps或者FINISHED状态。
-
每次执行一个step并获得result——step_result = await self.step()。
-
is_stuck
方法用于检查代理是否陷入了循环(即是否出现了重复的响应)。如果是,则调用handle_stuck_state
方法处理这种情况,例如添加一个提示来改变策略。
ReAct
step()
- 这里的逻辑很简单。
ToolcallAgent
Think()
-
输入:不需要输入,因为用户的question是被存放在Memory中。
-
输出:一个bool类型,当内部LLM判断需要act()时,为True,否则为Fasle。
询问LLM
- 55行的代码用于调用LLM的API接口,获取回复。
对应到OpenManus/app/llm.py 233行附近,这里就是基于OpenAI提供的API接口进行对话,具体的参数可参考相应官方文档。
- 这里会将之前定义的下一步Prompt发给LLM,LLM会根据提供的工具列表,判断是否需要且调用的是哪个工具,当然也可能是:1)不需要工具只进行回复 2)调用Terminate工具结束会话。
下图是一次返回response结果。
-
输入的question是“计算Kobe Bryant的BMI?”,LLM先分析出了要通过浏览器查询资料,因此要use the BrowserUseTool。
-
根据传入的工具类型等信息,LLM自动构建了执行工具需要用的tool_name、action等参数。
ChatCompletionMessage(
content="It seems there was an issue with retrieving the information about Kobe Bryant's height and weight through a Google search. To calculate Kobe Bryant's BMI, we need his height and weight. Let's try to find this information by opening a browser and visiting a reliable source. I will use the BrowserUseTool to navigate to a website that provides details about Kobe Bryant's height and weight. Let's proceed with this approach.",
refusal=None,
role='assistant',
annotations=None,
audio=None,
function_call=None,
tool_calls=[ ChatCompletionMessageToolCall( id='call_aez57ImfIEZrqjZdcW9sFNEJ', function=Function( arguments='{
"action":"navigate",
"url":"https://www.biography.com/athlete/kobe-bryant"
}', name='browser_use'), type='function')]
)
think后续逻辑
-
think()后续的逻辑比较简单,主要是更新memory(memory存储单位是message),最后在100行附近的逻辑,基于self.tool_choices等参数的设置和LLM返回的工具列表,输出bool类型结果。
-
同时,需要被调用的工具会被记录到self.tool_calls这个列表中,后续的act()会执行对应的工具。
Act()
-
输入:同think(),不需要输入。
-
输出:results,根据工具结果构建的一个字符串。
- 这个函数比较简单,主要是调用execute_tool()函数。
Execute_tool()
该函数会调用Tool
类提供的接口execute()。
-
Tool
类接口会在后面介绍。
同时,对于预设定的special tool,会self._handle_special_tool(name=name, result=result)进行特殊处理。
- 当前的special tool 只有一个Terminate工具,特殊处理就是设置Agent的状态为AgentState.FINISHED,结束对话。
工具相关
我们在之前介绍了MCP相关的概念,如下图所示:
事实上,OpenManus也是基于MCP的,OpenManus的tool相当于MCP server,根据MCP协议,我们只需要定义tool类支持的方法和参数等,每次注册一个新工具,根据父类override一个子类即可。
那我们首先要了解父类都定义了什么参数和方法,也就是OpenManus/app/tool/base.py定义的Basetool
类。
Base Tool
可以看出,代码很简单,每个tool包含的参数为:name、description(提供给LLM看的,对工具的介绍)、parameters(执行工具时要用的参数)。
同时,一个tool支持的方法有execute()和to_param()。
-
execute()用于执行具体的逻辑,每个子类需要override这个方法。
-
to_param()将工具调用的结果结构化输出。
当然,这里还有一个python关键字__call__,这个关键字很简单,定义了__call__,该类的实例对象可以像函数一样被调用。
工具JSON
可以根据OpenManus预定义的工具json简单了解一下,每个工具执行时需要的参数。
[
{
"type": "function",
"function": {
"name": "python_execute",
"description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python code to execute."
}
},
"required": ["code"]
}
}
},
{
"type": "function",
"function": {
"name": "google_search",
"description": "Perform a Google search and return a list of relevant links.\nUse this tool when you need to find information on the web, get up-to-date data, or research specific topics.\nThe tool returns a list of URLs that match the search query.\n",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "(required) The search query to submit to Google."
},
"num_results": {
"type": "integer",
"description": "(optional) The number of search results to return. Default is 10.",
"default": 10
}
},
"required": ["query"]
}
}
]
工具示例——google_search
OpenManus项目在OpenManus/app/tool中定义了bash工具、浏览器工具、谷歌搜索工具等,这里简单看一下谷歌搜索工具。
当然,国内可能比较难使用谷歌搜索,OpenManus社区也有大佬提供了baidu、bing等搜索引擎工具。
可以看出,代码很简单,主要做了两件事。
-
定义工具参数:name、description、parameters。
-
定义execute:基于googlesearch库提供的函数进行搜索并返回。
五、总结
OpenManus的代码介绍到这里,主要是介绍一下核心代码,同时,原作者写了planning部分的代码但暂时没有应用到项目中,笔者也没有介绍。如果想对该项目有更进一步的了解,请大家查看github上提供的源码。而且,作者还是非常积极的,每天会有十几个commit。
同时,读者可以简单本地部署玩一下OpenManus,通过几个prompt,就可以知道该项目还是停留在**“玩具阶段”,比如笔者测试了一下,当询问“计算一下科比的BMI?”,OpenManus可以很准确的实现谷歌搜索****——浏览器访问——python计算**这个过程。但如果询问“计算科比、梅西的BMI并排序?”,无论我改写了几次prompt,OpenManus都没有给我满意的回答。
此外,无论是在工具参数信息、还是prompt、memory管理中,都可以看到agent应用大模型token消耗量巨大,即使我们不考虑token成本,但大模型的上下文仍然是有限的,这种资源消耗也会直接导致模型在处理多步骤任务时面临信息截断的风险 —— 早期的关键信息可能因上下文溢出而被丢弃,进而引发推理链条的断裂。更值得警惕的是,当模型试图在有限的上下文中 “脑补” 缺失的信息时,往往会产生与事实不符的幻觉。
鉴于此,尽管 OpenManus 展示出了利用工具链解决复杂问题的潜力,不过距离成为一个实用、高效且稳定的生产级人工智能助手仍有很长的路要走。未来,开发者们或许需要在优化工具使用逻辑、提升多任务处理能力、降低大模型 token 消耗以及增强上下文管理等方面进行深入探索与改进。同时,对于普通用户而言,在体验这类项目时,也应该保持理性和客观的态度,既看到其创新性和趣味性,也认识到其当前存在的局限性。希望在技术的不断迭代和完善下,OpenManus 以及类似的项目能够早日突破现有的瓶颈,真正为人们的工作和生活带来实质性的帮助。
往期回顾
4. 基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术
5. LSM-TREE从入门到入魔:从零开始实现一个高性能键值存储 | 得物技术
文 / 汉堡
关注得物技术,每周一、三更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
得物技术部算法项目管理实践分享
商家域稳定性建设之原理探索|得物技术
一、稳定性及其意义
什么是稳定性?
我们先来探讨一个核心概念:稳定性 。想象一下,一个系统、一个物体或一个过程在受到外部干扰或内部变化时,能否如一面坚实的墙壁,屹立不倒?在信息系统的世界里,稳定性的定义就如同这面的墙壁,它确保在各种干扰面前,我们服务依旧保持可用。
然而,尽管这个定义听上去很清晰,若要用它来推动我们的稳定性建设,却显得有些模糊。因此,我们需要深入探讨如何将这一概念转化为切实可行的方法论。
我们将方法论定义为影响结果的公式,并为稳定性写下了如下公式:
稳定性 = 全局风险可见性 * 风险转化概率 * 故障可感知 * 预案可靠性
但随着实践经验的增长,再对上述公式因子进行正交分析后,我发现稳定性其实可以简化为:
稳定性 = 系统风险(概率) * 风险应对能力
我们进一步拆分这一关系:
系统风险(概率) = 固有风险(概率) + 变更风险(概率)
其中固有风险对应稳定性概念的内部变化,工程上可以定义为网络、服务器等运行环境变化。而变更风险,则是包括发布、配置变更在内的,由人为发起的系统变化。一般固有风险会由运维团队进行关注,因此我们主要展开变更风险:
变更风险 = 变更频率 * 变更复杂度 * 变更爆炸半径
其中:
变更频率: 代表变更的发生次数,它一般和业务的需求有关。
变更复杂度: 这不仅限于代码的可理解性和可修改性,还包括配置的复杂性。一般来说,复杂度越高,单次变更出问题的概率也越大。
变更爆炸半径: 表示发生问题后的影响面,直接影响实际的损失。这个爆炸半径也有很多的衡量因子,如QPS、场景重要性、强弱依赖关系等等。
接下来我们对风险处理能力进行展开:
风险处理能力 = 风险前置发现概率 * 前置风险处理 * 后置风险发现时长 * 应急效果
其中,风险前置发现可以定义为在测试阶段发现问题的场景,由于线下的风险总会有手段可以处理,因此前置风险处理并不会成为瓶颈;后置风险则可定义为上线之后暴露的问题,由于生产问题总会暴露,其关键影响点为发现时长,以及完成应急后最终的影响面,即应急效果。
如果认可这些稳定性公式的拆解推导步骤,我们最终可以将稳定性拉成一个庞大的公式:
稳定性 =(固有风险 + 变更频率 * 变更复杂度 * 变更爆炸半径)
(风险前置发现概率 * 前置风险处理)(后置风险发现时长 * 应急效果)
而这个公式,涵盖了影响稳定性的一系列关键因素,这些关键因素,也将为后续的稳定性建设定下基础。
为什么要进行稳定性建设?
在进行如何建设稳定性的探讨之前。我们先来讨论一个问题:为什么我们如此重视稳定性?这可以从两个重要方面来理解:
1.失去稳定性的损失 :
-
直接经济与业务损失 : 想象一下,系统故障导致下单链路出现异常,订单急剧下降;营销逻辑错误,导致优惠券被滥发,直接造成公司的资损。
-
信任度与隐形资产损失 : 故障不可用或大规模的技术问题可能引发舆论危机,给品牌形象带来巨大损害。尤其对云服务厂商而言,稳定性故障更可能导致客户的大量流失。
2.具备稳定性的好处 :
-
提高业务迭代效率,使得团队能迅速应对市场变化。
-
节约值班等额外的投入,减少资源浪费。
此时,大家一定会疑惑,稳定性差的损失容易理解,但良好稳定性与业务迭代的高效又有何关系呢?回到我们的变更风险公式,你会发现,变更复杂度与业务迭代效率之间存在着显著的负相关关系,容易的变更交付的快,复杂的变更交付的慢,这很好理解。因此:良好的稳定性 -> 低变更风险 -> 低变更复杂度 -> 高迭代效率,形成了一个合理的逻辑链。
稳定性建设究竟要建什么?
在推导出稳定性公式之前,这个命题简直宽泛地让人无从下手。但推导出公式之后,所有的关键点都已经成竹在胸!(这也是方法论的魅力所在)
1.1中,我们已经给出了稳定性的公式,并标红了关键因子。当我们应用上这个方法论时,问题就变得清晰起来:稳定性建设的目标应该聚焦于之前提及的关键因素,借此我们可以制定出实用的治理项如下:
当然,每个治理项都能进一步拆分成若干举措。由于每个团队、每个应用的生命周期阶段不同、实际特性不同,因此各团队在需要重点治理的方向和举措上均有所不同。但总归跳不出这个框架。
二、稳定性建设面临什么困难?
稳定性建设在当今技术驱动的时代至关重要,但它常常被视为“重要但不紧急”的任务,导致在排期过程中得不到必要的优先级支持。许多时候,团队甚至不得不依赖于故障的驱动才能艰难推进稳定性建设。这一现象的根源,可以归结为以下几个方面。
稳定性建设缺少立竿见影的短期价值
其一:量化价值不明确,收益评估困难
我们可以从上文提到的稳定性公式中找到一些线索,尤其是两个非常重要的因子:变更复杂度和风险前置发现概率。变更复杂度实际上对应的是研发引入的单次变更中携带风险的概率,而风险前置发现概率是经过研发和测试团队的努力后,变更风险仍被遗漏到生产环境中的可能性。
正是因为在稳定性的衡量公式计算中,带入了这2个概率因子,稳定性建设的量化价值的不确定性就显而易见了。概率常常需要经过大量的样本统计才能形成有效的量化指标,但在实操时聚焦到某个团队、某个应用或某个具体的治理需求中时,它提升的概率影响往往不足以成为一个可以衡量的量化指标。甚至运气不好的时候,可能会出现治理越多故障越多的离谱事件。
其二:业务压力重,稳定性任务排不上优先级
我们不妨再回到稳定性的关键因子,尤其是变更频率这个有意思的指标。我们很容易通过公式推导得出:变更频率越高的功能,其稳定性治理的收益也越大。然而,正如你所想的,这类功能往往是在业务高速发展时诞生的,此时需求繁多且时间紧迫,在这样的情况下,稳定性治理的优先级与业务的迭代需求相较,无疑排不上号。
而当业务进入稳定期,变更频率下降,终于有时间投入稳定性治理了,但变更频率的下降又同样带来了稳定性风险的减小,治理优先级随之降低。此时的稳定性治理就变成了“食之无味,弃之可惜”的鸡肋工程,仍旧排不上优先级。
稳定性建设存在极大的复杂性和风险
与上文所述的不确定收益相对的,却是稳定性建设确定性的复杂度和风险。无论是风险识别、风险治理,还是风险预防,均需要投入大量的精力。
存量风险识别的难度
在解决问题的第一步中,发现问题是重中之重。稳定性建设的第一步同样是识别其中的稳定性问题。但问题是,我们该如何发现这些问题呢?依靠故障或者TS工单吗?这种方式确实可以帮助我们发现问题,并在后续解决问题。但这种亡羊补牢的操作,对于稳定性的建设而言,实在是太过滞后,根本达不到预期的效果。
为了有效地防范问题发生,我们需要从整体上排除风险——它大到一个域,几十个应用,成千上万条调用链路;小到一个git仓库,数万甚至数十万代码——要准确评估整个域的稳定性并识别其中的风险,这无疑是一项巨大的挑战。
风险治理的难度
风险识别已经足够困难,风险治理的复杂性同样不容忽视。虽然理论上,技术同学从不畏惧已知问题,但不同问题背后的复杂性,也往往会带来不同的治理难度。
首当其冲的自然是技术同学最头疼的排期问题,“世上无难事,只要有排期”。但正如2.1中提到的,稳定性建设由于价值的不确定性,往往难以取得足够的排期。即便是再高瞻远瞩的管理者,也不得不严格控制技术投入的占比,将更多的资源用于服务业务,创造更多的增长。
其次,稳定性治理本身带来的变更风险也不容小觑。 这里贴上技术人员非常喜欢的一张图,来贴切地表示这个难题。
上图的这个房屋,毫无疑问是个风吹就塌的危房。但谁又真敢动手对这样的危房进行稳定性治理呢?如果就是个普通房屋,推倒重建就完了,但业务系统可无法停机。在这种情况下进行代码改动,就如同需要持续地挪动木头、泥土和石块,试图将其替换为坚固的建筑材料,却很可能无意中移走某个重要支撑,导致整个系统崩溃。
这在稳定性建设中是不可接受的。为了预防可能发生的故障,反而引入了变更故障,这实属本末倒置。
至于另外一种治理方案……新建一个系统,然后把流量切过去。如果面对类似图片这种治理难度地狱级的项目,确实是个最佳选择,并且该方法也确实大量应用在架构治理上(如服务拆分)。但大部分应用,使用该方案又着实奢侈了。叠加上文提及的排期问题,也限制了这种方案成为稳定性治理银弹的可能性。
如果继续深入探讨变更风险的问题,我们必然会碰到“代码债务”的概念。每一位技术开发者都对代码债务耳熟能详,深有感触。它通常定义为低代码质量和不合理架构设计等一系列技术负担,而这些问题并非立刻显现出危害。一个重的代码债务,只要在生产环境中能够正常运行,就意味着它是能被接受的。即如图中的房子再怎么危房,没塌之前,住人防风挡雨都是没问题的。
然而,代码债务阻碍了变更,无论是业务的迭代还是技术的治理,都会提升变更的风险。因此,最后的风险治理难度来到了稳定性风险因子中的变更复杂度问题。
命名为变更复杂度,而非代码复杂度这种客观描述,也是意味着变更难度是包含主观含义,是因人而异的。 例如某个应用由一位同学贯穿始终地维护,代码再复杂,变更复杂度也高不到哪里去。因为这份代码从始至终,都是由同一个人,以他的思维框架,解读业务链路后,再抽象建设而成的,这份代码从头到脚都是这位同学的形状。他知道这些代码从何而来,又应当往哪里去。但实际生产过程中,一个应用往往要经历多人维护,就必然出现信息传递的损失。最终,我们在面对这种代码时,大概率会遇到理解困难以及对变更后果的无能为力。因此,变更复杂度的本质,是由不同人员的思维方式、设计理念、编码习惯,以及业务知识在传递过程中的信息偏差交织在一起,构成的一种现象。
至此,排期、变更复杂度、变更风险三者,构成了整个稳定性风险治理的难度。
增量风险预防的难度
在此前的讨论中,我们已经探讨了存量风险的识别和治理。而本节将重点关注每次变更引入的增量风险。这是一个不可忽视的领域,因为风险的根源在于变更,而变更又是业务发展的必然过程。那么,如何有效控制这些因变更而来的风险呢?
变更可见性
首先,最重要的是确保变更的可见性和可感知性 。这里所说的可见性,不仅仅是变更执行者本人的知晓,更是整个团队乃至所有相关方共同的认知。毕竟,执行变更的同事自然会清楚自己做了什么,但真正的问题在于,执行变更的同事是否知道这些变更意味着什么,这个认知和其他相关人员——比如PM和测试人员——是否是一致的?
这就是为什么变更可见性如此重要。做过业务负责人的都知道,最担心的事情就是业务/产品和技术说要改个什么一句话功能,或者是刷数等操作,技术同学顺手就给做了;因为功能点太小,甚至都没通知测试和PM,直接自测完就上线了,真就映着一句话:天知地知,你知我知。但这个却是风险最高的行为,因为没有任何人帮助变更同学进行二次确认,不出问题都是侥幸。
那么难点来了,对一个域少则几十,多则数百的同学,每个迭代也是几十个需求,上百种不同类型的变更,怎么保证每个微小的变更,都能让变更的所有相关方都感知到,并且进行有效的二次确认呢?
方案可控性
在确认了变更共识后,下一步便是对变更方案本身进行评估,从而确保每项调整都符合预期。但此时,又出现了一个障碍:如何保证这个变更是符合预期的呢?
对于一项代码的变更,它不仅会对这行代码的所有上游场景产生影响,更会影响所有使用到这行代码结果的下游场景。若是数据的变更,更是牵一发而动全身,所有读取和写入到这行数据的场景都要受到影响。由于整体链路的复杂性和不可控性,对于变更方案的风险可控性评估就显得异常困难。
人员可靠性
最后,涉及到的还有变更执行人员本身的可靠性 ,人是同时具备高上限和低下限的特性的。即便是一个优秀的同学,即有高瞻远瞩,防范未然的时候;也有马失前蹄,被"!"和“NullPointException”搞得焦头烂额的时候。
那么怎样在各种各样的变更中,去保障人员的下限,不要让这种人员的波动性影响到系统的稳定性;甚至尽可能让人员保持他们的高上限,将更多的稳定性风险扼杀在摇篮之中?这便是稳定性建设中最后一个需要重点考虑的问题。
三、如何进行稳定性建设?
经过前面的铺垫,我们已经明确了稳定性建设的重要性,以及在实施过程中面临的种种挑战。那么,问题的关键就在于如何克服这些困难,顺利进行稳定性的建设。实际操作中,很多治理建设的思路和策略都已经隐含在前文的分析中,现在我们只需将它们整合提炼出来。
建立稳定性共识
从上文知,困难中排在第一点的,即是稳定性治理的优先级和的资源排期问题。因此,在解决稳定性建设的客观困难之前,首先需要业务团队内部从主观层面建立对稳定性的一致认知:即业务团队需要针对本团队业务的重要性、发展阶段、风险情况进行综合评估,确定好稳定性建设在本团队中的重要程度。
直白点说,这个共识就是业务团队确定好稳定性建设将在团队总投入中的时间占比。
这个占比可以在迭代维度进行波动,但周期拉长到季度、年维度的时候,是需要保持在一个符合预期的比例的。它可以是5%或者更低,也可以是10%甚至更高,具体的数值需要和团队目前的业务和技术现状相匹配。
明确稳定性建设目标
当确定了资源比例后,接下来就是明确具体的目标。在此过程中,我们需制定可执行的方案,将大方向细化为明确的阶段目标。
回到1.3脑图中提供的三个大方向:
-
风险前置发现 : 侧重于人和流程的管理;
-
变更风险控制 : 关注系统性架构建设;
-
风险后置处理 : 着眼于应急响应,同时关注人的应急流程和系统的预案建设;
因此,归根结底,稳定性的目标收敛成是练人、建系统两种。我们通常建议先从练人中的强化意识和流程入手,再优化系统,最后持续性地提高人员的综合能力。
这是因为加强团队意识和规范团队流程的投入相对较低,通过制定规范、流程,进行宣导培训甚至考试等形式,不需要投入过多资源就能取得良好效果。这种意识的培养,虽然不会立即影响故障率,但有助于营造稳定性的文化氛围,为长期的治理打下基础。其次,在这一过程中,无需直接修改代码,在初期可以尽量避免“越治理越故障”的困境。最后,加强团队意识和规范团队流程,有助于后续保护好稳定性治理结果。避免一边堵漏,一边挖坑的迷惑行为。
需要注意的是,稳定性建设是一个动态变化的过程。随着时间的推移,人可能会逐渐懈怠,系统架构也可能因为业务迭代而腐化。因此,稳定性建设必须是一个周期性的工作,并且建议每一个季度都专注于1-2项关键点,使得整个系统的稳定性可以在螺旋中上升。
落地稳定性建设任务
为了有效实施稳定性建设,我们将其任务进一步细分为五个核心部分:意识培养、 安全生产规范、应急响应、日常巡检和架构治理 。接下来,我们将逐一阐述这五个部分的重要性及其具体实现方案,并表述清楚这些部分应对的是上述的那些困难点。
意识培养
意识培养是提升团队成员在稳定性建设中能动性的关键环节。它主要涵盖三个方面:认知、意愿和能力。换句话说,我们要弄清楚团队成员对于稳定性的认知程度、愿意投入的程度以及他们的能力如何。
认知 :团队成员是否充分了解稳定性的重要性。
针对这一点,我们可以定期举办“谨慎编码”宣讲,以提高大家的意识。虽然这看似简单,但不可忽视。因为如果长期不提及稳定性,其重要性就会在潜意识中随时间弱化。
意愿 :团队成员是否愿意花费时间和精力去评估并解决稳定性风险。
评估稳定性风险往往需要深入细致的工作,还需要克服习惯、自信、侥幸、嫌麻烦等心理障碍。为了提升意愿度,可从奖、惩两方面入手。如可以通过设置稳定性红线或进行故障复盘来进行必要的惩罚,同时引入激励机制,比如对表现突出的团队成员进行表彰或绩效激励。此外权责到人也是激发意愿的手段之一,当同学有了固定负责的应用,并有权限进行完全控制时,会更愿意吃透其业务,保证其代码整洁和稳定。
能力 :团队成员能否识别风险,并设计有效的解决方案。
这块可提升点就很多了:如一是案例分享可以扩展同学眼界,通过举一反三可以避免同类问题的发生;二是沉淀组内/域内的稳定性知识库,将团队的能力沉淀下来,将团队的智慧变成个人的智慧,提高同学能力上限;三是寻求组内同学的帮助也是一种方法,这适合于发现了问题后,在设计方案时进行组内交流,查缺补漏,共同设计完备的解决方案。
当然,意识培养,或者说人的培养,同样是一个庞大复杂的体系,这里仅针对三个关键因素进行粗浅的解读,更多内容可以关注一些专业书籍。
安全生产规范
安全生产规范,我们定义为为了保证变更风险可控而制定的一系列流程规范。但很多人对于这些流程规范可能不以为然,认为繁琐的过程除了降低效率外并没有什么实际的益处。但其实,这些环节的存在是对变更方案及其风险进行二次确认的重要保障。
在一个典型的需求变更流程中,一般会有需求评审、技术方案评审、用例评审、自测/测试环节、CR、验收等多个环节。为什么需要有这么多环节呢?
-
需求评审: 针对业务变化带来的功能变化,在产品、研发、测试之间达成一致,进行多方确认。
-
技术方案评审: 针对功能变化对应的技术变化,在产品、研发、测试之间达成一致,进行多方确认。
-
用例评审: 针对功能变化/技术变化带来的用例变化,在产品、研发、测试之间达成一致,进行多方确认。
-
自测/测试环节: 针对技术变化的正确性和完备性,在研发、测试之间达成一致,进行二次确认。
-
CR: 针对技术变化对应的代码变化,在研发团队内部进行风险确认,属于二次确认。
-
验收: 针对功能变化的最终效果,在产品、研发、测试之间达成一致,属于多方确认。
可以看到,通过这些环节,变更的可见性将得以显著提升:几乎所有的相关方,都能够准确知道变更内容、变更方案和变更时间,并共同确认过变更风险。正因为这些环节在现有的需求流程中多半能够充分落实,因此需求变更带来风险的概率是相对较低的。
与需求变更的多方确认相反的是,技改需求、curl、数据订正、Ark变更等操作,在技术部多次管控加码之前,这些变更操作发生问题的概率远高于需求变更。其原因正是由于这些变更可能就是某个研发顺手操作了,其可见范围极小。根本没有相关方进行多轮有效的二次确认操作,容易出问题也就不足为奇了。其他类似的案例还有业务方突然执行了大量的业务变更操作,突然进行了某项营销活动导致引入远超预期的流量等等,这同样也是由于变更的可见性并未被技术团队感知,而导致的变更风险。
因此,安全生产规范,就是用来约定当任意变更产生时,需要通过何种流程将该变更通知到所有相关方,并通过何种方式进行多方确认,共同确保变更风险可控的共识方案。 了解了安全生产的本质后,各团队就完全可以针对自己的业务特性和所有的变更场景,制定专属的安全生产规范。其完备性取决于变更场景的完备性,其有效性取决于多方确认的有效性。 这样,也同时回答了“如何制定一个好的安全生产规范”这个问题。
应急响应
应急响应主要分三个部分:发现、响应和处理;关键的标准则是及时性和有效性。及时性确保了问题的影响不被放大,有效性则确保了已经发生的影响能被控制和修复。
发现: 发现的关键点是及时。若不考虑及时性,客户进线、结算错误这种后置发现手段,是可以发现所有的问题的。但这种通过实际的业务损失来发现问题的方案,显然不符合预期。因此,必须通过系统的手段,做好监控、告警布防,不论是系统资源使用率、服务可用性情况,还是业务数据正确性、波动值,均要做好完善的布控,方可及时发现。
响应: 响应的要点同样也是及时,它关系到已经出现的异常事件是否有人立即进行跟进处理,它一方面和意识培养直接相关,对应人的责任意识。另一方面对应的工具的正确使用,诸如手机、电脑、飞书等通知配置,也是关系到值班同学是否能第一时间获取到紧急事件通知的关键。
处理: 问题处理是应急响应的最后一环,它需要兼顾及时性和有效性:是否能够快速定位根因?是否能够有效止血?这就不仅和个人的能力有关,也和系统的完备性有关。
个人能力这块基本和3.3.1提及的内容一致。但系统能力的完备性,则同样可以展开大量的建设任务,如:为了定位问题根因: 告警信息的重要性(是否提供关键信息快速定位),日志信息的完备性和串联性(是否能够提供足够的信息定位问题,是否提供的信息均是重要的关联信息,减少不必要的噪声),都是非常重要的基础建设。
为了快速止血:除了通过个人的能力快速找到止血方案外,更重要的在于系统是否预设过相应的故障,并提供了止血预案。如果有,往往可以快速解决问题。但如果没有,要在短时间内解决问题,往往难度极高。如果操作不当,容易引入额外的风险。
最后,团队中关于应急问题处理的知识库也是非常重要的知识沉淀,有助于不熟悉该业务的同学,也能够快速定位和处理问题。
日常巡检
“防患于未然”是我们维护稳定性的重要目标。通过日常巡检,团队能够识别潜在的风险苗头。或是慢SQL、或是慢接口,或是cpu突刺。包括业务数据量是否逐步增长到了危险的范围,各项活动/配置是否临近过期,上下游的调用量是否接近容量上限……等等,这些风险,均可以在相应的巡检中发现问题,避免潜在风险逐步积累引发的灾难性后果。
架构治理
如果之前的部分主要关注人员层面的提升,那么架构治理则是从代码层面提升系统稳定性的一项重要措施,能够真正提升系统抗风险的硬实力。
从稳定性共识中可知,架构治理的能影响的关键因子为变更风险 ,而变更风险主要包括变更频率、变更复杂度和变更爆炸半径。
对应的领域建模、高内聚低耦合、OO等的架构原则,反映到变更风险中,就是控制了变更复杂度。因为内聚性,变更多可以聚焦在单一应用中,爆炸半径也同时得到控制。
资源隔离的架构设计,则是专门用于控制爆炸半径,不论是容器资源、线程池,还是DB、redis,甚至是P0/PN链路拆分等,均为控制爆炸半径,避免相互影响。
还有一种特殊的称作B/C流量拆分,这种看似是爆炸半径,但实际上也控制了变更频次。或者更精确的说法,是运营/B/C三端拆分,它的逻辑除了流量来源不同之外,更在于场景和变更频次不同。一般可以认为B端/运营端的供给侧,相较于C端的消费侧,会有更复杂的模型,更高的变更频次。进行这几端的拆分,更多在于减少C端(往往更核心)的变更频次,减少变更时的相互影响。
关于架构治理还有一个关键点,那就是抓住主要矛盾,先从最核心的业务场景开始治理。如果没有考虑好治理优先级,那么茫茫多的场景和链路就会成为一个交织在一起的毛线球,是无法进行抽丝剥茧逐一治理的。
资损防控
最后还有一个特殊的稳定性场景,资损防控。它在稳定性建设中比较特殊,是一个强业务相关的防控方案。一般可以在事前、事中、事后三个环节进行防控。
事前环节: 一般考虑防呆拦截/提醒、二次确认、审批流等多轮操作确认;更深入的可以增加结果预计算、影响面提示、前后对比等重提示,给到使用方对于执行后果的直观展示,减少误操作可能性。
事中环节: 一般会有资金上限熔断、实时/准实时Dcheck预警、相关资金指标波动预警等策略,在出现资损风险的时候进行预警,或业务熔断。
事后环节: 一般会采取T+1对账,确认多方资金数据一致。并辅以货款抵扣、调账等工具,在发生资金差额的情况下,进行金额补偿。
稳定性建设的困难是否都被解决了?
最后,让我们回过头来复盘一下第2节中提到的困难,看看这些拦路虎是否在本节中被逐一击破。
首先是短期价值不明确带来的争议,这块我们通过建立团队的稳定性共识得到彻底的解决。
其次稳定性建设的复杂性和风险性:
-
先说相对明确的增量风险预防:3.3.2中的安全生产规范整个存在的意义就是为了通过流程来控制增量风险。
-
然后是风险治理的难度:该问题先可以通过架构治理进行分而治之,将大问题拆解成若干个小问题;再通过安全生产规范,控制每次解决小问题引入风险的概率。
-
最后是存量风险识别的难度:日常巡检有助于发现存量风险的苗头,意识培养则有助于对单应用风险的摸排,架构治理则对应了对于应用间、甚至整个域内的依赖链路风险评估和治理。
至此,所有的核心困难点都有了解决的方案,稳定性治理不再是一座不可逾越的高山,剩下的无非是根据具体问题,照着公式,逢山开路,遇水搭桥了。
四、稳定性建设全景图
通过以上的探讨,我们不仅分析了稳定性建设的重要性,还从理论角度,揭示了稳定性建设的核心要素与挑战,提供了具体的解决方案和建设任务。简单统合一下,就生成了下面的稳定性建设全景图,希望能为正在努力追求系统稳定性的小伙伴们提供启发与帮助。
当然,其中的支撑事项仅是抛砖引玉,每个团队都可以因地制宜,设计有团队特色的支撑事项。只要是能够服务于上层的建设目标,就具备落地的价值。
往期回顾
2. 基于ANTLR4的大数据SQL编辑器解析引擎实践|得物技术
3. LSM-TREE从入门到入魔:从零开始实现一个高性能键值存储 | 得物技术
4. 一个Rust小白发布生产级Rust应用的进阶之路 | 得物技术
5. 得物小程序平台设计与实践
文 / 裁衣(Joker)
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
得物 Android Crash 治理实践
一、前言
通过修复历史遗留的Crash漏报问题(包括端侧SDK采集的兼容性优化及Crash平台的数据消费机制完善),得物Android端的Crash监控体系得到显著增强,使得历史Crash数据的完整捕获能力得到系统性改善,相应Crash指标也有所上升,经过架构以及各团队的共同努力下,崩溃率已从最高的万2降至目前的万1.1到万1.5,其中疑难问题占比约90%、因系统bug导致的Crash占比约40%,在本文中将简要介绍一些较典型的系统Crash的治理过程。
二、DNS解析崩溃
背景
Android11及以下版本在DNS解析过程中的有几率产生野指针问题导致的Native Crash,其中Android9占比最高。
堆栈与上报趋势
at libcore.io.Linux.android_getaddrinfo(Linux.java)
at libcore.io.BlockGuardOs.android_getaddrinfo(BlockGuardOs.java:172)
at java.net.InetAddress.parseNumericAddressNoThrow(InetAddress.java:1631)
at java.net.Inet6AddressImpl.lookupAllHostAddr(Inet6AddressImpl.java:96)
at java.net.InetAddress.getAllByName(InetAddress.java:1154)
#00 pc 000000000003b938 /system/lib64/libc.so (android_detectaddrtype+1164)
#01 pc 000000000003b454 /system/lib64/libc.so (android_getaddrinfofornet+72)
#02 pc 000000000002b5f4 /system/lib64/libjavacore.so (_ZL25Linux_android_getaddrinfoP7_JNIEnvP8_jobjectP8_jstringS2_i+336)
问题分析
崩溃入口方法InetAddress.getAllByName用于根据指定的主机名返回与之关联的所有 IP 地址,它会根据系统配置的名称服务进行解析,沿着调用链查看源码发现在parseNumericAddressNoThrow方法内部调用Libcore.os.android_getaddrinfo时中有try catch的容错逻辑,继续查看后续调用的c++的源码,在调用android_getaddrinfofornet函数返回值不为0时抛出GaiException异常。
https://cs.android.com/android/platform/superproject/+/android-9.0.0_r49:libcore/ojluni/src/main/java/java/net/InetAddress.java
static InetAddress parseNumericAddressNoThrow(String address) {
// Accept IPv6 addresses (only) in square brackets for compatibility.
if (address.startsWith("[") && address.endsWith("]") && address.indexOf(':') != -1) {
address = address.substring(1, address.length() - 1);
}
StructAddrinfo hints = new StructAddrinfo();
hints.ai_flags = AI_NUMERICHOST;
InetAddress[] addresses = null;
try {
addresses = Libcore.os.android_getaddrinfo(address, hints, NETID_UNSET);
} catch (GaiException ignored) {
}
return (addresses != null) ? addresses[0] : null;
}
https://cs.android.com/android/platform/superproject/+/master:libcore/luni/src/main/native/libcore_io_Linux.cpp?q=Linux_android_getaddrinfo&ss=android%2Fplatform%2Fsuperproject
static jobjectArray Linux_android_getaddrinfo(JNIEnv* env, jobject, jstring javaNode,
jobject javaHints, jint netId) {
......
int rc = android_getaddrinfofornet(node.c_str(), NULL, &hints, netId, 0, &addressList);
std::unique_ptr<addrinfo, addrinfo_deleter> addressListDeleter(addressList);
if (rc != 0) {
throwGaiException(env, "android_getaddrinfo", rc);
return NULL;
}
......
return result;
}
解决过程
解决思路是代理android_getaddrinfofornet函数,捕捉调用原函数过程中出现的段错误信号,接着吃掉这个信号并返回-1,使之转换为JAVA异常进而走进parseNumericAddressNoThrow方法的容错逻辑,和负责网络的同学提前做了沟通,确定此流程对业务没有影响后开始解决。
首先使用inline-hook代理了android_getaddrinfofornet函数,接着使用字节封装好的native try catch工具做吃掉段错误信号并返回-1的,字节工具内部原理是在try块的开始使用sigsetjmp打个锚点并快照当前寄存器的值,然后设置信号量处理器并关联当前线程,在catch块中解绑线程与信号的关联并执行业务兜底代码,在捕捉到信号时通过siglongjmp函数长跳转到catch块中,感兴趣的同学可以用下面精简后的demo试试,以下代码保存为mem_err.c,执行gcc ./mem_err.c;./a.out
#include <stdio.h>
#include <signal.h>
#include <setjmp.h>
struct sigaction old;
static sigjmp_buf buf;
void SIGSEGV_handler(int sig, siginfo_t *info, void *ucontext) {
printf("信号处理 sig: %d, code: %d\n", sig, info->si_code);
siglongjmp(buf, -1);
}
int main() {
if (!sigsetjmp(buf, 0)) {
struct sigaction sa;
sa.sa_sigaction = SIGSEGV_handler;
sigaction(SIGSEGV, &sa, &old);
printf("try exec\n");
//产生段错误
int *ptr = NULL;
*ptr = 1;
printf("try-block end\n");//走不到
} else {
printf("catch exec\n");
sigaction(SIGSEGV, &old, NULL);
}
printf("main func end\n");
return 0;
}
//输出以下日志
//try exec
//信号处理 sig: 11, code: 2
//catch exec
//main func end
inline-hook库: github.com/bytedance/a…
字节native try catch工具: github.com/bytedance/a…
三、MediaCodec 状态异常崩溃
背景
在Android 11系统库的音视频播放过程中,偶尔会出现因状态异常导致的SIGABRT崩溃。音视频团队反馈指出,这是Android 11的一个系统bug。随后,我们协助音视频团队通过hook解决了这一问题。
堆栈与上报趋势
#00 pc 0000000000089b1c /apex/com.android.runtime/lib64/bionic/libc.so (abort+164)
#01 pc 000000000055ed78 /apex/com.android.art/lib64/libart.so (_ZN3art7Runtime5AbortEPKc+2308)
#02 pc 0000000000013978 /system/lib64/libbase.so (_ZZN7android4base10SetAborterEONSt3__18functionIFvPKcEEEEN3$_38__invokeES4_+76)
#03 pc 0000000000006e30 /system/lib64/liblog.so (__android_log_assert+336)
#04 pc 0000000000122074 /system/lib64/libstagefright.so (_ZN7android10MediaCodec37postPendingRepliesAndDeferredMessagesENSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEERKNS_2spINS_8AMessageEEE+720)
#05 pc 00000000001215cc /system/lib64/libstagefright.so (_ZN7android10MediaCodec37postPendingRepliesAndDeferredMessagesENSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEi+244)
#06 pc 000000000011c308 /system/lib64/libstagefright.so (_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE+8752)
#07 pc 0000000000017814 /system/lib64/libstagefright_foundation.so (_ZN7android8AHandler14deliverMessageERKNS_2spINS_8AMessageEEE+84)
#08 pc 000000000001d9cc /system/lib64/libstagefright_foundation.so (_ZN7android8AMessage7deliverEv+188)
#09 pc 0000000000018b48 /system/lib64/libstagefright_foundation.so (_ZN7android7ALooper4loopEv+572)
#10 pc 0000000000015598 /system/lib64/libutils.so (_ZN7android6Thread11_threadLoopEPv+460)
#11 pc 00000000000a1d6c /system/lib64/libandroid_runtime.so (_ZN7android14AndroidRuntime15javaThreadShellEPv+144)
#12 pc 0000000000014d94 /system/lib64/libutils.so (_ZN13thread_data_t10trampolineEPKS_+412)
#13 pc 00000000000eba94 /apex/com.android.runtime/lib64/bionic/libc.so (_ZL15__pthread_startPv+64)
#14 pc 000000000008bd80 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64)
问题分析
根据堆栈内容分析Android11的源码以及结合SIGABRT信号采集到的信息(postPendingRepliesAndDeferredMessages: mReplyID == null, from kWhatRelease:STOPPING following kWhatError:STOPPING),找到崩溃发生在onMessageReceived函数处理kWhatRelease类型消息的过程中,onMessageReceived函数连续收到两条消息,第一条是kWhatError:STOPPING,第二条是kWhatRelease:STOPPING此时因mReplyID已经被置为空,因此走到判空抛异常的逻辑。
对比Android12的源码,在处理kWhatRelease事件且状态为STOPPING抛异常前,增加了对mReplyID不为空的判断来规避这个问题。
解决过程
Android12的修复方式意味着上述三个条件结合下吃掉异常是符合预期的,接下来就是想办法通过hook Android11使逻辑对齐Android12。
【初探】最先想到的办法是代理相关函数通过判断走到这个场景时提前return出去来规避,音视频的同学尝试后发现不可行,原因如下:
- void MediaCodec::postPendingRepliesAndDeferredMessages(std::string origin, status_t err): 匹配origin是否为特征字符串(postPendingRepliesAndDeferredMessages: mReplyID == null, from kWhatRelease:STOPPING following kWhatError:STOPPING);很多设备找不到这个符号不可行;
- void MediaCodec::onMessageReceived(const sp&msg): 已知MediaCodec实例的内存首地址,需要通过hardcode偏移量来获取mReplay、mState两个字段,这里又缺少可供校验正确性的特征,风险略大担心有不同机型的兼容性问题(不同机型新增、删除字段导致偏移量不准)。
【踩坑】接着尝试使用与修复DNS崩溃类似思路的保护方案,使用inline-hook代理onMessageReceived函数调用原函数时使用setjmp打锚点,然后使用plt hook代理_android_log_assert函数并在内部检测错误信息为特征字符串时通过longjmp跳转到onMessageReceived函数的锚点并作return操作,精简后的demo如下:
Plt-hook 库: github.com/iqiyi/xHook
#include <iostream>
#include <setjmp.h>
#include <csignal>
static thread_local jmp_buf _buf;
void *origin_onMessageReceived = nullptr;
void *origin__android_log_assert = nullptr;
void _android_log_assert_proxy(const char* cond, const char *tag, const char* fmt, ...) {
//模拟liblog.so的__android_log_assert函数
std::cout << "__android_log_assert start" << std::endl;
if (!strncmp(fmt, "postPendingRepliesAndDeferredMessages: mReplyID == null", 55)) {
longjmp(_buf, -1);
}
//模拟调用origin__android_log_assert,产生崩溃
raise(SIGABRT);
}
void onMessageReceived_proxy(void *thiz, void *msg) {
std::cout << "onMessageReceived_proxy start" << std::endl;
if (!setjmp(_buf)) {
//模拟调用onMessageReceived原函数(origin_onMessageReceived)进入崩溃流程
std::cout << "onMessageReceived_proxy 1" << std::endl;
_android_log_assert_proxy(nullptr, nullptr, "postPendingRepliesAndDeferredMessages: mReplyID == null, from kWhatRelease:STOPPING following kWhatError:STOPPING");
std::cout << "onMessageReceived_proxy 2" << std::endl;//走不到
} else {
//保护后从此处返回
std::cout << "onMessageReceived_proxy 3" << std::endl;
}
std::cout << "onMessageReceived_proxy end" << std::endl;
}
int main() {
std::cout << "main func start" << std::endl;
/**
inline-hook: shadowhook_hook_sym_name("libstagefright.so","_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE",(void *) onMessageReceived_proxy, (void **) &origin_onMessageReceived);
plhook: xh_core_register("libstagefright.so", "__android_log_assert", (void *) (_android_log_assert_proxy), (void **) (&origin__android_log_assert));
*/
//模拟调用libstagefright.so的_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE函数
onMessageReceived_proxy(nullptr, nullptr);
std::cout << "main func end" << std::endl;
return 0;
}
/**
日志输出
main func start
onMessageReceived_proxy start
onMessageReceived_proxy 1
__android_log_assert start
onMessageReceived_proxy 3
onMessageReceived_proxy end
main func end
*/
线下一阵操作猛如虎经测试保护逻辑符合预期,但是在灰度期间踩到栈溢出保护导致错误转移的坑,堆栈如下:
#00 pc 000000000004e40c /apex/com.android.runtime/lib64/bionic/libc.so (abort+164)
#01 pc 0000000000062730 /apex/com.android.runtime/lib64/bionic/libc.so (__stack_chk_fail+20)
#02 pc 000000000000a768 /data/app/~~JaQm4SU8wxP7T2GaSWxYkQ==/com.shizhuang.duapp-N5RFIB8WurdccMgAVsBang==/lib/arm64/libduhook.so (_ZN25CrashMediaCodecProtection5proxyEPvS0_)
#03 pc 0000000001091c0c [anon:scudo:primary]
*关于栈溢出保护机制感兴趣的同学可以参考这篇文章bbs.kanxue.com/thread-2217…
(CSPP 第3版 “3.10.3 内存越界引用和缓冲区溢出”章节讲的更详细)*
longjmp函数只是恢复寄存器的值后从锚点处再次返回,过程中也唯一可能会操作栈祯只有inline-hook,当时怀疑是与setjmp/longjmp机制不兼容,由于inline-hook内部逻辑大量使用汇编来实现排查起来比较困难,因此这个问题困扰比较久,网上的资料提到可以使用代理出错函数(__stack_chk_fail)或者编译so时增加参数不让编译器生成保护代码来绕过,这两种方式影响面都比较大所以未采用。有了前面的怀疑点想到使用c++的try catch机制来做跨函数域的跳转,大致的思路同上只是把setjmp替换为c++的try catch,把longjmp替换为throw exception,精简后的demo如下:
c++异常机制介绍: baiy.cn/doc/cpp/ins…
#include <iostream>
#include <csignal>
void *origin_onMessageReceived = nullptr;
void *origin__android_log_assert = nullptr;
class MyCustomException : public std::exception {
public:
explicit MyCustomException(const std::string& message)
: msg_(message) {}
virtual const char* what() const noexcept override {
return msg_.c_str();
}
private:
std::string msg_;
};
void _android_log_assert_proxy(const char* cond, const char *tag, const char* fmt, ...) {
//模拟liblog.so的__android_log_assert函数
std::cout << "__android_log_assert start" << std::endl;
if (!strncmp(fmt, "postPendingRepliesAndDeferredMessages: mReplyID == null", 55)) {
throw MyCustomException("postPendingRepliesAndDeferredMessages: mReplyID == null");
}
//模拟调用origin__android_log_assert,产生崩溃
raise(SIGABRT);
}
void onMessageReceived_proxy(void *thiz, void *msg) {
std::cout << "onMessageReceived_proxy start" << std::endl;
try {
//模拟调用onMessageReceived原函数(origin_onMessageReceived)进入崩溃流程
std::cout << "onMessageReceived_proxy 1" << std::endl;
_android_log_assert_proxy(nullptr, nullptr, "postPendingRepliesAndDeferredMessages: mReplyID == null, from kWhatRelease:STOPPING following kWhatError:STOPPING");
std::cout << "onMessageReceived_proxy 2" << std::endl;//走不到
} catch (const MyCustomException& e) {
//保护后从此处返回
std::cout << "onMessageReceived_proxy 3" << std::endl;
}
std::cout << "onMessageReceived_proxy end" << std::endl;
}
int main() {
std::cout << "main func start" << std::endl;
/**
inline-hook: shadowhook_hook_sym_name("libstagefright.so","_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE",(void *) onMessageReceived_proxy, (void **) &origin_onMessageReceived);
plhook: xh_core_register("libstagefright.so", "__android_log_assert", (void *) (_android_log_assert_proxy), (void **) (&origin__android_log_assert));
*/
//模拟调用libstagefright.so的_ZN7android10MediaCodec17onMessageReceivedERKNS_2spINS_8AMessageEEE函数
onMessageReceived_proxy(nullptr, nullptr);
std::cout << "main func end" << std::endl;
return 0;
}
/**
日志输出
main func start
onMessageReceived_proxy start
onMessageReceived_proxy 1
__android_log_assert start
onMessageReceived_proxy 3
onMessageReceived_proxy end
main func end
*/
灰度上线后发现有设备走到了_android_log_assert代理函数中的throw逻辑,但是未按预期走到catch块而是把错误又转移为" terminating with uncaught exception of type" ,有点搞心态啊。
【柳暗花明】C++的异常处理机制在throw执行时,会开始在调用栈中向上查找匹配的catch块,检查每一个函数直到找到一个具有合适类型的catch块,上述的错误信息代表未找到匹配的catch块。从转移的堆栈中注意到没有onMessageReceived代理函数的堆栈,此时基于inline-hook的原理(修改原函数前面的汇编代码跳转到代理函数)又怀疑到它身上,再次排查代码时发现代理函数开头漏写了一个宏,在inline-hook中SHADOWHOOK_STACK_SCOPE就是来管理栈祯的,因此出现找不到catch块以及前面longjmp的问题就不奇怪了。加上这个宏以后柳暗花明,重新放量后保护逻辑按预期执行并且保护生效后视频播放正常。和音视频的小伙伴一努力下,经历了几个版本终于解决了这个系统bug,目前仅剩老版本App有零星的上报。
四、bio多线程环境崩溃
背景
Android 11 Socket close过程中在多线程场景下有几率产生野指针问题导致Native Crash,现象是多个线程同时close连接时,一个线程已销毁了bio的上下文,另外一个线程仍执行close并在此过程中尝试获取这个bio有多少未写出去的字节数时出现野指针导致的段错误。此问题从21年首次上报以来在得物的Crash列表中一直处于较前的位置。
堆栈与上报趋势
at com.android.org.conscrypt.NativeCrypto.SSL_pending_written_bytes_in_BIO(Native method)
at com.android.org.conscrypt.NativeSsl$BioWrapper.getPendingWrittenBytes(NativeSsl.java:660)
at com.android.org.conscrypt.ConscryptEngine.pendingOutboundEncryptedBytes(ConscryptEngine.java:566)
at com.android.org.conscrypt.ConscryptEngineSocket.drainOutgoingQueue(ConscryptEngineSocket.java:584)
at com.android.org.conscrypt.ConscryptEngineSocket.close(ConscryptEngineSocket.java:480)
at okhttp3.internal.Util.closeQuietly_aroundBody0(Util.java:1)
at okhttp3.internal.Util$AjcClosure1.run(Util.java:1)
at org.aspectj.runtime.reflect.JoinPointImpl.proceed(JoinPointImpl.java:3)
at com.shizhuang.duapp.common.aspect.ThirdSdkAspect.t(ThirdSdkAspect.java:1)
at okhttp3.internal.Util.closeQuietly(Util.java:3)
at okhttp3.internal.connection.ExchangeFinder.findConnection(ExchangeFinder.java:42)
at okhttp3.internal.connection.ExchangeFinder.findHealthyConnection(ExchangeFinder.java:1)
at okhttp3.internal.connection.ExchangeFinder.find(ExchangeFinder.java:6)
at okhttp3.internal.connection.Transmitter.newExchange(Transmitter.java:5)
at okhttp3.internal.connection.ConnectInterceptor.intercept(ConnectInterceptor.java:5)
#00 pc 0000000000064060 /system/lib64/libcrypto.so (bio_ctrl+144)
#01 pc 00000000000615d8 /system/lib64/libcrypto.so (BIO_ctrl_pending+40)
#02 pc 00000000000387dc /apex/com.android.conscrypt/lib64/libjavacrypto.so (_ZL45NativeCrypto_SSL_pending_written_bytes_in_BIOP7_JNIEnvP7_jclassl+20)
问题分析
从设备分布上看,出问题都全是Android 11且各个国内厂商的设备都有,怀疑是Android 11引入的bug,对比了Android 11 和 Android 12的源码,发现在Android12 崩溃堆栈中的相关类 com.android.org.conscrypt.NativeSsl$BioWrapper有四个方法增加了读写锁,此时怀疑是多线程问题,通过搜索Android源码的相关issue以及差异代码的MR描述信息,进一步确认此结论。通过源码进一步分析发现NativeSsl的所有加锁的方法,会分发到NativeCrypto.java中的native方法,最终调用到native_crypto.cc中的JNI函数,如果能hook到相关的native函数并在Native层实现与Android12相同的读写锁逻辑,这个问题就可以解决了。
cs.android.com/android/pla… cs.android.com/android/pla… cs.android.com/android/pla…
解决过程
通过JNI hook代理Android12中增加锁的相关函数,当走到代理函数中时,先分发到JAVA层通过反射获取ReadWriteLock实例并上锁再通过跳板函数调用原来的JNI函数,此时就完成了对Android12 增量锁逻辑的复刻。经历了两个版本的灰度hook方案已稳定在线上运行,期间无因hook导致的网络不可用和其它崩溃问题,目前开关放全量的版本崩溃设备数已降为0。
JNI hook原理,以及详细修复过程: blog.dewu-inc.com/article/MTM…
五、小米Android15 焦点处理空指针崩溃
背景
随着Android15开放公测,焦点处理过程中发生的空指针问题逐步增多,并在1月份上升到Top。
堆栈与上报趋势
java.lang.NullPointerException: Attempt to invoke virtual method 'android.view.ViewGroup$LayoutParams android.view.View.getLayoutParams()' on a null object reference
at android.view.ViewRootImpl.handleWindowFocusChanged(ViewRootImpl.java:5307)
at android.view.ViewRootImpl.-$$Nest$mhandleWindowFocusChanged(Unknown Source:0)
at android.view.ViewRootImpl$ViewRootHandler.handleMessageImpl(ViewRootImpl.java:7715)
at android.view.ViewRootImpl$ViewRootHandler.handleMessage(ViewRootImpl.java:7611)
at android.os.Handler.dispatchMessage(Handler.java:107)
at android.os.Looper.loopOnce(Looper.java:249)
at android.os.Looper.loop(Looper.java:337)
at android.app.ActivityThread.main(ActivityThread.java:9568)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:593)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:935)
问题分析
通过分析ASOP的源码,崩溃的触发点是mView字段为空。
源码中mView为空的情况有两种:
- 未调用setView方法前触发窗口焦点变化事件(只有setView方法才会给mView赋不为空的值)。
- 先正常调用setView使mView不为空,其它地方置为空。
结合前置判断了mAdded为true才会走到崩溃点,在源码中寻找到只有先正常调用setView以后在调用dispatchDetachedFromWindow时才满足mAdded=true、mView=null的条件,从采集的logcat日志中可以证明这一点,此时基本可以定位根因是窗口销毁与焦点事件处理的时序问题。
解决过程
在问题初期,尝试通过 Hook 拦截 handleWindowFocusChanged 方法增加防御:当检测到 mView 为空时直接中断后续逻辑执行。本地验证阶段,通过在 Android 15 设备上高频触发商详页 Dialog 弹窗的焦点获取与关闭操作,未复现线上崩溃问题。考虑到 Hook 方案的侵入性风险 ,且无法本地测试,最终放弃此方案上线。
通过崩溃日志分析发现,问题设备100% 集中在小米/红米机型,而该品牌在 Android 15 DAU中仅占 36% ,因此怀疑是MIUI对Android15某些定制功能有bug。经与小米技术团队数周的沟通与联合排查,最终小米在v2.0.28版本修复了此问题,需要用户升级ROM解决,目前>=2.0.28的MIUI设备无此问题的上报。
六、总结
通过上述问题的治理,系统bug类的崩溃显著减少,希望这些经验对大家有所帮助。
文 / 亚鹏
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。