会话状态 · chat-state 怎么存这盘对话
一整盘对话——用户消息、助手回复、工具调用与结果、推理块——怎么建模、怎么存、怎么在压缩与回退时安全改写。这一章拆 xai-chat-state:又一个无锁 actor,加一套「写时修复 + 惰性剪枝 + 双文件持久化」的历史管理。
会话状态 · chat-state 怎么存这盘对话
模型要「记得」前面聊了什么,靠的就是一份不断增长的对话历史。管这份历史的是 crates/codegen/xai-chat-state。
又一个无锁 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 项截断。
和《从 LLM 到 Coding Agent》对照
对照 session-history:最小实现里,历史就是一个 messages 数组,append 就完事。grok 的增量是——真实产品要处理:并发读写(actor)、历史破损(写时修复)、体量爆炸(惰性剪枝)、崩溃与 fork(双文件 + 快照)。同样是「存一盘对话」,生产实现要多扛这四件事。
上一篇:流式解析与事件 · 下一篇:system prompt 怎么组装