跳到主要内容

上下文与记忆:JSONL 检查点与压缩

30 秒导读: 智能体每一步都要把"到目前为止发生的一切"喂给模型,这份"一切"就是它的记忆。 Kimi CLI 把记忆存成一份只追加(append-only)的 context.jsonl 文件,内存里的消息列表只是这份 文件的一个投影。检查点、回退(D-Mail 时间回溯)、上下文压缩——所有"改写历史"的动作,本质都是 把这份文件重放一遍、或轮转成新文件重写。读完你会有一个心智模型:磁盘即真相,内存是投影。

本章聚焦 Context(存储层)与 Compaction(瘦身层)两块,以及把它们缝在一起的 KimiSoul.compact_context。想先了解智能体主循环怎么调用它们,见 02-agent-loop.md; 检查点为什么能支撑"时间回溯",见 05-clever-mechanisms.md


1. 这是什么(零基础也能懂)

一句话定义: Context 是智能体的"记忆本"——一份记录了系统提示、每一条对话消息、每次真实 token 用量、每个检查点的流水账文件,加上它在内存里的一份镜像。

它要解决什么问题? 一个终端里的编码智能体会跑很久:读文件、改代码、跑测试、报错、再改…… 这中间攒下的对话会越来越长。于是有三个现实需求:

需求白话本章对应机制
持久化进程崩了 / 重开,记忆不能丢append-only context.jsonl + restore()
回退"刚才那步走错了,退回去重来"检查点 checkpoint + revert_to
瘦身对话太长塞不进上下文窗口了自动/手动压缩 compact_context

一句话直觉(记忆模型的类比): 把它想成一个只往后写的日记本。你从不涂改已经写下的页 ——要"回到过去",不是撕页,而是从第一页重新抄一遍、抄到某个书签就停,得到一本新日记。 旧日记留作备份(文件轮转)。这就是本章所有机制的共同底色。

用起来什么样? 使用者几乎感觉不到它——它在幕后运转:

  • 你正常对话,每条消息被悄悄追加进 context.jsonl;
  • 上下文快满时,状态栏出现"compacting…",一段 LLM 生成的摘要替换掉旧历史;
  • 你敲 /compact 可以手动触发一次压缩;
  • (若启用了 D-Mail 工具)智能体能把自己"送回"某个检查点重来。

本节不碰代码细节。记住一件事就够:真相在磁盘那份 .jsonl 里,内存里的 history 只是它的一次回放。


2. 顶层全景(它大概怎么转)

Context(src/kimi_cli/soul/context.py:20 Context)内部维护一份内存态,并把每一次变更 同步落盘context.jsonl。文件里不只有对话消息,还夹着几种**下划线开头的"特殊记录"**当元数据。

怎么读这张图: 上半是内存里的字段,下半是磁盘文件的一行行记录;中间的动词是把两者连起来的方法。

┌─────────────────── Context(内存投影)───────────────────┐
│ _history: list[Message] ← 喂给模型的对话 │
│ _system_prompt: str ← 系统提示 │
│ _token_count: int ← 上次 LLM 报的"精确"值 │
│ _pending_token_estimate: int ← 之后新增消息的"估算"值 │
│ _next_checkpoint_id: int ← 下一个检查点编号 │
└────────────────────────────────────────────────────────┘
restore() 重放 ↑ │ append_message / update_token_count
│ │ checkpoint (每次变更都落盘)
│ ▼
┌─────────────────────────────── context.jsonl(append-only,磁盘即真相)──────────────┐
│ {"role":"_system_prompt","content":"You are Kimi…"} ← 第一行,特殊记录 │
│ {"role":"_checkpoint","id":0} ← 书签:检查点 0 │
│ {"role":"user","content":[…]} ← 普通消息(Message) │
│ {"role":"assistant","content":[…]} │
│ {"role":"_usage","token_count":18734} ← 一次真实 token 用量快照 │
│ {"role":"_checkpoint","id":1} │
│ {"role":"user","content":[…]} ← 在 _usage 之后 → 只能"估算" │
└────────────────────────────────────────────────────────────────────────────────────────┘

四类记录的职责:

