深入理解大模型应用中的Cache:GPTCache

480 阅读8分钟

前言

无论在什么技术栈中,缓存都是比较重要的一部分。在大模型技术栈中,缓存存在于技术栈中的不同层次。本文将主要聚焦于技术栈中应用层和底层基座之间中间件层的缓存(个人定位),以开源项目GPTCache(LLM的语义缓存)为例,深入讲解这部分缓存的结构和关键实现。

完整技术栈详情参考之前文章《 构建大模型应用的技术栈浅析

使用场景

当用户流量比较大的C端应用试图接入大模型能力时,如果每次请求都访问LLM,通过LLM生成结果再返回给服务,那么对于LLM服务而言压力会比较大,整体服务的吞吐量和延迟通常都会有比较大的影响。如果这些请求之间有一些相似性(比如A用户提问:今天天气怎么样?B用户也提问:今儿天气如何?),那么这个时候就可以考虑在应用服务和LLM基座之间引入语义缓存来尝试提升服务性能。

常见用法

先来介绍一些语义缓存GPTCache的一些常见用法,以下示例取自官网

精确匹配

精确匹配意味着对于见过的重复问题可以不需要再去请求背后的LLM,而是命中cache之后直接从cache中去寻找。这种做法下,代码不需要有比较大的变动,只需要添加以下4行即可:

import timedef response_text(openai_resp):
    return openai_resp['choices'][0]['message']['content']

print("Cache loading.....")

#######################################
from gptcache import cache
from gptcache.adapter import openai
cache.init()
cache.set_openai_key()
#######################################

start_time = time.time()
response = openai.ChatCompletion.create(
  model='gpt-3.5-turbo',
  messages=[
    {
        'role': 'user',
        'content': question
    }
  ],
)
print(f'Question: {question}')
print("Time consuming: {:.2f}s".format(time.time() - start_time))
print(f'Answer: {response_text(response)}\n')

语义相似匹配

精确匹配只能匹配完全相同的问题,这在大多数场景下不是特别有用(用户可能以不同的方式问出相似的问题)。在这种情况下,基于语义相似匹配的cache可能更有用。通过缓存相似语义问题的回答来解决当前的问题。这种方式在初始化cache的时候,需要加上一些其他的模块,这些模块后续会进行介绍。

import timedef response_text(openai_resp):
    return openai_resp['choices'][0]['message']['content']

############################################################
from gptcache import cache
from gptcache.adapter import openai
from gptcache.embedding import Onnx
from gptcache.manager import CacheBase, VectorBase, get_data_manager
from gptcache.similarity_evaluation.distance import SearchDistanceEvaluation

print("Cache loading.....")

onnx = Onnx()
data_manager = get_data_manager(CacheBase("sqlite"), VectorBase("faiss", dimension=onnx.dimension))
cache.init(
    embedding_func=onnx.to_embeddings,
    data_manager=data_manager,
    similarity_evaluation=SearchDistanceEvaluation(),
    )
cache.set_openai_key()
############################################################

questions = [
    "what's github",
    "can you explain what GitHub is",
    "can you tell me more about GitHub",
    "what is the purpose of GitHub"
]

for question in questions:
    start_time = time.time()
    response = openai.ChatCompletion.create(
        model='gpt-3.5-turbo',
        messages=[
            {
                'role': 'user',
                'content': question
            }
        ],
    )
    print(f'Question: {question}')
    print("Time consuming: {:.2f}s".format(time.time() - start_time))
    print(f'Answer: {response_text(response)}\n')

整体架构

GPTCache的整体结构如下:

主要包含以下模块:

  LLM Adapter: 屏蔽底层LLM的差异,提供统一的对外接口。

  Embedding Generator: 对请求进行embedding,为后续的向量的相似度查询提供帮助。

  Similarity Evaluator: 负责使用各种策略去评估请求和缓存请求之间的相似度

  Cache Manager: 负责对Cache storage和Vector storage操作的管理。

  Cache storage: 负责对LLM返回结果的存储。

  Vector storage: 负责缓存之前的请求以及返回topK与当前请求相似的请求,帮助评估相似性

当请求来临时,会先经过LLM Adapter模块,通过该模块屏蔽底层LLM的差异,对外提供统一的接口。请求会继续被Emebdding生成模块处理,将请求处理成嵌入向量。然后,通过simlarity evaluator来计算当前问题和已经缓存问题的相似度,判断是否该问题已经被缓存过。如果已经被缓存过,则命中cache, 直接从cache storage中返回结果。否则,没有命中,则由LLM生成答案,并在返回结果时将当前请求和答案缓存起来,以便下次查询时使用。

关键实现

接下来我们根据前面的例子来深入代码看一下GPTCache中的一些关键模块和逻辑实现。

Adapter

从上面的例子看,当使用GPTCache之后,我们对原生api的调用就需要改成对Cache Adapter的调用。我们来看一下Adapter中的一些关键实现。

# 当进行openai.ChatCompletion.create后,进入以下代码
@classmethod
def create(cls, *args, **kwargs):
    chat_cache = kwargs.get("cache_obj", cache)
    enable_token_counter = chat_cache.config.enable_token_counter

    def cache_data_convert(cache_data):
        if enable_token_counter:
            input_token = _num_tokens_from_messages(kwargs.get("messages"))
            output_token = token_counter(cache_data)
            saved_token = [input_token, output_token]
        else:
            saved_token = [0, 0]
        if kwargs.get("stream", False):
            return _construct_stream_resp_from_cache(cache_data, saved_token)
        return _construct_resp_from_cache(cache_data, saved_token)

    kwargs = cls.fill_base_args(**kwargs)
    return adapt(
        cls._llm_handler,
        cache_data_convert,
        cls._update_cache_callback,
        *args,
        **kwargs,
    )

