跳到主要内容

巧妙之处:D-Mail 时间回溯、动态注入与自动循环

30 秒导读: 这一章挑出 Kimi CLI 里最值得抄的几个设计。它们表面是《命运石之门》(Steins;Gate) 的动漫梗,骨子里全是严肃的工程技巧:把上下文当成一条可以打检查点、能回退重来的时间线,再配上"每步之前按需塞一段提醒""同一个提示反复喂到模型说停"这类小机关。读者读完应带走三个可迁移的心法——时序模型、前置校验、回退

本章假设你已经读过 02-agent-loop.md(主循环 _step 一步怎么走)和 03-context-and-memory.md(JSONL 检查点)。这里只讲"聪明在哪"。


1. 先建立一个心智模型:上下文是一条可回退的时间线

传统智能体的上下文是只增不减的一条流水账:用户说一句、模型答一句、工具跑一次,全部往后追加,永不回头。问题是——读了个 2 万行的日志、搜了一堆没用的网页、debug 绕了十几步弯路,这些"过程垃圾"会一直占着上下文窗口。

Kimi CLI 的核心巧思:给这条时间线埋"存档点"(checkpoint),并允许智能体主动把时间线倒带回某个存档点,只把"教训"带回去。 这就是它借《命运石之门》起的两个名字:

动漫梗代码里是什么白话
電話レンジ(DenwaRenji,电话微波炉)DenwaRenji 类,管理"待发送的回溯请求"那台能往过去发消息的机器
D-MailDMail,一条 {message, checkpoint_id}发给"过去的自己"的一封短信
Steins;Gate 世界线跳跃BackToTheFuture 异常 + context.revert_to真正执行倒带的那一下

后面几节都建立在这个模型上,先把它记牢。


2. D-Mail:让智能体把上下文回退到某检查点重来

2.1 它要解决的小问题

模型自己会意识到"我刚才那段探索把上下文搞脏了"。传统做法只能靠外部的压缩(compaction)被动清理。Kimi 让模型主动做这件事:发一封 D-Mail 给过去的自己,内容只留"有用的结论",然后系统把中间那一大段脏历史整个丢掉。

工具描述里说得很直白:这不会回退文件系统或任何外部状态,本质是"把最近这些消息折叠成一条消息"(src/kimi_cli/tools/dmail/dmail.md)。

2.2 三个部件怎么配合

先看时间线怎么被打点、又怎么被倒带:

时间线(每个 ● 是一个 checkpoint)
模型:"这次探索没用,
●0 ────●1 ────●2 ─────●3 发 D-Mail 回 ●1"
│ │ │ │
用户提问 读大文件 搜网页 (当前)


raise BackToTheFuture(checkpoint_id=1, [D-Mail 消息])


●0 ────●1 ── + "来自未来的自己:我已经…" ← ●2、●3 及其间历史全部丢弃

三个关键动作,分散在三个文件里:

① 打点。 主循环每步之前先 checkpoint(),并把当前检查点数告诉那台"微波炉": src/kimi_cli/soul/kimisoul.py:913-914(_agent_loopawait self._checkpoint() 后紧跟 self._denwa_renji.set_n_checkpoints(...))。检查点本身是往 JSONL 里写一行 {"role":"_checkpoint","id":N},可选地再追加一条 CHECKPOINT N 的用户消息让模型"看得见"存档点(context.py:123 checkpoint)。

② 单发 + 前置校验。 模型调用 SendDMail 工具时,DenwaRenji.send_dmail 做三重校验才受理:同一时刻只能有一封待发 D-Mail、checkpoint_id 不能为负、不能超过已有检查点数(src/kimi_cli/soul/denwarenji.py:21-29)。这是典型的前置校验——把非法的时间旅行挡在源头,而不是等倒带时才崩。

