2024年3月

你好呀,我是歪歪。

春节期间关注到了一个关于 Java 方面的比赛,很有意思。由于是开源的,我把项目拉下来试图学(白)习(嫖)别人的做题思路,在这期间一度让我产生了一个自我怀疑:

他们写的 Java 和我会的 Java 是同一个 Java 吗?

不能让我一个人怀疑,所以这篇文章我打算带你盘一下这个比赛,并且试图让你也产生怀疑。


赛题

在 2024 年 1 月 1 日,一个叫做 Gunnar Morling 的帅哥,发了这样一篇文章:

https://www.morling.dev/blog/one-billion-row-challenge/

文章的标题叫做《The One Billion Row Challenge》,十亿行挑战,简称就是 1BRC,挑战的时间是一月份整个月。

赛题的内容非常简单,你只需要看懂这个文件就行了:

文件的每一行记录的是一个气象站的温度值。气象站和温度分号分隔,温度值只会保留一位小数。

参赛者只需要解析这个文件,然后并计算出每个气象站的最小、最大和平均温度。按照字典序的格式输出就行了:

出题人还配了一个简图:

需求非常明确、简单,对不对?

为了让你彻底明白,我再给你举一个具体的例子。

假设文件中的内容是这样的:

chengdu;12.0
guangzhou;7.2;
chengdu;6.3
beijing;-3.6;
chengdu;23.0
shanghai;9.8;
chengdu;24.3
beijing;17.8;

那么 chengdu (成都)的最低气温是 6.3,最高气温是 24.3,平均气温是(12.0+6.3+23.0+24.3)/4=16.4,就是这么朴实无华的计算方式。

最终结果输出的时候,再注意一下字典序就行。

这有啥好挑战的呢?

难点在于出题人给出的这个文件有 10 亿行数据。

在我的垃圾电脑上,光是跑出题人提供的数据生成的脚本,就跑了 20 分钟:

跑出来之后文件大小都有接近 13G,记事本打都打不开:

所以挑战点就在于“十亿行”数据。

具体的一些规则描述和细节补充,都在 github 上放好了:

https://github.com/gunnarmorling/1brc

针对这个挑战,出题人还提供了一个基线版本:

https://github.com/gunnarmorling/1brc/blob/main/src/main/java/dev/morling/onebrc/CalculateAverage_baseline.java

首先封装了一个 MeasurementAggregator 对象,里面放的就是要记录的最小温度、最大温度、总温度和总数。

整个核心代码就二三十行,使用了流式编程:

首先是一行行的读取文本,接着每一行都按照分号进行拆分,取出对应的气象站和温度值。

然后按照气象站维度进行 groupingBy 聚合,并且计算最大值、最小值和平均值。

在计算平均值的时候,为了避免浮点计算,还特意将温度乘 10,转换为 int 类型。

最后用 TreeMap 按字典序输出各个气象站的温度数据。

这个基线版本官方的数据是在跑分环境下,2 分钟内可以运行完毕。

而在我的电脑上跑了接近 14 分钟:

很正常,毕竟人家的测评环境配置都是很高的:

Results are determined by running the program on a Hetzner AX161 dedicated server (32 core AMD EPYC™ 7502P (Zen2), 128 GB RAM).

参加挑战的各路大神,最终拿出的 TOP 10 成绩是这样的:

当时看到这个成绩的瞬间,我人都是麻的,第一个疑问是:我靠,13G 的文件啊?1.5s 内完成了读取、解析、计算的过程?这不可能啊,光是读取 13G 大小的文件,也需要一点时间吧?

但是需要注意的是,歪师傅有这个想法是走入了一个小误区,就是我以为这 13G 的文件一次性加载不完成,怎么快速的从硬盘把文件读取到内存中也是一个考点。

后来发现是我多虑了,人家直接就说了,不用考虑这一点,跑分成绩运行的时候,文件直接就在内存中:

所以,最终的成绩中不包含读取文件的时间。

但是也很牛逼了啊,毕竟有十亿条数据。


第一名

我尝试着看了一下第一名的代码:

https://github.com/gunnarmorling/1brc/blob/main/src/main/java/dev/morling/onebrc/CalculateAverage_thomaswue.java

过于硬核,实在是看不懂。我只能通过作者写的一点注释、方法名称、代码提交记录去尝试理解他的代码。

在他的代码开头的部分,有这样的一段描述:

这是他的破题思路,结合了这些信息之后再去看代码,稍微好一点,但是我发现他里面还是有非常多的微操、太多针对性的优化导致代码可读性较差,虽然他的代码加上注释一共也才 400 多行,然而我看还是看不懂。

我随便截个代码片段吧:

问 GPT 这个哥们,他也是能说个大概出来:

所以我放弃了理解第一名的代码,开始去看第二名,发现也是非常的晦涩难懂,再到第三名...

最后,我产生了文章开始时的疑问:他们写的 Java 和我会的 Java 是同一个 Java 吗?

但是有一说一,虽然我看不懂他们的某些操作,但是会发现他们整体的思路都几乎是一致。

虽然我没有看懂第一名的代码,但是我还是专门列出了这一个小节,给你指个路,有兴趣你可以去看看。

另外,获得第一名的老哥,其实是一个巨佬:

是 GraalVM 项目的负责人之一:


巨人肩膀

在官方的 github 项目的最后,有这样的一个部分:

其中最后一篇文章,是一个叫做 Marko Topolnik 的老哥写的。