这里会调用adapt方法来返回最终结果,该方法的签名如下:

def adapt(llm_handler, cache_data_convert, update_cache_callback, *args, **kwargs)

其中各参数含义为:

参数说明
llm_handler当cache miss的时候,调用LLM的方法
cache_data_convert当cache 命中的时候,将cache中的回答格式转换成结果格式
update_cache_callback当cache miss的时候,调用LLM获得结果后将结果存储到cache中
args要传给llm的参数
kwargs要传给llm的关键字参数

最终返回llm的处理结果。从函数签名其实我们大致可以猜测到它的主要逻辑:首先,会去查看当前请求是否命中了缓存,如果命中了,就读出缓存中的回答数据,并通过cache_data_convert方法来生成最终返回值。如果没有命中缓存,则调用llm_handler来获取llm的回复,并在将回复返回之前通过update_cache_callback更新到缓存中,从而应对下一次的请求。实际项目中的adapter的主要处理逻辑代码如下(关键部分已进行注释):

# 对请求embedding后,通过data_manager进行search
search_data_list = time_cal(
    chat_cache.data_manager.search,
    func_name="search",
    report_func=chat_cache.report.search,
)(
    embedding_data,
    extra_param=context.get("search_func", None),
    top_k=kwargs.pop("top_k", 5)
    if (user_temperature and not user_top_k)
    else kwargs.pop("top_k", -1),
)

#对于循环结果,通过评估模块决定是否cache    
for search_data in search_data_list:
    # 获取对应的回答数据
    cache_data = time_cal(
        chat_cache.data_manager.get_scalar_data,
        func_name="get_data",
        report_func=chat_cache.report.data,
    )(
        search_data,
        extra_param=context.get("get_scalar_data", None),
        session=session,
    )
    if cache_data is None:
        continue

    # cache consistency check 校验cache和向量数据库中数据的一致性,防止脏数据
    if chat_cache.config.data_check:
        is_healthy = cache_health_check(
            chat_cache.data_manager.v,
            {
                "embedding": cache_data.embedding_data,
                "search_result": search_data,
            },
        )
        if not is_healthy:
            continue
    
    # 构造eval_query_data 和 eval_cache_data用于评估cache data和请求数据的相似性
    eval_query_data = ...
    eval_cache_data = ...
    
    # 通过相似度评估查询数据和缓存数据的一致性
    rank = time_cal(
        chat_cache.similarity_evaluation.evaluation,
        func_name="evaluation",
        report_func=chat_cache.report.evaluation,
    )(
        eval_query_data,
        eval_cache_data,
        extra_param=context.get("evaluation_func", None),
    )
    
    # 如果相似度排名大于排名阈值,则将回答添加到cache answer中,并调用data_manager的命中回调
    if rank_threshold <= rank:
        cache_answers.append(
            (float(rank), cache_data.answers[0].answer, search_data, cache_data)
        )
        chat_cache.data_manager.hit_cache_callback(search_data)
    # 命中结果排序
    cache_answers = sorted(cache_answers, key=lambda x: x[0], reverse=True)
 
 # 后续一些对answer的转换处理

在这部分逻辑中,我们可以看到一些整体架构中提到的具体的模块,比如similarity_evaluation,也会看到一些陌生的模块,比如data_manager。其实这里data_manager对应的就是上面提到的Cache Manager模块。它负责对缓存下来的数据进行管理。比如上面逻辑中的search操作,如果使用的是SSDDataManager,那么它的实现可能如下:

# SSDDataManager的search过程,调用data_manager的向量数据库进行search
def search(self, embedding_data, **kwargs):
    embedding_data = normalize(embedding_data)
    top_k = kwargs.get("top_k", -1)
    return self.v.search(data=embedding_data, top_k=top_k)

那么data_manager的构成是什么样的呢?

DataManager

参考前面的语义查询初始化语句中:

data_manager = get_data_manager(CacheBase("sqlite"), VectorBase("faiss", dimension=onnx.dimension))
cache.init(
    embedding_func=onnx.to_embeddings,
    data_manager=data_manager,
    similarity_evaluation=SearchDistanceEvaluation(),
    )

当需要进行语义检索的时候,需要构造一个data_manager这个data_manager对应Cache Manager模块,用来管理缓存数据的操作。在实际的项目代码中,我整理了一下data_manager相关的类,他们的关系如下:

image.png 这里DataManager是个抽象类,它的主要派生是MapDataManager和SSDDataManager,其中MapDataManager比较简单,并不需要用到各种不同数据库,这里暂不介绍。我们主要关注一下SSDDataManager。

在构造一个SSDDataManager的时候,需要依赖向量存储、Cache存储、对象存储、缓存淘汰存储以及缓存淘汰管理。其中,向量存储用于存储问题向量,用来进行相似度查询,找回topK个相似的问题;Cache存储用于存储问题对应的答案; 缓存淘汰存储以及缓存淘汰管理用来管理缓存中需要被淘汰的数据,根据缓存的淘汰策略的实施位置,分为In-Memory和分布式,目前In-Memory的缓存淘汰通过cachetools来实现,而分布式的缓存淘汰则是依赖于redis本身的策略。不同部分有自己的派生类,用来支持不同类型存储的接入。

总结

本文从GPTCache的基本示例出发,介绍了GPTCache的整体结构,并深入代码讲解了其中部分模块的构成和实现逻辑。还有一些模块比如Similarity Evaluator、Embedding Generator感觉相对比较简单,没有再继续深入代码去看,感兴趣的同学可以自行研究。

转载请注明出处~