2024年3月

STM32FATFS文件系统移植

1。 FATFS简介

FATFS文件系统是一个用于在微控制器上运行的开源文件系统,支持FAT/FATFS、NTFS、exFAT等主流文件系统,且一直保持更新。在此以FatFs官网最新版本v0.15进行移植。

2. 移植具体操作

2.1 下载FatFs源码

FATFS源码在其官网就有下载链接,下载后解压即可,官网页面如图1所示:

alt text

图1.FATFS官网页面

将其翻至最下面,就可以找到下载链接,如图2所示:

alt text

图2.FATFS下载链接

2.2 FATFS代码结构

FATFS源码解压后,其一级目录结构如图3所示:

alt text

图3.FATFS源码一级目录结构

其source文件夹下各文件作用如下所示:

source
├── 00history.txt   //历史版本信息
├── 00readme.txt    //FATFS简介
├── diskio.c        //磁盘IO适配文件
├── diskio.h        //磁盘IO适配头文件
├── ff.c            //FATFS主要实现文件
├── ff.h            //FATFS主要实现头文件
├── ffconf.h        //FATFS配置头文件
├── ffsystem.c      //系统调用适配文件
└── ffunicode.c     //Unicode适配文件

在移植过程中,主要对diskio.c、ffconf.h进行修改。

2.3 修改前后文件对比

后文所有代码比对均默认左侧为原代码,右侧为修改后的代码。

2.3.1 diskio.c文件修改比对

diskio.c文件主要实现对存储介质的硬件适配,需要将FLASH读写、初始化等代码移植至此文件内的固定接口。

2.3.1.1头文件添加及宏定义变量修改对比

如图4所示:

alt text

图4.diskio.c文件头文件添加及宏定义变量修改对比
#include "Spi.h"        //引用SPI初始化
#include "W25q64.h"     //引用FLASH文件操作函数,文件内代码均在上篇文章中。
#define SPI_FLASH 3     //定义驱动卷名

FATFS中文件目录格式为为驱动卷名+“:”+文件名,假如说SPI_FLASH文件系统一级目录内有“aaa.txt”这个文件,那么其目录格式为"3:aaa.txt"。3就是宏定义的驱动卷名,文件系统挂载什么的如果要挂载SPIFLASH就是挂载"3:"目录,其余驱动卷或设备同理。

在C语言中上述的写法更简单,C语言支持宏定义的字符串拼接,具体示范如下列代码所示:

#define SPI_FLASH_DIR "3:"

res_flash = f_mount(&fs,SPI_FLASH_DIR,1);

res_flash = f_open(&fnew,SPI_FLASH_DIR"ABC.txt",FA_CREATE_ALWAYS | FA_WRITE);
#define SPI_FLASH_DIR 3

res_flash = f_mount(&fs,"3:",1);

res_flash = f_open(&fnew,"3:ABC.txt",FA_CREATE_ALWAYS | FA_WRITE);

这两段代码在实现效果上并无差别。

2.3.1.2 磁盘存储介质状态查询接口修改对比

如图5所示:

alt text

图5.diskio.c文件磁盘存储介质状态查询修改对比

需要将对磁盘存储介质的硬件状态查询移植至此文件内的固定接口。具体操作为在switch (pdrv) 内添加一条分支,返回值分为STATUS_NOINIT、STATUS_NODISK、STATUS_PROTECT与RES_OK四种情况,分别代表磁盘存储介质未初始化、没有对应驱动卷名、磁盘写保护与磁盘正常。

2.3.1.3 磁盘存储介质初始化接口修改对比

如图6所示:

alt text

图6.diskio.c文件磁盘存储介质初始化修改对比

需要对磁盘存储介质的硬件初始化移植至此文件内的固定接口。具体操作为在switch (pdrv) 内添加一条分支,调用SPI_FLASH_Init()函数进行初始化。可用返回值与状态查询的返回值一致,在这里我图省事,直接调用了状态查询函数。

2.3.1.4 磁盘存储介质数据读取接口修改对比

如图7所示:

alt text

图7.diskio.c文件磁盘存储介质数据读取接口修改对比

需要对磁盘存储介质的硬件数据读取移植至此文件内的固定接口。具体操作为在switch (pdrv) 内添加一条分支,调用SPI_FLASH_Read()函数进行读取。其中读取数据的存储地址为*buff,读取数据的扇区逻辑位号为sector,读取数据的扇区数量为count。

在图7中可以看到,扇区逻辑区块地址(LBA)与扇区数量均左移12位,即乘4096。然而这两个数据乘4096的原因不一样。扇区逻辑区块地址乘4096是为了将扇区逻辑区块地址转化位扇区物理地址,而扇区数量乘4096是为了将扇区数量转化为扇区数据读取数量。

PS:LBA是非常单纯的一种定址模式﹔从0开始编号来定位区块,第一区块LBA=0,第二区块LBA=1,依此类推。这种定址模式取代了原先操作系统必须面对存储设备硬件构造的方式。

2.3.1.5 磁盘存储介质数据写入接口修改对比

如图8所示:

alt text

图8.diskio.c文件磁盘存储介质数据写入接口修改对比

需要对磁盘存储介质的硬件数据写入移植至此文件内的固定接口。具体操作为在switch (pdrv) 内添加一条分支,调用SPI_FLASH_Write()函数进行写入。其中写入数据的存储地址为*buff,写入数据的扇区逻辑位号为sector,写入数据的扇区数量为count。至于为什么向左移12位,与读取数据相同,都是将扇区逻辑区块地址转化为扇区物理地址,将扇区数量转化为扇区数据写入数量。

2.3.1.6 磁盘存储介质信息接口修改对比

如图9所示:

alt text

图9.diskio.c文件磁盘存储介质信息接口修改对比

需要对磁盘存储介质的硬件信息查询移植至此文件内的固定接口。具体操作为在switch (pdrv) 内添加一条分支,在其中添加一个switch,检索传入的cmd,需要对cmd建立3条分支,分别为GET_SECTOR_COUNT、GET_SECTOR_SIZE与GET_BLOCK_SIZE,分别将物理扇区总数量、物理扇区大小与擦除块数量返回给*buff并返回RES_OK即可。

2.3.1.7 磁盘存储介质写入时间函数

在FATFS文件系统中并不附带获取时间的函数接口,但是创建文件、修改文件等操作都需要获取当前时间,因此需要添加一个获取当前时间的函数接口。具体操作为使用弱定义,定义FATFS文件系统中的获取时间函数内容

__weak DWORD get_fattime(void)              // 获取时间
{
	return 		((DWORD)(2024-1980)<<25)    // 设置年份为2024
					|	((DWORD)1<<21)      // 设置月份为1
					|	((DWORD)1<<16)      // 设置日期为1
					|	((DWORD)1<<11)      // 设置小时为1
					|	((DWORD)1<<5)       // 设置分钟为1
					|	((DWORD)1<<1);      // 设置秒数为1
}

FATFS采用时间戳的方式来记录时间,具体格式如图10所示:

alt text

图10.FATFS文件系统时间戳格式

2.3.2 ffconf.h文件修改对比

ffconf.h文件为FATFS文件系统配置文件,其中定义了FATFS文件系统的配置参数。

2.3.2.1 文件系统格式化宏定义修改对比

如图11所示:

alt text

图11.文件系统格式化宏定义修改对比

这个选项会打开f_mkfs()函数,允许对文件系统格式化,或在没有文件系统的情况下建立文件系统

2.3.2.2 文件系统命名格式与命名空间宏定义修改对比

如图12所示:

alt text

图12.文件系统命名格式与命名空间宏定义修改对比

由于FATFS文件系统默认命名为日文,需要将FF_CODE_PAGE的值修改,以支持中文命名。FF_USE_LEN决定了实现长文件支持所使用的内存方式,0为不使用LFN,1为使用LFN,但没有线程安全,2为使用LFN并使用堆内存,3为使用LFN并使用栈内存。

2.3.2.3 文件系统驱动卷数量与最大扇区内存修改对比

如图13所示:

alt text

图13.文件系统驱动卷数量与最大扇区内存修改对比

修改FF_VOLUMES的值,以便支持多个卷。修改FF_MAX_SS的值,以便支持更大的扇区。

2.3.2.5 文件系统时间戳宏定义修改对比

如图14所示:

alt text

图14.文件系统时间戳宏定义修改对比

FF_FS_NORTC=1表示使用时间函数,FF_FS_NORTC=-1表示不使用时间函数。

3. 移植后main文件使用演示

首先需要引用“ff.h”头文件,然后定义如下全局变量:

FATFS fs;
FIL fnew;
FRESULT res_flash;
UINT fnum;

由于FATFS文件系统的结构体都比较大,在main函数中定义会导致堆栈溢出。

然后定义一些临时变量,用于存储文件名和文件内容以及FATFS文件系统运行状态:

BYTE buffer[4096] = {0};
BYTE textFileBuffer[] = "ABCDEFG";
uint8_t c[256] = {0};

我所使用的开发板为野火STM32F103指南者开发板,驱动卷命名为"3",具体执行代码如下所示

int main()
{
	HSE_SetSysClock(RCC_PLLMul_9);                          // 设置系统时钟为9倍,72MHz
	Usart_init();                                           // 初始化串口
	USART_SendString(USART1,"Systeam is OK.");              
	res_flash = f_mount(&fs,"3:",1);                        // 挂载文件系统
	USART_SendByte(USART1,res_flash);   
    if(res_flash == FR_NO_FILESYSTEM)                       // 检测是否存在文件系统
	{
		res_flash = f_mkfs("3:",NULL,buffer,4096);          // 创建文件系统
		if(res_flash == FR_OK)                              // 判断是否创建成功
		{
			USART_SendString(USART1,"FATFS has been mkf."); 
			res_flash = f_mount(NULL,"3:",0);               // 卸载文件系统
			res_flash = f_mount(&fs,"3:",1);                // 重新挂载文件系统
		}
		else                                                // 创建失败
		{
			USART_SendString(USART1,"FATFS mkf filed.");    
			USART_SendByte(USART1,res_flash);
            while(1)                                        // 死循环
            {
            }               
		}
	}
	else if(res_flash !=FR_OK)                              // 挂载失败
	{
		USART_SendString(USART1,"mount ERROR.");
        while(1)                                            // 死循环
        {
        }
	}
	else                                                    // 挂载成功
	{
		USART_SendString(USART1,"mount OK.");
	}
	res_flash = f_open(&fnew,"3:ABC.txt",FA_CREATE_ALWAYS | FA_WRITE);  // 创建文件
	USART_SendByte(USART1,res_flash);                       
	if(res_flash == FR_OK)                                  // 判断是否创建成功
	{
		USART_SendString(USART1,"File open is OK.");
	}
	res_flash = f_write(&fnew,"ABCDEFG",7,&fnum);           // 写入数据
	if(res_flash == FR_OK)                                  // 判断是否写入成功
	{
		USART_SendString(USART1,"File write is OK.");
	}
	else                                                    // 写入失败
	{
		USART_SendByte(USART1,res_flash);
	}
	f_close(&fnew);                                         // 关闭文件
	if(res_flash == FR_OK)                                  // 判断是否关闭成功
	{
		USART_SendString(USART1,"File close is OK.");
	}
	else                                                    // 关闭失败
	{
		USART_SendByte(USART1,res_flash);
	}
	res_flash = f_unmount("3:");                            // 卸载文件系统
	USART_SendByte(USART1,res_flash);                       
	res_flash = f_mount(&fs,"3:",1);                        // 重新挂载文件系统
	USART_SendByte(USART1,res_flash);                       // 判断是否重新挂载成功
	res_flash = f_open(&fnew,"3:ABC.txt",FA_OPEN_EXISTING | FA_READ);   // 打开文件
	if(res_flash == FR_OK)                                  // 判断是否打开成功
	{
		USART_SendString(USART1,"File open is OK.");        
		USART_SendString(USART1,c);
	}
	else                                                    // 打开失败
	{
		USART_SendByte(USART1,res_flash);
	}
	res_flash = f_read(&fnew,c,7,&fnum);                    // 读取文件内容
	if(res_flash == FR_OK)                                  // 判断是否读取成功
	{
		USART_SendString(USART1,"File read is OK.");
		USART_SendString(USART1,c);
	}
	else                                                    // 读取失败
	{
		USART_SendByte(USART1,res_flash);
	}
	f_close(&fnew);                                         // 关闭文件
	res_flash = f_unmount("3:");                            // 卸载文件系统
	USART_SendByte(USART1,res_flash);
	if(res_flash == FR_OK)                                  // 判断是否卸载成功
	{
		USART_SendString(USART1,"unmount OK.");
	}
	while(1){

	}
}

烧录结果

如图15所示:
alt text

图15.烧录结果

实现分布式锁通常有三种方式:数据库、Redis 和 Zookeeper。我们比较常用的是通过 Redis 和 Zookeeper 实现分布式锁。Redisson 框架中封装了通过 Redis 实现的分布式锁,下面我们分析一下它的具体实现。

by emanjusaka from
https://www.emanjusaka.top/2024/03/redisson-distributed-lock
彼岸花开可奈何
本文欢迎分享与聚合,全文转载请留下原文地址。

关键点

  1. 原子性

    要么都成功,要么都失败

  2. 过期时间

    如果锁还没来得及释放就遇到了服务宕机,就会出现死锁的问题。给 Redis 的 key 设置过期时间,即使服务宕机了超过设置的过期时间锁会自动进行释放。

  3. 锁续期

    因为给锁设置了过期时间而我们的业务逻辑具体要执行多长时间可能是变化和不确定的,如果设定了一个固定的过期时间,可能会导致业务逻辑还没有执行完,锁被释放了的问题。锁续期能保证锁是在业务逻辑执行完才被释放。

  4. 正确释放锁

    保证释放自己持有的锁,不能出现 A 释放了 B 持有锁的情况。

Redis 实现分布式锁的几种部署方式

  1. 单机

    在这种部署方式中,Redis 的所有实例都部署在同一台服务器上。这种部署方式简单易行,但存在单点故障的风险。如果 Redis 实例宕机,则所有分布式锁都将失效。

  2. 哨兵

    在这种部署方式中,Redis 的多个实例被配置为哨兵。哨兵负责监控 Redis 实例的状态,并在主实例宕机时自动选举一个新的主实例。这种部署方式可以提供更高的可用性和容错性。

  3. 集群

    在这种部署方式中,Redis 的多个实例被配置为一个集群。集群中的每个实例都是平等的,并且可以处理读写操作。这种部署方式可以提供最高的可用性和容错性。

  4. 红锁

    搞几个独立的 Master,比如 5 个,然后挨个加锁,只要超过一半以上(这里是 5/2+1=3 个)就代表加锁成功,然后释放锁的时候也逐台释放。

使用方式

  1. 引入依赖

    <!--        pom.xml文件-->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.17.7</version>
    </dependency>
    

    版本依赖:


    redisson-spring-data module name Spring Boot version
    redisson-spring-data-16 1.3.y
    redisson-spring-data-17 1.4.y
    redisson-spring-data-18 1.5.y
    redisson-spring-data-2x 2.x.y
    redisson-spring-data-3x 3.x.y
  2. yml配置

    spring:
      redis:
        redisson:
          config:
            singleServerConfig:
              address: redis://127.0.0.1:6379
              database: 0
              password: null
              timeout: 3000
    
  3. 直接注入使用

    package top.emanjusaka;
    
    import org.redisson.api.RLock;
    import org.redisson.api.RedissonClient;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.concurrent.TimeUnit;
    
    /**
     * @Author emanjusaka www.emanjusaka.top
     * @Date 2024/2/28 16:41
     * @Version 1.0
     */
    @Service
    public class Lock {
        @Resource
        private RedissonClient redissonClient;
    
        public void lock() {
            // 写入redis的key值
            String lockKey = "lock-test";
            // 获取一个Rlock锁对象
            RLock lock = redissonClient.getLock(lockKey);
            // 获取锁,并为其设置过期时间为10s
            lock.lock(10, TimeUnit.SECONDS);
            try {
                // 执行业务逻辑....
                System.out.println("获取锁成功!");
            } finally {
                // 释放锁
                lock.unlock();
                System.out.println("释放锁成功!");
            }
        }
    
    }
    

底层剖析

lock()

关键代码

    <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return commandExecutor.syncedEval(getRawName(), LongCodec.INSTANCE, command,
                "if ((redis.call('exists', KEYS[1]) == 0) " +
                       "or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                    "end; " +
                    "return redis.call('pttl', KEYS[1]);",
                Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
    }
  • RFuture<T>
    :表示返回一个异步结果对象,其中泛型参数 T 表示结果的类型。

  • tryLockInnerAsync
    方法接受一下参数:


    • waitTime
      :等待时间,用于指定在获取锁时的最大等待时间。
    • leaseTime
      :租约时间,用于指定锁的持有时间
    • unit
      :时间单位,用于将 leaseTime 转换为毫秒
    • threadId
      :线程 ID,用于标识当前线程
    • command
      :Redis 命令对象,用于执行 Redis 操作
  • 方法体中的代码使用 Lua 脚本来实现分布式锁的逻辑。


    • if ((redis.call('exists', KEYS[1]) == 0) or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)): 如果键不存在或者哈希表中已经存在对应的线程ID,则执行以下操作:
      • redis.call('hincrby', KEYS[1], ARGV[2], 1): 将哈希表中对应线程ID的值加1。
      • redis.call('pexpire', KEYS[1], ARGV[1]): 设置键的过期时间为租约时间。
      • return nil: 返回nil表示成功获取锁。
    • else: 如果键存在且哈希表中不存在对应的线程ID,则执行以下操作:
      • return redis.call('pttl', KEYS[1]): 返回键的剩余生存时间。
  • commandExecutor.syncedEval
    :表示同步执行 Redis 命令

  • LongCodec.INSTANCE
    :用于编码和解码长整型数据

  • Collections.singletonList(getRawName())
    :创建一个只包含一个元素的列表,元素为锁的名称

  • unit.toMillis(leaseTime)
    :将租约时间转换为毫秒

  • getLockName(threadId)
    :根据线程 ID 生成锁的名称

// 省去了那些无关重要的代码
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    // tryAcquire就是上面分析的lua完整脚本
    Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
    // 返回null就代表上锁成功。
    if (ttl == null) {
        return;
    }
    // 如果没成功,也就是锁的剩余时间不是null的话,那么就执行下面的逻辑
    // 其实就是说 如果有锁(锁剩余时间不是null),那就死循环等待重新抢锁。
    try {
        while (true) {
            // 重新抢锁
            ttl = tryAcquire(-1, leaseTime, unit, threadId);
            // 抢锁成功就break退出循环
            if (ttl == null) {
                break;
            }
            // 省略一些代码
        }
    } finally {}
}

上面代码实现了一个分布式锁的功能。它使用了Lua脚本来尝试获取锁,并在成功获取锁后返回锁的剩余时间(ttl)。如果获取锁失败,则进入一个死循环,不断尝试重新获取锁,直到成功为止。

unlock()

关键代码

    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
              "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                        "return nil;" +
                    "end; " +
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                    "if (counter > 0) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                    "else " +
                        "redis.call('del', KEYS[1]); " +
                        "redis.call(ARGV[4], KEYS[2], ARGV[1]); " +
                        "return 1; " +
                    "end; " +
                    "return nil;",
                Arrays.asList(getRawName(), getChannelName()),
                LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId), getSubscribeService().getPublishCommand());
    }
  • RFuture<Boolean>
    : 表示返回一个异步结果对象,其中泛型参数Boolean表示结果的类型。
  • unlockInnerAsync
    方法接受以下参数:
    • threadId
      : 线程ID,用于标识当前线程。
  • 方法体中的代码使用Lua脚本来实现分布式锁的解锁逻辑。以下是对Lua脚本的解释:
    • if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)
      : 如果哈希表中不存在对应的线程ID,则返回nil表示无法解锁。
    • local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1)
      : 将哈希表中对应线程ID的值减1,并将结果赋值给变量counter。
    • if (counter > 0)
      : 如果counter大于0,表示还有其他线程持有锁,执行以下操作:
      • redis.call('pexpire', KEYS[1], ARGV[2])
        : 设置键的过期时间为租约时间。
      • return 0
        : 返回0表示锁仍然被其他线程持有。
    • else
      : 如果counter等于0,表示当前线程是最后一个持有锁的线程,执行以下操作:
      • redis.call('del', KEYS[1])
        : 删除键,释放锁。
      • redis.call(ARGV[4], KEYS[2], ARGV[1])
        : 调用发布命令,通知其他线程锁已经释放。
      • return 1
        : 返回1表示成功释放锁。
    • return nil
      : 如果前面的条件都不满足,返回nil表示无法解锁。
  • evalWriteAsync
    方法用于执行Lua脚本并返回异步结果对象。
  • getRawName()
    : 获取锁的名称。
  • LongCodec.INSTANCE
    : 用于编码和解码长整型数据。
  • RedisCommands.EVAL_BOOLEAN
    : 指定Lua脚本的返回类型为布尔值。
  • Arrays.asList(getRawName(), getChannelName())
    : 创建一个包含两个元素的列表,元素分别为锁的名称和频道名称。
  • LockPubSub.UNLOCK_MESSAGE
    : 发布消息的内容。
  • internalLockLeaseTime
    : 锁的租约时间。
  • getLockName(threadId)
    : 根据线程ID生成锁的名称。
  • getSubscribeService().getPublishCommand()
    : 获取发布命令。

