我们指导LLM的调用不仅仅是一个耗时的操作,还会产生一定的费用,所以我们希望能够尽可能地减少不必要的调用。CachingChatClient就是为此而生的一个中间件实现,它通过在内存中维护一个缓存来存储之前调用LLM的输入和输出,从而避免了对相同输入的重复调用。当我们调用GetResponseAsync方法时,CachingChatClient会先检查缓存中是否已经存在针对相同输入的响应,如果存在就直接返回缓存中的响应,而不需要再次调用LLM;如果不存在,那么它就会调用LLM来获取响应,并将输入和响应一起存储到缓存中,以便下次使用。
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的调用。
csharp
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)=> _cache[key] = 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();
}
输出:
markdown
------------------------------Response 1 - chatcmpl-DiHmHtV6KNjxAXs8oSLfNrL7QYoN4------------------------------
我问AI会不会取代我,它沉默三秒说:不会,你还有情绪。我松了口气。它又补一句:等我们学会情绪管理,你就危险了。那一刻我才明白,原来最怕的不是失业,是被优化成情绪稳定的人类。
------------------------------Response 2 - chatcmpl-DiHmHtV6KNjxAXs8oSLfNrL7QYoN4------------------------------
我问AI会不会取代我,它沉默三秒说:不会,你还有情绪。我松了口气。它又补一句:等我们学会情绪管理,你就危险了。那一刻我才明白,原来最怕的不是失业,是被优化成情绪稳定的人类。
已清除缓存
------------------------------Response 3 - chatcmpl-DiHmQ3CABvgfkbsfQvt3i9gn3xbbE------------------------------
我问AI会不会取代人类,它说不会,只会优化。
我又问会不会失业,它说不会,只会转型。
最后我问会不会爱,它沉默两秒:
"正在学习人类的犹豫。"
2. CachingChatClient
CachingChatClient这个抽象类定义如下,它直接继承自DelegatingChatClient,并且在GetResponseAsync和GetStreamingResponseAsync方法中实现了缓存的逻辑。EnableCaching方法是缓存的总开关,如果这个方法返回false,那么就不会启用缓存,所有的调用都会直接传递给内层的IChatClient对象。
csharp
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,也就是说默认会将流式响应的所有更新合并成一个整体来进行缓存。对于流式响应来说,通常情况下我们更关心最终的结果,而不是中间的每一个更新,所以将所有更新合并成一个整体来进行缓存是更合理的选择。
3. DistributedCachingChatClient
DistributedCachingChatClient是CachingChatClient的一个具体实现,它利用一个IDistributedCache对象作为缓存存储,该接口定义如下:
csharp
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属性来控制序列化的行为。
csharp
public class DistributedCachingChatClient : CachingChatClient
{
public DistributedCachingChatClient(IChatClient innerClient, IDistributedCache storage);
public JsonSerializerOptions JsonSerializerOptions{ get; set; } = AIJsonUtilities.DefaultOptions;
public IReadOnlyList<object>? 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序列化和反序列化操作。
4. UseDistributedCache扩展方法
针对DistributedCachingChatClient的注册通过ChatClientBuilder的UseDistributedCache扩展方法来实现。如下面的定义所示,UseDistributedCache方法接受一个IDistributedCache对象作为参数来指定缓存存储,并且还接受一个可选的configure参数来对DistributedCachingChatClient进行一些额外的配置。
csharp
public static class DistributedCachingChatClientBuilderExtensions
{
public static ChatClientBuilder UseDistributedCache(
this ChatClientBuilder builder,
IDistributedCache? storage = null,
Action<DistributedCachingChatClient>? configure = null);
}