让在线 AI 帮你生成文章摘要(基于阿里云灵积模型服务)

声明:无利益相关,仅作技术分享与试用体验。 封面图由阿里云的通义万相生成,提示语为“AI,技术,Python,生成,摘要,大模型,自动化”。

起因

2023-10-08 上班的时候,阿里云的客服给我打来电话,大概就是说他们的大模型服务和 GPU 服务有优惠,让我试用一下。

这段时间,我忙于将博客迁移到 Hexo 上。之所以又搞回这个不太方便的平台上,是因为有一款主题我比较喜欢:安知鱼主题。只可惜,它只有 Hexo 版本的。而且说实话,很少有人愿意为 Typecho 写主题和插件了。本来这并不方便,但是我结识到了一个 Hexo 在线编辑的前端:Qexo。我为它做了一些代码上的贡献,并且写了易于它部署的 Docker 脚本,发到 Docker Hub 和 GitHub Container registry 上。虽然这款工具仍然有其不便之处,但是比起原先还是方便了不少。

回到那个主题上。主题中有生成并显示 AI 摘要的部分,有本地和在线的方式。

  • 本地:自己在 front-matter 中填写摘要,可以自己写,也可以找 AI 生成
  • 在线:通过 TianliGPT 生成摘要。须购买额度, ¥8.99 / 50000 字

我不差那点钱,但是毕竟现在只是尝试把博客迁移到 Hexo 上,还没有最终决定。所以,我并不想使用在线的服务。如果有条件,我更想使用开源项目自己部署这样的服务,实现资源可控。

不过,那一通电话,暂时改变了我的想法。反正也是有比较高(200 万 tokens,限时 180 天)的免费额度,不如试一下通义千问。

注意:上面提到的 token 的计算方式和其他的不太一样:对于中文文本来说,1 个 token 通常对应一个汉字;对于英文文本来说,1 个 token 通常对应 3 至 4 个字母。

阿里云灵积模型服务及其使用

服务简介

阿里云的这款服务名为灵积(DashScope),标榜为“模型服务”。简单来说,就是通过 Python / Java 的 SDK,或者是 HTTP 请求,提供在线的大模型 AI 服务。

最大的亮点是:除了阿里自己的通义千问外,它还支持 LLaMa2、百川、Stable Diffusion、ChatGLM 等多种其他模型。

操作步骤也非常简单:

  1. 开通灵积服务
  2. 生成一个 key
  3. 通过 SDK 或 HTTP 请求,调用可选择的大模型

说实话,只要看官网的文档就能够解决几乎所有问题。

通过 Python 自动生成摘要

阿里云官方提供 Python 和 Java 的 SDK,并给出了调用的代码。这是与本文相关的代码示例

我只需要安装 dashscope 模块,添加环境变量,所谓改一下里面的内容,就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import os

# 设置环境变量
os.environ['DASHSCOPE_API_KEY']="xxxxxxxxxxx"

import random
from http import HTTPStatus
from dashscope import Generation

def call_with_messages(content):
messages = [{'role': 'system', 'content': '你是文章提纲生成器,我将会输入一段 Markdown 格式的文章,你需要解析输入的文章,理解其中的意思,最后给出它的概要,可以多一些,但是内容在200字以内。'},
{'role': 'user', 'content': content}]
gen = Generation()
response = gen.call(
Generation.Models.qwen_turbo,
messages=messages,
seed=random.randint(1, 10000), # set the random seed, optional, default to 1234 if not set
result_format='message', # set the result to be "message" format.
)
if response.status_code == HTTPStatus.OK:
print(response)
else:
print('Request id: %s, Status code: %s, error code: %s, error message: %s' % (
response.request_id, response.status_code,
response.code, response.message
))


if __name__ == '__main__':
content = r"""
这里粘贴整段文章
"""

call_with_messages(content)

返回的 JSON 内容形如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"status_code": 200,
"request_id": "xxxxxx-xxxx-xxxx-xxxx-xxxxxx",
"code": "",
"message": "",
"output": {
"text": null,
"finish_reason": null,
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "这里是文章的摘要"
}
}
]
},
"usage": {
"input_tokens": 1145,
"output_tokens": 141,
"total_tokens": 919810
}
}

