# 会话状态 · chat-state 怎么存这盘对话

> 一整盘对话——用户消息、助手回复、工具调用与结果、推理块——怎么建模、怎么存、怎么在压缩与回退时安全改写。这一章拆 xai-chat-state：又一个无锁 actor，加一套「写时修复 + 惰性剪枝 + 双文件持久化」的历史管理。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/chat-state
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 会话状态 · chat-state 怎么存这盘对话

模型要「记得」前面聊了什么，靠的就是一份不断增长的对话历史。管这份历史的是 `crates/codegen/xai-chat-state`。

## 又一个无锁 actor

和[采样器](https://daiw.org/manual/grok-build/sampler-actor)一个套路：`ChatStateActor` 独占一个 tokio task 持有全部状态，外面只拿可克隆的 `ChatStateHandle`，发 `ChatStateCommand`——写是「发了不管」，读带一个 `oneshot` 回复通道。因为状态只有这一个 task 碰，历史就是一个**朴素的 `Vec`**，不用任何锁。

## 历史的原子单元：`ConversationItem`

历史不是「一个个 turn 对象」，而是一串扁平的 `ConversationItem`(定义在 `sampling-types/conversation.rs`)。一个「turn」只是其中一段连续的 items:

| item | 装什么 |
| --- | --- |
| `System` | system prompt |
| `User` | 用户消息(文本 + 图片)，可带 `synthetic_reason` |
| `Assistant` | 模型回复 + 它发起的工具调用 |
| `ToolResult` | 工具执行结果(`Arc<str>`，便于廉价替换) |
| `BackendToolCall` | 服务端执行的工具(web/x 搜索)，留着供 Responses API 续接 |
| `Reasoning` | 推理块，保留交错顺序以命中 KV 缓存 |

一个精妙处是 **`SyntheticReason`**：很多 `User` 项其实是运行时**合成注入**的，不是用户真打的——压缩摘要、`<system-reminder>`、AGENTS.md 项目指令、打断、子任务完成通知……都用这个枚举打标(如今已有十几种，还包括 goal 编排、`/loop` 定时任务、Stop hook 反馈等来源)。这样压缩、回放、统计就能把「真用户话」和「系统注入」分得清清楚楚。

## 写时修复，而不是读时

历史很容易出现「悬空工具调用」(assistant 说要调工具，结果还没回来就被截断)这类破损。grok 的策略是**只在写边界修复**：`ensure_conversation_integrity`(去重工具结果 + 修悬空调用)只在 `new`(崩溃恢复)、`push_user_message`、构建请求时跑(harness 主动叫停一轮后也会补修一次)，**绝不**在读处理器里跑——因为后台任务读到「在途」的调用会误判成悬空。这个「修复点选在哪」的取舍，是并发系统里很容易踩的坑。

## 惰性剪枝：让老工具结果「瘦身」

工具结果(尤其读大文件、跑长命令)会把历史撑爆。每推入一条用户消息，`prune_retained_conversation` 就把**够老的** `ToolResult` 内容硬清成一句占位符 `[Tool result omitted — too old]`(默认超过 10 个 turn，由 `[compaction.pruning]` 的 `hard_clear_age_turns` 控制)，既省内存又省磁盘。注意它按**真实 turn** 计龄(用合成项数量补偿阈值)，不会因为系统注入多就误伤。另外上下文用量过半时，构建请求还会对**发往 API 的那份副本**再剪一刀：最近 3 个 turn 不动，更老的超长结果(超过 4000 字符)只留头尾各 1500 字符。

## 双文件持久化

落盘分两份，各有用途：

- **`chat_history.jsonl`** —— 当前(已剪枝)的历史，会话恢复读它；
- **`updates.jsonl`** —— 只追加、留原始全文，供回放 / fork，也是 `transcript` 压缩模式下「按需回捞细节」的来源。

`ChatStateSnapshot` 是 fork/rewind 的载体(JSON 往返)，新字段都带 `#[serde(default)]` 保证旧快照能读。回退(rewind)就是 `truncate_to_prompt_index` 走到第 N 个 `User` 项截断。

<Callout type="info">
  **用法照进来**：[Sessions](https://daiw.org/manual/grok-build/sessions) 的保存、恢复、回退(rewind)，底层就是这里的快照与截断；至于压缩后还能让它「展开某段细节」，默认靠的是压缩时写下的 `compaction/` 分段文件(见[上下文压缩](https://daiw.org/manual/grok-build/compaction))。
</Callout>

## 和《从 LLM 到 Coding Agent》对照

对照 [session-history](https://daiw.org/manual/llm-to-agent/session-history)：最小实现里，历史就是一个 `messages` 数组，`append` 就完事。grok 的增量是——真实产品要处理：并发读写(actor)、历史破损(写时修复)、体量爆炸(惰性剪枝)、崩溃与 fork(双文件 + 快照)。同样是「存一盘对话」，生产实现要多扛这四件事。

---

上一篇：[流式解析与事件](https://daiw.org/manual/grok-build/streaming-events) · 下一篇：[system prompt 怎么组装](https://daiw.org/manual/grok-build/system-prompt)