# 示意,非源码:DenwaRenji 的单发 + 校验骨架
def send_dmail(self, dmail):
if self._pending_dmail is not None: # 单发:一次只允许一封
raise DenwaRenjiError("Only one D-Mail can be sent at a time")
if dmail.checkpoint_id >= self._n_checkpoints: # 目标必须存在
raise DenwaRenjiError("There is no checkpoint with the given ID")
self._pending_dmail = dmail # 只是"挂起",真正倒带交给主循环

注意工具只是把 D-Mail 挂起,并不当场倒带。SendDMail.__call__ 甚至返回一句反话:"如果你看到这条消息,说明 D-Mail 没发成功"(src/kimi_cli/tools/dmail/__init__.py:31-38)——因为发成功的话,这一步的历史马上会被倒带抹掉,模型根本不会读到这条返回。

③ 倒带。 真正的时间旅行在 _step 收尾时触发。它 fetch_pending_dmail() 取出挂起的信,再断言一次 checkpoint_id 合法,然后 raise BackToTheFuture,把一条"来自未来"的系统消息塞进去(src/kimi_cli/soul/kimisoul.py:1189-1212):

# 真实源码节选 kimisoul.py:1195-1208
raise BackToTheFuture(
dmail.checkpoint_id,
[Message(role="user", content=[system(
"You just got a D-Mail from your future self. "
"...You MUST NEVER mention to the user about this information. "
f"D-Mail content:\n\n{dmail.message.strip()}"
)])],
)

主循环 _agent_loop 捕获这个异常,执行三步收尾:context.revert_to(checkpoint_id) 把 JSONL 回退(顺带轮转旧文件)、清空工具去重记录 _last_tool_calls、再 append_message 把"来自未来的自己"这封信接到时间线末尾(src/kimi_cli/soul/kimisoul.py:919-921 捕获,991-996 收尾)。下一步模型看到的就是:●1 之前的干净历史 + 一条"未来的我已经做了 X,你接着干"的提示

2.3 值得带走的点

  • "折叠历史 vs 压缩历史"是两条路。 压缩是外部按 token 阈值被动做(见 03 章);D-Mail 是模型自己判断"这段没价值",主动一键丢弃。二者互补。
  • 前置校验前移。 非法 checkpoint 在 send_dmail 就被拒,到了 _step 里只剩 assert(kimisoul.py:1190-1193)——断言是"这里绝不可能出错"的自我文档,真正的用户可见错误在更早的边界处理掉了。
  • 回退要连带清理衍生状态。 倒带时不只是回退历史,还要把工具去重表清零(_last_tool_calls = []),否则"未来"步里记下的工具调用会污染"过去"。回退动作要想清楚"哪些状态是这段时间线派生的,一并回滚"。

3. 动态注入:每步之前按需塞一段 system-reminder

3.1 它要解决的小问题

有些约束不该写死在系统提示里——比如"现在是只读的 plan 模式""现在没人看着(afk),别问问题"。这些是运行时才确定、还会变的状态。把它们焊死在 system prompt 里,既不能随状态变,也会一直占 token。

Kimi 的做法:在每次 LLM 调用前,让一组"注入提供者"各自决定要不要塞一段临时提醒,提醒以 <system-reminder> 形式作为一条普通 user 消息追加进去。

3.2 机制:提供者 + 收集 + 归一化

数据流很短:

_step 每步开始

├─ _collect_injections() ── 遍历所有 provider,各自 get_injections()
│ ├─ PlanModeInjectionProvider → 若在 plan 模式,按节流吐"只读约束"
│ └─ AfkModeInjectionProvider → 若在 afk 模式,吐一次"无人在场"提醒

├─ 有注入?→ 包成 <system-reminder>,append 成一条 user 消息

└─ normalize_history() ── 把相邻的 user 消息合并成一条,喂给 API