记录 role是什么落盘处恢复时怎么处理
_system_prompt系统提示(整份文件第一行)context.py:99 write_system_prompt_system_prompt(:293)
_checkpoint书签,标记一个可回退点context.py:129 checkpoint_next_checkpoint_id 推到 id+1(:325)
_usage一次真实 token 用量快照context.py:248 update_token_count_token_count,并清空"此后估算"缓冲(:313)
普通消息kosong.Message(user/assistant/tool)context.py:240 append_messageMessage.model_validate_history(:328)

主线走一遍(高层): 进程启动 → restore() 把整份 .jsonl 逐行重放,重建内存态 → 智能体每步 往文件追加消息与用量 → 上下文太长时 compact_context 把文件轮转成新的、只留摘要 → 需要 回退时 revert_to 把旧文件重放到某个检查点、写出一份截断的新文件。


3. 核心机制一:Context 存储模型(append-only + 重放)

3.1 它要解决的小问题

智能体随时可能崩溃或被重启,但用户希望"接着上次继续"。最省心的持久化方式不是数据库,而是 一个只往后写、永不回改的日志文件——写入简单(永远是 open(..., "a") 追加)、天然抗并发写坏、 崩溃后至多丢最后没写完的一行。代价是:想知道"现在的记忆长什么样",必须从头重放

3.2 落盘:每次变更都追加一行

append_message(context.py:232)是最常走的路径。它做三件事,顺序值得注意:

# src/kimi_cli/soul/context.py:232 append_message —— 真实逻辑的白话版(示意,非源码)
self._history.extend(messages) # 1. 更新内存投影
self._pending_token_estimate += estimate_text_tokens(messages) # 2. 累加"估算"token
async with aiofiles.open(self._file_backend, "a",) as f: # 3. 追加落盘
for m in messages:
await f.write(m.model_dump_json(exclude_none=True) + "\n")

重点看第 2 步: 新追加的消息还没经过一次真正的 LLM 调用,没人知道它们的精确 token 数,所以先用 一个字符数启发式估一个值挂账(见 §5)。落盘用 model_dump_json 一行一条,这就是 JSONL (每行一个 JSON 对象)。

3.3 恢复:restore() 把文件重放成内存态

restore(context.py:30)逐行读文件,_parse_context_line(:250)负责容错解析——坏行不致命: JSON 解析失败或不是对象就 logger.warning 跳过(:259),用 errors="replace" 打开以容忍编码破损。 每行交给 _apply_context_record(:276)按 role 分派到上表四类处理。

一个精妙细节藏在恢复的收尾:

# src/kimi_cli/soul/context.py:64 —— restore 收尾(示意,非源码)
self._pending_token_estimate = estimate_text_tokens(messages_after_last_usage)

messages_after_last_usage 是一个"自最后一条 _usage 记录以来新增的消息"的滑动缓冲:每遇到 _usage.clear()(:314),每遇到普通消息就 .append()(:338)。于是恢复结束时,它里面正好 是"最后一次精确计数之后、还没被计入的那些消息",据此重算 _pending_token_estimate。这保证了 重启前后 token_count_with_pending 一致。

3.4 两个 token 属性:精确值 + 估算值

Context 对外暴露两个 token 计数,分工明确:

属性定义语义
token_count(:72)_token_count精确——来自上一次 LLM 返回的真实用量
token_count_with_pending(:76)_token_count + _pending_token_estimate精确 + 对此后新增消息的估算

自动压缩判断用的是后者(kimisoul.py:894),因为它要在"下一次 LLM 调用之前"就预判上下文会不会超 ——那时新消息的精确 token 还拿不到,只能连估算一起算。何时估算、何时精确,§5 专门讲。

3.5 系统提示为什么单独处理

write_system_prompt(context.py:91)把系统提示写成文件第一行_system_prompt 记录。它比 append_message 麻烦:如果文件已有内容(老会话没写过系统提示),不能直接追加到末尾,而要原子地 前插——写到一个 .tmp 临时文件、把原内容分块拷过去、再 replace 覆盖(:106-117)。这样即便中途 崩溃也不会写坏原文件。压缩后重建历史时会再次调用它(见 §4.3)。


4. 核心机制二:检查点与回退(重放到书签)

4.1 它要解决的小问题

有时智能体需要"撤销刚才几步、回到一个干净的状态重来"——典型场景是 D-Mail 时间回溯 (见 05-clever-mechanisms.md):模型意识到走岔了,给"过去的自己"发一条 消息,让整个对话退回某个更早的点,带着新信息重跑。检查点就是这套机制的存档点

