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

我们指导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的重复调用。第三次调用在我们调用了InMemoryDistributedCacheClear方法来清除缓存后,又会触发对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,并且在GetResponseAsyncGetStreamingResponseAsync方法中实现了缓存的逻辑。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;
}

GetResponseAsyncGetStreamingResponseAsync方法之外的抽象方法和虚方法说明如下:

  • 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

DistributedCachingChatClientCachingChatClient的一个具体实现,它利用一个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对象代表的是分布式存储,所以它存储的字节内容,该内容是通过JsonSerializerChatResponse对象或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)、消息列表、ChatOptionsCacheKeyAdditionalValues属性组合成一个对象数组,然后利用JsonSerializer将这个对象数组进行序列化,并对序列化后的字节内容进行哈希计算,这个哈希值转换成的字符串作就是所需的缓存键。至于其他的方法,它们的实现逻辑比较简单,就是通过调用IDistributedCache对象的对应方法来实现从缓存中读取和写入数据的功能,中间会涉及针对ChatResponse对象和ChatResponseUpdate对象列表的JsonSerializer序列化和反序列化操作。

4. UseDistributedCache扩展方法

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

csharp 复制代码
public static class DistributedCachingChatClientBuilderExtensions
{
    public static ChatClientBuilder UseDistributedCache(
        this ChatClientBuilder builder, 
        IDistributedCache? storage = null, 
        Action<DistributedCachingChatClient>? configure = null);
}
相关推荐
武子康1 小时前
调查研究-146 宇树科技科创板IPO上会:42亿募资背后的机器人商业化真相
大数据·人工智能·科技·程序人生·ai·机器人·具身智能
爱听歌的周童鞋1 小时前
Learn-Claude-Code | 笔记 | Tools & Execution | s03_new Permission
llm·agent·tools·permission·execution·claude code
Cosolar1 小时前
2026 年 AI 开源生态全景图
人工智能·面试·大模型·agent·rag
YueJoy.AI1 小时前
创业团队如何建立招聘流程
人工智能·ai·语言模型
曹牧1 小时前
C#:List<T>.ForEach(Action<T> action)
c#
星辰AI1 小时前
AI 应用架构设计模式:从原型到生产级系统
人工智能·ai·语言模型
雪碧聊技术1 小时前
AI通识一文详解(大模型应用、大模型服务、大模型API)
人工智能·大模型·agent
YueJoy.AI1 小时前
AI应用的安全工程:从威胁建模到防护
人工智能·ai·语言模型
编码如写诗1 小时前
瑞芯微RK3588+麒麟V10国防版+昇腾310异构部署k8s集群+KubeSphere
人工智能·ai·云原生·kubernetes