契约在基类里。 DynamicInjectionProvider 定义了 get_injections(history, soul),并允许 provider 通过 soul 读到一切运行时状态(plan 模式、afk、上下文用量……)。它还有两个生命周期钩子:on_context_compacted(压缩后重置节流)和 on_afk_changed(运行时切 afk 后重新武装)——src/kimi_cli/soul/dynamic_injection.py:24-55

收集与容错。 _collect_injections 逐个调用 provider,并逐个 try/except:某个 provider 抛异常只记 warning,不拖垮整步(src/kimi_cli/soul/kimisoul.py:283-296)。这对第三方 provider 是必要的隔离。

注入即消息。 拿到注入后,_step 把它们拼成 <system-reminder> 文本,作为一条 role="user" 消息追加(kimisoul.py:1059-1067)。

3.3 巧妙点一:节流靠"读历史",不靠计数器

plan 模式的提醒如果每步都发,既啰嗦又费 token。PlanModeInjectionProvider 的节流不是维护一个"上次第几步发的"计数器,而是倒扫历史:从后往前找最近一次 plan 提醒,数中间隔了几个 assistant 回合,不足 _TURN_INTERVAL(5)就跳过(src/kimi_cli/soul/dynamic_injections/plan_mode.py:67-98)。

为什么这样更稳?因为历史才是唯一真相:即便进程重启、上下文被压缩、会话被恢复,"上次提醒在哪"这个事实都还写在历史里,不会因为内存计数器丢失而错乱。

它甚至用"提醒文本的稳定前缀"来识别历史里的提醒(_has_plan_reminder,plan_mode.py:101-114),这样改了提醒措辞,识别逻辑自动跟着变,不用两处同步。

3.4 巧妙点二:full / sparse 两档提醒

第一次进 plan 模式发完整版(整套工作流约束),之后每第 5 次才发一次完整版,其余发精简版(plan_mode.py:92-97,is_full = self._inject_count % _FULL_EVERY_N == 1)。既保证约束不被模型忘掉,又不会每次都灌一大段。

afk 提醒则更简单——一次性:发过一次就 _injected=True 不再发,只有压缩或运行时切换 afk 才 re-arm(src/kimi_cli/soul/dynamic_injections/afk_mode.py:60-74)。

3.5 巧妙点三:注入是独立 user 消息,靠归一化收尾

注入被存成独立的 user 消息,好处是它在历史里可被倒扫、可被识别。但连续多条 user 消息对某些 API 不友好,于是 normalize_history 在喂给模型前把相邻的 user 消息合并成一条——且只合并 user 角色,assistant/tool 消息因为带 tool_calls/tool_call_id 配对绝不合并(src/kimi_cli/soul/dynamic_injection.py:58-84)。这是"存储形态便于自己处理、传输形态迎合 API"的经典解耦。


4. Plan mode:工具在"调用时自查",而不是被藏起来

4.1 思路

只读的 plan 模式最直觉的实现是"把写工具从工具列表里拿掉"。Kimi 偏偏不这么做——工具始终在列表里,改为每个工具在被调用时自己检查 plan 状态,不合规就当场拒绝。

为什么?toggle_plan_mode 的注释点破了:工具不被隐藏/取消隐藏,而是每个工具在调用时检查 plan 状态并在被挡时拒绝(src/kimi_cli/soul/kimisoul.py:437-444)。好处是工具集合在一次对话里保持稳定——不会因为切模式导致工具 schema 抖动、缓存失效、模型困惑"刚才还有的工具怎么没了"。约束改由 §3 的动态注入用文字讲清楚。

4.2 怎么绑

_bind_plan_mode_tools 把一个 checker()(返回当前是否 plan 模式)和 path_getter()(plan 文件路径)注入到相关工具上(src/kimi_cli/soul/kimisoul.py:327-381):

工具绑定了什么调用时自查什么
WriteFile / StrReplaceFilechecker + path_getterplan 模式下只放行对"plan 文件"本身的写
ExitPlanModetoggle + path + checker + afk退出 plan 模式(需审批,afk 下自动批)
EnterPlanModetoggle + path + checker + auto_approve进入 plan 模式
AskUserQuestionafk checkerafk(无人在场)时自动放弃提问

