# Peri Code 的快照锚点——用 MessageId 截取增量消息

> **[Peri Code](https://github.com/konghayao/peri)** — 用 Rust 写的开源 Coding Agent，兼容 Claude Code 生态。<https://github.com/KonghaYao/peri>

Peri 的 Agent 跑一轮工具调用密集的 ReAct 循环（Agent 反复推理再调工具的过程），内部消息列表膨胀到 30 多条，其中 System 消息占了近一半——系统提示词、中间件注入的指令、技能加载的上下文。这些 System 消息是给模型看的内部指令，TUI（终端界面）一条都不需要。

最直觉的做法是每轮把整个消息列表序列化发给 TUI。Peri 没这么做。Agent 内部的消息列表比 TUI 需要看到的复杂得多，全量传输既浪费性能又容易把不该暴露的内部状态泄露出去。Peri 的做法是每轮只截取新增的消息推给 TUI，靠一个消息 ID 作为锚点来标记截取位置。TUI 那边再配合流式事件做实时渲染，两者合力维持 UI 和 Agent 的状态一致。

## 消息列表里混着不该给前端的 System 指令

Agent 的消息列表不只有对话。每轮 ReAct 循环开始时，17 个中间件会依次执行 `before_agent` 钩子，其中好几个会往消息列表头部插入 System 消息。系统提示词本身就是一条 System 消息，通过 `prepend_message` 插在列表最前面。技能预加载中间件会注入技能摘要，权限中间件会注入审批规则，上下文压缩中间件会注入文件摘要——全是 System 类型，全是头部插入。

这些 System 消息加起来可能有好几 KB 甚至十几 KB。它们是给模型读的上下文指令，告诉模型该怎么做、有哪些约束。TUI 拿到这些消息没有任何用处——用户不需要看到系统提示词的原文，也不需要看到中间件注入的规则。把 System 消息推给前端只是增加序列化和传输的开销。

更麻烦的是，头部插入会改变列表结构。如果把整个列表发给 TUI，TUI 还得自己判断哪些该渲染哪些该跳过。与其让前端做过滤，不如在源头就只发该发的。

## 每轮靠上次最后一条消息来截取增量

Peri 的做法是在每轮开始时记录消息列表最后一条消息的 ID。这个 ID 就是锚点——标记「上次发到这里了」。一轮结束后，从锚点位置往后取所有新增消息，过滤掉 System 类型，把剩下的推给 TUI。TUI 收到后追加到自己的已完成消息列表里，不做覆盖。

这个过程在代码里是一个叫 `index_after_id` 的函数。它遍历消息列表找到锚点 ID 对应的位置，返回下一个索引。从这个索引到列表末尾的所有非 System 消息，就是这轮该推给 TUI 的增量。

锚点在每轮结束时更新为列表最后一条消息的 ID，下一轮接着用。这样无论消息列表怎么变动，Agent 都能精确地知道上一次推了哪些消息、这次该从哪里接着推。

## 头部插入让序号失效所以改用消息 ID

数组索引做锚点行不通。中间件每轮都往消息列表头部插入 System 消息，插入后后面所有消息的位置全部右移。

`prepend_message` 的实现是 `insert(0)`——在列表最前面插入一条消息，后面所有消息的位置全部右移一位。假设上一轮结束时消息列表有 20 条，锚点记的是第 20 条。这一轮开始时中间件往头部插了 3 条 System 消息，原来的第 20 条现在变成了第 23 条。如果锚点存的是序号 20，`index_after_id` 从第 21 条开始取，就漏掉了被右移的那 3 条 System 消息后面的内容——或者更糟，取到了不该取的 System 消息。

消息 ID 是每条消息创建时生成的唯一标识符，和它在列表中的位置无关。不管列表怎么插入、删除、重排，ID 始终指向同一条消息。用 ID 做锚点，中间件往头部插多少条 System 消息都不会影响截取的准确性。

即使锚点截取的位置正确，增量范围内仍可能混入 System 消息——比如某些中间件在轮次进行中通过 `add_message` 往尾部追加 System 消息。所以所有快照推送点都加了一道 `.filter(|m| !m.is_system())` 过滤，确保无论什么情况，System 消息都不会出现在推给 TUI 的快照里。

## TUI 靠流式加快照来和 Agent 保持一致

增量快照解决了「该发什么」的问题，但光靠快照还不够。快照是在一个阶段结束后才推送的——工具调用完成时推一次，最终回答完成时推一次。用户在 LLM 流式输出文字的过程中需要实时看到打字效果，不能等整段回答结束才显示。

LLM 每输出一小段文字，Agent 就发一个 `TextChunk` 事件给 TUI，TUI 把文字追加到当前正在渲染的 AI 消息块里。工具调用开始时发 `ToolStart`，结束时发 `ToolEnd`。这些流式事件让用户实时感知到 Agent 在做什么。

流式事件是「尽力而为」的——它追求实时性，不保证完整性。网络抖动、事件队列满、UI 渲染延迟，都可能导致某些流式事件被丢弃。这没关系，因为 StateSnapshot（Agent 在阶段边界推送的完整消息快照）会在阶段结束时做一次最终核对。快照到了之后，TUI 把快照里的消息追加到已完成列表，同时清空当前的流式缓冲区。如果流式过程丢了一段文字，快照里那条完整的 AI 消息会把缺的补回来。

## 已发什么消息靠当前列表加锚点算出来

Peri 没有维护一个单独的「已推送给 TUI 的消息列表」。这种列表看起来简单——每次推送后更新它就行了——但每一条异常路径都得同步处理。用户取消执行时已发列表要回滚，`/rewind` 回退对话时要截断，上下文压缩替换整个消息列表时要全量重建。任何一个路径漏了，已发列表就和实际状态对不上，TUI 要么重复显示消息要么丢消息。

Peri 的做法只存一个 MessageId。需要知道「上次发到哪里」时，从当前消息列表里找到这个 ID 的位置，往后取就是全部新增。发送状态是从当前列表算出来的，不是额外存出来的。消息列表是唯一的数据源，锚点只是一个指针，不持有任何消息的副本。cancel、rewind、compact 这些路径各自修改消息列表，但不需要额外维护一个「已发」镜像——列表改了，下次截取自然就从新位置开始。

System 过滤用的也是同一个思路。消息没有打一个「是否可见」的标记位，而是在推送时用消息类型做运行时判断。新增一种消息类型不需要修改任何标记逻辑，只要它不是 System 类型就会自动被包含在快照里。可见性是计算出来的属性，不是预先存储的属性。

## 事件分三层来匹配各自的消费者

Agent 执行过程中产生的事件按消费者需求分成三层。第一层是流式事件——`TextChunk`、`ToolStart`、`ToolEnd`。特点是高频、实时、允许丢弃。消费者是 TUI 的渲染管线，用来做打字效果和工具状态展示。丢一个 `TextChunk` 只是少显示几个字，后面快照来了就补上。

第二层是状态快照——`StateSnapshot`。低频，只在阶段边界推送（工具批次完成、最终回答完成、`after_agent` 钩子结束）。它是 TUI 的权威数据源，到了就以它为准，覆盖流式过程的临时状态。

第三层是内部事件——`MessageAdded`、`LlmCallStart`、`LlmCallEnd`。它们携带的信息和前两层有重叠。`MessageAdded` 在 Agent 每次往消息列表写入新消息时触发，持久化层靠它把每条消息实时写入 SQLite。`LlmCallStart` 和 `LlmCallEnd` 携带每次模型调用的完整输入输出和 token 用量，Langfuse（一个 LLM 可观测性平台）靠它们记录 trace（调用链追踪数据）。TUI 在事件映射层直接丢弃这一层的所有事件，一个都不转发——StateSnapshot 已经覆盖了 TUI 需要的全部消息状态，`MessageAdded` 只会造成重复处理。

分层的意义在于隔离。TUI 的渲染逻辑改动不会影响持久化，持久化的存储格式调整不会影响 UI。如果将来加一个新的消费者，比如一个调试面板想看每次 LLM 调用的完整输入，只需要让它消费第三层事件，不用动前两层的任何代码。

内部那十几 KB 的 System 指令从头到尾不会泄露到前端。

---

项目地址：[github.com/konghayao/peri](https://github.com/konghayao/peri)