4.2 打点:checkpoint

checkpoint(context.py:123)只做两件小事:取一个自增 checkpoint_id,把 {"role":"_checkpoint","id":N} 追加落盘。它可选地再追加一条内容为 CHECKPOINT N 的 user 消息 (:130)——是否追加由 add_user_message 决定。

上层 KimiSoul每一步开始前都打一个检查点(kimisoul.py:913 _checkpoint),使得几乎每一步 都可回退。是否附带那条可见的 CHECKPOINT N 消息,取决于会话里有没有装 SendDMail 工具 (kimisoul.py:182-187 _checkpoint_with_user_message)——没有 D-Mail 就不必污染上下文。

4.3 回退:revert_to(轮转 + 重放到书签)

这是本章最能体现"磁盘即真相"的方法。revert_to(checkpoint_id)(context.py:135)分四步:

怎么读这张图: 从上到下是时间顺序;核心是"旧文件只读、重放到书签即停、命中的记录抄进新文件"。

① 校验 checkpoint_id < _next_checkpoint_id ? 否则 ValueError
② 轮转 next_available_rotation(context.jsonl) → context_1.jsonl(旧的挪走当备份)
aiofiles.os.replace(context.jsonl, context_1.jsonl)
③ 清空内存 _history / _token_count / _next_checkpoint_id / _system_prompt 全部归零
④ 重放并重写 逐行读 context_1.jsonl(旧):
├─ 命中 {"_checkpoint", id==目标} → break(到书签就停)
├─ _apply_context_record(...) 重建内存态
└─ keep_line 为真 → 把这一行原样写进新的 context.jsonl

关键点:_apply_context_record 返回一个 keep_line 布尔(:276),四类记录都返回 True(值得保留), 只有解析失败的坏行返回 False。所以"重放"同时也是"过滤式重写"——把旧文件里、书签之前的 有效记录,原样搬进一份崭新的 context.jsonl。旧文件 context_1.jsonl 留作备份。收尾同样用 messages_after_last_usage 重算 pending 估算(:200),与 restore 一致。

4.4 轮转:next_available_rotation 怎么保证不撞名

轮转靠 next_available_rotation(src/kimi_cli/utils/path.py:34)。它扫描同目录,匹配 context_<N>.jsonl 找到最大的 N,然后从 N+1O_CREAT|O_EXCL 原子地抢占一个空占位文件 (:20 _reserve_rotation_path)。O_EXCL 意味着"文件已存在就失败",于是即便有并发也不会两个动作 抢到同一个轮转名——抢不到就 next_num += 1 继续试(:54)。

4.5 清空:clear

clear(context.py:202)是"回退到最开始"的特例:同样轮转旧文件当备份,但不重放——直接 touch 出一个空的新 context.jsonl,内存态全部归零。注释点明它"几乎等价于 revert_to(0),但 不依赖第一个检查点一定存在"(:203)。压缩流程正是靠它先把历史清空,再写入摘要(见 §4.6)。

4.6 一句话串起来:回退是压缩与 D-Mail 的共同底座

checkpoint / revert_to / clear 三者不是孤立功能,而是上层两大机制的地基:

  • D-Mail 时间回溯(05 章)= revert_to(某检查点) + 注入一条新消息(kimisoul.py:991-996);
  • 上下文压缩(下节)= clear() 清空 + 重写系统提示 + 写入 LLM 摘要。

两者都在"改写历史",而底层都归结为对 append-only 文件的轮转与重放——这正是本章反复强调的心智模型。


5. 核心机制三:上下文压缩(把旧历史换成一段摘要)

5.1 它要解决的小问题

对话会无限增长,但模型的上下文窗口是有限的。压缩(compaction)= 把早期的一大段对话交给 LLM 摘要 成一小段,只保留最近几条原文,从而把 token 占用压下来、腾出空间继续干活。

5.2 什么时候自动触发:双条件

should_auto_compact(src/kimi_cli/soul/compaction.py:56)用两个条件、谁先满足谁触发:

