ChatStateActor:历史与 token 计数 本节摘要:上一节拆解的循环里,反复出现一个叫 chatstate 的东西——组装请求时从它取历史,执行工具后把结果塞回它,检查压缩时看它的 token 计数。这个 chatstate 背后,是一个独立的子 Actor,叫 ChatStateActor。它专门负责会话历史的维护与 token 的计数,是 SessionActor 的「记忆官」。本节会讲清为什么把历史管理独立成一个 Actor、它如何计数 token、token 计数如何驱动压缩决策,以及它与 SessionActor 的协作关系。理解这一节,你就理解了 Agent「记得什么、记得多久、何时该忘」的机制。
本节摘要:上一节拆解的循环里,反复出现一个叫 chat_state 的东西——组装请求时从它取历史,执行工具后把结果塞回它,检查压缩时看它的 token 计数。这个 chat_state 背后,是一个独立的子 Actor,叫 ChatStateActor。它专门负责会话历史的维护与 token 的计数,是 SessionActor 的「记忆官」。本节会讲清为什么把历史管理独立成一个 Actor、它如何计数 token、token 计数如何驱动压缩决策,以及它与 SessionActor 的协作关系。理解这一节,你就理解了 Agent「记得什么、记得多久、何时该忘」的机制。
回顾上一章,SessionActor 已经是会话的「主人」,它独占会话的所有状态。那为什么还要把历史拆出来,单独搞一个 ChatStateActor?
原因在于职责的复杂度。会话历史不是简单的「一个消息列表」,它要处理:
这些职责本身就很复杂,且都是有状态的长生命周期操作。如果把它们揉进 SessionActor 的主循环,会让主循环变得臃肿,职责混乱。
把它们独立成一个 Actor,带来几个好处:
好处一:SessionActor 主循环保持聚焦
主循环只关心「思考-行动」的节奏,历史的细节由 ChatStateActor 处理。SessionActor 只需要通过 chat_state_handle 发几个高层命令(取请求、追加消息、查 token 数),不必关心历史的内部结构。
好处二:历史操作可以独立并发
某些历史操作(如压缩)是耗时操作,独立 Actor 可以让它们不阻塞主循环。主循环该干嘛干嘛,压缩在后台跑完通知一声。
好处三:状态边界清晰
历史的内部表示(消息数组、token 索引、压缩标记)封存在 ChatStateActor 里,外部看不到细节。这避免了多处代码依赖历史的内部结构,降低了耦合。
好处四:可独立测试
历史的追加、计数、压缩、回放,可以脱离 SessionActor 单独测试。这让这块复杂逻辑更容易保证质量。
关键概念:把复杂的有状态职责拆成独立 Actor,是 Actor 模型的常见用法。它不是「为了拆而拆」,而是当某个职责的复杂度与状态量达到一定程度时,拆分能让各方都更清晰。ChatStateActor 就是这一原则的体现。
从源码看(xai-grok-chat-state crate),ChatStateActor 大致承担以下职责:
会话历史是一条消息序列,每条消息可能是:
ChatStateActor 维护这条序列,提供追加、查询、截断等操作。
每条消息、整个历史、最近一次请求,各占多少 token,都需要计数。这个计数有几个用途:
token 计数的具体方法,后面专门讲。
这是 ChatStateActor 最核心的对外接口——把历史、系统提示、工具定义、采样参数,组装成一个完整的模型请求。上一节循环里的 chat_state.build_request(tools) 就是调它。
组装不是简单拼接,要处理:
ChatStateActor 与压缩子系统协作,在合适时机触发压缩、应用压缩结果。下一节(及第 7 章)会详谈压缩。
会话恢复时,ChatStateActor 从持久化存储重建历史;回滚时,丢弃某节点后的消息。第 7 章会详谈。
token 计数是 ChatStateActor 的一项基础但重要的工作。它怎么算?
理想情况:精确分词
最精确的方法是用模型对应的 tokenizer(如 tiktoken、SentencePiece)实际分词,数 token 数。但这有几个问题:
实际情况:启发式估算
Grok Build 采用的是启发式估算:用一个简单的经验公式近似 token 数。具体在 xai-token-estimation crate,核心思路是:
token 数 ≈ 字节数 / 4
即每 4 字节近似 1 个 token。这是基于「英文文本平均每 token 约 4 字符」的经验观察。对于代码、中文等其他内容,这个估算会有偏差,但作为「要不要压缩」的决策依据,精度足够。
为什么这样选
这种「粗略估算」在工程上很常见,它的好处是:
设计警示:不要把「token 估算」当作「token 精确值」。它是一个工程近似,用于驱动决策,不是用于计费的精确统计。实际计费以服务端返回的用量为准。如果你发现界面显示的 token 数与你预期差很多,首先要想到这可能是估算而非精确值。
token 计数的核心用途是驱动上下文压缩。机制大致是:
每轮组装请求前后,ChatStateActor 检查: last_prompt_tokens = 上一次请求的 token 数 threshold = context_window * 触发比例(默认 0.85) if last_prompt_tokens >= threshold: 触发自动压缩
具体来说:
压缩的具体策略有多种(full-replace、intra、inter),第 7 章会详谈。这里只建立印象:token 计数是压缩的「传感器」,ChatStateActor 持续监测它,在阈值触发时启动压缩。
注意:这个检查在循环里发生(上一节的 check_auto_compact_needed),不是独立的定时任务。因为它要与「当前轮的请求组装」协调——压缩后要重新组装请求,而不是发出去旧的。
最后看 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 放回第 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,你就理解了:
这些都是后续章节的重点,本节为它们打下了基础。
下一节,我们拆解 SessionActor 与 sampler 之间的桥梁——run_turn_via_sampler,看循环如何下沉到采样层,处理流式 token 与工具增量。