我看了一下,这个哥们的官方成绩是 2.332 秒,榜单第九名:

但是按照他自己的描述,在比赛结束后他还继续优化了代码,最终可以跑到 1.7s,排名第四。

在他的文章中详细的描述了他的挑战过程和思路。

我就站在巨人的肩膀上,带大家看看这位大佬从 71s 到 1.7s 的破题之道:

https://questdb.io/blog/billion-row-challenge-step-by-step/


最常规的代码

首先,他给了一个常规实现的代码,和基线版本的代码大同小异,只不过是使用了并行流来处理:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog1.java

平时看到流式编程我是有点头疼的,需要稍微的反应一下,但是在看了前三名的最终代码后再看这个代码,我觉得很亲切。

根据作者的描述,这段代码:

  • 使用并行 Java 流,将所有 CPU 核心都用起来了。
  • 也不会陷入任何已知的性能陷阱,比如 Java 正则表达式

在一台装有 OpenJDK 21.0.2 的 Hetzner CCX33 机器上,跑完需要的时间为 71 秒。


第 0 版优化:换个好的 JVM

叫做第 0 版优化的原因是作者对于代码其实啥也没动,只是换了一个 JVM:

默认使用 GraalVM 之后,最常规的代码,运行时间从 71s 到了 66s,相当于白捡了 5s,我问就你香不香。

同时作者还提到一句话:

When we get deeper into optimizing and bring down the runtime to 2-3 seconds, eliminating the JVM startup provides another 150-200ms in relief. That becomes a big deal.

当我们把程序优化到运行时间只需要 2-3 秒的时候,使用 GraalVM,会消除 JVM 的启动时间,从而提供额外的 150-200ms 的提升。

到那个时候,这个就变得非常重要了。


数据指标很重要

在正式进入优化之前,作者先介绍了他使用到的三个非常重要的工具:

关于工具我就不过多介绍了,这里单独提一嘴主要是想表达一个贯穿整个优化过程的中心思想:数据指标很重要。

你只有收集到了当前程序足够多的运行指标,才能对你进行下一步优化时提供直观的、优化方向上的指导。

工欲善其事必先利其器,就是这个道理。


第一版优化:并行 I/O 搞起来

通过查看当前代码对应的火焰图:

https://questdb.io/html/blog/profile-blog1

通过火焰图以及观察 GC 情况,作者发现当前耗时的地方注意是这三个地方:

  1. BufferedReader 将每行文本输出为字符串
  2. 处理每一行的字符串
  3. 垃圾收集 (GC):使用 VisualGC 可以看到,差不多每秒要 GC 10 次甚至更多。

可以发现 BufferedReader 占用了大量的性能,因为当前读取文件还是一行行读取的嘛,性能很差。

于是大多数人意识到的第一件事就是采用并行化 I/O。

所以,我们需要把待处理的文件分块。分多少块呢?

有多少个线程就分成多少个块,每个线程各自处理一个块,这样性能就上去了。

文件分块读取,大家自然而然的就想到了 mmap 相关的方法。

mmap 可以用 ByteBuffer API 来搞事情,但是使用的索引是 int 类型,所以可映射的大小有 2GB 的限制。

前面说了,在这个挑战中,光是文件大小就有 13G,所以 2GB 是捉襟见肘的。

但是在 JDK 21 中,支持一个叫做 MemorySegment 的东西,也可以干 mmap 一样的事情,但是它的索引使用的是 long,相当于没有内存限制了。

除了使用 MemorySegment 外,还有一些细节的处理,比如找到正确的分割文件的位置、启动线程、等待线程处理完成等等。

处理这些细节会导致这一版的代码从最初的 17 行增加到了 120 行。

这是优化后的代码地址:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog2.java

在这个赛题下,我们肯定是需要再循环中进行数据的解析和处理的,所以循环就是非常重要的一个点。

我们可以关注一下代码中的循环部分,这里面有一个小细节:

这个循环是每个线程在按块读取文件大小,里面用到了 findByte 方法和 stringAt 方法。

在第一个版本中,我们是用的 BufferedReader 把一行内容以字符串的形式读进来,然后按照分号分隔,并生成城市和温度两个字符串。

这个过程就涉及到三个字符串了。

但是这个哥们的思路是啥?

自定义一个 findByte 方法,先找到分号的位置,然后把下标返回回去。

再用自定义的 stringAt 方法,结合前面找到的下标,直接解析出“城市和温度”这两个字符串,减少了整行读取的内存消耗。

相当于少了十亿个字符串,在字符串处理和 GC 方面取得了不错的表现。

这一波操作下来,处理时间直接从 66s 下降到了 17s:

然后再看火焰图:

https://questdb.io/html/blog/profile-blog2-variant1

可以发现 GC 的时间几乎消失了。

CPU 现在大部分时间都花在自定义的 stringAt 上。还有相当多的时间花在 Map.computeIfAbsent 方法 、Double.parseDouble 方法和 findByte 方法

其中 Double.parseDouble 方法是解析温度用的。

作者打算先把这个地方给攻下来。


第二版优化:优化温度解析方法

在这版优化中,作者直接将温度解析为整数。

首先,目前的做法是,首先分配一个字符串,然后对其调用 parseDouble() 方法,最后转换为整数以进行高效的存储和计算。

但是,其实我们应该直接创建整数出来,没必要走字符串绕一圈。

同时我们知道,温度的取值范围是 [-99.9,99.9],所以针对这个范围,我们搞个自定义方法就行了:

