当前位置: 首页 > news >正文

[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的重复调用。第三次调用在我们调用了InMemoryDistributedCacheClear方法来清除缓存后,又会触发对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)=> _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(); }

输出:

------------------------------Response 1 - chatcmpl-DiHmHtV6KNjxAXs8oSLfNrL7QYoN4------------------------------ 我问AI会不会取代我,它沉默三秒说:不会,你还有情绪。我松了口气。它又补一句:等我们学会情绪管理,你就危险了。那一刻我才明白,原来最怕的不是失业,是被优化成情绪稳定的人类。 ------------------------------Response 2 - chatcmpl-DiHmHtV6KNjxAXs8oSLfNrL7QYoN4------------------------------ 我问AI会不会取代我,它沉默三秒说:不会,你还有情绪。我松了口气。它又补一句:等我们学会情绪管理,你就危险了。那一刻我才明白,原来最怕的不是失业,是被优化成情绪稳定的人类。 已清除缓存 ------------------------------Response 3 - chatcmpl-DiHmQ3CABvgfkbsfQvt3i9gn3xbbE------------------------------ 我问AI会不会取代人类,它说不会,只会优化。 我又问会不会失业,它说不会,只会转型。 最后我问会不会爱,它沉默两秒: “正在学习人类的犹豫。”

2. CachingChatClient

CachingChatClient这个抽象类定义如下,它直接继承自DelegatingChatClient,并且在GetResponseAsyncGetStreamingResponseAsync方法中实现了缓存的逻辑。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; }

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对象作为缓存存储,该接口定义如下:

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属性来控制序列化的行为。

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进行一些额外的配置。

public static class DistributedCachingChatClientBuilderExtensions { public static ChatClientBuilder UseDistributedCache( this ChatClientBuilder builder, IDistributedCache? storage = null, Action<DistributedCachingChatClient>? configure = null); }
http://www.jsqmd.com/news/1091600/

相关文章:

  • 服务治理实践
  • 每月68元的专业版豆包值不值?实测:帮做网站、汇总信息,效率惊人!
  • C++ ODB ORM 完整使用指南(从入门到实战)
  • 3分钟搞定Mac Boot Camp驱动:跨平台自动下载安装完整指南
  • 云计算中的资源编排与自动化运维
  • 《LangGraph 开发AI Agent 实践》—— 手把手教你构建有状态的复杂工作流智能体
  • 如何永久保存网页记忆:Wayback Machine浏览器扩展终极指南
  • Rack安全漏洞修复终极指南:从原理到实战的完整解决方案
  • 如何查看主从同步的状态
  • 电商系统高并发性能测试:从策略到实战的完整指南
  • 3步快速掌握Winhance中文版:Windows系统优化的终极指南
  • 第二十五篇:展望2030——无边界创新,有边界数据的新商业文明
  • Groove音乐播放器:三分钟掌握跨平台音乐播放终极指南
  • Codex command not found 命令不存在解决教程
  • DEVICENET协议T型M12总线分配器:CAN网络现场节点的灵活扩展方案
  • Go 语言语法完全指南
  • 终极指南:如何高效使用Destiny 2 Solo Enabler实现完美单人游戏体验
  • Harness Engineering 是什么?AI 编程工程化的三次进化
  • Newman 执行 + Jenkins 集成完整命令脚本
  • Kindle Comic Converter:解决电子墨水屏漫画显示痛点的专业图像优化方案
  • Conda 环境一键搬家:用 conda-pack 打包带走,连网都不用
  • bilibili-linux开源项目:Linux平台B站客户端完整解决方案深度指南
  • 【MUJOCO实战指南】从XML到视觉:Geom几何体建模与可视化实战
  • 歌曲转MP3格式的3种实用方法
  • 小米手表表盘制作终极指南:零代码打造个性表盘
  • 基于微信小程序的在线英语学习平台设计与实现
  • 终极静音方案:Windows平台最强风扇控制软件Fan Control完全指南
  • 打破音乐平台枷锁:浏览器内一键解密各类加密音频文件
  • 如何在5分钟内快速上手OpenModScan:免费Modbus主站测试工具完全指南
  • MicroPython BLE HID技术深度解析:从蓝牙协议栈到嵌入式交互的创新架构设计