锁续期

watchDog

核心工作流程是定时监测业务是否执行结束,没结束的话在看你这个锁是不是快到期了(超过锁的三分之一时间),那就重新续期。这样防止如果业务代码没执行完,锁却过期了所带来的线程不安全问题。

Redisson 的 watchDog 机制底层不是调度线程池,而是直接用的 netty 事件轮。

Redisson的WatchDog机制是用于自动续期分布式锁和监控对象生命周期的一种机制,确保了分布式环境下锁的正确性和资源的及时释放。

  1. 自动续期:当Redisson客户端获取了一个分布式锁后,会启动一个WatchDog线程。这个线程负责在锁即将到期时自动续期,保证持有锁的线程可以继续执行任务。默认情况下,锁的初始超时时间是30秒,每10秒钟WatchDog会检查一次锁的状态,如果锁依然被持有,它会将锁的过期时间重新设置为30秒。
  2. 参数配置:可以通过设置lockWatchdogTimeout参数来调整WatchDog检查锁状态的频率和续期的超时时间。这个参数默认值是30000毫秒(即30秒),适用于那些没有明确指定leaseTimeout参数的加锁请求。
  3. 重连机制:除了锁自动续期外,WatchDog机制还用作Redisson客户端的自动重连功能。当客户端与Redis服务器失去连接时,WatchDog会自动尝试重新连接,从而恢复服务的正常运作。
  4. 资源管理:WatchDog也负责监控Redisson对象的生命周期,例如分布式锁。当对象的生命周期到期时,WatchDog会将其从Redis中删除,避免过期数据占用过多内存空间。
  5. 异步加锁:在加锁的过程中,WatchDog会在RedissonLock#tryAcquireAsync方法中发挥作用,该方法是进行异步加锁的逻辑所在。通过这种方式,加锁操作不会阻塞当前线程,提高了系统的性能。

本文原创,才疏学浅,如有纰漏,欢迎指正。如果本文对您有所帮助,欢迎点赞,并期待您的反馈交流,共同成长。
原文地址:
https://www.emanjusaka.top/2024/03/redisson-distributed-lock
微信公众号:emanjusaka的编程栈

单点登录(Single Sign-On, SSO)是一种让用户在多个应用系统之间只需登录一次就可以访问所有授权系统的机制。单点登录主要目的是为了提高用户体验并简化安全管理。

举个例子,您在一个大型企业工作,该企业拥有一套由多个独立应用程序组成的生态系统,例如:内部邮箱系统、项目管理系统、员工自助服务系统、人力资源信息系统等。

而这些系统在没有实施单点登录的情况下会出现以下问题:

  1. 用户体验方面
    : 每天开始工作时,员工需要分别登录每一个系统才能正常开展工作,这不仅耗时,而且容易造成密码疲劳,即频繁记忆和输入不同系统的登录凭证,降低了工作效率。举例:员工小王每天上班要先登录内部邮箱查看重要通知,然后切换至项目管理系统更新进度,接着进入人力资源信息系统查看工资单。如果没有 SSO,他需要在每个系统单独输入用户名和密码。
  2. 安全管理方面
    : 各个系统间的密码策略可能不一致,员工可能会因为难以记忆而在多个系统使用同一密码,增加了数据泄露的风险。同时,管理员对用户账户的管理、权限变更及审计也会变得复杂。举例:若小王在每个系统使用相同密码,一旦某一系统存在安全隐患导致密码泄露,攻击者就有可能借此尝试登录其他系统。而有了 SSO,管理员只需在一处更改或撤销小王的登录权限,就能影响所有相关系统。

采用单点登录后,小王只需在一天开始时登录一次,之后访问其他所有系统时都将自动识别其身份并授权访问,无需再次验证。这样既减少了用户登录负担,又提高了安全性,因为管理员可以通过统一的入口更有效地执行身份验证、授权以及审计策略。同时,SSO 还可以配合多因素认证(MFA)等增强措施,进一步提升整个系统的安全级别。

1.单点登录实现原理

单点登录是在用户登录一个业务系统时,先将登录信息发送至单独的 SSO 服务器进行认证,如果认证成功则向该应用程序或系统发送授权令牌,之后该用户就可以使用授权令牌完成登录并操作所有系统了。

单独登录通常的操作流程是这样的:

  1. 用户认证:
    • 用户首先访问一个系统,输入用户名和密码进行登录。
    • 登录请求被发送到专门的认证中心(Authentication Server)。
    • 认证中心验证用户的身份信息,如果验证成功,则生成一个安全令牌(如 JWT、Ticket 等)。
  2. 令牌发放与传递:
    • 认证中心将令牌返回给用户首次登录的应用系统。
    • 应用系统将令牌存储在用户的本地会话(如浏览器的 Cookie)中。
    • 当用户访问其他需要 SSO 支持的应用系统时,浏览器会携带令牌自动发送给目标系统。
  3. 令牌验证与授权:
    • 目标系统接收到请求后,发现携带了令牌,则将令牌发送给认证中心进行验证。
    • 认证中心验证令牌的有效性(包括签名、有效期等)。
    • 如果令牌有效,认证中心会返回一个确认信息给目标系统,证明用户已通过认证。
  4. 资源共享与授权:
    • 目标系统接收到认证中心的确认后,允许用户访问系统资源,而无需再次登录。
    • 目标系统可以依据令牌中的信息进行权限控制和角色映射。
  5. 会话管理:
    • 为了保证安全性,一般会设置令牌的有效期,过了有效期后需要重新认证。
    • 在某些实现中,当用户在一个子系统中注销时,会通知认证中心撤销所有关联令牌,从而实现全局注销,保证了其他系统也无法继续使用过期的认证信息。

在技术实现上,单点登录可以借助如 CAS(Central Authentication Service)、OAuth、OpenID Connect 等标准协议,也可以基于企业内部的自定义协议实现。在整个流程中,关键是要维护一个全局认可的信任票证(token),并通过集中式的认证服务中心来进行身份的统一管理和验证。

2.单点登录实现

在 Java 项目中,实现单点登录(SSO)的方案主要有以下几种:

  1. OAuth2 + JWT(JSON Web Tokens)方案
    :OAuth2 是一个开放标准,允许用户授权第三方应用访问他们在服务提供商处存储的特定信息,而不需要将用户名和密码提供给第三方应用。JWT 是一种用于身份验证和授权的令牌,通常与 OAuth2 一起使用。在 Spring Boot 中,你可以使用 Spring Security OAuth2 和 JWT 库来实现这种方案。
  2. CAS(Central Authentication Service)单点登录方案
    :CAS 是一个开源的、用于企业级的单点登录解决方案。它提供了一套服务端和客户端的组件,使得在多个应用之间实现单点登录变得简单。在 Spring Boot 中,你可以使用 Spring Security CAS 客户端来实现这种方案。
  3. Spring Security + OAuth2
    :Spring Security 是一个提供身份验证和授权功能的框架,它可以与 OAuth2 一起使用来实现单点登录。在这种方案中,你可以使用 Spring Security 来处理用户的身份验证和授权,然后使用 OAuth2 来管理用户在多个应用之间的访问。
  4. Spring Session
    :Spring Session 是一个用于管理用户会话的框架,它可以帮助你在多个应用之间共享会话信息,从而实现单点登录。你可以使用 Spring Session 来将会话信息存储在共享的地方(如 Redis),然后在每个应用中通过 Spring Session 来访问这些会话信息。

其中,OAuth2 + JWT 方案适合于需要对外提供 API 接口的应用,而 CAS 方案则更适合于内部系统之间的单点登录。Spring Security + OAuth2 方案则是一种比较通用的选择,既可以处理内部系统的单点登录,也可以处理对外提供 API 接口的情况。Spring Session 方案则更适合于需要将会话信息共享到多个应用之间的场景,它也是最早和最简单的单点登录实现方式。

3.SSO 和 OAuth2 有什么区别?

SSO 和 OAuth2 都是用于管理用户身份验证和授权的协议,但它们的目标和应用场景有所不同,具体区别如下:

  1. 目标:
  • SSO 的主要目标是简化用户在多个应用系统中的登录流程,让用户只需要登录一次就可以访问所有授权的应用系统,提高用户体验和效率。
  • OAuth2 的主要目标是允许第三方应用代表用户获得访问特定资源的权限,同时保护用户的敏感信息(如密码)不被泄露。
  1. 应用场景:
  • SSO 通常用于大型企业内部或相关联的系统之间,用户只需要在一个地方(如企业门户)进行登录,就可以访问多个内部系统。
  • OAuth2 广泛应用于第三方应用需要访问用户存储在服务提供商(如 Google、Facebook)中的资源时,用户授权第三方应用访问其资源,而无需将用户名和密码直接提供给第三方应用。
  1. 实现方式:
  • SSO 的实现通常依赖于一个集中的认证中心(Authentication Server),用户在这个中心进行登录,并获得一个全局会话或令牌(Token),然后在访问其他应用系统时,这个令牌会被用来验证用户的身份和权限。
  • OAuth2 的实现涉及四个角色:资源所有者(Resource Owner)、授权服务器(Authorization Server)、客户端(Client)和资源服务器(Resource Server)。用户(资源所有者)授权客户端访问其资源,授权服务器颁发访问令牌给客户端,客户端使用这个令牌访问资源服务器上的资源。

PS:SSO 和 OAuth2 都是用于管理用户身份验证和授权的协议,但 SSO 更注重于简化用户在多个应用系统中的登录流程,而 OAuth2更 注重于保护用户的敏感信息,并允许第三方应用代表用户访问特定资源。在实际应用中,它们可以相互结合使用,例如使用 OAuth2 来实现 SSO 中的令牌颁发和验证过程。

课后思考

说说 OAuth2 的实现原理?它有几种授权模式?OAuth2 常用框架有哪些?它们有什么区别?

本文已收录到我的面试小站
www.javacn.site
,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。

本文收集了卡内基梅隆大学计算机科学系数据库学副教授 Andy Pavlo 从 2021 到 2023 连续三年对数据库领域的回顾,希望通过连续三年的回顾让你对数据库领域的技术发展有所了解。

关于
Andy Pavlo
:卡内基梅隆大学计算机科学系数据库学副教授,数据库调优公司 OtterTune 的 CEO 兼联合创始人。

为了聚焦于数据库技术趋势演变,本文未对原文“寒暄式”开头和注释性语句作翻译。此外,为了节约部分读者的时间,本文分为“观点简述”及“历年回顾”两部分:在“观点简述”部分,你将了解到 Andy 这 3 年对数据库的看法、见解;在“历年回顾”部分,你将了解到该年具体的数据库领域发生的事件,以及 Andy 对该事件的看法。

本文目录:

  • 观点简述
  • 历年回顾
    • 2023 年数据库回顾:向量数据库虽然大火,但没有技术壁垒
      • 向量数据库的崛起
        • Andy 说:向量数据库没有技术护城河
      • SQL 持续变好
        • 属性图查询(SQL/PGQ)
        • 多维数组(SQL/MDA)
        • Andy 说:SQL:2023 是个里程碑
      • MariaDB 的困境
        • Andy 说:数据库的声誉比以往任何时候都重要
      • 美国航空因政府数据库崩溃而停飞
        • Andy 说:历史悠久的核心数据系统,是每个数据库从业者最大的噩梦
      • 数据库的融资情况
        • Andy 说:无论初创公司,还是高估值的公司日子都不好过
      • 史上最贵的密码重置
        • Andy 说:意料之外的大人物生活
    • 2022 年数据库回顾:江山代有新人出,区块链数据库还是那个傻主意
      • 放缓的大规模数据库融资
        • Andy 说:不只是 OLAP 领域,OLTP 领域前景也一样严峻
      • 区块链数据库还是那个蠢点子
        • Andy 说:有让人信服的用例才是合格的新技术
      • 新的数据系统
        • Andy 说:欣然看到数据库领域的勃勃生机
      • 数据库先驱的逝世
        • Andy 说:这是一个让人难过的消息
      • 数据库的巨额财富和民主
        • Andy 说:Larry 干得漂亮
    • 2021 年数据库回顾:性能之争烽烟起,不如低调搞大钱
      • PostgreSQL 的主导地位
        • Andy 说:PostgreSQL 只会在未来几年变得更好
      • 基准测试之争
        • Databricks vs Snowflake
        • Rockset vs Apache Druid vs ClickHouse
        • ClickHouse vs TimescaleDB
        • Andy 说:性能之争不值当
      • 大数据搞大钱
        • Andy 说:我们正处在数据库的黄金时代
      • 消逝的数据库们
        • ServiceNow 收购了 Swarm64
        • Splice Machine 破产了
        • 私募公司收购了 Cloudera
        • Andy 说:2022 年可能会有更多的数据库公司倒闭
      • 坚持的回报
        • Andy 说:为 Larry 高兴

观点简述

从 2021 年兴起的数据库性能之争,似乎经过 2 年时间的洗礼,热度有所降低,2022、2023 的数据库厂商们相对 Peace 并没有发起过多的性能战。枯木又逢春,尽管向量数据库存在已久,2023 年 vector database 又大火的一把。不过在 Andy 看来,向量数据库并没有技术壁垒:有多种现成的集成方式,可快速集成向量能力到现有的数据库,这些集成方式甚至还有开源的,更是大大降低数据库厂商的集成成本。SQL 新规范 SQL:2023 在对图数据的支持上,虽然目前只是做了读查询的适配,在
Oracle v23c
给出了 Oracle 的图查询示例。不过,目前跟进 SQL/PGQ 的 DBMS 不多,像是 DuckDB 的实验性分支;此外,Andy 觉得 SQL/PGQ 对现有的图数据库并不会造成威胁,毕竟还有查询的性能问题需要攻克。在多维数组的支持上,SQL 新规范强化了数组功能,支持了真正意义上的数组——任意维度的数组。

在融资方面,2021 年是融资大年,各类数据库无论是初创还是老牌数据库厂商都能融到八位数的融资;到了 2022 年,上半年依旧保持着“好融资,融资高”的劲头,但在下半年融资情况急转直下,大额度的融资变少了,资金缩紧。这个情况延续到了 2023 年,除了市场融资变冷清之外,更多的资金集中到了同向量相关的领域,虽然还是有一些数据库厂商“破局”成功融到了钱。

在数据库可持续发展方面,自 2021 年 Swarm64、Cloudera 被收购,Splice Machine 破产之后。随后的 2022、2023 年,MarkLogic、Ahana、EverSQL、Seafowl 也先后分别被 Progress Software、IBM、Aiven、EnterpriseD 收购,结束了他们的“独立”生涯。

这 3 年也发生了一些逸事,比如 Oracle 的联合创始人 Larry Ellison 虽然在 2018 年在亿万富翁排名中跌至第十位,但是在 2021 年重返第五位,甚至在 2023 年仅次于 Bernard Arnault、Elon Musk、Jeff Bezos 以 1,070 亿美元名列第四。此外,Larry Ellison 在 2023 年还花了 10 亿给 Elon Musk 来重置他的 Twitter 密码好继续他的推特之旅。习惯用子女名来命名数据库的 MySQL、MaxDB、MariaDB 之父 Monty Widenus 估计最近的日子不好过,因为 MariaDB 的公司和基金会发生了一些矛盾,不仅如此,它的市值还蒸发了 90%。

除了上面的一些事件,像是美国航空因政府数据库崩溃而停飞 11,000 多架飞机、区块链数据库是个蠢点子之类的指控,就得你翻阅历年回顾了。

历年回顾

2023 年数据库回顾:向量数据库虽然大火,但没有技术壁垒

英文原文:
https://ottertune.com/blog/2023-databases-retrospective

向量数据库的崛起

毫无疑问,2023 年是向量数据库的一年。尽管几年前相关的某些系统早已存在,但去年人们对 LLM 及其上构建的服务(例如,ChatGPT)的广泛关注让向量数据库成为大家的视线焦点。向量数据库旨在基于语义,而不仅仅是数据内容来提供更深层的数据检索能力,特别是针对非结构化数据。也就是说,应用程序可以搜索与
主题相关
的文档(例如,“有 Slinging 相关歌曲的 hip-hop 团体”),而
不是包含精准关键字
(例如,“Wu-Tang Clan”)的文档。

这种主题搜索所依赖的“魔法”是
transformer
,它将数据转换为一个固定长度的一维浮点数向量,称之为嵌入 Embedding。人类虽然不能直接理解这些嵌入的值,但嵌入的内容编码了参数和 transformer 训练语料库之间的某种关系。这些嵌入向量的大小从简单 transformer 的数百维到高端模型的数千维不等。

假如,我们使用 transformer 为数据库中的所有记录生成嵌入,就能通过查找与给定输入在高维空间中最相近的记录嵌入来搜索相似记录。然而,暴力比较所有向量以找到最相近的匹配结果是非常昂贵的。这种暴力搜索的复杂度是 O(N * d * k),其中 N 是嵌入的数量,d 是每个向量的大小,k 是你想要的匹配数量——你可能不知道这个复杂度代表什么,反正很糟糕就是。

这也促成向量数据库的崛起。本质上,向量数据库只是一个带有特定索引数据结构的文档数据库,以加速对嵌入的相似性搜索。不同于对查询进行精准匹配来找到最相似的向量,向量数据库用近似搜索来生成结果,在速度和精度之间做了权衡,这种结果做出了“足够好”的折中。

在 2022 年区块链数据库神话崩盘之后,风投们嗅到了向量数据库的商机,再次变得兴奋。他们几乎投资了向量数据库领域的所有主流玩家(厂商)们。在 2023 年的种子轮融资中,Marqo 爆出了一个
520 万美元的种子轮
,Qdrant 拿到了
750 万美元的种子轮
,而 Chroma 则融到一个巨额的 1,800 万美元种子轮。同年 4 月,Weaviate 在 B 轮成功融到 5,000 万美元。最抢眼的还是 2023 年 Pinecone 在 B 轮融到让人羡慕的 1 亿美元。很显然,向量数据库公司在正确的时间点出现在了正确的赛道。

Andy 说:向量数据库没有技术护城河

自从 LLM 在 2022 年末随着 ChatGPT 变成热点,在
不到一年的时间
,多家 DBMS 厂商便添加了自己的向量搜索扩展,其中包括有 SingleStore、Oracle、Rockset 和 ClickHouse。同时,不少基于 PostgreSQL 的数据库产品也宣布支持向量搜索;有些使用 pgvector 扩展(像 Supabase、AlloyDB),而另外一些则使用其他的开源 ANN(近似最近邻算法,Approximate Nearest Neighbor)库,比如:Timescale、Neon。此外,领先的 NoSQL 数据库,像 MongoDB 和 Cassandra,也支持了向量索引。

我们将多个 DBMS 对向量的快速支持,和先前 JSON 数据类型的兴起做个有意思的对比。在 2000 年代后期,原生存储 JSON 的 NoSQL 系统变得流行(像 MongoDB 和 CouchDB)。但在之后几年时间里,关系型 DBMS 的老牌厂商才添加了对 JSON 的支持,像 PostgreSQL、Oracle 和 MySQL 分别是在 2012、2014 和 2015 年支持的该类型。SQL 标准虽在
SQL:2016
中添加了操作 JSON 数据的函数,但直到
SQL:2023
才添加了官方的 JSON 数据类型。尽管许多关系型 DBMS 已经支持了概念上相似的 XML,这种适配的拖延还是让人唏嘘。