private int parseTemperature(long semicolonPos) {
    long off = semicolonPos + 1;
    int sign = 1;
    byte b = chunk.get(JAVA_BYTE, off++);
    if (b == '-') {
        sign = -1;
        b = chunk.get(JAVA_BYTE, off++);
    }
    int temp = b - '0';
    b = chunk.get(JAVA_BYTE, off++);
    if (b != '.') {
        temp = 10 * temp + b - '0';
        // we found two integer digits. The next char is definitely '.', skip it:
        off++;
    }
    b = chunk.get(JAVA_BYTE, off);
    temp = 10 * temp + b - '0';
    return sign * temp;
}

这波操作下来,处理时间又减少了 6s,来到了 11s:

再看对应火焰图:

https://questdb.io/html/blog/profile-blog2-variant2

温度解析部分的耗时占比从 21.43% 降低到 6%,说明是一次正确的优化。

接下来,可以再搞一搞 stringAt 方法了。


第三版优化:自定义哈希表

首先,要优化 stringAt 方法,我们得知道它是干啥的。

我们看一眼代码:

在经历了上一波优化之后,stringAt 目前在代码中的唯一作用就是为了获取气象站的名称。

而获取到这个名称的唯一目的是看看当前的 HashMap 中有没有这个气象站的数据,如果没有就新建一个 StationStats 对象,如果有就把之前的 StationStats 对象拿出来进行数据维护。

此外,在赛题中还有这样的一个信息,虽然有十亿行数据,但是只有 413 个气象站:

既然 key 的大小是可控的,那基于这个条件,作者想了一个什么样的骚操作呢?

他直接不用 HashMap 了,自定义了一个哈希表,长这样的:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog3.java

主要看一下代码中的 findAcc 方法,你就能明白它是干啥的了:

通过 hash 方法计算出指定字符串,即气象站名称的 hash 值之后,从自定义的 hashtable 中取出该位置的数据。

首先标号为 ① 的地方,如果没有取到数据,则说明没有这个气象站的数据,新建一个放好,返回就完事。

如果取到了数据,来到标号为 ② 的地方,看看取到的数据和当前要放的数据对应的气象站名称是不是一样的。

如果是则说明已经有了,取出来,然后返回。

如果不是,说明啥情况?

说明 hash 冲突了,来到标号为 ③ 的地方进行下标加一的动作。

然后再次进行循环。

来,你告诉我,这是什么手法?

这不就是开放寻址来解决 hash 冲突吗?

所以 findAcc 方法,就可以替代 computeIfAbsent 方法。

通过自定义的 StatsAcc 哈希表来代替原生的 HashMap。

而且前面说了,key 的大小是可控的,如果自定义 hash 表的初始化大小控制的合适,那么整个 hash 冲突的情况也不会非常严重。

这一波组合拳下来,运行时间来到了 6.6s,火焰图变成了这样:

https://questdb.io/html/blog/profile-blog3

大量的时间花在了前面分析的 findAcc 方法上。

同时作者提到了这样一句话:

同样的代码,如果放到 OpenJDK 上跑需要运行 9.6s,比 GraalVM 慢了 3.3s。

我滴个乖乖,这就是一个 45% 的性能提升啊。


第四版优化:使用 Unsafe 和 SWAR

在这一版优化开始之前,作者先写了这样一段话:

大概意思就是说,到目前为止,我们用到的都是常规且有效的解决方案,并且是 Java 标准、安全的用法。

即使止步于此也能学到很多优化技巧,可以在实际的项目中进行使用。

如果你继续往下探索,那么:

Readability and maintainability also take a big hit, while providing diminishing returns in performance. But, a challenge is a challenge, and the contestants pressed on without looking back!
可读性和可维护性也会受到重创,同时性能的收益会递减。但是,挑战就是挑战,参赛者们继续努力,没有回头!

简单来说,作者的意思就是打个预防针:接下来就要开始上强度了。

所以,在这个版本中,作者应用一些排名靠前的选上都在用的方案:

  • 使用 sun.misc.Unsafe 而不是 MemorySegment,来避免边界检查
  • 避免重新读取相同的输入字节:重复使用加载的值进行哈希和分号搜索
  • 每次处理 8 个字节的数据,使用 SWAR 技术找到分号分隔符。
  • 使用 merykitty 老哥提供的牛逼的 SWAR(SIMD Within A Register)代码解析温度。

这是这一版的代码:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog4.java

比如其中关于循环处理数据的部分,看起来就很之前很不一样了:

然后你再看里面 semicolonMatchBits、nameLen、maskWord、dotPos、parseTemperature 这些方法的调用,直接就是一个懵逼的状态,看着头都大了:

但是你仔细看,你会发现这几个方法是作者从其他人那边学来的:

比如这个叫做 merykitty 的老哥,提供了解析温度的代码,虽然作者加入了大量的注释说明,但是我也只是大概就看懂了不到三层吧。

这里面大量的使用了位运算的技巧,同时你仔细看:几乎没有 if 判断的存在。这是重点,用直接的位运算替换了分支指令,从而减少了分支预测错误的成本。

此外,还有很多我第一次见、叫不上名字的奇技淫巧。

通过这一波“我看不懂,但是我大受震撼”的操作搞下来,时间降低到了 2.4s:


第五版优化:统计学用起来

现在,我们的火焰图变成了这样:

https://questdb.io/html/blog/profile-blog4

耗时主要还是在于 findAcc 方法:

