BERT多任务实战:意图识别、领域分类与槽位填充

发布时间:2026/11/2 14:42:49
BERT多任务实战:意图识别、领域分类与槽位填充 简介围绕BERT的意图识别与自然语言理解实战资源面向NLP入门者及需要落地领域分类、意图识别、槽位填充任务的开发者。内容对应SMP2019ECDT任务完整覆盖数据预处理、BERT微调、顶部分类层与序列标注设计、模型评估和推理输出可直接作为课程设计或竞赛复现的基线。压缩包共14个文件大小约1.53MB整体结构紧凑、各文件职责清晰以6个Python脚本、3个JSON数据文件和3份PDF技术报告为主体Python脚本包含模型配置、LSTM-CRF层、训练与评估等关键模块JSON数据提供train/test和SMP2019任务样本PDF报告展示不同参赛团队的解题思路与调优经验。已有1079人学习下载。包内还附带README、运行输出结果和数据格式说明能够帮助读者快速理解从数据到模型落地的完整链路并结合自己的对话系统或业务场景进行扩展改造。1. 这个项目到底在解决什么问题一条文本里的三层语义意图识别在人工智能项目实践中是最接近落地的那类任务尤其是用 BERT 做领域分类、意图识别和槽位填充能把用户一句话拆成三个层次这句话属于哪个垂直领域天气、机票、酒店、用户想做什么动作查天气、订机票、订酒店、动作里缺少哪些关键参数出发地、目的地、时间。一句话说清楚现象用户输入「帮我订明天早上从上海到北京的机票」系统先判定领域是 flight再判定意图是 book_ticket最后把「明天早上」填进 time 槽、「上海」填进 departure_city 槽、「北京」填进 destination_city 槽。三个任务共享一份文本输入输出互相补充这才构成一个能被上层逻辑直接消费的语义结果。如果你正在找人工智能大作业方向、入门对话系统或者从纯分类任务往序列标注上迈一步这个组合是性价比最高的选择之一数据不用太多、训练不用太长、效果却非常直观。更难能可贵的是这三个任务可以挂在一个 BERT 上同时训练而不是分别训三个模型再拼装这让你一次就接触到多任务学习和共享参数的思想。后面几章我按自己搭这套任务的习惯把数据格式、模型结构、训练参数和踩过的坑完整写一遍。2. 动手前先把标签体系定对领域、意图、槽位的边界2.1 领域分类与意图识别两层的粒度差别决定数据标注成本很多人第一次接触会混淆领域分类和意图识别总觉得两个任务差不多。其实它们的边界很清楚领域是一个粗粒度的主题概念意图是这个主题下用户具体要执行的动作。「帮我订机票」落在 flight 领域、book_ticket 意图「北京今天天气怎么样」落在 weather 领域、query_weather 意图。领域的数量通常几十个封顶意图则需要拆到几十甚至几百因为同一个领域下往往有大量动作变体。看标签体系设计{ text: 帮我订明天早上从上海到北京的机票, domain: flight, intent: book_ticket, slots: [ {value: 明天早上, slot: time}, {value: 上海, slot: departure_city}, {value: 北京, slot: destination_city} ] }text 是原始句子domain 是领域标签intent 是意图标签slots 是槽位键值对列表。这个设计里intent 和 domain 都是有穷枚举属于文本分类slots 是序列标注产物在文本里定位实体再映射到语义槽。领域类别少意图类别多这两层的分类器共享同一个句子表示所以你会希望一个 BERT 模型同时输出两组分类结果而不是各训一套。动手前最应该先做的不是搭模型而是把领域和意图的层级关系画出来。比如 flight 下面有 book_ticket、cancel_ticket、query_flightweather 下面有 query_weather、alert_setting。画完这张表你才知道数据标注要覆盖多少意图分支也才能避免「领域和意图概念互相混用」这种最容易犯的归类错误。数据标注成本跟着意图数量走领域多不影响整体量级意图每多一个分支至少要补几十条带槽值标注的语料否则分类边界就是玄学。2.2 槽位填充为什么要设计成序列标注BIO 标签与实体边界槽位填充任务在建模上其实是命名实体识别只是实体类别换成了业务槽位名。常见做法是把每个字或每个词分配到 B、I、O 三种前缀里B 表示槽位实体的开始I 表示槽位实体的内部O 表示非槽位内容。加上槽位名本身就形成了一套类似 B-time、I-time、B-departure_city、I-departure_city 的标签集合。看一个 BIO 标注例子帮 我 订 明 天 早 上 从 上 海 到 北 京 的 机 票 O O O B-time I-time I-time O B-departure_city I-departure_city O B-destination_city I-destination_city O O O需要注意的是「从上海到北京」这类表达里出发地和目的地是相邻实体模型必须准确切出边界。BIO 体系在相邻实体上的表现比 BIOES 稍微弱一点因为缺少 E 后缀来标记实体结束边界但胜在标签数量少、模型更容易收敛。如果你的任务里实体密集、相邻频发可以换成 BIOES 试试代价是标签种类多了一半数据量小的时候反而容易把标签学混。序列标注的损失是按 token 逐个计算的每一个 token 都贡献一个分类损失。这带来一个特性标注数据的质量比数量更敏感。单独一个词标错会拉低周围序列的边界判断所以做槽位数据清洗时重点不是删脏数据而是人工检查实体边界有没有被多切或少切。常见做法是先跑一遍程序把标注结果可视化每行文本下面挂上 token 和标签肉眼扫一遍边界错误比统计准确率还管用。2.3 一套数据组织方式训练集、验证集、测试集的结构实践项目里我不喜欢把三种标签分开存成多个文件那会让数据对齐变得很痛苦。更顺手的是按行组织一行一个样本JSON 格式保存。明确了文本、领域、意图、槽位信息之后还需要考虑训练集、验证集、测试集的划分常见比例是 8:1:1且划分时要按意图类型做分层抽样不能简单随机切否则某些冷门意图可能只在训练集里出现一两次验证集和测试集完全没见过指标虚高或虚低都说不准。import json import random from collections import defaultdict def load_data(path): samples [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue samples.append(json.loads(line)) return samples def stratified_split(samples, test_ratio0.1, val_ratio0.1, seed42): random.seed(seed) groups defaultdict(list) for s in samples: groups[s[intent]].append(s) train, val, test [], [], [] for intent, items in groups.items(): random.shuffle(items) n_test max(1, int(len(items) * test_ratio)) n_val max(1, int(len(items) * val_ratio)) test items[:n_test] val items[n_test:n_test n_val] train items[n_test n_val:] return train, val, test这个函数按 intent 做分层切分避免冷门意图在验证集和测试集里彻底缺席。n_test 和 n_val 用 max(1, ...) 保证每条意图至少分到一个测试样本代价是数据量极少时验证集偏大这可以接受因为槽位任务的评估本身就需要跨意图观察。数据组织还有一个容易被忽略的点数量配比。领域分类的数据天然不平衡「查天气」的语料往往比「取消机票」多得多。意图多的类别如果只有个位数样本分类器大概率直接放弃它。常见补救方式不是简单复制样本而是收集更多同义表达或者对意图做合并——把语义动作相近的子类合成一个父类宁可牺牲粒度也别让类别裸奔。标签体系定到这里才算真正具备开工条件。3. 用 BERT 把三个任务跑起来从数据处理到模型训练3.1 tokenizer 对齐标签这一步决定后面所有结果BERT 的 tokenizer 会把词拆成子词这是意图识别项目里第一个翻车点。中文场景下BERT 使用字级分词一个字通常是一个 token似乎没有子词问题但一旦文本里混入英文、数字、特殊符号tokenizer 就会把一个词拆成多个子词标签对齐就可能错位。以「明天早上9点出发」为例「9」可能保持原样但「2025年」会被拆成「2025」和「年」。如果槽位标注是按原始字符做的必须把标签逐个映射到 tokenizer 切出来的 token 序列上。常见做法是直接按字符级标注用 tokenizer 的 offset mapping 做映射或者干脆把标签复制到该词的所有子 token 上。后一种做法实现简单评估时再做一次合并就行。from transformers import BertTokenizerFast tokenizer BertTokenizerFast.from_pretrained(bert-base-chinese) label2id {O: 0, B-time: 1, I-time: 2} # 示意实际按你的标签集合扩展 def encode_with_slot_labels(text, slot_labels): encoded tokenizer( text, truncationTrue, max_length128, return_offsets_mappingTrue, return_tensorspt ) offsets encoded[offset_mapping][0] slot_ids [] char_idx 0 for offset in offsets: start, end offset[0].item(), offset[1].item() if start 0 and end 0: slot_ids.append(-100) # special token 不参与 loss continue # 取该 token 覆盖区间内第一个字符的标签子 token 沿用原标签 label slot_labels[char_idx] if char_idx len(slot_labels) else O slot_ids.append(label2id.get(label, 0)) char_idx end encoded[slot_labels] slot_ids return encoded这里把 special token 位置标成 -100因为 PyTorch 的 CrossEntropyLoss 自带 ignore_index 参数-100 位置的 token 不参与损失计算。char_idx 随着 token 的右边界推进保证每个 token 都能拿到对应字符的槽位标签。提醒一句如果句子被 truncation 截断slot_labels 最后一个 token 又超界了这里的if char_idx len(slot_labels)是必须的否则直接越界。3.2 一个共享 BERT 三个输出头领域分类、意图分类、槽位填充的模型写法三个任务并不需要三个独立模型。BERT 的每个 token 都会产出一个向量表示[CLS] 位置的向量可以当作整个句子的语义表示喂给领域分类头和意图分类头其他位置 token 的向量按序列输出喂给槽位分类头。这是一个典型的共享编码器、多任务输出的结构。import torch.nn as nn from transformers import BertModel class JointBERT(nn.Module): def __init__(self, bert_path, num_domains, num_intents, num_slots): super().__init__() self.bert BertModel.from_pretrained(bert_path) hidden self.bert.config.hidden_size self.domain_head nn.Linear(hidden, num_domains) self.intent_head nn.Linear(hidden, num_intents) self.slot_head nn.Linear(hidden, num_slots) def forward(self, input_ids, attention_mask, token_type_idsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) sequence_output outputs.last_hidden_state cls_output sequence_output[:, 0, :] # [B, H] domain_logits self.domain_head(cls_output) intent_logits self.intent_head(cls_output) slot_logits self.slot_head(sequence_output) # [B, L, S] return domain_logits, intent_logits, slot_logits输出头都是单层线性层参数量很小真正干活的是 BERT 本体。domain 和 intent 都用 [CLS] 表示槽位用全序列表示。这里选择不加 CRF 是因为数据量不保证 CRF 能学到可靠的转移约束线性分类头在小规模数据上已经能拿到足够好的边界。如果你的训练数据有十万条以上可以再考虑把 slot_head 换成 CRF 层。3.3 多任务损失怎么融合直接相加不等于最优三个输出各自算交叉熵加起来当作总损失。常见做法是加权求和并且在初期把领域分类的权重调高。原因很现实领域判断错了意图和槽位基本全错领域任务在对话系统里其实是后面所有逻辑的前置条件。我给三个 loss 的默认权重是领域 1.0、意图 1.0、槽位 1.0先跑基线看验证集趋势再调。import torch from torch.utils.data import DataLoader def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss 0.0 for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) domain_labels batch[domain_labels].to(device) intent_labels batch[intent_labels].to(device) slot_labels batch[slot_labels].to(device) domain_logits, intent_logits, slot_logits model( input_ids, attention_mask ) loss_fn nn.CrossEntropyLoss(ignore_index-100) domain_loss loss_fn(domain_logits, domain_labels) intent_loss loss_fn(intent_logits, intent_labels) slot_loss loss_fn( slot_logits.permute(0, 2, 1), slot_labels ) loss domain_loss intent_loss slot_loss optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)槽位 loss 需要把 logits 的维度从 [B, L, num_slots] 转成 [B, num_slots, L]因为 CrossEntropyLoss 期望类别维在第二维。loss 直接相加是最省事的融合方式但它假设三个任务难度相当。实际任务里槽位标注的序列长度和标签数都远大于分类任务loss 数值天然偏大不加权重就会让模型主要迁就槽位任务。如果要调最不容易翻车的做法是先跑几个 epoch 看三个任务各自的 loss 下降曲线哪个下得慢就给哪个更高的权重。3.4 训练关键参数拿这一组配置先跑通参数方面我给出一个经过多次项目验证的基线配置适合从几千到十万条规模的中文对话数据。learning_rate 取 2e-5 到 5e-5BERT 微调用太大会导致灾难性遗忘用太小则收敛缓慢batch_size 8 到 32 取决于显存文本短的话直接上 32max_length 64 到 128 足够覆盖绝大多数意图数据不要盲目开 512会拖慢训练还引入大量 padding。epoch 3 到 5 是 BERT 微调的常用区间跑完看验证集指标再决定是否继续。warmup_ratio 0.1 帮助前几步稳定学习率。这组参数在大部分情况下不会出明显问题。想调优先调 learning rate其次动 batch size最后才考虑模型结构。一次只动一个变量记录验证集指标变化这是最快建立体感的路径。4. 避坑数据对齐、损失函数和评估里最容易翻车的几个点4.1 子 token 标签错位一切 bad case 的根源现象训练 loss 在下降验证集槽位 F1 却一直不涨随便看几条预测结果发现槽位实体总是偏一位。原因tokenizer 把原始文本切成了子词而槽位标签仍然按原字符对齐两者长度不一致代码里没有做映射导致标签错位。中文看似没有子词但数字、英文、特殊符号会把词切开一旦出现后面所有标签都会漂移。解决使用 3.1 节里的 offset mapping 对齐方案给每个 token 分配它涵盖的首个字符标签子 token 沿用原词标签。每次预处理后抽几条数据把「token 列表 标签列表」打印出来人工对比这个习惯能省掉大量后期排查时间。4.2 padding 标签当成有效标签参与 loss现象训练时 loss 降得又快又稳但验证集槽位 F1 只有零点几细看发现模型把所有 padding 位置都预测成了 O。原因padding 位置的标签默认是 0如果 label2id 里 O 恰好是 0padding 部分被当成非实体参与损失计算还占序列比例很大模型被大量无信息标签带偏。解决把 padding 位置的标签设为 -100同时保证 O 标签编号不是 -100。CrossEntropyLoss 里设置 ignore_index-100。如果自定义了损失函数同样要处理 ignore_index。检查方法很简单统计一条样本里 -100 的 token 数量再对比实际 special token 数两者应一致。4.3 领域和意图分类头的输入搞错位置现象领域分类的验证准确率正常意图分类的准确率就是上不去而且 bad case 集中在语义相近的意图之间。原因两个分类头都用了 [CLS] 向量但 [CLS] 是整个句子的全局表示对意图这种更细粒度的区分不够敏感尤其在语料表达分歧大的时候。解决先确认意图类别是否互相纠缠例如「订酒店」和「查酒店价格」动作相近人为难分模型当然难分。然后再考虑换表征用 sequence_output 做 mean pooling 或取倒数第二层的 [CLS] 向量往往是提升细粒度分类的简单有效手段。不要一上来换模型先换输入特征。4.4 中文实体边界越过特殊符号现象槽位预测里「明天早上8点」被模型预测为「明天早上」和「8点」两个实体而标注只有 time 一个槽位。原因数字和中文之间被 tokenizer 拆开模型学到的是字面片段而非业务边界加上训练数据里时间表达形式太多样规则性的边界在规模小时学不收敛。解决最直接的方法是把数字类 token 单独做归一化在数据预处理阶段把普遍数字替换成数字占位符如「2025年」→「[NUM]年」缩短表达多样性。更优雅的方案是在实体级别做后处理拿预测的 BIO 序列合并成完整短语后再根据槽位类型修正边界比如 time 槽里数字后面紧跟的时间单位必须收进同一实体。4.5 三个任务的验证指标混在一起看现象训练日志里三个 loss 都在降模型就收工了上线后用户一问「帮我查明天上海到北京的机票」领域和意图都对槽位却把出发地和目的地互换。原因三个任务的难易程度和收敛速度不同联合训练的 loss 值是加权后的总和总 loss 下降不代表每个子任务都达到可用状态。解决训练过程中分别打印三个子任务的验证指标而不是只看总 loss。领域分类看准确率意图分类看准确率和混淆矩阵槽位看实体级 F1。每个子任务都要设一个最低达标线比如槽位 F1 不低于 0.9 才能发布防止总指标掩盖单任务拉胯。停止训练的标准也应该取三个子任务都进入平台期之后。5. 多任务联合优化与评估把三个指标拆开看才能上线5.1 为什么要让三个任务共享同一个 BERT三个独立模型各自训练每个模型参数量差不多但推理时要串行跑三次延迟和显存都翻倍。共享 BERT 的意义不只是省资源更重要的是三个任务可以互相增强表示。比如槽位标注学到了「明天早上」是时间表达这会促使 [CLS] 表示对时间语义更敏感意图分类也会间接受益。多任务训练的本质是在同一个语义空间里做三个子任务互相提供正则化的效果。实践感受是槽位数据的量级通常是意图数据的数倍如果没有槽位任务帮忙意图分类在小数据下更容易过拟合。不过共享参数也有一个隐患任务之间如果有冲突梯度会互相拉扯。领域分类想强调句子主题意图分类想聚焦动作词槽位在强调实体边界理论上它们都在不同维度上抽取信息实际冲突不明显。真遇到收敛异常可以改成两个阶段先免费使用槽位任务训练几轮冻结 BERT 之后只调三个分类头。这种方案在数据极度不平衡时有用平时不需要。5.2 分类看准确率槽位看实体 F1评估方案要和任务本身匹配。领域分类、意图分类属于多分类直接算准确率和 macro-F1。槽位填充是序列标注逐 token 算准确率没有意义因为绝大多数 token 是 O模型即使一个实体没抓到也能拿到很高的 token 准确率。实体级 F1 才是对的指标先把 BIO 序列合并成实体列表比如 [(time, 明天早上), (departure_city, 上海), (destination_city, 北京)]然后按实体精确匹配计算 F1。预测的实体边界只要错一个字就计为预测错误。def merge_slot_spans(slot_ids, id2label, offsets): spans [] start None current_label None for i, slot_id in enumerate(slot_ids): label id2label[slot_id] if label.startswith(B-): if start is not None: spans.append((current_label, start, i - 1)) start i current_label label[2:] elif label.startswith(I-): if start is None: start i current_label label[2:] elif label O: if start is not None: spans.append((current_label, start, i - 1)) start None current_label None if start is not None: spans.append((current_label, start, len(slot_ids) - 1)) return spans把预测的 BIO 序列和真实标注的 BIO 序列都合并成实体跨度列表用元组集合求交集再算 F1。这个函数只吃 slot_ids 和 id2label可以挂在验证循环里对每个 batch 调用。注意标签缩写要和训练时的 label2id 严格一致否则前缀判断出错。很多项目槽位指标上不去查到最后不是模型问题而是评估代码里 BIO 合并逻辑写错了。5.3 验证集应该怎么搭建验证集既要反映真实意图分布又要保证每个意图都有一定样本否则指标波动太大调参失去意义。前面用 stratified_split 按意图分层切分就是这个原因。除了分层还要保证同一句话的语义变体不重复出现在训练集和验证集里。比如「帮我订明天去上海」和「订明天去上海的机票」是两种表达方式算不同样本但「帮我订明天去上海的机票」和「帮我订明天去上海的机票谢谢」则只应保留一个版本否则验证集会看着像过拟合实际是数据泄漏。模拟真实场景时验证集样本应该分配不同渠道来源比如比例上偏用户自由输入而不是对着模板生成的干净句子。模板生成的句子在 BERT 面前太规整验证集看起来高上线遇到口语变体直接败下阵来。这个修为无法在模型侧解决只能在数据侧解决。5.4 训练过程的监督机制训练中监控三个子任务的验证指标需要提前定义好频率每半个 epoch 评估一次比较稳妥。需要注意多任务下总 loss 下降而个别任务不降的可能在这一步最好能自动最小化设定早停条件当槽位 F1 连续两次验证不涨时提前停止即使其他两个任务还在涨。原因很简单槽位标注最依赖边界信息的精确性这是最难优化的任务一旦停止提升其他任务继续训练只是过拟合训练分布。做项目时优先保护最难任务这是一个很主观但有效的经验。6. 把模型接进真实对话系统推理输出、简单部署和下一步6.1 从原始文本到语义框架推理时的输出组装训练完成的模型在推理阶段其实很轻量把一条用户输入经过 tokenizer 编码丢给模型得到三组 logits。取 argmax 得到领域、意图和每个 token 的槽位标签再合并 BIO 序列得到可读的槽位键值对。组装成 JSON 语义框架后上层逻辑直接消费这个 JSON 就能做下一步执行。def predict_and_parse(model, tokenizer, text, device, id2domain, id2intent, id2label): encoded tokenizer(text, return_tensorspt, truncationTrue, max_length128) input_ids encoded[input_ids].to(device) attention_mask encoded[attention_mask].to(device) model.eval() with torch.no_grad(): domain_logits, intent_logits, slot_logits model(input_ids, attention_mask) domain_id domain_logits.argmax(dim-1).item() intent_id intent_logits.argmax(dim-1).item() slot_ids slot_logits.argmax(dim-1).squeeze().tolist() domain id2domain[domain_id] intent id2intent[intent_id] slots merge_slot_spans(slot_ids, id2label, None) return {domain: domain, intent: intent, slots: slots}推理时不再需要 slot_labelsmerge_slot_spans 可以直接基于 token 索引工作。要注意这里 text 是单条输入tokenizer 会在句首句尾插入 [CLS] 和 [SEP]所以 merge 结果的第一项和最后一项都是 special token合并槽位时要跳过 0 和结尾的 special token 位置。这个细节漏掉的话槽位实体会带着一个偏移量错位到句外是你排错时最容易忽略的部分。6.2 模型版本切换让服务安静完成更新模型训练好后要落到线上这里有一个经常被忽视的工程问题模型更新策略。如果直接把新模型文件替换旧服务里的加载路径正在进行的会话会中断甚至因为新旧模型输出格式稍有差异而出现空指针。常见做法是保留两套模型文件把推理服务做成可配置加载路径先只加载新模型做压测再通过流量切分逐步把新请求路由到新版。这里的关键是模型版本号管理和加载缓存让同一个模型文件只加载一次而不是每次请求都重新加载否则上线高峰会带来明显的延迟抖动。我自己的习惯是训练完成后固定保存三个产物模型权重、tokenizer、标签映射 JSON。标签映射 JSON 是最容易丢的重新训练的时候没人记得 id 和 label 的对应关系模型权重和标签映射一旦失配就是一场灾难。把 id2domain、id2intent、id2label 保存好部署时就不用重新生成。6.3 往这个方向继续深挖的三个去处对话系统这条线上意图识别只是入口后面还能做对话状态追踪、知识库检索、执行动作生成。如果对人工智能项目实践和 ai 相关岗位感兴趣这套流程已经覆盖了数据标注、模型微调、评估验证、部署上线的完整链路可以作为独立项目写进履历。往下走可以从这三个点继续一是用更轻量的模型替换 BERT比如蒸馏版或直接训练字级模型把推理时延降下来二是给槽位填充引入词典约束保证地点、航班号等实体不出现标签边界断裂三是把单轮数据扩成多轮把上文槽位并入当前轮模型输入让系统能完整处理指代和省略。这三件事做完这套系统就不再是课程作业水平而是接近产品原型。我自己最深的教训是每天训练出的模型都要手动挑 10 条 bad case 看一遍因为验证集指标再高真实用户的写法永远会超出预期比如「帮我看看明天上海有没有下雨」「上海那边明天天气咋样」这俩句子领域意图都对但表达方式完全不在模板里。尽早暴露这类差异比上线后让用户帮我们暴露要划算太多。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询