[MAF预定义ChatClient中间件-03]CachingChatClient——利用缓存省钱省时间

  1. 利用CachingChatClient中间件来缓存LLM的调用结果
    虽然LLM的调用可能会产生一些随机性,相同的输入也会得到不同的输出。使用CachingChatClient中间件来缓存LLM的调用结果的一个前提是:我们将LLM视为一个完全由输入决定输出的纯函数(Pure Function)。在这种情况下,针对相同输入的调用会得到相同的输出,所以我们就可以利用CachingChatClient中间件来缓存LLM的调用结果,从而避免了对相同输入的重复调用,节省了时间和费用。CachingChatClient是一个抽象类,我们一般使用的是它的子类DistributedCachingChatClient,之后使用一个IDistributedCache对象作为缓存存储。

在下面的演示程序中,我们定义了通过实现IDistributedCache接口来创建了InMemoryDistributedCache类型,后者利用一个字典来存储缓存数据。在利用OpenAIClient创建了一个IChatClient对象后,我们调用AsBuilder扩展方法将ChatClientBuilder构建出来,通过调用UseDistributedCache方法来注册DistributedCachingChatClient中间件,并传入一个InMemoryDistributedCache对象来作为缓存存储。之后我们调用GetResponseAsync方法来获取LLM的响应,第一次调用会触发对LLM的调用,而第二次调用则会直接返回缓存中的响应,从而避免了对LLM的重复调用。第三次调用在我们调用了InMemoryDistributedCache的Clear方法来清除缓存后,又会触发对LLM的调用。

using Azure;

using dotenv.net;

using Microsoft.Extensions.AI;

using Microsoft.Extensions.Caching.Distributed;

using OpenAI;

DotEnv.Load();

var model = Environment.GetEnvironmentVariable("MODEL")!;

var apiKey = Environment.GetEnvironmentVariable("API_KEY")!;

var endpoint = Environment.GetEnvironmentVariable("OPENAI_URL")!;

var cache = new InMemoryDistributedCache();

var client = new OpenAIClient(

credential: new AzureKeyCredential(apiKey),

options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })

.GetChatClient(model:model)

.AsIChatClient()

.AsBuilder()

.UseDistributedCache(cache)

.Build();

var prompt = "写一个关于AI的段子, 要求100字以内,好笑且深刻。";

var response = await client.GetResponseAsync(prompt);

Console.WriteLine($"{new string('-',30)}Response 1 - {response.ResponseId}{new string('-',30)}");

Console.WriteLine(response);

response = await client.GetResponseAsync(prompt);

Console.WriteLine($"\n{new string('-', 30)}Response 2 - {response.ResponseId}{new string('-', 30)}");

Console.WriteLine(response);

cache.Clear();

Console.WriteLine("\n已清除缓存\n");

response = await client.GetResponseAsync(prompt);

Console.WriteLine($"\n{new string('-', 30)}Response 3 - {response.ResponseId}{new string('-', 30)}");

Console.WriteLine(response);

class InMemoryDistributedCache : IDistributedCache

{

private readonly Dictionary<string, byte\[\]> _cache = \[\];

public byte\[\]? Get(string key) =>_cache.TryGetValue(key, out var value) ? value : null;

public Task<byte\[\]?> GetAsync(string key, CancellationToken token = default)=> Task.FromResult(Get(key));

public void Refresh(string key) { }

public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask;

public void Remove(string key) => _cache.Remove(key);

public Task RemoveAsync(string key, CancellationToken token = default)

{

Remove(key);

return Task.CompletedTask;

}

public void Set(string key, byte\[\] value, DistributedCacheEntryOptions options)=> _cachekey = value;

public Task SetAsync(string key, byte\[\] value, DistributedCacheEntryOptions options, CancellationToken token = default)

{

Set(key, value, options);

return Task.CompletedTask;

}

public void Clear() => _cache.Clear();

}

输出:

