Skip to content

trim_messages_history() 的 token 估算在多模型/中文场景下可能存在较大偏差 #750

Description

@windcat2333

trim_messages_history() 的 token 估算在多模型/中文场景下可能存在较大偏差

关联:#202#121#219#737

先说一下这个问题是怎么发现的

最近我自己在比较重度地用 GA,过程中一直感觉 token 消耗和 history 的增长有点不太符合预期,尤其是中文任务跑长以后比较明显。

一开始我也没确定是不是模型/API 本身的问题,所以直接让 GA 对自己的 context 管理逻辑做了一次自查。GA 最后把问题定位到了 llmcore.pytrim_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)

这里实际上是把:

3 chars ≈ 1 token

作为固定估算。

之后我又拿这个问题和 GPT 讨论了一段时间,包括现在几个主流模型的 tokenizer、官方 token counting 方式,以及 GA 目前 _record_usage() 的实现。

讨论下来,我觉得原来把这个问题简单理解成“中文和英文 chars/token 不一样”其实还不太准确。

真正的问题可能是:

现在 trim_messages_history() 用一个固定的 character/token 比例,去代理所有模型、所有语言和所有类型内容的 token budget。

所以整理一下放到这里讨论。


目前的问题

以默认:

context_win = 30000

为例,现在:

cap = context_win * 3

意味着 history 大约达到:

90,000 chars

以后才会触发 trim。

也就是说 context_win 看起来是一个 token budget,但真正执行裁剪时用的其实是 character budget。

如果刚好:

3 chars/token

这个估算和当前 workload 接近,那没有什么问题。

问题在于实际使用时这个比例变化挺大的。

比如:

  • 中文和英文差异很大;
  • 不同模型 tokenizer 不一样;
  • 同一个厂商的新旧模型 tokenizer 也可能变化;
  • 普通自然语言、代码、JSON、tool call/result 的 token density 也不一样。

所以误差会有两个方向。

估高了:history 提前被裁

如果真实情况是:

> 3 chars/token

那么 90k chars 实际还没有达到 30k tokens,GA 就已经开始 trim。

这会导致本来还能继续利用的 history 被提前删除。

估低了:history 太晚才裁

反过来,如果实际:

< 3 chars/token

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。

所以我觉得这里最好还是把:

history size estimation

和:

actual request token usage

稍微区分一下。


一个比较有意思的点:GA 其实已经拿到了真实 usage

继续看 llmcore.py 时发现,现在 _record_usage() 已经会从 provider response 里面记录真实 token usage。

例如不同接口里的:

input_tokens
prompt_tokens

Claude 这边也会记录相关 input/cache token。

最后这些数据已经进:

STATS["inp"]

了。

所以现在实际上有点像:

发送前
  ↓
用 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 系统。

甚至不用马上引入 tiktokentransformers 或各家的 SDK。

可能先把:

len(json.dumps(...)) / 3

和 history trim 解耦就够了。

比如抽成:

def estimate_context_tokens(history, sess):
    ...

然后 trim_messages_history() 只负责:

current_tokens = estimate_context_tokens(history, sess)

if current_tokens > sess.context_win:
    ...

这样至少:

context_win

继续表达的是一个 token budget,

而不是在 trim_messages_history() 内部直接变成:

context_win * 3 characters

第一版甚至可以继续保持现在的行为

为了不把改动搞太大,我觉得第一版可以:

  1. 把现有 / 3 逻辑抽成独立 estimator;
  2. 默认 estimator 仍然使用现在的 heuristic;
  3. 给 session/model 留一个覆盖 estimator 的入口;
  4. 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() 这里:

max_len=800

同样是 character limit。

所以:

800 英文字符
800 中文字符
800 JSON/code 字符

实际保留下来的 token 数量也可能差很多。

不过这个我觉得可以先不放进第一版修改。

这个 issue 如果要做 PR,我倾向于先把:

history 总预算怎么估算

解决/解耦掉。

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 一起往下梳理的。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions