
【#8】 OpenClaw 해독하기 — 대화와 문맥을 키우고 구성하는 규율
요약
OpenClaw 프레임워크의 세션 관리 및 컨텍스트 엔진 구조를 분석합니다. 대화 이력을 트리(DAG) 구조로 저장하여 분기(branching)를 지원하고, 토큰 효율성을 위해 문맥을 구성하는 메커니즘을 다룹니다.
핵심 포인트
- 대화 이력을 트리 구조(DAG)로 관리하여 대화 분기 및 복구를 지원함
- JSONL 기반의 append-only 방식으로 세션 데이터를 안전하게 저장
- parentId 체인을 통해 현재 활성화된 대화 경로의 문맥을 정확히 추출
- ContextEngine 인터페이스를 통한 유연한 문맥 구성 및 압축 로직 제공
본 기사의 코드는 OpenClaw main 브랜치의 cee2aca409 (version 2026.6.10) 시점을 참조합니다. 행 번호는 업데이트에 따라 어긋날 수 있습니다.
연재 「OpenClaw 해독하기」
#07의 에이전트 루프(Agent Loop)는 "문맥 (context)"을 받아 LLM을 호출합니다. 이번 테마는 그 문맥을 만들고, 저장하고, 넘치면 압축하는 것입니다. 대화 이력을 트리 구조로 가지는 **세션 (session)**과, 토큰 예산 내에서 프롬프트를 구성하는 **컨텍스트 엔진 (context engine)**을 src/agents/sessions/, src/context-engine/, src/trajectory/에서 읽어옵니다.
OpenClaw의 세션은 추가 전용 (append-only) JSONL 트리입니다. 각 엔트리 (entry)는 다음을 가집니다.
id: 8자리의 고유 ID (충돌 체크 포함)parentId: 부모 엔트리에 대한 포인터 (이를 통해 DAG를 형성)type:message/compaction/branch_summary/custom등timestamp: ISO 문자열
저장 위치는 ~/.openclaw/agent/sessions/--{cwd-encoded}--/{sessionId}.jsonl 입니다. 1행 = 1 JSON이며, 쓰기는 파일 단위로 큐잉(queuing)되어 순서가 보장됩니다 (src/config/sessions/transcript-append.ts:37).
왜 단순한 배열이 아니라 **트리 (tree)**인가? 분기 (branching)를 표현하기 위해서입니다. leafId 포인터가 "현재 어디에 있는지"를 가리키며, 과거의 엔트리로 leaf를 되돌리면 그곳에서부터 다른 가지를 뻗을 수 있습니다.
LLM에 전달할 이력은 현재의 leaf에서 parentId를 따라 root까지 거슬러 올라간 뒤, 이를 반전시켜 얻습니다 (buildSessionContext(), src/agents/sessions/session-manager.ts:383).
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
...
byId 맵 (map)을 통해 O(1) 룩업 (lookup)을 수행합니다. 활성화된 가지에 있는 엔트리만이 문맥에 포함되므로, 다른 가지 (과거에 분기하여 버려진 대화)는 섞이지 않습니다.
#03에서 예고한 불변성 (invariant)이 바로 여기 있습니다. 메시지 추가는 반드시 appendMessage() (session-manager.ts:2333)를 거쳐야 합니다.
appendMessage(message): string {
const entry: SessionMessageEntry = {
type: "message",
...
parentId에 전달하는 것은 순수한 leafId가 아니라 appendParentId입니다. 통상적으로 이것은 현재의 leaf를 가리키지만, 분기나 논리적 부모 (logicalParentsById)가 얽히는 상황에서는 "지금 추가해야 할 부모"를 해결한 값이 됩니다.
src/gateway/server-methods/AGENTS.md가 "생(raw) JSONL로 type: "message"를 직접 작성하지 말고, 반드시 SessionManager.appendMessage(...)를 사용하라"고 강제하는 이유가 이것입니다. 직접 작성하는 것은 parentId 체인을 깨뜨리고, 압축 및 이력을 파괴합니다.
문맥 구성 로직 자체는 플러그인화되어 있습니다. ContextEngine 인터페이스 (src/context-engine/types.ts:298)는 다음과 같은 계약을 가집니다.
bootstrap(): 세션 단위의 초기화ingest(): 메시지 1건의 수집assemble(): 토큰 예산 내에서 모델에 전달할 순서가 있는 메시지를 구성compact(): 요약 및 가지치기 (pruning)로 토큰을 절약afterTurn()/maintain(): 턴 이후의 유지보수 및 트랜스크립트 (transcript) 재작성
assemble()의 반환값이 시사하는 바가 큽니다.
type AssembleResult = {
messages: AgentMessage[];
estimatedTokens: number;
...
promptAuthority를 통해 "이 조립(assembly)이 권위 있는 최종 형태인지, 아니면 사전 추정치를 초과할 가능성이 있는지"를 반환합니다. 토큰 예산은 모델의 컨텍스트 윈도우 (context window)에서 예약분을 제외하여 산출하며, 전송 전에 압축 필요 여부를 판정합니다.
/compact CLI 측 라이프사이클 (lifecycle)은 src/agents/command/cli-compaction.ts (runCliTurnCompactionLifecycle가 진입점)가 관리합니다. 다만 실제 처리는 여러 모듈에 분산되어 있으며, 대략적인 흐름은 다음과 같습니다.
- 압축 필요 여부 판정 —
shouldCompact는 함수가 아니라 불리언 (boolean) 필드이며,src/agents/embedded-agent-runner/run/preemptive-compaction.ts가 산출합니다. prepareCompaction()(src/agents/sessions/compaction/compaction.ts)에서 이력의 절단점을 특정합니다.generateSummary()(압축 러너 측의 의존성)를 통해 쳐낼 부분을 LLM으로 요약합니다.CompactionEntry를 요약 및firstKeptEntryId와 함께 트랜스크립트 (transcript)에 추가합니다.
이것들은 cli-compaction.ts가 이 순서대로 일직선상에서 호출하는 것이 아니라, cli-compaction.ts가 오케스트레이션 (orchestration)을 수행하고 판정, 준비, 요약은 위의 각 모듈이 담당하는 분업 구조입니다.
CompactionEntry의 형태 (session-manager.ts:95)는 다음과 같습니다.
interface CompactionEntry<T = unknown> extends SessionEntryBase {
type: "compaction";
summary: string;
...
압축 또한 트리에 추가하는 것일 뿐, 과거를 파괴하지 않습니다. "firstKeptEntryId 이전은 요약으로 대체, 이후는 원문"이라는 방식으로, 문맥을 조립할 때 오래된 부분을 요약으로 교체할 수 있습니다.
세션 커맨드의 다른 두 가지: /new는 TUI 스코프의 고유 세션 키 (tui-{uuid})를 생성하여 클라이언트 상태를 격리하며, /reset은 resetSession()을 통해 런타임 메타데이터를 클리어합니다 (이력은 비파괴적으로 보존됩니다).
세션이 "대화의 트리"라면, 트래젝토리 (trajectory, src/trajectory/)는 "실행 이벤트의 흐름"입니다. TrajectoryEvent (types.ts:13)는 버전이 포함된 봉투 (envelope) 형태이며 다음과 같습니다.
source:"runtime" | "transcript" | "export"type: 이벤트 종류 (tool-call, model-complete 등)sessionId/runId/entryId/parentEntryId: 실행 및 세션과의 대응data: 이벤트 고유 페이로드 (payload)ts/seq/sourceSeq: 타이밍과 순서
이는 세션 트랜스크립트를 보완하는 위치에 있으며, 동일한 sessionId로 상호 참조합니다. 내보내기 (export) 시에는 TrajectoryBundleManifest를 생성하여 런타임 이벤트와 트랜스크립트 엔트리를 결합합니다 (서두의 git 로그에 있었던 "trajectory session branch bundle" 테스트는 이 주변의 내용입니다).
사소하지만 효과적인 것은 루트 AGENTS.md의 이 규약입니다.
Prompt cache: 모델/도구 페이로드 전의 maps/sets/registries/plugin lists/files/network results에 대한 결정론적 순서 (deterministic ordering). 가능한 경우 이전 트랜스크립트 바이트를 보존할 것.
LLM 프로바이더의 프롬프트 캐시 (Prompt Cache)는 "이전과 동일한 시작 바이트 열"일 때 효과가 있습니다. 맵 (Map)이나 세트 (Set), 플러그인 목록의 순서가 요청마다 흔들리면 캐시가 무효화되어 속도가 느려지고 비용이 높아집니다. 따라서 모델/툴 페이로드 (model/tool payload)에 싣기 전에 결정론적으로 정렬합니다. 구현 예시는 src/agents/prompt-cache-stability.ts:17 입니다.
export function normalizePromptCapabilityIds(capabilities: ReadonlyArray<string>): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
...
중복을 제거하고, 공백과 줄바꿈을 정규화하며, localeCompare를 사용하여 사전순으로 정렬합니다. 툴 (Tool) 또한 이름순으로 구체화 (materialize) 되며, 입력 순서가 [zeta, alpha, mu] 이더라도 [alpha, mu, zeta]로 정렬됨이 테스트를 통해 보장됩니다. "캐시를 깨뜨리지 않는다"라는 운영 요구사항이 코드의 순서 규율 (ordering discipline)로 결정화되어 있는 것입니다.
- 세션은 분기 (branching)를 표현할 수 있으며, 추가는 반드시
parentId로 연결되는 JSONL 트리 (tree)를 통해appendMessage를 거쳐 이루어집니다. - 문맥 (Context)은 리프 (leaf)에서 루트 (root)로의 역추적을 통해 구성하며, 활성화된 가지 (branch)만을 사용합니다.
- 컨텍스트 엔진 (Context Engine)은 **교체 가능 (pluggable)**하며,
assemble이 토큰 예산 내의 최종 형태를 반환합니다. - 압축 (Compression) 또한 트리에 대한 추가 (
CompactionEntry) 방식으로 이루어져 과거를 파괴하지 않습니다. - 트래젝토리 (Trajectory)는 실행 이벤트의 기록으로서 세션을 보완합니다.
- **결정론적 순서 (deterministic ordering)**로 프롬프트 캐시를 작동시키는 규율이 코드에 스며들어 있습니다.
#09는 대화를 넘어 기억을 갖는 메커니즘인 **메모리 시스템 (Memory System)**입니다. "동시에 활성화할 수 있는 메모리 플러그인은 하나뿐"이라는 배타적 슬롯, MemorySearchManager 계약, 그리고 memory-core / lancedb / wiki / active-memory의 역할 분담을 분석합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기