尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

HHFT:异构层级特征的两级自注意力推荐模型

HHFT:异构层级特征的两级自注意力推荐模型 推荐系统做到一定阶段多数团队都会撞上一堵墙模型的离线指标怎么调都涨不动了特征该加的也加了样本量也够大但AUC就是卡在某个数位上。这时候问题往往不在特征的数量而在特征的组织方式。HHFTHierarchical Heterogeneous Feature Transformer这套结构就是我在处理这类瓶颈时反复打磨出来的一个方案它把推荐系统里那些天生异构、天生带层级的特征用一种更贴近它们本来面目的方式喂给Transformer而不是简单粗暴地把所有特征拼成一条长向量丢进MLP。如果你正在做CTR/CVR预估、正在被特征交互的高阶组合问题困扰、或者手上有一堆行为序列不知道怎么和静态画像融合那这套思路值得花时间看一遍。下面我会从设计动因、架构细节、PyTorch实现到工程避坑完整讲一遍。1. 推荐系统的特征到底异构在哪HHFT要解决的问题先把术语说清楚。所谓异构特征指的是推荐场景里同时存在好几种性质完全不同的字段它们的取值空间、语义粒度、统计分布都不一样硬塞进同一个处理流程里必然有一方吃亏。所谓层级特征指的是这些字段天然存在分组关系和嵌套关系比如用户-行为-物品这三层或者域-字段-取值这三层。HHFT的核心主张就是异构决定每类字段要用不同的Token化方式层级决定注意力要分两级来做而不是一次性拍平。1.1 四类特征字段各有各的脾气我习惯把线上特征分成四类来对待。第一类是类别型单值字段比如用户性别、城市等级、物品一级类目、品牌ID这类字段值域有限但分布极度倾斜头部几个ID覆盖八成流量长尾ID可能一周才出现几次。第二类是数值型字段比如价格、历史点击率、商品评分、用户活跃天数这类字段的麻烦在于分布长尾且量纲不一直接做标准化在推荐场景里效果往往不如分桶离散化。第三类是多值字段比如物品标签、用户的兴趣类目集合、多级类目路径这类字段的取值是个集合长度可变。第四类是行为序列字段比如最近N次点击、最近N次加购这类字段既有顺序信息又有时间间隔信息。这四类字段如果统一用embedding lookup处理数值型字段会丢掉连续性的信息多值字段会被截断或者填充序列字段的顺序会被打乱。我早期就干过这种蠢事把价格当成类别ID做embedding结果模型对价格的响应完全是阶梯式的涨十块钱和涨一百块钱在模型眼里没有区别离线AUC看着还行线上价格敏感的商品推荐就明显不对劲。后来改成先分桶再做桶内embedding桶边界用分位数采样确定才算把这段价格响应救回来。所以HHFT里每一类字段都有独立的Token化分支这是整个结构的地基。处理这四类字段时有个容易被忽略的细节字段的缺失本身也是信息。用户没填年龄、物品没有品牌这些缺失不能简单用0填充或者用UNK token糊弄过去因为缺失和恰好取到UNK这个值是两回事。我的做法是给每个字段额外留一个独立的缺失embedding和UNK token的embedding分开实测在冷启动流量上差异很明显缺失率高的字段尤其敏感。1.2 两个层级的结构信息怎么用起来层级这件事在推荐特征里至少体现为两个层面。第一个层面是特征域到字段的层级用户画像域下面有年龄、性别、城市、注册天数这些字段物品内容域下面有类目、品牌、标签、价格这些字段上下文域下面有时段、设备、场景这些字段。同一域内的字段语义相近、交互频繁跨域的字段交互相对稀疏但价值更高。第二个层面是序列到元素的层级一条点击序列内部元素之间有时序依赖序列整体又能和当前候选物品产生交叉。传统做法比如DeepFM是把所有字段的embedding拼在一起然后FM部分做二阶交叉、DNN部分做高阶交叉。问题在于DNN那部分是全连接字段两两之间不管有没有交互价值都被强行连上参数利用率低而且跨域和域内的交互权重是一起学的域内的强交互信号容易被跨域的噪声稀释。AutoInt用自注意力做特征交互算是往前走了一步但它是把所有字段拍平成一条序列序列长度直接等于字段总数字段上百个的时候注意力矩阵就是上万规模而且域结构信息完全丢失了。HHFT的做法是分两级先在每个域内部做自注意力把域内字段交互压缩成一个域向量再在所有域向量之间做一次自注意力捕捉跨域高阶交互。这样域内的交互和域间的交互各用一套参数互不干扰而且计算量从字段数的平方降到域数的平方加上字段数的平方分域后结构上更合理。这个设计选择背后的直觉很朴素同一批特征里域内交互是高频强信号域间交互是低频但高价值的信号两者混在一起学梯度尺度都不一样。分开之后两个编码器可以各用各的学习率、各用各的层数调参空间也打开了。1.3 为什么不是直接上大Transformer有人会问既然Transformer这么强为什么不用一个足够大的Transformer把所有字段全塞进去靠数据和参数硬吃这个思路我试过结论是在推荐系统的样本规模和特征结构下直接堆大Transformer的性价比很低。原因有三个。第一是计算复杂度字段数N直接决定注意力矩阵是N²特征工程做得越细N越大线上推理延迟撑不住。假设你有一百个字段N²就是一万如果字段数到两百N²就是四万而分层结构下这个量级能压下来一个数量级。第二是异构字段的Token语义不对齐数值字段和类别字段经过同样的embedding层之后向量空间的尺度不一致自注意力学出来的相似度矩阵会被数值字段的高方差主导类别字段的信息被淹没。必须先用不同的Token化分支把尺度对齐才谈得上公平交互。第三是过拟合风险推荐系统的标签噪声很大负样本里混着大量没曝光但可能感兴趣的样本模型容量远超数据能支撑的量级时很快就记住训练集了。我自己的经验法则是在特征数超过50个、且有明显域结构的时候分层比拍平更划算特征数少于20个、结构松散的时候直接拍平反而简单有效。HHFT是为前者设计的不要无脑套用。2. HHFT架构拆解从字段Token化到两级注意力把设计动因讲完之后这部分进入正题我会一层一层拆HHFT的架构。整体数据流是原始特征 → 分类型Token化 → 域内自注意力 → 域内聚合 → 域间自注意力 → 全局池化 → 多任务MLP。下面每一层我都会说明它为什么这么设计以及有哪些容易踩的细节。2.1 统一Token化让数值、类别、多值、序列说同一种语言Token化的目标是把所有字段都映射到同一个维度d_model的空间里但同时保留各自的特性。类别型字段用标准的embedding lookupembedding_dim直接设成d_model初始化用小的正态分布标准差0.02左右这点很重要初始化太大会导致训练初期注意力分布过于尖锐。数值型字段我用的是分位数分桶 embedding查表 原始值线性投影的双通道分桶通道负责捕捉非线性的阶梯响应线性投影通道负责保留连续变化的细粒度信息两条通道相加再过一层LayerNorm。桶的数量我一般设32到64之间太少区分度不够太多每个桶的样本稀疏、embedding学不充分。多值字段的处理要小心简单的mean pooling会让长集合和短集合的表示尺度不一致我更喜欢用attention pooling给集合里每个元素算一个重要性权重权重由该元素和一个可学习的query向量点积得到归一化后加权求和。这样模型能自己决定哪些标签更重要比如一个商品有十个标签其中两个是强相关的attention会自动给它们更高权重。序列字段的Token化除了embedding之外必须叠加位置编码或时间间隔编码否则序列和集合没区别。位置编码我倾向用可学习的位置embedding而不是正弦编码因为行为序列长度通常有限截断到50到100可学习的位置embedding拟合得更充分。注意所有字段的Token在进入注意力之前建议统一乘一个字段级的可学习缩放系数或者各自过一层独立的LayerNorm。这一步在论文里经常被一笔带过但实测对收敛稳定性帮助很大尤其是当字段里混有量纲悬殊的数值特征时。2.2 域内编码器先把同类特征搅匀域内编码器的输入是同一个域里所有字段的Token序列长度等于该域的字段数。因为同一域内字段数通常不多5到20个这里用标准的Transformer Encoder层就够了多头数一般设4到8前馈维度设成d_model的4倍层数1到2层足矣。用两层以上的域内编码器收益递减很快因为域内字段本来语义就相近一层自注意力基本能把主要交互捕捉到再加层主要是增加过拟合风险。域内编码器的参数是否跨域共享是个值得权衡的点。共享参数的版本更省而且能让样本量在域之间互通对字段数少的域特别友好独立参数的版本表达能力强但小域的样本撑不起来。我的折中做法是共享主干、保留域专属的偏置和LayerNorm参数这样既利用了共享带来的泛化又保留了域之间的差异。实测下来这个折中比全共享和全独立都好一些尤其是在有十来个域、域之间样本量差距较大的场景。域内的聚合方式也有讲究。最简单的mean pooling会丢掉字段重要性差异我一般用一个可学习的CLS Token把它拼在字段序列最前面经过自注意力之后直接取CLS位置的输出当作域向量。CLS Token的初始化用截断正态效果比mean pooling稳定代价是多了一个位置的注意力计算几乎可以忽略。2.3 域间编码器跨域高阶交互的轻量做法域向量的数量等于域的个数通常十来二十个所以域间自注意力的矩阵规模很小可以放心地堆层数我一般用2到3层头数8。这里有一个设计选择域间注意力的位置关系要不要编码因为域之间其实没有严格的顺序关系用位置编码反而可能引入错误的先验所以域间编码器里我一般不加位置编码只保留token本身的表示。但如果域是按照某种业务顺序排列的比如用户域、物品域、上下文域这种固定顺序加一个轻量的位置embedding也无妨这个可以当成超参去试。域间编码器的输出是一个域向量序列最后的聚合方式有几种取CLS、取所有域向量的拼接、或者用一个可学习的加权求和。我实测下来直接拼接所有域向量再送进MLP效果和取CLS差不多但可解释性更好因为每个域的贡献是显式的做特征重要性分析的时候能直接看到哪个域在起作用。如果线上对延迟极敏感可以把拼接换成加权求和把维度降下来。还有一个容易忽略的细节域间编码器的层数不要太多。跨域交互的信息增益是有限的层数堆到4层以上时模型开始倾向于退化成近似恒等映射各域的表示趋同反而丢掉了域的个性。我踩过这个坑加了层数之后离线AUC持平但线上掉点最后砍回两层才恢复。2.4 输出层与多任务头设计输出层这边我做了两件事。第一是把域间编码器的输出和原始Token化结果做一次残差连接保证浅层信息不会在多层注意力中被磨掉。具体做法是把所有字段Token做一次mean pooling得到一个全局向量和域间输出拼接起来送进MLP。第二是多任务头共享底层、独立顶层因为CTR、CVR、停留时长这些目标的相关性不一样硬用一个头预测所有目标会互相拖累但底层表示共享能提升样本效率。多任务的loss加权是另一个坑。简单把几个loss相加通常会被量级最大的那个目标主导。我用的是动态权重调整每轮根据各任务loss的下降速率反比调整权重或者用不确定性加权的做法给每个任务学一个可学习的方差参数。这个部分在后面的训练章节会展开讲。3. PyTorch实现把HHFT跑起来理论讲完直接上代码。下面的实现是我简化过的版本去掉了公司内部的业务细节保留核心结构你可以直接拿去改。整体分四块特征Schema定义、Token化层、两级编码器、训练循环。3.1 特征Schema与Embedding层先把特征结构用配置声明出来这样加字段、改字段类型不用动模型代码。我用一个列表描述每个字段的类型和所属域然后按类型建不同的分支。import torch import torch.nn as nn import torch.nn.functional as F class FeatureSchema: def __init__(self, fields): # fields: list of dict(name, domain, type, vocab_size, bucket_num) self.fields fields self.domains sorted({f[domain] for f in fields}) self.domain2fields {d: [] for d in self.domains} for i, f in enumerate(fields): self.domain2fields[f[domain]].append(i) class FieldTokenizer(nn.Module): def __init__(self, schema, d_model): super().__init__() self.schema schema self.d_model d_model self.emb_layers nn.ModuleDict() self.num_projs nn.ModuleDict() for i, f in enumerate(schema.fields): key ff{i} if f[type] in (categorical, multivalue): # 多留一个位置给缺失值vocab_size 1 self.emb_layers[key] nn.Embedding(f[vocab_size] 1, d_model) nn.init.normal_(self.emb_layers[key].weight, std0.02) elif f[type] numeric: self.emb_layers[key] nn.Embedding(f[bucket_num], d_model) self.num_projs[key] nn.Linear(1, d_model) elif f[type] sequence: self.emb_layers[key] nn.Embedding(f[vocab_size] 1, d_model) self.norm nn.LayerNorm(d_model) def forward(self, batch): tokens [] for i, f in enumerate(self.schema.fields): key ff{i} v batch[key] if f[type] categorical: t self.emb_layers[key](v) elif f[type] numeric: bucket batch[key _bucket].clamp(min0) t self.emb_layers[key](bucket) self.num_projs[key](v.unsqueeze(-1)) elif f[type] multivalue: emb self.emb_layers[key](v) # [B, L, D] mask (v ! 0).float().unsqueeze(-1) # 0 视为 padding t (emb * mask).sum(1) / mask.sum(1).clamp(min1.0) elif f[type] sequence: emb self.emb_layers[key](v) # [B, L, D] t emb.mean(1) # 序列信息在域内编码器里再处理 tokens.append(self.norm(t)) return tokens数值字段的分桶值我在数据管道里预先算好模型里只做查表和投影避免在训练循环里做分位数计算。多值字段里我把索引0当作padding实际ID从1开始这样mask逻辑很简单。3.2 两级编码器的核心代码域内编码器负责把每个域的字段Token压成一个域向量域间编码器负责跨域交互。注意这里我用的是norm_firstTrue也就是Pre-LN结构训练早期比Post-LN稳定很多尤其是学习率偏大的时候。class HierarchicalEncoder(nn.Module): def __init__(self, schema, d_model128, intra_layers1, inter_layers2, nhead8, dropout0.1): super().__init__() self.schema schema self.d_model d_model intra_layer nn.TransformerEncoderLayer( d_model, nhead, dim_feedforward4 * d_model, dropoutdropout, batch_firstTrue, norm_firstTrue) self.intra nn.TransformerEncoder(intra_layer, intra_layers) self.domain_cls nn.Parameter(torch.randn(1, 1, d_model) * 0.02) inter_layer nn.TransformerEncoderLayer( d_model, nhead, dim_feedforward4 * d_model, dropoutdropout, batch_firstTrue, norm_firstTrue) self.inter nn.TransformerEncoder(inter_layer, inter_layers) self.global_cls nn.Parameter(torch.randn(1, 1, d_model) * 0.02) def forward(self, tokens): # 1) 每个域内做自注意力取CLS作为域向量 domain_vecs [] for d in self.schema.domains: idx self.schema.domain2fields[d] seq torch.stack([tokens[i] for i in idx], dim1) # [B, n_f, D] cls self.domain_cls.expand(seq.size(0), -1, -1) seq torch.cat([cls, seq], dim1) out self.intra(seq) domain_vecs.append(out[:, 0]) # [B, D] # 2) 域间自注意力 dom_seq torch.stack(domain_vecs, dim1) # [B, M, D] gcls self.global_cls.expand(dom_seq.size(0), -1, -1) dom_seq torch.cat([gcls, dom_seq], dim1) out self.inter(dom_seq) global_vec out[:, 0] domain_out out[:, 1:].reshape(out.size(0), -1) # [B, M*D] return global_vec, domain_out两级编码器的层数分配上我建议域内1层、域间2层作为起点。域内字段本来强相关一层足够跨域交互更复杂两层比较合适。如果你的域特别多超过20个可以把域间层数加到3层试试。3.3 训练循环与损失配置多任务部分我用不确定性加权来平衡loss这样不用手工调权重。核心思路是给每个任务学一个可学习的log方差参数loss写成0.5 * exp(-s) * task_loss 0.5 * s这样量级大的任务方差大自然被压低权重。class HHFT(nn.Module): def __init__(self, schema, d_model128, num_tasks2): super().__init__() self.tokenizer FieldTokenizer(schema, d_model) self.encoder HierarchicalEncoder(schema, d_model) self.num_tasks num_tasks in_dim d_model d_model * len(schema.domains) self.heads nn.ModuleList([ nn.Sequential(nn.Linear(in_dim, 256), nn.GELU(), nn.Dropout(0.1), nn.Linear(256, 1)) for _ in range(num_tasks) ]) self.log_vars nn.Parameter(torch.zeros(num_tasks)) def forward(self, batch): tokens self.tokenizer(batch) gvec, dvec self.encoder(tokens) feat torch.cat([gvec, dvec], dim-1) logits [h(feat).squeeze(-1) for h in self.heads] return logits def multitask_loss(logits, labels): losses [] for i, (lg, lb) in enumerate(zip(logits, labels)): losses.append(F.binary_cross_entropy_with_logits(lg, lb, reductionnone)) stack torch.stack(losses, dim0) # [T, B] log_vars model.log_vars weighted 0.5 * torch.exp(-log_vars).unsqueeze(1) * stack 0.5 * log_vars.unsqueeze(1) return weighted.mean()训练超参我一般这么起步AdamW学习率2e-3注意是Transformer的常用量级不是1e-4weight decay 1e-5warmup 1000步batch size 2048到4096。学习率做cosine衰减到初始值的十分之一。梯度裁剪阈值设1.0防止早期梯度爆炸。embedding层的weight decay要单独关掉否则ID embedding会被拉向0长尾ID本来样本就少再被正则压制基本学不到东西。3.4 参数量与显存估算上线前必须算清楚资源账。假设100个字段10个域d_model128域内1层、域间2层。Embedding表是大头假设ID类特征总词表五千万加上序列特征的核心词表三千万总共八千万个embedding每个128维float32就是8000万 × 128 × 4字节 ≈ 41GB这个量级必须用分片embedding加优化器状态的稀疏更新方案否则单机根本放不下。Transformer本体部分反而很小两层encoder大概几百万参数。激活显存估算batch 2048域内序列最长20个tokenCLS加字段10个域并行加上域间20个token10个域加CLS单层注意力的中间激活大概几十MB量级整体比较轻松。真正的瓶颈在embedding梯度的通信和聚合上分布式训练时稀疏梯度的传输效率往往比计算本身更耗时间这块要用AllReduce的稀疏版本或者参数服务器方案。4. 落地避坑数据、训练、推理三个环节模型能跑通只是第一步能上线并且稳定涨点才是目的。这一章讲的都是我实际踩过的坑很多在论文里根本不会提。4.1 特征穿越最贵的一类bug特征穿越指的是训练时用到了预测时刻还不可知的信息。这个问题在序列特征上尤其隐蔽。比如你用用户最近一次点击的物品ID做特征如果没有严格按照样本的时间戳去截取历史很容易把当前样本之后的点击也带进来。表现是离线AUC涨得飞起线上直接掉点。我的做法是所有特征都带一个生效时间戳训练时只允许访问样本时间戳之前的数据并且在数据管道里做断言校验。还有一个更隐蔽的情况统计类特征比如物品的七天点击率如果在离线计算时用了全量数据那这个特征本身就穿越了必须用滑动窗口按小时或按天滚动计算保证线上线下的计算口径一致。注意序列特征的截断策略要和线上保持一致。离线用最近50个行为训练线上也必须取最近50个不能离线用全量、线上只取最近20个否则特征分布偏移模型上线就崩。4.2 冷启动与ID特征退化HHFT里ID类特征占比很高冷启动是绕不开的问题。新用户、新物品的ID没出现过embedding就是随机初始化的小向量等于噪声。我的处理有几个层次第一ID做hash分桶把超长尾的ID映射到有限个桶里比如十万个桶这样至少能共享一些统计信息。第二内容特征兜底物品类目、品牌这些属性特征的embedding是全局共享的新物品即使ID是新的靠属性也能得到一个合理表示。第三加一个ID置信度门控ID出现次数少的时候自动降低ID特征的权重让模型更多依赖属性特征。这个门控用一个简单的sigmoid函数实现输入是该ID在过去一段时间的曝光次数。4.3 训练不稳定与梯度异常Transformer训推荐模型最常见的两个问题是loss震荡和梯度爆炸。除了标准的Pre-LN、warmup、梯度裁剪之外还有几个细节。embedding初始化方差要小0.02这个经验值可以用。注意力logits要做缩放标准Transformer里是除以根号d_k这一步千万别漏。数值特征进模型前要做clipping把极端值截断到分位数的1%和99%之间否则个别异常样本会把梯度带偏。多值字段的mask除法要加clamp避免空集合导致除零。如果训练过程中发现loss突然变成一个很大的值八成是某条样本的数值特征异常或者某个embedding查到了未初始化的区域。我的排查方法是先在小批量数据上跑几个epoch确认能过拟合再逐步扩大到全量这样能快速定位是数据问题还是模型问题。4.4 线上推理延迟的优化路径HHFT的推理可以拆成用户侧和物品侧两部分。用户侧的域画像、行为序列变化频率低可以做缓存用户向量每几分钟更新一次请求时直接取缓存。这样每次请求实际只需要算物品域和上下文域再和缓存的用户域向量做一次域间注意力计算量小很多。序列特征是最耗时的线上可以做长度截断最近N个行为用完整注意力更早的行为用简单加权池化降级处理。实测下来这套优化能把P99延迟控制在几十毫秒量级具体数值取决于域数和序列长度。如果延迟还是紧张可以考虑把域间编码器蒸馏成一个小MLP用大模型的输出做软标签训练效果损失很有限延迟能降一个量级。5. 效果验证与调参速查最后讲讲怎么验证效果、怎么调参。这部分是最容易自作欺人的环节指标涨了不代表模型真的好必须设计严格的对照实验。5.1 离线评估怎么设计才不骗自己离线评估我不只看AUC还会看GAUC按用户分组的AUC能过滤掉用户本身偏好强弱带来的干扰、LogLoss反映概率校准、以及预测均值与真实CTR的偏差校准度。推荐场景里AUC涨0.002可能是噪声GAUC涨0.003以上才值得关注。消融实验一定要做至少要验证三个点去掉域间编码器、把分层结构换成拍平、去掉时间间隔编码。这三个消融能告诉你涨点到底来自哪里。还有一点离线评估必须按时间切分而不是随机切分随机切分会让未来信息泄漏到训练集指标虚高。5.2 超参调优的先后顺序调参不要一把梭按敏感度排序先调影响最大的。我习惯的顺序是先定embedding维度和d_model64到256之间扫通常128是性价比拐点再定两级层数域内1到2层域间1到3层然后调学习率和warmup步数最后调dropout和weight decay做正则。batch size在显存允许范围内尽量大配合学习率同步放大。域数多的时候域间层数可以适当加但超过3层基本没收益。5.3 常见问题速查表现象可能原因排查方向训练loss不下降学习率过小或初始化方差过大先在小数据集上验证能否过拟合loss震荡剧烈学习率过大或batch过小降学习率、加warmup、加梯度裁剪离线AUC高但线上掉点特征穿越或线上线下口径不一致检查时间戳对齐和统计特征窗口长尾ID效果差embedding正则过强或初始化不当关掉embedding的weight decay加置信度门控推理延迟超标序列过长或用户侧未缓存序列截断、用户向量缓存、模型蒸馏多任务互相拖累loss权重量级失衡用不确定性加权替代手工权重域间注意力学不出差异域间层数过多导致表示趋同减到2层加域专属偏置显存爆炸embedding表过大或batch过大分片embedding、稀疏优化器、减batch这份表基本覆盖了我遇到过的八九成问题。剩下那一两成往往是数据本身的问题比如样本偏置、标签延迟、曝光偏差这些不是模型结构能解决的得从数据管道和采样策略上去修。5.4 后续可以继续深挖的方向如果HHFT的基础版本已经跑顺还有几个方向可以继续挖。一个是把序列编码器换成更精细的结构比如在序列域内部也用多级注意力先做局部窗口再做全局对长序列特别有效。另一个是引入对比学习做辅助任务用同一用户相邻两次行为构造正样本能显著提升序列表示的鲁棒性尤其在行为稀疏的用户上。还有就是模型蒸馏到轻量结构把HHFT当作teacher用它的中间层输出指导学生模型能在延迟受限的场景里保住大部分效果。这几个方向我自己试过前两个收益都比较实在后面有机会再详细拆。我自己在做这类结构时最大的体会是结构设计一定要服务于数据和业务而不是反过来。HHFT分层这套东西之所以有效根本原因在于推荐特征本身就有域结构和层级关系模型只是把这个先验显式地编码进去了。如果换成图像、文本这类本身没有明显域划分的输入这套分层反而会引入无意义的约束。所以动手之前先花时间看清楚你的特征到底长什么样比急着调模型结构重要得多。
返回列表