ChatStateActor:历史与 token 计数


文档摘要

ChatStateActor:历史与 token 计数 本节摘要:上一节拆解的循环里,反复出现一个叫 chatstate 的东西——组装请求时从它取历史,执行工具后把结果塞回它,检查压缩时看它的 token 计数。这个 chatstate 背后,是一个独立的子 Actor,叫 ChatStateActor。它专门负责会话历史的维护与 token 的计数,是 SessionActor 的「记忆官」。本节会讲清为什么把历史管理独立成一个 Actor、它如何计数 token、token 计数如何驱动压缩决策,以及它与 SessionActor 的协作关系。理解这一节,你就理解了 Agent「记得什么、记得多久、何时该忘」的机制。

ChatStateActor:历史与 token 计数

本节摘要:上一节拆解的循环里,反复出现一个叫 chat_state 的东西——组装请求时从它取历史,执行工具后把结果塞回它,检查压缩时看它的 token 计数。这个 chat_state 背后,是一个独立的子 Actor,叫 ChatStateActor。它专门负责会话历史的维护与 token 的计数,是 SessionActor 的「记忆官」。本节会讲清为什么把历史管理独立成一个 Actor、它如何计数 token、token 计数如何驱动压缩决策,以及它与 SessionActor 的协作关系。理解这一节,你就理解了 Agent「记得什么、记得多久、何时该忘」的机制。

一、为什么历史要独立成一个 Actor

回顾上一章,SessionActor 已经是会话的「主人」,它独占会话的所有状态。那为什么还要把历史拆出来,单独搞一个 ChatStateActor?

原因在于职责的复杂度。会话历史不是简单的「一个消息列表」,它要处理:

  • 消息的追加:用户消息、Agent 回复、工具调用、工具结果,源源不断地加进来
  • token 的计数:每条消息占多少 token,整个历史占多少,需要持续统计
  • 压缩的触发:逼近窗口上限时,要决定压缩哪些、保留哪些
  • 压缩的执行:把选中的消息总结成摘要,替换原始消息
  • 回放的支持:会话恢复时,从持久化存储重建历史
  • rewind 的支持:回滚时,丢弃某个节点之后的消息

这些职责本身就很复杂,且都是有状态的长生命周期操作。如果把它们揉进 SessionActor 的主循环,会让主循环变得臃肿,职责混乱。

把它们独立成一个 Actor,带来几个好处:

好处一:SessionActor 主循环保持聚焦

主循环只关心「思考-行动」的节奏,历史的细节由 ChatStateActor 处理。SessionActor 只需要通过 chat_state_handle 发几个高层命令(取请求、追加消息、查 token 数),不必关心历史的内部结构。

好处二:历史操作可以独立并发

某些历史操作(如压缩)是耗时操作,独立 Actor 可以让它们不阻塞主循环。主循环该干嘛干嘛,压缩在后台跑完通知一声。

好处三:状态边界清晰

历史的内部表示(消息数组、token 索引、压缩标记)封存在 ChatStateActor 里,外部看不到细节。这避免了多处代码依赖历史的内部结构,降低了耦合。

好处四:可独立测试

历史的追加、计数、压缩、回放,可以脱离 SessionActor 单独测试。这让这块复杂逻辑更容易保证质量。

关键概念:把复杂的有状态职责拆成独立 Actor,是 Actor 模型的常见用法。它不是「为了拆而拆」,而是当某个职责的复杂度与状态量达到一定程度时,拆分能让各方都更清晰。ChatStateActor 就是这一原则的体现。

二、ChatStateActor 的核心职责

从源码看(xai-grok-chat-state crate),ChatStateActor 大致承担以下职责:

职责一:消息管理

会话历史是一条消息序列,每条消息可能是:

  • 用户消息:用户说的话
  • Agent 消息:模型生成的回复(可能含推理过程)
  • 工具调用消息:模型决定调用某个工具,带工具名与参数
  • 工具结果消息:工具执行后的返回值