# src/kimi_cli/soul/compaction.py:69 —— should_auto_compact(示意,非源码)
return (
token_count >= max_context_size * trigger_ratio # 比例触发:默认 85%
or token_count + reserved_context_size >= max_context_size # 预留触发:默认留 50k 给回复
)
条件默认值直觉
比例触发 trigger_ratio0.85(config.py:92)用到窗口 85% 就该瘦身了
预留触发 reserved_context_size50_000(config.py:88)无论比例,总要给模型这一轮的回复留够空间

主循环在每步开始、真正调用 LLM 之前token_count_with_pending(而非精确值)来判断 (kimisoul.py:893),因为它要预判"把新消息也算上会不会超"。

5.3 怎么压:SimpleCompaction

SimpleCompaction(compaction.py:103)分"准备"和"执行"两步。

prepare(:145):挑出保留区。 从后往前数,保留最近 max_preserved_messages 条(默认 2,:104) user/assistant 消息,更早的划入"待压缩区"。若够不到那么多条可保留,就干脆不压(:161)。待压缩 的每条消息被拼进一个大的 user 消息,并在末尾附上 prompts.COMPACT 摘要指令 (src/kimi_cli/prompts/compact.md,列出"当前任务状态 > 错误与解法 > 代码演进…"的压缩优先级)。 用户还能通过 custom_instruction 追加"这次压缩重点关注什么"(:181)。

compact(:107):调 LLM 生成摘要。kosong.step 发出那条拼好的消息,关键是配了一个 EmptyToolset()(:121)——摘要阶段不该调用任何工具,只要模型吐文字。返回的摘要里若带 "思考"部分(ThinkPart)会被丢弃(:136),最终拼成:一条 user 消息(摘要)+ 原样保留的最近几条。

压缩前: [_system][ckpt0][msg1][msg2]……[msg48][msg49][msg50]
└────── 待压缩(交给 LLM 摘要)──────┘ └─ 保留最近 2 条 ─┘
压缩后: [_system][ckpt0][摘要一条][msg49][msg50]

5.4 缝合层:compact_context 如何重写整份记忆

SimpleCompaction 只产出"新的消息序列",真正把它落成新记忆的是 KimiSoul.compact_context(src/kimi_cli/soul/kimisoul.py:1247)。它的编排顺序很讲究:

步骤代码做什么
① PreCompact 钩子kimisoul.py:1307触发 PreCompact hook,带上触发原因与压缩前 token 数
② 带重试地压缩:1320 _compact_with_retry用 tenacity 指数退避重试调用 LLM 摘要
清空历史:1333 self._context.clear()轮转旧文件、清空内存(见 §4.5)
重写系统提示:1334 write_system_prompt新文件第一行重新落系统提示
⑤ 新检查点:1335 _checkpoint在干净历史上打检查点 0
⑥ 写入摘要:1336 append_messagecompaction_result.messages 追加进去
⑦ 回填后台任务快照:1339-1353仅根智能体:把仍在跑的后台任务用 build_active_task_snapshot 补一条 system 消息
⑧ 盖上估算 token:1356 update_token_count(estimated)让状态栏不显示 0%(此刻没有真实用量,只能估)
⑨ 通知注入方:1362 _notify_injection_providers_compacted历史被重建,动态注入 provider 重置一次性节流状态
⑩ PostCompact 钩子:1381异步触发 PostCompact hook + 遥测

第⑦步是个容易忽略的正确性细节:压缩会抹掉"哪些后台任务还在跑"的记忆,所以根智能体要把活动任务 快照重新注入(background/summary.py:56 build_active_task_snapshot),否则模型压缩后就"忘了"自己 还有子任务在后台跑(子智能体/后台任务见 06-subagents-and-background.md)。 第⑨步失败被逐 provider 隔离(:298 _notify_injection_providers_compacted),一个坏 provider 不会中断压缩、跳过 CompactionEnd 事件。


6. 精华:token 计数何时精确、何时估算

这是本章最值得带走的一个"隐性契约"。系统里有两种 token 数,来源与可信度不同:

场景用哪个值来源精确度
一次 LLM 调用之后_token_countusage.input,模型 API 亲口报的(kimisoul.py:1143)精确
追加了新消息、还没再调 LLM_pending_token_estimateestimate_text_tokens 字符数启发式估算
判断是否自动压缩token_count_with_pending精确 + 估算 之和保守偏高
压缩刚结束、还没调 LLMestimated_token_count摘要用 usage.output 精确 + 保留区估算半精半估

估算函数极其朴素——约 4 个字符算 1 个 token:

# src/kimi_cli/soul/compaction.py:44 estimate_text_tokens(示意,非源码)
total_chars = sum(len(part.text) for m in messages for part in m.content
if isinstance(part, TextPart))
return total_chars // 4 # 英文≈4字符/token;中日韩会偏低估,但下次真实用量会纠正

设计上的自洽在于:估算只是临时的。每次真正的 LLM 调用回来,update_token_count(context.py:242) 就用精确的 usage.input 覆盖 _token_count、并把 _pending_token_estimate 归零(:245),同时落一条 _usage 记录。压缩结束时的 CompactionResult.estimated_token_count(compaction.py:22)也走同样思路: 摘要那条能拿到 LLM 的 usage.output(精确),保留区的几条只能估——但也会在下一次调用后被真实值取代。

一句话:估算保证了"没有真实数据时状态栏和压缩判断仍能工作",而每次 LLM 调用都会把估算拉回精确。


7. 边界与局限(诚实)

  • 压缩是有损的。 LLM 摘要会丢信息;prompts/compact.md 用一套优先级(当前任务 > 错误与解法 > …) 来减少损失,但不保证不丢。保留区只有默认 2 条最近消息(compaction.py:104)。
  • 估算对 CJK 偏低。 //4 的启发式按英文标定,中日韩文本真实 token 通常比字符数少不了 4 倍, 故估算偏低——代码注释明确承认这点(compaction.py:51),依赖下次真实用量纠偏。
  • 只有 TextPart 参与估算。 estimate_text_tokens 只数文本部分(compaction.py:49),图片等其它 内容部分不计入,估算会偏低。
  • 轮转文件不自动清理。 revert_to/clear/压缩都会留下 context_<N>.jsonl 备份,越攒越多;代码 里没看到自动回收(inferred,本章检索范围内未见清理逻辑)。
  • prepare 的一个退让分支。 当保留区就是全部消息、待压缩区为空时,直接返回不压并附注释 "Let's hope this won't exceed the context size limit"(compaction.py:168)——极端情况下压缩会"放弃"。

8. 代码地图(导航索引)

主题文件路径符号
记忆存储层(内存投影 + 落盘)src/kimi_cli/soul/context.py:20Context
恢复:重放整份 jsonlsrc/kimi_cli/soul/context.py:30Context.restore
追加消息(含 pending 估算)src/kimi_cli/soul/context.py:232Context.append_message
精确 token 落盘src/kimi_cli/soul/context.py:242Context.update_token_count
精确 vs 估算两个属性src/kimi_cli/soul/context.py:72token_count / token_count_with_pending
系统提示原子前插src/kimi_cli/soul/context.py:91Context.write_system_prompt
打检查点src/kimi_cli/soul/context.py:123Context.checkpoint
回退:轮转+重放到书签src/kimi_cli/soul/context.py:135Context.revert_to
清空(压缩前用)src/kimi_cli/soul/context.py:202Context.clear
按 role 分派记录src/kimi_cli/soul/context.py:276Context._apply_context_record
文件轮转(O_EXCL 抢占)src/kimi_cli/utils/path.py:34next_available_rotation
自动压缩双条件src/kimi_cli/soul/compaction.py:56should_auto_compact
字符数估算 tokensrc/kimi_cli/soul/compaction.py:44estimate_text_tokens
简单压缩策略src/kimi_cli/soul/compaction.py:103SimpleCompaction / prepare / compact
压缩结果与估算src/kimi_cli/soul/compaction.py:18CompactionResult.estimated_token_count
压缩编排(hook+清空+重写)src/kimi_cli/soul/kimisoul.py:1247KimiSoul.compact_context
自动压缩触发点src/kimi_cli/soul/kimisoul.py:893主循环 step 2c
每步打检查点src/kimi_cli/soul/kimisoul.py:537KimiSoul._checkpoint
D-Mail 回退调用点src/kimi_cli/soul/kimisoul.py:991主循环 revert_to 分支
活动后台任务快照src/kimi_cli/background/summary.py:56build_active_task_snapshot
压缩相关配置默认值src/kimi_cli/config.py:75LoopControl