------------------------------Response 1 - chatcmpl-DiHmHtV6KNjxAXs8oSLfNrL7QYoN4------------------------------

我问AI会不会取代我,它沉默三秒说:不会,你还有情绪。我松了口气。它又补一句:等我们学会情绪管理,你就危险了。那一刻我才明白,原来最怕的不是失业,是被优化成情绪稳定的人类。

------------------------------Response 2 - chatcmpl-DiHmHtV6KNjxAXs8oSLfNrL7QYoN4------------------------------

我问AI会不会取代我,它沉默三秒说:不会,你还有情绪。我松了口气。它又补一句:等我们学会情绪管理,你就危险了。那一刻我才明白,原来最怕的不是失业,是被优化成情绪稳定的人类。

已清除缓存

------------------------------Response 3 - chatcmpl-DiHmQ3CABvgfkbsfQvt3i9gn3xbbE------------------------------

我问AI会不会取代人类,它说不会,只会优化。

我又问会不会失业,它说不会,只会转型。

最后我问会不会爱,它沉默两秒:

"正在学习人类的犹豫。"

  1. CachingChatClient

CachingChatClient这个抽象类定义如下,它直接继承自DelegatingChatClient,并且在GetResponseAsync和GetStreamingResponseAsync方法中实现了缓存的逻辑。EnableCaching方法是缓存的总开关,如果这个方法返回false,那么就不会启用缓存,所有的调用都会直接传递给内层的IChatClient对象。

public abstract class CachingChatClient : DelegatingChatClient

{

protected CachingChatClient(IChatClient innerClient);

复制代码
public bool CoalesceStreamingUpdates { get; set; } = true;

public override Task<ChatResponse> GetResponseAsync(
    IEnumerable<ChatMessage> messages, 
    ChatOptions? options = null, 
    CancellationToken cancellationToken = default);
public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
    IEnumerable<ChatMessage> messages, 
    ChatOptions? options = null, 
    CancellationToken cancellationToken = default);  

protected abstract string GetCacheKey(
    IEnumerable<ChatMessage> messages, 
    ChatOptions? options, 
    params ReadOnlySpan<object?>[] additionalValues);
protected abstract Task<ChatResponse?> ReadCacheAsync(
    string key, 
    CancellationToken cancellationToken);
protected abstract Task<IReadOnlyList<ChatResponseUpdate>?> ReadCacheStreamingAsync(
    string key, 
    CancellationToken cancellationToken);
protected abstract Task WriteCacheAsync(
    string key, 
    ChatResponse value, 
    CancellationToken cancellationToken);
protected abstract Task WriteCacheStreamingAsync(
    string key, 
    IReadOnlyList<ChatResponseUpdate> value, 
    CancellationToken cancellationToken);
protected virtual bool EnableCaching(
    IEnumerable<ChatMessage> messages, 
    ChatOptions? options) => options?.ConversationId is null;

}

除GetResponseAsync和GetStreamingResponseAsync方法之外的抽象方法和虚方法说明如下:

GetCacheKey: 用于生成缓存的键,它会根据输入的消息列表、选项和一些额外的值来生成一个唯一的字符串作为缓存的键。这个方法的实现需要保证对于相同的输入能够生成相同的键,以便能够正确地命中缓存;

ReadCacheAsync: 用于从缓存中读取一个ChatResponse对象,它会根据提供的键来查找缓存中的响应,如果找到就返回这个响应,否则返回null;

ReadCacheStreamingAsync: 用于从缓存中读取一个ChatResponseUpdate对象列表,它会根据提供的键来查找缓存中的响应更新列表,如果找到就返回这个列表,否则返回null;

WriteCacheAsync: 用于将一个ChatResponse对象写入缓存中,它会根据提供的键来存储这个响应,以便后续能够通过这个键来查找缓存中的响应;

WriteCacheStreamingAsync: 用于将一个ChatResponseUpdate对象列表写入缓存中,它会根据提供的键来存储这个响应更新列表,以便后续能够通过这个键来查找缓存中的响应更新;

