1 模型升级迭代需求背景介绍
众所周知,在人工智能领域,一些训练好的模型会随着时间推移不断优化,数据集也在不断迭代。例如某公司的人脸识别系统因为新员工的入职,老员工的离职要进行模型的升级迭代;又或者某分类模型在上线之初由于训练集不完善,识别率并不高,但是在实际使用过程中不断有用户进行问题反馈,进而不断对模型进行完善,提高模型最终的准确率。
本节以Kaggle平台非常著名的项目Dogs vs. Cats为例,在本地进行模型训练,并将预测模型部署到Serverless架构,通过用户反馈进行模型的升级。
如图5-1所示,整个基本流程为:开发者在本地进行猫狗识别模型的构建与训练,之后将预测功能、训练功能以及用户反馈功能部署到线上。
·预测功能:通过用户上传的图片,根据当前模型进行二次分类判断,确定图片是猫还是狗。
·训练功能:建模后对模型进行训练,但是线上训练有一定特殊性,会加载当前模型,并在当前模型的基础上进行模型的训练和完善。
·用户反馈功能:用户可以针对模型的预测结果告知系统这个预测是否正确,例如用户上传了一只狗的照片,系统预测为猫,那么用户可以反馈为狗,同时该功能模块将图片与用户的反馈记录到系统中,参与下次训练。
图5-1 猫狗识别案例设计简图
5.2.2 猫狗识别项目训练
Kaggle是由联合创始人、首席执行官安东尼·高德布卢姆(Anthony Goldbloom)于2010年在墨尔本创立的,为开发商和数据科学家提供了举办机器学习竞赛、托管数据库、编写和分享代码的平台。
如图5-2所示,多年前,Kaggle平台上线了一个名为Dogs vs. Cats的比赛项目。时至今日,该项目成为人工智能学习过程中非常经典的案例之一。该项目使用的猫狗分类图像共25000张,猫、狗均有12500张。
如图5-3所示,可以发现猫狗的姿态不一,有的站着,有的眯着眼睛,有的和其他可识别物体比如桶、人混在一起。同时,图像尺寸也不一致。因此,数据预处理变得非常重要。
图5-2 Kaggle上的猫狗识别项目
图5-3 猫狗识别案例的数据集预览
1.数据预处理
原数据的命名规则是“类别.编号.格式”,例如cat.73.jpg。为了便于数据的后期处理,我们可以将猫与狗的图像进行归类:
import os import shutil import base root_path = base.SOURCE_ROOT train_path = base.SOURCE_ROOT + '/train' def copyFile(srcfile, dstfile): if os.path.isfile(srcfile): path, name = os.path.split(dstfile) # 分离文件名和路径 if not os.path.exists(path): os.makedirs(path) # 创建路径 shutil.copyfile(srcfile, dstfile) # 复制文件 print("copy %s -> %s" % (srcfile, dstfile)) for root, dirs, files in os.walk(train_path, True, None, False): # 遍历目录 for f in files: if os.path.isfile(os.path.join(root, f)): lable, label_id = os.path.splitext(f)[0].split('.') base_path = './strengthen' if int(label_id) > 5000 else './train' img_path = os.path.join(root, f) target_path = base_path + ("/cat" if lable == 'cat' else "/dog") if not os.path.exists(target_path): os.makedirs(target_path) # 创建路径 copyFile(img_path, os.path.join(target_path, f))
图5-4 猫狗识别案例项目数据集目录结构
由于猫和狗的图像数据均为12500份,而我们不仅要通过这些数据进行初始模型的训练,还要模拟测试后期进行模型的完善,所以在进行数据归类时,只把id小于5000的图像用于训练,id大于5000的图像用于后期的模型完善,结果如图5-4所示。
完成上述的简单归类之后,为了有效使用内存资源,我们使用tfrecord对图像进行存储:
_float_feature = lambda value: tf.train.Feature(float_list=tf.train.Float-List(value=[value] if not isinstance(value, list) else value)) _int_feature = lambda value: tf.train.Feature(int64_list=tf.train.Int64- List(value=[value] if not isinstance(value, list) else value)) _bytes_feature = lambda value: tf.train.Feature(bytes_list=tf.train.Bytes-List(value=[value] if not isinstance(value, list) else value)) getFolderName = lambda folder: sorted([x for x in os.listdir(folder) if os.path.isdir (os.path.join(folder, x))]) get_file_name = lambda folder: [x for x in map(lambda x: os.path.join(folder, x), os.listdir (folder)) if os.path.isfile(x)] def convert2Tfrecord(mode, anno): assert mode in MODES, "Mode Error" filename = os.path.join(FLAGS.save_dir, mode + '.tfrecords') with tf.python_io.TFRecordWriter(filename) as writer: for fnm, cls in tqdm(anno): # 读取图像、转换 img = transform.resize(color.rgb2gray(io.imread(fnm)), [224, 224]) # 获取转换后的信息 if 3 == img.ndim: rows, cols, depth = img.shape else: rows, cols = img.shape depth = 1 example = tf.train.Example( features=tf.train.Features( feature={ 'image/height': _int_feature(rows), 'image/width': _int_feature(cols), 'image/depth': _int_feature(depth), 'image/class/label': _int_feature(cls), 'image/encoded': _bytes_feature(img.astype(np.float32).toby-tes()) } ) ) # 序列化并保存 writer.write(example.SerializeToString()) def getAnnotations(directory, classes): files = [] labels = [] for ith, val in enumerate(classes): fi = get_file_name(os.path.join(directory, val)) files.extend(fi) labels.extend([ith] * len(fi)) assert len(files) == len(labels), "The number of pictures and labels varies" annotation = [x for x in zip(files, labels)] random.shuffle(annotation) return annotation def main(_): annotation = getAnnotations(FLAGS.directory, getFolderName(FLAGS.directory)) convert2Tfrecord(tf.estimator.ModeKeys.TRAIN, annotation[FLAGS.test_size:]) convert2Tfrecord(tf.estimator.ModeKeys.EVAL, annotation[:FLAGS.test_size])
2.模型构建
使用tf.layer进行模型构建,例如创建一个简单的CNN网络,首先声明结构:
def model(inputs, mode): net = tf.reshape(inputs, [-1, 224, 224, 1]) net = tf.layers.conv2d(net, 32, [3, 3], padding='same', activation=tf.nn.relu) net = tf.layers.max_pooling2d(net, [2, 2], strides=2) net = tf.layers.conv2d(net, 32, [3, 3], padding='same', activation=tf.nn.relu) net = tf.layers.max_pooling2d(net, [2, 2], strides=2) net = tf.layers.conv2d(net, 64, [3, 3], padding='same', activation=tf.nn.relu) net = tf.layers.conv2d(net, 64, [3, 3], padding='same', activation=tf.nn.relu) net = tf.layers.max_pooling2d(net, [2, 2], strides=2) net = tf.reshape(net, [-1, 28 * 28 * 64]) net = tf.layers.dense(net, 1024, activation=tf.nn.relu) net = tf.layers.dropout(net, 0.4, training=(mode == tf.estimator.ModeKeys.TRAIN)) net = tf.layers.dense(net, FLAGS.classes) return net
然后对该网络进行操作,包括创建网络、创建Loss对象、获取训练准确度、可视化训练准确度等:
tf.summary.image('images', features) logits = model(features, mode) predictions = { 'classes': tf.argmax(input=logits, axis=1), 'probabilities': tf.nn.softmax(logits, name='softmax_tensor') } if mode == tf.estimator.ModeKeys.PREDICT: return tf.estimator.EstimatorSpec(mode=mode, predictions=predictions) loss = tf.losses.softmax_cross_entropy(onehot_labels=labels, logits=logits, scope= 'loss') tf.summary.scalar('train_loss', loss) train_op = tf.train.AdamOptimizer(learning_rate=1e-3).minimize(loss, tf.train. get_or_create_global_step()) if mode == tf.estimator.ModeKeys.TRAIN else None accuracy = tf.metrics.accuracy(tf.argmax(labels, axis=1), predictions['classes'], name='accuracy') accuracy_topk = tf.metrics.mean(tf.nn.in_top_k(predictions['probabilities'], tf.argmax(labels, axis=1), 2), name='accuracy_topk') tf.summary.scalar('train_accuracy', accuracy[1]) tf.summary.scalar('train_accuracy_topk', accuracy_topk[1]) return tf.estimator.EstimatorSpec(mode=mode, predictions=predictions, loss=loss, train_op=train_op, eval_metric_ops={'test_accuracy': accuracy, 'test_accuracy_topk': accuracy_topk})
3.模型训练
模型训练时,需要给定训练退出条件。在该模型中的训练退出条件是模型在之后连续5次训练没有准确率的提升,则认为该模型已经达到最优。当然在实际生产过程中,不同情况可能需要不同的模型训练退出条件,例如除了本次试验的训练退出条件之外,常见的退出条件还有如下几种。
·模型训练指定的次数。
·模型训练的准确度超过某预期数据。
# 创建状态 step = 0 status = 5 max_accuracy = 0 while status: step = step + 1 model.train(input_fn=lambda: inputFn(tf.estimator.ModeKeys.TRAIN, FLAGS.batch_size), steps=FLAGS.steps) eval_results = model.evaluate(input_fn=lambda: inputFn(tf.estimator.ModeKeys.EVAL)) status = status - 1 if eval_results['test_accuracy'] > max_accuracy: status = 5 max_accuracy = eval_results['test_accuracy'] print("-" * 10, step, "-" * 10) print("max_accuracy: ", max_accuracy) print("status_count: ", status) print("-" * 23)
通过训练,可以看到结果如下所示。
图5-5 本地模型训练结果
从图5-5可以看到,模型在训练7轮后停止,且在第2轮时,准确度达到了一个短期内的最优值。
我们通过TensorBoard可以看到训练过程中的一些图像的信息,如图5-6所示。
图5-6 训练过程中TensorBoard的输出示意图
同时也可以看到Loss值、准确度等一些数据的可视化图形(折线图),例如准确度变化,如图5-7所示。
图5-7 准确度随训练次数变化关系
整个模型的结构如图5-8所示。
图5-8 整个模型的结构
4.模型测试
通过predict()方法进行预测功能的编写:
from __future__ import absolute_import from __future__ import division from __future__ import print_function import os import tensorflow.compat.v1 as tf import train from skimage import io, transform, color import base mode = tf.estimator.ModeKeys.PREDICT _NUM_CLASSES = 2 image_size = [224, 224] model_dir = base.MODEL_DIR os.environ['TF_ENABLE_WINOGRAD_NONFUSED'] = '1' model = tf.estimator.Estimator(model_fn=train.modelFn, model_dir=model_dir) def predictInputFn(image_path): image = transform.resize(color.rgb2gray(io.imread(image_path)), [224, 224]) - 0.5 images = tf.image.convert_image_dtype(image, dtype=tf.float32) dataset = tf.data.Dataset.from_tensors((images,)) return dataset.batch(1).make_one_shot_iterator().get_next(), None def predict(image_path): for r in model.predict(input_fn=lambda: predictInputFn(image_path)): return {"dog": r['probabilities'][1], "cat": r['probabilities'][0]} if __name__ == '__main__': image_files = base.TEST_PIC print(predict(image_files))
完成之后,可以随机选择一张图像进行预测,如图5-9所示。
图5-9 本地测试效果
可以看到得到的结果是:{'dog': 0.69301635, 'cat': 0.3069837},即该图像有极大的概率是狗。
5.2.3 将模型部署到Serverless架构
将模型部署到Serverless架构涉及几个部分。
·训练函数:该函数主要用于完成因模型迭代而所必需的训练,由对象存储触发器触发。
·预测函数:该函数主要用于对用户上传的图像进行预测,由HTTP触发器触发。
·用户反馈函数:该函数主要用于将用户反馈的数据上传到对象存储,核心功能是数据持久化。相对来说,这部分并不需要过多的计算资源,为了保证项目的整体成本,这一部分可以拆出。
由于训练函数和预测函数需要TensorFlow等较为复杂的依赖,所以此处为了简化流程,采用镜像部署方法,将应用构建成镜像并部署到Serverless架构。另外,这两部分可能涉及较多的计算资源,所以这里采用性能实例或GPU实例。(现在阶段,在Serverless架构中,GPU实例并没有得到普及,所以在某些情况下也可以采用CPU版本的TensorFlow等。)
另外,由于该模型存在更新迭代,而函数本身不能进行“自更新”,所以更新迭代后的模型文件等就需要有额外的存储模块进行存储。此时,我们可以考虑图5-10所示的解决方案,即通过额外的硬盘挂载方式。
图5-10 模型更新迭代方案简图
1.传统项目改造
由于训练函数和预测函数是通过容器镜像部署的,所以在一定程度上并不需要太多的改造,只需要将原有的执行函数与一些Web框架进行结合即可。以预测函数为例,我们可以将其与Bottle框架进行结合:
@bottle.route('/predict', method='POST') def http_invoke(): if not os.path.exists('./temp/'): os.makedirs('./temp/') # 创建路径 filePath = './temp/' + (''.join(random.sample('zyxwvutsrqponmlkjihgfedcba', 5))) with open(filePath, 'wb') as f: f.write(base64.b64decode(bottle.request.body.read().decode("utf-8").split (',')[1])) return {'result': predict(filePath)} if __name__ == '__main__': if os.environ.get("release", None): bottle.run(host='0.0.0.0', port=8080) else: image_files = base.TEST_PIC print(predict(image_files))
此时由于本地没有设置release环境变量,将会执行:
image_files=base.TEST_PIC print(predict(image_files))
如果在Serverless架构下执行,可以增加环境变量:
release: true
此时将执行:
bottle.run(host='0.0.0.0', port=8080)
与此同时,我们还需要对train.py等进行同样的改造。尽管训练函数将会通过对象存储触发器触发,但是根据阿里云函数计算的文档可以看到:对于Custom Container Runtime,可以根据Headers中的x-fc-control-path来判断是HTTP函数调用还是事件函数调用。
·/invoke:该请求为事件函数调用,表示是Invoke函数调用请求。
·/http-invoke:该请求为HTTP函数调用,表示是一个HTTP Invoke函数调用请求。函数计算会将请求(包括Method、Path、Body、Query及Headers)加上Common Headers后转发给Custom Container Runtime,Custom Container Runtime返回的响应头和响应体则会被返回给客户端。
训练函数也可以与Bottle框架结合:
# 函数计算事件触发 @bottle.route('/invoke', method='POST') def event_invoke(): getKeys = lambda tempPrefix: [obj.key for obj in oss2.ObjectIteratorV2(bucket, prefix=prefix + tempPrefix)] cats_keys = getKeys('cats') dogs_keys = getKeys('dogs') if len(cats_keys) > threshold and len(dogs_keys) > threshold: for category in [cats_keys[0:999], dogs_keys[0:999]]: for key in category: bucket.get_object_to_file(key, base.TRAIN_PATH + key.replace(prefix, '')) bucket.delete_object(key) tf.app.run() shutil.rmtree(base.TRAIN_PATH) if __name__ == '__main__': if os.environ.get("release", None): bottle.run(host='0.0.0.0', port=8080) else: tf.app.run()
除此之外,还需要实现用户反馈函数:
import bottle import random import base64 import json import oss2 bucket_name = 'serverless-cats-vs-dogs' auth = oss2.Auth('用户的密钥信息', '用户的密钥信息') bucket = oss2.Bucket(auth, 'http://oss-cn-hangzhou.aliyuncs.com', bucket_name) # 函数计算事件触发 @bottle.route('/callback', method='POST') def http_invoke(): temp_json = json.loads(bottle.request.body.read().decode("utf-8")) temp_token = ''.join(random.sample('zyxwvutsrqponmlkjihgfedcba', 5)) filePath = '/tmp/' + temp_token with open(filePath, 'wb') as f: f.write(base64.b64decode(temp_json['picture'].split(',')[1])) bucket.put_object_from_file('callback/' + temp_json['target'] + '/' + temp_token, filePath) return {'result': True} app = bottle.default_app() if __name__ == '__main__': bottle.run(host='0.0.0.0', port=8080)
2.部署到Serverless架构
当完成项目的基本改造之后,我们可以进行相关配置资源的编写。
1)Dockerfile的编写:
FROM python:3.7-slim WORKDIR /usr/src/app COPY ./model_fc/base.py . COPY ./model_fc/prediction.py . COPY ./model_fc/train.py . RUN pip install tensorflow bottle numpy oss2 scikit-image tqdm -i https://pypi. tuna.tsinghua.edu.cn/simple/
2)s.yaml资源描述文档的配置主要包括两个部分。
·全局变量的配置:
vars: region: cn-shanghai service: name: cats-vs-dogs-project description: cats vs dogs project nas: auto vpc: auto log: auto image: 'registry.cn-shanghai.aliyuncs.com/custom-container/cats-vs-dogs:0.0.1' httpTriggers: - name: httpTrigger type: http config: authType: anonymous methods: - GET - POST customDomains: - domainName: auto protocol: HTTP routeConfigs: - path: /* environmentVariables: release: true
·函数详情的配置:
train: component: fc props: region: ${vars.region} service: ${vars.service} function: name: tarin runtime: custom-container memorySize: 32768 caPort: 8080 timeout: 7200 instanceType: c1 customContainerConfig: image: ${vars.image} command: '["python"]' args: '["train.py"]' environmentVariables: {vars.environmentVariables} triggers: - name: ossTrigger type: oss config: bucketName: serverless-cats-vs-dogs events: - oss:ObjectCreated:* filter: Key: Prefix: 'callback' predict: component: fc props: region: ${vars.region} service: ${vars.service} function: name: predict runtime: custom-container memorySize: 2048 caPort: 8080 timeout: 60 customContainerConfig: image: ${vars.image} command: '["python"]' args: '["prediction.py"]' environmentVariables: {vars.environmentVariables} triggers: {vars.httpTriggers} customDomains: {vars.customDomains} callback: component: fc props: region: ${vars.region} service: ${vars.service} function: name: callback runtime: python3 memorySize: 256 codeUri: ./callback_fc timeout: 60 handler: callback.app environmentVariables: {vars.environmentVariables} triggers: {vars.httpTriggers} customDomains: {vars.customDomains}
完成相关配置的描述之后,我们可以通过Serverless Devs开发者工具进行项目的构建以及部署。
·通过build方法进行构建,例如构建train函数时可以用s train build--use-docker(见图5-11)。
图5-11 项目构建示意图
·构建完成之后,可以通过deploy方法进行部署,例如通过s train deploy部署train函数,通过s deploy可以同时部署所需要的3个函数(见图5-12)。
图5-12 项目部署成功示意图
·部署完成之后,可以将模型文件上传到NAS,以便加载和使用,例如:
s nas upload -r -n ./fc_model/model /mnt/auto/model
3.项目测试
以predict函数为例进行预测,此时可以选择一张图像,并将其转换为Base64编码,如图5-13所示。
通过系统返回的地址以及Postman工具进行测试,如图5-14所示。
图5-13 图像转换为Base64编码示例
图5-14 通过Postman进行测试
可以看到,系统最终返回结果如下:
{ "result": { "dog": 0.0012328019365668297, "cat": 0.9987672567367554 } }
系统判定该图像为猫,符合预期。
5.2.4 用户反馈与模型迭代
用户反馈与模型迭代的核心流程如图5-15所示。
用户上传图像进行预测后,发现预测结果并不是自己想要的,或者有一批新的标注内容上传到系统时,可以触发用户反馈函数,将上传的图像和标注的内容发送给该函数:
{ "picture": "图片Base64的结果", "target": "图片类别,可选cats/dogs" }
图5-15 用户反馈与模型迭代流程简图
此时,函数将会进行一定的逻辑处理,按照target分类将Base64格式的图像转为文件,并存储到对象存储中,再由对象存储异步触发训练函数。为了保证训练效率以及训练时模型的安全与稳定,我们可以针对训练函数进行最小和最大实例数的确定,以保证实例预留带来启动性能提升,以及防止过多的训练任务同时出现,如图5-16所示。
图5-16 函数计算预留功能
在训练函数内部,我们将根据每次触发结果判断已存在的猫狗数据是否分别达到某个阈值。为了确保模型更新迭代的公平性,我们可以设定当猫的标注数据与狗的标注数据同时达到某个数值时进行模型训练,进而更新迭代,最后将数据写入NAS,以确保新的预测函数可以读取到最新的数据。
此时,可以通过Postman对用户反馈函数进行功能验证,如图5-17所示。
图5-17 通过Postman进行测试示意图
之后可以看预期反馈,此时可以通过Python脚本将之前准备的./strengthen目录下的内容批量提交:
import requests import base64 import os url = "http://callback.cats-vs-dogs-project.1583208943291465.cn-shanghai.fc.devsapp.net/ callback" for root, dirs, files in os.walk('./strengthen', True, None, False): # 遍列目录 for f in files: if os.path.isfile(os.path.join(root, f)): lable, label_id = os.path.splitext(f)[0].split('.') with open(os.path.join(root, f), 'rb') as f: base64_data = base64.b64encode(f.read()) s = base64_data.decode() payload = "{\"target\": \"%s\", \"picture\": \"data:image/jpg;base64, %s\"}" % (lable, s) headers = {'Content-Type': 'text/plain'} response = requests.request("POST", url, headers=headers, data=payload) print(response.text)
批量反馈的脚本执行完成之后,可以通过日志服务查看模型训练结果:
---------- 6 ---------- max_accuracy: 0.8256281 status_count: 0 -----------------------
同时,通过Serverless Devs开发者工具查看NAS中对应的模型文件内容:
model_checkpoint_path: "model.ckpt-13000"
可见,模型的准确度已经从之前的0.7542857提升到了0.8256281,同时NAS中的模型文件也完成了更新,即通过用户的反馈或者新的标注数据已经成功实现原始模型的更新升级。
5.2.5 项目总结
1)冷启动优化:如图5-18所示,通过测试可以看到,该项目在热启动时完成一次请求的响应时间大约是570毫秒,而冷启动涉及镜像的拉取、进程的启动、初始资源的准备,整个时间大约为1分26秒。
图5-18 冷启动和热启动性能对比
为了降低冷启动带来的影响,我们可以通过以下几个方面进行优化。
·预留实例。
·减小构建的镜像的体积。
·开启镜像加速功能。
·减小模型体积。
2)模型迭代触发逻辑优化:Serverless是天然分布式架构,天然具备弹性能力,所以在对象存储触发训练函数进行训练之后,可能会由于并发极高,瞬间出现多个训练任务,最终同时将训练结果更新到NAS,这在一定程度上具有风险,所以模型迭代触发逻辑可以从以下几点考虑。
·确保每次最多只有一个训练任务(可以通过设置实例上限实现该功能)。
·确保所有数据都可以正常存入数据库,触发训练函数时,其自行决定本次触发是否需要执行。
3)模型的优化:由于本项目更多的是抛砖引玉,希望通过一个简单的案例帮助读者快速掌握通过函数计算进行预测和模型更新迭代的技巧,因此模型定义相对比较简单。在实际生产中,模型定义会直接决定模型效果。
4)数据集优化:针对模型训练,数据集的质量和数量在一定程度上直接决定模型的最终效果。该实验采用的是Kaggle开源数据集,一方面进行初始训练,另一方面又要进行测试,同时还需要进行模型的更新迭代,所以将原有的数据分成比较多的份数,这在一定程度上会影响模型的训练效果以及最终的预测结果。
本项目相对来说不是一个非常复杂的项目,尽管实现的是比较经典的猫狗识别任务,但是在这个项目基础之上增加了用户反馈机制以及模型优化迭代机制。在实际生产过程中,类似的流程非常常见,毕竟一个模型训练完成之后,通常情况下它是具有时效性的,即随着时间的推移,要在原来的模型之上进行升级迭代,具体如下。
·推荐系统:随着被推荐内容不断增加,系统要尽可能地推荐最新的内容,必要时需要屏蔽老的内容,此时训练的内容和推荐的规则等都需要不断升级迭代。
·分类系统:随着老的类别的剔除,新的类别的增加,模型需要升级,以确保分类结果的准确性和精确度。
·目标检测系统:随着新的标注内容不断增加,为了保证目标识别的精准度,模型需要不断升级迭代。
·……
在传统阶段,我们很可能要通过分布式架构,以及GPU服务器、CPU服务器等进行混合以实现项目,并且开发者要关注极多的底层监控、资源等。而在Serverless架构下,我们仅需要通过对象存储、硬盘挂载以及函数即可实现项目,而开发者关注的更多是业务逻辑本身。
综上所述,我们通过简单的案例,意在实现一个生产环境中常见的人工智能模型的更新迭代,希望读者可以对以下技术点有进一步的了解。
·如何通过Serverless架构实现人工智能模型的更新。
·如何将容器镜像部署到Serverless架构。
·Custom Container函数如何使用事件触发。
·在人工智能场景下,如何进行Serverless应用的冷启动优化,提升系统性能。
标签:Serverless,架构,训练,模型,train,tf,path,os From: https://www.cnblogs.com/muzinan110/p/17067949.html