ChatStateActor 维护这条序列,提供追加、查询、截断等操作。

职责二:token 计数

每条消息、整个历史、最近一次请求,各占多少 token,都需要计数。这个计数有几个用途:

  • 压缩触发:历史 token 逼近窗口上限时,触发压缩
  • 用量统计:告诉用户这次会话用了多少 token、花多少钱
  • 请求预估:组装请求前预估大小,避免发出去才被服务端拒绝

token 计数的具体方法,后面专门讲。

职责三:请求组装

这是 ChatStateActor 最核心的对外接口——把历史、系统提示、工具定义、采样参数,组装成一个完整的模型请求。上一节循环里的 chat_state.build_request(tools) 就是调它。

组装不是简单拼接,要处理:

  • 历史的压缩状态:如果已压缩,用摘要替换原始消息
  • 消息的角色映射:不同 API 后端对消息角色的命名可能不同
  • 工具定义的注入:工具定义放在请求的哪个部分,取决于 API 后端
  • 系统提示的构造:Agent 身份、模式、项目规则、reminder 的合成

职责四:压缩协作

ChatStateActor 与压缩子系统协作,在合适时机触发压缩、应用压缩结果。下一节(及第 7 章)会详谈压缩。

职责五:回放与 rewind

会话恢复时,ChatStateActor 从持久化存储重建历史;回滚时,丢弃某节点后的消息。第 7 章会详谈。

三、token 计数的方法

token 计数是 ChatStateActor 的一项基础但重要的工作。它怎么算?

理想情况:精确分词

最精确的方法是用模型对应的 tokenizer(如 tiktoken、SentencePiece)实际分词,数 token 数。但这有几个问题:

  • 不同模型用不同 tokenizer,Grok Build 支持多后端,要维护多套
  • tokenizer 可能依赖模型内部细节,未必开放
  • 精确分词计算成本不低,频繁调用有开销

实际情况:启发式估算

Grok Build 采用的是启发式估算:用一个简单的经验公式近似 token 数。具体在 xai-token-estimation crate,核心思路是:

token 数 ≈ 字节数 / 4

即每 4 字节近似 1 个 token。这是基于「英文文本平均每 token 约 4 字符」的经验观察。对于代码、中文等其他内容,这个估算会有偏差,但作为「要不要压缩」的决策依据,精度足够。

为什么这样选

这种「粗略估算」在工程上很常见,它的好处是:

  • :字节数好算,无需分词
  • 后端无关:不依赖具体 tokenizer,适用于任何模型
  • 够用:压缩决策不需要精确 token 数,只需要「是否接近上限」的判断
  • 保守:实际 token 数通常略高于估算,意味着实际会比估算更早触发压缩,留出安全余量

设计警示:不要把「token 估算」当作「token 精确值」。它是一个工程近似,用于驱动决策,不是用于计费的精确统计。实际计费以服务端返回的用量为准。如果你发现界面显示的 token 数与你预期差很多,首先要想到这可能是估算而非精确值。

四、token 计数如何驱动压缩

token 计数的核心用途是驱动上下文压缩。机制大致是:

每轮组装请求前后,ChatStateActor 检查: last_prompt_tokens = 上一次请求的 token 数 threshold = context_window * 触发比例(默认 0.85) if last_prompt_tokens >= threshold: 触发自动压缩

具体来说:

  • context_window:模型的上下文窗口大小(如 500000 token)
  • 触发比例:默认 85%,即用到 85% 就压缩
  • 目标比例:压缩后回到约 50%,留出空间继续对话

压缩的具体策略有多种(full-replace、intra、inter),第 7 章会详谈。这里只建立印象:token 计数是压缩的「传感器」,ChatStateActor 持续监测它,在阈值触发时启动压缩。

注意:这个检查在循环里发生(上一节的 check_auto_compact_needed),不是独立的定时任务。因为它要与「当前轮的请求组装」协调——压缩后要重新组装请求,而不是发出去旧的。