处理现有的 Markdown 文件,直接添加摘要字段

做到这里,我突发奇想:我是否可以读取目前整个 Hexo 项目中的 Markdown 文件,提取正文,交给 AI 分析,将得出的摘要写到原文件中呢?

读取目录下的全部文件

首先,我需要读取某个目录下的全部文件,包含子文目录下的。

这比较简单,我以前写了一个函数,可以直接拿来用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 这里面的 import 语句只写新添加的,下同
from loguru import logger # 第三方日志模块


def tree(filepath, ignore_dir_names=None, ignore_file_names=None):
"""返回两个列表,第一个列表为 filepath 下全部文件的完整路径, 第二个为对应的文件名"""
if ignore_dir_names is None:
ignore_dir_names = []
if ignore_file_names is None:
ignore_file_names = []
ret_list = []
if isinstance(filepath, str):
if not os.path.exists(filepath):
logger.error("路径不存在: " + filepath)
return None, None
elif os.path.isfile(filepath) and os.path.basename(filepath) not in ignore_file_names:
return [filepath], [os.path.basename(filepath)]
elif os.path.isdir(filepath) and os.path.basename(filepath) not in ignore_dir_names:
for file in os.listdir(filepath):
fullfilepath = os.path.join(filepath, file)
if os.path.isfile(fullfilepath) and os.path.basename(fullfilepath) not in ignore_file_names:
ret_list.append(fullfilepath)
if os.path.isdir(fullfilepath) and os.path.basename(fullfilepath) not in ignore_dir_names:
ret_list.extend(tree(fullfilepath, ignore_dir_names, ignore_file_names)[0])
return ret_list, [os.path.basename(p) for p in ret_list]

预期目标

剩下的问题就是怎么将 AI 摘要写入 Markdown 文件了。

预期目标是:将 AI 摘要写到 ai 字段中,比如原先是:

1
2
3
4
5
6
7
8
---
...
title: 开发 Web 自动化测试辅助工具 nopo 的历程
updated: 2021-12-20 11:54

---
做自动化 UI 测试,我最开始用的是 Selenium,用得算是比较精通,除了等待方面往往直接摆烂写 `time.sleep()`
...

加入 AI 摘要后:

1
2
3
4
5
6
7
8
9
---
...
title: 开发 Web 自动化测试辅助工具 nopo 的历程
updated: 2021-12-20 11:54
ai: nopo 是一个基于 Python 的自动化 UI 测试工具,它提供了一种更简洁、更高效的元素查找方式,并支持层叠选择器和自动等待元素出现。它还提供了一些自定义功能,如清空元素、查找元素的层叠关系等。

---
做自动化 UI 测试,我最开始用的是 Selenium,用得算是比较精通,除了等待方面往往直接摆烂写 `time.sleep()`
...

而文章上面的 front-matter 不是正文的一部分,不应该把这些内容喂给 AI。

所以上面的问题的具体描述就变成了:如何提取 Markdown 中的各项 front-matter 参数与正文,写入新的 front-matter 字段。

处理报错

在测试的时候,我突然发现,有一些文章无法通过通义千问生成 AI 摘要,报错信息如下:

1
Request id: xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, Status code: 400, error code: DataInspectionFailed, error message: Input data may contain inappropriate content.

文档对此的解释为:

数据检查错误,输入或者输出包含疑似敏感内容被绿网拦截

一个非常正常的内容都这样,难怪国内的大语言模型发展不起来。

不过现在要处理这个问题。原先的代码我基本上没怎么动,就是添加了返回值,以及日志、报错信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def call_with_messages(content):
messages = [{'role': 'system', 'content': '你是文章提纲生成器,我将会输入一段 Markdown 格式的文章,你需要解析输入的文章,理解其中的意思,最后给出它的概要,可以多一些,但是内容在200字以内。'},
{'role': 'user', 'content': content}]
gen = Generation()
response = gen.call(
Generation.Models.qwen_turbo,
messages=messages,
seed=random.randint(1, 10000), # set the random seed, optional, default to 1234 if not set
result_format='message', # set the result to be "message" format.
)
if response.status_code == HTTPStatus.OK:
logger.debug(response)
logger.info(response.output.choices[0].message.content)
return response.output.choices[0].message.content
else:
raise RuntimeError('Request id: %s, Status code: %s, error code: %s, error message: %s' % (
response.request_id, response.status_code,
response.code, response.message
))