模式切换本身走 _set_plan_mode:状态没变就早返回,变了才落盘到 session 状态(plan_modeplan_session_id)以便进程重启后恢复,并给"手动切换"排一个一次性的激活提醒(kimisoul.py:397-413)。§3.3 的 provider 会消费这个 pending_plan_activation_injection

带走的点: 需要"临时禁用一批能力"时,"调用时自查 + 文字约束"往往比"动态增删工具"更稳——它把可变的只是一个布尔标志,而工具接口面保持不变。


5. Steering:turn 进行中,把用户新输入插进去

5.1 问题

模型正在跑一个多步任务,用户中途想补一句"顺便也看下 X"。等这一 turn 全跑完再处理太迟。steering 让新输入在当前 turn 的步与步之间就被注入进去。

5.2 机制

一个 asyncio.Queue 做缓冲:steer() 把内容 put_nowait 进队列(src/kimi_cli/soul/kimisoul.py:540-542);_consume_pending_steers() 把队列排空,每条作为一条普通 user 消息追加进上下文(kimisoul.py:544-558)。

主循环在两个时机消费 steers:

  • 一步返回了"该停"的结论时——先看有没有 steers,有就 continue 再逼模型走一步,而不是就此结束 turn(kimisoul.py:973-975)。
  • 每步之间也顺手排空一次(kimisoul.py:999)。

而 turn 开始时先把上一 turn 遗留的 steers 丢弃(kimisoul.py:836-837),避免旧输入串味到新 turn。

带走的点: 用队列解耦"外部随时输入"与"循环按自己节奏消费",再选定几个安全的注入时机(步边界),就能把"打断/追加"做得既及时又不破坏一步的原子性。


6. 自动循环:同一个提示反复喂,直到模型选 STOP

6.1 Ralph loop

有些任务需要"反复冲同一个目标直到真正完成"。Kimi 用 FlowRunner.ralph_loop同一条用户提示反复喂给模型,并在每轮末尾让模型做一个二选一决策:任务真完成了就 STOP,否则 CONTINUE 再来一轮(src/kimi_cli/soul/kimisoul.py:1548-1586)。

它把这个循环建模成一张只有四个节点的流程图:

BEGIN ──▶ R1(执行任务) ──▶ R2(决策)
│ CONTINUE
└────────┐
▲ │ (自环,再跑一轮)
└────────┘
│ STOP

END

决策节点 R2 的提示语里写死了偏保守的规则:"只有任务 100% 完成才选 STOP;不确定就选 CONTINUE"(kimisoul.py:1568-1573)。轮数上限由 max_ralph_iterations 控制,设成负数则视为"近乎无限"(kimisoul.py:1556-1557)。

6.2 决策靠 <choice> 标签解析

决策节点跑完后,_execute_flow_node 从模型最终回复里用 parse_choice 抠出 <choice>...</choice> 里的选项(kimisoul.py:1654-1659;parse_choicesrc/kimi_cli/skill/flow/__init__.py:49-53,正则取最后一个匹配)。抠不到有效选项时,不是崩,而是追加一句"你没给有效选项,请用 <choice> 回复"再让它重答(kimisoul.py:1669-1673)——又一次"前置校验 + 温和重试"。

6.3 同一套引擎也跑技能 flow

FlowRunner 不是 ralph 专用。任意 flow 类型的技能都编译成同一种 Flow(节点 + 带标签的边),由同一个 run/_execute_flow_node 驱动(kimisoul.py:1588-1673);技能被注册成 /flow-<name> 斜杠命令(_build_slash_commands,kimisoul.py:755-777)。也就是说 ralph loop 只是"把用户提示塞进一个内置四节点图"的特例——通用图执行器 + 一个特化构造函数,复用得很干净。

