一条贯穿全文的分析主线:上下文预算守恒定律——任何对话策略都无法消灭 Token 消耗,只能决定它在“信息保真度、延迟、工程复杂度、费用”四者之间如何重新分配。本文用这条主线逐层核算三类主流策略的真实账本。
摘要
Semantic Kernel 的 ChatHistory 对象在默认用法下会把整段对话历史逐条回放给模型,而多数团队在立项估算时只按“单轮输入 + 输出”计价,忽略了历史随轮次线性累积这一事实。当对话轮次达到 10–20 轮、单轮消息长度在数百 Token 量级时,累计输入 Token 与“首轮估算”之间出现 3–5 倍的偏差,这与二次型累积的数学结构高度吻合。
本文不做策略推荐式的泛泛比较,而是先建立“上下文预算守恒”这一分析框架,把滑动窗口、摘要压缩、token 感知截断三类策略还原为对信息保真度与费用的不同折中曲线;随后给出各策略的机制推导、失效边界、参数标定方法与可复现的评估口径;最后提出分层预算架构与工程落地步骤,并对长上下文模型、KV 缓存复用、提示缓存等前沿方向作出审慎预判。
文中数据来源分为三类并逐一标注:公开文档与论文、公开定价页推算、以及明确标注的模拟数据。所有结论均给出可检验的推理链,供读者按自身业务参数复算。
目录
一、问题的真实形状:3–5 倍偏差从何而来
先把现象说清楚。一个典型的 C# 服务里,开发者用 Semantic Kernel 构建对话能力,代码大致如下:
// 典型但昂贵的写法:每轮把完整 ChatHistory 交给模型
var history = new ChatHistory();
history.AddSystemMessage(systemPrompt);
while (true)
{
var userInput = Console.ReadLine();
history.AddUserMessage(userInput);
// 注意:这里传入的是整个 history
var result = await chatCompletion.GetChatMessageContentAsync(
history, executionSettings, kernel);
history.AddAssistantMessage(result.Content);
Console.WriteLine(result.Content);
}
这段代码在功能上完全正确,问题出在计费结构上。设第 i 轮用户消息与助手回复的 Token 数分别为 ui 与 ai,系统提示为 s,则第 n 轮请求的输入 Token 为:
In = s + Σi=1..n(ui + ai)
若各轮长度近似平稳,即 ui ≈ u、ai ≈ a,则 In ≈ s + n(u + a)。累计到第 N 轮的输入 Token 总量为:
T(N) = Σn=1..N In ≈ N·s + (u + a)·N(N+1)/2
这就是偏差的数学根源:累计输入 Token 随轮次呈二次增长,而立项估算通常按线性口径(“每轮约 X Token,每天 Y 轮”)计算。两者之比为:
R(N) = T(N) / (N · I1) ≈ [N·s + (u+a)N(N+1)/2] / [N(s + u + a)]
代入一组常见参数:s = 300,u = 120,a = 250(单位 Token),则 I1 = 670。当 N = 10 时,T(10) ≈ 3000 + 370×55 = 23350,线性估算为 6700,比值约 3.49;当 N = 15 时,比值约 4.6;N = 20 时约 5.7。这与“3 到 5 倍”的经验观察区间基本重合,说明该偏差不是偶发的计费错误,而是全量回放策略的必然结果。
上述计算为本文基于公开计费结构的模拟推算,参数取自常见对话场景的典型值,不代表任何特定厂商的实测账单。读者应代入自身业务的真实 s、u、a 与 N 分布复算。
需要强调的是,偏差倍数对 N 的分布极其敏感。真实业务中会话长度往往呈长尾分布:大量会话在 3–5 轮结束,少数会话长达数十轮。因此正确的做法不是用平均轮次估算,而是用轮次分布的期望计算:
E[T] = ΣN P(N) · T(N),其中 P(N) 为会话恰在第 N 轮结束的概率
由于 T(N) 是二次的,长尾会话对总成本的贡献远超其数量占比。一个 50 轮会话的成本约等于 25 个 10 轮会话——这是许多团队在成本归因时容易忽略的结构性事实。本文评述:把“平均轮次”作为成本估算输入,在二次型成本函数下是系统性低估,必须改用分布口径。
二、背景与概念界定:ChatHistory 在 SK 中的角色
2.1 ChatHistory 的定位
在 Semantic Kernel 的抽象中,ChatHistory 是一个承载消息序列的容器,元素类型为 ChatMessageContent,每条消息带有角色(System / User / Assistant / Tool)与内容。它的设计意图是忠实记录对话状态,而不是管理上下文预算。这一区分至关重要:容器语义与预算语义是两件事,SK 把前者做好了,把后者留给了调用方。
本文评述:这不是设计缺陷,而是合理的职责划分。框架无法预知业务对“历史保真度”的要求,也无法预知所用模型与计费方式,因此把裁剪决策外置是稳妥的。但这也意味着,“默认全量回放”实际上是一个隐式的成本承诺,开发者若未显式接管,就等于默认接受了二次型账单。
2.2 与之相关的几个概念
这里必须点明一个常被混淆的问题:上下文窗口变大,不等于成本问题消失。窗口从 8K 扩到 128K,只是把“截断发生的位置”往后推,单价并未因此下降,反而因为输入更长而总价更高。本文评述:长上下文能力解决的是“能不能放进去”,而本文讨论的是“值不值得放进去”,两者是正交问题。
三、上下文预算守恒:本文的分析主线
3.1 主线的表述
本文提出并贯穿全文的分析框架是上下文预算守恒:在给定任务质量要求的前提下,对话系统每轮可用的“有效信息预算”是有限的,任何策略都不能凭空创造信息,只能在四个维度之间做再分配——
- 信息保真度:保留多少原始语义细节;
- 费用:每轮与累计的 Token 支出;
- 延迟:额外处理(如摘要调用)带来的端到端时延;
- 工程复杂度:实现、测试、监控与故障排查的负担。
这条主线的价值在于:它把“选哪个策略”这种容易陷入经验主义的争论,转化为“在哪个维度上让渡、让渡多少”的可计算问题。本文评述:工程决策的困难往往不在于不知道选项,而在于没有统一的度量坐标;守恒框架提供的正是这个坐标。
3.2 三类策略在守恒框架中的位置
注意表中“成本增长阶”一列:三者都把二次增长压回了线性,区别在于常数项与信息损失的位置。这正是本文要反复强调的结论——三类策略在“把账算过来”这件事上是同构的,差异在损失什么、损失多少、以及为此付出多少工程代价。
四、策略一:滑动窗口的机制、边界与账本
4.1 机制
滑动窗口保留最近 K 条消息(或最近 W 个 Token),丢弃更早的部分。实现上通常保留系统消息不动,仅对 User/Assistant 序列做窗口裁剪。其请求输入为 In = s + min(Σ 近期, W),当窗口填满后趋于常数。
// 基于消息条数的滑动窗口(保留系统消息)
public static void TrimByMessageCount(ChatHistory history, int keepRecent)
{
if (history.Count <= keepRecent + 1) return;
var system = history.FirstOrDefault(m => m.Role == AuthorRole.System);
var recent = history
.Where(m => m.Role != AuthorRole.System)
.TakeLast(keepRecent)
.ToList();
history.Clear();
if (system is not null) history.Add(system);
foreach (var m in recent) history.Add(m);
}
这段代码有一个容易被忽视的隐患:按条数裁剪不等于按 Token 裁剪。一条包含长文档的用户消息可能抵得上几十条短消息,按条数裁剪会导致单轮输入剧烈波动,进而使预算不可控。本文评述:条数窗口只适合消息长度方差小的场景(如固定模板的问答),一旦引入文档、代码或工具返回结果,必须改用 Token 口径。
4.2 失效边界
滑动窗口的失效模式非常明确:当任务依赖的信息落在窗口之外时,模型会“忘记”约束。典型场景包括:多轮表单填写(早期给出的字段被丢弃)、长流程排障(初始症状描述丢失)、以及需要引用前文的改写任务。这类失效不会报错,只会表现为质量下降,因而最难被发现。
一个缓解手段是关键信息锚定:把必须长期保留的要素(用户身份、任务目标、硬约束)抽取为结构化字段,固定注入系统消息或单独的“状态块”,使其不参与窗口淘汰。这实际上是把“记忆”从对话流中剥离出来,属于下一节摘要压缩的轻量变体。
4.3 账本核算
设窗口容量 W = 2000 Token,系统提示 300,则每轮输入稳定在 2300 以内。相比全量回放,20 轮会话的累计输入从约 23350 降至约 46000 以下(前几轮未填满时略低)。节省比例随轮次增加而提高:10 轮时约节省 0–10%,20 轮时约节省 50% 以上,50 轮时节省超过 80%。这说明滑动窗口的收益是“越长的会话越划算”,而短会话几乎无感。
上述比例为基于固定参数的模拟计算,用于说明趋势,不代表实测。实际节省取决于 W 与平均消息长度的相对关系。
本文评述:滑动窗口是性价比最高的“止血”手段——实现成本极低、延迟零增加、预算立即可控。但它把风险从“费用”转移到了“质量”,且这种转移是隐性的。因此它适合作为第一道防线,而不是唯一防线。
五、策略二:摘要压缩的机制、边界与账本
5.1 机制与两种实现路径
摘要压缩把较早的对话内容替换为一段概括文本。实现上有两条路径:滚动摘要(每积累若干轮就调用一次模型生成摘要,替换旧内容)与分段摘要(对历史分块摘要,检索时按需拼接)。前者延迟可控但信息有损,后者保留更多细节但需要检索组件。
// 滚动摘要:超过阈值时压缩早期消息
public async Task<ChatHistory> CompactAsync(
ChatHistory history, int threshold, int keepRecent)
{
if (history.Count <= threshold) return history;
var older = history
.Where(m => m.Role != AuthorRole.System)
.SkipLast(keepRecent)
.ToList();
var summaryPrompt = "将以下对话压缩为要点,保留事实、约束与未决问题:\n"
+ string.Join("\n", older.Select(m => $"{m.Role}: {m.Content}"));
var summary = await _chat.GetChatMessageContentAsync(
new ChatHistory { new ChatMessageContent(AuthorRole.User, summaryPrompt) });
var rebuilt = new ChatHistory();
rebuilt.AddSystemMessage(_systemPrompt);
rebuilt.AddSystemMessage($"[历史摘要] {summary.Content}");
foreach (var m in history.TakeLast(keepRecent)) rebuilt.Add(m);
return rebuilt;
}
这里有一个工程细节值得展开:摘要本身也是一次模型调用,它的输入是被压缩的那段历史,因此摘要步骤的成本与被压缩内容成正比。如果每轮都触发摘要,总成本反而可能高于全量回放。合理的做法是设置触发阈值(如每积累 8 轮压缩一次),使摘要调用的频次远低于对话轮次。
5.2 信息损失的不可逆性
摘要压缩与滑动窗口的关键区别在于:滑动窗口丢弃的是可重新获取的原始消息(若业务侧有持久化),而摘要压缩丢弃的是原始文本本身,只留下二次加工的产物。一旦摘要遗漏了某个细节,后续轮次无法回溯。这构成一种不可逆的信息损失。
本文评述:因此摘要压缩的正确用法不是“替换历史”,而是“在保留原始存储的前提下,用摘要作为工作集”。也就是说,摘要服务于上下文构建,原始对话仍应落库,以便在检测到信息缺失时按需回捞。把摘要当作唯一真相来源,是生产环境中的常见误用。
5.3 账本核算
设摘要长度 C = 400 Token,保留最近 K = 6 条消息(约 1500 Token),每 8 轮触发一次摘要。稳态下每轮输入约 s + C + 1500 ≈ 2200 Token,与滑动窗口接近;额外成本来自摘要调用:每 8 轮一次,每次输入约 3000 Token、输出 400 Token,摊到每轮约 425 Token。合计每轮约 2625 Token,仍显著低于全量回放的长会话,但高于纯滑动窗口。
延迟方面,摘要调用是同步阻塞的,若在用户请求路径上执行,会带来一次额外的模型往返。工程上应将其异步化或预计算:在上一轮响应返回后后台触发,避免用户感知。本文评述:摘要压缩的复杂度主要不在算法,而在调度——何时压缩、在哪压缩、失败如何降级,才是决定它能否上生产的关键。
六、策略三:token 感知截断的机制、边界与账本
6.1 机制:从“条数”到“预算”
token 感知截断在发送前精确计算消息序列的 Token 数,按预算 B 从新到旧累加,超出即停止,并保证不切断单条消息(或对超长单条做二次切分)。它比滑动窗口更精确的地方在于:预算是一个硬上限,而非近似值。
// 基于 Token 预算的截断(伪代码,需接入实际分词器)
public static ChatHistory TrimByTokenBudget(
ChatHistory history, int budget, ITokenCounter counter)
{
var system = history.Where(m => m.Role == AuthorRole.System).ToList();
int used = system.Sum(m => counter.Count(m.Content));
var kept = new List<ChatMessageContent>();
// 从最新消息向前累加,保证最近上下文完整
foreach (var m in history.Where(m => m.Role != AuthorRole.System).Reverse())
{
int cost = counter.Count(m.Content);
if (used + cost > budget) break;
kept.Add(m);
used += cost;
}
kept.Reverse();
var result = new ChatHistory();
foreach (var s in system) result.Add(s);
foreach (var m in kept) result.Add(m);
return result;
}
实现上有两个必须处理的细节。其一,分词器必须与目标模型一致:不同模型的分词规则不同,用错分词器会导致预算估算偏差,进而触发服务端的长度错误或产生未预期的截断。其二,消息边界与角色交替:截断后若首条是 Assistant 消息,部分模型对角色交替有约束,需要在前面补一条占位 User 消息或调整起始位置。
6.2 与滑动窗口的本质差异
表面上看,token 感知截断就是“按 Token 计的滑动窗口”。但两者在工程语义上不同:滑动窗口的 W 是一个容量参数,token 截断的 B 是一个预算参数。容量参数描述“窗口多大”,预算参数描述“这轮最多花多少”。当业务需要按租户、按套餐、按 SLA 差异化控制成本时,预算参数是更自然的抽象——它可以直接与计费单元对齐。
本文评述:这一区分看似细微,实则决定了系统的可运营性。以容量为中心的系統难以回答“这个用户本月还能用多少”,而以预算为中心的系统天然支持配额、限流与成本归因。对于多租户 SaaS,预算口径几乎是必选项。
6.3 账本核算与边界
预算 B 固定时,每轮输入严格不超过 B,累计成本为 O(N·B),是三者中最可预测的。其代价是:当单条消息本身超过预算时,必须做二次切分,而切分可能破坏语义完整性(例如把一段代码或一个 JSON 截成两半)。因此需要为超长消息设计专门策略,如“整条保留 + 其余全部丢弃”或“结构化摘要 + 原文引用”。
另一个边界是预算与质量的非线性关系:预算从 4000 降到 2000 时质量可能只轻微下降,但从 2000 降到 800 时可能断崖式恶化。这意味着预算不能线性外推,必须通过评估确定拐点。本文评述:token 感知截断的最大风险不是技术实现,而是把预算设得过紧而不自知——系统不报错,只是答得越来越差。
七、三类策略的横向对比与组合失效分析
7.1 横向对比
7.2 组合失效:为什么“全都上”未必更好
实践中常见一种“保险式”做法:先摘要、再窗口、最后截断,三重保险。这在工程上看似稳妥,实则引入两类问题。
其一是信息衰减叠加:摘要已经损失细节,窗口再丢弃摘要覆盖不到的近期消息,截断又可能切掉摘要的尾部,最终上下文可能既不含原文也不含完整摘要。其二是成本叠加:摘要调用本身消耗 Token,若摘要结果又被窗口淘汰,等于白花。本文评述:组合策略必须有明确的职责分层,而不是简单串联。合理的分层是:截断负责“硬预算兜底”,摘要负责“远期信息压缩”,窗口负责“近期消息保真”,三者作用于不同的信息层,而非同一层的重复裁剪。
八、工程落地:分层预算架构与实施步骤
8.1 分层预算架构
基于守恒框架,本文提出一个四层结构,从下到上依次是:
L1 原始存储层:完整对话落库(关系库或文档库),带会话 ID、轮次、时间戳、Token 计数。这是可回溯性的基础,不参与裁剪。
L2 状态抽取层:把关键要素(用户意图、已确认事实、硬约束、未决问题)抽取为结构化状态块,随会话演进更新。它不随窗口淘汰。
L3 摘要层:对超出近期窗口的历史做滚动摘要,作为“远期记忆”注入。
L4 预算装配层:按 Token 预算把系统提示、状态块、摘要、近期消息装配为最终请求,硬上限兜底。
这个结构的核心思想是:让不同信息层承担不同的保真度职责,避免在同一层反复裁剪。状态块保证“关键约束不丢”,摘要保证“远期脉络不断”,近期消息保证“当前语境完整”,预算层保证“成本可控”。
8.2 实施步骤
- 第一步:建立计量基线。在现有系统中埋点,记录每轮请求的输入/输出 Token、消息条数、会话轮次、模型名。运行一到两周,得到真实的轮次分布与 Token 分布。没有基线,任何优化都无法验证。
- 第二步:复算成本结构。用第一节的公式,代入实测分布,计算当前累计成本与“线性估算”的比值,确认偏差来源是历史累积还是单轮超长。
- 第三步:接入分词器。选择与目标模型一致的分词实现,封装
ITokenCounter接口,并对典型消息做校准(估算值与服务端返回值比对,偏差应控制在可接受范围)。 - 第四步:先上预算兜底。在装配层实现 token 感知截断,设定一个宽松预算(如模型窗口的 60%),确保永不超限。这一步风险最低,可立即止血。
- 第五步:引入状态块。定义结构化状态 schema,从对话中抽取并更新。初期可用规则 + 小模型抽取,逐步迭代。
- 第六步:按需启用摘要。仅对长会话(超过阈值轮次)启用滚动摘要,异步执行,失败时降级为纯截断。
- 第七步:建立评估闭环。用固定评测集对比优化前后的任务成功率、关键信息召回率与单位会话成本,确认“账算过来”且质量未跌破可接受线。
8.3 一个可复用的装配器骨架
public sealed class ContextAssembler
{
private readonly ITokenCounter _counter;
private readonly int _budget;
public ContextAssembler(ITokenCounter counter, int budget)
{
_counter = counter;
_budget = budget;
}
public ChatHistory Assemble(
string systemPrompt,
string? stateBlock,
string? summary,
IReadOnlyList<ChatMessageContent> recent)
{
var result = new ChatHistory();
result.AddSystemMessage(systemPrompt);
int used = _counter.Count(systemPrompt);
if (!string.IsNullOrEmpty(stateBlock))
{
used += _counter.Count(stateBlock);
result.AddSystemMessage($"[会话状态]\n{stateBlock}");
}
if (!string.IsNullOrEmpty(summary) && used < _budget * 0.7)
{
used += _counter.Count(summary);
result.AddSystemMessage($"[历史摘要]\n{summary}");
}
// 近期消息从新到旧装配,保证最新语境优先
var picked = new List<ChatMessageContent>();
foreach (var m in recent.Reverse())
{
int c = _counter.Count(m.Content);
if (used + c > _budget) break;
picked.Add(m);
used += c;
}
picked.Reverse();
foreach (var m in picked) result.Add(m);
return result;
}
}
这个骨架把“预算分配”显式化:状态块优先,摘要次之,近期消息填满剩余。读者可根据业务调整优先级——例如客服场景可能更看重近期消息,而长流程任务可能更看重状态块。本文评述:优先级顺序本身就是一种业务假设,应当被显式声明并接受评估检验,而不是藏在代码的 if 分支里。
九、评估体系:如何证明“账算过来了”
9.1 三个必须同时看的指标
只盯成本会掉进“越省越差”的陷阱,只盯质量则无法回答成本问题。本文建议同时跟踪三个指标:
其中“关键信息召回率”是本文特别强调的指标。它的构造方法是:在多轮用例的早期轮次埋入一个必须遵守的约束(如“金额不得超过 500”),在后续轮次考察模型是否仍遵守。这个指标能直接暴露窗口过小或摘要遗漏的问题,比笼统的“回答质量打分”更有诊断力。
9.2 评测集构造与预处理
若使用公开多轮对话数据集做评测,需要说明预处理细节,否则结果不可比。本文建议的预处理流程包括:
- 按会话 ID 分组并按时间戳排序,剔除轮次少于 3 的会话(无法体现累积效应);
- 统一分词口径,用与目标模型一致的分词器重新计数,而非沿用数据集自带的统计;
- 对超长会话做截断或分段,避免单条样本超出评测预算;
- 标注“关键约束”位置,用于计算召回率;
- 固定随机种子划分训练/验证/测试(若涉及微调),并记录划分比例。
本文评述:评估的价值不在于给出一个分数,而在于定位失效发生的轮次与位置。建议在评测报告中按轮次分段统计成功率,观察曲线在哪个轮次开始下滑——那个拐点就是当前预算配置的实际上限。
十、前沿预判:长上下文、缓存与记忆分层
10.1 长上下文不改变守恒关系
近年模型上下文窗口持续扩大,从数千到数十万 Token 不等(具体规格以各厂商公开文档为准)。直觉上这会削弱截断策略的必要性,但本文认为恰恰相反:窗口越大,全量回放的诱惑越强,而单轮成本也越高。守恒关系依然成立——你只是把“截断”换成了“付费保留”,并没有消灭预算约束。
此外,长上下文还存在“中间信息利用率下降”的现象,即模型对位于长上下文中部的信息召回较弱。这一现象在多项公开研究中被讨论,本文评述:它意味着“放进去”不等于“用得上”,盲目扩大上下文可能既增加成本又不提升质量,反而稀释了关键信息。
10.2 提示缓存改变成本斜率
提示缓存(prompt caching)对重复前缀按折扣计费,这在多轮对话中天然适用——系统提示与前几轮历史是稳定的前缀。它能把全量回放的“有效单价”降下来,但二次增长的结构不变,只是斜率变小。本文评述:缓存是重要的成本优化手段,但它与截断策略是互补而非替代关系。缓存降低单位成本,截断降低总量,两者叠加才是最优解。
10.3 记忆分层是长期方向
从更长的视角看,对话系统的上下文管理正在从“单次请求内的裁剪”走向“跨会话的记忆分层”:短期工作记忆(当前窗口)、中期情节记忆(摘要与状态)、长期语义记忆(向量库检索)。这一方向与本文的分层预算架构同构,只是把 L2/L3 扩展到跨会话。本文评述:对于需要长期陪伴或持续任务的产品,检索增强的记忆层比单纯扩大窗口更具成本效益,因为它按需取用,而非每轮全量付费。
🔒 复制本站文章内容需登录并达到 L3。当前:未登录
微信扫一扫分享
打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。
💬 评论 (0)
评论功能已关闭

