3과: 에이전트 루프와 세션: 모든 것이 기록되어 있다
한 문장 요약: DSH는 에이전트의 전체 작업 과정을 추가만 가능하고 수정할 수 없는 세션 로그로 기록합니다——모델이 보는 것은 무엇이든 로그에 기록됩니다. 복구, fork, 리플레이, 텔레메트리, UI는 모두 이 하나의 로그에서 파생되므로, 작업이 중간에 멈춰도 손실 없이 계속하거나, 분기하거나, 되돌아볼 수 있습니다.
1. 사용자 스토리: 작업이 중간까지 진행됐을 때, 어떻게 손실 없이 이어갈 수 있을까?
이런 상황을 상상해 보세요: 에이전트에게 오래된 리포지토리를 Vue 2에서 Vue 3로 마이그레이션하라고 시켰습니다. 이미 세 시간 동안 실행되었습니다——수십 개의 파일을 수정하고, 여러 차례 테스트를 실행하고, 모델과 수백 단계에 걸쳐 주고받았습니다. 이때 혈압이 오르는 세 가지 일이 발생합니다:
- 컴퓨터가 갑자기 재시작되어 프로세스가 사라졌습니다;
- 아니면 마음이 바뀌었습니다: 더 보수적인 마이그레이션 경로를 시도해 보고 싶지만, 이미 완료된 진행 상황을 버리기는 아깝습니다;
- 아니면 리플레이해 보고 싶습니다: 어제 도대체 어느 단계에서 그 설정 파일이 망가진 걸까요?
일반적인 도구에서 이 세 가지는 사실상 「다시 시작」이거나 「기억에 의존해 찾기」입니다. DSH에서는 이 모두가 손실 없이, 정확하게, 기록에 근거해 처리됩니다:
| 원하는 것 | DSH의 방식 | 결과 |
|---|---|---|
| 크래시 후 계속하기 | resumeSessionId로 영속화된 세션 로드 | 턴 번호와 파생 히스토리가 로드된 로그에서 이어지며, 중단된 적이 없었던 것처럼 동작 |
| 다른 길 시도하기 | 세션을 fork | 안정적인 체크포인트에서 병렬 브랜치가 복사되고, 원래 세션은 전혀 손상되지 않음 |
| 히스토리 되돌아보기 | 세션 로그 읽기 | 모든 모델 요청, 도구 호출, 결과에 대한 원본 기록이 있음 |
이번 과에서 명확히 할 것은 단 하나입니다: 왜 이 모든 것이 가능한가. 답은 한 단어에 숨어 있습니다——실행 재구성 가능성입니다.
2. 핵심 아이디어: 세션은 append-only 이벤트 스트림이자 권위 있는 로그
세션 = 진실의 원천, 메시지 히스토리 = 파생
먼저 dsh-session 패키지가 스스로를 어떻게 정의하는지 살펴 보세요(출처: packages/core/session/README.zh.md):
이벤트 소싱 기반의 세션 로그와 인메모리 스토리지. Session은 에이전트 전체 상호작용 히스토리의 추가 전용(append-only) 진실의 원천이며, LLM 메시지 히스토리는 이것으로부터 파생된다.
이 문장을 나누어 살펴 보겠습니다:
- 추가 전용(append-only): 이벤트는 로그의 끝에 추가만 할 수 있으며, 이미 기록된 내용을 되돌아가 수정하거나 삭제할 수 있는 사람은 아무도 없습니다;
- 진실의 원천(source of truth): 세션 로그가 유일한 권위입니다. 모델이 보는 메시지 히스토리와 UI에 표시되는 transcript는 모두 이 로그에서 파생된 사본이지, 별도의 상태가 아닙니다;
- 이것으로부터 파생: 「로그」「모델 히스토리」「UI 상태」 세 가지 데이터를 동시에 유지할 필요가 없습니다——데이터는 하나뿐이고, 나머지는 모두 프로젝션입니다.
모델에 보인다 ⟺ 기록되어 있다
아키텍처 문서는 이 원칙을 하나의 공식으로 적고 있습니다(출처: docs/architecture.zh.md · 「세션 로그」):
세션 로그는 권위 있는 근거이다.
deriveMessages()가 모델 히스토리를 프로젝션하고, 원본assistant/chunk이벤트가 리플레이와 UI 충실도를 보장한다. fork, 복구, transcript(텍스트 기록) 렌더링, 텔레메트리, 영속화는 모두 이 이벤트 스트림에서 파생된다.모델에 보인다 ⟺ 기록되어 있다:
step/start에 들어오는 메시지와 접힌(folded)request/header로 모든 요청을 재구성할 수 있다.
「모델에 보인다 ⟺ 기록되어 있다」는 이번 과의 심장입니다: 모델이 볼 수 있는 것은 반드시 로그에 기록되어 있고, 로그에 없는 것은 모델도 볼 수 없습니다. 「모델이 몰래 무언가를 사용했는데 기록되지 않은」 블랙홀도 존재하지 않고, 「로그에는 기록됐지만 모델이 볼 수 없는」 유령도 존재하지 않습니다. 바로 이 때문에 영속화, 복구, fork, 리플레이, 텔레메트리, UI——전혀 관련 없어 보이는 여섯 개의 서브시스템이 모두 같은 이벤트 스트림에서 데이터를 가져오며, 서로 장부가 맞지 않는 일이 없습니다.
💡 비유하자면: 이것은 「일기 쓰기」가 아니라 「전 과정 녹화」입니다. 일기는 사후에 기억에 의존해 쓰기 때문에 빠뜨리거나 바꿀 수 있습니다. 녹화는 사건이 발생한 그 순간의 원본 기록이며, 모든 프레임이 진짜입니다.
3. 3계층 구조: 세션 → 턴 → 스텝
세션 로그는 뒤죽박죽이 아니라 명확한 3계층 구조를 가지고 있습니다:
会话 → 轮次 → 步骤:事件全都追加进日志,从任意检查点都能重建
- 세션(Session): 하나의 온전한 상호작용 히스토리로, 하나의 append-only 이벤트 로그에 대응하며, 전역적으로 유일한
SessionId를 가집니다; - 턴(Turn): 하나의 메시지를 가져오는(claim) 것으로 시작해, 이번 응답이 끝날 때까지입니다. 턴은
turn/start와turn/end두 이벤트로 둘러싸이며,turn/end는 종료 이유——정상 종료,aborted취소,error실패——를 있는 그대로 기록하고, 크래시 복구 시에는interrupted가 합성됩니다; - 스텝(Step): 한 번의 모델 요청 + 그에 딸린 도구들. 스텝은
step/start와step/end로 둘러싸입니다. 성공한 모델 호출은 모두assistant/message를 남깁니다——그 호출이 빈 내용을 반환했거나max-tokens때문에 잘렸더라도 로그는 똑같이 기록합니다(빈 내용은 파생된 메시지 히스토리에 들어가지 않을 뿐이며, 영속화된 이벤트와 사용량은 모두 남아 있습니다).
agent-lifecycle 문서는 역할 분담을 한 문장으로 정확히 짚어 냅니다(출처: docs/agent-lifecycle.zh.md):
영속적인 리플레이 사실은
session/event에 저장되고, 실시간 제어와 상태는agent/*에 저장된다.
즉: 로그는 「무슨 일이 일어났는가」를 관리하고, 이벤트는 「지금 어떻게 되고 있는가」를 관리합니다. 전자는 정확하게 리플레이할 수 있고, 후자는 실시간 구동(예: running / idle 상태, 인박스 큐)을 담당합니다.
하나의 턴에는 보통 여러 개의 스텝이 있습니다: 모델이 「파일을 읽겠다」고 말함 → 도구 실행 → 결과 반환 → 모델이 「파일을 수정하겠다」고 말함 → 도구 실행 → …… 모델이 작업 완료라고 판단할 때까지 이어지고, 그제야 턴이 닫힙니다. 닫힌 턴 만이 안정적인 fork / 체크포인트 경계가 됩니다.
4. 같은 로그에서 모든 것을 되찾기: 복구, fork, 검색
복구: resumeSessionId로 이어서 실행
DSH의 에이전트 루프 드라이버(dsh-agent-loop)는 세션에 진입하는 두 가지 경로를 제공합니다:
- 생성:
ctx.agents.create(...)——새로운sessionId로 처음부터 시작; - 복구:
ctx.agents.resume({ resumeSessionId, ... })——이미 존재하는 영속화된 세션을 로드해 이어서 실행.
agent-loop 문서는 복구를 이렇게 설명합니다(출처: packages/core/agent-loop/README.zh.md):
ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }):ctx.sessionPersistence를 통해 영속화된 세션을 로드하고, 같은 id로 에이전트를 등록하며, 히스토리를 재구성한다…… 턴 번호와 파생 히스토리는 로드된 로그에서 이어진다. 이 작업에는 세션 영속화 백엔드가 필요하며, 영속화가 없으면resume은 명확한 오류로 거부된다.
세 가지 요점: 복구는 처음부터 다시 재생하는 것이 아니라, 번호를 이어서 계속 실행하는 것입니다(턴 번호와 파생 히스토리가 로드된 로그에서 이어집니다). 복구에는 세션이 실제로 영속화되어 있어야 합니다. 영속화 백엔드를 아예 설정하지 않았다면, 성공한 척하지 않고 명확한 오류를 냅니다——지원하지 않는 편을 택하더라도, 「똑같아 보이지만 사실은 컨텍스트를 잃은」 가짜 복구를 건네주지는 않습니다.
fork: 복사 경계
「다른 길을 시도해 보고」 싶나요? ctx.sessions.fork(source, boundary?, childSessionId?)를 사용하세요. 그 의미는 다음과 같습니다(출처: packages/core/session/README.zh.md):
라이브 세션 객체 또는 id를 해석하고,
boundary이벤트 서수(해당 이벤트 포함)까지의 시드를 선택하며(기본값은 현재 마지막 이벤트), 선택된 접두사의 끝에 열린 턴이 없어야 하고, 계보 메타데이터가 있는 라이브 자식 세션을 생성한다.
- 복사 경계: 기본값은 현재 마지막 이벤트이며,
boundary를 명시적으로 지정할 수도 있습니다. 단, 선택된 접두사의 끝은 반드시 닫힌 턴이어야 합니다——턴의 한가운데에서 fork할 수는 없습니다; - 계보 메타데이터: 자식 세션은
parentSession등의 정보를 기록하므로, 어떤 것이 복사본인지 조회하면 바로 알 수 있습니다; - 원래 세션은 무손실: fork는 「로그 읽기 + 새 세션 파생」일 뿐이며, 원본 세션에는 전혀 손을 대지 않습니다.
검색: session-query 전문 검색
로그는 날이 갈수록 커집니다. 그 안에서 정확하게 무언가를 찾으려면 어떻게 해야 할까요? DSH는 전용 세션 검색 기능 패밀리(session-query)를 제공합니다. 이것은 「승인된 라이브 및 영속 세션 로그 검색을 제공하며, 컴팩션과 독립적」입니다——어떤 컨텍스트가 나중에 컴팩션으로 대체되더라도 원본 로그는 그대로 남아 있어 똑같이 검색할 수 있습니다.
searchSessions(): 세션 전반에 걸친 전문 검색으로, 가장 매칭이 강한 이벤트별로 그룹화해 반환합니다;searchEvents(): 단일 세션 내에서 이벤트를 검색합니다;- SQLite 프로바이더는 전문 검색(FTS)으로 인덱스를 구현합니다;
- 보안 세부 사항: 쿼리어는 리터럴로 해석되며, 실행 가능한 검색 구문으로 취급되지 않습니다——검색을 코드가 아니라 데이터로 봅니다.
🎁 세 가지의 공통점: 복구, fork, 검색은 모두 로그를 읽기만 합니다. 로그가 권위 있고, 완전하고, 추가 전용이기 때문에 이 세 가지 작업이 각각 독립적으로 성립하고 서로 충돌하지 않을 수 있습니다.
핵심 요점 복습
이번 과에서는 다음 다섯 문장만 기억하면 충분합니다:
- 실행 재구성 가능성은 DSH의 약속입니다: 모델이 볼 수 있는 모든 내용은 권위 있는 세션 로그에 기록됩니다——모델에 보인다 ⟺ 기록되어 있다.
- 세션 = append-only 이벤트 스트림: 이벤트는 추가만 가능하고 다시 쓸 수 없습니다. 모델 메시지 히스토리와 UI transcript는 로그에서 파생된 프로젝션이며, 로그가 유일한 진실의 원천입니다.
- 3계층 구조: 세션 → 턴 → 스텝. 턴은 하나의 메시지를 가져오고 닫힙니다. 스텝 = 한 번의 모델 요청 + 그에 딸린 도구들.
- 복구와 fork:
resumeSessionId는 영속화된 로그에서 번호를 이어서 계속 실행합니다.fork(source, boundary)는 닫힌 턴의 안정적인 경계에서 병렬 세션을 복사하며, 원래 세션은 무손실입니다. - 전문 검색: session-query가 로그를 검색 가능하게 만들고(
searchSessions/searchEvents), 컴팩션과 독립적입니다——대체된 옛 컨텍스트도 찾아낼 수 있습니다.
🚀 다음 과 예고: 모델에게 「기억」만으로는 부족하며, 손을 움직일 수 있어야 합니다. 다음 과에서는 「도구와 실행」을 다룹니다——에이전트가 어떻게 「함수 한 번 호출」을 권한이 있고, 샌드박스가 있고, 기록이 있는 진짜 행동으로 바꾸는지 알아봅니다.
셀프 테스트 · 루프와 세션
답을 모두 선택한 후 「답안 제출」을 클릭하면 정답 여부와 해설을 확인할 수 있습니다.