处理 Markdown 文件

回到上面的预期目标,有一个 Python 第三方模块能够解决我的问题:python-frontmatter。它可以读取 Markdown 文件,返回一个类似于字典的类,从中能够读取 front-matter 中各字段及其值,以及 Markdown 文件的正文部分。这篇文章简单介绍了如何读取它们,官方文档里面有更详细的用法。

我以下面的代码简单介绍如何使用该模块:

1
2
# 如果安装的包名不同于导入的包名,会特别指出
pip install python-frontmatter
1
2
3
4
5
6
7
8
9
10
import frontmatter

path = r'文件路径'

md = frontmatter.load(path) # 读取文件
f.metadata # 返回字典,键为 front-matter 包含的字段,值为对应的值
md.content # 返回正文
md['ai'] # 像字典一样获取字段(值)
md['ai'] = 'aaaaaaa' # 像字典一样写入字段(值)
frontmatter.dump(md, path) # 保存到文件

可以依此写一个读取、写入文件的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
def gen_ai_abstract_from_one_md_file(md_path):
md = frontmatter.load(md_path)
if 'ai' not in md and 'YOU_NEED_TO_ADD_ABSTRACT_MANUALLY' not in md:
try:
abstract = call_with_messages(md.content)
md['ai'] = abstract
except RuntimeError as e:
logger.error(e)
logger.error('你需要自行添加摘要')
md['YOU_NEED_TO_ADD_ABSTRACT_MANUALLY'] = True
frontmatter.dump(md, md_path)
else:
logger.info('已添加,跳过')

上面的代码中,对于无法获取 AI 摘要的情况,我会添加一个 YOU_NEED_TO_ADD_ABSTRACT_MANUALLY 字段,以便后期检查。

读取文件后,首先检查是否已经写入 ai 字段 或 YOU_NEED_TO_ADD_ABSTRACT_MANUALLY 字段,如果没有才会把文本发给阿里云那边处理,以节省成本。

中英文之间加空格

我发现,很多情况下,通义千问生成的摘要中,中英文之间未加空格。这不符合我的文章风格。

解决方法也很简单,使用 pangu 第三方模块 处理生成的文本:

1
2
3
import pangu

pangu.spacing_text('要处理的文本')

整体代码

整体的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import os
from loguru import logger
from pprint import pprint
import frontmatter
import pangu
import random
from http import HTTPStatus

# 此处填写你从阿里云灵积模型服务获得的 key。注意不要泄露该 key,更稳妥的方法是用另外的方法将其写到环境变量中。
os.environ['DASHSCOPE_API_KEY']="sk-xxxxxxx"


# 这条导入语句要写在赋环境变量之后
from dashscope import Generation


def tree(filepath, ignore_dir_names=None, ignore_file_names=None):
"""返回两个列表,第一个列表为 filepath 下全部文件的完整路径, 第二个为对应的文件名"""
if ignore_dir_names is None:
ignore_dir_names = []
if ignore_file_names is None:
ignore_file_names = []
ret_list = []
if isinstance(filepath, str):
if not os.path.exists(filepath):
logger.error("路径不存在: " + filepath)
return None, None
elif os.path.isfile(filepath) and os.path.basename(filepath) not in ignore_file_names:
return [filepath], [os.path.basename(filepath)]
elif os.path.isdir(filepath) and os.path.basename(filepath) not in ignore_dir_names:
for file in os.listdir(filepath):
fullfilepath = os.path.join(filepath, file)
if os.path.isfile(fullfilepath) and os.path.basename(fullfilepath) not in ignore_file_names:
ret_list.append(fullfilepath)
if os.path.isdir(fullfilepath) and os.path.basename(fullfilepath) not in ignore_dir_names:
ret_list.extend(tree(fullfilepath, ignore_dir_names, ignore_file_names)[0])
return ret_list, [os.path.basename(p) for p in ret_list]


def call_with_messages(content):
messages = [{'role': 'system', 'content': '你是文章提纲生成器,我将会输入一段 Markdown 格式的文章,你需要解析输入的文章,理解其中的意思,最后给出它的概要,可以多一些,但是内容在200字以内。'},
{'role': 'user', 'content': content}]
gen = Generation()
response = gen.call(
Generation.Models.qwen_turbo,
messages=messages,
seed=random.randint(1, 10000), # set the random seed, optional, default to 1234 if not set
result_format='message', # set the result to be "message" format.
)
if response.status_code == HTTPStatus.OK:
logger.debug(response)
logger.info(response.output.choices[0].message.content)
return response.output.choices[0].message.content
else:
raise RuntimeError('Request id: %s, Status code: %s, error code: %s, error message: %s' % (
response.request_id, response.status_code,
response.code, response.message
))


def gen_ai_abstract_from_one_md_file(md_path):
md = frontmatter.load(md_path)
if 'ai' not in md and 'YOU_NEED_TO_ADD_ABSTRACT_MANUALLY' not in md:
try:
abstract = pangu.spacing_text(call_with_messages(md.content))
md['ai'] = abstract
except RuntimeError as e:
logger.error(e)
logger.error('你需要自行添加摘要')
md['YOU_NEED_TO_ADD_ABSTRACT_MANUALLY'] = True
frontmatter.dump(md, md_path)
else:
logger.info('已添加,跳过')


if __name__ == '__main__':
ROOT_PATH = r'目录路径'
file_lists = list(zip(*tree(ROOT_PATH)))
for file_tuple in file_lists:
if file_tuple[1].endswith('.md'):
logger.info('当前处理文件:' + file_tuple[1])
gen_ai_abstract_from_one_md_file(file_tuple[0])

执行情况

我对我现有的文章执行了上述代码,其中成功 47 个,失败 23 个。失败的里面除了一个 提示的是 Range of input length should be [1, 6000] 外,其他的都是上面提到的错误。总计用掉 token 数量为 86715(输入输出都会消耗 token)。

执行效果

对于成功的文章——尤其是技术类的文章——来说,生成的效果还是不错的。

但还是要检查摘要是否正确。比如这篇文章,生成的摘要如下:

这篇文章主要介绍了 Unicode 中的数学字母数字符号,这些字符可以用于数学公式中。虽然 Unicode 支持这些字符,但并不是所有字体都支持它们,且不同的软件和设备可能显示不同。作者还分享了如何在电脑和手机上查看这些字符的全部字形。

但是“作者还分享了如何在电脑和手机上查看这些字符的全部字形。”并非我的意思。我只是在文末列出了全部的 Unicode 中的数学字母数字符号。

而对于诗歌,准确性就差了。比如这篇文章,生成的摘要如下:

这篇文章讲述了作者在高三时,听到了 July 的 In The Wind 这首歌,被深深打动,于是花了半天的时间为这首歌填词。歌词表达了作者想要丢弃过去的回忆和痛苦,继续前行的决心。

最后一句属实是过分解读了,我只是填词而已。

小说呢?这篇文章,生成的摘要如下:

文章讲述了作者在家乡的一片原野上玩耍的经历,包括春天的花朵、夏天的草地、秋天的星空和冬天的烟花。作者曾经试图骑自行车穿越那片原野,但发现了一堵高高的墙,他试图越过这堵墙,但没有成功。后来,他在地道中发现了一个美丽的世外桃源,但因为学习压力大,无法再去。最后,作者发现了一片湖,但湖边又出现了一堵更远的墙,他无法到达那片世外桃源。作者希望那堵墙能够消失,这样他就能再次去那片美丽的地方。

“但湖边又出现了一堵更远的墙”完全无法表达文章内容。原文的意思是,在原先的墙之前,又新建了一堵墙,把湖挡住了。

总结

总之,如果不考虑经常被拦截的情况,还是可以用的。

但是问题就在于,输入内容经常被误拦截。