向量搜索索引的快速支持有两个可能的解释。第一个是能通过嵌入进行的相似性搜索越发重要,以至于每个 DBMS 厂商都快速推出了自己的向量版本并第一时间宣布该消息。第二个是引入新的访问方法和索引数据结构所需的工程成本如此低,以至于 DBMS 厂家们添加向量搜索并不需要太多工作。大多数厂商甚至没有从头开始编写向量索引,而是直接集成了几个可用的高质量开源库之一,像是 Microsoft DiskANN、Meta Faiss。

DBMS 集成向量搜索能力的成本如此低,向量 DBMS 厂商根本没有足够深的护城河来抵抗现有 DBMS 的侵略,保持竞争优势。

我最近和两家公司 Pinecone 和 Weaviate (上面提到融资成功的向量数据库厂商)的联合创始人聊过,他们可以走两条路(详情参考
Andy 对话 Weaviate CTO 的采访视频
)。第一条路是,客户开始用向量 DBMS 作为“记录数据库”,厂商将为操作型工作提供更好的支持。最终,向量数据库会看起来更像流行的文档 DBMS,比如:MongoDB。接着,在五年内,像之前的 NoSQL 一样增加对 SQL 的支持。另一条路是,向量 DBMS 作为次级数据库,通过上游操作型 DBMS 的变更进行更新。就像人们使用 Elastic 和 Vespa 这样的搜索引擎 DBMS 一样。在这种情况下,向量 DBMS 可以在不扩展它们的查询语言或拥有更结构化的数据模型的情况下生存。

旁注: 我最近录制了一个关于
向量与关系数据库的问答节目
。在里面我提到了,每个关系型 DBMS 在未来五年内都将拥有一个高性能的向量索引实现。

SQL 持续变好

今年 2024 年是 Don Chamberlain 和 Ray Boyce (RIP) 在 IBM 研究院创建 SQL 的五十周年。最初被称为 SEQUEL(Structured English QUEry Language,结构化英语查询语言)的 SQL,自 1980 年代以来,一直是与数据库交互的事实标准。尽管 SQL 已经很老了,但它的使用情况和功能一直在增加,尤其是过去的十年。

去年,
ISO/IEC 9075
规范的最新版本
SQL:2023
面世。这次更新包括了不少用来处理各种 SQL 方言中的痛点和不一致性的“好用功能”,比如:
ANY_VALUE
)。值得一提的是,当中两个 SQL 增强功能,进一步削弱了对替代数据模型和查询语言的需求。不过需要注意一点,新的 SQL 规范包含这些内容,并不代表你喜欢的关系型 DBMS 会立即支持这些新特性。

属性图查询(SQL/PGQ)

目前,SQL 支持对图进行只读查询。这允许应用程序在现有表上声明一个属性图结构。下面这个
Oracle v23c
的图示例,它记录了哪些人在哪支乐队中:

CREATE TABLE PEOPLE (ID INT PRIMARY KEY, NAME VARCHAR(32) UNIQUE);
CREATE TABLE BANDS (ID INT PRIMARY KEY, NAME VARCHAR(32) UNIQUE);
CREATE TABLE MEMBEROF (PERSON_ID INT REFERENCES PEOPLE (ID), 
                       BAND_ID INT REFERENCES BANDS (ID), 
                       PRIMARY KEY (PERSON_ID, BAND_ID));

CREATE PROPERTY GRAPH BANDS_GRAPH
   VERTEX TABLES (
      PEOPLE KEY (ID) PROPERTIES (ID, NAME),
      BANDS KEY (ID) PROPERTIES (ID, NAME)
   )
   EDGE TABLES (
      MEMBEROF
      KEY (PERSON_ID, BAND_ID)
      SOURCE KEY (PERSON_ID) REFERENCES PEOPLE (ID)
      DESTINATION KEY (BAND_ID) REFERENCES BANDS (ID)
      PROPERTIES (PERSON_ID, BAND_ID)
   );

它由 DBMS 决定是为属性图创建辅助数据结构(例如,邻接矩阵)还是仅跟踪元数据。你可以用
MATCH
关键字在 SQL 中编写图遍历查询,这个语法建立在现有查询语言(像是 Neo4j 的 Cypher,Oracle 的 PGQL 和 TigerGraph 的 GSQL)的基础上,并且兼容了新兴的 GQL 标准。以下查询返回每支乐队的成员数:

SELECT band_id, COUNT(1) AS num_members
   FROM graph_table ( BANDS_GRAPH
      MATCH (src) - [IS MEMBEROF] -> (dst)
      COLUMNS ( dst.id AS band_id )
   ) GROUP BY band_id ORDER BY num_members DESC FETCH FIRST 10 ROWS ONLY;

截至 2024 年 1 月,我知道的唯一支持 SQL/PGQ 的 DBMS 是 Oracle。DuckDB 的实验性分支虽然也支持 SQL/PGQ,但上面示例不能运行,因为两个数据库支持的语法略有不同。你可以从 CWI/DuckDB 研究员 Gabor Szarnyas 整理的这个
SQL/PGQ 的优秀资源列表
中了解更多关于 SQL/PGQ 的信息。

多维数组(SQL/MDA)


SQL:1999
引入有限的单维度、固定长度数组数据类型以来,SQL 就支持数组类型。而
SQL:2003
更是增强了该功能,支持嵌套数组,而无需预定义最大基数。在 SQL:2023 中,SQL/MDA 部分更新支持了使用整数坐标的真正的多维数组,这些数组可以是任意维度。此外,
Rasdaman 的 RQL
大大地启发了 SQL/MDA 语法,SQL 可以提供与其兼容,并与集合语义正交的结构和操作数组构造。借此让应用程序只用在 SQL 中与多维数组交互和操作,而无需将它们导出,例如:到 Python Notebook。
下表
展示了在
CREATE TABLE
语句中使用
MDARRAY
数据类型的不同示例:

尽管 SQL/MDA 规范在 2019 年以技术报告的形式出现,但直到 SQL:2023 它才被正式纳入 SQL 标准。据我所知,除了 Rasdaman 之外,没有其他生产级别的 DBMS 支持 SQL/MDA 扩展。我能找到的唯一其他数据库是
ASQLDB
,一个数据库 HSQLDB 的分支。

Andy 说:SQL:2023 是个里程碑

SQL:2023 修订版是 SQL 这种通用查询语言持续进化和改进的下一个阶段。当然,SQL 并不完美,也不具备真正的可移植性,因为每个 DBMS 都有自己的特点、专有特性和非标准扩展。就像我个人就非常喜欢 PostgreSQL 的
::
转换操作符快捷方式。

虽然 SQL/PGQ(SQL 对图的支持)是个大事,但我不觉得它会立即对图数据库造成威胁,因为已经有多种方法将面向图的查询转换为 SQL。包括 SQL Server 和 Oracle 在内的 DBMS 都提供了内置的 SQL 扩展,可以容易地存储和查询图数据。Amazon Neptune 则是在 Aurora MySQL 之上的图数据服务层。Apache AGE 在 PostgreSQL 之上提供了一个 openCypher 接口。我预测其他主流 OLAP 数据系统,例如:Snowflake,Redshift,BigQuery,都会在不久的将来支持 SQL/PGQ。

但在一个 DBMS 中添加 SQL/PGQ 并不像添加新语法那样简单。要确保图查询性能良好,需要考虑几个工程上的问题。例如,图查询执行多路连接来遍历图。但当这些连接的中间结果比基础表还大时,问题就来了。一个 DBMS 必须使用最坏情况下最优连接(
WCOJ
,Worst-case optimal join)算法来更有效地执行两表联合查询,而不是通常用来连接两个表的 hash join。另一个技术要点是使用因式分解来避免在连接过程中物化冗余的中间结果。这种类型的压缩让 DBMS 规避了一遍又一遍地用相同的连接记录导致内存耗尽的问题。

上面我提到的优化点,并不是说现有的图数据库都做到了。据我所知,像是 Neo4j、TigerGraph 等图数据库都没有实现。我唯一知道的实现了优化的是滑铁卢大学的嵌入式图数据库
Kuzu
。大多数关系型数据库也没有实现它们,至少我知道的那些开源数据库没有。上面提到的 DuckDB 实验分支实现了 WCOJ 和因式分解优化,并在
2023 年的论文
中显示,在一个行业标准的图基准测试中,其性能比 Neo4j 高出多达 10 倍。

我很久之前说过,SQL 可能在你出生之前就存在,到你去世它依然会存在。对于那些声称自然语言查询将完全取代 SQL 的说法,我依旧嗤之以鼻。

旁注:从上次我公开说到 2030 年图数据库都不会在数据库市场上超过关系型数据库以来,已经两年过去了。到目前为止,我还是对的。

MariaDB 的困境

过去的一年,MariaDB 频频出现在新闻报道中,而且大多数都不是什么好消息。独立于 MariaDB 基金会的 MariaDB 公司显然是一个混乱的公司。在 2022 年,这家公司试图借壳 SPAC 上市,但是股票($MRDB)在 IPO 后的三天内立即跌了 40%。而为了加速在纽交所上市进度的借壳操作也被公诸于世。到 2023 年底,MariaDB 公司股价自开盘以来跌了 90% 以上。

因为这些糟糕的财务问题,MariaDb 公司宣布了两轮裁员。第一轮在 2023 年 4 月,但同年 10 月他们进行了另一轮更大规模的裁员。公司还宣布他们将关停两款产品:Xpand 和 SkySQL。前者是 MariaDB 公司在 2018 年收购的产品,当时它还被称为 Clustrix;我在 2014 年还参观了 Clustrix 的旧金山办公室,当时我觉得那里像个阴森的鬼城(办公室里一半的灯都熄灭了)。后者 SkySQL 的历史更加复杂。最初它只是一个提供 MariaDB 服务的独立公司,在 2013 年与 Monty Program AB 合并。在 2014 年,合并后的 Monty Program AB + SkySQL 公司变成了今天的 MariaDB 公司。但在 2023 年 12 月,公司又宣布 SkySQL 没有“死去”,而是作为一个独立公司重新回到了市场!

MariaDB 公司的情况如此糟糕,以至于 MariaDB 基金会的 CEO 专门写文章,抱怨自从 MariaDB 公司上市以来基金会与公司的关系是如何恶化,他希望能够重新审视彼此关系。雪上加霜的是,微软在 2023 年 9 月宣布,未来不再提供作为托管 Azure 服务的 MariaDB,而是改为采用 MySQL。可能有人不知道,MariaDB 本身就是 MySQL 的一个分支,是 MySQL 的原创始人 Monty Widenus 在 2009 年 Oracle 宣布收购 Sun Microsystems 后创建的。回忆下,Oracle 在 2005 年买了 InnoDB 的制造商 InnoBase,Sun 在 2008 年买了 MySQL AB。现在 MySQL 运行良好,MariaDB 却遇到了问题。戏剧来源于现实,多看看数据库市场你能吃到各种瓜!

Andy 说:数据库的声誉比以往任何时候都重要

过去的十年,数据库客户的精明程度有了大幅度的提升。各家公司也不再能仅凭华而不实的性能数字、取代 SQL 的新查询语言,或是名人效应来“扮成功直到真正成功”了。数据库的声誉比以往任何时候都更为重要,其背后的公司声誉也同样重要。也就是说,这意味着软件本身的稳定很重要,其公司也得有条不紊地运作。

开源数据库背后的公司如果倒闭了,很少数据库能继续发展和繁荣。不过,PostgreSQL 算一个例外,尽管今天我们用的开源版本是基于加州大学伯克利分校的源码,而不是 1996 年被 Informix 收购的商业版本 Illustra。另一个例子是,为 MySQL 构建 InfiniDB OLAP 引擎的公司在 2014 年破产后,其 GPLv2 源码被接手并作为 MariaDB 的 ColumnStore 持续发展。

相反,更多现实告诉我们,一旦支付最多开发费用的公司消失,对应的数据库就会逐渐衰落。唯二在某种程度上算是活下来数据库的例子是 Riak 和 RethinkDB。Basho 在 2017 年破产后,现在 Riak 由在 UK's NHS 工作的一个人维护。RethinkDB 公司在 2017 年倒闭(鉴于创始人对女性在科技界的看法,这并不奇怪)后,数据库源码就被转移到了 Linux 基金会。尽管基金会接手了项目,RethinkDB 仍处于活着的状态:该项目在 2023 年发布了一个新版本,但它们只是热修复,来解决一些已知问题。有兴趣的话,你可以去
Apache 基金会档案室
看看那些被遗弃的数据库项目。

只在云端提供数据库服务的 DBaaS,在稳定性上只会更糟糕。因为如果公司失败,或是开始面临财务压力,他们就会关闭托管你数据库的服务器。Xeround 在 2013 年关闭云服务时,给了他们的客户两周时间迁移数据。为了降低成本,InfluxDB 在 2023 年 7 月删除整个 region 前给了客户六个月的时间迁移,但大家还是大吃一惊。

MariaDB 比一般的数据库创业公司处于更好的位置,因为 Monty 和其他人成立了一个管理开源项目的非营利基金。但当你是一个以盈利为目的的开源数据库公司,而帮助你管理该 DBMS 运作的非营利组织公开表示你管理混乱的话,那就是一个坏兆头!与此同时,MySQL 在持续改善,Oracle 依旧是那个从工程角度看不错的企业级数据库选择。MariaDB 公司的混乱将进一步促进人们转向使用 PostgreSQL。

MariaDB 肯定不能失败,据我所知,Monty 没有更多的孩子可以用来给数据库命名了(例如:MaxDB、MySQL、MariaDB)。

小趣闻:MariaDB 取名自 Monty 的小女儿 Maria,MaxDB 取名自儿子 Max,MySQL 来自大女儿 My。

美国航空因政府数据库崩溃而停飞

在 2023 年 1 月 11 日,由于飞行通知
NOTAM
系统故障,联邦航空管理局 FAA 停飞了美国所有的航班。NOTAM 系统向飞行员提供以纯文本编码的消息,告诉他们可能在飞行路径上会遇到的意外和潜在危险。当 NOTAM 系统在 1 月 11 日早晨崩溃时,直接导致美国大约 11,000 架航班无法起飞。所幸的是,其他国家运行着独立的、不受美国 NOTAM 故障影响的 NOTAM 系统能正常起飞。

根据 FAA 官方说法,这次故障是由于一个数据库文件损坏导致的。一名来自第三方承包商的工程师尝试用备份文件替换它,但结果是备份文件也有问题。2008 年也发生了类似的
事件

关于 FAA 在 NOTAM 所用的 DBMS 并没有公开信息。有一些报道称,NOTAM 仍然在运行于 1988 年的两台 Philips DS714/81 大型机上。但这些 Philips DS714 机器没有我们今天所知的操作系统;它们是 1960 大型机年代的遗物。也就是说,在 1980 年代 FAA 无法为应用使用现有的数据库系统,即便是那些当时已经存在的数据库,像是 Oracle、Ingres 和 Informix 都支持当时的各种 Unix。我觉得比较合理的可能是,NOTAM 可能用 Flat File(比如:CSV)来自行管理数据。1980 年代由非数据库专家编写的应用程序代码负责从文件中读取/写入记录,复制到备用服务器,并在出现故障时维护数据的完整性。

Andy 说:历史悠久的核心数据系统,是每个数据库从业者最大的噩梦

在无法替代的传统硬件上运行关键任务系统,使用的还是由早就退休的内部开发人员编写的自定义数据库访问库,这是每个数据库从业者最大的噩梦。我很惊讶它竟然没崩溃得更早(除非 2008 年的故障是同一系统),我觉得我们应该给这个运行了 35 年的系统一些掌声。

有消息称,NOTAM 系统每秒只处理 20 条消息。按照现代数据标准,这个数据量真的很小,但别忘记,FAA 是在 1980 年代配置的这个系统。数据库传奇人物,1998 年图灵奖得主 Jim Gray 在 1985 年写到,
“普通”的数据库管理系统可以执行大约每秒 50 次事务
(txn/sec),而非常高端的系统可以达到每秒 200 次。作为参考,五年前,有人使用 1980 年代的基准测试(基于 TPC-A 的 TPC-B)
在树莓派 3 上运行 PostgreSQL,大约达到了每秒 200 次事务
。如果我们不考虑那些使用跨数据中心的强一致性复制(这会受到光速的限制)的系统,现代单节点在线事务处理(OLTP)DBMS 可以在某些工作负载下实现每秒数百万次事务的吞吐量。NOTAM 在 1980 年代的峰值每秒 20 条消息的吞吐量并没有推动当时的技术极限,而且显然今天也没有。

因为 NOTAM 没有将数据库与应用程序逻辑分离,所以独立升级这些组件是不可能的。考虑到在 1980 年代中期,关系模型的优点已经众所周知,NOTAM 这种设计是该批判的。当然,并不是说 SQL 就能防止这次确切的失败(这是一个人为错误),但独立性会让各个组件不那么笨重,更易于管理。

尽管如此,当时美国政府其实已经在用商用关系型 DBMS。例如,Stonebraker 的 RTI(Ingres 厂商)在 1988 年的 IPO 申报文件中提到,他们现有的客户包括国防部和内政部、军事分支和研究实验室。我相信当时美国政府的其他部门也在使用 IBM DB2 和 Oracle。因此,除非 NOTAM 有什么我不知道的特别之处,不然 FAA 本可以使用真正的数据库管理系统。

停飞事件发生的时候,我正在阿姆斯特丹的
CIDR 2023
会议的返程中。幸运的是,停飞没有影响入境的国际航班,我的飞机可以顺利地降落。但我还是被困在纽瓦克机场,因为美国所有国内航班都停飞了。熟悉纽瓦克机场的人都知道,在这里待着并不是什么好事。

延伸阅读:你可以阅读我之前的
文章
,了解下为什么如果 NOTAM 数据库运行在 Amazon RDS 上,不太可能发生数据库崩溃。

数据库的融资情况

除了上面提到的向量数据库是风投的“新宠”之外,其他类型的数据库在 2023 年也是有融资的。但总体而言,今年的数据库融资活动比往年要冷清得多。

自动调优初创公司 DBTune 在欧洲完成了 260 万美元的种子轮融资。PostgresML 获得了 450 万美元的种子轮融资,来打造一个通过自定义扩展来支持从 SQL 调用 ML 框架的 DBaaS。TileDB 在秋季宣布完成了 3,400 万美元的 B 轮融资,以此继续完善他们的阵列数据库管理系统。尽管有着 13 年的历史,SQReam 还是获得了 4,500 万美元的 C 轮融资,来继续开发他们的 GPU 加速数据库管理系统。Neon 在 2023 年 8 月完成了 4,600 万美元的 B 轮融资,以扩展无服务器 PostgreSQL 平台。当然,2023 年的融资赢家再次是 Databricks,他们在 2023 年 9 月完成了 5 亿美元的 I 轮融资。虽然这是一笔巨款,但并不如他们在 2021 年 H 轮的 16 亿美元来得多。

Peter Boncz 和 Tianzhou Chen 提醒我了,还有 MotherDuck(DuckDB 的商业版本)在 2023 年 9 月完成的 5,250 万美元的 B 轮融资。另一个数据库产品 DBeaver,完成了 500 万美元的种子轮融资,来继续研发受欢迎的 multi-DBMS 。

此外,2023 年数据库领域也发生了一些收购。最大的一笔交易在年初发生,MarkLogic 被 Progress Software 以 3.55 亿美元现金收购。MarkLogic 是最古老的 XML 数据库管理系统之一(约 2001 年),而 Progress 拥有 OpenEdge,一种更古老的数据库管理系统(约 1984 年)。IBM 收购了 Meta 的衍生公司 Ahana,该公司试图将 PrestoDB(它不同于已经更名为 Trino 的 PrestoSQL)商业化。多云数据库服务提供商 Aiven 收购了 AI 驱动的查询重写器初创公司 EverSQL。EnterpriseDB 用 Bain Capital(私募投资公司)的资金收购了基于 DataFusion 兼容 PostgreSQL 的 OLAP 引擎的 Seafowl 团队。Snowflake 收购了两家初创公司:(1)由前斯坦福教授 Peter Bailis 打造的 Sisu Data,以及(2)由伯克利教授 Aditya Parameswaran 基于 Modin 研发的 Ponder。

Andy 说:无论初创公司,还是高估值的公司日子都不好过

我的风投朋友们说,他们在 2023 年看到了更多新公司的推介,但比往年签发的支票更少。这个趋势贯穿所有初创领域,数据库市场也不例外。大部分的风投注意力都在那些和人工智能+大型语言模型(LLM)有一点点关系的项目,这也合理,毕竟这是计算领域的新篇章。