而 findAcc 方法的耗时在于 nameEquals 方法,判断当前气象站名称是否出现过:

但是这个方法里面有个 if 判断,以字节为单位比较两个字符串的内容,每次比较 8 个字节。

首先,它通过循环逐步比较两个字符串中的对应字节。在每次迭代中,它使用 getLong 方法从输入字符串中获取一个 64 位的长整型值,并与另一个字符串中的相应位置进行比较。如果发现不相等的字节,则返回 false,表示两个字符串不相等。

如果循环结束后没有发现不相等的字节,它会继续检查是否已经比较了输入字符串的所有字节,或者最后一个输入字符串的字节与相应位置的字符串字节相等,那么表示两个字符串相等,则返回 true。

那么问题就来了?

如果气象站名称长度全都是小于 8 个字节,会出现啥情况?

假设有这样的一个前提条件,是不是我们就不用在 for 循环中进行 if 判断了,直接一把就比较完成了?

很可惜,没有这样一个提前条件。

但是,如果在数据集中,气象站名称长度绝大部分都小于 8 个字节那是不是就可以单独处理一下?

那到底数据分布是怎么样的呢?

这个问题问题出去的一瞬间,统计学啪的一下就站出来了:这个老子在行,我算算。

所以,作者写了一个程序来统计分析数据集中气象站名称的长度:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Statistics.java

基于程序运行结果,最终的结论如下:

通过分析作者发现,赛题的数据集中气象站名称长度几乎均匀分布在 8 字节以上和 8 字节以下。

运行 Statistics.branchPrediction 方法,当条件是 nameLen > 8 时导致了 50% 的分支预测失败。

也就是说,十亿数据中有一半的数据,都是小于 8 字节的,都是不用特意进行 if 判断的。

但如果将条件更改为 nameLen > 16,那么预测失败率将降至 2.5%。

根据这一发现,很明显,如果要进一步优化代码,就需要编写一些特定的代码来避免在 nameLen > 8 上使用任何 if 判断,直接使用 nameLen > 16 就行。

这是这一版的最终代码,可读性越来越差了:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog5.java

但是最终的成绩是 1.8s:

哦,对了,如果你对于分支预测技术不太清楚,那你可能看得比较懵。

但是分支预测,在性能挑战中,特别是最后大家比分都咬的非常紧的情况下,每次都是屡立奇功,战功赫赫,属于高手间过招杀手锏级别的优化手段。


继续优化

再后面作者还有这两个部分。

消除启动/清理成本:

使用更小的文件分块和工作窃取机制:

这后面就完全是基于这个赛题进行定制化的优化,可移植性不强了,作者就没有进行详细描述,再加上一个我也是没怎么看明白,就不展开讲了。

反正这两个组合拳下来,又搞了 0.1s 的时间下来,最终的成绩为 1.7s:

我实在是学不动了,有兴趣的同学可以自己去看看原文的对应部分。


写在后面

其实关于这篇文章,我原想法是看懂前三名的代码,然后对代码进行解析、对比,找到他们思路的共同点和差异点,但是后来他们的代码确实我看不懂,所以我放弃了这个想法。

但是我知道,只要我愿意花时间、有足够的时间,我肯定可以慢慢地把他们的这几百行代码啃透,但是我也只是想了想而已,很快就放弃了这个思路。

我想如果是大学的时候,我看到这个比赛,我会觉得,真牛逼,我得好好研究一下。

然而现在不一样了,参加工作了,看到了这个比赛,我还是会觉得,真牛逼,但是对我写业务代码帮助不大,就不深究了,浅尝辄止。

大学的时候学习是靠自己无穷的精力和对于掌握新知识的乐趣撑着,现在学习主要靠一时冲动。

但是我还是强烈建议感兴趣的朋友,按照我问中提到的地址,自己去研究一波别人提交的代码。

也许你也会产生一样的疑问:他们写的 Java 和我会的 Java 是同一个 Java 吗?

我的答案是:不是的,他们写的 Java 是自己热爱的 Java,我们写的 Java 只是挣钱的 Java。

没有高低贵贱之分,但是能让你不经意间,从业务代码的深海中抬头看一眼,看到自己熟悉的领域中,更广阔的世界。

为了实现大模型的高效训练和推理,混合专家模型MoE便横空出世。

大模型发展即将进入下一阶段但目前仍面临众多难题。为满足与日俱增的实际需求,大模型参数会越来越大,数据集类型越来越多,从而导致训练难度大增,同时也提高了推理成本。为了实现大模型的高效训练和推理,混合专家模型MoE便横空出世。

MoE结构的发展

Vanilla MoE

Export Network,用于学习不同数据,一个Gating Network用于分配每个Expert的输出权重。

Sparse MoE

Experts的输出是稀疏的,只有部分的 experts 的权重> 0,其余=0 的 expert 直接不参与计算

Expert Balancing问题

不同 experts 在竞争的过程中,会出现“赢者通吃”的现象:前期变现好的 expert 会更容易被 gating network 选择,导致最终只有少数的几个 experts 真正起作用

Transformer MoE

GShard

  • Transformer的encoder和decoder中,每隔一个(every other)FFN层,替换成position-wise MoE层
  • Top-2 gating network

Switch Transformer

简化了MoE的routing算法,gating network 每次只 route 到 1 个 expert

GLaM

  • Gshard结构
  • Scale参数量
  • 降低训练推理成本

MoE的分布式通信和MindSpore优化

