前言
无论在什么技术栈中,缓存都是比较重要的一部分。在大模型技术栈中,缓存存在于技术栈中的不同层次。本文将主要聚焦于技术栈中应用层和底层基座之间中间件层的缓存(个人定位),以开源项目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相关的类,他们的关系如下:
这里DataManager是个抽象类,它的主要派生是MapDataManager和SSDDataManager,其中MapDataManager比较简单,并不需要用到各种不同数据库,这里暂不介绍。我们主要关注一下SSDDataManager。
在构造一个SSDDataManager的时候,需要依赖向量存储、Cache存储、对象存储、缓存淘汰存储以及缓存淘汰管理。其中,向量存储用于存储问题向量,用来进行相似度查询,找回topK个相似的问题;Cache存储用于存储问题对应的答案; 缓存淘汰存储以及缓存淘汰管理用来管理缓存中需要被淘汰的数据,根据缓存的淘汰策略的实施位置,分为In-Memory和分布式,目前In-Memory的缓存淘汰通过cachetools来实现,而分布式的缓存淘汰则是依赖于redis本身的策略。不同部分有自己的派生类,用来支持不同类型存储的接入。
总结
本文从GPTCache的基本示例出发,介绍了GPTCache的整体结构,并深入代码讲解了其中部分模块的构成和实现逻辑。还有一些模块比如Similarity Evaluator、Embedding Generator感觉相对比较简单,没有再继续深入代码去看,感兴趣的同学可以自行研究。
转载请注明出处~