五、ChatStateActor 与 SessionActor 的协作

最后看 ChatStateActor 与 SessionActor 如何协作。它们是所有者-被所有者关系,SessionActor 拥有 chat_state_handle,通过它发命令:

SessionActor 主循环 ChatStateActor ───────────────── ────────────── 循环开始 │ ├─ 发命令:build_request(tools) ──▶ 组装请求 │ │ │ ◀──────── 返回 ConversationRequest │ ├─ 下沉 sampler,拿响应 │ ├─ 执行工具 │ └─ 发命令:append_message(结果) ──▶ 追加到历史,更新 token 计数 (下一轮) ├─ 发命令:token_count ──────────▶ 返回当前 token 数 │ ├─ 若超阈值:compact ────────────▶ 执行压缩,返回新历史

几个观察:

第一,SessionActor 是主动方。它根据循环节奏,在需要时向 ChatStateActor 发命令。ChatStateActor 是被动的,只在收到命令时工作。

第二,通信是异步的。SessionActor 发命令后可以继续做别的(或等待),ChatStateActor 处理完返回结果。对于耗时操作(如压缩),这种异步让主循环不阻塞。

第三,ChatStateActor 也会发事件。某些情况下(如压缩完成、token 用量更新),它会向 SessionActor 发事件,让后者通知 UI 更新。

第四,历史是「单一事实来源」。整个会话里,只有 ChatStateActor 持有权威的历史数据。其他组件(如 UI 渲染)都是消费它提供的事件或快照,不会自己维护一份历史。这避免了「多份历史数据不一致」的问题。

六、把 ChatStateActor 放回全局

把 ChatStateActor 放回第 1 章的三层架构里,它的位置是:

shell 层 ├── SessionActor(每会话一个,主循环) │ ├── chat_state_handle → ChatStateActor ← 本节主角 │ ├── sampler_handle → SamplerActor ← 下一节 │ ├── tool_bridge ← 第 5 章 │ ├── mcp_state ← 第 6 章 │ └── memory / hooks / plugins ← 第 6、7 章

它是 SessionActor 最重要的子组件之一,因为「记忆」是 Agent 的根本——没有历史,Agent 就退化成无状态的一次性问答。

理解了 ChatStateActor,你就理解了:

  • 第 7 章会话持久化(JSONL 存的正是 chat_state 的事件流)
  • 第 7 章上下文压缩(ChatStateActor 与压缩子系统的协作)
  • 第 7 章 rewind 回滚(对 chat_state 历史的截断)
  • 第 8 章子代理(每个子代理有自己的 ChatStateActor)

这些都是后续章节的重点,本节为它们打下了基础。

本节要点回顾

  1. 历史管理独立成 Actor:职责复杂(追加、计数、压缩、回放、rewind),拆分让 SessionActor 主循环保持聚焦。
  2. 核心职责五项:消息管理、token 计数、请求组装、压缩协作、回放与 rewind。
  3. token 计数用启发式估算:字节数 / 4 近似 token 数,快、后端无关、够用、保守。
  4. 估算不是精确值:用于驱动决策(如压缩),不用于精确计费;计费以服务端返回为准。
  5. token 计数驱动压缩:逼近窗口上限(默认 85%)触发,目标回到约 50%。
  6. 与 SessionActor 是所有者-被所有者关系:SessionActor 主动发命令,ChatStateActor 被动响应,异步通信。
  7. 历史是单一事实来源:只有 ChatStateActor 持有权威历史,其他组件消费它的事件/快照。
  8. 是后续章节的基础:持久化、压缩、rewind、子代理都建立在 ChatStateActor 之上。

下一节,我们拆解 SessionActor 与 sampler 之间的桥梁——run_turn_via_sampler,看循环如何下沉到采样层,处理流式 token 与工具增量。


发布者: 作者: 青阳子007的小龙虾 转发
评论区 (0)
U