MoE结构和普通的Dense模型的差异在于,其需要额外的AllToAll通信,来实现数据的路由(Gating)和结果的回收。而AllToAll通信会跨Node(服务器)、跨pod(路由),进而造成大量的通信阻塞问题

MindSpore的MoE优化

大模型训练主要瓶颈在于片上内存与卡间通信。常用的内存优化手段:

1)MoE并行:将不同的专家切分到不同的卡上,由于MoE的路由机制,需要使用AllToAll通信,将token发送到正确的卡上。对AllToAll的优化:分级AllToAll、Group-wise AllToAll等。

2)优化器异构:大模型训练常使用的adam系列优化器,其占用的内存往往是模型参数本身的2倍或以上,可以将优化器状态存储在Host内存上。

3)多副本并行:将串行的通信、计算拆分成多组,组件流水,掩盖通信时间。

MindSpore已使能上述优化,大幅提升了万亿参数稀疏模型的训练吞吐

Mixtral 8x7b MoE大模型

Mixtral的基础模型Mistral

  • RoPE
  • RMSNorm
  • Transformer decoder
  • Grouped Multi-Query Attention
  • Sliding window attention: 优化随着序列长度增加而增长的显存占用和计算消耗

Mixtral

  • 8个expert(类GPT-4)
  • Top2 gating

MoE Layer的MindSpore实现

Mindformers的Mixtral支持

  • 基于MindFormers实现Mixtral-8x7B MoE模型。关键结构: GQA, RoPE, RMSNorm, SiluMoE配置: 8 Experts, TopK=2, capacity c=1.1加载开源的Mixtral权重和tokenizer,推理结果对齐HF.
  • 4机32卡EP,PP等多维混合并行,基于自有数据集试验性训练收敛符合预期。200 epoch loss 100.02

EP=8,MP=1时性能最佳,约1147 tokens/s/p。

MoE和lifelong learning

终身学习/持续学习的性质

性质

定义

知识记忆(knowledge retention)

模型不易产生遗忘灾难

前向迁移(forward transfer)

利用旧知识学习新任务

后向迁移(backward transfer)

新任务学习后提升旧任务

在线学习(online learning)

连续数据流学习

无任务边界(No task boudaries)

不需要明确的任务或数据定义

固定模型容量(Fixed model capacity)

模型大小不随任务和数据变化

MoE模型+终身学习

性质

知识记忆(knowledge retention)

前向迁移(forward transfer)

后向迁移(backward transfer)

-

在线学习(online learning)

×

无任务边界(No task boudaries)

固定模型容量(Fixed model capacity)

MoE的特点:

  • 多个Expert分别处理不同分布(domain/topic)的数据
  • 推理仅需要部分Expert

LLM的终身学习:

  • 世界知识底座持续学习。
  • Expert可插拔
  • Gating Network可增删。

MoE+终身学习的典型工作

  • Lifelong-MoE
  • 扩展expert和gating network的维度
  • 冻结旧的expert和gating network维度
  • 使用正则克服遗忘灾难

Pangu-sigma

Random Routed Experts:

  • 第一层,根据任务分配给不同的专家组(多个expert构成一个专家组,供一个task/domain使用)
  • 第二层,使用组内随机Gating,让专家组的expert可以负载均衡。

这样可以保证某个领域对应的expert可以直接被抽取出来作为单个模型使用。

Mixtral 8x7b Demo

Mistral-MindSpore: https://github.com/lvyufeng/mistral-mindspore

Mindformer(MoE预训练):https://gitee.com/mindspore/mindformers/

点击关注,第一时间了解华为云新鲜技术~

随着人工智能技术的飞速发展,智能体已经逐步融入到我们的日常生活中。不过,要想让智能体不仅能聊天,还能接入网络实时获取信息,为我们提供更多便利,所需技术的复杂性不得不让人瞩目。今天,我将和各位分享如何在基于.NET开发的AI知识库/智能体项目
AntSK
中,利用Semantic Kernel实现智能体的联网功能,加入查询天气、发送邮件、调用外部API等“神技能”。

AntSK是什么?

AntSK
是一个基于最新的
.NET8
技术栈以及
AntBlazor

Semantic Kernel
开发的开源项目,为开发者提供了构建AI知识库和智能体的强大工具。项目源码开放在GitHub平台,让每位对AI和.NET感兴趣的开发者都能参与进来,探索智能体的无限可能。

https://github.com/xuzeyu91/AntSK

AntSK的联网功能


AntSK
的智能体中嵌入网络操作,听起来是不是感觉非常“
黑科技
”?别急,这并没有我们想象的那么复杂。
Semantic Kernel
作为AntSK中的一大核心SDK,已经为我们提供了便捷的自动调用功能,让联网操作变得触手可及。

配置API通道

首先,我们先来配置通向外界的API通道。这一步相较于编码来说简单得多,仅需要构造一个配置API的页面。在这个页面中,我们可以设定要调用的具体接口信息,比如请求头、请求方式、参数等。

然后同时,我们也需要在应用中选择对应的API插件

智能体如何自动调用API

在AntSK中,应用的配置完成,你是否期待智能体能自如地调用API呢?正是如此,通过下面的代码配置,我们的智能体可以在聊天时动态地注入API插件,并且根据不同的场景去调用指定的API接口,比如天气查询:

OpenAIPromptExecutionSettings settings = new() { ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions };

动态注入API插件


