第 9 周 · Tether 源码课
Session Events:把发生过的事写成不可变日志
学完你能做到
- 区分权威 event log 与派生的 ChatMessage 历史
- 读懂 SessionEvent 的 Type、Seq、Time、Data 与 metadata
- 用 turn/start、step/start、step/end、turn/end 检查嵌套边界
- 用 InMemorySessionTests 验证 snapshot、sequence 与 replay
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 16 周
一句话先懂
Session 不保存“当前对话状态”,而是按顺序保存“发生过哪些不可变事实”;当前状态随时从这些事实重放出来。
AppendAsync 只在尾部提交新的 SessionEvent,DeriveMessages() 则把完整日志折叠成此刻给模型看的消息历史。
这是一条重要分界:如果把 List<ChatMessage> 当作唯一事实,工具何时开始、turn 为什么失败、恢复时缺了哪一半都很难回答。Tether 保留更细的 event,再根据用途生成 view。
先看大图
一个类比:不可擦除的实验记录本
做实验时,不把昨天写的温度擦掉改成今天的,而是在下一行记录新测量与修正原因。最终报告可以从记录本算平均值、画曲线;报告丢了还能重算,原始记录丢了就无法证明发生过什么。
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 | 跨进程和跨版本识别事实 |
Seq | session 内从 0 开始的连续编号 | 排序、引用与发现 gap |
Time | Unix milliseconds | 时间分析;不替代 Seq 顺序 |
Data | detached 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
三条容易混淆的边界:
- payload 在 commit 前深拷贝;之后修改原
ChatMessage不改变 log。 - observer failure 不回滚已提交 event,而是由 contained event path 上报。
- 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。完整事件表可查会话与事件溯源。