资讯详情

AGREE群组推荐:注意力机制如何动态聚合群体偏好

📅 2026/10/17 13:12:46 | 华诺云谱 👁 阅读
AGREE群组推荐:注意力机制如何动态聚合群体偏好
最近在做推荐系统方向的算法调研把目光落在了群组推荐上。之前单用户个性化推荐做得比较多看论文时注意到一篇挺有意思的工作——AGREE全称 Attentive Group Recommendation核心就是用注意力机制来建模一群人的共同偏好。这篇文章我前后复现了两遍第一遍磕磕绊绊第二遍才把细节吃透。趁着记忆还新鲜把原理和代码一起整理出来给正在入门群组推荐或者想了解注意力机制怎么落地的人做个参考。先说清楚这篇博文能给你什么第一理清 AGREE 到底在解决什么问题和普通推荐有什么区别第二拆解注意力机制在群组建模里是怎么工作的第三给出一份基于 PyTorch 的最小可运行代码你可以在小规模模拟数据上直接跑通第四分享我复现过程中踩过的坑和调参心得。如果你是推荐系统方向的工程师、研究生或者刚接触注意力机制想找个具体场景练手这篇文章应该能帮你节省不少时间。1. 群组推荐在解决什么问题从个体偏好到群体共识1.1 单用户推荐和群组推荐的本质差异传统的个性化推荐输入是一个用户ID输出是一串物品排序逻辑是你感兴趣什么我就推荐什么。但现实中很多消费行为不是一个人完成的——一家三口选周末看的电影同事聚餐挑餐厅班级小组确定期末展示的主题。这种场景下系统要服务的对象是一个群组而不是某个具体的用户。表面上看群组推荐只是把推荐对象从一个人换成了一群人但问题难度完全不一样。单个用户的兴趣虽然多变但总体有个比较稳定的大方向而一个群组里的成员可能品味迥异有人喜欢文艺片有人只爱动作片有人就图个轻松搞笑。这时候系统面对的不再是一个平均人而是一个内部充满张力的组合体。那么最朴素的想法是什么把群组里所有用户的历史行为数据拼在一起当成一个超级用户来对待然后跑一套单用户推荐模型。这种方式问题很明显它把每个成员的偏好无差别地倒进同一个桶里忽略了人在群体决策中的差异性。还有一类做法是先用单用户模型算出每个成员对物品的分数再设计一个融合规则比如取平均、取最大、取最小来得到群组分数。这种分数聚合策略看起来合理但聚合规则是人手写的无法适应不同情境下的复杂决策模式。1.2 话语权不是固定的AGREE 的出发点我一开始也觉得用平均分来代表群组偏好已经够用了直到我意识到一个关键点在真实的群体决策中成员之间的话语权是动态变化的而且高度依赖于具体决策什么。来举一个特别生活化的例子。一家三口选电影如果片单里有一部新上映的科幻大片那平时就对科幻类型特别狂热的老爸大概率最有发言权他的偏好应该占更重的权重但如果片单变成一部校园青春片老妈年轻时追剧的类型那她的意见分量就上来了如果是一部动画电影小孩的喜好就成了决定因素。也就是说每个群组成员的权重不应该是一个固定不变的数而应该随着待决策物品的变化而变化。这个观察就是 AGREE 的核心动机。它没有用任何人工设定的聚合规则而是引入注意力机制从用户的历史交互数据中自动学习出在推荐某个具体物品时群组里的每个成员到底应该有多大的发言权。1.3 AGREE 是怎么把自己补全的除了注意力权重之外AGREE 还做了一件我认为很关键的事它在训练时同时保留了两部分信息——用户个体层面的交互和群组群体层面的交互。这个设计是有道理的。群组的历史交互数据通常非常稀疏一个家庭可能几个月才一起看一次电影如果只靠群组层面来建模注意力网络的参数很难学好。但每个用户的个人交互数据相对丰富得多个体偏好学扎实了聚合出来的群组表示也有了个坚实的基础。两条路线联合训练正好让模型既看到单个人怎么选又看到一群人怎么选互相补充。理解了这一点再往下看模型结构就顺理成章了。2. 注意力机制如何决定谁说了算动态权重的学习目标2.1 从平均分配到动态加权一个公式的变化先回顾一下最简单的群组表示构建方式。假设群组成员有 m 个每个用户被表示成向量 u_i那么平均聚合得到的群组向量就是g (u_1 u_2 ... u_m) / m这种方式把每个成员一视同仁权重都是 1/m。AGREE 改成了加权求和g alpha_1 * u_1 alpha_2 * u_2 ... alpha_m * u_m其中 alpha_i 是第 i 个成员在这个群组中的权重满足所有 alpha 的和为 1。和固定平均的区别在于alpha 不是一个手工设置的值而是一个由神经网络根据当前推荐物品自动计算出来的动态权重。那这个权重是怎么算出来的AGREE 用的是经典的两层注意力网络。第一步把用户向量和物品向量拼接在一起经过一个全连接层加 ReLU 激活第二步再经过一个线性层输出一个标量分数把所有成员的标量分数过 softmax 归一化。这个过程用数学语言描述就是score_i W_2 * ReLU(W_1 * concat(u_i, v_j) b_1) b_2 alpha_i softmax(score_i)其中 u_i 是成员 i 的用户嵌入v_j 是当前待推荐物品的嵌入。注意看物品嵌入 v_j 直接参与了这个计算所以同一个用户在不同物品下会得到完全不同的权重。这正是 2.1 节里强调的话语权随情境变化的落地方式。2.2 为什么权重必须依赖物品直觉与数学的一致性如果去掉物品嵌入只把用户向量作为注意力网络的输入那学出来的权重就是这个用户在这个群里平均有多重要。这样做简单是简单了但在很多场景下不够准确。还是拿家庭看电影来说。如果某个家庭成员平均话语权最大的是父亲但这次推荐的是一部育儿纪录片这时候父亲的意见权重再大也是离谱的。只有当物品信息进入注意力打分网络模型才能学会当物品是科幻片时父亲的权重高当物品是动画片时孩子的权重高这种细粒度规律。从推荐系统视角看这种设计也让模型具备了可解释性。当你看到某个群组被推荐了一部动作片可以反查注意力权重发现群组里那几个动作片爱好者拿到了接近 1 的权重占比。这在 B 端场景下很有价值毕竟群组推荐很多时候要说服的不是一个用户而是一群人。2.3 把个体偏好和群体偏好拧在一起联合损失函数AGREE 模型的最终输出会分成两路。一路是个体路计算用户对物品的偏好分数另一路是群体路计算群组对物品的偏好分数。训练时两路分数都要和真实交互做对比用 BPR贝叶斯个性化排序损失来优化。BPR 损失的核心思想是对每个正样本随机采一个用户没交互过的负样本让正样本分数尽量高于负样本分数。形式化地讲给定一个正样本 (u, i) 和负样本 (u, k)L_bpr -ln(sigmoid(score(u, i) - score(u, k)))这其实是把推荐当成了一个排序任务而不是分类任务。AGREE 的整体损失就等于个体 BPR 损失加群体 BPR 损失再加上一组 L2 正则项防止过拟合。联合训练时两路梯度会同时作用在用户嵌入表、物品嵌入表和注意力网络上形成一种互相牵制的学习信号——个体信息帮群体建模兜底群体信号反过来也能强化个体嵌入的表达。3. 网络结构逐层拆解从嵌入到群组打分器的完整数据流3.1 三张嵌入表用户、物品、群组AGREE 的输入特征非常简单基本上就是 ID 映射没有用任何手工特征或者上下文特征。所以模型里面最基础的数据结构就是三张 embedding 表用户嵌入表、物品嵌入表、群组嵌入表。用户嵌入表把每个用户 ID 映射成一个 d 维稠密向量物品嵌入表把每个物品 ID 映射成同样的 d 维向量群组嵌入表把每个群组 ID 映射成一个 d 维向量代表群组本身的独立特征为什么群组还需要一张独立的嵌入表既然我们已经通过注意力机制把成员向量聚合成群组向量了何必多此一举这个设计其实是 AGREE 的一个亮点。群组本身的行为模式并不完全等于成员偏好的加权和它有可能包含一些群体涌现出的口味。比如某群组虽然成员平时各看各的但聚在一起的时候偏偏喜欢选评分高的热门片而不是各自偏好类型的叠加。这种规律很难从成员聚合中自然学到单独学一个群组嵌入能把这些额外信息兜住。3.2 打分函数内积和注意力聚合的配合整个模型前向传播分两条分支。第一条是个体预测分支。给定用户 u 和物品 i直接取用户向量和物品向量做内积得到用户对物品的偏好分数score_user u_embed, i_embed第二条是群组预测分支。输入某个群组 g、成员列表、当前物品 i流程如下对每个成员取用户向量和物品向量拼接经过两个全连接层得到这个成员在当前物品下的注意力分数softmax 归一化得到最终的成员权重按权重加权求和所有成员向量得到注意力聚合的群组表示把这个加权结果和群组自身的嵌入向量相加或经过一次线性变换融合最后和物品向量做内积得到群组对物品的偏好分数我在这里画一条相当于配方的伪代码便于直接对应到后面 PyTorch 代码member_embeds user_embed(members) # (B, max_members, d) item_embeds item_embed(item) # (B, d) - expand to (B, max_members, d) attn_input concat(member_embeds, item_embeds) attn_hidden ReLU(W1(attn_input)) attn_logits W2(attn_hidden) # (B, max_members, 1) alpha softmax(attn_logits, dim1) # 掩码掉无效成员 group_attn_embed sum(alpha * member_embeds, dim1) group_embed_final group_embed(group) group_attn_embed score_group inner_product(group_embed_final, item_embeds)3.3 训练样本和掩码处理群组推荐训练数据的构造相比单用户推荐多了一层维度。每个训练样本包含四部分一个用户、一个群组、一个物品以及这个群组完整的成员 ID 序列。由于不同群组的人数不一样成员序列长度不一PyTorch 里一般用固定长度 padding。我习惯用 0 作为填充 ID对应的用户嵌入那一行全设为 0 向量或者干脆不让它参与梯度更新代码如下实现。在注意力分数计算之后要把填充位置对应的分数设成一个非常小的负数再送进 softmax这样填充位置的权重就自动变成 0不会污染真正的成员信息。还有一点需要注意BPR 里要给正样本配一个负样本负样本可以从用户没交互过的物品里采样也可以整体随机采样后过滤。对于群组部分负物品也要避开群组交互过的物品。工程实现上我在负采样时简单判断一下物品 ID 是否在用户/群组的交互集合里在的话重新采样。群组交互集合通常很小这个过滤操作在预处理阶段就可以提前算好。4. 一个能跑通的最小 PyTorch 实现代码逐段讲解接下来是重头戏。我把 AGREE 的核心逻辑浓缩成了一分可以直接运行的 PyTorch 代码用模拟数据演示。代码结构尽量简洁去掉不必要的封装方便你对照论文理解每个模块。4.1 数据准备构造模拟交互样本先准备一份模拟数据。假设场景是有 20 个用户、30 个物品、6 个群组每个群组 3-5 人。生成用户和群组的交互记录时我让同一个群组内偏好看上去有一定相似性这样训练出来的注意力权重会出现可辨识的差异——比如某个群组对物品 0-9 有共同偏好那这几个物品对应的注意力权重分布就会相对清晰。import numpy as np import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import Dataset, DataLoader def generate_sim_data(num_users20, num_items30, num_groups6): # 用户-物品交互随机生成一部分保证每个用户至少有4条记录 user_items {} for u in range(num_users): items list(range(num_items)) np.random.shuffle(items) user_items[u] items[:np.random.randint(4, 8)] # 群组-成员关系 group_members { 0: [0, 1, 2], 1: [3, 4, 5, 6], 2: [7, 8, 9, 10], 3: [11, 12, 13], 4: [14, 15, 16, 17], 5: [18, 19, 0, 1], } # 群组-物品交互让群组偏好接近内部成员偏好的并集 group_items {} for g, members in group_members.items(): candidate_items [] for u in members: candidate_items.extend(user_items[u]) candidate_items list(set(candidate_items)) np.random.shuffle(candidate_items) group_items[g] candidate_items[:np.random.randint(5, 10)] return user_items, group_items, group_members这里我故意把群组 5 设计成跨了两个已有的用户圈子方便后面观察注意力权重的分化。接着定义一个 Dataset每个样本是用户ID、群组ID、物品ID、成员ID列表。正样本从交互记录里抽负样本在交互集合之外随机采。class GroupRecDataset(Dataset): def __init__(self, user_items, group_items, group_members, num_items, neg_num1, max_members6): self.user_items user_items self.group_items group_items self.group_members group_members self.num_items num_items self.neg_num neg_num self.max_members max_members self.samples [] for u, items in user_items.items(): for i in items: self.samples.append((user, u, i, None)) for g, items in group_items.items(): for i in items: self.samples.append((group, g, i, group_members[g])) def __len__(self): return len(self.samples) def __getitem__(self, idx): kind, uid, item, member_list self.samples[idx] # 采样负样本 neg_items [] while len(neg_items) self.neg_num: neg np.random.randint(0, self.num_items) if kind user: if neg not in self.user_items[uid]: neg_items.append(neg) else: if neg not in self.group_items[uid]: neg_items.append(neg) if kind user: group_id -1 # 无效群组ID代码里会用全0向量替代 members np.zeros(self.max_members, dtypenp.int64) else: group_id uid members np.array(member_list[:self.max_members], dtypenp.int64) # padding到max_members if len(members) self.max_members: pad_len self.max_members - len(members) members np.concatenate([members, np.zeros(pad_len, dtypenp.int64)]) item np.array(item, dtypenp.int64) # 负样本只取第一个简化演示 neg_item np.array(neg_items[0], dtypenp.int64) return { user_id: torch.tensor(uid, dtypetorch.long), group_id: torch.tensor(group_id, dtypetorch.long), item_id: torch.tensor(item, dtypetorch.long), neg_item_id: torch.tensor(neg_item, dtypetorch.long), members: torch.tensor(members, dtypetorch.long), }4.2 模型定义AGREE 类完整实现现在到了核心部分。模型类里包含三张嵌入表、注意力网络、以及一个用于融合群组嵌入的线性变换层。class AGREE(nn.Module): def __init__(self, num_users, num_items, num_groups, embed_dim32, attn_dim16, reg1e-4): super().__init__() self.num_users num_users self.num_items num_items self.num_groups num_groups self.embed_dim embed_dim self.reg reg # 三张嵌入表 self.user_embed nn.Embedding(num_users, embed_dim) self.item_embed nn.Embedding(num_items, embed_dim) self.group_embed nn.Embedding(num_groups, embed_dim) # 注意力网络 self.attn_layer1 nn.Linear(embed_dim * 2, attn_dim, biasTrue) self.attn_layer2 nn.Linear(attn_dim, 1, biasTrue) # 群组融合变换可选也可直接用加法融合 self.group_linear nn.Linear(embed_dim, embed_dim, biasTrue) self._init_weights() def _init_weights(self): for module in self.modules(): if isinstance(module, nn.Embedding): nn.init.xavier_uniform_(module.weight) elif isinstance(module, nn.Linear): nn.init.xavier_uniform_(module.weight) if module.bias is not None: nn.init.zeros_(module.bias) def forward(self, user_ids, group_ids, item_ids, members): # user_ids: B # group_ids: B, 普通用户样本时是无效ID-1 # item_ids: B # members: B x max_members # ---------- 个体预测分支 ---------- user_embeds self.user_embed(user_ids) # B x d item_embeds self.item_embed(item_ids) # B x d user_score (user_embeds * item_embeds).sum(dim1, keepdimTrue) # B x 1 # ---------- 群组预测分支 ---------- # 先算成员嵌入 member_embeds self.user_embed(members.clamp(min0)) # B x max_mem x d # 无效群组ID-1时手动指定群组向量为0 valid_group (group_ids 0) (group_ids self.num_groups) group_ids_safe group_ids.clamp(min0, maxself.num_groups - 1) group_embeds self.group_embed(group_ids_safe) # B x d # 无效群组置零 group_embeds group_embeds * valid_group.unsqueeze(-1).float() # 计算注意力分数 item_expand item_embeds.unsqueeze(1).expand(-1, member_embeds.size(1), -1) # B x max_mem x d attn_input torch.cat([member_embeds, item_expand], dim-1) # B x max_mem x 2d attn_hidden torch.relu(self.attn_layer1(attn_input)) attn_logits self.attn_layer2(attn_hidden).squeeze(-1) # B x max_mem # 掩码padding位置成员ID为0 mask (members 0).float() attn_logits attn_logits (mask - 1) * 1e9 # 填充位置变为很小数值 attn_weights torch.softmax(attn_logits, dim-1) * mask # 再乘一次mask保险 # 加权聚合成员向量 group_attn_embed (attn_weights.unsqueeze(-1) * member_embeds).sum(dim1) # B x d # 融合群组自身嵌入 group_attn_embed self.group_linear(group_attn_embed) group_final group_attn_embed group_embeds group_score (group_final * item_embeds).sum(dim1, keepdimTrue) # B x 1 return user_score, group_score这个模型实现里有两个细节值得说明。第一个是群组 ID 的无效值处理在 BPR 里正负样本既有用户样本也有群组样本我让群组样本传入真实群组 ID用户样本传入 -1并在 group_embeds 上做了一次乘法置零。第二个是掩码的实现方式把填充位置的注意力 logits 加上一个极大负数softmax 之后权重趋近于 0再顺手乘一次 mask 把数值彻底清理干净。4.3 训练循环与 BPR 损失训练时我把每个样本的正物品和负物品各跑一遍前向得到两组分数然后用 BPR 损失约束正负分数的相对高低。但这里有个操作细节群组正样本能同时计算个体分支和群体分支分数用户正样本只有个体分支有效。所以损失计算要分类型处理。简单起见下面的代码里我把用户样本和群组样本分开计算def bpr_loss(pos_score, neg_score, model, reg): score_diff pos_score - neg_score loss -torch.log(torch.sigmoid(score_diff) 1e-8).mean() # L2正则 reg_loss 0.0 for name, param in model.named_parameters(): if embed in name: reg_loss torch.norm(param, 2) ** 2 return loss reg * reg_loss def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss 0.0 for batch in dataloader: uid batch[user_id].to(device) gid batch[group_id].to(device) item batch[item_id].to(device) neg_item batch[neg_item_id].to(device) members batch[members].to(device) pos_user_score, pos_group_score model(uid, gid, item, members) neg_user_score, neg_group_score model(uid, gid, neg_item, members) # 群组样本个体分支也会算但因为gid-1group_score会变成0不影响 # 这里统一把两个分支的分数相加作为最终打分 pos_score pos_user_score pos_group_score neg_score neg_user_score neg_group_score loss bpr_loss(pos_score, neg_score, model, model.reg) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)如果你想把个体分支和群体分支分开调权重可以分别算两个 BPR loss 再相加我实验里发现两种方式差别不大统一打分更省事。小数据集上跑几十轮loss 会稳定下降。降到一个比较低的平台期后模型的注意力权重就能看到明显的分化了。5. 复现过程中的踩坑记录与参数调优细节5.1 注意力网络的两层陷阱层数、非线性与过拟合我第一遍复现时总想着把注意力网络做得豪华一点塞了一堆高层特征进去结果小数据集上过拟合得一塌糊涂训练集 loss 很低但验证集的召回率反而比简单的平均基线还低。后来复盘才发现注意力网络本质上读取的是用户向量和物品向量的某种亲密度这个关系用一层或者两层全连接就能表达得很好。网络加深之后可学习的参数变多在群组数据本来就稀疏的情况下很容易把训练集里那些噪声记住而不是真正学到什么成员在什么物品上有话语权。我的经验是注意力部分保持两到三层全连接就够激活函数用 ReLU最后一层不加激活直接输出分数。如果数据规模不大可以把 attention_dim 设成 embed_dim 的一半甚至三分之一正则项系数 reg 从 1e-4 附近开始调。5.2 群组嵌入的初始化会严重影响冷启动表现很多人最初实现时容易忽略群组嵌入表或者把它初始化成全零。全零初始化表面上让群组表示从零开始学但实际上梯度信号如果一直依赖注意力聚合那一支群组嵌入永远学不强。我第一次就把 group_embed 初始化成全零结果在冷启动群组成员很少、交互极少的群组上的效果几乎为零。后来改成 xavier 初始化让群的独立特征之间一开始就有差异模型才有机会学习哪些群组特征需要被激活冷启动场景下的表现明显改善。如果你仔细观察 AGREE 的结构就会发现群组嵌入这一支其实有点残差连接的意思注意力聚合的结果是主干群组嵌入是补充。把补充支路初始化好整体训练稳定性会提升不少。5.3 负采样方法与频率的微妙影响负采样看起来是个小细节但实际影响很大。最简单的做法是全局随机采样完全不考虑用户/群组是否已经交互这种情况下模型很快就学会了把负样本分数压低但排序能力未必好。更好的做法是引入一部分难负样本——比如从交互次数接近但在当前样本中未交互的物品里采样让模型学会更精细的区分。AGREE 原论文的公开实现里用的是均匀负采样我在复现时对比过均匀负采样配上足够多的训练轮次结果也挺稳。我的建议是先用均匀采样跑通流程再尝试难负样本策略不要在第一步就引入过多变量。负样本数量方面每个正样本配 1 个负样本是最常见的配置。我之前试过配 4 个负样本batch 变大后训练速度慢了不少在验证集上的提升很有限。如果你的显存充裕可以提高负采样个数但别把 I/O 主要时间耗在负采样上。5.4 评估指标的细节召回率、命中率与 NDCG 的取舍复现论文时指标评估是绕不开的一环。群组推荐常用的是 HRHit Ratio和 NDCG。计算时对每个群组随机选 999 个未交互物品和 1 个真实交互物品组成测试集模型给这 1000 个物品打分排序看真实物品排在第几位。这里面有几个容易踩的坑。第一个负采样时要保证这些候选负物品不是群组真正交互过的否则真实正样本的排名会被搞乱。第二个评估数据集不能和训练数据集重叠所有用于评估的正样本在训练时要一律抹掉不然指标虚高。第三个NDCG 对排序质量敏感程度要高于 HR建议两个一起看别只报一个。在公开的真实数据集上复现时我的感受是AGREE 相比简单的平均基线能高几个百分点但提升幅度严重依赖于群组交互数据的覆盖度。如果数据集里群组的平均人数很少只有两三个注意力机制发挥空间有限差距会缩小如果群组人数在五到十人之间且群组间成员构成差异性大性能提升会更明显。5.5 调试建议先跑单 batch 验证梯度再上全量数据最后分享一个我每次复现新模型都会做的调试习惯先用单 batch 数据验证模型能不能正常收敛。具体方法是把数据集的 batch_size 设成样本总数或者干脆用手工构造的三四个样本跑一个 batch 的前向和反向观察 loss 是否在稳步下降。如果单 batch 都跑不降说明代码里可能存在逻辑错误这时候不要急着上全量训练先把 bug 找出来。单 batch 跑通后再切回正常训练配置这时候如果 loss 依然降不下去大概率是学习率或者嵌入维度设置的问题。AGREE 的模型结构不算复杂不太需要花哨的优化器Adam 配 0.001 的学习率在我的实验里效果稳定。还有一个容易被忽略的点训练和评估时的随机种子。复现学术模型时固定随机种子极其重要。如果不固定种子同样的代码跑两遍指标可能浮动一两个百分点你就很难确认是模型改动带来的收益还是随机波动造成的。写在最后的一点实操体会复现 AGREE 的过程让我重新理解了群组推荐的难点所在。它真正难的不是把用户向量平均出群组向量而是找到什么时候应该更相信谁的规律。注意力机制给了一个漂亮的回答让模型自己去数据里学。但这也意味着如果你的群组交互数据过于稀疏注意力网络能发挥的空间其实非常有限。我从这个项目里带走的经验是用群组推荐模型时先做数据分析看看每个群组的成员数量分布、交互频次分布再决定要不要上注意力这类复杂结构。如果一个数据集里大多数群组只有两三个人交互记录也稀稀拉拉简单模型加上人工规则可能已经够用反过来如果群组构成复杂、交互充足AGREE 这种模型带来的提升就非常值得你花时间去调参。最后再分享一个小技巧如果想在现有代码基础上扩展可以试着把注意力权重从群组-物品级别细化到群组-物品-上下文级别比如加入时间维度看看工作日和周末的群组决策模式会不会学出不同的权重分布。这个方向我还在摸索整体思路和 AGREE 保持一致核心改动只是把注意力网络的输入多加一个上下文嵌入。祝你好运。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