第 9 周 · Tether 源码课

Session Events:把发生过的事写成不可变日志

预计 90 分钟 先修:第 1 周:能跑一次 ReplayLlm turn、第 5 周:Service seam、知道 JSON 与 immutable snapshot

学完你能做到

  • 区分权威 event log 与派生的 ChatMessage 历史
  • 读懂 SessionEvent 的 Type、Seq、Time、Data 与 metadata
  • 用 turn/start、step/start、step/end、turn/end 检查嵌套边界
  • 用 InMemorySessionTests 验证 snapshot、sequence 与 replay

课程进度

  1. 第 1 周
  2. 第 2 周
  3. 第 3 周
  4. 第 4 周
  5. 第 5 周
  6. 第 6 周
  7. 第 7 周
  8. 第 8 周
  9. 第 9 周
  10. 第 10 周
  11. 第 11 周
  12. 第 12 周
  13. 第 13 周
  14. 第 14 周
  15. 第 15 周
  16. 第 16 周

一句话先懂

Session 不保存“当前对话状态”,而是按顺序保存“发生过哪些不可变事实”;当前状态随时从这些事实重放出来。

AppendAsync 只在尾部提交新的 SessionEvent,DeriveMessages() 则把完整日志折叠成此刻给模型看的消息历史。

这是一条重要分界:如果把 List<ChatMessage> 当作唯一事实,工具何时开始、turn 为什么失败、恢复时缺了哪一半都很难回答。Tether 保留更细的 event,再根据用途生成 view。

先看大图

权威事实 · append-only log seq 0 turn/start seq 1 step/start seq 2 user/messageSurface Append seq 3 assistant/messageSurface Append seq 4 step/end seq 5 turn/end Fold 模型可见面 · derived view User来自 seq 2 Assistant来自 seq 3 生命周期事件仍在日志中,只是不投影成 ChatMessage 同一份事实还可重放为:恢复 · 审计 · 统计 · 标题 · 遥测 view 可以重建,log 才是 authority
“不修改旧事件”不等于“界面永远不能变化”;后续 event 可以让投影产生新的可见结果,旧事实仍保留。

一个类比:不可擦除的实验记录本

做实验时,不把昨天写的温度擦掉改成今天的,而是在下一行记录新测量与修正原因。最终报告可以从记录本算平均值、画曲线;报告丢了还能重算,原始记录丢了就无法证明发生过什么。

Tether 中:

  • SessionEvent 是一行带编号、时间和 typed JSON payload 的记录。
  • SessionEventType<T> 是这类记录的说明书:稳定 name、payload type、校验与可选 message projection。
  • DeriveMessages() 是给模型使用的一份报告,不是另一份权威存储。
  • Projection、持久化与恢复都从同一条 log 读取事实。

类比边界:event log 不是纸本,也不保证所有 observer 同步完成。数据库的事务、physical schema、write-behind 和崩溃修复属于持久化层;本周只讨论 ISession 的逻辑提交与投影语义。

Event envelope 里有什么

在 src/Tether.Core.Contracts/SessionEvent.cs 中,每条 event 包含:

字段含义为什么需要
Type稳定事件名,例如 user/message跨进程和跨版本识别事实
Seqsession 内从 0 开始的连续编号排序、引用与发现 gap
TimeUnix milliseconds时间分析;不替代 Seq 顺序
Datadetached JsonElement snapshot原对象之后变动也不能改写历史
Ignorable老 reader 遇到未知 type 能否跳过;由 descriptor 的 required-on-read 派生,不能在写入时反言演进兼容信号
SurfaceOp对模型可见面的 Append / Replace把事实折叠成消息顺序
SourceEventSeqs产生本 event 的更早 seq保留派生关系

payload 不能用随意的 object 读回。调用 evt.ReadData(SessionEventTypes.TurnStart) 时,descriptor name 必须与 evt.Type 相同,才会反序列化并重新校验。

AppendAsync 的精确完成含义

阅读 src/Tether.Core.Session/InMemorySession.cs 的主链:

validate descriptor + snapshot payload
  → 进入 append gate
  → 分配连续 seq 与 timestamp
  → 校验 candidate log
  → commit 到 _events,旧 snapshot cache 失效
  → 发出 contained session/event notification
  → 返回已提交 event

三条容易混淆的边界:

  1. payload 在 commit 前深拷贝;之后修改原 ChatMessage 不改变 log。
  2. observer failure 不回滚已提交 event,而是由 contained event path 上报。
  3. observer 不能在 notification 期间对同一个 session重入 append;这会破坏单一顺序,因而立即失败。

