通常のチャットシステムでは、セッションはメッセージのまとまりとして扱われることが多い。ユーザーがメッセージを送ると、アシスタントが1件返す。ページを開き直せば、messages[]を読み戻すだけでよい。
Agentのセッション状態はもっと複雑だ。1回のインタラクションには、モデルのストリーミング出力、Tool Call、Tool Result、コンテキスト圧縮、ブランチ切り替え、承認、バックグラウンドタスク、途中からの入力などが含まれる。最終的なメッセージだけを保存すると、UIを復元できるとしても、実行上の事実の多くが失われる。逆に、実行中のオブジェクトをすべて直接シリアライズすると、AbortController、ネットワーク接続、Promiseなどの一時的な状態まで永続化層に持ち込むことになる。
したがって、Sessionの設計で最初に必要なのは、状態の境界を確定することだ。どの状態が長期的な事実で、どの状態が現在のプロセス内にのみ存在し、モデルがリクエストごとに実際に見る必要があるのはどの部分なのかを、明確にすることが先だ。データベースの選択は、その後の実装上の問題になる。
この境界は、まず次の4つの概念に分けられる。
4つは同じシステムに同時に現れることが多いが、ライフサイクルと役割は異なる。
messages[] がすぐに足りなくなる理由
まず最もシンプルなモデルから始めよう。
この構造だけでも、通常チャットの大部分はカバーできる。履歴の表示も、次のラウンドのモデル入力も同じ配列から読み取れる。
AgentがTool Callingを導入しても、Message自体は一部の実行結果を格納できる。
user message
assistant message + tool call
tool result
assistant message
問題になるのは、Messageへ自然にまとめられない状態が増えていくことだ。
たとえば、1回のモデルリクエストが始まる前に、システムが次の情報をすでに決めている場合がある。
当前模型
System Prompt
Tool schemas
Thinking / Reasoning 配置
Context Compaction 结果
当前分支
本次注入的环境信息
実行中还には、次のような状態が生まれる。
Assistant streaming chunks
Tool execution started
Tool execution completed
Turn started / ended
Step started / ended
Cancellation / interruption
この状態をすべてチャットメッセージとして扱うのは適切ではない。無理にMessage[]へ押し込めると、MessageはUI表示、モデル履歴、実行ログ、復旧プロトコルの4つの役割を同時に担う。機能を1つ追加するたびにMessageの型を拡張しなければならず、どの記録をユーザーに見せるか、モデルに渡すか、それともランタイム復旧にだけ使うのかを判別しづらくなる。
したがって、まずより安定した原則が必要だ。
永続化層は復旧可能な事実を保存し、モデルContextとUI Historyはその事実から構築する。
この原則は、すべてのシステムにEvent Sourcingを求めるものではない。PiとDeepSeek Harnessは,明显に異なる永続化モデルを採用している。重要なのは、長期的な事実と一時的な実行状態が同じオブジェクトグラフに依存してはならないという点だ。
Sessionが表す長期的な境界
Sessionのライフサイクルは通常、複数のモデルリクエストと複数のAgent実行をまたぐ。
少なくとも次の2つの機能が必要だ。
- 永続化される状態に安定した所属先を与えること
- 新しいランタイムインスタンスの起動後に、作業を再開するために必要な状態を再構築できること
そのため、Sessionに保存する内容には通常、共通の特徴がある。プロセスが終了した後も意味を持つ状態だ。
たとえば次のようになる。
逆に、次のようなオブジェクトは通常Runtimeに属する。
AbortController
正在等待的 Promise
SSE connection
当前 socket
stream reader
进程句柄
内存锁
これらは「今この瞬間にどう実行するか」を表している。プロセスの復旧後は通常、再作成が必要になる。
境界は次のように図で表せる。
Sessionは復旧用の材料をRuntimeに提供し、Runtimeは現在のプロセス内の実行オブジェクトを再構築する。
これは「Sessionを保存する」ことを、AgentインスタンスのJSONシリアライズと単純に同じに扱うことはできないことを意味する。成熟したRuntimeは、古いRuntimeオブジェクトが引き続き存在することに依存せず、Sessionから新しいRuntimeを復元できるべきだ。
Messageが担う会話の意味
MessageはAgentシステムで重要なオブジェクトであり続ける。モデルもユーザーも、メッセージを主な対話形式としているためだ。
ただし、Messageは意味上のビューとして使うほうが適している。
たとえば、ユーザーが次のように入力したとする。
帮我检查 UserService 的权限判断
その後、Agentはファイルを読み、参照を検索し、テストを実行して、最終的に分析結果を返す。
ユーザーUIで必要な表示は、次のものだけかもしれない。
User
帮我检查 UserService 的权限判断
Assistant
...分析结果...
開発者モードではTool Callも表示されることがある。
Read UserService.java
Search hasPermission
Run ./gradlew test
モデルが次回のリクエストに使う履歴は、また別のものになる。Tool Resultは必要なが、UIの折りたたみ状態、所要時間、ボタン、エラースタックの表示形式までは必要とは限らない。
少なくとも次の3つのビューが存在する。
Message自体を唯一の永続的事実として扱うと、これらのビューは互いに混入しやすい。
より明快な設計は、Messageを会話層が安定して識別できる意味的对象としつつ、SessionにはMessageより豊富な事実を保存できるようにすることだ。
PiのSession Entryにも、その考え方が表れている。Sessionファイルにはmessageだけでなく、compaction、branch_summary、モデル変更などのEntryも現れる。Sessionの永続状態は、モデルメッセージの一覧より本質的に広い。
DeepSeek Harnessはより徹底した方式を採る。Sessionには型付きSessionEventを直接保存し、user/message、assistant/message、tool/resultは、その一部としてモデルメッセージへ投影できるにすぎない。
Eventが「何が起きたか」を担う
Eventの価値は、状態の変化を事実として表せることにある。
最終的なMessageしか保存されていないと仮定すると、
assistant:
测试失败,原因是权限配置错误。
この結果だけでは、システムに次のことを伝えられない。
これらの情報が復旧、replay、UI、観測に有用なら、独立した記録として残す必要がある。
最小構成のEvent Logは次のようになる。
turn/start
user/message
step/start
assistant/message
tool/call
tool/result
step/end
assistant/message
turn/end
ここには重要な違いがある。Eventは過去に起きたことを表す。そのため、永続イベントは不変のセマンティクスを備えているのが望ましい。
これは、特定のTool Resultがセッションの事実として記録されたことを表す。
後で別のUIを表示したり、モデルメッセージの構築方法を変更したりしたときは、Eventを再投影すればよい。元の事実をUIに合わせて書き換える必要はない。
这也是、Event Logが複数のProjectionを支えられる基盤になる。
ただしEvent Logには別の複雑さも伴う。イベントのschemaにはバージョン管理が必要で、Projectionは決定性を保ち、長大なログでは圧縮にも留意し、公開済みの履歴イベントのセマンティクスを後から自由に変えてはならない。
「Event Sourcingのほうが先進的だ」という理由だけで、すべてのAgent製品をEvent Logへ変更すべきではない。判断基準は、システムが本当にreplay、複数の独立したProjection、監査、正確な復旧を必要としているかどうかだ。
Contextが担うのは現在のモデルリクエストだけ
SessionとContextは、違いを最も混同しやすい。
Sessionに完全な履歴が保存されていても、次のリクエストで全内容をモデルへ送るわけではない。
理由は次のとおりだ。
したがって、Contextはランタイム上のProjectionとして定義するほうが適している。
PiはAgent Loopに入る前に、長期的Agent StateからAgentContextスナップショットを作成する。-coding Agentが既存のSessionを復元するときには、SessionManager.buildSessionContext()によって現在のSessionパスからメッセージを構築し、出来上がったものをAgent Stateへ戻す。
DeepSeek Harnessの境界はより厳格だ。モデル履歴はSession Event Surfaceの投影として生成される。一方、request/headerには実際のモデルリクエストで使われたモデル設定、System Prompt、Tool schemasが記録され、1回のモデルリクエストをSession Logから再構築できる。
2つの設計の深さは違うが、どちらもContextに明確な時効性があることを示している。Contextは「この1回のモデル呼び出しが何を見たか」を表し、Sessionは「この会話に何が長期保存されているか」を表す。
CompactionがSessionとContextの分離をさらに示す理由
長い会話は、いずれContext Windowに突き当たる。
Sessionに次の内容があるとしよう。
M1 M2 M3 ... M200
モデルはすべての履歴を受け取り続けられない。もっとも直接的な対策は、初期の履歴をSummaryへ圧縮することだ。
Session History
M1 M2 M3 ... M200
Model Context
Summary(M1...M150)
+ M151 ... M200
SessionとContextが同じ配列なら、Compactionは「履歴を削除してSummaryで置き換える」処理へなりやすい。Context Tokenは節約できるが、ユーザーの履歴、監査情報、ブランチ復旧まで一緒に失われる。
Piの設計は、この違いを直观的に示している。Compaction自体がSession Entryとして木に追加され、summary、firstKeptEntryId、tokensBeforeを記録する。Contextを構築するとき、このEntryを基に、どの古いメッセージがSummaryで置き換わるかを決める。元のEntryはJSONLに残される。
つまりCompactionの意味は、すでに起きた履歴を書き換えることではなく、その後のContextの構築方法を変えることにある。
監査可能でブランチを許容するAgentにとって、これは非常に重要な境界だ。
SnapshotとEvent Tailで一致性の問題が起きやすい理由
多くのWeb AgentシステムはEvent Sourcingを完全には採用せず、次の方式を選ぶ。
Message Snapshot
+
Recent Event Tail
この設計自体は十分に成立するが、checkpointを明示的に扱う必要がある。
データベースのMessage SnapshotにはEvent 0..105が含まれ、Event Logは109まで書き込まれているとする。
Snapshot checkpoint = 105
Event Log = 0 ... 109
復旧時には次を実行する。
load Snapshot@105
↓
replay Event 106..109
↓
current state
本当のリスクは書き込み順序にある。
ProjectionがMessage 109まで書き込んだ状態でcheckpointが105のままなら、復旧時に106..109をreplayして、同じ状態を二重適用してしまう可能性がある。
逆に、checkpointが109へ進んだ状態でMessage Projectionが105までの書き込みしか終わっていなければ、復旧処理は末尾のイベントを誤って飛ばす。
したがってcheckpointとProjectionには、アトミックコミット、または検証可能な冪等性制約が必要だ。
Event Log
│
▼
Projector
│
├── update Message Snapshot
└── advance checkpointSeq
两者必须形成一致提交边界
Event Logを唯一の事実源にしていれば、Messageはいつでも再投影できるため、この二重書き込み問題は軽くなる。付出的代償は、読み取り経路がProjectionの性能とイベントschemaの安定性により強く依存することだ。
どの状態をSessionに入れるべきか
実用的な判断基準が1つある。
プロセスが直ちに終了しても、復旧後にその事実を知っている必要があるか?
答えが「はい」なら、通常はSessionまたは別のDurable Storeに入れるべきだ。
たとえば次のようになる。
| 状態 | 通常は永続化するか | 理由 |
|---|---|---|
| ユーザーからのメッセージ | は | 会話履歴 |
| 完了したAssistant Message | は | 対話結果 |
| Tool Result | は | 今後のモデル判断が依存する可能性がある |
| Compaction Summary | は | 今後のContextを左右する |
| 現在のブランチ | は、または再構築可能 | 現在の履歴パスを決める |
| Approvalの判断 | 通常は | 復旧後に推測し直せない |
AbortController | いいえ | プロセス内限りのオブジェクト |
| SSE Connection | いいえ | Transportの状態 |
| Streaming iterator | いいえ | プロセス横断では復旧できない |
| Tool子プロセスのハンドル | 実装次第 | 通常は復旧可能なタスク状態へ変換する |
もう1つ判断基準を加えるなら次のとおりだ。
この状態が今後のモデルリクエストに影響するなら、永続的な事実から再構築できるか?
この基準にすると、Sessionに求められる水準が一段上がる。
たとえば、Agentの復旧後にSystem PromptとToolsがまったく別物に変わっても、歴史メッセージは残っているものの、以前のTool Resultは意味上の文脈を失うことがある。DSHはそのため、request headerやagentPresetなどの情報も、永続的に保持して再構築できる範囲に含めている。
単純な製品がここまで細かい粒度で保存する必要はないNevertheless、設計時には「復旧」の目標を、チャット本文の復元なのか、当時のモデル実行が持っていた意味の復元なのか、明確にしておくべきだ。
安定した状態の階層分け
ここまでの関係を整理すると、次の状態モデルにまとめられる。
中心となる判断は3つある。
第1に、Sessionには、別プロセスでも引き続き成立する状態を保存すべきであり、Runtimeオブジェクト全体ではない。
第2に、Messageは重要な意味モデルだが、すべての永続的事実を担う必要はない。Toolのライフサイクル、Compaction、Branch、リクエスト設定などの状態は、それぞれ独立した表現を持たせられる。
第3に、Contextは、現在のSessionとRuntimeの条件から構築する。Compaction、Memory、Agent設定の変化によって、システムが履歴全体を書き換えたりする必要はない。
次回はPiの実装を具体的に見ていく。PiはSessionIFA完全な実行Event Logとして設計せず、より軽量な構造を選んだ。JSONLには物理的に追記し、Entryはid / parentIdによって論理木を形成する。現在のleafが現在の会話パスを決める。この構造は、「永続履歴」と「モデルContext」が同じSessionの中で別々の形を保つことを分析するのに非常に適している。
参考資料
[1] Piセッションファイル形式: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session-format.md
[2] DeepSeek Harnessセッション: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
[3] Pi SDKのセッション復元: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/sdk.ts
[4] DeepSeek Harnessのアーキテクチャ/永続化: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.zh.md ; https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/persistence.zh.md
[5] PiのCompaction: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md