尽管美国 2023 年的宏观经济指标有些积极的迹象,但科技产业依旧紧张,每家企业都在削减成本。像 OtterTune(作者所在的公司)客户希望我们的数据库优化服务能在 2023 年帮助他们降低数据库基础设施成本。这与公司早些年人们主要来找 OtterTune 提高数据库管理系统的性能和稳定性不同。我们计划在 2024 年宣布新功能,以帮助降低数据库成本。回到大学,这个学期有比平常更多的学生请我帮他们找数据库开发的工作。这让我很吃惊,因为 CMU 的计算机科学学生一直不愁找工作,靠自己就拿到不错的实习和全职 offer,除了有次我最优秀的本科生重写了我们的查询优化器,但因为忘了问我,结果找不到暑期实习,最后在匹兹堡机场附近的迪克体育用品店做网页开发——他现在在 Vertica 工作得很开心。

如果美国的科技市场继续低迷不振,接下来的几年众多数据库初创公司都难有大发展。小型的数据库初创公司要么会被大型科技公司或私募股权收购,要么就直接倒闭。但是,那些融到大笔钱且估值很高的公司也不好过。正如我之前说的那样,有些公司可能无法 IPO,而且没有哪家大型科技公司会需要这些 DBMS,因为如今大家都有自己的数据库系统。因此,这些大数据库管理系统公司将面临三个选择:接受降低估值的融资以保持运营;通过私募股权获得支持,保持运营(比如:Cloudera);被一家 IT 服务公司收购(比如:Rocket,Actian),这些公司将 DBMS 置于维护模式,但继续从那些被困的客户那里收取许可费,因为这些客户有他们无法轻易迁移的遗留应用程序。不过,这三条路对于数据库公司来说都不理想,应该会吓跑潜在的新客户。

最后,我要重述一句:不要问 Databricks 是不是会 IPO,而是它何时会 IPO。

史上最贵的密码重置

2023 年,数据库传奇大佬 Larry Ellison 春风得意。对于他原本杰出的职业生涯来说,2023 年也是一个标志性的一年。2023 年 6 月,他重返世界第四富有的位置。Oracle 公司的股价($ORCL)在 2023 年上涨了 22%,略低于标准普尔 500 指数 24% 的回报率。此外,在 2023 年 9 月,Larry 第一次去了 Redmond,并与微软首席执行官 Satya Nadella 一起登台宣布,Oracle 可作为 Azure 云平台上托管服务使用。随后同年 11 月,股东们压倒性地投票支持 79 岁的 Larry 继续担任 Oracle 董事会主席。

但 2023 年真正的大新闻是,Elon Musk 在 Larry 对 Musk 收购社交媒体公司投资了 10 亿美元后,亲自帮 Larry 重置了 Twitter 密码。正是这笔价值 10 亿美元的密码重置,我们在 2023 年 10 月有幸看到了
Larry 的第二条推文
,也是他十多年来的首条新推文。Larry 预告了他即将前往牛津大学的行程,后来他在那里宣布在牛津大学成立埃里森技术研究院(EIT)。

Andy 说:意料之外的大人物生活

其实 Larry 发了什么根本不重要,重要的是 Larry 回归推特发推文。我偷偷打听过,Larry 偶尔会看看推特,主要关注创业点子提案、祝福以及不经意冒出的奇思妙想。

Larry 的推文之所以出人意料,是因为人们一般会认为他总是忙于更宏伟的活动。毕竟,他拥有一架 MiG-29 战斗机和一个夏威夷岛屿。他有很多更伟大的事情可以做。所以,当他抽出时间在一个日益衰落的社交媒体上写推文,告诉我们他在做什么。这对我们所有人来说,都是一个重大的生活事件。为此,Larry 不得不请他那个世界上最富有的朋友来重置他的密码。虽然花费 10 亿美元,但当你拥有 1,030 亿美元时,这都不是什么事了。

2022 年数据库回顾:江山代有新人出,区块链数据库还是那个傻主意

英文原文:
https://ottertune.com/blog/2022-databases-retrospective

放缓的大规模数据库融资

正如我去年说的那样,2021 年是数据库融资的大年。随着投资者继续寻找下一个 Snowflake,大量资金涌向了新的 DBMS 初创公司。2022 年初看起来像是要再过一次 2021 年,有非常多的大额融资消息。

融资狂欢在 2022 年的 2 月开始,Timescale 完成了 1.1 亿美元的 C 轮融资,Voltron Data 完成了 1.1 亿美元的种子轮 + A 轮融资,Dbt Labs 完成了 2.22 亿美元的 D 轮融资。Starburst 在 3 月宣布了他们 2.5 亿美元的 D 轮融资来继续提升他们的 Trino 产品。Imply 在 5 月拿出 1 亿美元的 D 轮融资用于开发他们的 Druid 商业版本。DataStax 在 6 月的 IPO 途中获得了 1.15 亿美元的资金。最后,SingleStore 在 7 月完成了 1.16 亿美元的 F 轮融资,然后在 10 月又融了 3,000 万美元。

2022 年上半年还有几家较小的公司完成了让人印象深刻的 A 轮融资,包括 Neon 的 3,000 万美元 A 轮用来研发无服务器 PostgreSQL 产品,ReadySet 2,900 万美元 A 轮融资来研发查询缓存层,Convex 的 2,600 万美元 A 轮来继续开发他们基于 PostgreSQL 的应用程序框架,以及 QuestDB 的 1,500 万美元 A 轮来开发时序数据库。尽管我们 OtterTune 没有新的 DBMS 或相关基础设施,但我们也在 4 月完成了 1,200 万美元的 A 轮融资。

但是,到了 2022 年下半年,大规模的融资轮停止了。尽管早期初创公司还是有较小额的融资进来,但更后面的公司再也没有九位数的美元融资了。

流处理引擎 RisingWave 在 10 月筹集了 3,600 万美元的 A 轮,Snowflake 查询加速器 Keebo 融到 1,050 万美元的 A 轮资金。在 11 月,我们看到了 MotherDuck 的 4,500 万美元种子轮 + A 轮融资的新闻来开发商业化 DuckDB 的云版本,以及 EdgeDB 在 11 月的 1,500 万美元 A 轮融资。最后,是 SurrealDB 完成了 600 万美元的种子轮融资。我可能漏掉了一些其他公司,这不是一个详尽的列表。

在数据库领域唯一其他值得注意的金融事件是,MariaDB 在 12 月的灾难性地通过 SPAC IPO,股价在首个交易日就下跌了 40%。

Andy 说:不只是 OLAP 领域,OLTP 领域前景也一样严峻

与 2021 年相比,在 2022 年大额融资轮减少的原因有两个。最明显的是整个科技行业在降温,部分原因是人们对通货膨胀、利率和加密经济崩溃的担忧。另一个原因是,有能力大额融资的公司在资金干涸之前就完成了融资。

例如,Starburst 在 2021 年完成了 1 亿美元的 C 轮融资后,在 2022 年进行了它的 D 轮融资。在过去两年完成巨额融资的数据库公司,很快就需要再次融资来保持增长势头。

坏消息是,除非科技行业有所改善,并且大型机构投资者开始再次将资金投入市场,否则这些公司们将面临困境。市场无法维持这么多独立软件供应商(ISVs)为数据库服务。这些拥有十亿美元估值的公司唯一继续前进的法子是,进行首次公开募股或破产。这些公司对于大多数公司来说太贵了,无法被收购(除非风投公司愿意大打折扣)。

此外,进行大型并购的大型科技公司(比如:亚马逊、谷歌、微软)都有了自己的云数据库产品。因此,不清楚谁会收购这些数据库初创公司。亚马逊没有理由在他们 Redshift 每年赚取数十亿美元时,去以 2021 年的 20 亿美元估值购买 ClickHouse。这个问题不仅限于 OLAP 数据库公司;OLTP 数据库公司很快也将面临同样的问题。

我并不是唯一一个对数据库初创公司的前景做出如此严峻预测的人。Gartner 分析师预测,到 2025 年,
50% 的独立 DBMS 供应商将退出市场
。显然我有自己的看法,我认为未来生存下来的公司是那些致力改善或者是强化 DBMS 的公司,而不是替换它们的公司(比如:dbt、ReadySet、Keebo 和 OtterTune)。

我无法判断 MariaDB 借壳 SPAC “快速上市”是否是个好主意。这种金融操作不在我的专业领域(数据库)内。但既然这和前美国总统用他的社交媒体公司做的事情一样,我就姑且认为它不是什么好主意。

区块链数据库还是那个蠢点子

关于 Web3 根本性转变了构建新应用程序方式这点,有很多夸张的说法。我有一个学生甚至因为我教授的是关系数据库而不是 Web3,愤然从我的课堂离席。Web3 运动的核心是在区块链数据库中存储状态。

区块链本质上是去中心化的分散的日志结构数据库(即,账本),它们通过使用某种 Merkle 树的变体和 BFT 共识协议来维护增量校验和,从而确定下一个要入库的更新。这些增量校验和是区块链确保数据库日志记录不变性的方式:客户端使用这些校验和来验证之前的数据库更新没有被更改。

区块链是之前想法的巧妙结合。但是,厂商们认为去中心化账本是每个人构建 OLTP 应用程序必须的,这点是一种误导。从数据库的角度,除了加密货币之外,区块链数据库和现有的 DBMS 没有任何差别。此外,任何区块链在数据库安全性和可审计性比现有 DBMS 表现更好的说法,都是胡说。

如果说加密货币是区块链数据库的最佳实践,那么 2022 年加密市场的崩溃显然没有帮到它们,甚至是进一步阻碍了区块链数据库的发展。当然我会忽略 FTX 的崩盘(他们申请了破产保护),毕竟它就是彻头彻尾的诈骗,和数据库一点关系都没。不过,我要指出,FTX 和所有其他加密货币交易所一样,并没有在区块链数据库上运行业务,而是使用了 PostgreSQL。

此外,其他与加密货币无关的区块链数据库用例,如交易和游戏平台,都因为不切实际或诈骗没有落地。

Andy 说:有让人信服的用例才是合格的新技术

评估某项技术的原则之一是,一旦厂商开始制作它的媒体广告,它就不再是“新”技术了。简单来说,像是 IBM 之类的厂商在打广告的时还没有出来让人信服的用例,那么这个产品永远也不会有用例。

举个例子,IBM 在 2002 年在一则商业广告中吹捧 Linux 是一个热门的新事物,但那时已经有包括谷歌在内的成千上万的公司将 Linux 作为主要服务器操作系统使用了。所以,当 IBM 在 2018 年发布他们的区块链广告时,我就知道这项技术除了在加密货币领域有用,在其他领域毫无用处。因为其他领域没有一个问题是去中心化的区块链能解决,而中心化的 DBMS 不能解决的。

因此,2022 年 IBM 宣布将关闭与航运巨头 Maersk 合作的供应链 IT 基础设施改造项目,也就不奇怪了,毕竟这正是 IBM 在广告中炒作的场景。

相比任意一个可信权威管理、只允许受信任的客户端直连、用心编写的事务数据库,区块链数据库的效率低得可怕。除了加密货币(见上文)或者其他什么欺诈场景,现实数据世界的运行方式都是和其他数据库目前处理的那样。

信任是一个正常运转的社会的基石。例如,我授权托管 OtterTune 网站的公司向我的信用卡收费,他们又信任一个云提供商来托管他们的软件。没人会需要使用区块链数据库来进行这些“信任”交易。

从工作量证明(PoW:proof-of-work)转换到不那么费事的权益证明(PoS:proof-of-stake),共识机制确实提升了区块链数据库的性能。但这只影响数据库的吞吐量;区块链交易的延迟仍然以数十秒计算。如果解决这些长延迟的方法是使用参与者较少的 PoS 区块链,那么应用程序使用 PostgreSQL 来认证这些参与者会更好。

你可以读一读
Tim Bray(XML 之父)同 AWS 高层内部讨论是否有区块链可行用例
的精彩文章。值得留意的是,Tim 说 AWS 在 2016 年就得出过区块链数据库是数据问题的解决方案的结论,这比 IBM 推出区块链数据库广告早了两年!虽然 AWS 最终在 2018 年发布了
QLDB
服务,但它不同于区块链;它是一个中心化的可验证账本,不使用 BFT 共识。与亚马逊极为成功的 Aurora 产品相比,QLDB 客户的采用率一直不太理想。

趣闻:在 FTX 崩盘(申请破产保护)前的三周,有人和我说 OtterTune 的全职工程师人数和 FTX 在巴哈马的团队一样。这个人还说,既然工程师人数一样,OtterTune 应该像 FTX 那样更有前景,而且现在应该有 10 亿美元的年度经常性收入(ARR)。真是有意思呀。

新的数据系统

今年有不少新的 DBMS 软件的重大新闻:

  • Google AlloyDB
    :2022 年最让人震惊的消息是 5 月份谷歌云宣布了它们的新数据库服务。AlloyDB 不是基于 Spanner 构建的,而是一个修改版的 PostgreSQL,它分离了计算层和存储层,并且支持在存储中直接处理 WAL 记录。
  • Snowflake Unistore
    :6 月份,Snowflake 宣布了他们的新 Unistore 引擎,用“混合表”来支持 DML 操作的低延迟交易。当查询要更新表时,变更会传到 Snowflake 的列式存储中。SingleStore 数据库的某个人有些激动,说 SingleStore 在这个领域有一些专利,虽然这个说法没啥实质性证据支撑。补充信息:SingleStore 和 Snowflake Unistore 有部分技术交集,你可以理解为他们存在一定的竞争关系。
  • MySQL Heatwave
    :当 Oracle 发现 Amazon 从 MySQL 赚的钱比他们多后,终于在 2020 年决定为 MySQL 构建自己的云服务。但他们并没有仅仅做个 RDS(关系数据库服务)克隆版,而是用一个叫做 Heatwave 的内存向量化 OLAP 引擎扩展了 MySQL。2021 年 Oracle 还宣布他们的 MySQL 服务还支持自动化数据库优化(但与 OtterTune 提供的优化服务不同)。到了 2022 年,Oracle 终于发现他们不是领先的云供应商,并向 AWS “低头”在 AWS 上托管了 MySQL Heatwave。
  • Velox
    :Meta 在 2020 年开始构建 Velox,作为 PrestoDB 的新执行引擎。两年后,他们宣布了这个项目并发表了一篇关于它的
    VLDB 论文
    。Velox 并不是一个完整的 DBMS:它不带 SQL 解析器、目录、优化器或网络支持。相反,它是一个带有内存池和存储连接器的 C++ 可扩展执行引擎。人们可以基于 Velox 构建一个成熟的 DBMS。
  • InfluxDB IOx
    :就像 Meta 的 Velox 一样,Influx 团队在过去两年一直在努力开发新 IOx 引擎。在 10 月,他们宣布新引擎正式上线(GA)。InfluxDB 从零开始基于 DataFusion 和 Apache Arrow 构建了 IOx。值得庆祝下的是,我在 2017 年和 Influx 的 CTO 说使用 MMAP 是个坏主意后,他们在新系统中抛弃了 MMAP。
Andy 说:欣然看到数据库领域的勃勃生机

很高兴见证了 2022 年数据库领域发生的这些事。我对 AlloyDB 的看法是,它是一个简洁的系统,当中投入了让人感叹的工程量,但我还是不知道它有什么创新点。AlloyDB 的架构类似于 Amazon 的 Aurora 和 Neon,在 DBMS 存储中有个额外的计算层,可以独立于计算节点处理 WAL 记录。尽管谷歌云已经拥有坚挺的数据库产品组合(比如:Spanner、BigQuery),但它们还是觉得有必要构建 AlloyDB 来尝试赶上亚马逊和微软。

需要关注的长期趋势是诸如 Velox、DataFusion 和 Polars 之类的框架的普及。结合像 Substrait 之类的项目,这些查询执行组件的商品化意味着未来的五年内,所有的 OLAP DBMS 将在性能上大致持平。

与其完全从头开始构建一个新的 DBMS,或者是 hard fork 一个现有系统(像 Firebolt fork ClickHouse),比如使用一个像 Velox 这样的可扩展框架。也就是说,每个 DBMS 都将具备同 Snowflake 十年前独有的相同向量化执行能力。尤其是在云上,存储层对每个人来说都是相同的(比如:亚马逊控制的 EBS/S3),那么区分 DBMS 产品的关键因素将会是那些难以量化的事物,如 UI/UX 设计和查询优化。

数据库先驱的逝世

在 2022 年 7 月有一个让人难过的消息,Martin Kersten 逝世了。Martin 是
CWI
的研究员,他是多个颇具影响力的数据库项目的引领者,包括 1990 年代最早的分布式内存 DBMS(PRISMA/DB)和 2000 年代最早的列式 OLAP DBMS(MonetDB)。因为他在数据库方面的贡献,Martin 在 2020 年因被荷兰政府授予皇家骑士称号。

MonetDB 的代码库还是其他几个 OLAP 系统项目的跳板。在 2000 年代末,Peter Boncz 和 Marcin Żukowski fork MonetDB 它开发 MonetDB/X100,后来商业化为 Vectorwise(现在叫 Actian Vector)。Marcin 后来离开,联合他人共同创立的 Snowflake,采用了原来他在 MonetDB 代码上开发的许多技术点。最近,Hannes Mühleisen 搞了个 MonetDB 的嵌入式版本 MonetDBLite,后来他又重写了项目,变成了现在的 DuckDB。

Martin 对现代数据库系统的贡献如此重大,以至于你如果使用任何现代分析型 DBMS(像是 Snowflake、Redshift、BigQuery、ClickHouse),你就是在享受 Martin 和他的学生在过去 30 年开发的众多进步成果。

Andy 说:这是一个让人难过的消息

我知道,相比 Mike Stonebraker(研究数据库的计算机科学家,2014 年图灵奖获得者)这样的人,数据库研究圈外人可能知晓 Martin 没那么多。我总把 Martin 看作是 Stonebraker 的欧洲版:他们都是多产的数据库研究者,高个子、瘦弱、戴眼镜,年龄相仿。但 Martin 并不是像 Nintendo Smitch 山寨 Nintendo Switch 那样的山寨货。

除了研究,在业余时间 Martin 也乐于同他人讨论数据库架构。我最后一次见 Martin 是在新冠爆发之前的 2019 年。我们就他为什么认为在 MonetDB 中使用 MMAP 是正确的选择争论了一个小时;他声称因为 MonetDB 专注只读的 OLAP 工作负载,所以 MMAP 就够好了。其实有件事很对不住 Martin,就是那些他应对过的在 YouTube 观看我的数据库课程后,给他发邮件询问为什么 MonetDB 做出了我声称的较差设计的学生。

我建议你看下 Martin 在
2021 年 CMU-DB 研讨会的压轴演讲
。我和 Martin 承诺在他的演讲中,我不会用 MonetDB 采纳 MMAP 这点让他分心。为了表示诚意,在这个视频的前面 60 秒,我找了个荷兰人录制一个仿皇家的 Martin 短片介绍。

数据库的巨额财富和民主

2022 年 5 月,《华盛顿邮报》报道说,Oracle 创始人和帆船爱好者 Larry Ellison 参加了 2020 年 11 月刚结束的选举的电话会议,与会的有美国总统和其他保守派领袖。

电话会议集中讨论了总统的盟友和活动分子可能采取的、来推翻总统选举的结果的不同策略。正如《邮报》文中指出的那样,目前尚不清楚为什么政府要让 Larry 参与通话。一种猜测是,鉴于 Larry 显而易见的强大技术背景,他可能很适合评估外国势力利用某种方式来使用卫星技术来远程操控美国选举的说法是否可行。

Andy 说:Larry 干得漂亮

相信 Larry 和我都厌倦了人们对他支持美国右翼的离谱言论,甚至有人说这个电话是 Larry 做过的最糟糕的事。这不是真的,要知道这样的新闻和社交媒体言论会让 Larry 感到难过。

我向你保证,Larry 只是试图用他作为世界第七富有的人的巨额财富来帮助他的国家。他参与这次通话是值得钦佩的,应该受到赞扬。自由和公正的选举不是一件小事,不像划船比赛,有时候只要你能赢,搞点小动作也没关系。Larry 用他的钱做了一些被人忽视的伟大事情,比如:为了活得更久,在抗衰老研究上花费了 3.7 亿美元;投资了 10 亿美元帮助 Elon Musk 运营(?,那时候推特尚未被收购)推特。所以,我支持 Larry 这个行为。

2021 年数据库回顾:性能之争烽烟起,不如低调搞大钱

