上下文内容的滑动清理
普通用户一般不需要清理上下文,可以忽略本节内容。
在多轮对话中,随着对话的持续,上下文(Messages)会不断增长,最终可能超过大模型的上下文长度限制,导致 API 调用失败。
要解决这个问题,我们需要先掌握两个方法。
TrimMessages方法
TrimMessages方法用于按轮次清理上下文,只保留最近的若干轮对话。其语法为:
TrimMessages(n)
n:整数型,要保留的最近轮数,在此之前的对话内容会被删除。
这里的一轮,指从一条 user 消息开始,到下一次出现 user 消息之前的所有消息(包括 assistant
回答、工具调用请求和工具执行结果)。TrimMessages 会以轮为单位整段保留或删除,不会拆散工具调用链。
提示:
1、TrimMessages不会删除系统提示词,只删除用户对话,所以对于需要长期保留的内容,可以放在系统提示词中(SystemPrompt属性)
2、不能频繁清理上下文,否则会有很严重的副作用(后面会讲述),比较合适的做法是当上下文长度达到一个阈值后才进行清理,那么如何来估算阈值呢,这就要用到EstimateTokenCount方法。
EstimateTokenCount方法
EstimateTokenCount方法用于粗略计算指定字符串的Token数,其语法为:
语法:
EstimateTokenCount(content,cnWeight, enWeight)
content: 要计算token数的字符串
cnWeight:可选参数,用于指定一个中文字符对应的token数,默认为0.6
enWeight:可选参数,用于指定一个英文文字符对应的token数,默认为0.3
大模型的上下文长度是以token为单位的,而不是以字符数为单位的,例如Deepseek最大上下文长度为1M,就是1M个token,而不是1M个字符数。
根据DeepSeek官方文档,token与字符的换算关系为:
1 个中文字符 ≈ 0.6 个 token
1 个英文字符 ≈ 0.3 个 token
反过来算:1M token 大约对应 1.67M 个中文字符,或 3.33M 个英文字符。
可以根据不同的模型调整cnWeight和enWeight的值,对于DeepSeek来说,一个中文字符大约0.6个token,一个英文字符大约0.3个token,这是一个偏保守(安全)的预估值,据此计算出来的token数通常比实际的token数大。
修改发送按钮代码
之前我们设计了多个AI对话框,为了确保持续对话过程中,上下文长度不会超过限制,可以修改发送按钮的代码,将发送请求的代码改为:
' ----
4. 发送请求
----
e.Sender.Enabled =
False
Dim
body
As
String
= caller.GetRequestBody()
'获取请求体
if
caller.EstimateTokenCount(body) > 900 * 1000
'如果请求体长度大于900K
caller.TrimMessages(10)
'清理上下文,保留最近10轮对话内容,其余内容会从上下文删除
End
If
Await
caller.GetResultAsync()
'请不要漏掉Await关键词
e.Sender.Enabled =
True
这里假定大模型上下文长度是1MToken,当上下文长度超过900K时开始清理上下文,留出100K上下文是给新一轮对话使用的。
如果你希望在不超过上下文长度限制的情况下,尽可能保留更多轮次的对话(以提高对话质量),
同时保留的上下文长度也不希望太长(避免频繁清理),可以将发送请求的代码改为:
' ----
4. 发送请求
----
e.Sender.Enabled =
False
Dim
body
As
String
= caller.GetRequestBody()
'获取请求体
Dim
n
As
Integer
= 50
'最多保留50轮旧对话
Dim
preserveTokens
As
Integer
= 300 * 1000
'最多保留300KToken的上下文
If
caller.EstimateTokenCount(body) > 900 * 1000
Then'如果请求体长度大于900K,开始清理上下文
While
caller.EstimateTokenCount(body) > preserveTokens
'每次清理后保留的旧上下文不超过300K
caller.TrimMessages(n)
'清理上下文,保留最近n轮对话内容,其余内容会从上下文删除
If
n = 1
Then
Exit
While
End
If
n = Math.Max(1, n - 5)
body = caller.GetRequestBody()
'再次获取请求体
End
While
End
If
Await
caller.GetResultAsync()
'请不要漏掉Await关键词
e.Sender.Enabled =
True
这里将上下文的长度大约限在300K到900K之间,且最多保留50轮对话,大家可根据需要自行调整相关数值
为什么不能频繁清理上下文
上下文清理的阈值设置,不仅关系到 API 调用能否成功,还直接影响成本。
阈值越小,清理越频繁,上下文长度越小,对话质量就会下降,大模型会更容易出现幻觉。
你可能觉得阈值越小,虽然会影响对话质量,但因为每次请求的上下文长度也小了,所以成本会下降,实际却不一定是这么回事。
因为DeepSeek
的上下文缓存机制(主流大模型大多支持类似的缓存功能),使得“大阈值”、“少清理”反而可能更省钱。
DeepSeek 的上下文缓存是自动开启的,命中规则基于前缀完整匹配。
在多轮对话中,Messages 是逐轮叠加的:第 n 轮请求包含了第 1 到 n-1 轮的全部历史,以及第 n 轮的新问题。对于 DeepSeek 的 API
来说:
第 1 到 n-1 轮的历史内容,构成了当前请求的前缀。由于这部分内容与上一轮请求完全一致,可以完整匹配上一次落盘的缓存,从而命中缓存。
第 n 轮的新问题(以及模型生成的回答),是新增内容,位于前缀之后,是全新的文本,需要按标准价格(未命中缓存)计费。
命中缓存和未命中缓存,价格差异极大,以 deepseek-flash 模型、空闲时段为例:
|
计费项 |
价格(元 / 百万 Token) |
|---|---|
|
输入(缓存命中) |
0.02 |
|
输入(缓存未命中) |
1 |
|
输出 |
4 |
缓存命中的输入价格仅为未命中的 1/50。在多轮对话中,随着轮次增加,历史上下文越来越长,但这部分“重复”的内容始终能以极低的价格命中缓存。你每一轮真正需要按标准价格付费的,只是本轮新增的提问和答案。
理解了缓存机制后,阈值设置就有了成本层面的考量:
|
策略 |
缓存命中 |
成本 |
|---|---|---|
|
阈值较高,尽量不清理 |
历史完整保留,每轮都能命中缓存 |
有可能更低——只有新增内容按标准价计费 |
|
阈值较低,频繁清理 |
历史被删除,前缀改变,缓存失效 |
有可能更高——清理后的第一次请求的全部内容都按未命中价计费 |
频繁的清理,不仅影响对话质量,还可能会增加成本,所以在实践中,一般是尽可能少清理或不清理,保留完整历史,以维持高缓存命中率。
本节示例中阈值为900K个Token,接近1.5M个中文字符,意味着大多数情况下不会触发清理,从而保护了缓存命中率,同时为保持对话质量,清理后保留了50轮对话(或300K)的上下文。
在 DeepSeek 的缓存机制下,保持上下文完整(高阈值或不清理)通常是成本最优的策略。TrimMessages
应被视为一个“防超限保险”,而非日常优化手段。阈值设置需要在“避免请求失败”和“维持缓存命中率”之间取得平衡。
注意上面关于成本的讨论,没有任何确定的结论,用的是"通常"、"可能",一切都以实际测试为准。
分析token消费
我们可以通过LLMCaller的ResultJObject属性,获取每次请求的完整信息,参考:获取更多信息
假定某次请求后通过下面的代码:
Output.Show(caller.ResultJObject.ToString())
获得返回的具体信息为:
{
"id": "42f102d4-266a-40c5-a3d2-da17fcd62e8c",
"object": "chat.completion",
"created": 1791599109,
"model": "deepseek-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "85500 再加 5
得 855050,再减 1 最终等于
**8550490**。",
"reasoning_content": "Result 8550490. Answer."
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1175,
"completion_tokens": 34,
"total_tokens": 1209,
"prompt_tokens_details": {
"cached_tokens": 1024
},
"completion_tokens_details": {
"reasoning_tokens": 8
},
"prompt_cache_hit_tokens": 1024,
"prompt_cache_miss_tokens": 151
},
"system_fingerprint": "aeb56401ca74e127821c4f9126dcb669"
}
通过上面的信息可以看到token消费的数据:
总 token 数:total_tokens = 1209
它由 输入(prompt) 和 输出(completion) 两部分组成:
输入:prompt_tokens = 1175
输出:completion_tokens = 34
1175 + 34 = 1209
输入消费的1175个token包括:
系统提示(system)
工具定义(tools)
历史消息(messages)
本次最新的用户消息
这个1175个token又分为缓存命中和缓存未命中:
prompt_cache_hit_tokens = 1024(命中缓存)
prompt_cache_miss_tokens = 151(未命中缓存)
1024 + 151 = 1175
缓存命中率 = 1024 / 1175 ≈ 87.1%,说明这次请求的大部分前缀(很可能是系统提示、工具定义和之前的对话历史)都被成功复用了。