DeepSeek Harness セッションログとターンライフサイクル
考えたことはありますか?モデルが目にする会話履歴は、いったいどこに保存されているのでしょうか?
dsh の答えはとても特別です:専用の場所は存在せず、セッションログから派生するのです。
この章では、セッションログとターンライフサイクルについて説明します:ログが唯一の真実源になる方法、ターンとステップの分割方法、そして「モデルに見えるものはすでに記録されている」という不変条件です。
一言で言うと:セッションは追記専用のイベントログであり、モデル履歴はログから派生します。ターンとステップはログ上の実行境界です。
セッションログ:唯一の真実源
Sessionは、型付けられたSessionEventから構成される追記専用ログです。
それはエージェントの完全な対話履歴の唯一の真実源であり、LLM メッセージ履歴はログから派生し、単独で保存されることはありません。
再生(リプレイ)とは、同じイベント群から履歴を再度派生することです。
ログ内の各イベントには、単調増加するseq(seq = log.length)とエポックミリ秒のtime。
イベントはtype真の判別可能なユニオンを構成するため、switch (event.type)直接絞り込めますevent.data、型アサーションは必要ありません。
すべてのevent.dataは、JSON に無損失でシリアライズ可能である必要があり、Session.appendはソース側でこれを強制します。
モデルに見えるものはすべて記録済みである。モデルリクエストに到達するすべてのものはログから再構築可能でなければならず、これは実行時不変条件によって保証されます。
したがって、モデル可視の入力を新しく追加するには、セッションイベントを1つ新規追加する必要があります:SessionEventMap を拡張し、ログからレンダリングします。
リプレイ可能な transcript データを必要とする SDK ユーザーはsession/eventイベントストリームを消費する必要があります。
deriveMessages:ログからモデル履歴を投影する
Session.deriveMessages()イベントログをモデルが見るMessage[]。
これはキャッシュされます:各 surface ノードは最初に出現したときに一度だけ投影され、surface が書き換えられたときに再構築されます。
返るのはフリーズされたメッセージ配列であり、投影によって記録済みの履歴を変更することは型レベルで表現できません。
投影ルールは非常に直接的です:
| イベント | 投影結果 | 説明 |
|---|---|---|
user/message | 1 つの user メッセージ | 正確な content を持ち、任意の envelope はログ表示用メタデータとしてのみ使用されます。 |
assistant/message | 1 つの assistant メッセージ | プロバイダー、モデル、オプションのリプレイ状態を含みます。 |
assistant/chunk | スキップ | リプレイ/UI データに属し、組み立て後のメッセージが正となります。 |
tool/result | tool-result ブロックを含む 1 つの user メッセージ | ツール結果は user ロールとしてモデルに戻ります。 |
turn/*、step/* | スキップ | 構造情報であり、メッセージとして投影されません。 |
1 つの細かい点:内容が空のassistant/messageもスキップされます。
max-tokens による切り詰めで内容がないステップでも、使用量・プロバイダー・モデルを保存するために assistant/message を1件記録します。ただし、内容のない assistant ターンはプロバイダーの transcript に含めてはいけません。
イベントの3つのドメイン
イベントドメインを正しく選ぶことは、ほとんどの変更における最初の決定です。
公式ドキュメントはイベントを3つのドメインに分けており、それぞれ用途が異なります:
| イベントドメイン | 代表イベント | 特徴 | 使用場面 |
|---|---|---|---|
| セッションイベント | turn/start、step/start、user/message、assistant/*、tool/call、tool/result | ログに追記されブロードキャストされる永続的な事実 | 再読み込み後も残る必要がある事実の場合 |
| Agent イベント | agent/pre-step、agent/request、agent/status、agent/turn-stopping | アクティブな Agent を持ち、リアルタイム制御と状態を提供 | 進行中の作業を監視・インターセプトする場合 |
| 能力イベント | tools/*、fs/*、llm/stream | インポートループなしにシームへポリシーをアタッチする | 能力シームにポリシーとアダプターを追加する場合 |
このうちagent/pre-step、agent/request、llm/streamと3つのtools/*イベントはウォーターフォールであり、リスナーはnext()を呼び出して委譲を続ける必要があります。
agent/turn-stoppingはシリアルイベントであり、ウォーターフォールはありません。next()。
ターンとステップの定義
1 つのステップ(step)は、1 回のモデルリクエストと、それが呼び出すツールから構成されます。
1 つのターン(turn)は、0 個以上のステップを含みます:最初の入力を受け取る前に開かれ、未処理の作業がなくなった時点で閉じられます。
注意:ターンは 1 回のモデルループ実行を囲みます。セッションログ全体ではありません。
公式シーケンス図は完全なフローを次のように示しています:turn/start → agent/pre-step → step/start → llm/stream → 工具 → step/end → turn/end。
入力は同じ inbox を通ってドライバーに到達します。
一部のメッセージは直ちにドライバーを起動します。注入されたコンテキストは、別のメッセージが起動するまで inbox に残ります。
agent/pre-stepモデルが見るものを決定します:リスナーは受け取ったメッセージを書き換えたり、直接拒否したりできます。
最初の受け取りが拒否されるか空に書き換えられた場合でも、ステップを含まない永続的なターンが閉じられるため、ログはこの試行を記録します。
turn と step の主要イベント
ログ内の各境界には対応するイベントがあり、以下に最もよく使われるものを示します。
| イベント | 保持するデータ | 説明 |
|---|---|---|
turn/start | { turn } | loop がキューに入った入力を受け取るか pre-step を実行する前にターンを開く |
turn/end | { turn, reason } | TurnEndReason でターンを閉じる(completed / aborted / blocked / error / max-tokens / interrupted) |
step/start | { turn, step } | ターン内のステップを開く |
step/end | { turn, step } | そのステップを閉じる |
user/message | UserMessage | 直接プロンプト、注入コンテキスト、steering、リアルタイム受信ボックスイベントが共有する識別子付きの値 |
assistant/chunk | { turn, step, chunk } | 生のストリーミングチャンク、トークンレベルのリプレイ忠実度 |
assistant/message | { turn, step, message, usage? } | 組み立て後の assistant メッセージ(派生履歴がこれを使用) |
tool/call | { turn, step, callId, name, arguments } | モデルリクエストによる1回のツール呼び出し。arguments はモデルが生成した生の JSON 文字列 |
tool/result | { turn, step, message, error?, meta? } | 完了した1回のツール呼び出しのモデル可視な結果 |
有用なセマンティクスの1つ:assistant/messageイベントは、空のコンテンツを返す呼び出しや max-tokens で終了する呼び出しを含め、成功したプロバイダー呼び出しのたびに記録します。
空のコンテンツは派生履歴に入りませんが、その永続イベントは使用量を保持します。
それはsourceEventSeqs正確に対応するassistant/chunkイベントを、明示的な空リストを含めて列挙します。
実践例:JSONL セッションログを解析する
以下は、JSONL の標準的なパック行レイアウトによる小さなセッションログで、「サンプル リポジトリの typo を修正する」タスクからのものです。
各行は SessionEvent であり、type、seq、time、data を含みます。
{"type":"turn/start","seq":0,"time":1755000000000,"data":{"turn":1}}
{"type":"step/start","seq":1,"time":1755000000010,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":2,"time":1755000000020,"data":{"role":"user","content":[{"type":"text","text":"Fix the typo in the サンプル README."}]},"surfaceOp":"append","sourceEventSeqs":[0]}
{"type":"assistant/chunk","seq":3,"time":1755000000030,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","text":"I'll "}}}
{"type":"assistant/chunk","seq":4,"time":1755000000040,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","name":"bash","arguments":"{\"command\":\"grep サンプル README.md\"}"}}}
{"type":"assistant/message","seq":5,"time":1755000000050,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I'll search"},{"type":"tool_use","id":"call_1","name":"bash","input":{"command":"grep サンプル README.md"}}]},"usage":{"inputTokens":12,"outputTokens":4}},"surfaceOp":"append","sourceEventSeqs":[3,4]}
{"type":"tool/call","seq":6,"time":1755000000060,"data":{"turn":1,"step":1,"callId":"call_1","name":"bash","arguments":"{\"command\":\"grep サンプル README.md\"}"}}
{"type":"tool/result","seq":7,"time":1755000000070,"data":{"turn":1,"step":1,"message":{"role":"tool","toolName":"bash","content":"サンプル","isError":false}},"surfaceOp":"append","sourceEventSeqs":[6]}
{"type":"step/end","seq":8,"time":1755000000080,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":9,"time":1755000000090,"data":{"turn":1,"reason":{"kind":"completed"}}}
注意user/message、assistant/message、tool/result3 種類の surface イベントにはsurfaceOpマーカーが付いており、派生サーフェスにどのように組み込まれるかを示しています。
turn/start、step/startなどの境界イベントは surfaceOp を持たず、モデルメッセージとして投影されません。
以下では Python を使ってこのログを再生し、会話を再構築して turn/step 境界をマークします。
サンプル
# JSONL セッションログを解析し、モデル可視の会話を再構築して turn/step 境界をマークします。
# これは Session.deriveMessages() の簡略化した教育用モデルです。実際の実装はキャッシュされており、フリーズされたメッセージを返します。
import json
import sys
def derive_messages(events):
"""surface イベントのみを投影し、deriveMessages の投影ルールをシミュレートします。
user/message -> user メッセージ
assistant/message -> assistant メッセージ
tool/result -> tool-result ブロックを持つ user メッセージ
turn/*, step/*, assistant/chunk, tool/call はメッセージとして投影されません
"""
messages = []
for ev in events:
t = ev["type"]
d = ev["data"]
if t == "user/message":
messages.append({"role": "user", "content": d["content"]})
elif t == "assistant/message":
messages.append({"role": "assistant", "content": d["message"]["content"]})
elif t == "tool/result":
messages.append({"role": "tool", "name": d["message"]["toolName"], "content": d["message"]["content"]})
return messages
def main(path):
with open(path, encoding="utf-8") as f:
events = [json.loads(line) for line in f if line.strip()]
# 第一遍:実行境界を出力し、turn と step のネスト関係を理解する。
for ev in events:
d = ev["data"]
if ev["type"] == "turn/start":
print(f"[turn/start] turn={d['turn']}")
elif ev["type"] == "turn/end":
print(f"[turn/end] turn={d['turn']} reason={d['reason']}")
elif ev["type"] == "step/start":
print(f" [step/start] turn={d['turn']} step={d['step']}")
elif ev["type"] == "step/end":
print(f" [step/end] turn={d['turn']} step={d['step']}")
elif ev["type"] == "assistant/chunk":
print(f" chunk: {d['chunk']['type']}")
elif ev["type"] == "tool/call":
print(f" tool/call: {d['name']} args={d['arguments']}")
# 第二遍:モデルに可視な派生履歴を再構築する。
print("\nモデルに可視な派生メッセージ:")
for m in derive_messages(events):
if m["role"] == "tool":
print(f" [tool] {m['name']}: {m['content']}")
else:
print(f" [{m['role']}] {m['content']}")
if __name__ == "__main__":
main(sys.argv[1])
このログで一回実行すると、出力はおおよそ次のようになります:
[turn/start] turn=1
[step/start] turn=1 step=1
chunk: text-delta
chunk: tool-call-delta
tool/call: bash args={"command":"grep サンプル README.md"}
[step/end] turn=1 step=1
[turn/end] turn=1 reason={'kind': 'completed'}
模型可见的派生消息:
[user] [{'type': 'text', 'text': 'Fix the typo in the サンプル README.'}]
[assistant] [{'type': 'text', 'text': 'I'll search'}, {'type': 'tool_use', ...}]
[tool] bash: サンプル
原文
assistant/chunk派生時にスキップされ、組み立てられた後のassistant/messageこそが権威です。これが「モデルに可視なら記録済み」という意味です:モデルが見たすべてのメッセージは、このログからそのまま再構築できます。
まとめと自己テスト
会話ログはモデルが見るコンテキストの唯一のソースであり、ターンとステップはログ上の実行境界であり、イベントの3つのドメインが正しい拡張ポイントを選択するのに役立ちます。
自己テスト問題:
| 問題 | 参考 |
|---|---|
| 「リロード後も存在する」事実を保存したい場合、どの種類のイベントを使いますか? | セッションイベント(永続、ログに追記) |
| モデルが見る履歴はどこから来るのですか? | Session.deriveMessages()ログから派生し、単独で保存されることはありません |
| 1つのターンにはいくつのステップを含めることができますか? | 0個以上;最初の入力を受け取る前に開かれ、未処理の作業がなくなったときに閉じられます |