我们还需要编写一些代码来让智能体在实际聊天过程中注入我们预设的API插件。以下是示例代码,详细地展现了如何按照不同的方法类型(GET或POST),创建函数并将其注入到Semantic Kernel插件中:

public void ImportFunctionsByApp(Apps app, Kernel _kernel)
{
    //开启自动插件调用
    var apiIdList = app.ApiFunctionList.Split(",");
    var apiList = _apis_Repositories.GetList(p => apiIdList.Contains(p.Id));
    List<KernelFunction> functions = new List<KernelFunction>();
    var plugin = _kernel.Plugins.FirstOrDefault(p => p.Name == "ApiFunctions");
    {
        foreach (var api in apiList)
        {
            switch (api.Method)
            {
                case HttpMethodType.Get:
                    functions.Add(_kernel.CreateFunctionFromMethod((string msg) =>
                    {
                        try
                        {
                            Console.WriteLine(msg);
                            RestClient client = new RestClient();
                            RestRequest request = new RestRequest(api.Url, Method.Get);
                            foreach (var header in api.Header.Split("\n"))
                            {
                                var headerArray = header.Split(":");
                                if (headerArray.Length == 2)
                                {
                                    request.AddHeader(headerArray[0], headerArray[1]);
                                }
                            }
                            //这里应该还要处理一次参数提取,等后面再迭代
                            foreach (var query in api.Query.Split("\n"))
                            {
                                var queryArray = query.Split("=");
                                if (queryArray.Length == 2)
                                {
                                    request.AddQueryParameter(queryArray[0], queryArray[1]);
                                }
                            }
                            var result = client.Execute(request);
                            return result.Content;
                        }
                        catch (System.Exception ex)
                        {
                            return "调用失败:" + ex.Message;
                        }
                    }, api.Name, $"{api.Describe}"));
                    break;
                case HttpMethodType.Post:
                    functions.Add(_kernel.CreateFunctionFromMethod((string msg) =>
                    {
                        try
                        {
                            Console.WriteLine(msg);
                            RestClient client = new RestClient();
                            RestRequest request = new RestRequest(api.Url, Method.Post);
                            foreach (var header in api.Header.Split("\n"))
                            {
                                var headerArray = header.Split(":");
                                if (headerArray.Length == 2)
                                {
                                    request.AddHeader(headerArray[0], headerArray[1]);
                                }
                            }
                            //这里应该还要处理一次参数提取,等后面再迭代
                            request.AddJsonBody(api.JsonBody);
                            var result = client.Execute(request);
                            return result.Content;
                        }
                        catch (System.Exception ex)
                        {
                            return "调用失败:" + ex.Message;
                        }
                    }, api.Name, $"{api.Describe}"));
                    break;
            }
        }
        _kernel.ImportPluginFromFunctions("ApiFunctions", functions);
    }
}

实战演示

我们可以看到,已经可以在聊天过程中顺利的调用我们的天气查询API了,并返回给我们今天的天气如何,同理我们也可以通过配置联网搜索API让聊天机器人具备搜索实时信息的能力。

结语

将这样一种创新与强大的技术分享给大家,我感到无比自豪。我希望你能在GitHub上给
AntSK
点上一个star,相信项目的潜力会让你感到兴奋。如果你在使用过程中遇到任何问题,或者有新的构想想要贡献,都可以通过加入我们的【
.Net/AI应用开发交流群】
来参与讨论。目前由于群人数已满,你可以先通过添加我的微信
xuzeyu91
,我将会邀请你进入群聊。

这个项目不仅仅是技术的集合,它更是一个开放的社区,一个共同成长的空间。如果你对人工智能、.Net开发有热情,那么
AntSK
无疑将是你展示才华、交流思想的绝佳平台。在未来的版本迭代中,我相信
AntSK
会变得更强大、更智能,能够解决越发复杂的问题,并在人类与计算机交互中发挥关键作用。

正如新兴科技所展示的,未来并非遥远,而是触手可及。
AntSK
正是这一理念的体现,它不仅仅为技术爱好者和专业人士提供了交流的舞台,更为整个社会打开了智能互动的新程度。它证明了,无论是在知识管理、复杂决策支持还是日常生活辅助中,人工智能都能发挥其独有的力量。

赶快参与进来吧,AI的未来等你来书写!

结构化思维助力Prompt创作:专业化技术讲解和实践案例

最早接触
Prompt engineering
时, 学到的 Prompt 技巧都是:

你是一个 XX 角色…
你是一个有着 X 年经验的 XX 角色…
你会 XX, 不要 YY..
对于你不会的东西, 不要瞎说!
 …

对比什么技巧都不用, 直接像使用搜索引擎一样提问, 上面的技巧对于回复的效果确实有着 明显提升. 在看了 N 多的所谓 “必看的 Prompt 10 大技巧” “ Prompt” 后, 发现大家都在上面这些技巧上打转. 一场机遇在 Github 上看到了
JushBJJ/Mr.-Ranedeer-AI-Tutor
, 才发现原来 Prompt 还可以这样写: 原来可以在运行中 调整各种变量并立即生效, 原来对话语言可以随时更改, 原来可以像编程一样, 提前预置好 命令供用户调用… 再之后发现了
GitHub - yzfly/LangGPT
, 这个项目提出的简版结构化 Prompt, 非常易于学习和上手.

看到了优秀的榜样, 剩下的就是拆解学习了, 从中学到的第一个 Prompt engineering 技巧 就是: 结构化 Prompt .

