trim_messages_history() 的 token 估算在多模型/中文场景下可能存在较大偏差
关联:#202、#121、#219、#737
先说一下这个问题是怎么发现的
最近我自己在比较重度地用 GA,过程中一直感觉 token 消耗和 history 的增长有点不太符合预期,尤其是中文任务跑长以后比较明显。
一开始我也没确定是不是模型/API 本身的问题,所以直接让 GA 对自己的 context 管理逻辑做了一次自查。GA 最后把问题定位到了 llmcore.py 的 trim_messages_history():
def trim_messages_history(history, sess):
cap = sess.context_win * 3
target = int(cap * getattr(sess, 'trim_keep_rate', 0.6))
def cost(ms):
return sum(len(json.dumps(m, ensure_ascii=False)) for m in ms)
这里实际上是把:
作为固定估算。
之后我又拿这个问题和 GPT 讨论了一段时间,包括现在几个主流模型的 tokenizer、官方 token counting 方式,以及 GA 目前 _record_usage() 的实现。
讨论下来,我觉得原来把这个问题简单理解成“中文和英文 chars/token 不一样”其实还不太准确。
真正的问题可能是:
现在 trim_messages_history() 用一个固定的 character/token 比例,去代理所有模型、所有语言和所有类型内容的 token budget。
所以整理一下放到这里讨论。
目前的问题
以默认:
为例,现在:
意味着 history 大约达到:
以后才会触发 trim。
也就是说 context_win 看起来是一个 token budget,但真正执行裁剪时用的其实是 character budget。
如果刚好:
这个估算和当前 workload 接近,那没有什么问题。
问题在于实际使用时这个比例变化挺大的。
比如:
- 中文和英文差异很大;
- 不同模型 tokenizer 不一样;
- 同一个厂商的新旧模型 tokenizer 也可能变化;
- 普通自然语言、代码、JSON、tool call/result 的 token density 也不一样。
所以误差会有两个方向。
估高了:history 提前被裁
如果真实情况是:
那么 90k chars 实际还没有达到 30k tokens,GA 就已经开始 trim。
这会导致本来还能继续利用的 history 被提前删除。
估低了:history 太晚才裁
反过来,如果实际:
90k chars 可能早就已经超过 30k tokens。
这种情况在我的中文使用场景里比较值得注意,也和我最开始感觉“token 消耗不太对”的现象比较吻合。
模型 context 足够大时不一定直接报错,但 history 会比预期大很多,每轮 input token、延迟和成本也都会跟着增加。
所以我现在感觉,问题不一定是 3 这个数字取得不好,而是固定比例本身不太适合现在 GA 的多模型场景。
一开始我想的是按 CJK 单独处理,但后来感觉也不太够
GA 自查出来以后,我最开始的想法其实很简单:
英文用一个 chars/token
中文用另一个 chars/token
应该就能解决大部分问题。
但后来查了一下目前几个主流模型,感觉这样修可能很快又会遇到新的问题。
现在不同模型的情况大概是:
- OpenAI 有对应的 tokenizer,可以本地计算;
- Claude 有官方 token counting,而且不同代模型之间 tokenizer 本身也发生过变化;
- Gemini 有
countTokens;
- DeepSeek、Qwen 以及很多开放模型都有自己的 tokenizer。
所以:
中文 = x chars/token
英文 = y chars/token
本质上还是另外一种固定 heuristic。
它可能比现在的 / 3 好,但很难长期覆盖 GA 支持的所有模型。
尤其 GA 后面如果继续加 backend,这个映射表估计会越来越难维护。
另外还有一个我之前没注意到的问题
现在这里算的是:
len(json.dumps(history, ensure_ascii=False))
也就是 history 本身的字符数。
但 provider 最终统计 input_tokens 时,实际请求里通常还不只有 history,还会有类似:
system prompt
tools / tool schema
chat formatting
messages
provider-specific overhead
GA 又是一个 tool-heavy 的 agent,所以 tools/schema 这一部分应该也不能完全忽略。
因此就算把现在的:
len(json.dumps(history)) / 3
改成:
len(tokenizer.encode(json.dumps(history)))
应该也只是把 history 本身算得更准,并不一定等于一次真实 API request 的完整 input token。
所以我觉得这里最好还是把:
和:
actual request token usage
稍微区分一下。
一个比较有意思的点:GA 其实已经拿到了真实 usage
继续看 llmcore.py 时发现,现在 _record_usage() 已经会从 provider response 里面记录真实 token usage。
例如不同接口里的:
input_tokens
prompt_tokens
Claude 这边也会记录相关 input/cache token。
最后这些数据已经进:
了。
所以现在实际上有点像:
发送前
↓
用 3 chars/token 估算
↓
调用模型
↓
provider 返回真实 input token
↓
GA 已经记录真实值
↓
下一轮
↓
还是重新从 3 chars/token 开始估
看到这里以后,我觉得这些真实 usage 其实挺有价值的。
不一定非要每次都调用 tokenizer/count API,也可以考虑拿实际 usage 对 estimator 做校准。
当然这里也不能简单写成:
chars_per_token = history_chars / input_tokens
因为 input_tokens 里面还包含 system prompt、tools 等不是 history 的部分。
但至少可以把真实 usage 作为 calibration/debug signal,而不是完全不用。
我现在比较倾向的改法
我觉得第一步不用一下做成一个很复杂的 tokenizer 系统。
甚至不用马上引入 tiktoken、transformers 或各家的 SDK。
可能先把:
和 history trim 解耦就够了。
比如抽成:
def estimate_context_tokens(history, sess):
...
然后 trim_messages_history() 只负责:
current_tokens = estimate_context_tokens(history, sess)
if current_tokens > sess.context_win:
...
这样至少:
继续表达的是一个 token budget,
而不是在 trim_messages_history() 内部直接变成:
context_win * 3 characters
第一版甚至可以继续保持现在的行为
为了不把改动搞太大,我觉得第一版可以:
- 把现有
/ 3 逻辑抽成独立 estimator;
- 默认 estimator 仍然使用现在的 heuristic;
- 给 session/model 留一个覆盖 estimator 的入口;
- debug 时把 estimate 和 provider 返回的真实 usage 一起记录。
也就是说第一版合进去以后,默认行为甚至可以完全不变。
只是从:
trim_messages_history
│
└── 写死 / 3
变成:
trim_messages_history
│
▼
token estimator
│
├── 当前 heuristic
└── 后续可扩展
这样以后想继续做精确一点就比较容易。
比如可以逐步变成:
当前模型有可靠 tokenizer
→ 用 tokenizer
provider 有官方 count API
→ 接近阈值时做一次校准
有真实 API usage
→ 用于修正 estimator
以上都没有
→ fallback 到现有 heuristic
我觉得这样比现在直接去维护:
Claude 几 chars/token
GPT 几 chars/token
Qwen 几 chars/token
中文几 chars/token
英文几 chars/token
会更稳一点。
关于 context_win
这里我个人不太希望最后的解决方案变成让用户自己根据语言调 context_win。
比如:
英文用 30000
中文用 10000
换模型再重新调
因为我自己实际使用 GA 时会切模型,这样配置很容易越来越难理解。
我更希望它一直保持现在比较直观的语义:
context_win = GA 希望用于 history/context 管理的 token budget
具体当前模型下多少字符约等于这个 budget,应该尽量放到内部 estimator 解决。
这样用户切 Claude / OpenAI-compatible / DeepSeek / 其他模型时,不需要跟着手工换一套 context_win。
顺手发现的另一个类似问题
compress_history_tags() 这里:
同样是 character limit。
所以:
800 英文字符
800 中文字符
800 JSON/code 字符
实际保留下来的 token 数量也可能差很多。
不过这个我觉得可以先不放进第一版修改。
这个 issue 如果要做 PR,我倾向于先把:
解决/解耦掉。
compress_history_tags() 的 field truncation 后面可以单独处理,否则范围很容易越做越大。
如果按最小改动做
我现在想到的第一版大概就是:
- 把 character → token estimate 从
trim_messages_history() 抽出来;
- 默认保留现在的
/ 3 fallback;
- estimator 可以知道当前 session/model;
- debug 下记录 estimated token 和实际 API usage;
- 加几组中文、英文、中英混合、代码、JSON/tool result 的 case。
暂时不需要:
- 为所有 provider 引入 tokenizer SDK;
- 维护一张各模型
chars/token 表;
- 要求用户根据语言修改
context_win;
- 一次性把整个 context management 重构掉。
不知道我对这里的理解有没有遗漏,尤其是 context_win 当初乘 3 有没有其他设计考虑。
这个问题最开始只是我实际用 GA 时觉得 token 消耗有点奇怪,然后让 GA 自查才顺着找到这里;后面的 tokenizer 和实现方案是再借助 GPT 一起往下梳理的。
trim_messages_history()的 token 估算在多模型/中文场景下可能存在较大偏差关联:#202、#121、#219、#737
先说一下这个问题是怎么发现的
最近我自己在比较重度地用 GA,过程中一直感觉 token 消耗和 history 的增长有点不太符合预期,尤其是中文任务跑长以后比较明显。
一开始我也没确定是不是模型/API 本身的问题,所以直接让 GA 对自己的 context 管理逻辑做了一次自查。GA 最后把问题定位到了
llmcore.py的trim_messages_history():这里实际上是把:
作为固定估算。
之后我又拿这个问题和 GPT 讨论了一段时间,包括现在几个主流模型的 tokenizer、官方 token counting 方式,以及 GA 目前
_record_usage()的实现。讨论下来,我觉得原来把这个问题简单理解成“中文和英文 chars/token 不一样”其实还不太准确。
真正的问题可能是:
所以整理一下放到这里讨论。
目前的问题
以默认:
为例,现在:
意味着 history 大约达到:
以后才会触发 trim。
也就是说
context_win看起来是一个 token budget,但真正执行裁剪时用的其实是 character budget。如果刚好:
这个估算和当前 workload 接近,那没有什么问题。
问题在于实际使用时这个比例变化挺大的。
比如:
所以误差会有两个方向。
估高了:history 提前被裁
如果真实情况是:
那么 90k chars 实际还没有达到 30k tokens,GA 就已经开始 trim。
这会导致本来还能继续利用的 history 被提前删除。
估低了:history 太晚才裁
反过来,如果实际:
90k chars 可能早就已经超过 30k tokens。
这种情况在我的中文使用场景里比较值得注意,也和我最开始感觉“token 消耗不太对”的现象比较吻合。
模型 context 足够大时不一定直接报错,但 history 会比预期大很多,每轮 input token、延迟和成本也都会跟着增加。
所以我现在感觉,问题不一定是
3这个数字取得不好,而是固定比例本身不太适合现在 GA 的多模型场景。一开始我想的是按 CJK 单独处理,但后来感觉也不太够
GA 自查出来以后,我最开始的想法其实很简单:
应该就能解决大部分问题。
但后来查了一下目前几个主流模型,感觉这样修可能很快又会遇到新的问题。
现在不同模型的情况大概是:
countTokens;所以:
本质上还是另外一种固定 heuristic。
它可能比现在的
/ 3好,但很难长期覆盖 GA 支持的所有模型。尤其 GA 后面如果继续加 backend,这个映射表估计会越来越难维护。
另外还有一个我之前没注意到的问题
现在这里算的是:
也就是 history 本身的字符数。
但 provider 最终统计
input_tokens时,实际请求里通常还不只有 history,还会有类似:GA 又是一个 tool-heavy 的 agent,所以 tools/schema 这一部分应该也不能完全忽略。
因此就算把现在的:
改成:
应该也只是把 history 本身算得更准,并不一定等于一次真实 API request 的完整 input token。
所以我觉得这里最好还是把:
和:
稍微区分一下。
一个比较有意思的点:GA 其实已经拿到了真实 usage
继续看
llmcore.py时发现,现在_record_usage()已经会从 provider response 里面记录真实 token usage。例如不同接口里的:
Claude 这边也会记录相关 input/cache token。
最后这些数据已经进:
了。
所以现在实际上有点像:
看到这里以后,我觉得这些真实 usage 其实挺有价值的。
不一定非要每次都调用 tokenizer/count API,也可以考虑拿实际 usage 对 estimator 做校准。
当然这里也不能简单写成:
因为
input_tokens里面还包含 system prompt、tools 等不是 history 的部分。但至少可以把真实 usage 作为 calibration/debug signal,而不是完全不用。
我现在比较倾向的改法
我觉得第一步不用一下做成一个很复杂的 tokenizer 系统。
甚至不用马上引入
tiktoken、transformers或各家的 SDK。可能先把:
和 history trim 解耦就够了。
比如抽成:
然后
trim_messages_history()只负责:这样至少:
继续表达的是一个 token budget,
而不是在
trim_messages_history()内部直接变成:第一版甚至可以继续保持现在的行为
为了不把改动搞太大,我觉得第一版可以:
/ 3逻辑抽成独立 estimator;也就是说第一版合进去以后,默认行为甚至可以完全不变。
只是从:
变成:
这样以后想继续做精确一点就比较容易。
比如可以逐步变成:
我觉得这样比现在直接去维护:
会更稳一点。
关于
context_win这里我个人不太希望最后的解决方案变成让用户自己根据语言调
context_win。比如:
因为我自己实际使用 GA 时会切模型,这样配置很容易越来越难理解。
我更希望它一直保持现在比较直观的语义:
具体当前模型下多少字符约等于这个 budget,应该尽量放到内部 estimator 解决。
这样用户切 Claude / OpenAI-compatible / DeepSeek / 其他模型时,不需要跟着手工换一套
context_win。顺手发现的另一个类似问题
compress_history_tags()这里:同样是 character limit。
所以:
实际保留下来的 token 数量也可能差很多。
不过这个我觉得可以先不放进第一版修改。
这个 issue 如果要做 PR,我倾向于先把:
解决/解耦掉。
compress_history_tags()的 field truncation 后面可以单独处理,否则范围很容易越做越大。如果按最小改动做
我现在想到的第一版大概就是:
trim_messages_history()抽出来;/ 3fallback;暂时不需要:
chars/token表;context_win;不知道我对这里的理解有没有遗漏,尤其是
context_win当初乘 3 有没有其他设计考虑。这个问题最开始只是我实际用 GA 时觉得 token 消耗有点奇怪,然后让 GA 自查才顺着找到这里;后面的 tokenizer 和实现方案是再借助 GPT 一起往下梳理的。