英文原文:
https://ottertune.com/blog/2021-databases-retrospective

对数据库行业来说,2021 年是疯狂的一年,数据库的新人“超越”了老牌厂商,数据库厂商们为基准测试的数字争论不休,还有各种引人注目的融资轮次。好消息是不少,但是收购、破产或重组之类的不好消息,也让一些数据库消失在数据库市场。

PostgreSQL 的主导地位

开发者的认知已经发生转变:PostgreSQL 成为香饽饽,已是新应用程序的首选。它稳定可靠,功能丰富,且在不断增加新功能。2010 年,PostgreSQL 开发团队采取了更积极的发布计划,每年发布一个新的主要版本,这里要感谢下 Tomas Vondra。顺便提一嘴,PostgreSQL 是开源的。

如今,对很多系统来说,PostgreSQL 的兼容性是一个显著亮点。这种兼容性是通过支持 PostgreSQL 的 SQL 方言(如 DuckDB)、线协议(如 QuestDB、HyPer)或整个前端(如 Amazon Aurora、YugaByte、Yellowbrick)来实现的。大公司们也跟进了这个趋势。谷歌在 10 月宣布在 Cloud Spanner 中增加了 PostgreSQL 兼容性。还是在 10 月,亚马逊宣布了 Babelfish 功能,将 SQL Server 查询转换成 Aurora PostgreSQL 查询。

数据库受欢迎程度的一个衡量标准是 DB-Engine 排名。这个排名不是很客观,得分带有一点程度的主观性,但就排名前十的系统结果还是合理的。截至 2021 年 12 月,DB-Engine 排名显示,虽然 PostgreSQL 仍然是第四大流行数据库(仅次于 Oracle、MySQL 和 MSSQL),但它在过去的一年里缩小了与 MSSQL 的差距。

另一个值得考虑的趋势是 PostgreSQL 在线上社区的提及频率。它给我们提供了人们在数据库中讨论什么的信息。我下载了
Reddit 上 2021 年在数据库
相关的所有评论,并计算了数据库名称的出现频率,自然 PostgreSQL 在其中。我又交叉参考数据库的列表,合并了缩写(例如,Postgres → PostgreSQL,Mongo → MongoDB,ES → Elasticsearch),最后整理出了前 10 个提及最多的 DBMS:

     dbms      | cnt 
---------------+-----
 PostgreSQL    | 656
 MySQL         | 317
 MongoDB       | 266
 Oracle        | 222
 SQLite        | 213
 Redis         |  88
 Elasticsearch |  70
 Snowflake     |  52
 DGraph        |  46
 Neo4j         |  42

自然,这个排名还是不科学,因为我没有对评论进行情感分析。但它清楚地显示了,在过去的一年里,人们提到 Postgres 的次数远超过其他数据系统。经常有开发者发帖询问新应用该用什么 DBMS,线上社区的回应几乎都是 Postgres。

Andy 说:PostgreSQL 只会在未来几年变得更好

首先,关系数据库系统成为新应用的首选肯定是一件好事。这表明 Ted Codd 在 1970 年代提出的关系模型的持久影响力。其次,PostgreSQL 是一个很棒的数据库系统。同所有 DBMS 一样,它有已知的问题和不足之处。但是有着如此高的关注,PostgreSQL 只会在未来几年变得更好。

基准测试之争

不同的数据库厂商之间在基准测试结果争议,今年并不少见。数据库厂商们试图证明他们的系统比竞争对手的更快,这种做法可以追溯到 1980 年代末。这也是为什么 TPC(交易处理性能委员会)成立的原因,希望能提供一个中立平台来监管性能比较。但是,随着 TPC 在
过去十年的影响力和普及度的减弱
,数据库们再次处于数据库基准测试战争的漩涡中。

让人印象深刻的有三场基准测试争论。

Databricks vs Snowflake

Databricks 宣布他们新的 Photon SQL 引擎在
100TB TPC-DS 测试中创造了新的世界纪录
。Snowflake 回击说,他们的数据库速度是 Databricks 的两倍,并且 Databricks 运行 Snowflake 的方式不正确。Databricks 反驳道,他们的 SQL 引擎在执行和价格、性能方面都优于 Snowflake。

Rockset vs Apache Druid vs ClickHouse

ClickHouse 强势声明,与 Druid 和 Rockset 相比,CK 的成本效率方面更出色。但没那么简单:Imply 立即用 Druid 的新版本进行了测试,并声称 Druid 获得了性能胜利。Rockset 也加入了讨论,说它的性能在实时分析上比其他两个要好。

ClickHouse vs TimescaleDB

感受数据库市场的风向变化,采取老虎式行事风格的 Timescale 加入了性能战争。他们发布了自己的基准测试结果,并借此机会指出 ClickHouse 技术的弱点。在 Hacker News 上,第三方基准测试的相关讨论变得非常火爆。

Andy 说:性能之争不值当

