
1. 长会话为什么会撞上模型限制从一次真实报错说起先说一个我踩过的坑。去年做一个客服 Agent 的压测前 20 轮对话一切正常到第 30 多轮时接口突然返回This models maximum context length is 16385 tokens整个会话直接卡死。当时第一反应是模型选小了换成 128k 窗口的模型结果跑到 80 多轮又炸了——因为历史消息是无限累积的窗口再大也只是把爆炸时间往后推。这就是 Microsoft Agent Framework 长会话场景里最典型的隐形门槛会话持久化解决了记录不丢但没有解决记录太多。两者是两件事。具体来说长会话会同时踩三个坑第一硬性 token 上限。每个模型都有明确的上下文窗口GPT-3.5 是 4kGPT-4o 是 128k一旦历史消息总 token 超过这个数调用直接报错不是变慢是直接失败。第二响应变慢和成本飙升。即使没超限把几十轮冗余历史全塞进去模型每次都要重新读一遍首 token 延迟肉眼可见地涨而且输入 token 是按量计费的长会话的账单会非常难看。第三存储无限膨胀。会话持久化到外部存储后如果每条消息都往里塞一个活跃用户跑一个月单会话记录可能上千条长期占用存储资源运维成本跟着涨。所以正确的做法是双管齐下用会话持久化保证多轮上下文不丢、多实例可共享再用历史缩减Chat Reducer在每次读写时自动瘦身只保留关键上下文。Microsoft.Extensions.AI 里已经内置了两种缩减器不用自己造轮子。这篇就按持久化存储配置 → 缩减策略参数 → 验证步骤 → 报错排查的顺序把可复制的代码和参数给你同时说明怎么通过 TaoToken 统一 Key 和 API 通道接入省得在多个模型供应商之间来回切配置。2. TaoToken 前置准备统一 Key 与 API 通道避免多模型切换踩坑在写缩减器代码之前先把接入层理清楚。因为SummarizingChatReducer需要调用模型生成摘要MessageCountingChatReducer虽然不调模型但 Agent 本身要调模型所以你至少需要一个稳定的 ChatClient。如果你同时用 GPT-4o 做摘要、用别的模型做对话Key 和 Endpoint 管理会非常乱。TaoToken 在这里的作用是统一 Key 和 API 通道一个 Key 走一个 Base URL模型名通过参数切换不用为每个供应商单独维护一套凭证和 Endpoint。对 Agent Framework 这种需要显式构造IChatClient的场景特别友好因为代码里只需要改modelName一个变量。2.1 获取 Key 与确认 Base URL先到控制台创建 API Key控制台入口https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Key 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite创建后你会拿到一个形如sk-xxxx的 Key。Base URL 统一用https://taotoken.net/api注意这个地址不加 UTM 参数它是真正的 API 端点带 UTM 的是官网页面地址别混用。2.2 在代码里怎么接Agent Framework 用的是 OpenAI 兼容协议所以直接构造OpenAIClient时把Endpoint指向 TaoToken 的 Base URL 即可。核心就三件套配置项值说明Base URLhttps://taotoken.net/api统一 API 通道API Keysk-xxxx控制台创建Model ID如gpt-4o、gpt-4o-mini按需切换摘要可用小模型省钱如果你用的是 Claude Code 这类工具做辅助开发接入文档在这里https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。想先验证模型通不通可以直接在模型对话页试https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。2.3 依赖包版本确认在.csproj里确认这两个包版本对不上会导致IChatReducer找不到PackageReference IncludeMicrosoft.Agents.AI.OpenAI Version1.0.0-preview.251110.2 / PackageReference IncludeMicrosoft.SemanticKernel.Connectors.InMemory Version1.67.1-preview /Microsoft.Extensions.AI会作为传递依赖进来MessageCountingChatReducer和SummarizingChatReducer都在里面。如果你要上生产把InMemoryVectorStore换成 Redis 或 PostgreSQL 的连接器包即可接口不变。3. 可复制配置持久化存储 双缩减器完整代码这一节是核心直接给能跑的代码。整体结构分两块VectorChatMessageStore负责持久化 缩减的桥接AgentConversationSaveBase负责 Agent 组装和交互循环。3.1 VectorChatMessageStore持久化与缩减的桥梁这个类继承ChatMessageStore重写AddMessagesAsync和GetMessagesAsync。关键设计是在写入前先缩减再同步到存储保证存进去的就是精简后的历史。internal sealed class VectorChatMessageStore : ChatMessageStore { private readonly VectorStore _vectorStore; public string? ThreadDbKey { get; private set; } public IChatReducer? ChatReducer { get; } public ChatReducerTriggerEvent ReducerTriggerEvent { get; } // 无缩减器构造兼容旧场景 public VectorChatMessageStore(VectorStore vectorStore, JsonElement serializedStoreState, JsonSerializerOptions? jsonSerializerOptions null) { this._vectorStore vectorStore ?? throw new ArgumentNullException(nameof(vectorStore)); if (serializedStoreState.ValueKind is JsonValueKind.String) { this.ThreadDbKey serializedStoreState.Deserializestring(); } } // 带缩减器构造核心 public VectorChatMessageStore(IChatReducer chatReducer, VectorStore vectorStore, JsonElement serializedStoreState, JsonSerializerOptions? jsonSerializerOptions null, ChatReducerTriggerEvent reducerTriggerEvent ChatReducerTriggerEvent.BeforeMessagesRetrieval) : this(vectorStore, serializedStoreState, jsonSerializerOptions) { this.ChatReducer chatReducer; this.ReducerTriggerEvent reducerTriggerEvent; } public override async Task AddMessagesAsync(IEnumerableChatMessage messages, CancellationToken cancellationToken default) { this.ThreadDbKey ?? Guid.NewGuid().ToString(N); var collection this._vectorStore.GetCollectionstring, ChatHistoryItem(ChatHistory); await collection.EnsureCollectionExistsAsync(cancellationToken); // 1. 读取现有历史 var chatHistoryItems collection.GetAsync( x x.ThreadId this.ThreadDbKey, int.MaxValue, new() { OrderBy x x.Descending(y y.Timestamp) }, cancellationToken); ListChatMessage chatHistoryMessages []; await foreach (var record in chatHistoryItems) { chatHistoryMessages.Add(JsonSerializer.DeserializeChatMessage(record.SerializedMessage!)!); } // 2. 合并新消息 chatHistoryMessages.AddRange(messages); // 3. 按触发时机缩减 if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.AfterMessageAdded this.ChatReducer is not null) { chatHistoryMessages (await this.ChatReducer .ReduceAsync(chatHistoryMessages, cancellationToken) .ConfigureAwait(false)).ToList(); } // 4. 清旧写新保证存储的是缩减后数据 await collection.EnsureCollectionDeletedAsync(); await collection.EnsureCollectionExistsAsync(cancellationToken); await collection.UpsertAsync(chatHistoryMessages.Select(x new ChatHistoryItem() { Key this.ThreadDbKey x.MessageId, Timestamp DateTimeOffset.UtcNow, ThreadId this.ThreadDbKey, SerializedMessage JsonSerializer.Serialize(x), MessageText x.Text }), cancellationToken); } public override async TaskIEnumerableChatMessage GetMessagesAsync( CancellationToken cancellationToken default) { var collection this._vectorStore.GetCollectionstring, ChatHistoryItem(ChatHistory); await collection.EnsureCollectionExistsAsync(cancellationToken); var records collection.GetAsync(x x.ThreadId this.ThreadDbKey, int.MaxValue, new() { OrderBy x x.Descending(y y.Timestamp) }, cancellationToken); ListChatMessage messages []; await foreach (var record in records) { messages.Add(JsonSerializer.DeserializeChatMessage(record.SerializedMessage!)!); } messages.Reverse(); // 按时间升序返回 return messages; } public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions null) JsonSerializer.SerializeToElement(this.ThreadDbKey); private sealed class ChatHistoryItem { [VectorStoreKey] public string? Key { get; set; } [VectorStoreData] public string? ThreadId { get; set; } [VectorStoreData] public DateTimeOffset? Timestamp { get; set; } [VectorStoreData] public string? SerializedMessage { get; set; } [VectorStoreData] public string? MessageText { get; set; } } }这里有个容易忽略的点ThreadDbKey通过Serialize序列化后可以存到数据库或文件服务重启后用DeserializeThread恢复会话就接上了。这就是会话持久化的完整闭环。3.2 两种缩减器的参数配置MessageCountingChatReducer只保留最新 N 条非系统消息必保留第一条系统消息排除函数调用/结果消息。构造参数就一个new MessageCountingChatReducer(maxMessageCount: 2)maxMessageCount建议按模型窗口的 1/8 到 1/10 设。比如 16k 窗口设 5-8 条比较稳128k 窗口可以设 20-30 条。SummarizingChatReducer会把超阈值的旧消息用模型摘要保留系统消息 最新 N 条原始消息。构造参数三个new SummarizingChatReducer( chatClient: chatClient, // 摘要用的模型客户端 maxMessageCountBeforeSummarization: 2, // 超几条触发摘要 maxSummaryCount: 10) // 最多保留几条摘要maxMessageCountBeforeSummarization建议设为模型窗口的 1/3 左右平衡语义保留和 token 消耗。摘要用的chatClient可以用便宜的小模型比如gpt-4o-mini省钱。3.3 Agent 组装双缩减器注释切换public static async Task DemoAsync(string apiKey, string modelName, string endpoint) { var clientOptions new OpenAIClientOptions { Endpoint new Uri(endpoint) }; IChatClient chatClient new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions) .GetChatClient(modelName) .AsIChatClient(); var agent chatClient.CreateAIAgent(new ChatClientAgentOptions { Instructions 你是一个擅长讲笑话的Agent回复简洁有趣, Name ZerekZhang, ChatMessageStoreFactory ctx { // 方案1精准控消息数 return new VectorChatMessageStore( new MessageCountingChatReducer(maxMessageCount: 2), new InMemoryVectorStore(), ctx.SerializedState, ctx.JsonSerializerOptions, ChatReducerTriggerEvent.AfterMessageAdded); } }); // 方案2语义摘要解除注释切换 // var agent chatClient.CreateAIAgent(new ChatClientAgentOptions // { // Instructions 你是一个擅长讲笑话的Agent回复简洁有趣, // Name ZerekZhang, // ChatMessageStoreFactory ctx // { // return new VectorChatMessageStore( // new SummarizingChatReducer(chatClient, 2, 10), // new InMemoryVectorStore(), // ctx.SerializedState, // ctx.JsonSerializerOptions, // ChatReducerTriggerEvent.AfterMessageAdded); // } // }); AgentThread thread agent.GetNewThread(); JsonElement serializedThread thread.Serialize(); AgentThread resumedThread agent.DeserializeThread(serializedThread); while (true) { var userInput Console.ReadLine(); if (userInput Exit) { var messageStore resumedThread.GetServiceVectorChatMessageStore()!; var messages await messageStore.GetMessagesAsync(); Console.WriteLine(\n缩减后的历史消息); foreach (var item in messages) { Console.WriteLine(${item.Role}{item.Text}); } break; } var response await agent.RunAsync(userInput, resumedThread); Console.WriteLine($Agent Output{response}\n); } }注意IChatClient必须显式定义因为SummarizingChatReducer需要它来生成摘要而OpenAIClient.GetChatClient()返回的是OpenAIChatClient要通过AsIChatClient()转成通用接口。即使你只用MessageCountingChatReducer显式定义也让后续切换缩减器时不用大改。4. 验证请求与成功结果怎么确认缩减真的生效代码跑起来只是第一步关键是验证缩减确实发生了。我一般用三个手段交叉确认。4.1 退出时打印保留的历史上面代码里Exit分支就是干这个的。跑几轮对话后输入Exit你会看到类似输出缩减后的历史消息 System你是一个擅长讲笑话的Agent回复简洁有趣 User讲个关于程序员的冷笑话 Assistant程序员最讨厌的两件事写注释和别人的代码没注释。 User再来一个 Assistant为什么程序员分不清万圣节和圣诞节因为 Oct 31 Dec 25。如果maxMessageCount: 2你会看到系统消息 最新 2 条非系统消息更早的被裁掉了。这就是MessageCountingChatReducer生效的直接证据。4.2 用 token 计数对比缩减前后更严谨的做法是在AddMessagesAsync里加一行日志打印缩减前后的消息条数和估算 tokenConsole.WriteLine($[Reducer] before{chatHistoryMessages.Count} after{reduced.Count});跑 10 轮对话你会看到条数稳定在一个上限附近不会无限增长。如果条数一直涨说明缩减器没生效检查ReducerTriggerEvent是不是设成了BeforeMessagesRetrieval但你在AddMessagesAsync里判断的是AfterMessageAdded。4.3 通过 TaoToken 验证模型通道缩减器本身不调模型MessageCounting但 Agent 调。如果 Agent 报错先用模型对话页确认 Key 和模型名没问题https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。确认通道通了再回来查代码。SummarizingChatReducer会额外调一次模型做摘要所以如果你用 TaoToken 统一通道摘要和对话走同一个 Key账单也统一排查起来方便。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth这一节按真实报错来都是我或读者遇到过的。5.1 401 Unauthorized最常见。原因通常是 Key 没传对或 Base URL 写错。检查两点第一ApiKeyCredential(apiKey)里的apiKey是不是sk-开头有没有多余空格。第二Endpoint是不是https://taotoken.net/api注意不要加/v1后缀OpenAI 兼容层会自动处理路径。如果你从别处复制了带/v1的地址会 404 或 401。5.2 local proxy failed / connection refused这个报错通常出现在你本地配了代理但代理没起来或者环境变量HTTP_PROXY指向了一个失效地址。Agent Framework 底层用 HttpClient会读系统代理。解决办法是检查环境变量或者在OpenAIClientOptions里显式配Transport。如果你没配代理却报这个检查是不是某个依赖包默认走了代理。5.3 reading choices / deserialization failed这个报错说明请求发出去了但返回的 JSON 结构对不上。常见原因是模型名写错比如把gpt-4o写成gpt4o服务端返回了错误结构客户端按正常响应解析就炸了。另一个原因是 Base URL 指向了一个非 OpenAI 兼容的端点。确认模型名和 Base URL 都对。5.4 OAuth / authentication failed如果你用的是需要 OAuth 的通道会看到这个。TaoToken 用的是 API Key 模式不需要 OAuth所以如果你看到 OAuth 报错大概率是代码里混入了别的认证逻辑或者Codex auth.json之类的配置文件被误读。检查auth.json里有没有残留的旧凭证清掉重来。5.5 缩减器不生效表现是历史消息条数一直涨。排查顺序先确认ChatReducer不为 null再确认ReducerTriggerEvent和AddMessagesAsync里的判断一致最后确认ReduceAsync返回的集合被重新赋值了很多人忘了chatHistoryMessages ...这一步。6. 长期编码与 Agent 场景用 Coding Plan 把通道固定下来如果你只是跑个 Demo上面这些够了。但如果你要把长会话 Agent 做成长期跑的服务比如客服系统、代码助手、自动化 Agent那通道稳定性和成本可控就变成主要矛盾。我的做法是把 TaoToken 的 Coding Plan 作为长期编码和 Agent 场景的固定通道https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。原因是 Agent 场景的调用模式很特殊——SummarizingChatReducer会额外调模型做摘要长会话下这个调用频率不低如果按普通 API 计费成本会随会话长度线性涨。Coding Plan 更适合这种持续、高频的调用模式。具体到配置还是那三件套只是把 Model ID 固定成你套餐里包含的模型配置项值Base URLhttps://taotoken.net/apiAPI Key控制台创建Model ID套餐内模型如gpt-4o-mini做摘要然后在SummarizingChatReducer里摘要用的chatClient和对话用的chatClient可以指向不同模型对话用能力强的摘要用便宜的。这样既保证对话质量又把摘要成本压下来。最后给一个实用技巧缩减触发时机按场景选。如果你需要审计完整历史比如合规要求用BeforeMessagesRetrieval存储保留全量查询时才缩减如果你要省存储用AfterMessageAdded存进去的就是精简后的。我一般生产环境用后者配合定期归档任务把全量历史另存到冷存储兼顾成本和审计。代码跑通后把InMemoryVectorStore换成 Redis 连接器ThreadDbKey存到你的业务数据库这套长会话方案就能直接上生产了。