惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Proofpoint News Feed
U
Unit 42
V
Visual Studio Blog
D
DataBreaches.Net
F
Fortinet All Blogs
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
The GitHub Blog
The GitHub Blog
Y
Y Combinator Blog
月光博客
月光博客
大猫的无限游戏
大猫的无限游戏
T
The Blog of Author Tim Ferriss
GbyAI
GbyAI
博客园 - 叶小钗
Blog — PlanetScale
Blog — PlanetScale
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
MongoDB | Blog
MongoDB | Blog
The Cloudflare Blog
云风的 BLOG
云风的 BLOG
D
Docker
G
Google Developers Blog
罗磊的独立博客
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
S
SegmentFault 最新的问题

博客园_首页

Linux实操--组管理、权限管理和定时任务 Java + EasyExcel 实现单个接口导出多个Excel Mem0 源码解析系列(二):提示词工程的深度剖析 Openclaw TaskFlow究竟是什么?和普通Skill技能有什么区别 博文阅读密码验证 - 博客园 嘉立创开源:应该是全网MicroPython教程最多的开发板 Hermes Agent 集成实践:从协议到生产 2026年AI编程工具横评:Cursor、Codex、Claude Code、Zed、Windsurf Java程序员必看的RAG入门教程 2026 AI效率神器:Superpowers + Claude Code 保姆级教程 本地大模型部署全攻略:从 0 到 1 玩转 Ollama 【从0到1构建一个ClaudeAgent】内存管理-上下文压缩 .NET 高级开发 | 设计、实现一个事件总线框架 电子小白入门之NE555 3. WorkBuddy:隐藏玩法,一键召唤专家,让 AI 以"专家身份"给你干活 和AI一起搞事情#3:Claude Teammate 游戏开发翻车实录 【OpenClaw】通过 Nanobot 源码学习架构---(7)Memory C# .NET 周刊|2026年3月3期 我在 Debian 11 上把 K8s 单机搭起来了,过程没你想的那么顺(/opt 目录版) 深度学习进阶(七)Data-efficient Image Transformer CLI+Skill搭建浏览器AI自动化框架,告别一切重复枯燥任务 告别Token账单无底洞:OpenClaw本地部署,重塑企业数据主权的唯一解 FastAPI+Vue:文件分片上传+秒传+断点续传,这坑我帮你踩平了! SBTI 爆火后,我做了个程序员版的 CBTI。。已开源 + 附开发过程 多模态检索开始进入工程期:用 Sentence Transformers 搭建可落地的 Multimodal RAG 100多行代码实现一个最简单的Agent(用ReAct) Claude Code 通关手册(八):推荐 5 个 Hooks,代码质量提升 3 倍 老板:“有人截图了!”。安全部门:“收到,马上查暗水印!” - why技术 技术之外,皆是人间 C#/.NET/.NET Core技术前沿周刊 | 第 69 期(2026年4.01-4.12)
[MAF预定义ChatClient中间件-03]CachingChatClient——利用缓存...
Artech · 2026-06-05 · via 博客园_首页

我们知道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的调用。

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);
}