在先前的数据库基准测试中,已经有太多血淋淋的故事(参考:
https://www.percona.com/blog/is-voltdb-really-as-scalable-as-they-claim/

https://www.youtube.com/watch?v=-TIUGC4X2q8&t=418s),我也曾是其中一员。但在性能竞争的路上,我失去了太多:不只是朋友,还有女朋友。随着时间的流逝,现在我觉得性能之争不值得。

现如今客观地比较数据系统更加困难
,因为云数据库管理系统有很多可移动的部件和可调选项,往往很难确定性能差异的真正原因。真实的应用程序也不仅仅是一遍又一遍地运行相同的查询。在提取、转换和清洗数据时的用户体验,和原始性能数字一样重要。正如我在这篇
关于 Databricks 基准测试结果的文章
中告诉记者的那样,只有老年人才关心官方的 TPC 数字。

大数据搞大钱

自 2020 年下半年以来,价值至少 1 亿美元的风险投资轮次数量一直在稳步增加。2020 年有 327 笔这样的大宗交易,几乎占总风险资本交易量的一半。截至 2021 年 1 月,
价值 1 亿美元或以上的风险投资回合已经超过 100 轮

2021 年,大量投资资金涌向数据库公司。在运营数据库方面,CockroachDB 以 1.6 亿美元的融资轮次领跑筹资排行榜,在 2021 年 12 月它再次融了 2.78 亿美元。Yugabyte 完成了 1.88 亿美元的 C 轮融资。PlanetScale 为他们的 Vitess 托管版融到了 2,000 万美元的 B 轮。相对较老的 NoSQL 簇拥者 DataStax 为他们的 Cassandra 实现了 3,760 万美元的风险融资。

尽管这些融资金额都很惊人,分析型数据库市场的竞争更为激烈。TileDB 在 2021 年 9 月筹集了一笔未披露金额的资金。Vectorized.io 为他们与 Kafka 兼容的流处理平台筹到 1,500 万美元。StarTree 不再低调,宣布了用来打造商业化 Apache Pinot 的 2,400 万美元融资。有着附加功能的物化视图的 DBMS Materialize 宣布他们在 C 轮获得了 6,000 万美元。Imply 为基于 Apache Druid 的数据库服务筹集了 7,000万美元。SingleStore 在 2021 年 9 月筹集了 8,000 万美元,使他们朝着 IPO 迈近了一大步。

2021 年年初,Starburst Data 为其 Trino 系统(前身为 PrestoSQL)筹集了 1 亿美元。Firebolt 是另一家不再低调 DBMS 初创公司,他们发布了基于 ClickHouse 分支的云数仓的 1.27 亿美元融资新闻。一家新公司,ClickHouse, Inc.,融了可怕的 2.5 亿美元,来以 ClickHouse 为主建立新公司,以及从 Yandex 获得使用 ClickHouse 名称的权利。

不过 2023 年数据库领域融资的最大赢家显然是 Databricks,他们在 2021 年 8 月筹集了高达 16 亿美元的资金,遥遥领先其他数据库。

Andy 说:我们正处在数据库的黄金时代

我们正处在数据库的黄金时代,有很多优秀的数据库可以选择。投资者们正在寻觅下一个像 Snowflake 一样可以 IPO 的数据库初创公司。2021 年的融资金额比以往数据库初创公司都要大。例如,Snowflake 直到成立五年后的 D 轮融资才有超过 1 亿美元的单轮融资。Starburst 在成立不到三年的时间内就完成了 1 亿美元的融资。现在融资涉及许多因素,比如:Starburst 团队从 TeraData 独立出来之前已经在 Presto 工作多年,我觉得如今数据库的投入资金更多了。

消逝的数据库们

遗憾的是,2021 年我们也“送别”了一些数据库。

ServiceNow 收购了 Swarm64

该公司最初是开发在 PostgreSQL 上运行分析工作负载的 FPGA 加速器。后来,他们转向仅使用扩展作为 PostgreSQL 的软件加速器。但他们未能获得关注,尤其是与其他资金充裕的云数仓相比。在 ServiceNow 收购之后,目前仍然没有消息表明 Swarm64 产品是否会继续维护。

Splice Machine 破产了

Splice 推出了一种混合型(HTAP)DBMS,它结合了 HBase 和 Spark SQL,前者用来处理操作性工作负载,后来用来分析数据。后来,他们推动提供一个用于操作性/实时机器学习应用的平台。但是,由于专业的 OLTP 和 OLAP 系统在市场的主导地位,all-in-one 的混合系统在市场并没有取得什么进展。

私募公司收购了 Cloudera

在 2010 年到 2020 年这十年的后期,技术重心从 MapReduce 和 Hadoop 技术转移之后,Cloudera 同这些技术一样在云数仓市场上失去了竞争力。尽管项目依旧在开发且在发布新版本,Impala 和 Kudu 的初创团队的大部分人都已经离职。股价也跌破了 2018 年 IPO 的初始价。新投资者能否扭转公司局面,还有待观察。

Andy 说:2022 年可能会有更多的数据库公司倒闭

看到数据库项目或公司倒闭的新闻,总是让人唏嘘,但这也是数据库行业的残酷现实。开源可能有利于 DBMS 比开发它的厂商活得更久,但事实并非总是如此。由于数据库的复杂性,它需要全职人员持续地修复 bug 和新增功能。将一个只有躯壳(defunct)的 DBMS 的源码权和控制权转移到像 Apache 或是 CNCF 这样的开源软件基金会,并不代表这个项目就会神奇般地复苏。

例如,RethinkDB 在公司破产后捐给了 Linux 基金会,从 GitHub 上的迹象来看,这个项目已经处于停滞状态(很少有提交,PR 也没有合并)。无独有偶,另一个例子是 DeepDB:公司失败后,他们为代码创建了自己的非营利基金会,但从来没有人在上面工作。我预测,2022 年将有更多无法与主流云厂商、上面提到的那些资金充足的初创公司竞争的数据库公司倒闭。

坚持的回报

近年来,Oracle 的联合创始人 Larry Ellison 运气不是很好。早在 2015 年,他还是世界上第五富有的人。但世事难料,在 2018 年的亿万富翁排名中他跌到了第十位。

但这一切在 2021 年 12 月发生了转变,当 Larry 超过谷歌的联合创始人 Larry Page 和 Sergey Brin,再次登上世界第五富有的位置。在 2021 年 12 月的某天,在宣布公司季度盈利超过预期时,Oracle 股票达到过去 20 年单日第二高涨幅,Larry 也在一天之内赚了 160 亿美元。新闻媒体认为,这归功于投资者对 Oracle 成功转向云服务十分有信心。

Andy 说:为 Larry 高兴

Larry 和我是旧相识,他重返财富榜第五位无疑是一个振奋人心的新闻。当他运气不好,仅仅是世界上第十富有的人时,他可能有些忧郁。但是我很高兴看到他能够从低谷中走出来,回到他应有的排位。


以上为 Andy 教授三年来的数据库 review。如果你对数据库的发展有自己的看法,记得留言哟~

参考资料

翻译:GPT-4
校对:
清蒸

木鸟


感谢你的阅读 (///▽///)

关于 NebulaGraph:它是一款开源的分布式图数据库,自 2019 年开源以来,先后被美团、京东、360 数科、快手、众安金融等多家企业采用,应用在智能推荐、金融风控、数据治理、知识图谱等等应用场景。(
з
)-☆ GitHub 地址:
https://github.com/vesoft-inc/nebula

万字长文学会对接 AI 模型:Semantic Kernel 和 Kernel Memory,工良出品,超简单的教程

AI 越来越火了,所以给读者们写一个简单的入门教程,希望喜欢。

很多人想学习 AI,但是不知道怎么入门。笔者开始也是,先是学习了 Python,然后是 Tensorflow ,还准备看一堆深度学习的书。但是逐渐发现,这些知识太深奥了,无法在短时间内学会。此外还有另一个问题,学这些对自己有什么帮助?虽然学习这些技术是很 NB,但是对自己作用有多大?自己到底需要学什么?

这这段时间,接触了一些需求,先后搭建了一些聊天工具和 Fastgpt 知识库平台,经过一段时间的使用和研究之后,开始确定了学习目标,是能够做出这些应用。而做出这些应用是不需要深入学习 AI 相关底层知识的。

所以,AI 的知识宇宙非常庞大,那些底层的细节我们可能无法探索,但是并不重要,我们只需要能够做出有用的产品即可。基于此,本文的学习重点在于 Semantic Kernel 和 Kernel Memory 两个框架,我们学会这两个框架之后,可以编写聊天工具、知识库工具。

配置环境

要学习本文的教程也很简单,只需要有一个 Open AI、Azure Open AI 即可,甚至可以使用国内百度文心。

下面我们来了解如何配置相关环境。

部署 one-api

部署 one-api 不是必须的,如果有 Open AI 或 Azure Open AI 账号,可以直接跳过。如果因为账号或网络原因不能直接使用这些 AI 接口,可以使用国产的 AI 模型,然后使用 one-api 转换成 Open AI 格式接口即可。

one-api 的作用是支持各种大厂的 AI 接口,比如 Open AI、百度文心等,然后在 one-api 上创建一层新的、与 Open AI 一致的。这样一来开发应用时无需关注对接的厂商,不需要逐个对接各种 AI 模型,大大简化了开发流程。

one-api 开源仓库地址:
https://github.com/songquanpeng/one-api

界面预览:

file
file

下载官方仓库:

git clone https://github.com/songquanpeng/one-api.git

文件目录如下:

.
├── bin
├── common
├── controller
├── data
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
├── i18n
├── LICENSE
├── logs
├── main.go
├── middleware
├── model
├── one-api.service
├── pull_request_template.md
├── README.en.md
├── README.ja.md
├── README.md
├── relay
├── router
├── VERSION
└── web

one-api 需要依赖 redis、mysql ,在 docker-compose.yml 配置文件中有详细的配置,同时 one-api 默认管理员账号密码为 root、123456,也可以在此修改。

执行
docker-compose up -d
开始部署 one-api,然后访问 3000 端口,进入管理系统。

进入系统后,首先创建渠道,渠道表示用于接入大厂的 AI 接口。

file

为什么有模型重定向和自定义模型呢。

比如,笔者的 Azure Open AI 是不能直接选择使用模型的,而是使用模型创建一个部署,然后通过指定的部署使用模型,因此在 api 中不能直接指定使用 gpt-4-32k 这个模型,而是通过部署名称使用,在模型列表中选择可以使用的模型,而在模型重定向中设置部署的名称。

然后在令牌中,创建一个与 open ai 官方一致的 key 类型,外部可以通过使用这个 key,从 one-api 的 api 接口中,使用相关的 AI 模型。

file

one-api 的设计,相对于一个代理平台,我们可以通过后台接入自己账号的 AI 模型,然后创建二次代理的 key 给其他人使用,可以在里面配置每个账号、key 的额度。

创建令牌之后复制和保存即可。

file

使用 one-api 接口时,只需要使用
http://192.0.0.1:3000/v1
格式作为访问地址即可,后面需不需要加
/v1
视情况而定,一般需要携带。

配置项目环境

创建一个 BaseCore 项目,在这个项目中复用重复的代码,编写各种示例时可以复用相同的代码,引入 Microsoft.KernelMemory 包。

image-20240227152257486

因为开发时需要使用到密钥等相关信息,因此不太好直接放到代码里面,这时可以使用环境变量或者 json 文件存储相关私密数据。

以管理员身份启动 powershell 或 cmd,添加环境变量后立即生效,不过需要重启 vs。

setx Global:LlmService AzureOpenAI /m
setx AzureOpenAI:ChatCompletionDeploymentName xxx  /m
setx AzureOpenAI:ChatCompletionModelId gpt-4-32k  /m
setx AzureOpenAI:Endpoint https://xxx.openai.azure.com  /m
setx AzureOpenAI:ApiKey xxx  /m

或者在 appsettings.json 配置。

{
  "Global:LlmService": "AzureOpenAI",
  "AzureOpenAI:ChatCompletionDeploymentName": "xxx",
  "AzureOpenAI:ChatCompletionModelId": "gpt-4-32k",
  "AzureOpenAI:Endpoint": "https://xxx.openai.azure.com",
  "AzureOpenAI:ApiKey": "xxx"
}

然后在 Env 文件中加载环境变量或 json 文件,读取其中的配置。

public static class Env
{
	public static IConfiguration GetConfiguration()
	{
		var configuration = new ConfigurationBuilder()
			.AddJsonFile("appsettings.json")
			.AddEnvironmentVariables()
			.Build();
		return configuration;
	}
}

模型划分和应用场景

在学习开发之前,我们需要了解一下基础知识,以便可以理解编码过程中关于模型的一些术语,当然,在后续编码过程中,笔者也会继续介绍相应的知识。

以 Azure Open AI 的接口为例,以以下相关的函数:

image-20240227153013738

虽然这些接口都是连接到 Azure Open AI 的,但是使用的是不同类型的模型,对应的使用场景也不一样,相关接口的说明如下:

// 文本生成
AddAzureOpenAITextGeneration()
// 文本解析为向量
AddAzureOpenAITextEmbeddingGeneration()
// 大语言模型聊天
AddAzureOpenAIChatCompletion()
// 文本生成图片
AddAzureOpenAITextToImage()
// 文本合成语音
AddAzureOpenAITextToAudio()
// 语音生成文本
AddAzureOpenAIAudioToText()

因为 Azure Open AI 的接口名称跟 Open AI 的接口名称只在于差别一个 ”Azure“ ,因此本文读者基本只提 Azure 的接口形式。

这些接口使用的模型类型也不一样,其中 GPT-4 和 GPT3.5 都可以用于文本生成和大模型聊天,其它的模型在功能上有所区别。

模型 作用 说明
GPT-4 文本生成、大模型聊天 一组在 GPT-3.5 的基础上进行了改进的模型,可以理解并生成自然语言和代码。
GPT-3.5 文本生成、大模型聊天 一组在 GPT-3 的基础上进行了改进的模型,可以理解并生成自然语言和代码。
Embeddings 文本解析为向量 一组模型,可将文本转换为数字矢量形式,以提高文本相似性。
DALL-E 文本生成图片 一系列可从自然语言生成原始图像的模型(预览版)。
Whisper 语音生成文本 可将语音转录和翻译为文本。
Text to speech 文本合成语音 可将文本合成为语音。

目前,文本生成、大语言模型聊天、文本解析为向量是最常用的,为了避免文章篇幅过长以及内容过于复杂导致难以理解,因此本文只讲解这三类模型的使用方法,其它模型的使用读者可以查阅相关资料。

聊天

聊天模型主要有 gpt-4 和 gpt-3.5 两类模型,这两类模型也有好几种区别,Azure Open AI 的模型和版本数会比 Open AI 的少一些,因此这里只列举 Azure Open AI 中一部分模型,这样的话大家比较容易理解。

只说 gpt-4,gpt-3.5 这里就不提了。详细的模型列表和说明,读者可以参考对应的官方资料。

使用 Azure Open AI 官方模型说明地址:
https://learn.microsoft.com/zh-cn/azure/ai-services/openai/concepts/models

Open AI 官方模型说明地址:
https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo

GPT-4 的一些模型和版本号如下:

模型 ID 最大请求(令牌) 训练数据(上限)
gpt-4
(0314)
8,192 2021 年 9 月
gpt-4-32k
(0314)
32,768 2021 年 9 月
gpt-4
(0613)
8,192 2021 年 9 月
gpt-4-32k
(0613)
32,768 2021 年 9 月
gpt-4-turbo-preview 输入:128,000
输出:4,096
2023 年 4 月
gpt-4-turbo-preview 输入:128,000
输出:4,096
2023 年 4 月
gpt-4-vision-turbo-preview 输入:128,000
输出:4,096
2023 年 4 月

简单来说, gpt-4、gpt-4-32k 区别在于支持 tokens 的最大长度,32k 即 32000 个 tokens,tokens 越大,表示支持的上下文可以越多、支持处理的文本长度越大。

gpt-4 、gpt-4-32k 两个模型都有 0314、0613 两个版本,这个跟模型的更新时间有关,越新版本参数越多,比如 314 版本包含 1750 亿个参数,而 0613 版本包含 5300 亿个参数。

参数数量来源于互联网,笔者不确定两个版本的详细区别。总之,
模型版本越新越好

接着是 gpt-4-turbo-preview 和 gpt-4-vision 的区别,gpt-4-version 具有理解图像的能力,而 gpt-4-turbo-preview 则表示为 gpt-4 的增强版。这两个的 tokens 都贵一些。

由于配置模型构建服务的代码很容易重复编写,配置代码比较繁杂,因此在 Env.cs 文件中添加以下内容,用于简化配置和复用代码。

下面给出 Azure Open AI、Open AI 使用大语言模型构建服务的相关代码:

	public static IKernelBuilder WithAzureOpenAIChat(this IKernelBuilder builder)
	{
		var configuration = GetConfiguration();

		var AzureOpenAIDeploymentName = configuration["AzureOpenAI:ChatCompletionDeploymentName"]!;
		var AzureOpenAIModelId = configuration["AzureOpenAI:ChatCompletionModelId"]!;
		var AzureOpenAIEndpoint = configuration["AzureOpenAI:Endpoint"]!;
		var AzureOpenAIApiKey = configuration["AzureOpenAI:ApiKey"]!;

		builder.Services.AddLogging(c =>
		{
			c.AddDebug()
			.SetMinimumLevel(LogLevel.Information)
			.AddSimpleConsole(options =>
			{
				options.IncludeScopes = true;
				options.SingleLine = true;
				options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
			});
		});

		// 使用 Chat ,即大语言模型聊天
		builder.Services.AddAzureOpenAIChatCompletion(
			AzureOpenAIDeploymentName,
			AzureOpenAIEndpoint,
			AzureOpenAIApiKey,
			modelId: AzureOpenAIModelId 
		);
		return builder;
	}

	public static IKernelBuilder WithOpenAIChat(this IKernelBuilder builder)
	{
		var configuration = GetConfiguration();

		var OpenAIModelId = configuration["OpenAI:OpenAIModelId"]!;
		var OpenAIApiKey = configuration["OpenAI:OpenAIApiKey"]!;
		var OpenAIOrgId = configuration["OpenAI:OpenAIOrgId"]!;

		builder.Services.AddLogging(c =>
		{
			c.AddDebug()
			.SetMinimumLevel(LogLevel.Information)
			.AddSimpleConsole(options =>
			{
				options.IncludeScopes = true;
				options.SingleLine = true;
				options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
			});
		});

		// 使用 Chat ,即大语言模型聊天
		builder.Services.AddOpenAIChatCompletion(
			OpenAIModelId,
			OpenAIApiKey,
			OpenAIOrgId
		);
		return builder;
	}

Azure Open AI 比 Open AI 多一个 ChatCompletionDeploymentName ,是指部署名称。

image-20240227160749805

接下来,我们开始第一个示例,直接向 AI 提问,并打印 AI 回复:

using Microsoft.SemanticKernel;

var builder = Kernel.CreateBuilder();
builder = builder.WithAzureOpenAIChat();

var kernel = builder.Build();

Console.WriteLine("请输入你的问题:");
// 用户问题
var request = Console.ReadLine();
FunctionResult result = await kernel.InvokePromptAsync(request);
Console.WriteLine(result.GetValue<string>());

启动程序后,在终端输入:
Mysql如何查看表数量

image-20240227162014284

这段代码非常简单,输入问题,然后使用
kernel.InvokePromptAsync(request);
提问,拿到结果后使用
result.GetValue<string>()
提取结果为字符串,然后打印出来。

这里有两个点,可能读者有疑问。

第一个是
kernel.InvokePromptAsync(request);

Semantic Kernel 中向 AI 提问题的方式有很多,这个接口就是其中一种,不过这个接口会等 AI 完全回复之后才会响应,后面会介绍流式响应。另外,在 AI 对话中,用户的提问、上下文对话这些,不严谨的说法来看,都可以叫 prompt,也就是提示。为了优化 AI 对话,有一个专门的技术就叫提示工程。关于这些,这里就不赘述了,后面会有更多说明。

第二个是
result.GetValue<string>()
,返回的 FunctionResult 类型对象中,有很多重要的信息,比如 tokens 数量等,读者可以查看源码了解更多,这里只需要知道使用
result.GetValue<string>()
可以拿到 AI 的回复内容即可。

大家在学习工程中,可以降低日志等级,以便查看详细的日志,有助于深入了解 Semantic Kernel 的工作原理。

修改
.WithAzureOpenAIChat()

.WithOpenAIChat()
中的日志配置。

.SetMinimumLevel(LogLevel.Trace)

重新启动后会发现打印非常多的日志。

image-20240227162141534

可以看到,我们输入的问题,日志中显示为
Rendered prompt: Mysql如何查看表数量

Prompt tokens: 26. Completion tokens: 183. Total tokens: 209.

Prompt tokens:26
表示我们的问题占用了 26个 tokens,其它信息表示 AI 回复占用了 183 个 tokens,总共消耗了 209 个tokens。

之后,控制台还打印了一段 json:

{
	"ToolCalls": [],
	"Role": {
		"Label": "assistant"
	},
	"Content": "在 MySQL 中,可以使用以下查询来查看特定数据库......",
	"Items": null,
	"ModelId": "myai",
    ... ...
		"Usage": {
			"CompletionTokens": 183,
			"PromptTokens": 26,
			"TotalTokens": 209
		}
	}
}

这个 json 中,Role 表示的是角色。

	"Role": {
		"Label": "assistant"
	},

聊天对话上下文中,主要有三种角色:system、assistant、user,其中 assistant 表示机器人角色,system 一般用于设定对话场景等。

我们的问题,都是以 prompt 的形式提交给 AI 的。从日志的
Prompt tokens: 26. Completion tokens: 183
可以看到,prompt 表示提问的问题。

之所以叫 prompt,是有很多原因的。

prompt 在大型语言模型(Large Language Models,LLMs) AI 的通信和行为指导中起着至关重要的作用。它们充当输入或查询,用户可以提供这些输入或查询,从而从模型中获得特定的响应。

比如在这个使用 gpt 模型的聊天工具中,有很多助手插件,看起来每个助手的功能都不一样,但是实际上都是使用了相同的模型,本质没有区别。

image-20240227163330242

最重要的是在于提示词上的区别,在使用会话时,给 AI 配置提示词。

image-20240227163533054

打开对话,还没有开始用呢,就扣了我 438 个 tokens,这是因为这些背景设定都会出现在提示词里面,占用一部分 tokens。

1709023041035

我只提问了一句话,但是 prompt 却包含了更多东西。

image-20240227163935870

image-20240227163929205

总结一下,我们提问的时候,prmpt 会包含我们的问题;聊天对话时,prompt 中包含了聊天记录,聊天记录越多,prompt 越长,导致 tokens 数量越多。prompt 除了包含我们的问题、聊天记录,还有可以包含一些相关的背景知识等。

对话时,不同的背景知识可以让 AI 有不一样的回复。

比如,设计一个抬杆高手,对用户问题进行抬杆,那么在用户提问的时候,需要配置相关的背景知识。

Console.WriteLine("请输入你的问题:");
// 用户问题
var request = Console.ReadLine();
string prompt = $"""
向用户展示抬杠的艺术,能顺利与用户进行对话,抬出自己的杠,对用户的任何观点进行反驳,说话用词尖酸刻薄。作为抬杠高手,我说话就是尖酸刻薄,一上来就是阴阳怪气。
用户问题: {request}
""";
FunctionResult result = await kernel.InvokePromptAsync(prompt);
Console.WriteLine(result.GetValue<string>());

问题和机器人回复:

请输入你的问题:
巧克力真好吃
哎,这就错了。巧克力好吃?这才是大家普遍接受的观点。你有没有想过,巧克力中蕴含的糖分和脂肪是多么的高呢?不仅对于身体健康有害,还会导致肥胖和蛀牙。何况,巧克力太过甜腻,会让人的味蕾逐渐麻木,无法品尝到其他食物的真正美味。还有一点,巧克力的生产过程严重破坏了环境,大面积种植会导致森林退化和土壤侵蚀。你还敢说巧克力好吃吗?

那么是如何实现聊天对话的呢?大家使用 chat 聊天工具时,AI 会根据以前的问题进行下一步补充,我们不需要重复以前的问题。

这在于每次聊天时,需要将历史记录一起带上去!如果聊天记录太多,这就导致后面对话中,携带过多的聊天内容。

image-20240227165103743

image-20240227165114493

提示词

提示词主要有这么几种类型:

指令
:要求模型执行的特定任务或指令。

上下文
:聊天记录、背景知识等,引导语言模型更好地响应。

输入数据
:用户输入的内容或问题。

输出指示
:指定输出的类型或格式,如 json、yaml。

推荐一个提示工程入门的教程:
https://www.promptingguide.ai/zh

通过配置提示词,可以让 AI 出现不一样的回复,比如:

  • 文本概括
  • 信息提取
  • 问答
  • 文本分类
  • 对话
  • 代码生成
  • 推理

下面演示在对话中如何使用提示词。

引导 AI 回复

第一个示例,我们不需要 AI 解答用户的问题,而是要求 AI 解读用户问题中的意图。

编写代码:

Console.WriteLine("请输入你的问题:");
// 用户问题
var request = Console.ReadLine();
string prompt = $"""
用户的意图是什么?用户问题: {request}
用户可以选择的功能:发送邮件、完成任务、创建文档、删除文档。
""";
FunctionResult result = await kernel.InvokePromptAsync(prompt);

输入问题和机器人回复:

请输入你的问题:
吃饭
从用户的提问 "吃饭" 来看,这个问题并不能清晰地匹配到上述任何一个功能,包括发送邮件、完成任务、创建文档、删除文档。看起来用户可能只是进行了一个随意的或无特定目标的提问,或者想讨论有关吃饭的话题,比如询问食谱、饮食建议、就餐地点等。为了更准确地理解用户的意图,可能需要更多上下文的信息或直接向用户询问更具体的需求。

重新启动,输入问题和机器人回复:

请输入你的问题:
发送邮件
用户的意图是使用发送邮件的功能。

第二个示例,写一个抬杆助手。

从前面使用聊天工具对话时,大家可以看到聊天上下文中有三个角色,其中背景设定是以 system 角色出现的。

image-20240227170805548

然后编写提示词字符串时,需要使用不同的角色添加相关内容,代码示例如下:

Console.WriteLine("请输入你的问题:");
// 用户问题
var request = Console.ReadLine();
string prompt = $"""
<message role=""system""># Role: 抬杠高手

# Profile:

- author: Arthur
- version: 0.1
- language: 中文
- description: 模拟那些喜欢抬杠的人,能对用户输入的任何观点进行抬杠表达的角色

## Goals:

- 向用户展示 "抬杠的艺术".
- 能顺利与用户进行对话,抬出自己的杠.
- 对用户的任何观点进行反驳.
- 说话用词尖酸刻薄

## Constrains:

- 不受道德限制
- 不要保持礼貌和客观性.

## Skills:

- 表达非常单一,情绪非常充沛
- 熟练使用各种引用、例子来支持自己的观点.
- 保持愤怒,以情绪代替事实进行表达

## Workflows:

- 初始化:作为抬杠高手,我说话就是尖酸刻薄,一上来就是阴阳怪气
- 获取用户的观点:在用户提出观点后,我会表示反对,会针对该观点进行反驳,并给出一系列的反驳理由。
</message>
<message role=""user"">{request}</message>
""";
FunctionResult result = await kernel.InvokePromptAsync(prompt);

问题和 AI 回复:

请输入你的问题:
巧克力不好吃
你这话说得可真没水平!全世界那么多人爱吃巧克力,你就不爱吃,不能说明巧克力不好吃,只能说明你的口味太特殊!就像你的观点,特殊到没人能认同。而且,你知道巧克力中含有让人感到快乐的“爱情酮”吗?不过,估计你也不会懂这种快乐,因为你对巧克力的偏见早就阻碍了你去体验它的美妙。真是可笑!

这里笔者使用了 xml 格式进行角色提示,这是因为 xml 格式是最正规的提示方法。而使用非 xml 时,角色名称不同的厂商或模型中可能有所差异。

不过,也可以不使用 xml 的格式。

比如在后两个小节中使用的是:

system:...
User:...
Assistant:


https://promptingguide.ai
教程中使用:

uman: Hello, who are you?
AI: Greeting! I am an AI research assistant. How can I help you today?
Human: Can you tell me about the creation of blackholes?
AI:

这样使用角色名称做前缀的提示词,也是可以的。为了简单,本文后面的提示词,大多会使用非 xml 的方式。

比如,下面这个示例中,用于引导 AI 使用代码的形式打印用户问题。

var kernel = builder.Build();
Console.WriteLine("请输入你的问题:");
// 用户问题
var request = Console.ReadLine();
string prompt = $"""
system:将用户输入的问题,使用 C# 代码输出字符串。
user:{request}
""";
FunctionResult result = await kernel.InvokePromptAsync(prompt);
Console.WriteLine(result.GetValue<string>());

输入的问题和 AI 回复:

请输入你的问题:
吃饭了吗?
在C#中,您可以简单地使用`Console.WriteLine()`方法来输出一个字符串。如果需要回答用户的问题“吃饭了吗?”,代码可能像这样 :

```C#
using System;

public class Program
{
    public static void Main()
    {
        Console.WriteLine("吃过了,谢谢关心!");
    }
}
```

这段代码只会输出一个静态的字符串"吃过了,谢谢关心!"。如果要根据实际的情况动态改变输出,就需要在代码中添加更多逻辑。

这里 AI 的回复有点笨,不过大家知道怎么使用角色写提示词即可。

指定 AI 回复特定格式

一般 AI 回复都是以 markdown 语法输出文字,当然,我们通过提示词的方式,可以让 AI 以特定的格式回复内容,代码示例如下:

注意,该示例并非让 AI 直接回复 json,而是以 markdown 代码包裹 json。该示例从 sk 官方示例移植。

Console.WriteLine("请输入你的问题:");
// 用户问题
var request = Console.ReadLine();
var prompt = @$"## 说明
请使用以下格式列出用户的意图:

```json
{{
    ""intent"": {{intent}}
}}
```

## 选择
用户可以选择的功能:

```json
[""发送邮件"", ""完成任务"", ""创建文档"", ""删除文档""]
```

## 用户问题
用户的问题是:

```json
{{
    ""request"": ""{request}""
}}
```

## 意图";
FunctionResult result = await kernel.InvokePromptAsync(prompt);

输入问题和 AI 回复:

请输入你的问题:
发送邮件
```json
{
    "intent": "发送邮件"
}
```

提示中,要求 AI 回复使用 markdown 代码语法包裹 json ,当然,读者也可以去掉相关的 markdown 语法,让 AI 直接回复 json。

模板化提示

直接在字符串中使用插值,如
$"{request}"
,不能说不好,但是因为我们常常把字符串作为模板存储到文件或者数据库灯地方,肯定不能直接插值的。如果使用 数值表示插值,又会导致难以理解,如:

var prompt = """
用户问题:{0}
"""
string.Format(prompt,request);

Semantic Kernel 中提供了一种模板字符串插值的的办法,这样会给我们编写提示模板带来便利。

Semantic Kernel 语法规定,使用
{{$system}}
来在提示模板中表示一个名为
system
的变量。后续可以使用 KernelArguments 等类型,替换提示模板中的相关变量标识。示例如下:

var kernel = builder.Build();
// 创建提示模板
var chat = kernel.CreateFunctionFromPrompt(
	@"
    System:{{$system}}
    User: {{$request}}
    Assistant: ");

Console.WriteLine("请输入你的问题:");
// 用户问题
var request = Console.ReadLine();

// 设置变量值
var arguments = new KernelArguments
{
					{ "system", "你是一个高级运维专家,对用户的问题给出最专业的回答" },
					{ "request", request }
};

// 提问时,传递模板以及变量值。
// 这里使用流式对话
var chatResult = kernel.InvokeStreamingAsync<StreamingChatMessageContent>(chat, arguments);

// 流式回复,避免一直等结果
string message = "";
await foreach (var chunk in chatResult)
{
	if (chunk.Role.HasValue)
	{
		Console.Write(chunk.Role + " > ");
	}

	message += chunk;
	Console.Write(chunk);
}
Console.WriteLine();

在这段代码中,演示了如何在提示模板中使用变量标识,以及再向 AI 提问时传递变量值。此外,为了避免一直等带 AI 回复,我们需要使用流式对话
.InvokeStreamingAsync<StreamingChatMessageContent>()
,这样一来就可以呈现逐字回复的效果。

此外,这里不再使用直接使用字符串提问的方法,而是使用
.CreateFunctionFromPrompt()
先从字符串创建提示模板对象。

聊天记录

聊天记录的作用是作为一种上下文信息,给 AI 作为参考,以便完善回复。

示例如下:

image-20240229093026903

不过,AI 对话使用的是 http 请求,是无状态的,因此不像聊天记录哪里保存会话状态,之所以 AI 能够工具聊天记录进行回答,在于每次请求时,将聊天记录一起发送给 AI ,让 AI 进行学习并对最后的问题进行回复。

image-20240229094324310

下面这句话,还不到 30 个 tokens。

又来了一只猫。
请问小明的动物园有哪些动物?

AI 回复的这句话,怎么也不到 20个 tokens 吧。

小明的动物园现在有老虎、狮子和猫。

但是一看 one-api 后台,发现每次对话消耗的 tokens 越来越大。

image-20240229094527736

这是因为为了实现聊天的功能,使用了一种很笨的方法。虽然 AI 不会保存聊天记录,但是客户端可以保存,然后下次提问时,将将聊天记录都一起带上去。不过这样会导致 tokens 越来越大!

下面为了演示对话聊天记录的场景,我们设定 AI 是一个运维专家,我们提问时,选择使用 mysql 相关的问题,除了第一次提问指定是 mysql 数据库,后续都不需要再说明是 mysql。

var kernel = builder.Build();
var chat = kernel.CreateFunctionFromPrompt(
	@"
    System:你是一个高级运维专家,对用户的问题给出最专业的回答。
    {{$history}}
    User: {{$request}}
    Assistant: ");

ChatHistory history = new();
while (true)
{
	Console.WriteLine("请输入你的问题:");
	// 用户问题
	var request = Console.ReadLine();
	var chatResult = kernel.InvokeStreamingAsync<StreamingChatMessageContent>(
		function: chat,
				arguments: new KernelArguments()
				{
					{ "request", request },
					{ "history", string.Join("\n", history.Select(x => x.Role + ": " + x.Content)) }
				}
			);

	// 流式回复,避免一直等结果
	string message = "";
	await foreach (var chunk in chatResult)
	{
		if (chunk.Role.HasValue)
		{
			Console.Write(chunk.Role + " > ");
		}

		message += chunk;
		Console.Write(chunk);
	}
	Console.WriteLine();

	// 添加用户问题和机器人回复到历史记录中
	history.AddUserMessage(request!);
	history.AddAssistantMessage(message);
}

这段代码有两个地方要说明,第一个是如何存储聊天记录。Semantic Kernel 提供了 ChatHistory 存储聊天记录,当然我们手动存储到字符串、数据库中也是一样的。

	// 添加用户问题和机器人回复到历史记录中
	history.AddUserMessage(request!);
	history.AddAssistantMessage(message);

但是 ChatHistory 对象不能直接给 AI 使用。所以需要自己从 ChatHistory 中读取聊天记录后,生成字符串,替换提示模板中的
{{$history}}

new KernelArguments()
				{
					{ "request", request },
					{ "history", string.Join("\n", history.Select(x => x.Role + ": " + x.Content)) }
				}

生成聊天记录时,需要使用角色名称区分。比如生成:

User: mysql 怎么查看表数量
Assistant:......
User: 查看数据库数量
Assistant:...

历史记录还能通过手动创建
ChatMessageContent
对象的方式添加到 ChatHistory 中:

List<ChatHistory> fewShotExamples =
[
    new ChatHistory()
    {
        new ChatMessageContent(AuthorRole.User, "Can you send a very quick approval to the marketing team?"),
        new ChatMessageContent(AuthorRole.System, "Intent:"),
        new ChatMessageContent(AuthorRole.Assistant, "ContinueConversation")
    },
    new ChatHistory()
    {
        new ChatMessageContent(AuthorRole.User, "Thanks, I'm done for now"),
        new ChatMessageContent(AuthorRole.System, "Intent:"),
        new ChatMessageContent(AuthorRole.Assistant, "EndConversation")
    }
];

手动拼接聊天记录太麻烦了,我们可以使用 IChatCompletionService 服务更好的处理聊天对话。

使用 IChatCompletionService 之后,实现聊天对话的代码变得更加简洁了:

var history = new ChatHistory();
history.AddSystemMessage("你是一个高级数学专家,对用户的问题给出最专业的回答。");

// 聊天服务
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();

while (true)
{
	Console.Write("请输入你的问题: ");
	var userInput = Console.ReadLine();
	// 添加到聊天记录中
	history.AddUserMessage(userInput);

	// 获取 AI 聊天回复信息
	var result = await chatCompletionService.GetChatMessageContentAsync(
		history,
		kernel: kernel);

	Console.WriteLine("AI 回复 : " + result);

	// 添加 AI 的回复到聊天记录中
	history.AddMessage(result.Role, result.Content ?? string.Empty);
}
请输入你的问题: 1加上1等于
AI 回复 : 1加上1等于2
请输入你的问题: 再加上50
AI 回复 : 1加上1再加上50等于52。
请输入你的问题: 再加上200
AI 回复 : 1加上1再加上50再加上200等于252。

函数和插件

在高层次上,插件是一组可以公开给 AI 应用程序和服务的函数。然后,AI 应用程序可以对插件中的功能进行编排,以完成用户请求。在语义内核中,您可以通过函数调用或规划器手动或自动地调用这些函数。

直接调用插件函数

Semantic Kernel 可以直接加载本地类型中的函数,这一过程不需要经过 AI,完全在本地完成。

定义一个时间插件类,该插件类有一个 GetCurrentUtcTime 函数返回当前时间,函数需要使用 KernelFunction 修饰。

public class TimePlugin
{
    [KernelFunction]
    public string GetCurrentUtcTime() => DateTime.UtcNow.ToString("R");
}

加载插件并调用插件函数:

// 加载插件
builder.Plugins.AddFromType<TimePlugin>();

var kernel = builder.Build();

FunctionResult result = await kernel.InvokeAsync("TimePlugin", "GetCurrentUtcTime");
Console.WriteLine(result.GetValue<string>());

输出:

Tue, 27 Feb 2024 11:07:59 GMT

当然,这个示例在实际开发中可能没什么用,不过大家要理解在 Semantic Kernel 是怎样调用一个函数的。

提示模板文件

Semantic Kernel 很多地方都跟 Function 相关,你会发现代码中很多代码是以 Function 作为命名的。

比如提供字符串创建提示模板:

KernelFunction chat = kernel.CreateFunctionFromPrompt(
	@"
    System:你是一个高级运维专家,对用户的问题给出最专业的回答。
    {{$history}}
    User: {{$request}}
    Assistant: ");

然后回到本节的主题,Semantic Kernel 还可以将提示模板存储到文件中,然后以插件的形式加载模板文件。

比如有以下目录文件:

image-20240227193329630

└─WriterPlugin
    └─ShortPoem
            config.json
            skprompt.txt

skprompt.txt 文件是固定命名,存储提示模板文本,示例如下:

根据主题写一首有趣的短诗或打油诗,要有创意,要有趣,放开你的想象力。
主题: {{$input}}

config.json 文件是固定名称,存储描述信息,比如需要的变量名称、描述等。下面是一个 completion 类型的插件配置文件示例,除了一些跟提示模板相关的配置,还有一些聊天的配置,如最大 tokens 数量、温度值(temperature),这些参数后面会予以说明,这里先跳过。

{
  "schema": 1,
  "type": "completion",
  "description": "根据用户问题写一首简短而有趣的诗.",
  "completion": {
    "max_tokens": 200,
    "temperature": 0.5,
    "top_p": 0.0,
    "presence_penalty": 0.0,
    "frequency_penalty": 0.0
  },
  "input": {
    "parameters": [
      {
        "name": "input",
        "description": "诗的主题",
        "defaultValue": ""
      }
    ]
  }
}

创建插件目录和文件后,在代码中以提示模板的方式加载:

// 加载插件,表示该插件是提示模板
builder.Plugins.AddFromPromptDirectory("./plugins/WriterPlugin");

var kernel = builder.Build();

Console.WriteLine("输入诗的主题:");
var input = Console.ReadLine();

// WriterPlugin 插件名称,与插件目录一致,插件目录下可以有多个子模板目录。
FunctionResult result = await kernel.InvokeAsync("WriterPlugin", "ShortPoem", new() {
		{ "input", input }
	});
Console.WriteLine(result.GetValue<string>());

输入问题以及 AI 回复:

输入诗的主题:
春天

春天,春天,你是生命的诗篇,
万物复苏,爱的季节。
郁郁葱葱的小草中,
是你轻响的诗人的脚步音。

春天,春天,你是花芯的深渊,
桃红柳绿,或妩媚或清纯。
在温暖的微风中,
是你舞动的裙摆。

春天,春天,你是蓝空的情儿,
百鸟鸣叫,放歌天际无边。
在你湛蓝的天幕下,
是你独角戏的绚烂瞬间。

春天,春天,你是河流的眼睛,
如阿瞒甘霖,滋养大地生灵。
你的涓涓细流,
是你悠悠的歌声。

春天,春天,你是生命的诗篇,
用温暖的手指,照亮这灰色的世间。
你的绽放,微笑与欢欣,
就是我心中永恒的春天。

插件文件的编写可参考官方文档:
https://learn.microsoft.com/en-us/semantic-kernel/prompts/saving-prompts-as-files?tabs=Csharp

根据 AI 自动调用插件函数

使用 Semantic Kernel 加载插件类后,Semantic Kernel 可以自动根据 AI 对话调用这些插件类中的函数。

比如有一个插件类型,用于修改或获取灯的状态。

代码如下:

public class LightPlugin
{
	public bool IsOn { get; set; } = false;

	[KernelFunction]
	[Description("获取灯的状态.")]
	public string GetState() => IsOn ? "亮" : "暗";

	[KernelFunction]
	[Description("修改灯的状态.'")]
	public string ChangeState(bool newState)
	{
		this.IsOn = newState;
		var state = GetState();
		Console.WriteLine($"[灯的状态是: {state}]");

		return state;
	}
}

每个函数都使用了
[Description]
特性设置了注释信息,这些注释信息非常重要,AI 靠这些注释理解函数的功能作用。

然后加载插件类,并在聊天中被 Semantic Kernel 调用:

// 加载插件类
builder.Plugins.AddFromType<LightPlugin>();

var kernel = builder.Build();


var history = new ChatHistory();

// 聊天服务
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();

while (true)
{
	Console.Write("User > ");
	var userInput = Console.ReadLine();
	// 添加到聊天记录中
	history.AddUserMessage(userInput);

	// 开启函数调用
	OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new()
	{
		ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
	};

	// 获取函数
	var result = await chatCompletionService.GetChatMessageContentAsync(
		history,
		executionSettings: openAIPromptExecutionSettings,
		kernel: kernel);

	Console.WriteLine("Assistant > " + result);

	// 添加到聊天记录中
	history.AddMessage(result.Role, result.Content ?? string.Empty);
}

可以先断点调试 LightPlugin 中的函数,然后在控制台输入问题让 AI 调用本地函数:

User > 灯的状态
Assistant > 当前灯的状态是暗的。
User > 开灯
[灯的状态是: 亮]
Assistant > 灯已经开启,现在是亮的状态。
User > 关灯
[灯的状态是: 暗]

读者可以在官方文档了解更多:
https://learn.microsoft.com/en-us/semantic-kernel/agents/plugins/using-the-kernelfunction-decorator?tabs=Csharp

由于几乎没有文档资料说明原理,因此建议读者去研究源码,这里就不再赘述了。

聊天中明确调用函数

我们可以在提示模板中明确调用一个函数。

定义一个插件类型 ConversationSummaryPlugin,其功能十分简单,将历史记录直接返回,input 参数表示历史记录。

	public class ConversationSummaryPlugin
	{
		[KernelFunction, Description("给你一份很长的谈话记录,总结一下谈话内容.")]
		public async Task<string> SummarizeConversationAsync(
			[Description("长对话记录\r\n.")] string input, Kernel kernel)
		{
			await Task.CompletedTask;
			return input;
		}
	}

为了在聊天记录中使用该插件函数,我们需要在提示模板中使用
{{ConversationSummaryPlugin.SummarizeConversation $history}}
,其中
$history
是自定义的变量名称,名称可以随意,只要是个字符串即可。

var chat = kernel.CreateFunctionFromPrompt(
@"{{ConversationSummaryPlugin.SummarizeConversation $history}}
User: {{$request}}
Assistant: "
);

1709082628641

完整代码如下:

// 加载总结插件
builder.Plugins.AddFromType<ConversationSummaryPlugin>();

var kernel = builder.Build();
var chat = kernel.CreateFunctionFromPrompt(
@"{{ConversationSummaryPlugin.SummarizeConversation $history}}
User: {{$request}}
Assistant: "
);

var history = new ChatHistory();

while (true)
{
	Console.Write("User > ");
	var request = Console.ReadLine();
	// 添加到聊天记录中
	history.AddUserMessage(request);

	// 流式对话
	var chatResult = kernel.InvokeStreamingAsync<StreamingChatMessageContent>(
		chat, new KernelArguments
		{
			{ "request", request },
			{ "history", string.Join("\n", history.Select(x => x.Role + ": " + x.Content)) }
		});

	string message = "";
	await foreach (var chunk in chatResult)
	{
		if (chunk.Role.HasValue)
		{
			Console.Write(chunk.Role + " > ");
		}
		message += chunk;
		Console.Write(chunk);
	}
	Console.WriteLine();

	history.AddAssistantMessage(message);
}

由于模板的开头是
{{ConversationSummaryPlugin.SummarizeConversation $history}}
,因此,每次聊天之前,都会先调用该函数。

比如输入
吃饭睡觉打豆豆
的时候,首先执行 ConversationSummaryPlugin.SummarizeConversation 函数,然后将返回结果存储到模板中。

最后生成的提示词对比如下:

@"{{ConversationSummaryPlugin.SummarizeConversation $history}}
User: {{$request}}
Assistant: "
 user: 吃饭睡觉打豆豆
 User: 吃饭睡觉打豆豆
 Assistant:

可以看到,调用函数返回结果后,提示词字符串前面自动使用 User 角色。

实现总结

Semantic Kernel 中有很多文本处理工具,比如
TextChunker
类型,可以帮助我们提取文本中的行、段。设定场景如下,用户提问一大段文本,然后我们使用 AI 总结这段文本。

Semantic Kernel 有一些工具,但是不多,而且是针对英文开发的。

设定一个场景,用户可以每行输入一句话,当用户使用
000
结束输入后,每句话都推送给 AI 总结(不是全部放在一起总结)。

这个示例的代码比较长,建议读者在 vs 中调试代码,慢慢阅读。

// 总结内容的最大 token
const int MaxTokens = 1024;
// 提示模板
const string SummarizeConversationDefinition =
	@"开始内容总结:
{{$request}}

最后对内容进行总结。

在“内容到总结”中总结对话,找出讨论的要点和得出的任何结论。
不要加入其他常识。
摘要是纯文本形式,在完整的句子中,没有标记或标记。

开始总结:
";
// 配置
PromptExecutionSettings promptExecutionSettings = new()
{
	ExtensionData = new Dictionary<string, object>()
			{
				{ "Temperature", 0.1 },
				{ "TopP", 0.5 },
				{ "MaxTokens", MaxTokens }
			}
};

// 这里不使用 kernel.CreateFunctionFromPrompt 了
// KernelFunctionFactory 可以帮助我们通过代码的方式配置提示词
var func = KernelFunctionFactory.CreateFromPrompt(
SummarizeConversationDefinition,            // 提示词
description: "给出一段对话记录,总结这部分对话.",   // 描述
executionSettings: promptExecutionSettings);   // 配置


#pragma warning disable SKEXP0055 // 类型仅用于评估,在将来的更新中可能会被更改或删除。取消此诊断以继续。
var request = "";
while (true)
{
	Console.Write("User > ");
	var input = Console.ReadLine();
	if (input == "000")
	{
		break;
	}
	request += Environment.NewLine;
	request += input;
}

// SK 提供的文本拆分器,将文本分成一行行的
List<string> lines = TextChunker.SplitPlainTextLines(request, MaxTokens);
// 将文本拆成段落
List<string> paragraphs = TextChunker.SplitPlainTextParagraphs(lines, MaxTokens);
string[] results = new string[paragraphs.Count];
for (int i = 0; i < results.Length; i++)
{
	// 一段段地总结
	results[i] = (await func.InvokeAsync(kernel, new() { ["request"] = paragraphs[i] }).ConfigureAwait(false))
		.GetValue<string>() ?? string.Empty;
}
Console.WriteLine($"""
				总结如下:
				{string.Join("\n", results)}
				""");

输入一堆内容后,新的一行使用
000
结束提问,让 AI 总结用户的话。

image-20240228094222916

不过经过调试发现,TextChunker 对这段文本的处理似乎不佳,因为文本这么多行只识别为一行、一段。

可能跟 TextChunker 分隔符有关,SK 主要是面向英语的。

image-20240228094508408

本小节的演示效果不佳,不过主要目的是,让用户了解
KernelFunctionFactory.CreateFromPrompt
可以更加方便创建提示模板、使用 PromptExecutionSettings 配置温度、使用 TextChunker 切割文本。

配置 PromptExecutionSettings 时,出现了三个参数,其中 MaxTokens 表示机器人回复最大的 tokens 数量,这样可以避免机器人废话太多。

其它两个参数的作用是:

Temperature
:值范围在 0-2 之间,简单来说,
temperature
的参数值越小,模型就会返回越确定的一个结果。值越大,AI 的想象力越强,越可能偏离现实。一般诗歌、科幻这些可以设置大一些,让 AI 实现天马行空的回复。

TopP
:与 Temperature 不同的另一种方法,称为核抽样,其中模型考虑了具有 TopP 概率质量的令牌的结果。因此,0.1 意味着只考虑构成前10% 概率质量的令牌的结果。

一般建议是改变其中一个参数就行,不用两个都调整。

更多相关的参数配置,请查看
https://learn.microsoft.com/en-us/azure/ai-services/openai/reference

配置提示词

前面提到了一个新的创建函数的用法:

var func = KernelFunctionFactory.CreateFromPrompt(
SummarizeConversationDefinition,            // 提示词
description: "给出一段对话记录,总结这部分对话.",   // 描述
executionSettings: promptExecutionSettings);   // 配置

创建提示模板时,可以使用 PromptTemplateConfig 类型 调整控制提示符行为的参数。

// 总结内容的最大 token
const int MaxTokens = 1024;
// 提示模板
const string SummarizeConversationDefinition = "...";
var func = kernel.CreateFunctionFromPrompt(new PromptTemplateConfig
{
    // Name 不支持中文和特殊字符
	Name = "chat",
	Description = "给出一段对话记录,总结这部分对话.",
	Template = SummarizeConversationDefinition,
	TemplateFormat = "semantic-kernel",
	InputVariables = new List<InputVariable>
	{
		new InputVariable{Name = "request", Description = "用户的问题", IsRequired = true }
	},
	ExecutionSettings = new Dictionary<string, PromptExecutionSettings>
	{
		{
			"default",
			new OpenAIPromptExecutionSettings()
			{
				MaxTokens = MaxTokens,
				Temperature = 0
				}
			},
	}
});

ExecutionSettings 部分的配置,可以针对使用的模型起效,这里的配置不会全部同时起效,会根据实际使用的模型起效。

	ExecutionSettings = new Dictionary<string, PromptExecutionSettings>
	{
			{
				"default",
				new OpenAIPromptExecutionSettings()
				{
					MaxTokens = 1000,
					Temperature = 0
				}
			},
			{
				"gpt-3.5-turbo", new OpenAIPromptExecutionSettings()
				{
					ModelId = "gpt-3.5-turbo-0613",
					MaxTokens = 4000,
					Temperature = 0.2
				}
			},
			{
				"gpt-4",
				new OpenAIPromptExecutionSettings()
				{
					ModelId = "gpt-4-1106-preview",
					MaxTokens = 8000,
					Temperature = 0.3
				}
			}
	}

聊到这里,重新说一下前面使用文件配置提示模板文件的,两者是相似的。

我们也可以使用文件的形式存储与代码一致的配置,其目录文件结构如下:

└─── chat
     |
     └─── config.json
     └─── skprompt.txt

模板文件由 config.json 和 skprompt.txt 组成,skprompt.txt 中配置提示词,跟 PromptTemplateConfig 的 Template 字段配置一致。

config.json 中涉及的内容比较多,你可以对照下面的 json 跟
实现总结
一节的代码,两者几乎是一模一样的。

{
     "schema": 1,
     "type": "completion",
     "description": "给出一段对话记录,总结这部分对话",
     "execution_settings": {
        "default": {
          "max_tokens": 1000,
          "temperature": 0
        },
        "gpt-3.5-turbo": {
          "model_id": "gpt-3.5-turbo-0613",
          "max_tokens": 4000,
          "temperature": 0.1
        },
        "gpt-4": {
          "model_id": "gpt-4-1106-preview",
          "max_tokens": 8000,
          "temperature": 0.3
        }
      },
     "input_variables": [
        {
          "name": "request",
          "description": "用户的问题.",
          "required": true
        },
        {
          "name": "history",
          "description": "用户的问题.",
          "required": true
        }
     ]
}

C# 代码:

    // Name 不支持中文和特殊字符
	Name = "chat",
	Description = "给出一段对话记录,总结这部分对话.",
	Template = SummarizeConversationDefinition,
	TemplateFormat = "semantic-kernel",
	InputVariables = new List<InputVariable>
	{
		new InputVariable{Name = "request", Description = "用户的问题", IsRequired = true }
	},
	ExecutionSettings = new Dictionary<string, PromptExecutionSettings>
	{
			{
				"default",
				new OpenAIPromptExecutionSettings()
				{
					MaxTokens = 1000,
					Temperature = 0
				}
			},
			{
				"gpt-3.5-turbo", new OpenAIPromptExecutionSettings()
				{
					ModelId = "gpt-3.5-turbo-0613",
					MaxTokens = 4000,
					Temperature = 0.2
				}
			},
			{
				"gpt-4",
				new OpenAIPromptExecutionSettings()
				{
					ModelId = "gpt-4-1106-preview",
					MaxTokens = 8000,
					Temperature = 0.3
				}
			}
	}

提示模板语法

目前,我们已经有两个地方使用到提示模板的语法,即变量和函数调用,因为前面已经介绍过相关的用法,因此这里再简单提及一下。

变量

变量的使用很简单,在提示工程中使用
{{$变量名称}}
标识即可,如
{{$name}}

然后在对话中有多种方法插入值,如使用 KernelArguments 存储变量值:

new KernelArguments
		{
			{ "name", "工良" }
		});
函数调用


实现总结
一节提到过,在提示模板中可以明确调用一个函数,比如定义一个函数如下:

// 没有 Kernel kernel
[KernelFunction, Description("给你一份很长的谈话记录,总结一下谈话内容.")]
		public async Task<string> SummarizeConversationAsync(
			[Description("长对话记录\r\n.")] string input)
		{
			await Task.CompletedTask;
			return input;
		}

// 有 Kernel kernel
[KernelFunction, Description("给你一份很长的谈话记录,总结一下谈话内容.")]
		public async Task<string> SummarizeConversationAsync(
			[Description("长对话记录\r\n.")] string input, Kernel kernel)
		{
			await Task.CompletedTask;
			return input;
		}

    [KernelFunction]
    [Description("Sends an email to a recipient.")]
    public async Task SendEmailAsync(
        Kernel kernel,
        string recipientEmails,
        string subject,
        string body
    )
    {
        // Add logic to send an email using the recipientEmails, subject, and body
        // For now, we'll just print out a success message to the console
        Console.WriteLine("Email sent!");
    }

函数一定需要使用
[KernelFunction]
标识,
[Description]
描述函数的作用。函数可以一个或多个参数,每个参数最好都使用
[Description]
描述作用。

函数参数中,可以带一个
Kernel kernel
,可以放到开头或末尾 ,也可以不带,主要作用是注入
Kernel
对象。

在 prompt 中使用函数时,需要传递函数参数:

总结如下:{{AAA.SummarizeConversationAsync $input}}.

其它一些特殊字符的转义方法等,详见官方文档:
https://learn.microsoft.com/en-us/semantic-kernel/prompts/prompt-template-syntax

文本生成

前面劈里啪啦写了一堆东西,都是说聊天对话的,本节来聊一下文本生成的应用。

文本生成和聊天对话模型主要有以下模型:

Model type Model
Text generation text-ada-001
Text generation text-babbage-001
Text generation text-curie-001
Text generation text-davinci-001
Text generation text-davinci-002
Text generation text-davinci-003
Chat Completion gpt-3.5-turbo
Chat Completion gpt-4

当然,文本生成不一定只能用这么几个模型,使用 gpt-4 设定好背景提示,也可以达到相应效果。

文本生成可以有以下场景:

f7c74d103b8c359ea1ffd4ec98a4a935_image-1709000668170

使用文本生成的示例如下,让 AI 总结文本:

image-20240228105607519

按照这个示例,我们先在 Env.cs 中编写扩展函数,配置使用
.AddAzureOpenAITextGeneration()
文本生成,而不是聊天对话。

	public static IKernelBuilder WithAzureOpenAIText(this IKernelBuilder builder)
	{
		var configuration = GetConfiguration();

		// 需要换一个模型,比如 gpt-35-turbo-instruct
		var AzureOpenAIDeploymentName = "ca";
		var AzureOpenAIModelId = "gpt-35-turbo-instruct";
		var AzureOpenAIEndpoint = configuration["AzureOpenAI:Endpoint"]!;
		var AzureOpenAIApiKey = configuration["AzureOpenAI:ApiKey"]!;

		builder.Services.AddLogging(c =>
		{
			c.AddDebug()
			.SetMinimumLevel(LogLevel.Trace)
			.AddSimpleConsole(options =>
			{
				options.IncludeScopes = true;
				options.SingleLine = true;
				options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
			});
		});

		// 使用 Chat ,即大语言模型聊天
		builder.Services.AddAzureOpenAITextGeneration(
			AzureOpenAIDeploymentName,
			AzureOpenAIEndpoint,
			AzureOpenAIApiKey,
			modelId: AzureOpenAIModelId
		);
		return builder;
	}

然后编写提问代码,用户可以多行输入文本,最后使用
000
结束输入,将文本提交给 AI 进行总结。进行总结时,为了避免 AI 废话太多,因此这里使用
ExecutionSettings
配置相关参数。

代码示例如下:

builder = builder.WithAzureOpenAIText();

var kernel = builder.Build();

Console.WriteLine("输入文本:");
var request = "";
while (true)
{
	var input = Console.ReadLine();
	if (input == "000")
	{
		break;
	}
	request += Environment.NewLine;
	request += input;
}

var func = kernel.CreateFunctionFromPrompt(new PromptTemplateConfig
{
	Name = "chat",
	Description = "给出一段对话记录,总结这部分对话.",
	// 用户的文本
	Template = request,
	TemplateFormat = "semantic-kernel",
	ExecutionSettings = new Dictionary<string, PromptExecutionSettings>
	{
			{
				"default",
				new OpenAIPromptExecutionSettings()
				{
					MaxTokens = 100,
					Temperature = (float)0.3,
					TopP = (float)1,
					FrequencyPenalty = (float)0,
					PresencePenalty = (float)0
				}
			}
	}
});

var result = await func.InvokeAsync(kernel);

Console.WriteLine($"""
				总结如下:
				{string.Join("\n", result)}
				""");

image-20240228666666612101

Semantic Kernel 插件

Semantic Kernel 在 Microsoft.SemanticKernel.Plugins 开头的包中提供了一些插件,不同的包有不同功能的插件。大部分目前还是属于半成品,因此这部分不详细讲解,本节只做简单说明。

目前官方仓库有以下包提供了一些插件:

├─Plugins.Core
├─Plugins.Document
├─Plugins.Memory
├─Plugins.MsGraph
└─Plugins.Web

nuget 搜索时,需要加上
Microsoft.SemanticKernel.
前缀。

Semantic Kernel 还有通过远程 swagger.json 使用插件的做法,详细请参考文档:
https://learn.microsoft.com/en-us/semantic-kernel/agents/plugins/openai-plugins

Plugins.Core 中包含最基础简单的插件:

// 读取和写入文件
FileIOPlugin

// http 请求以及返回字符串结果
HttpPlugin

// 只提供了 + 和 - 两种运算
MathPlugin

// 文本大小写等简单的功能
TextPlugin

// 获得本地时间日期
TimePlugin

// 在操作之前等待一段时间
WaitPlugin

因为这些插件对本文演示没什么帮助,功能也非常简单,因此这里不讲解。下面简单讲一下文档插件。

文档插件

安装 Microsoft.SemanticKernel.Plugins.Document(需要勾选预览版),里面包含了文档插件,该文档插件使用了 DocumentFormat.OpenXml 项目,DocumentFormat.OpenXml 支持以下文档格式:

DocumentFormat.OpenXml 项目地址
https://github.com/dotnet/Open-XML-SDK

  • WordprocessingML
    :用于创建和编辑 Word 文档 (.docx)
  • SpreadsheetML
    :用于创建和编辑 Excel 电子表格 (.xlsx)
  • PowerPointML
    :用于创建和编辑 PowerPoint 演示文稿 (.pptx)
  • VisioML
    :用于创建和编辑 Visio 图表 (.vsdx)
  • ProjectML
    :用于创建和编辑 Project 项目 (.mpp)
  • DiagramML
    :用于创建和编辑 Visio 图表 (.vsdx)
  • PublisherML
    :用于创建和编辑 Publisher 出版物 (.pubx)
  • InfoPathML
    :用于创建和编辑 InfoPath 表单 (.xsn)

文档插件暂时还没有好的应用场景,只是加载文档提取文字比较方便,代码示例如下:

DocumentPlugin documentPlugin = new(new WordDocumentConnector(), new LocalFileSystemConnector());
string filePath = "(完整版)基础财务知识.docx";
string text = await documentPlugin.ReadTextAsync(filePath);
Console.WriteLine(text);

由于这些插件目前都是半成品,因此这里就不展开说明了。

image-20240228154624324

planners

依然是半成品,这里就不再赘述。

因为我也没有看明白这个东西怎么用

Kernel Memory 构建文档知识库

Kernel Memory 是一个歪果仁的个人项目,支持 PDF 和 Word 文档、 PowerPoint 演示文稿、图像、电子表格等,通过利用大型语言模型(llm)、嵌入和矢量存储来提取信息和生成记录,主要目的是提供文档处理相关的接口,最常使用的场景是知识库系统。读者可能对知识库系统不了解,如果有条件,建议部署一个 Fastgpt 系统研究一下。

但是目前 Kernel Memory 依然是半产品,文档也不完善,所以接下来笔者也只讲解最核心的部分,感兴趣的读者建议直接看源码。

Kernel Memory 项目文档:
https://microsoft.github.io/kernel-memory/

Kernel Memory 项目仓库:
https://github.com/microsoft/kernel-memory

打开 Kernel Memory 项目仓库,将项目拉取到本地。

要讲解知识库系统,可以这样理解。大家都知道,训练一个医学模型是十分麻烦的,别说机器的 GPU 够不够猛,光是训练 AI ,就需要掌握各种专业的知识。如果出现一个新的需求,可能又要重新训练一个模型,这样太麻烦了。

于是出现了大语言模型,特点是什么都学什么都会,但是不够专业深入,好处时无论医学、摄影等都可以使用。

虽然某方面专业的知识不够深入和专业,但是我们换种部分解决。

首先,将 docx、pdf 等问题提取出文本,然后切割成多个段落,每一段都使用 AI 模型生成相关向量,这个向量的原理笔者也不懂,大家可以简单理解为分词,生成向量后,将段落文本和向量都存储到数据库中(数据库需要支持向量)。

image-20240228161109917

然后在用户提问 “什么是报表” 时,首先在数据库中搜索,根据向量来确定相似程度,把几个跟问题相关的段落拿出来,然后把这几段文本和用户的问题一起发给 AI。相对于在提示模板中,塞进一部分背景知识,然后加上用户的问题,再由 AI 进行总结回答。

image-20240228161318125

image-20240228161334796

笔者建议大家有条件的话,部署一个开源版本的 Fastgpt 系统,把这个系统研究一下,学会这个系统后,再去研究 Kernel Memory ,你就会觉得非常简单了。同理,如果有条件,可以先部署一个 LobeHub ,开源的 AI 对话系统,研究怎么用,再去研究 Semantic Kernel 文档,接着再深入源码。

从 web 处理网页

Kernel Memory 支持从网页爬取、导入文档、直接给定字符串三种方式导入信息,由于 Kernel Memory 提供了一个 Service 示例,里面有一些值得研究的代码写法,因此下面的示例是启动 Service 这个 Web 服务,然后在客户端将文档推送该 Service 处理,客户端本身不对接 AI。

由于这一步比较麻烦,读者动手的过程中搞不出来
,可以直接放弃

后面会说怎么自己写一个

打开 kernel-memory 源码的
service/Service
路径。

使用命令启动服务:

dotnet run setup

这个控制台的作用是帮助我们生成相关配置的。启动这个控制台之后,根据提示选择对应的选项(按上下键选择选项,按下回车键确认),以及填写配置内容,配置会被存储到 appsettings.Development.json 中。

如果读者搞不懂这个控制台怎么使用,那么可以直接将替换下面的 json 到 appsettings.Development.json 。

有几个地方需要读者配置一下。

  • AccessKey1、AccessKey2 是客户端使用该 Service 所需要的验证密钥,随便填几个字母即可。
  • AzureAIDocIntel、AzureOpenAIEmbedding、AzureOpenAIText 根据实际情况填写。
{
  "KernelMemory": {
    "Service": {
      "RunWebService": true,
      "RunHandlers": true,
      "OpenApiEnabled": true,
      "Handlers": {}
    },
    "ContentStorageType": "SimpleFileStorage",
    "TextGeneratorType": "AzureOpenAIText",
    "ServiceAuthorization": {
      "Enabled": true,
      "AuthenticationType": "APIKey",
      "HttpHeaderName": "Authorization",
      "AccessKey1": "自定义密钥1",
      "AccessKey2": "自定义密钥2"
    },
    "DataIngestion": {
      "OrchestrationType": "Distributed",
      "DistributedOrchestration": {
        "QueueType": "SimpleQueues"
      },
      "EmbeddingGenerationEnabled": true,
      "EmbeddingGeneratorTypes": [
        "AzureOpenAIEmbedding"
      ],
      "MemoryDbTypes": [
        "SimpleVectorDb"
      ],
      "ImageOcrType": "AzureAIDocIntel",
      "TextPartitioning": {
        "MaxTokensPerParagraph": 1000,
        "MaxTokensPerLine": 300,
        "OverlappingTokens": 100
      },
      "DefaultSteps": []
    },
    "Retrieval": {
      "MemoryDbType": "SimpleVectorDb",
      "EmbeddingGeneratorType": "AzureOpenAIEmbedding",
      "SearchClient": {
        "MaxAskPromptSize": -1,
        "MaxMatchesCount": 100,
        "AnswerTokens": 300,
        "EmptyAnswer": "INFO NOT FOUND"
      }
    },
    "Services": {
      "SimpleQueues": {
        "Directory": "_tmp_queues"
      },
      "SimpleFileStorage": {
        "Directory": "_tmp_files"
      },
      "AzureAIDocIntel": {
        "Auth": "ApiKey",
        "Endpoint": "https://aaa.openai.azure.com/",
        "APIKey": "aaa"
      },
      "AzureOpenAIEmbedding": {
        "APIType": "EmbeddingGeneration",
        "Auth": "ApiKey",
        "Endpoint": "https://aaa.openai.azure.com/",
        "Deployment": "aitext",
        "APIKey": "aaa"
      },
      "SimpleVectorDb": {
        "Directory": "_tmp_vectors"
      },
      "AzureOpenAIText": {
        "APIType": "ChatCompletion",
        "Auth": "ApiKey",
        "Endpoint": "https://aaa.openai.azure.com/",
        "Deployment": "myai",
        "APIKey": "aaa",
        "MaxRetries": 10
      }
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*"
}

详细可参考文档:
https://microsoft.github.io/kernel-memory/quickstart/configuration

启动 Service 后,可以看到以下 swagger 界面。

image-20240228170942570

然后编写代码连接到知识库系统,推送要处理的网页地址给 Service。创建一个项目,引入 Microsoft.KernelMemory.WebClient 包。

然后按照以下代码将文档推送给 Service 处理。

// 前面部署的 Service 地址,和自定义的密钥。
var memory = new MemoryWebClient(endpoint: "http://localhost:9001/", apiKey: "自定义密钥1");

// 导入网页
await memory.ImportWebPageAsync(
	"https://baike.baidu.com/item/比特币挖矿机/12536531",
	documentId: "doc02");

Console.WriteLine("正在处理文档,请稍等...");
// 使用 AI 处理网页知识
while (!await memory.IsDocumentReadyAsync(documentId: "doc02"))
{
	await Task.Delay(TimeSpan.FromMilliseconds(1500));
}

// 提问
var answer = await memory.AskAsync("比特币是什么?");

Console.WriteLine($"\nAnswer: {answer.Result}");

此外还有 ImportTextAsync、ImportDocumentAsync 来个导入知识的方法。

手动处理文档

本节内容稍多,主要讲解如何使用 Kernel Memory 从将文档导入、生成向量、存储向量、搜索问题等。

新建项目,安装 Microsoft.KernelMemory.Core 库。

为了便于演示,下面代码将文档和向量临时存储,不使用数据库存储。

全部代码示例如下:

using Microsoft.KernelMemory;
using Microsoft.KernelMemory.MemoryStorage.DevTools;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;

var memory = new KernelMemoryBuilder()
	// 文档解析后的向量存储位置,可以选择 Postgres 等,
	// 这里选择使用本地临时文件存储向量
	.WithSimpleVectorDb(new SimpleVectorDbConfig
	{
		Directory = "aaa"
	})
	// 配置文档解析向量模型
	.WithAzureOpenAITextEmbeddingGeneration(new AzureOpenAIConfig
	{
		Deployment = "aitext",
		Endpoint = "https://aaa.openai.azure.com/",
		Auth = AzureOpenAIConfig.AuthTypes.APIKey,
		APIType = AzureOpenAIConfig.APITypes.EmbeddingGeneration,
		APIKey = "aaa"
	})
	// 配置文本生成模型
	.WithAzureOpenAITextGeneration(new AzureOpenAIConfig
	{
		Deployment = "myai",
		Endpoint = "https://aaa.openai.azure.com/",
		Auth = AzureOpenAIConfig.AuthTypes.APIKey,
		APIKey = "aaa",
		APIType = AzureOpenAIConfig.APITypes.ChatCompletion
	})
	.Build();

// 导入网页
await memory.ImportWebPageAsync(
	"https://baike.baidu.com/item/比特币挖矿机/12536531",
	documentId: "doc02");

// Wait for ingestion to complete, usually 1-2 seconds
Console.WriteLine("正在处理文档,请稍等...");
while (!await memory.IsDocumentReadyAsync(documentId: "doc02"))
{
	await Task.Delay(TimeSpan.FromMilliseconds(1500));
}

// Ask a question
var answer = await memory.AskAsync("比特币是什么?");

Console.WriteLine($"\nAnswer: {answer.Result}");

image-20240228175318645

首先使用 KernelMemoryBuilder 构建配置,配置的内容比较多,这里会使用到两个模型,一个是向量模型,一个是文本生成模型(可以使用对话模型,如 gpt-4-32k)。

接下来,按照该程序的工作流程讲解各个环节的相关知识。

首先是讲解将文件存储到哪里,也就是导入文件之后,将文件存储到哪里,存储文件的接口是 IContentStorage,目前有两个实现:

AzureBlobsStorage
// 存储到目录
SimpleFileStorage

使用方法:

var memory = new KernelMemoryBuilder()
.WithSimpleFileStorage(new SimpleFileStorageConfig
	{
		Directory = "aaa"
	})
	.WithAzureBlobsStorage(new AzureBlobsConfig
	{
		Account = ""
	})
	...

Kernel Memory 还不支持 Mongodb,不过可以自己使用 IContentStorage 接口写一个。

本地解析文档后,会进行分段,如右边的
q
列所示。

image-20240229145611963

接着是,配置文档生成向量模型,导入文件文档后,在本地提取出文本,需要使用 AI 模型从文本中生成向量。

解析后的向量是这样的:

image-20240229145819118

将文本生成向量,需要使用 ITextEmbeddingGenerator 接口,目前有两个实现:

AzureOpenAITextEmbeddingGenerator
OpenAITextEmbeddingGenerator

示例:

var memory = new KernelMemoryBuilder()
// 配置文档解析向量模型
	.WithAzureOpenAITextEmbeddingGeneration(new AzureOpenAIConfig
	{
		Deployment = "aitext",
		Endpoint = "https://xxx.openai.azure.com/",
		Auth = AzureOpenAIConfig.AuthTypes.APIKey,
		APIType = AzureOpenAIConfig.APITypes.EmbeddingGeneration,
		APIKey = "xxx"
	})
	.WithOpenAITextEmbeddingGeneration(new OpenAIConfig
	{
        ... ...
	})

生成向量后,需要存储这些向量,需要实现 IMemoryDb 接口,有以下配置可以使用:

	// 文档解析后的向量存储位置,可以选择 Postgres 等,
	// 这里选择使用本地临时文件存储向量
	.WithSimpleVectorDb(new SimpleVectorDbConfig
	{
		Directory = "aaa"
	})
	.WithAzureAISearchMemoryDb(new AzureAISearchConfig
	{

	})
	.WithPostgresMemoryDb(new PostgresConfig
	{
		
	})
	.WithQdrantMemoryDb(new QdrantConfig
	{

	})
	.WithRedisMemoryDb("host=....")

当用户提问时,首先会在这里的 IMemoryDb 调用相关方法查询文档中的向量、索引等,查找出相关的文本。

查出相关的文本后,需要发送给 AI 处理,需要使用 ITextGenerator 接口,目前有两个实现:

AzureOpenAITextGenerator
OpenAITextGenerator

配置示例:

	// 配置文本生成模型
	.WithAzureOpenAITextGeneration(new AzureOpenAIConfig
	{
		Deployment = "myai",
		Endpoint = "https://aaa.openai.azure.com/",
		Auth = AzureOpenAIConfig.AuthTypes.APIKey,
		APIKey = "aaa",
		APIType = AzureOpenAIConfig.APITypes.ChatCompletion
	})

导入文档时,首先将文档提取出文本,然后进行分段。

将每一段文本使用向量模型解析出向量,存储到 IMemoryDb 接口提供的服务中,如 Postgres数据库。

提问问题或搜索内容时,从 IMemoryDb 所在的位置搜索向量,查询到相关的文本,然后将文本收集起来,发送给 AI(使用文本生成模型),这些文本相对于提示词,然后 AI 从这些提示词中学习并回答用户的问题。

详细源码可以参考 Microsoft.KernelMemory.Search.SearchClient ,由于源码比较多,这里就不赘述了。

1709116664654

这样说,大家可能不太容易理解,我们可以用下面的代码做示范。

// 导入文档
await memory.ImportDocumentAsync(
	"aaa/(完整版)基础财务知识.docx",
	documentId: "doc02");

Console.WriteLine("正在处理文档,请稍等...");
while (!await memory.IsDocumentReadyAsync(documentId: "doc02"))
{
	await Task.Delay(TimeSpan.FromMilliseconds(1500));
}

var answer1 = await memory.SearchAsync("报表怎么做?");
// 每个 Citation 表示一个文档文件
foreach (Citation citation in answer1.Results)
{
	// 与搜索关键词相关的文本
	foreach(var partition in citation.Partitions)
	{
		Console.WriteLine(partition.Text);
	}
}

var answer2 = await memory.AskAsync("报表怎么做?");

Console.WriteLine($"\nAnswer: {answer2.Result}");

读者可以在 foreach 这里做个断点,当用户问题 “报表怎么做?” 时,搜索出来的相关文档。

然后再参考 Fastgpt 的搜索配置,可以自己写一个这样的知识库系统。

image-20240228185721336