Turn 与 Step 是严格嵌套的括号

turn/start 1
  step/start 1.1
    user/message
    assistant/message(可能声明 tool call)
    tool/call → tool/result
  step/end 1.1
  step/start 1.2(工具结果回给模型时)
    assistant/message
  step/end 1.2
turn/end 1

turn 是一轮 inbox 工作的外边界;step 是一次模型请求加上它请求的工具执行。没有工具调用的普通回复通常只有一个 step。括号让恢复代码能发现“进程停在 step 中间”,而不必从文本内容猜测。

这些约束由 src/Tether.Core.Session/SessionInvariantPlugin.cs 实时检查:不能在已有 turn 内再开 turn,不能在 step 外写 tool result,turn end 时也不能留着 open step。

动手实验:从输出重建括号

实验目标:先验证 InMemorySession 的不可变与 replay,再把一次真实 CLI 输出按 turn / step 缩进。

1. 预测

一个全新的 session 第一次 append 后:

  • committed Seq 是 0 还是 1?
  • 修改传入的 TextContent.Text 会不会改变 event Data?
  • 修改一次 DeriveMessages() 返回的 message,会不会污染下一次 projection?

2. 运行 session 测试

dotnet test tests/Tether.Core.Session.Tests/Tether.Core.Session.Tests.csproj \
  --filter FullyQualifiedName~InMemorySessionTests

对应测试名是 Append_commits_a_deep_snapshot_before_notifying_observers 与 Mutating_one_projection_cannot_rewrite_the_log_or_later_projections。

3. 生成一条真实日志

course_home=$(mktemp -d)
dotnet run --project apps/Tether.Cli -- \
  --home "$course_home" \
  --replies apps/Tether.Cli/replies/text.json \
  --send "trace one turn"

复制第二行 JSON 的 events,只保留 seq 与 type。遇到 turn/start、step/start 就向右缩进;遇到对应 end 就向左退一层。你的日志还会包含 agent/inbox/spliced、request/header、request/start 与若干 assistant/chunk(每个流式分片各一条),不要为了得到上面的简图而删除它们。

4. 解释

测试会固定:第一次 seq 为 0,payload 是 commit 前的深快照,每次 projection 都产生 detached message。CLI 日志则证明 lifecycle facts 与 message facts 共存于一条序列;DeriveMessages() 只挑可投影的 surface events。

一个重要的实现边界:chunk 与 message 的分工

SessionEventTypes 声明的 assistant/chunk descriptor 不只是预留:当前 src/Tether.Core.AgentLoop/Agent.cs 的 StepAsync 在消费流时就把每个 provider 无关分片落成 durable 的 assistant/chunk(block-start / text-delta / reasoning-delta / tool-call-delta / usage / finish 等 type),流结束后再提交一条完整 assistant/message,并用 SourceEventSeqs 引用本尝试已落盘的 chunk。因此:

  • live UI 可从 runtime agent/text-delta 逐段显示文字,回放方也能从 assistant/chunk 重建逐段渲染;
  • assistant/message 仍是 surface 上的权威可见节点:被 max-tokens 截断的 tool call 只留在 chunk 里,不会出现在折叠出的消息历史;
  • chunk 归某次尝试所有:request 重试前的失败尝试由 request/start 与 request/failed 界定,其 chunk 不跨尝试累积。

检查理解

1. append-only 是否表示 tool result 写错后永远无法修正模型可见内容?

查看答案

不是。旧 event 本身不改,但可追加带 SurfaceOp.Replace 的新 surface event,让折叠后的可见位置使用新内容。审计时仍能看到原 event 与修正 event。

2. Time 更早的 event 能否排在更大的 Seq 后面?读取顺序看谁?

查看答案

系统时钟可能调整,Time 主要用于时间分析;session 内的权威顺序由连续 Seq 决定。

3. observer 抛错时,AppendAsync 为什么不删除刚提交的 event?

查看答案

notification 发生在事实 commit 之后,observer 是派生消费者。用 observer failure 回滚 authority 会让其他已观察者与 log 分叉;因此错误被报告,但 event 保持 committed。

本周带走

  • Session event log 是 authority;ChatMessage history、统计和 UI 都是可重建 view。
  • Seq 给出连续顺序,typed descriptor 负责 snapshot、校验与 projection。
  • turn/step 是严格嵌套的持久括号;恢复与不变量检查依赖它,而不是猜文本。

下一周研究同一份日志如何同时生成 projection 与 durable storage。完整事件表可查会话与事件溯源。

在 GitHub 上编辑此页