일반적인 채팅 시스템은 세션을 쉽게 메시지 묶음으로 이해한다. 사용자가 한 메시지를 보내면 어시스턴트가 한 번 답하고, 페이지를 다시 열 때 messages[]를 읽어오면 그만이다.
Agent 세션의 상태는 더 복잡하다. 한 번의 인터랙션에 모델 스트리밍 출력, Tool Call, Tool Result, Context 압축, 브랜치 전환, 승인, 백그라운드 작업, 중간 입력이 모두 포함될 수 있다. 시스템이 최종 메시지만 저장하면 UI는 복원할 수 있지만, 중요한 런타임 사실은 이미 사라진다. 반대로 실행 중인 모든 객체를 그대로 직렬화하면 AbortController, 네트워크 연결, Promise 같은 일시적인 상태까지 영속화 계층에 들여오게 된다.
따라서 Session 설계에서 먼저 정해야 할 것은 상태 경계다. 어떤 상태가 장기적인 사실이고, 어떤 상태가 현재 프로세스 안에만 존재하며, 모델의 매 요청에서 실제로 어떤 부분을 보여줘야 하는지를 먼저 정해야 한다. 데이터베이스 선택은 그다음에야 다룰 구현 문제다.
이 경계는 우선 네 가지 개념으로 나눌 수 있다:
이 네 개념은 흔히 한 시스템에 함께 등장하지만, 수명과 역할은 서로 다르다.
messages[]만으로는 왜 금방 한계에 부딪히는가
가장 단순한 모델에서 시작해 보자.
일반 채팅이라면 이 구조만으로 대부분의 요구를 충족할 수 있다. 대화 기록을 표시할 때도, 다음 턴의 모델 입력을 만들 때도 모두 같은 배열을 읽는다.
Agent가 Tool Calling을 도입한 뒤에도 Message 자체는 여전히 일부 실행 결과를 담을 수 있다.
user message
assistant message + tool call
tool result
assistant message
문제는 점점 더 많은 상태를 Message 안에 자연스럽게 넣기 어렵다는 데 생긴다.
예를 들어 모델 요청이 시작되기 전에 시스템은 이미 다음을 확정했을 수 있다:
当前模型
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 표시, 모델 히스토리, 실행 로그, 복구 프로토콜이라는 네 가지 역할을 동시에 떠안게 된다. 기능을 하나 추가할 때마다 Message 타입을 계속 확장해야 하고, 결국 어떤 레코드를 사용자에게 보여줘야 하는지, 모델에 보여줘야 하는지, 아니면 런타임 복구에만 쓰이는지 판단하기 어렵다.
따라서 더 안정적인 원칙이 필요하다: 영속화 계층은 복원 가능한 사실을 저장하고, 모델 Context와 UI History는 그 사실에서 구성한다.
이 원칙은 모든 시스템이 Event Sourcing을 채택해야 한다는 뜻은 아니다. Pi와 DeepSeek Harness는 분명히 서로 다른 영속화 모델을 사용한다. 중요한 것은 장기적인 사실과 임시 실행 상태가 같은 객체 그래프에 의존하지 않아야 한다는 점이다.
Session은 장기 경계를 나타낸다
Session의 수명은 보통 여러 모델 요청과 여러 번의 Agent 실행을 아우른다.
Session은 최소한 두 가지 기능을 제공해야 한다:
- 영속 상태가 안정적으로 귀속될 자리를 제공하는 것;
- 새 런타임 인스턴스가 시작된 뒤에도 계속 작업하는 데 필요한 상태를 재구성하는 것.
따라서 Session에 저장하기에 적합한 내용은 공통적으로 다음 특징을 갖는다: 프로세스가 종료된 뒤에도 의미가 남아 있어야 한다.
예를 들어:
반면 다음 객체들은 보통 Runtime에 속한다:
AbortController
正在等待的 Promise
SSE connection
当前 socket
stream reader
进程句柄
内存锁
이들은 “이 순간 어떻게 실행되는지”를 설명할 뿐이며, 프로세스를 복구한 뒤에는 일반적으로 다시 만들어야 한다.
경계를 다음과 같이 그릴 수 있다:
Session은 복구에 필요한 재료를 제공하고, Runtime은 현재 프로세스 안에서 실행 객체를 다시 세운다.
따라서 “Session을 저장한다”는 말이 Agent 인스턴스를 JSON으로 직렬화하는 것과 간단히 같아서는 안 된다. 성숙한 Runtime은 Session에서 새로운 Runtime을 복구할 수 있어야 하며, 기존 Runtime 객체가 계속 존재하는 데 의존해서는 안 된다.
Message는 대화 의미를 담당한다
Message는 여전히 Agent 시스템에서 아주 중요한 객체다. 모델과 사용자가 주로 메시지를 통해 상호작용하기 때문이다.
문제는 Message가 의미적 뷰에 더 적합하다는 점이다.
예를 들어 사용자가 다음과 같이 입력한다:
帮我检查 UserService 的权限判断
그 뒤 Agent는 파일을 읽고, 참조를 검색하고, 테스트를 실행한 다음 분석 결과를 답한다.
사용자 UI에는 아마 다음만 표시하면 될 것이다:
User
帮我检查 UserService 的权限判断
Assistant
...分析结果...
개발자 모드에서는 Tool Call도 표시할 수 있다:
Read UserService.java
Search hasPermission
Run ./gradlew test
모델의 다음 요청에 필요한 히스토리는 또 다를 수 있다. 모델에는 Tool Result가 필요하지만, UI의 접기 상태나 소요 시간, 버튼, 오류 스택의 표시 형식은 필요하지 않을 수 있다.
따라서 최소한 세 종류의 뷰가 존재한다:
Message 자체를 유일한 영속적 사실로 정의한다면, 이 세 뷰가 서로 오염되기 쉬다.
더 명확한 설계는 Message를 대화 계층에서 안정적으로 식별할 수 있는 의미 객체로 두고, 동시에 Session이 Message보다 더 풍부한 사실을 저장하도록 하는 것이다.
Pi의 Session Entry가 바로 이를 보여준다. Session 파일에는 message뿐 아니라 compaction, branch_summary, 모델 변경 등의 Entry도 등장한다. Session의 영속 상태는 모델 메시지 목록보다 본질적으로 더 넓다.[1]
DeepSeek Harness는 더 근본적인 방식을 택한다. Session이 typed SessionEvent를 직접 저장하고, user/message, assistant/message, tool/result는 그중 모델 메시지로 투영할 수 있는 일부에 불과하다.[2]
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 snapshot을 먼저 만들고, Coding Agent가 기존 Session을 복구할 때는 SessionManager.buildSessionContext()를 통해 현재 Session 경로에서 메시지를 구성한 뒤 Agent State에 다시 넣는다.[1][3]
DeepSeek Harness의 경계는 더 엄격하다. 모델 히스토리는 Session Event Surface에서 투영해 만들어지고, request/header에는 실제 요청에서 사용된 모델 설정, System Prompt, Tool schemas를 기록한다. 따라서 한 번의 모델 요청을 Session Log에서 재구성할 수 있다.[2][4]
두 설계의 깊이는 다르지만, 둘 다 Context에 명확한 시간적 유효 범위가 있음을 보여준다. Context는 “이번 모델 호출에서 무엇을 보았는가”를 설명하고, 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에 그대로 남아 있다.[1][5]
따라서 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에 넣어야 하는가
매우 실용적인 판단 기준이 있다:
프로세스가 즉시 종료되더라도, 복구한 뒤 이 사실을 알아야 하는가?
답이 그렇다면 그 상태는 보통 Session 또는 다른 Durable Store에 들어가야 한다.
예를 들어:
| 상태 | 보통 영속화되는가 | 이유 |
|---|---|---|
| 사용자 메시지 | 예 | 대화 기록 |
| 완료된 Assistant Message | 예 | 대화 결과 |
| Tool Result | 예 | 이후 모델이 결정할 때 필요할 수 있음 |
| Compaction Summary | 예 | 이후 Context를 결정함 |
| 현재 브랜치 | 예 또는 재구성 가능 | 현재 히스토리 경로를 결정함 |
| Approval 결정 | 보통 예 | 복구 후에는 다시 추측할 수 없음 |
AbortController | 아니요 | 프로세스 로컬 객체 |
| SSE Connection | 아니요 | Transport 상태 |
| Streaming iterator | 아니요 | 프로세스를 넘겨 복구할 수 없음 |
| Tool 하위 프로세스 핸들 | 구현에 따라 다름 | 보통 복구 가능한 작업 상태로 변환해야 함 |
두 번째 기준도 추가할 수 있다:
이 상태가 향후의 모델 요청에 영향을 준다면, 영속적인 사실에서 재구성할 수 있는가?
이 기준을 적용하면 Session에 요구되는 수준이 한 단계 올라간다.
예를 들어 Agent가 복구된 뒤 완전히 다른 System Prompt와 Tools를 사용한다면, 히스토리 메시지는 남아 있어도 이전 Tool Result는 이미 의미적 맥락을 잃을 수 있다. DSH는 그래서 request header와 agentPreset 등의 정보를 영속적으로 재구성할 수 있는 범위에 포함시켰다.[4]
단순한 제품은 이 세분화까지 필요하지 않을 수 있지만, 설계할 때 “복구”의 목표가 채팅 텍스트를 복원하는 것인지, 그때 모델의 실행 의미를 복원하는 것인지 분명히 해야 한다.
안정적인 상태分层
앞에서 다룬 관계를 정리하면 다음 모델을 얻을 수 있다:
여기서 핵심 판단은 세 가지다.
첫째, Session은 Runtime 객체 전체가 아니라 프로세스를 넘어 계속 성립하는 상태를 저장해야 한다.
둘째, Message는 중요한 의미 모델이지만 모든 영속 사실을 떠안을 필요는 없다. Tool 수명주기, Compaction, Branch, 요청 설정 등은 별도의 표현을 가질 수 있다.
셋째, Context는 현재 Session과 Runtime 조건에서 구성해야 한다. Compaction, Memory, Agent 설정이 바뀌더라도 시스템이 전체 히스토리를 변조하도록 강요해서는 안 된다.
다음 편에서는 Pi의 구체적인 구현으로 들어간다. Pi는 Session을 완전한 실행 Event Log로 설계하지 않았다. 더 가벼운 구조를 선택했다. JSONL에 물리적으로 append하고, Entry는 id / parentId를 통해 논리 트리를 이루며, 현재 leaf가 현재 세션 경로를 결정한다. 이 구조는 “영속 히스토리”와 “모델 Context”가 같은 Session 안에서 서로 다른 형태를 유지하는 방식을 분석하기에 매우 적합하다.
참고 자료
[1] Pi Session File Format: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session-format.md
[2] DeepSeek Harness Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
[3] Pi SDK session restore: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/sdk.ts
[4] DeepSeek Harness Architecture / Persistence: 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