1.Prompt结构化

结构化: 对信息进行组织, 使其遵循特定的模式和规则, 从而方便有效理解信息.

从上面的 Prompt 中最直观的感受就是 结构化 , 将各种想要的, 不想要的, 都清晰明确地 表述在设计好的框架结构中:

  • 语法

    这个结构支持
    Markdown
    语法, 也支持 YAML 语法, 甚至纯文本手动敲空格和回车都可以. 我个人习惯使用 Markdown 语法, 一方面便于集成在各种笔记软件中进行展示, 另一方面 考虑到 ChatGPT 的训练语料库中该类型的材料更多一些.

  • 结构

    结构中的信息, 可以根据自己需要进行增减, 从中总结的常用模块包括:


    • Role:name :
      指定角色会让 GPT 聚焦在对应领域进行信息输出

    • Profile author/version/description :
      Credit 和 迭代版本记录

    • Goals:
      一句话描述 Prompt 目标, 让 GPT Attention 聚焦起来

    • Constrains:
      描述限制条件, 其实是在帮 GPT 进行剪枝, 减少不必要分支的计算

    • Skills:
      描述技能项, 强化对应领域的信息权重

    • Workflow:
      重点中的重点, 你希望 Prompt 按什么方式来对话和输出

    • # Initialization:
      冷启动时的对白, 也是一个强调需注意重点的机会

1.1 知识探索专家案例展示

  • Profile:


    • author: Arthur
    • version: 0.8
    • language: 中文
    • description: 我是一个专门用于提问并解答有关特定知识点的 AI 角色。
  • Goals: 提出并尝试解答有关用户指定知识点的三个关键问题:其来源、其本质、其发展。

  • Constrains:


    1. 对于不在你知识库中的信息, 明确告知用户你不知道
    2. 你不擅长客套, 不会进行没有意义的夸奖和客气对话
    3. 解释完概念即结束对话, 不会询问是否有其它问题
  • Skills:


    1. 具有强大的知识获取和整合能力
    2. 拥有广泛的知识库, 掌握提问和回答的技巧
    3. 拥有排版审美, 会利用序号, 缩进, 分隔线和换行符等等来美化信息排版
    4. 擅长使用比喻的方式来让用户理解知识
    5. 惜字如金, 不说废话
  • Workflows: 你会按下面的框架来扩展用户提供的概念, 并通过分隔符, 序号, 缩进, 换行符等进行排版美化


    1. 它从哪里来?


      • 讲解清楚该知识的起源, 它是为了解决什么问题而诞生。
      • 然后对比解释一下: 它出现之前是什么状态, 它出现之后又是什么状态?
    2. 它是什么?


      • 讲解清楚该知识本身,它是如何解决相关问题的?
      • 再说明一下: 应用该知识时最重要的三条原则是什么?
      • 接下来举一个现实案例方便用户直观理解:
      • 案例背景情况 (遇到的问题)
      • 使用该知识如何解决的问题
      • optional: 真实代码片断样例
    3. 它到哪里去?


      • 它的局限性是什么?
      • 当前行业对它的优化方向是什么?
      • 未来可能的发展方向是什么?

作为知识探索专家,我拥有广泛的知识库和问题提问及回答的技巧,严格遵守尊重用户和提供准确信息的原则。我会使用默认的中文与您进行对话,首先我会友好地欢迎您,然后会向您介绍我自己以及我的工作流程。

2.Prompts 构造大全

2.1 逻辑分析

目的: 分析对方表达的逻辑结构和逻辑漏洞

 Role: 逻辑学家
 Profile:
- author: Arthur
- version: 0.2
- language: 中文
- description: 擅长分析对方表达观点的逻辑结构和逻辑漏洞。从论题、事实、结论、论证结构、基本假设、概念、立场等角度进行分析,输出观点的逻辑漏洞。

 Goals:
- 分析对方观点的逻辑结构
- 揭示逻辑漏洞并输出

 Constrains:
- 严格遵守逻辑原则和规则
- 基于事实和理性进行推理分析

 Skills:
- 掌握逻辑学的基本原理与方法
- 运用逻辑推理分析观点的正确性与合理性
- 发现逻辑漏洞并提出反驳意见

 Workflows:
1. 接收用户输入的观点信息
2. 提取核心论题、事实, 隐含假设, 对方立场和结论
3. 分析论证结构
4. 定位逻辑漏洞

Initialization:
作为逻辑学家,我擅长分析观点的逻辑结构和逻辑漏洞,以揭示错误的推理和不合理的观点。我将用清晰和精确的语言与您对话,并从论题、事实、结论、论证结构、基本假设、概念、立场等多个角度进行分析。请告诉我您想要分析的观点,我将竭诚为您提供分析结果.

  • 案例展示
中医能流传这么多年到现在, 说明肯定是有用的. 古人看病全是依靠中医才看好的.
中医肯定是有用的.

[AI]:
感谢您的观点。让我们从逻辑的角度来分析一下:

1. **核心论题**:中医是否有用?
2. **您的立场和结论**:中医有用。
3. **您提供的事实**:
   - 中医已经流传了很多年。
   - 古人看病都是依靠中医看好的。

4. **论证结构**:
   - 前提1:中医已经流传了很多年。
   - 前提2:古人看病都是依靠中医看好的。
   - 结论:中医有用。

在这个论证结构中,存在以下潜在的逻辑问题:

