ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

实体关系抽取Pipline实战:BiLSTM+CRF与BERT两阶段搭建与调优

实体关系抽取Pipline实战:BiLSTM+CRF与BERT两阶段搭建与调优 简介面向有一定NLP基础的开发者与研究者实体关系抽取项目采用BiLSTMCRF与BERT的pipeline方式实现完整覆盖实体识别、关系分类到知识图谱三元组构建的流程适合作为序列标注与文本分类的进阶参照。资源包共29个文件核心为17个Python脚本涵盖模型定义、数据预处理、训练评估与主流程另含4个Json关系配置、4个zbak备份文件、README与requirements依赖清单整体约40KB已有30人浏览学习目录按功能模块划分便于定位与二次开发。实体识别阶段利用双向LSTM分别捕获前向与后向上下文特征并由CRF对标签转移概率建模有效避免非法标签序列提升实体边界识别与类型判定的准确率关系分类借助BERT的深层语义表征进行实体对关系映射可参考SemEval-2010任务8等标准数据集的评测思路开展验证。抽取得到的三元组可直接转化为知识图谱中的节点与边例如“马斯克-创办-SpaceX”这类事实知识为智能问答和语义搜索提供数据支撑。这套代码提供可复现的训练管线与模块化设计能直接服务于结构化知识库构建也可将关系推理模块替换为图神经网络等新方法为后续研究留出扩展空间。1. 实体关系抽取这条 pipline卡点从来不在模型而在对齐如果你做过知识图谱入库或者合同文本的结构化抽取大概率绕过不了一个任务把“张三在 2021 年入职某某科技任算法工程师”这句话抽成(张三, 任职于, 某某科技)和(张三, 担任, 算法工程师)这样的三元组。这个任务叫实体关系抽取而工程里最常见的落地形态就是一个“先找实体、再判关系”的两阶段 pipline前一级用序列标注模型做命名实体识别后一级对实体对做关系分类。标题里的 BiLSTMCRF 和 BERT代表的正是 NER 阶段两条最主流的技术路线。我见过不少朋友单跑 NER 时准确率很高单跑关系分类时效果也不差一旦把两个模型串成一条完整的实体关系抽取 pipline整体指标就莫名掉一截。排到最后绝大多数问题都不在模型本身而在两个阶段之间的实体边界对齐、句子截断策略、标签体系一致性这些工程细节。这篇文章会从选型理由开始把 NER 训练、关系分类构造、两阶段串联的完整实现路径讲清楚最后落在验证和调优上给一份能直接照着搭的基于 BiLSTMCRF 与 BERT 的实体关系抽取 pipline 方案。2. 实体识别选型BiLSTMCRF 与 BERT 在 NER 层的分工与取舍2.1 为什么二阶段关系抽取普遍保留 BiLSTMCRF 这条线端到端联合抽取在论文里指标很好看但实际做知识图谱构建、文档结构化时多数团队还是采用两阶段 pipline。原因很现实联合模型把实体识别和关系分类耦合在同一个网络里一次推理同时输出实体和关系看起来省事但训练时需要三元组级别的标注数据配合而且实体识别错误会直接污染关系预测很难单独定位是哪一级出了问题。分开做的两个模型可以各自调参、各自迭代、各自替换这对生产系统太重要了。BiLSTMCRF 能长期留在 NER 这条线上靠的不是花哨而是资源效率和高可控性。BiLSTM 负责建模上下文把每个 token 编码成包含前后文信息的向量CRF 层负责在解码阶段约束标签之间的转移。比如B-PER后面跟I-ORG这种非法转移CRF 在训练中会自动压低它的转移分数。这种结构对中文实体抽取足够用推理时不需要额外的预训练模型CPU 上也能跑显存不够的团队完全可以用它把 pipline 先跑通。我一般会建议团队在搭实体关系抽取 pipline 的第一版时NER 阶段直接上 BiLSTMCRF而不是一上来就挂 BERT。原因不复杂第一版要解决的是“整条链路能不能跑通、标注数据够不够、边界对齐方案合不合理”这些问题和用不用 BERT 没关系。拿 BiLSTMCRF 做基线把数据清洗、标注、评估的流程理顺之后再替换成 BERT 编码器看收益是最稳妥的推进方式。2.2 BERT 在 pipline 里到底替换掉了哪一层以及 bilstm 代码怎么改BERT 进入这条 pipline 后替换的是输入侧和编码侧不是解码侧。常见做法有两种一种是用 BERT 直接替换 BiLSTM变成 BERTCRF这种结构最简单另一种是保留 BiLSTM变成 BERTBiLSTMCRF用 BiLSTM 对 BERT 输出做一次轻量序列建模。第二种在样本量不大时往往比直接 BERTCRF 更稳因为 BiLSTM 能对 BERT 输出的高维特征做一次压缩减少过拟合。从 bilstm 代码改造的角度看改动其实很小。原来输入是随机初始化的词向量或者字向量现在把输入层换成 BERT 的 token 序列输出即可。值得注意的是BERT 输出的 hidden_state 通常是 768 维或 1024 维直接接 CRF 不是不行但参数量大且训练慢所以我通常会在中间加一层单向压缩class BertBilstmCrf(nn.Module): def __init__(self, bert_dir, num_tags, lstm_hidden128, dropout0.1): super().__init__() self.bert AutoModel.from_pretrained(bert_dir) self.dropout nn.Dropout(dropout) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, bidirectionalTrue, ) self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, token_type_ids, labelsNone): bert_out self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids, ).last_hidden_state sequence_out, _ self.bilstm(bert_out) logits self.fc(self.dropout(sequence_out)) if labels is not None: loss -self.crf(logits, labels, maskattention_mask.bool()) return loss return self.crf.decode(logits, maskattention_mask.bool())这里lstm_hidden我一般取 128不要为了“更大更强”设成 512。因为 BERT 已经做了主要的上下文建模BiLSTM 在这里只是辅助hidden 过大反而增加过拟合风险。dropout取 0.1 到 0.3 之间样本少时调大一点有明显效果。代码里的crf直接用了 torchcrf 库的实现它的forward返回负对数似然所以取负号作为 loss。还有一个实际问题是 bert 模型参数的下载。很多人卡在从 Hugging Face 下载权重后pytorch_model.bin和vocab.txt的版本对不上加载时直接报错。我的习惯是下载后先检查目录里是否有config.json、vocab.txt和pytorch_model.bin三个文件并且用AutoModel.from_pretrained走一遍前向确认输出维度符合预期再进训练能省掉很多排查时间。2.3 数据标注策略BIO vs BIOES关系抽取场景为什么我不推荐 BIONER 的标签体系常见有 BIOBegin/Inside/Outside和 BIOESBegin/Inside/Outside/End/Single两种。很多开源代码默认用 BIO因为它简单直观标注工作量小。但在实体关系抽取的 pipline 里我更推荐 BIOES原因和关系分类的候选生成直接相关。关系分类阶段要为每个实体对构造输入需要精确知道实体的边界。BIO 体系下一个实体的结束位置要靠“遇到下一个 B 或 O”来判断这对模型预测稍微宽容但会对齐代码增加复杂度。BIOES 把实体的结尾显式标成E-类型单字实体标成S-类型边界信息在标签序列里是完备的后面实体对齐代码可以写得非常干净。从转换脚本的角度看从实体列表生成 BIOES 标签比 BIO 多一个判断分支但逻辑并不复杂def assign_bioes(labels, start, end, ent_type): if end - start 1: labels[start] fS-{ent_type} else: labels[start] fB-{ent_type} for i in range(start 1, end - 1): labels[i] fI-{ent_type} labels[end - 1] fE-{ent_type}这段代码把原始文本的字符级 span 映射成 BIOES 标签序列。start和end是实体的起止位置注意这里end是开区间也就是不含end这个下标。单字实体打S-多字实体分别打B-、I-、E-。实际项目里还要处理多个实体重叠的情况我的处理原则是谁先出现谁占位重叠部分后出现的实体直接丢弃因为大多数业务场景并不需要嵌套实体。3. 搭 NER 训练管线BIOES 标注转换、CRF 解码与 bilstm 代码即用3.1 把原始语料转成 token 级标签序列的转换脚本NER 训练的第一步是把“原始文本 字符级实体 span”转成“token_ids token 级标签”。这一步看似简单却是整条 pipline 里最容易埋雷的地方尤其是引入 BERT 之后分词器会把英文单词、数字、标点切成子词一个字符 span 可能落在半个 token 上。我的做法是直接用 tokenizer 返回的offset_mapping它记录了每个 token 对应原文本的字符区间用它把字符级 span 映射到 token 级 span可以同时兼容中文、英文和混合文本def convert_span_to_token_labels(text, spans, tokenizer, label2id, max_len128): encoding tokenizer( text, return_offsets_mappingTrue, truncationTrue, max_lengthmax_len, ) offsets encoding[offset_mapping] num_tokens len(offsets) token_labels [O] * num_tokens for span_start, span_end, ent_type in spans: start_idx, end_idx None, None for i, (token_start, token_end) in enumerate(offsets): if token_start 0 and token_end 0: continue if start_idx is None and token_start span_start token_end: start_idx i if end_idx is None and token_start span_end token_end: end_idx i if start_idx is not None and end_idx is not None: break if start_idx is None or end_idx is None: continue assign_bioes(token_labels, start_idx, end_idx, ent_type) label_ids [label2id[label] for label in token_labels] return encoding[input_ids], label_ids这个函数的核心思路是遍历 tokenizer 返回的offset_mapping找到实体字符 span 的起始 token 和结束 token再调用前面写的assign_bioes打标签。token_start 0 and token_end 0是过滤[CLS]、[SEP]和 padding 的通用判断因为它们的 offset 都是(0, 0)。跑这个脚本很容易发现一个现象实体刚好落在截断边界上时start_idx或end_idx会是None代码会直接把这段样本跳过。很多人的做法是静默 continue结果训练集里这些样本完全没有梯度预测时自然翻车。我的建议是至少打印一条 warning统计一下这种样本的数量如果占比超过 1%优先在数据侧解决截断策略而不是靠模型硬学。3.2 模型定义BERTBiLSTMCRF 在 PyTorch 中的最小完整实现前面已经给出了模型核心类这里把训练和预测的调用方式补全因为 CRF 的用法和普通分类模型差异很大新手最容易在这里踩坑。以 PyTorch 为例训练循环里对每个 batch 只拿到一个标量 loss然后正常 backward 即可但必须注意 CRF 层依赖 mask 来忽略 padding 位置的分数。model BertBilstmCrf(bert_dirbert-base-chinese, num_tagslen(label2id)) optimizer AdamW(model.parameters(), lr2e-5) for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) token_type_ids batch[token_type_ids].to(device) labels batch[labels].to(device) loss model(input_ids, attention_mask, token_type_ids, labels) optimizer.zero_grad() loss.backward() optimizer.step()AdamW的学习率我一般设2e-5到5e-5这个范围对 BERT 微调比较稳。如果用的是纯 BiLSTMCRF 而没有 BERT学习率可以放宽到1e-3量级。CRF 层的参数和 BERT 参数混在一起更新实践中没问题但如果你想精细控制可以把 CRF 参数单独分组设一个稍大的学习率比如0.01收敛会更快一些。推理阶段和训练不一样的地方在于CRF 要用 Viterbi 解码而不是直接取 argmax。torchcrf 提供了现成的decode方法它接收logits和mask返回每个样本的最优标签序列with torch.no_grad(): decoded model(input_ids, attention_mask, token_type_ids)这里model在forward中没有传labels直接走decode分支返回的是一个 listlist 里每个元素是该样本的标签 id 序列。注意decode返回的序列已经去掉了 padding但长度和原始输入 token 数一致后面做实体对齐时直接用即可。3.3 训练与预测CRF 损失函数和 Viterbi 解码的参数细节CRF 的核心思想是计算整个标签序列的联合概率而不是每个 token 独立预测。训练时模型会计算所有可能标签路径的分数之和forward score再计算真实标签路径的分数gold score二者的差作为损失。torchcrf 的forward方法做的就是这个计算返回的是负对数似然所以我们在代码里加了负号。有个细节必须提如果自己手写 CRF 层最容易出问题的就是 mask 没有正确广播。torchcrf 的mask参数要求是布尔型True表示这个位置是有效 tokenFalse表示 padding。在计算转移分数和发射分数时padding 位置不参与路径计算否则会出现训练时 loss 正常、预测时结果全乱的现象。Viterbi 解码的参数细节主要在边界条件上。序列开头只能从O或B-*开始序列结尾不能落在I-*上这些约束都编码在转移矩阵里。实际推理时还有一个常见坑如果预测出的标签序列在实体中间就结束了比如B-PER后面直接到序列末尾后处理时要算这个实体结束位置是len(seq)-1不要因为找不到E-PER就丢弃整个实体。4. 关系分类与 pipline 串联候选生成、输入拼接与对齐实现4.1 关系分类输入怎么构造把实体位置传给 BERT 的三种常见姿势实体识别完成之后关系分类模型需要一个输入让它知道当前要判断的是哪两个实体。直接把整句话丢给 BERT 而不管实体位置模型无法区分谁是主体谁是客体关系分类也就无从谈起。业界常见的做法有三种。第一种是实体替换法把实体文本替换成[MASK]或[unused1]这样的特殊标记。这种做法简单直接模型通过特殊标记的位置和内容来感知实体缺点是替换后原文信息有损失实体本身的语义内容可能对关系判断有用。第二种是 segment embedding 法用 token_type_ids 来区分实体位置。比如定义 0 表示普通 token1 表示 head 实体区间2 表示 tail 实体区间。但这里有个隐藏坑BERT 原生的 token_type_embedding 只支持 0 和 1 两类想用 2 需要自己扩展 embedding 层很多初学的人在这里直接报 index out of range。第三种是目前效果最稳的做法在实体前后插入特殊标记。具体是 head 实体的前和后插入[unused1]、[unused2]tail 实体的前和后插入[unused3]、[unused4]。四个特殊标记把实体边界框出来模型能精确知道两个实体的起止位置同时保留实体原文效果通常比前两种好。def build_relation_input(text, head_span, tail_span, tokenizer, max_len128): tokens list(text) special_tokens { head_start: [unused1], head_end: [unused2], tail_start: [unused3], tail_end: [unused4], } inserted_tokens [] for i, char in enumerate(tokens): if i head_span[0]: inserted_tokens.append(special_tokens[head_start]) if i head_span[1]: inserted_tokens.append(special_tokens[head_end]) if i tail_span[0]: inserted_tokens.append(special_tokens[tail_start]) if i tail_span[1]: inserted_tokens.append(special_tokens[tail_end]) inserted_tokens.append(char) return tokenizer( .join(inserted_tokens), max_lengthmax_len, truncationTrue, paddingmax_length, )这段代码里head_span是(start, end)形式end是开区间。插入特殊标记后再交给 tokenizer后面的分类头和普通文本分类没有区别。需要特别注意的是中文场景按字插入没问题但英文场景要按 token 处理不能直接按字符插入否则特殊标记会被 BERT 分词器拆得不成样子。4.2 候选实体对生成与负采样两阶段 pipline 的搭法实体识别模型输出的是一组实体列表关系分类需要对实体对逐一判断。最朴素的做法是把同一句话里的所有实体两两配对全部作为候选。假设一句话里有 5 个实体就有 20 个有序实体对。但绝大多数实体对之间并没有关系如果不做负采样关系分类模型的训练数据会严重不平衡。我一般采用的负采样策略是全部正样本保留负样本从非关系实体对里随机采样控制正负比例在 1:3 到 1:10 之间。这个比例不是玄学太小模型学不到区分能力太大模型会倾向把所有样本都判成负例。实际调参时可以先从 1:5 开始看验证集上的 F1 再调整。候选生成的代码并不复杂但要注意过滤掉无效对。比如两个完全重叠的实体、同一实体自身构成的对这些都要剔除否则会给训练数据注入噪声def generate_candidates(entities): candidates [] for i in range(len(entities)): for j in range(len(entities)): if i j: continue head entities[i] tail entities[j] if head[start] tail[start] and head[end] tail[end]: continue candidates.append({ head: head, tail: tail, }) return candidatesentities是 NER 阶段输出的实体列表每个实体包含start、end、type和text四个字段。这种结构化的表达方式在 pipline 里非常重要因为后续构造关系输入时需要快速按位置插入特殊标记同时也要在模型中区分实体类型对关系判断的影响。4.3 打通两个阶段的实体对齐span 索引对齐、截断后重定位和空指针防护两阶段 pipline 最常见的坑是 NER 输出的 token 级 span 和关系分类需要的字符级 span 对不上。BERT 分词后一个中文字符通常对应一个 token但英文单词、数字、emoji 会对应多个 token如果直接把 NER 输出的 token 下标当字符下标用在混合文本里必然出错。解决思路是建立一套统一的坐标体系。NER 训练时我就用offset_mapping把字符级 span 转成了 token 级 span预测时需要反向操作把 token 级标签转回字符级 span。好在 BERT tokenizer 的offset_mapping在预测时同样可用只要把同一句话重新 tokenize 一次就能拿到每个 token 对应的字符区间。def extract_entities_from_labels(text, token_ids, label_ids, tokenizer): encoding tokenizer(text, return_offsets_mappingTrue) offsets encoding[offset_mapping] entities [] i 0 while i len(label_ids): label label_ids[i] if label.startswith(B-): ent_type label[2:] j i 1 while j len(label_ids) and label_ids[j] fI-{ent_type}: j 1 if j len(label_ids) and label_ids[j] fE-{ent_type}: start_char offsets[i][0] end_char offsets[j][1] entities.append({ start: start_char, end: end_char, type: ent_type, text: text[start_char:end_char], }) i j 1 continue elif label.startswith(S-): ent_type label[2:] start_char offsets[i][0] end_char offsets[i][1] entities.append({ start: start_char, end: end_char, type: ent_type, text: text[start_char:end_char], }) i 1 return entities这段代码把模型预测的标签序列还原成实体字典列表offsets提供了从 token 下标到字符下标的映射。注意遍历时用while而不是for因为在B-到E-的区间内需要跳过内部的所有I-token用for会让 i 重复走到已处理的位置。两阶段串联时还要处理截断问题。训练时句子被截断到 128 或 256但预测时如果句子长度超过限制实体的后半段会被直接切掉。我的做法是预测时动态延长 max_len比如长文本用 512再不行就按句子切分。实体的跨段问题很复杂目前没有完美解法我的经验是优先保证句子完整而不是死守 128 的长度。5. 实体关系抽取常见问题与避坑训练-预测不一致与显存陷阱5.1 预测结果出现非法标签序列比如 B-ORG 紧接 I-PER现象是训练时 loss 正常下降但预测出来的标签序列里出现明显违法的组合比如B-ORG后面直接跟I-PER或者O后面跟I-PER。原因是 CRF 层在解码时的约束没有生效。最常见的情况是训练时用的 CRF 带了 mask但预测时忘记传 mask导致 padding 位置参与了 Viterbi 解码转移分数被 padding 位置的填充值干扰。解决方法是检查推理代码确保decode时传入和训练时一致的attention_mask。另外如果 CRF 层是手写的检查是否实现了完整的转移矩阵约束只做了发射分数 argmax 的写法已经不属于 CRF 了。5.2 验证集 F1 很高线上抽取效果一塌糊涂现象是模型在验证集上实体关系 F1 超过 0.85但跑到真实业务数据上明显漏抽和错抽。原因是数据泄漏。很多人的训练语料来自同一批文档切分 train 和 valid 时按句子随机切分导致同一篇文档的上下文句子分别出现在训练集和验证集里模型实际是记住了文档特有的表达方式。解决方法是按文档切分数据保证同一文档的所有句子只出现在一个集合里。如果一份文档太长需要分段也要确保分段后的片段不跨集合。这是实体关系抽取 pipline 里最容易被忽略、影响却最大的一个数据问题。5.3 BERT 权重参数加载时报错显示 checkpoint 不匹配现象是AutoModel.from_pretrained加载模型时报维度不匹配或者 key 不存在常见报错是Some weights of BertModel were not initialized from the model checkpoint。原因是下载的pytorch_model.bin和config.json、vocab.txt不是同一版本的权重。Hugging Face 上很多模型仓库更新过 vocab但本地缓存的 config 没有同步更新就会导致 embedding 维度对不上。解决方法是优先使用snapshot_download完整拉取整个仓库不要单独手动下载单个文件。如果已经出了这个问题删掉本地缓存重新下载同时确认tokenizer_config.json中的model_max_length和实际使用是否一致。5.4 关系分类模型永远只输出“无关系”这一种标签现象是训练完成后关系分类在验证集上对所有候选对都预测为负例正例一个都出不来。原因是正负样本比例失衡太严重或者负采样方式有问题。如果数据集中 95% 以上的实体对都无关系模型收敛后天然倾向于把样本判成多数类以获得最低的 loss。解决方法是控制负采样比例同时计算验证指标时不要只看准确率要关注召回率和 F1。另一个技巧是在 loss 里给正样本加权重比如pos_weight3.0可以在不改采样逻辑的情况下缓解不平衡问题。5.5 实体在截断边界上反复丢失怎么调都抽不全现象是预测时实体列表总比标注少分析坏案例发现丢失的实体大多出现在长句的尾部。原因是max_length截断把实体切掉了。BERT 的 max length 到 512 已经是极限但很多业务文档句子超过 200 字非常常见512 也不一定够。解决方法是先用分句工具把长文本切分成句子再逐句做实体抽取。如果实体本身跨句子目前两阶段 pipline 基本无解只能靠规则把跨句实体合并。这个问题的取舍在于切分句子会丢失跨句实体不切分句子会丢尾部实体我一般先按句子切分再结合业务规则做跨句合并。6. 验证与调优技巧用交叉验证和坏案例体系定位 pipline 上限实体关系抽取和普通分类任务不一样最终评价的是三元组的质量所以验证时要用实体级指标而不是 token 级指标。我常用的验证方法是先按文档 K 折切分对每一折单独训练 NER 和关系分类再把两阶段的输出串起来算端到端 F1。这样得到的是一个真实可上线的 pipline 指标而不是两个模型各自指标的平均。具体计算端到端指标时要注意匹配规则。实体级匹配分严格匹配和宽松匹配两种严格匹配要求头实体、尾实体、关系类型三者全部一致才算对宽松匹配允许边界相差一两个字。我的经验是两者都算严格 F1 作为上线门槛宽松 F1 作为优化参考。如果宽松比严格高很多说明模型的主要问题出在实体边界上优先去调 NER 的 BIOES 标签和截断逻辑如果两者相差不大说明问题主要在关系分类层面。坏案例体系是我排查 pipline 最依赖的工具。具体做法是把预测错误的三元组按原因分成三类实体识别错、边界错、关系判断错每类统计占比。实体识别错指的是实体类型判错比如把人名判成公司名边界错指的是实体起止位置偏了关系判断错指的是实体都识别对了但关系分类错了。做过两三个项目后我的体感是绝大多数情况边界错占大头这时不要去调关系分类模型而是回头检查数据标注和 NER 的 CRF 约束调整效果会立竿见影。推理性能方面如果 pipline 要走线上服务BERT 编码器往往是瓶颈。我的习惯是先用 BiLSTMCRF 做基线上线然后把 NER 的 BERT 编码器蒸馏成一个轻量模型。关系分类阶段如果候选对数量大可以先做一个粗筛分类器把明显无关系的对直接过滤掉再对保留的对跑 BERT这样整体延迟可以压到原来的三分之一以下。还有一个投入产出比很高的技巧把实体类型作为关系分类模型的输入特征。比如头实体是人名、尾实体是公司可以优先判断“任职于”这类关系头实体是公司、尾实体是地点可以优先判断“位于”这类关系。具体实现上把实体类型 embedding 拼到 [CLS] 向量后面维度没有对齐问题实现成本极低但对指标提升通常非常明显。最后分享一个我自己的习惯每跑通一版 pipline先固定 20 条带标注的真实业务样本作为回归测试集。每次改完模型结构或者数据策略都先在这 20 条样本上跑一遍看有没有引入新的边界问题。这个习惯帮我避免了好几次“NER 指标涨了但端到端指标跌了”的尴尬也让我在调参时能很快判断改动是正向还是反向。一套实体关系抽取 pipline 从搭起来到稳定跑业务真正花时间的不是模型结构选型而是这些验证和回归的细节希望帮到你。本文还有配套的精品资源点击获取
返回列表