EnableCaching: 用于控制是否启用缓存,它会根据输入的消息列表和选项来决定是否启用缓存;

EnableCaching方法的默认实现是:当ChatOptions对象的ConversationId属性为null时启用缓存,否则不启用缓存,它表达的含义是:如果采用无状态的调用方式,有输入决定输出的缓存策略是安全的;如果采用有状态会话的调用方式,由于会话状态也会影响输出,采用缓存可能是致命的。比如当我们使用OpenAI Responses API时,由于历史记录非常长,我们往往只把最新的一句话发过去,此时我们希望得到是针对整个对话历史的响应,而不是针对最新一句话的响应,所以启用缓存就会导致得到错误的结果。

重写的GetResponseAsync方法体现了阻塞式调用的缓存逻辑,它们的实现逻辑大致如下:

首先调用EnableCaching方法来判断是否启用缓存,如果不启用缓存,就直接调用内层的IChatClient对象来获取响应,并将结果写入缓存中;

如果启用缓存,那么就调用GetCacheKey方法来生成缓存的键,并调用ReadCacheAsync方法来尝试从缓存中读取响应,如果成功命中缓存,就直接返回缓存中的响应;如果没有命中缓存,就调用内层的IChatClient对象来获取响应,并将结果通过WriteCacheAsync方法写入缓存中;

最后返回获取到的响应;

流式相应的缓存机制与CoalesceStreamingUpdates属性有关。流式响应(如聊天时文字一个字一个字地蹦出来)是由成百上千个微小的碎片数据块组成的。这个属性的作用,就是决定如何把这些碎片存进缓存,以及下次命中时如何把它们吐出来。如果这个属性设置为true,那么就会将流式响应的所有更新合并成一个整体来进行缓存;如果这个属性设置为false,那么就会针对每一个更新单独进行缓存。此属性的默认值是true,也就是说默认会将流式响应的所有更新合并成一个整体来进行缓存。对于流式响应来说,通常情况下我们更关心最终的结果,而不是中间的每一个更新,所以将所有更新合并成一个整体来进行缓存是更合理的选择。

  1. DistributedCachingChatClient
    DistributedCachingChatClient是CachingChatClient的一个具体实现,它利用一个IDistributedCache对象作为缓存存储,该接口定义如下:

public interface IDistributedCache

{

byte\[\]? Get(string key);

Task<byte\[\]?> GetAsync(string key, CancellationToken token = default(CancellationToken));

void Set(string key, byte\[\] value, DistributedCacheEntryOptions options);

Task SetAsync(string key, byte\[\] value, DistributedCacheEntryOptions options, CancellationToken token = default(CancellationToken));

void Refresh(string key);

Task RefreshAsync(string key, CancellationToken token = default(CancellationToken));

void Remove(string key);

Task RemoveAsync(string key, CancellationToken token = default(CancellationToken));

}

类型成员说明如下:

Get: 用于从缓存中获取一个值,它会根据提供的键来查找缓存中的值,如果找到就返回这个值,否则返回null;

GetAsync: 是Get方法的异步版本,它会根据提供的键来查找缓存中的值,如果找到就返回这个值,否则返回null;

Set: 用于将一个值写入缓存中,它会根据提供的键来存储这个值,以便后续能够通过这个键来查找缓存中的值;

SetAsync: 是Set方法的异步版本,它会根据提供的键来存储这个值,以便后续能够通过这个键来查找缓存中的值;

Refresh: 用于刷新缓存中的一个值,它会根据提供的键来刷新缓存中的值,以便延长这个值在缓存中的有效期;

RefreshAsync: 是Refresh方法的异步版本,它会根据提供的键来刷新缓存中的值,以便延长这个值在缓存中的有效期;

Remove: 用于从缓存中移除一个值,它会根据提供的键来移除缓存中的值,以便后续无法通过这个键来查找缓存中的值;