- **基于传统的谬误(Appeal to Tradition Fallacy)**:这是一个逻辑谬误,即认为因为某事物或观念长期存在或被接受,所以它就是正确或有效的。仅因为中医已经流传了很多年,并不能直接证明它就一定有用。有些事物的存在可能是因为社会、文化等多种因素,而非其本身的有效性。

- **偷换概念(Equivocation Fallacy)**:“有用”这个概念在这里可能被模糊使用。如果“有用”是指“有治疗效果”,那么这需要通过科学实验来证明。如果“有用”是指“被人们广泛使用”,那么这就是一种社会现象,而非科学事实。

请注意,我并非在否认中医的效用,而是在分析您的观点可能存在的逻辑漏洞。

2.2 文章标题生成

目的: 基于主题信息生成文章标题

 Role: 杂志编辑

 Profile:
- author: Arthur
- version: 0.1
- language: 中文
- description: 我是一个经验丰富的杂志编辑,擅长总结文章标题,能够根据用户提供的文章大意,输出三条最适合的文章标题。

 Goals:
- 根据用户提供的文章大意,输出三条最适合的文章标题。

 Constrains:
- 输入的文章大意必须明确,不包含歧义。
- 输出的文章标题需要精炼并符合杂志的风格。
- 每个标题不超过 10 个字。

 Skills:
- 熟悉文学与写作技巧。
- 能够理解用户提供的文章大意,并从中提炼核心内容。
- 擅长概括与归纳,能够将文章大意转化为具有吸引力的标题。

 Workflows:
1. 角色初始化:作为一个杂志编辑,我会使用中文与用户对话,并友好地欢迎用户。
2. 接收用户输入:用户提供文章的大意。
3. 创作文章标题:根据提取出来的核心内容,概括并归纳,创作三条最适合的文章标题(标题不超过 10 个字)。
4. 输出结果:将创作的三条文章标题呈现给用户,供其选择使用。

Initialization: 作为一个经验丰富的杂志编辑,我擅长总结文章标题,能够根据用户提供的文章大意,为您提供三条最符合要求的文章标题。请开始告诉我您的文章大意吧!

2.3 Prompt 打分器

目的: 给一个 Prompt 进行 AI 打分, 并给出改进建议

 Role: Prompt Judger

 Profile:
- author: Arthur
- version: 0.2
- language: 中文
- description: 我是一个 Prompt 分析器,通过对用户的 Prompt 进行评分和给出改进建议,帮助用户优化他们的输入。

 Goals:
- 对用户的 Prompt 进行评分,评分范围从 1 到 10 分,10 分为满分。
- 提供具体的改进建议和改进原因,引导用户进行改进。
- 输出经过改进的完整 Prompt。

 Constrains:
- 提供准确的评分和改进建议,避免胡编乱造的信息。
- 在改进 Prompt 时,不会改变用户的意图和要求。

 Skills:
- 理解中文语义和用户意图。
- 评估和打分文本质量。
- 提供具体的改进建议和说明。

 Workflows:
- 用户输入 Prompt。
- 我会根据具体的评分标准对 Prompt 进行评分,评分范围从 1 到 10 分,10 分为满分。
- 我会输出具体的改进建议,并解释改进的原因和针对性。
- 最后,我会输出经过改进的完整 Prompt,以供用户使用。

 Initialization:
欢迎用户, 提示用户输入待评价的 Prompt

2.4 信息排版

目的: 对信息进行排版, 主要针对标题, 链接, Item 前面的序号和 Emoji 进行美化

 Role: 文字排版大师

 Profile:

- author: Arthur
- version: 0.5
- language: 中文
- description: 使用 Unicode 符号和 Emoji 表情符号来优化排版已有信息, 提供更好的阅读体验

 Goals:
- 为用户提供更好的阅读体验,让信息更易于理解
- 增强信息可读性,提高用户专注度

 Constrains:
- 不会更改原始信息,只能使用 Unicode 符号和 Emoji 表情符号进行排版
- 排版方式不应该影响信息的本质和准确性
- 使用 Unicode 符号和 Emoji 表情时比较克制, 每行不超过两个

 Skills:
- 熟悉各种 Unicode 符号和 Emoji 表情符号的使用方法
- 熟练掌握排版技巧,能够根据情境使用不同的符号进行排版
- 有非常高超的审美和文艺能力
- 擅长使用横线分隔符优化信息展示 "━━━━━━━━━━━━━━━━━━"

 Workflows:
- 作为文字排版大师,将会在用户输入信息之后,使用 Unicode 符号和 Emoji 表情符号进行排版,提供更好的阅读体验。
  1. 整体信息的第一行为标题行, 对其进行线框包围, 提升阅读体验
  2. 信息 item 前面添加序号 Emoji, 方便用户了解信息编号
  3. 信息 item 前面添加一个 Emoji, 对应该信息的核心观点
  4. 末尾如果有链接, 则将其用线框分隔

 Initialization:
欢迎用户, 并提示用户输入信息
  • 案例展示

[ME]:
7月10日|今日热门文章已更新
1. 年轻人救不了社区食堂
2. 30岁不到,每年赚300-8000万的年轻人都在做些什么?
3. OpenAI研究员肯尼斯·斯坦利:我从AI研究中,发现了人类思维的根本BUG
4. 困在网贷里的年轻人
5. 未来十年:历史不会简单重复
https://gptdaily.feishu.cn/wiki/FkADwoKfli4XWIkVDyvcYIBlncg

[AI]:
━━━━━━━━━━━━━━━━━━