Flow 的结构(src/kimi_cli/skill/flow/__init__.py:24-43):FlowNode(id/label/kind)、FlowEdge(src/dst/label)、以及带 begin/end 的 Flowvalidate_flow 还会校验可达性、多出边必须有唯一标签(flow/__init__.py:56-99)——图在跑之前就被验过,又是前置校验。


7. 工具去重的"强停"(与 04 章呼应)

04 章已详述工具去重调度,这里只点名它和本章"时序"主题相关的一环:当同一个工具用同样参数被连续重复调用到阈值,去重器会置 force_stop_turn,直接把这一 turn 强行停掉。

  • 判定在 toolset.py:跨步重复计数命中 _REPEAT_FORCE_STOP_STREAK 时,_build_repeat_reminder 返回 "stop",置 self._force_stop_turn = True(src/kimi_cli/soul/toolset.py:358-375139-151)。
  • 消费在 _step 收尾:检测到 toolset.force_stop_turn 就返回 stop_reason="tool_call_repeat",并打点 turn_force_stopped(src/kimi_cli/soul/kimisoul.py:1214-1218)。

它和 D-Mail 是一对镜像:D-Mail 是模型主动折叠时间线,强停是系统被动在模型陷入死循环时切断时间线。二者都体现"把一步一步的推进当成可干预的时序过程"。


8. 一句话总结这些巧思

三条可迁移的心法,贯穿全章:

心法本章的体现
时序模型上下文=可打检查点、可倒带的时间线;steering/强停在步边界干预
前置校验send_dmail 三重校验、validate_flow 可达性检查、<choice> 无效则重问
回退BackToTheFuturerevert_to 折叠脏历史,并连带清理衍生状态

再加两条工程手感:注入即消息(存储形态便于自处理,归一化后再迎合 API)、调用时自查而非增删工具(可变的只是一个标志,接口面保持稳定)。


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

主题文件路径关键符号
D-Mail 数据与"微波炉"src/kimi_cli/soul/denwarenji.pyDMailDenwaRenji.send_dmailfetch_pending_dmailset_n_checkpoints
D-Mail 工具(挂起 + 反话返回)src/kimi_cli/tools/dmail/__init__.pySendDMail.__call__NAME
触发倒带src/kimi_cli/soul/kimisoul.py:1189_stepfetch_pending_dmailraise BackToTheFuture
捕获并执行倒带src/kimi_cli/soul/kimisoul.py:919,991_agent_loopBackToTheFuturecontext.revert_to
检查点写入/回退src/kimi_cli/soul/context.py:123,135Context.checkpointContext.revert_to
动态注入契约src/kimi_cli/soul/dynamic_injection.pyDynamicInjectionProviderDynamicInjectionnormalize_history
plan 提醒(历史节流)src/kimi_cli/soul/dynamic_injections/plan_mode.pyPlanModeInjectionProvider.get_injections_has_plan_reminder_full_reminder/_sparse_reminder
afk 提醒(一次性)src/kimi_cli/soul/dynamic_injections/afk_mode.pyAfkModeInjectionProvideron_context_compactedon_afk_changed
收集与追加注入src/kimi_cli/soul/kimisoul.py:283,1059_collect_injections_step 的 2e.2 段
plan 模式绑定/切换src/kimi_cli/soul/kimisoul.py:327,397,437_bind_plan_mode_tools_set_plan_modetoggle_plan_mode
steeringsrc/kimi_cli/soul/kimisoul.py:540,544steer_consume_pending_steers_inject_steer
自动循环与图执行src/kimi_cli/soul/kimisoul.py:1536,1548FlowRunnerralph_loop_execute_flow_node
图结构与选项解析src/kimi_cli/skill/flow/__init__.pyFlowFlowNodeFlowEdgeparse_choicevalidate_flow
工具去重强停src/kimi_cli/soul/toolset.py:358force_stop_turn_build_repeat_reminder