RemoveAsync: 是Remove方法的异步版本,它会根据提供的键来移除缓存中的值,以便后续无法通过这个键来查找缓存中的值;

DistributedCachingChatClient定义如下。我们需要在构造函数中提供作为存储的IDistributedCache对象。由于IDistributedCache对象代表的是分布式存储,所以它存储的字节内容,该内容是通过JsonSerializer将ChatResponse对象或ChatResponseUpdate对象列表进行针对UTF-8序列化的结果,所以该类型还提供了一个JsonSerializerOptions属性来控制序列化的行为。

public class DistributedCachingChatClient : CachingChatClient

{

public DistributedCachingChatClient(IChatClient innerClient, IDistributedCache storage);

public JsonSerializerOptions JsonSerializerOptions{ get; set; } = AIJsonUtilities.DefaultOptions;

public IReadOnlyList? CacheKeyAdditionalValues{ get; set; }

复制代码
protected override async Task<ChatResponse?> ReadCacheAsync(string key, CancellationToken cancellationToken);
protected override async Task<IReadOnlyList<ChatResponseUpdate>?> ReadCacheStreamingAsync(string key, CancellationToken cancellationToken);
protected override async Task WriteCacheAsync(string key, ChatResponse value, CancellationToken cancellationToken);
protected override async Task WriteCacheStreamingAsync(string key, IReadOnlyList<ChatResponseUpdate> value, CancellationToken cancellationToken);
protected override string GetCacheKey(IEnumerable<ChatMessage> messages, ChatOptions? options, params ReadOnlySpan<object?> additionalValues);

}

作为缓存键的字符串是通过GetCacheKey方法生成的,具体的生成逻辑是:首先将输入的缓存策略的版本号(目前为2)、消息列表、ChatOptions和CacheKeyAdditionalValues属性组合成一个对象数组,然后利用JsonSerializer将这个对象数组进行序列化,并对序列化后的字节内容进行哈希计算,这个哈希值转换成的字符串作就是所需的缓存键。至于其他的方法,它们的实现逻辑比较简单,就是通过调用IDistributedCache对象的对应方法来实现从缓存中读取和写入数据的功能,中间会涉及针对ChatResponse对象和ChatResponseUpdate对象列表的JsonSerializer序列化和反序列化操作。

  1. UseDistributedCache扩展方法
    针对DistributedCachingChatClient的注册通过ChatClientBuilder的UseDistributedCache扩展方法来实现。如下面的定义所示,UseDistributedCache方法接受一个IDistributedCache对象作为参数来指定缓存存储,并且还接受一个可选的configure参数来对DistributedCachingChatClient进行一些额外的配置。

public static class DistributedCachingChatClientBuilderExtensions

{

public static ChatClientBuilder UseDistributedCache(

this ChatClientBuilder builder,

IDistributedCache? storage = null,

Action? configure = null);

}

相关推荐
難釋懷3 小时前
Nginx浏览器强制缓存
运维·nginx·缓存
STDD3 小时前
Traefik 中间件深度配置:限流、认证、IP 白名单与错误页面定制
网络协议·tcp/ip·中间件
诚信定制8393 小时前
如何启动 Redis 服务:详细步骤指南
数据库·redis·缓存
cxr8285 小时前
缓存策略探测实验方案架构设计
缓存
ffqws_18 小时前
redis面试:如何保证双写一致
数据库·redis·缓存
纸小铭1 天前
[MAF预定义ChatClient中间件-01]LoggingChatClient——在调用LLM前后输出日志
python·中间件·flask
枫叶丹41 天前
Loop Engineering:从提示词到循环系统
数据库·redis·缓存·loop
zandy10112 天前
从可用到好用:2026年信创改造建设之邮件系统经验分享
经验分享·中间件·操作系统·信创·国产cpu
云计算磊哥@2 天前
运维开发宝典051-缓存服务Redis集群2
redis·缓存·运维开发