OpenClaw Runtime · End-to-End · 코드 + 로그 매핑

Tool / Skill 질문이
답변이 되기까지

사용자가 skill/tool을 요청하는 순간부터 payload 조립, transcript 저장, 캐싱, pruning, compaction을 거쳐 답변이 전달되는 전 과정 — 각 단계별 실제 코드 위치로그 관측 명령어까지.

Agent Loop trajectory.jsonl cache-trace.jsonl Transcript Pruning / Compaction jq 명령어

01 · Big Picture

전체 실행 파이프라인

각 단계 옆 🔍 관측 패널에서 실제 로그 파일과 jq 명령어를 확인한다.

User
질문 도착
채널 어댑터(Telegram/CLI 등)가 메시지를 정규화. dispatchInboundMessage로 전달.
src/auto-reply/dispatch.ts
관측 포인트
trajectory: user.message sessions/*.jsonl
# 현재 세션 파일 찾기
openclaw sessions --json --all-agents | jq '.[0] | {sessionId, file}'

# 들어온 user 메시지 확인
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="user.message") | {ts, data}'
OpenClaw
Session 로드 + Payload 조립
sessions/*.jsonl에서 이전 대화를 읽어 messages[]를 재구성. system prompt 조립.
attempt.ts → replay-history.ts → system-prompt.ts
관측 포인트
cache-trace: session:loaded cache-trace: session:raw-model-run
# cache-trace 활성화 후 session 로드 확인
OPENCLAW_CACHE_TRACE=1 OPENCLAW_CACHE_TRACE_MESSAGES=1 \
  openclaw gateway run

# 로드된 메시지 수 확인
cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage=="session:loaded") | {ts, messageCount}'
OpenClaw
Budget Gate — 사전 토큰 검사
예상 토큰이 context window를 초과하면 Pruning → Compaction 수행 후 재조립.
src/agents/pi-embedded-runner/run/preemptive-compaction.ts
관측 포인트
trajectory: session.compaction cache-trace: session:limited
# compaction 발생 여부 확인
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="session.compaction") | {ts, data}'

# limit 적용 후 남은 메시지 수
cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage=="session:limited") | {messageCount}'
OpenClaw
System Prompt + cache_control 주입
stable prefix(skills, tools, 오래된 history)에 cache_control: ephemeral을 붙임. cacheStablePromptPrefix()가 LRU 캐시(64개)로 관리.
src/agents/system-prompt.ts → resolveCacheRetention()
관측 포인트
trajectory: context.compiled cache-trace: prompt:before
# 조립된 system prompt 확인 (가장 유용!)
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="context.compiled") | .data.systemPrompt'

# 이번 턴 tools 목록 확인
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="context.compiled") | .data.tools[].name'
LLM
Turn 1 — LLM 호출 (tool_use 반환)
LLM이 Skills 목록과 SKILL.md를 읽고 tool_use를 결정. 반드시 prompt.submittedmodel.completed 쌍으로 기록.
src/agents/anthropic-transport-stream.ts
관측 포인트
trajectory: prompt.submitted trajectory: tool.call cache-trace: stream:context
# 전송된 실제 payload 확인 (민감정보 주의!)
OPENCLAW_CACHE_TRACE=1 OPENCLAW_CACHE_TRACE_MESSAGES=1 \
  OPENCLAW_CACHE_TRACE_PROMPT=1 OPENCLAW_CACHE_TRACE_SYSTEM=1 \
  openclaw gateway run

cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage=="stream:context")'

# LLM이 반환한 tool_use 확인
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="tool.call") | {ts, data}'
OpenClaw
Tool 실행 + tool_result 생성
tool.execute() 실행 → tool_result를 role: "user"로 messages[]에 추가. sessions/*.jsonl에 append.
src/agents/agent-tool-definition-adapter.ts
관측 포인트
trajectory: tool.result sessions/*.jsonl append
# tool_result 내용 확인
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="tool.result") | {ts, data}'

# 세션 JSONL에서 tool_use/tool_result 쌍 확인
cat ~/.openclaw/agents/main/sessions/<id>.jsonl \
  | jq 'select(.type=="tool_use" or .type=="tool_result")'
LLM
Turn 2~N — 루프 반복
messages[]에 tool_result가 쌓인 전체 히스토리를 다시 전송. tool_use가 없을 때까지 반복.
attempt.ts loop · SKILL.md 단계 수 = tool 호출 횟수
관측 포인트
trajectory: tool.call → tool.result (반복) trajectory: model.completed (루프 종료)
# 실시간으로 trajectory 팔로우
tail -f ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type | test("tool|model|prompt"))'

# 이번 세션의 전체 tool 호출 횟수
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="tool.call")' | wc -l
Store
Transcript 저장 (append-only)
모든 메시지가 sessions/*.jsonl에 영구 기록. 디스크 원본은 절대 수정되지 않는다.
~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl
관측 포인트
sessions/*.jsonl (원본) cache-trace.jsonl (전송본)
# 세션 JSONL 전체 메시지 타입 분류
cat ~/.openclaw/agents/main/sessions/<id>.jsonl \
  | jq '.type' | sort | uniq -c

# 전송된 실제 payload (anthropic-payload.jsonl)
cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage=="stream:context") | .messageCount'
User
최종 답변 전달
LLM의 text 응답이 deliverReplies를 통해 채널로 전송. trajectory에 session.ended 기록.
trajectory: assistant.message → model.completed → session.ended
관측 포인트
trajectory: assistant.message trajectory: model.completed trajectory: session.ended
# 최종 응답 텍스트 확인
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="assistant.message") | .data'

# 토큰 사용량 확인
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="model.completed") | .data.usage'

01 · Variant — Multimodal Input

입력이 이미지일 때 — 파이프라인 앞단의 분기

앞의 전체 흐름은 텍스트 입력("수원 날씨") 기준이다. 입력이 이미지면 메인 루프(③ Agent Runtime) 진입 전에 전처리 분기가 추가된다. 핵심 갈림길은 단 하나 — "활성 모델이 vision 네이티브인가".

한 줄 요약: 이미지는 ① 첨부 staging → ② 정규화 → ③ modelSupportsVision() 분기. vision 모델이면 이미지 블록을 그대로 주입, 아니면 별도 모델로 "텍스트 설명"으로 변환해 주입. 그 뒤부터는 기존 파이프라인과 동일하게 흐른다.

A. 메인 루프 진입 전 전처리 (텍스트엔 없는 단계)

attachment staging

① 첨부 파싱 + staging

채널 어댑터가 이미지를 받으면 chat.send 핸들러가 첨부를 파싱·staging한다. 정책(허용 여부·크기) 검사 후 통과한 것만 다음 단계로.

chat.ts: attachment 파싱media/store.ts
normalize

② 이미지 정규화 + 축소

HEIC/HEIF → JPEG 변환, EXIF 방향 보정, base64 인코딩. 그리고 핵심 — 바이트 예산(DEFAULT_MAX_BYTES.image)·픽셀 상한(MAX_IMAGE_INPUT_PIXELS)을 넘으면 반복 축소(resize)한다. 자르는(crop/tile) 게 아니라 줄인다 — 아래 상세 참고.

image-input-normalize.tsmedia/image-ops.ts
vision 분기

modelSupportsVision() — 여기서 두 갈래로 갈린다

모델 카탈로그에서 활성 모델의 vision 지원 여부를 조회. 이 결과에 따라 아래 B의 두 경로 중 하나로 진행한다.

media-understanding/runner.ts (runCapability)model-catalog

A-2. 확인: 이미지를 "자르는" 게 아니라 "줄여서" 보낸다

흔한 오해 — "큰 이미지를 조각내(tile/crop) LLM에 보낸다". src/media/image-ops.ts를 확인하면 타일링·크롭·분할 코드는 없다. 실제로는 최대 변(longest side)을 단계적으로 축소 + JPEG 품질을 사다리로 하향해 바이트 예산에 맞춘다.

오해 vs 실제
🔴 오해: 이미지를 N조각으로 잘라서 여러 장으로 전송 (tiling/crop)
🟢 실제: 한 장 그대로, 해상도와 품질만 낮춰서 전송 (resize + recompress). 조각내지 않는다.

실제 축소 알고리즘 (image-ops.ts)

1단계 · 차원(최대 변) 그리드로 축소 — buildImageResizeSideGrid()
원본 1800 1600 1400 1200 1000 800 px

최대 변(가장 긴 쪽) 픽셀을 내림차순 그리드로 단계 축소. 가로세로 비율은 유지(찌그러지지 않음).

2단계 · JPEG 품질 사다리 하향 — IMAGE_REDUCE_QUALITY_STEPS
85 75 65 55 45 35

차원 조정으로도 부족하면 JPEG 품질을 85부터 35까지 단계적으로 낮춰 바이트를 더 줄인다.

종료 조건 · 하드 상한
target: DEFAULT_MAX_BYTES.image 이하가 될 때까지 반복  ·  ceiling: MAX_IMAGE_INPUT_PIXELS = 25,000,000 (25MP)

Rastermill 라이브러리가 (차원 우선) 최적 조합을 탐색해 maxBytes 제약을 만족시킨다. resizeToJpeg() / optimizeImageToPng().

상수 / 함수값 / 역할위치
MAX_IMAGE_INPUT_PIXELS25,000,000 (25MP) 픽셀 하드 상한media/image-ops.ts
IMAGE_REDUCE_QUALITY_STEPS[85, 75, 65, 55, 45, 35] 품질 사다리media/image-ops.ts
buildImageResizeSideGrid()최대 변 후보 [1800…800] 내림차순 생성media/image-ops.ts
resizeToJpeg() / resizeToPng()maxSide·quality로 1회 리사이즈+인코딩media/image-ops.ts
optimizeImageToPng()target byte 이하로 최적화(Rastermill 위임)media/image-ops.ts
convertHeicToJpeg()HEIC/HEIF → JPEG 변환media/image-ops.ts
왜 자르지 않고 줄일까: 자르면(crop) 정보가 사라지고, 타일로 쪼개면(tile) 이미지가 여러 장이 돼 토큰이 배로 든다. 한 장으로 해상도/품질만 낮추면 전체 맥락을 유지하면서 토큰·바이트를 통제할 수 있다. 단, 작은 글씨가 많은 스크린샷은 과도한 축소 시 판독이 어려워질 수 있다(트레이드오프).

B. 갈림길 — Vision 네이티브 vs 비-Vision

경로 A · Vision 네이티브

modelSupportsVision = true

이미지를 image content block으로 그대로 주입한다. 텍스트 변환 단계를 완전히 스킵.

{ "role": "user",
  "content": [
    { "type": "image",
      "source": {
        "type": "base64",
        "media_type": "image/jpeg",
        "data": "<base64>" } },
    { "type": "text",
      "text": "이 사진 설명해줘" }
  ] }
특징가장 정확. 모델이 픽셀을 직접 본다. 단, 이미지 토큰 비용이 크다(고해상도일수록 ↑).

경로 B · 비-Vision

modelSupportsVision = false

별도 vision 모델/CLI로 이미지 → 텍스트 설명 변환 후, 그 텍스트를 메인 모델에 주입.

// media-understanding가 먼저 처리
// → "텍스트 설명" 생성
{ "role": "user",
  "content": [
    { "type": "text",
      "text": "[이미지 설명: 강아지가\n   잔디밭에서 뛰는 사진]\n이 사진 설명해줘" }
  ] }
Fallback 체인설정 모델 → 자동 선택 → 로컬 바이너리(whisper 등) → API provider → CLI. 순서대로 시도하다 성공 시 반환.
오디오는 항상 경로 B다: 모델은 소리를 "듣지" 못하므로 음성 메모는 예외 없이 전사(transcription)를 거쳐 텍스트로 변환된다. audio-transcription-runner.ts(whisper / sherpa-onnx 로컬 또는 API). 즉 이미지는 모델에 따라 갈리지만, 오디오는 무조건 텍스트화.

C. 메인 파이프라인 합류 후 — 이미지 특화 지점

전처리가 끝나면 기존 파이프라인(sanitize→limit→assemble→wire)을 그대로 탄다. 단, 이미지 때문에 달라지는 지점이 셋 있다.

단계이미지일 때 무슨 일이관측 지점
Sanitize provider 호환을 위해 이미지 블록 형태를 정돈. 오래되거나 과대한 이미지를 정리. cache-trace: session:sanitized
Prompt 조립 이미지가 payload에 실리는 전용 단계. 텍스트 입력엔 없는 stage. cache-trace: prompt:images
Pruning 이미지는 토큰이 크다 → 히스토리에 쌓인 과거 이미지가 트리밍 1순위. 최근 것만 남김. cache-trace: session:limited

D. 텍스트 입력 vs 이미지 입력 한눈에

구분텍스트 입력이미지 입력
전처리없음 (바로 루프 진입)staging → 정규화 → vision 분기
정규화HEIC→JPEG, base64, 크기 상한
모델 주입 형태text 블록image 블록(vision) 또는 변환된 text(비-vision)
추가 모델 호출없음비-vision이면 변환용 모델 1회 추가
토큰 비용본문 길이 비례해상도 비례, 보통 텍스트보다 큼
Pruning 우선도보통높음 (큰 토큰 → 먼저 잘림)
전용 관측 stageprompt:images

E. 이미지 입력 관측 명령

# 이미지 처리 stage 확인 (cache-trace 활성화 필요)
OPENCLAW_CACHE_TRACE=1 OPENCLAW_CACHE_TRACE_MESSAGES=1 openclaw gateway run

# prompt:images = 이미지가 payload에 실리는 전용 stage
cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage=="prompt:images")'

# 비-vision 경로에서 변환 모델이 돌았는지 (media-understanding)
openclaw logs --follow --json | grep -i "media-understanding\|vision\|transcription"

# 모델이 vision 네이티브인지 확인
openclaw status --json | jq '.model'

01 · Variant — Output & Delivery

응답이 돌아가는 길 — 전달 & 미디어 생성

입력의 대칭. 루프가 끝난(또는 도는 중) 응답이 채널로 돌아가는 경로다. 두 갈래가 있다 — ① 미디어 생성(모델이 이미지·음성을 만드는 경우)과 ② 전달(delivery)(텍스트·미디어를 채널 규격에 맞춰 보내는 경우).

핵심: 미디어 생성은 자동이 아니라 tool 호출이다(memory_search와 동일). 모델이 "이미지를 만들어야겠다"고 판단 → tool_use → 생성 런타임 → asset 저장 → tool_result가 그 asset을 참조. 최종 답변과 함께 deliverReplies가 채널로 내보낸다.

A. 미디어 생성 = tool (이미지·음성·영상)

생성 흐름

model → tool → asset
// 1. 모델이 생성 tool 호출
{ "type": "tool_use",
  "name": "generate_image",
  "input": { "prompt": "..." } }

// 2. 런타임이 provider 호출 → 저장
// 3. tool_result가 asset 참조
{ "type": "tool_result",
  "content": "saved: media/abc.png" }
코드image-generation/runtime.tsprovider-registry.tsimage-assets.ts(저장) → normalization.ts(형식 정규화).

생성 종류

전부 같은 패턴
image-generation — 이미지 생성. OpenAI 호환 provider.
tts — 텍스트 → 음성(speech). 음성 답장.
music-generation — 음악 생성.
video-generation — 영상 생성.
💡 전부 provider-registry + 정규화 + asset 저장 동일 구조. 입력의 media-understanding과 대칭(이해 ↔ 생성).
생성은 비싸고 느리다: 그래서 자동 실행이 아니라 모델이 필요하다고 판단할 때만 tool로 호출. 생성 결과(asset)는 transcript에 참조(경로)로만 남기고 바이너리 자체는 media/store.ts에 둔다 — 히스토리에 base64를 쌓으면 컨텍스트가 폭발하므로.

B. 전달 파이프라인 — deliverReplies

모델의 최종 응답(ReplyPayload)을 채널 규격에 맞춰 변환·전송한다. 채널마다 메시지 크기·포맷·미디어 API가 다르므로 이 단계가 그 차이를 흡수한다 — 입력의 channel adapter와 대칭.

ReplyPayload

① ReplyPayload 생성

루프 결과(text + 생성된 미디어 asset 참조 + 메타)를 전달용 페이로드로 묶는다. final / block / progress 종류가 있다.

bot/delivery.replies.ts
chunk 분할

② Chunk 분할 + 포맷 변환

채널 메시지 크기 제한(예: Telegram 4096자)에 맞춰 쪼갠다. 동시에 HTML/markdown 등 채널 포맷으로 변환. 코드블록·마크다운이 경계에서 깨지지 않게 분할하는 게 핵심.

bot/body-helpers.tsbot/helpers.ts
media 해석

③ 미디어 해석 + 재시도

생성된 이미지/음성 asset을 채널 첨부로 변환. 전송 실패 시 retry(미디어 업로드는 특히 불안정). 입력의 정규화와 대칭되는 outbound 정규화.

bot/delivery.resolve-media.tsmedia/outbound-attachment.ts
threading

④ 스레딩 메타 부착

reply_to, thread_id(forum topic/DM topic) 등 "어느 대화에 붙일지" 메타데이터 부착. 세션 키와 연결.

bot/reply-threading.tsbot/native-quote.ts
send

⑤ 전송 + transcript 기록

채널 API로 최종 전송(sendMessage / editMessage). 보낸 assistant 응답도 append-only transcript에 기록(다음 턴의 history가 됨).

bot/delivery.send.tstrajectory: assistant.message

C. 스트리밍 전달 (선택)

응답이 길거나 생성이 느릴 때, 완성을 기다리지 않고 점진적으로(progressive) 보여준다.

draft 갱신생성 중 부분 응답을 editMessage로 계속 갱신. 사용자는 타이핑되는 것처럼 본다.
reply fence스트리밍 중 어디까지가 확정인지 경계를 둬, 같은 청크를 중복 전송하지 않게 한다.
함정edit 호출이 너무 잦으면 rate limit. 일정 간격/델타로 throttle 필요.

D. 전체 I/O 한 바퀴

단계입력 측 (IN)출력 측 (OUT)
경계 변환channel → normalized messageReplyPayload → channel 포맷
미디어media-understanding (이해)media-generation (생성)
정규화HEIC→JPEG, base64, 크기 상한chunk 분할, HTML, 첨부 변환
비-텍스트 처리vision 분기 / 오디오 전사생성 tool / tts
불안정성 대응provider retry미디어 전송 retry
기록user.message appendassistant.message append

E. 출력 관측 명령

# 미디어 생성 tool 호출 확인
cat <id>.trajectory.jsonl \
  | jq 'select(.type=="tool.call" and (.data.name | test("generate|tts|image|video"))) | .data'

# 최종 전달된 응답 + 토큰
cat <id>.trajectory.jsonl \
  | jq 'select(.type=="assistant.message") | .data'

# 전달 단계 로그 (chunk/미디어/재시도)
openclaw logs --follow --json | grep -i "deliver\|sendMessage\|resolve-media"

02 · Turn-by-Turn

각 턴에서 LLM에 전송되는 Payload

messages[]는 매 턴 sessions/*.jsonl에서 재조립된다. tool_use / tool_result 쌍이 쌓일수록 payload가 커진다.

핵심 원칙: LLM API는 stateless다. 개수 제한 없이 현재 세션 전체 히스토리를 매 턴 전송. context window 토큰 한계에 도달할 때까지 전부 들어간다.
T1

첫 번째 LLM 호출 — Skill 판단

API Call #1
전송되는 Payload
// POST → Anthropic / OpenAI
{
  "instructions": "[system prompt]\n# Skills\n- weather: ...\n# Memory Recall\n반드시 memory_search 먼저...",
  "messages": [
    // ← 이전 세션 히스토리 전체
    { "role": "user", "content": "수원 날씨 알려줘" }
  ],
  "tools": [
    { "name": "exec", ... },
    { "name": "memory_search", ... }
  ]
}
관측 명령
# stream:context = 전송 직전 payload
cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage==
    "stream:context") | {
    messageCount,
    systemLen,
    toolCount
  }'

# tool_use 반환 확인
cat <id>.trajectory.jsonl \
  | jq 'select(.type==
    "tool.call")'
T2

tool 실행 후 두 번째 LLM 호출

Tool Execute → API Call #2
누적된 messages[]
"messages": [
  // 이전 히스토리 전체 +
  { user: "수원 날씨 알려줘" },
  { assistant: tool_use #1 },  ← 추가
  { user: tool_result #1 },    ← 추가
]

// tool_use_id === tool_result.tool_use_id
// 이 쌍이 깨지면 LLM이 오류!
관측 명령
# sanitize 전후 메시지 수 비교
cat cache-trace.jsonl | jq '
  select(.stage |
    test("loaded|sanitized|limited|stream"))
  | {stage, messageCount}
'

# tool_result 내용 확인
cat <id>.trajectory.jsonl \
  | jq 'select(.type==
    "tool.result") | .data'
최종

text 반환으로 루프 종료

Loop End
루프 종료 조건
// tool_use 없이 text만 반환 → 루프 끝
{
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "수원 현재 22°C"
    }
  ]
}
// → deliverReplies → sendMessage
관측 명령
# 전체 루프 요약 (turn별 타입 흐름)
cat <id>.trajectory.jsonl \
  | jq '{type, ts}' \
  | head -40

# 토큰 비용 확인
cat <id>.trajectory.jsonl \
  | jq 'select(.type==
    "model.completed")
    | .data.usage'

03 · Storage Pipeline

Transcript → 정제 → Pruning → Compaction

디스크 JSONL은 불변(append-only) 원본. messages[]는 매 턴 재조립되며 아래 단계를 거친다. cache-trace의 stage 이름이 각 단계와 정확히 매핑된다.

💾

Disk Transcript

모든 메시지를 sessions/*.jsonl에 append-only 저장. 절대 수정 안 됨.

sessions/<id>.jsonl
🔧

Sanitize

thinking, image, tool-call 구조 정돈. provider가 거부할 형태 제거.

session:sanitized

Validate

provider별 turn contract 확인. 위반 메시지 제거.

session:sanitized
✂️

Pruning

캐시 TTL 만료 시 오래된 tool_result를 in-memory에서만 제거. JSONL 유지.

session:limited
🗜️

Compaction

budget 초과 시 오래된 대화를 요약본으로 교체.

session.compaction
📡

Provider Payload

최종 재조립된 messages[]를 API로 전송. cache-trace에 기록.

stream:context

단계별 메시지 수 감소 확인 명령

# cache-trace 활성화 (메시지 포함)
OPENCLAW_CACHE_TRACE=1 OPENCLAW_CACHE_TRACE_MESSAGES=1 openclaw gateway run

# 각 단계의 메시지 수를 한 번에 비교
cat ~/.openclaw/logs/cache-trace.jsonl | jq '
  select(.stage | test("loaded|raw-model|sanitized|limited|stream"))
  | {stage, messageCount}
'

# 출력 예시:
# {"stage":"session:loaded",       "messageCount":42}
# {"stage":"session:raw-model-run", "messageCount":42}
# {"stage":"session:sanitized",     "messageCount":40}  ← 2개 제거
# {"stage":"session:limited",       "messageCount":30}  ← 10개 pruning
# {"stage":"stream:context",        "messageCount":30}  ← 전송본

# Compaction 발생 확인
cat ~/.openclaw/agents/main/sessions/<id>.trajectory.jsonl \
  | jq 'select(.type=="session.compaction") | {ts, data}'

Pruning vs Compaction 비교

Before
user
안녕!
assistant
안녕~
tool_use
exec #1
tool_result
파일 100KB
user
날씨?
tool_use
exec #2
tool_result
22°C
Pruning
✂️
After Pruning
user
안녕!
assistant
안녕~
tool_use
exec #1
tool_result (제거)
파일 100KB
user
날씨?
tool_use
exec #2
tool_result
22°C

오래된 tool_result만 in-memory 제거.
JSONL 원본 유지.

Compaction
🗜️
After Compaction
summary
안녕 인사, 파일 조회, 날씨 22°C
user
날씨?
tool_use
exec #2
tool_result
22°C

정보 유지하면서 줄임.
최근 N턴 보존.

04 · Prompt Caching

cache_control로 토큰 비용 절감

stable prefix에 cache_control을 붙여 Anthropic 서버 캐싱 활용. cache-trace의 cache:result stage로 hit 여부 확인.

Stable Prefix
system prompt SKILL.md 목록 tools 정의 오래된 history

거의 변하지 않는 부분. cache_control: ephemeral. TTL: short=5분, long=1시간. cacheStablePromptPrefix() LRU 64개.

Dynamic Suffix
최근 3턴 현재 user 메시지

매 턴마다 바뀌는 부분. 캐싱 없이 매번 새로 전송.

Cache Hit 확인
# cache hit 여부와 절감된 토큰 확인
OPENCLAW_CACHE_TRACE=1 openclaw gateway run

cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage=="cache:result") | {
    cacheHit,
    cacheReadTokens,
    cacheWriteTokens,
    inputTokens
  }'

# cache:state = 현재 캐시 상태
cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage=="cache:state")'

05 · History vs Memory

Session History와 Memory의 차이

항목Session HistoryMemory (MEMORY.md)
역할현재 세션 대화 흐름세션을 넘나드는 장기 기억
저장 위치sessions/<id>.jsonlMEMORY.md / memory/*.md
LLM 접근매 턴 자동으로 messages[]에 포함memory_search tool 호출로만 접근
로그 확인session:loaded stagetrajectory: tool.call (memory_search)
세션 리셋 후새 세션, 이전 접근 불가영구 보존, 검색 가능
관측 명령cat sessions/<id>.jsonl | jq '.type'openclaw memory status --deep
# memory_search 호출 이력 확인
cat <id>.trajectory.jsonl \
  | jq 'select(.type=="tool.call" and .data.name=="memory_search") | .data'

# memory 인덱스 상태
openclaw memory status --deep

# 특정 키워드 검색
openclaw memory search "날씨" --json

06 · Walkthrough

실전 예시: "수원 날씨 알려줘"

1
메시지 도착 → trajectory: user.message

Telegram bot → buildTelegramMessageContextrunInboundReplyTurn

trajectory: user.message
2
memory_search 선제 호출 (시스템 프롬프트 지시)

"prior work 전 반드시 memory_search" 지시에 따라 LLM이 먼저 메모리를 검색.

T1: tool.call (memory_search)tool.result
3
weather SKILL.md 매칭 → exec tool_use

스킬 목록에서 "weather" 발견. SKILL.md 읽고 exec: curl wttr.in/Suwon 결정.

T2: tool.call (exec)
4
OpenClaw exec 실행 → tool_result

curl 실행 후 결과를 role:"user"로 추가. JSONL append.

Suwon: ⛅ 22°Ctool.result
5
최종 text 반환 → loop 종료

tool_use 없이 text만 반환. deliverReplies로 Telegram 전송.

assistant.messagemodel.completedsession.ended

위 예시를 실시간으로 추적하는 명령

# 1. 세션 ID 확인
openclaw sessions --json --all-agents | jq '.[0]'

# 2. 실시간 trajectory 팔로우
tail -f ~/.openclaw/agents/main/sessions/<SESSION_ID>.trajectory.jsonl \
  | jq '{type, ts, data: .data}'

# 3. 한 번에 전체 흐름 요약 (실행 후)
cat ~/.openclaw/agents/main/sessions/<SESSION_ID>.trajectory.jsonl \
  | jq [{type, ts: .ts[11:19]}]

# 4. tool 호출 목록만 추출
cat <SESSION_ID>.trajectory.jsonl \
  | jq 'select(.type | test("tool")) | {type, name: .data.name}'

07 · 로그 관측 완전 가이드

실제 로그로 각 단계를 직접 확인하기

파일 경로, 환경변수 설정, 단계별 jq 명령어를 한 곳에 모았다. 위에서 아래로 순서대로 실행하면 전체 파이프라인을 직접 관측할 수 있다.

① 파일 경로 맵

Sessions ~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl
Trajectory ~/.openclaw/agents/<agentId>/sessions/<sessionId>.trajectory.jsonl
Cache Trace ~/.openclaw/logs/cache-trace.jsonl
App Log ~/.openclaw/logs/openclaw-YYYY-MM-DD.log
Traj Dir $OPENCLAW_TRAJECTORY_DIR (기본: sessions 폴더와 동일)

② 환경변수 설정

환경변수설명
OPENCLAW_TRAJECTORY 0 trajectory 기록 비활성화 (기본값: 활성화됨)
OPENCLAW_TRAJECTORY_DIR /path/to/dir trajectory 파일 저장 위치 변경
OPENCLAW_CACHE_TRACE 1 cache-trace 활성화 (기본값: 비활성)
OPENCLAW_CACHE_TRACE_MESSAGES 1 cache-trace에 메시지 내용 포함
OPENCLAW_CACHE_TRACE_PROMPT 1 cache-trace에 prompt 내용 포함
OPENCLAW_CACHE_TRACE_SYSTEM 1 cache-trace에 system 정보 포함
OPENCLAW_CACHE_TRACE_FILE /custom/path.jsonl cache-trace 파일 경로 변경
OPENCLAW_STATE_DIR ~/.openclaw 모든 상태 파일의 루트 디렉토리

③ Trajectory 이벤트 타입 → 파이프라인 매핑

user.message

사용자 메시지 수신

채널에서 메시지 도착. content, role, sessionId 포함.

cat <id>.trajectory.jsonl | jq 'select(.type=="user.message")'
context.compiled

시스템 프롬프트 + tools 조립 완료

가장 중요한 이벤트. data.systemPrompt, data.tools 로 조립 결과 확인.

cat <id>.trajectory.jsonl \
  | jq 'select(.type=="context.compiled") | {
    systemPromptLen: (.data.systemPrompt | length),
    toolNames: [.data.tools[].name]
  }'
prompt.submitted

LLM에 payload 전송 직전

provider, modelId, 전송 메시지 수 확인 가능.

cat <id>.trajectory.jsonl | jq 'select(.type=="prompt.submitted")'
tool.call

LLM이 tool_use 반환

data.name (tool 이름), data.input (tool 입력 파라미터) 확인.

cat <id>.trajectory.jsonl \
  | jq 'select(.type=="tool.call") | {name: .data.name, input: .data.input}'
tool.result

tool 실행 결과

data.content (실행 결과), data.toolUseId (매핑 ID) 확인.

cat <id>.trajectory.jsonl \
  | jq 'select(.type=="tool.result") | {content: .data.content}'
session.compaction

Compaction 발생

context budget 초과 시 발생. 요약 전 메시지 수와 후 메시지 수 비교.

cat <id>.trajectory.jsonl | jq 'select(.type=="session.compaction")'
model.completed

LLM 응답 완료 + 토큰 사용량

data.usage: inputTokens, outputTokens, cacheReadTokens 확인.

cat <id>.trajectory.jsonl \
  | jq 'select(.type=="model.completed") | .data.usage'
session.ended

세션 턴 종료

전체 턴이 완료. 다음 사용자 메시지 대기 상태로 전환.

cat <id>.trajectory.jsonl | jq 'select(.type=="session.ended")'

④ Cache-Trace Stage 순서 (messages 감소 추적)

# 활성화: OPENCLAW_CACHE_TRACE=1 OPENCLAW_CACHE_TRACE_MESSAGES=1
#
# Stage 순서와 의미:
#  session:loaded        → JSONL에서 읽은 원본 메시지 수
#  session:raw-model-run → 모델 실행 직전 (sanitize 전)
#  session:sanitized     → sanitize + validate 완료 후
#  session:limited       → turn limit (pruning) 적용 후
#  prompt:before         → prompt 조립 직전
#  stream:context        → provider에 전송되는 최종 payload
#  cache:result          → 캐시 hit 여부와 절감 토큰
#  cache:state           → 현재 캐시 상태
#  session:after         → 응답 처리 후

# 전체 단계별 메시지 수 한 번에 보기
cat ~/.openclaw/logs/cache-trace.jsonl | jq '
  select(.stage | test("loaded|raw|sanitized|limited|stream|cache:result"))
  | "\(.stage): \(.messageCount // "N/A") msgs, cache_hit=\(.cacheHit // "N/A")"
'

⑤ 채팅 내 관측 명령어

명령어효과확인 내용
/verbose onverbose 모드실시간 상세 로그 출력
/trace ontrace 모드tool 호출 추적 활성화
/usage tokens토큰 표시매 응답 후 토큰 사용량 표시
/usage full전체 사용량cache hit 포함 전체 usage
/status상태 확인채널, 세션, 모델 상태
/compact강제 compactionsession.compaction 즉시 발생
/new세션 리셋새 sessionId 생성

⑥ 원클릭 관측 루틴 (처음 시작 시)

#!/bin/bash
# ── OpenClaw 로그 관측 시작 스크립트 ──

# 1. gateway를 cache-trace 활성화 모드로 실행
OPENCLAW_CACHE_TRACE=1 \
OPENCLAW_CACHE_TRACE_MESSAGES=1 \
OPENCLAW_CACHE_TRACE_PROMPT=1 \
  openclaw gateway run &

# 2. 세션 ID 확인 (질문 하나 보낸 후)
openclaw sessions --json --all-agents | jq '.[0] | {sessionId, file}'

# 3. SESSION_ID 변수 설정
SESSION_ID="여기에_세션ID_입력"
TRAJ="~/.openclaw/agents/main/sessions/${SESSION_ID}.trajectory.jsonl"

# 4. 실시간 trajectory 팔로우 (터미널 1)
tail -f $TRAJ | jq '{type, ts: .ts[11:19]}'

# 5. cache-trace 팔로우 (터미널 2)
tail -f ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage | test("loaded|sanitized|limited|stream|cache:result"))
    | {stage, messageCount, cacheHit}'

# 6. 앱 로그 (터미널 3)
openclaw logs --follow --json

# 7. 실행 후 전체 분석
cat $TRAJ | jq '[{type, ts: .ts[11:19]}]'

08 · 코드 레벨 정밀 검토

파이프라인 순서 — 정정 및 완성판

GitHub 소스코드(context-engine/legacy.ts, anthropic-transport-stream.ts, trajectory/export.ts)를 직접 확인해 정정한 내용이다. 특히 Pruning과 Budget Gate의 순서가 일반적인 오해와 반대다.

① 자주 틀리는 오해 vs 실제 코드

항목 ❌ 오해 ✅ 실제 코드 근거
파이프라인 순서 Budget Gate → Pruning sanitize → validate → Limit(Pruning) → Budget Gate context-engine/legacy.ts
"sanitize → validate → limit → repair"
Validate cache-trace stage 별도 stage 존재 Sanitize + Validate 모두 session:sanitized 하나로 기록 src/agents/cache-trace.ts
CacheTraceStage 타입 정의
tool 호출 trajectory 이름 tool.use tool.call src/trajectory/export.ts
이벤트 타입 목록
Transcript 저장 시점 파이프라인 맨 끝 한 번 각 이벤트(user msg, tool_use, tool_result)마다 incremental append sessions/user-turn-transcript.ts
appendUserTurnTranscriptMessage()
stream:context 위치 Agent Runner + Provider Payload 두 곳 Provider 전송 직전 한 곳만 src/agents/cache-trace.ts
stage 정의
Context Engine 파이프라인에 없음 Limit 이후 Context Engine assemble() → context.compiled src/context-engine/registry.ts
src/context-engine/legacy.ts
Repair 단계 없음 limit 이후 repair 단계 존재 (provider 규칙 위반 복구) context-engine/legacy.ts
"sanitize→validate→limit→repair"
session:after 없음 응답 처리 후 session:after cache-trace stage 존재 src/agents/cache-trace.ts
나머지 (memory, cache_control, LRU64, append-only 등) ✅ 정확 ✅ 정확 코드 확인 완료

② 완성된 정밀 파이프라인 (코드 검증 완료)

핵심 수정: Pruning(limit)은 Budget Gate보다 먼저 실행된다. Budget Gate는 Pruning 이후에 남은 토큰이 여전히 초과인지 확인한다. Compaction은 Budget Gate가 실패할 때만 발동한다.
JSONL Load

① Session 로드 — sessions/*.jsonl 읽기

디스크의 JSONL에서 raw messages[]를 읽어 메모리에 올린다. 이 시점의 내용이 source of truth.

cache-trace: session:loaded cache-trace: session:raw-model-run
cat ~/.openclaw/logs/cache-trace.jsonl | jq 'select(.stage=="session:loaded") | {messageCount}'
session:sanitized

② Sanitize + Validate — 하나의 cache-trace stage

[Sanitize] provider가 거부할 수 있는 thinking 블록, 불완전한 tool-call 구조, 내부 details 제거.
[Validate] Anthropic/OpenAI/Gemini별 turn contract 위반 메시지 제거.
⚠️ 이 두 단계는 하나의 session:sanitized stage로 기록된다. 별도 stage 없음.

cache-trace: session:sanitized (sanitize + validate 통합)
cat ~/.openclaw/logs/cache-trace.jsonl | jq '
  select(.stage | test("loaded|sanitized"))
  | {stage, messageCount}'
# loaded: 42 → sanitized: 40  ← 2개 제거됨
session:limited

③ Limit (Pruning) — Budget Gate보다 먼저!

limitHistoryTurns()가 오래된 tool_result를 in-memory에서만 제거. 캐시 TTL 만료 조건. 디스크 JSONL은 절대 수정 안 됨.
⚠️ 사용자가 "Budget Gate → Pruning" 으로 알고 있지만, 실제 코드 순서는 Pruning(limit) → Budget Gate다.

cache-trace: session:limited
cat ~/.openclaw/logs/cache-trace.jsonl | jq '
  select(.stage=="session:limited") | {messageCount}'
# sanitized: 40 → limited: 30  ← 10개 pruning
repair

④ Repair — (누락되기 쉬운 단계)

context-engine/legacy.ts에 명시된 4단계 파이프라인의 마지막: "sanitize → validate → limit → repair".
limit 후에도 provider 규칙을 위반하는 엣지 케이스를 복구. 별도 cache-trace stage 없이 session:limited 이후 처리.

context-engine/legacy.ts
Budget Gate

⑤ Budget Gate (Preemptive Compaction) — Pruning 이후

Pruning 후 남은 messages + system prompt + current turn의 예상 토큰을 계산.
초과 시: Compaction 발동 → session.compaction trajectory 기록 → step ①부터 재시작.
정상 시: Context Engine으로 진행.

trajectory: session.compaction (overflow 시)
cat <id>.trajectory.jsonl | jq 'select(.type=="session.compaction")'
# 발생하지 않았으면 빈 출력 = 정상 범위 내
context.compiled

⑥ Context Engine assemble() — (자주 누락되는 단계)

정리된 messages[]를 받아 이번 턴의 최종 context를 조립. context-engine/registry.ts가 plugin 엔진 또는 legacy fallback을 선택.
출력: assembled.messages, systemPromptAddition, promptAuthority.
이 시점에 trajectory의 context.compiled 이벤트가 기록됨. systemPrompt + tools 확인의 핵심 포인트.

trajectory: context.compiled ← systemPrompt + tools[]
cat <id>.trajectory.jsonl | jq '
  select(.type=="context.compiled") | {
    systemPromptLen: (.data.systemPrompt | length),
    toolNames: [.data.tools[].name]
  }'
prompt:before

⑦ cache_control 주입 + prompt 조립

resolveCacheRetention()applyAnthropicPayloadPolicyToParams()가 stable prefix에 cache_control: ephemeral 마킹.
cacheStablePromptPrefix(): LRU 캐시(최대 64개)로 stable prefix 관리.
LRU = Least Recently Used: 가장 오랫동안 참조되지 않은 항목을 먼저 교체.

cache-trace: prompt:before
stream:context

⑧ Provider Payload 전송 — stream:context는 여기 한 곳만

anthropic-transport-stream.ts가 OpenClaw 내부 형식을 provider API 형식으로 변환.
Anthropic: input_schema / OpenAI: parameters 형식.
⚠️ stream:context는 이 시점 단 한 번만 기록됨. Agent Runner 단계에서 기록되지 않음.

cache-trace: stream:context ← 최종 전송 payload trajectory: prompt.submitted
cat ~/.openclaw/logs/cache-trace.jsonl | jq '
  select(.stage=="stream:context") | {
    messageCount,     # 실제 전송 메시지 수
    systemLen,        # system prompt 길이
    toolCount         # tools 배열 크기
  }'
tool.call

⑨ LLM 응답 → tool.call / tool.result 루프

LLM이 tool_use 블록 반환 → tool.call trajectory 기록.
tool.execute() 실행 → tool.result trajectory 기록.
⚠️ trajectory 이벤트 이름은 tool.use가 아니라 tool.call.
결과는 role: "user"로 messages[]에 추가 → 즉시 JSONL에 append (loop 끝이 아님!).
tool_use 없는 text 반환 시 루프 종료.

trajectory: tool.call (← tool.use ❌) trajectory: tool.result
# 이번 세션의 tool 호출 순서 (tool.use 아님!)
cat <id>.trajectory.jsonl \
  | jq 'select(.type | test("tool\\.call|tool\\.result"))
    | {type, name: .data.name, ts: .ts[11:19]}'
JSONL append

⑩ Transcript 저장 — 매 이벤트마다 incremental append

⚠️ JSONL 저장은 파이프라인 끝에 한 번만 일어나는 것이 아님.
user message 도착 시 → 즉시 append
tool_use 반환 시 → 즉시 append
tool_result 생성 시 → 즉시 append
assistant 최종 응답 시 → 즉시 append
appendUserTurnTranscriptMessage()가 각 시점에 호출됨. 디스크 원본은 절대 수정되지 않는다.

sessions/*.jsonl (append-only, 각 이벤트마다)
# 실시간으로 JSONL append 추적
tail -f ~/.openclaw/agents/main/sessions/<id>.jsonl | jq '{type, ts}'
# tool_use, tool_result가 실행 즉시 한 줄씩 쌓이는 걸 확인할 수 있음
session:after

⑪ 응답 전달 + 캐시 결과 기록

deliverReplies가 채널로 최종 전송. cache-trace에 session:after, cache:result, cache:state 기록.
cache:result에서 이번 턴의 캐시 hit 여부와 절감된 토큰을 확인할 수 있음.

trajectory: assistant.message trajectory: model.completed trajectory: session.ended cache-trace: session:after cache-trace: cache:result
cat ~/.openclaw/logs/cache-trace.jsonl \
  | jq 'select(.stage | test("cache:result|cache:state")) | {
    stage, cacheHit,
    cacheReadTokens,    # 캐시 hit으로 절감된 토큰
    cacheWriteTokens,   # 이번에 새로 캐싱된 토큰
    inputTokens         # 총 입력 토큰
  }'

③ Cache-Trace Stage 전체 순서 (정정 완료)

#Cache-Trace Stage의미주요 확인 내용
1session:loadedJSONL에서 읽은 raw 원본원본 메시지 수
2session:raw-model-run모델 실행 직전 (처리 전)처리 전 상태 스냅샷
3session:sanitizedsanitize + validate 완료제거된 메시지 수 (loaded - sanitized)
4session:limitedPruning(limit) 완료Pruning으로 제거된 메시지 수
5prompt:beforeprompt 조립 직전cache_control 주입 전 상태
6stream:contextprovider에 전송되는 최종 payload실제 전송 messages 수 + system 길이
7cache:result캐시 hit/miss 결과cacheReadTokens, cacheWriteTokens
8cache:state현재 캐시 상태TTL, 캐시된 항목 정보
9session:after응답 처리 후최종 정리 상태

④ Trajectory 이벤트 전체 순서 (정정 완료)

#Trajectory Type발생 시점주요 data 필드
1user.message사용자 메시지 수신content, role, sessionId
2context.compiledContext Engine assemble() 완료systemPrompt, tools[]
3prompt.submittedLLM API 호출 직전provider, modelId, messageCount
4tool.callLLM이 tool_use 반환name, input (← tool.use 아님!)
5tool.resulttool 실행 완료 후 즉시content, toolUseId
(반복)tool.call → tool.resultSKILL.md 단계 수만큼 반복
6session.compactionBudget Gate overflow 시만 발생before/after 메시지 수
7assistant.messageLLM text 반환 (루프 종료)content
8model.completedLLM 응답 완전 수신usage: inputTokens, outputTokens, cacheReadTokens
9session.ended전체 턴 완료

09 · Building Your Own Agentic Engine

코드에서 역으로 읽는 에이전트 엔진 설계론

OpenClaw 코드베이스에서 반복적으로 나타나는 설계 결정을 소프트웨어 개발론 관점으로 추출했다. 비슷한 agentic engine을 만들 때 어떤 철학으로, 무엇을 중심으로, 어떤 순서로 구현해야 하는가에 대한 청사진이다.

한 문장 요약: "LLM은 stateless한 순수 함수다. 엔진의 본질은 매 턴 올바른 입력을 재조립하고, 모든 경계를 관측 가능하게 만들고, 단일 진실 소스로부터 모든 것을 파생시키는 것이다." 나머지는 전부 이 원칙의 구현 디테일이다.

A. 12가지 핵심 설계 원칙

01

LLM은 Stateless 순수 함수다

엔진은 모델이 아무것도 기억하지 못한다고 가정하고 설계해야 한다. 매 호출은 f(system, messages, tools) → response 형태의 순수 함수다.
코드 근거매 턴 sessions/*.jsonl에서 전체 messages[]를 재조립해 전송. anthropic-transport-stream.ts
당신의 엔진에서"상태는 외부에, 추론은 모델에." 모든 상태를 엔진이 소유하고, 모델에는 매번 완전한 컨텍스트를 주입하라.
메커니즘Anthropic Messages API·OpenAI Chat Completions 모두 무상태다. 매 요청은 200K 컨텍스트 윈도우 안에 전체 대화를 다시 담는다. 실제로 35개 메시지가 208K 토큰이 되어 윈도우를 초과하며 조용히 멈춘 사례가 있다 — 모델이 "기억"하는 게 아니라 매번 다시 읽는 것이기 때문이다.
안티패턴"이전 턴에서 읽은 파일을 모델이 기억하겠지" 가정. tool_result를 messages[]에서 빼는 순간 모델은 그 내용을 완전히 잊는다. 잊지 않게 하려면 매 턴 다시 넣는 수밖에 없다.
02

단일 진실 소스 + 파생 뷰 (Event Sourcing)

디스크의 append-only 로그가 유일한 진실이고, 모델에 보내는 payload는 매번 재계산되는 파생물이다. CQRS / Event Sourcing 패턴.
코드 근거transcript(JSONL)는 불변, payload는 sanitize→limit→compact로 매 턴 재생성. Pruning은 디스크를 건드리지 않음.
당신의 엔진에서저장(write model)과 전송(read model)을 분리하라. 로그는 절대 수정하지 말고, 보낼 것은 매번 계산하라. 디버깅과 재현성이 극적으로 좋아진다.
메커니즘write model = JSONL에 append되는 원본 이벤트. read model = 매 턴 sanitize→limit→compact를 거쳐 재조립되는 payload. 원본이 영원히 보존되므로 정책(예: limit 개수, 요약 방식)을 바꿔 언제든 다른 방식으로 재투영할 수 있다. anthropic-payload.jsonl은 그 파생 결과의 스냅샷.
안티패턴컨텍스트를 줄이려고 원본 히스토리 파일을 직접 수정. 되돌릴 수 없고, 다른 정책으로 재생성 불가, "그때 실제로 무슨 일이 있었나"를 감사할 수 없다. 한 번 잘리면 영원히 사라진다.
03

관측 가능성은 기능이 아니라 기반

trajectory가 기본 ON이고 끄려면 명시적으로 opt-out해야 한다. 모든 파이프라인 경계에 cache-trace stage가 박혀 있다.
코드 근거OPENCLAW_TRAJECTORY default-on. context.compiled, stream:context 등 경계마다 이벤트.
당신의 엔진에서"프롬프트에 있었을 것"이라 추측하지 말고 관측하게 하라. 각 변환 경계(load/sanitize/limit/assemble/wire)에서 스냅샷을 남기는 것을 1순위로 설계하라.
메커니즘두 종류의 로그가 역할을 나눈다. trajectory(context.compiled)는 "조립한 것", cache-trace(stream:context)는 "실제로 보낸 것". 둘을 비교하면 "조립까진 됐는데 전송에서 빠졌다" 같은 문제를 한 줄로 특정할 수 있다. 그래서 default-on이다 — 끄는 게 예외.
안티패턴로그 없이 코드만 읽어 "왜 모델이 이걸 모르지?"를 추론. 파이프라인이 sanitize/validate/limit/compact/engine 5단계만 넘어도 머릿속 추론은 불가능해진다. 관측을 나중에 붙이려 하면 이미 늦다.
04

토큰 예산 기반, 개수 기반 아님

"최근 N개 유지"가 아니라 "예산에 맞을 때까지". 모든 컨텍스트 결정의 통화(currency)는 토큰이다.
코드 근거Budget Gate(preemptive-compaction)가 token 초과 시 Pruning→Compaction 발동. limitHistoryTurns도 예산 연동.
당신의 엔진에서토큰 추정기를 코어에 두고, 모든 트리밍/요약/캐싱 정책이 이 예산 하나를 중심으로 돌게 하라. 단일 제약이 시스템을 단순하게 만든다.
메커니즘tool_result 크기 상한조차 컨텍스트 윈도우에 따라 자동 결정된다. 가장 최근 완료된 3개 턴은 바이트 단위로 보존해서 prompt cache prefix를 안정적으로 유지한다. 즉 "얼마를 남길까"가 아니라 "예산 안에서 무엇을 어떤 순서로 버릴까"가 핵심 결정이다.
안티패턴"메시지 10개만 유지" 같은 고정 개수 정책. 큰 tool_result 하나가 10개여도 윈도우를 터뜨리고, 반대로 작은 메시지 10개는 멀쩡한데 과도하게 잘린다. 개수는 토큰과 상관관계가 약하다.
05

능력 = 지시(prompt) + 호출 가능 스키마(tool)

두 가지가 모두 provider payload에 존재할 때만 실행 가능하다. system prompt에 스킬 설명이 있어도 tool schema가 없으면 못 한다.
코드 근거skillsPrompt(텍스트) ≠ effectiveTools(호출 가능). toolsAllow 필터 후 providerVisibleTools로 확정.
당신의 엔진에서"무엇을 알려주는가"와 "무엇을 호출할 수 있는가"를 별도 레이어로 분리하라. 디버깅 시 두 레이어를 독립적으로 확인할 수 있어야 한다.
메커니즘skillsPrompt는 system prompt에 들어가는 텍스트(LLM이 읽는 지식), effectiveTools는 provider의 tools[]에 들어가는 스키마(LLM이 호출하는 수단). 이 둘은 서로 다른 파이프라인에서 독립적으로 필터된다. toolsAllow가 tool을 빼도 prompt의 스킬 설명은 남을 수 있다.
안티패턴프롬프트에 "파일을 읽어라" 적어놓고 read tool schema는 안 보냄. 모델은 의지는 있으나 호출 수단이 없어 결국 내용을 지어낸다(환각). 능력의 진실은 prompt 문구가 아니라 provider tools[]에 있다.
06

Provider는 Adapter로 격리한다

내부 공통 포맷 하나를 정의하고, provider별 wire 포맷으로 변환하는 adapter만 갈아끼운다. Anthropic은 input_schema, OpenAI는 parameters.
코드 근거llm/providers/에 anthropic.ts, openai-completions.ts, google.ts 등. 공통 transform-messages.ts + register-builtins.ts 중앙 등록.
당신의 엔진에서코어는 단 하나의 내부 메시지 타입만 알게 하라. provider 차이(role 처리, tool 필드명, streaming 형식)는 전부 adapter 경계 안에 가두라.
메커니즘내부 AnyAgentToolparameters를 가진 공통 객체. transport 단계에서 OpenAI는 parameters로 유지, Anthropic은 input_schema로 변환한다. transform-messages.ts가 role/content 구조 차이를, register-builtins.ts가 provider 등록을 중앙에서 관리한다. 코어는 이 변환을 전혀 모른다.
안티패턴루프 코드에 if (provider === 'openai') 분기 삽입. provider 3개만 돼도 코어가 if문 지옥이 되고, 새 provider 추가 시 전역을 뒤져 수정해야 한다. 변환 로직이 비즈니스 로직과 섞이면 테스트도 불가능.
07

교체 가능한 계약 + 안전한 Fallback

ContextEngine, 검색 backend 등 핵심 컴포넌트가 plugin으로 교체 가능하되, 실패 시 legacy/builtin으로 자동 폴백(graceful degradation).
코드 근거context-engine/registry.ts: plugin 실패→quarantine→legacy fallback. memory: qmd→builtin fallback.
당신의 엔진에서확장점은 인터페이스로 정의하고, 항상 "절대 실패하지 않는 기본 구현"을 두라. 플러그인은 향상이지 의존이 아니어야 한다.
메커니즘registry가 plugin 슬롯을 해석한다. 등록 안 됨/팩토리 오류/계약 위반 시 quarantine(격리) 기록 후 legacy로 폴백. 단 중요도에 따라 다르다 — assemble은 guarded라 실패 시 throw(부분 폴백 불가), compact는 폴백 허용. "어디까지 폴백할지"가 설계 결정이다.
안티패턴확장점을 필수 의존성으로 만듦. 플러그인 하나가 죽으면 전체 엔진이 정지한다. 플러그인은 "있으면 더 좋은 것"이어야지 "없으면 안 도는 것"이면 안 된다.
08

상태 소유권을 명확히 분리한다

Gateway는 오케스트레이션 상태(dedupe, abort, lifecycle, broadcast)를, Runtime은 모델에 보이는 상태(transcript, prompt)를 소유. 누가 무엇을 책임지는지 모호하지 않다.
코드 근거Gateway runtime-state vs Pi/session runtime transcript. 책임 경계가 파일 단위로 분리.
당신의 엔진에서"실행 제어 상태"와 "모델 컨텍스트 상태"를 다른 객체가 소유하게 하라. 한 객체가 둘 다 가지면 동시성 버그와 컨텍스트 오염이 생긴다.
메커니즘Gateway가 소유: dedupe·abort·lifecycle·broadcast = "이 요청을 실행할지/중단할지". Runtime이 소유: transcript·prompt = "모델이 무엇을 볼지". 둘은 broadcast 이벤트로만 통신한다. 책임이 파일 단위로 갈려 있어 한쪽을 고쳐도 다른 쪽이 안 깨진다.
안티패턴God object 하나가 실행 상태와 컨텍스트를 둘 다 소유. 동시 요청이 들어오면 한 세션의 컨텍스트가 다른 세션으로 새고, abort 신호가 transcript를 중간에 깨뜨린다.
09

복원력은 코어에 내장한다

provider 호출은 본질적으로 불안정하다. 지수 백오프 + 에러 분류(일시적 vs 영구)를 기본 제공한다.
코드 근거provider-runtime/operation-retry.ts: 250ms→1s 백오프, 5xx/ECONNRESET은 재시도, 4xx/invalid key는 즉시 실패.
당신의 엔진에서모든 외부 호출을 retry 래퍼로 감싸되, 재시도 가능 에러만 분류해서 재시도하라. 400/401을 재시도하면 비용만 낭비된다.
메커니즘250ms → 500ms → 1s(max) 지수 백오프. 에러의 cause 객체까지 재귀 검사해서 ECONNRESET·ETIMEDOUT·5xx·timeout만 일시적 에러로 분류해 재시도. "같은 API 키로 다시 시도할지"를 콜백으로 결정해 키 rotation과도 연동된다.
안티패턴모든 에러를 일괄 재시도. 401(잘못된 키)이나 400(잘못된 요청)을 3번 재시도하면 고칠 수 없는 걸 반복하며 rate limit까지 맞는다. 영구 에러는 즉시 실패가 정답.
10

불변성 = 감사 가능성

기록은 append-only. 과거 메시지를 절대 수정하지 않는다. tool_use/tool_result는 발생 즉시 기록(incremental append).
코드 근거appendUserTurnTranscriptMessage()가 각 이벤트 시점마다 호출. Pruning은 in-memory만.
당신의 엔진에서로그를 append-only로 강제하라. "지금 무슨 일이 일어났는가"를 시간순으로 재생할 수 있어야 사후 디버깅·재현·재학습이 가능하다.
메커니즘tool_use 반환 즉시 append, tool_result 생성 즉시 append — 루프 끝이 아니라 이벤트마다. appendUserTurnTranscriptMessage()가 각 시점에 호출된다. 그래서 루프 중간에 크래시가 나도 그 직전까지의 진행이 디스크에 남아 복구·재개가 가능하다.
안티패턴턴이 끝날 때 한 번에 일괄 저장. tool을 3개 부르다 2번째에서 크래시 나면 그 턴 전체가 유실되고, 부분 진행을 복구할 수 없다. 비싼 tool 실행 결과도 같이 날아간다.
11

생명주기 훅을 1급으로 설계한다

ContextEngine은 assemble/compact뿐 아니라 bootstrap/maintain/afterTurn/dispose/subagent 훅까지 계약으로 정의. 확장점이 "언제"에 대해 명시적이다.
코드 근거context-engine/types.ts: bootstrap, ingest, maintain, afterTurn, prepareSubagentSpawn, onSubagentEnded, dispose.
당신의 엔진에서"턴 전/후", "압축 전/후", "서브에이전트 시작/종료" 같은 시점을 인터페이스 메서드로 노출하라. 나중에 기능을 끼워넣을 자리가 생긴다.
메커니즘ContextEngine 계약: bootstrap(세션 시작 시 과거 컨텍스트 로드), ingest(메시지 수집), maintain(턴 후 정리), afterTurn(라이프사이클 후처리), prepareSubagentSpawn/onSubagentEnded(서브에이전트), dispose(자원 정리). 각 "언제"가 명시적 메서드라 메모리 자동 기록, 압축 후 후처리 같은 기능이 자연스럽게 들어간다.
안티패턴모든 로직을 assemble 하나에 욱여넣음. 서브에이전트 시작이나 압축 후 정리 같은 기능을 끼울 자리가 없어, 결국 플래그로 분기하는 수백 줄짜리 거대 함수가 된다.
12

Lazy 로딩 + Fast Path로 성능을 산다

자주 쓰는 경로는 무거운 초기화를 건너뛴다. Gateway는 lazy wrapper, CLI는 route-first, stable prompt prefix는 LRU 캐시(64개).
코드 근거server.ts lazy entry, cli/route.ts fast path, cacheStablePromptPrefix() LRU.
당신의 엔진에서변하지 않는 것(system prompt prefix, tool schema)은 캐싱하고, 흔한 명령은 전체 초기화 전에 빠르게 분기하라. 체감 지연이 크게 준다.
메커니즘server.ts는 가벼운 lazy wrapper일 뿐, 실제 구현은 server.impl.ts에 있어 필요할 때 로드된다. CLI는 Commander 전체 초기화 전에 route.ts에서 --version 같은 명령을 fast path로 처리한다. stable prompt prefix는 LRU 64개로 캐싱해 매 턴 재조립을 피한다.
안티패턴모든 요청이 전체 플러그인·채널·provider 초기화를 거치게 함. --version이나 --help 같은 단순 명령조차 수 초 지연되고, 자주 쓰는 경로일수록 누적 비용이 커진다.

B. 권장 레이어 아키텍처

위에서 아래로 = 요청이 흐르는 방향. 각 레이어는 자기 책임만 지고, 인접 레이어와는 계약(인터페이스)으로만 대화한다.

① Edge / Channelchannel adapters
외부 표면(CLI, 웹, 메신저)을 정규화된 내부 메시지로 변환. 프로토콜·인증·미디어(음성→텍스트 전사) 차이를 여기서 전부 흡수해, 위 레이어는 채널이 무엇인지 몰라도 되게 한다.
IN: Telegram update / HTTP req / CLI argv  OUT: NormalizedMessage{ body, sender, route, sessionKey }
프로토콜 파싱미디어 정규화sender 식별outbound 전송
하지 않는 것: 모델 호출, 컨텍스트 조립, 비즈니스 로직. 순수하게 "번역기" 역할만.
② Orchestration / Gatewayexecution state
권한, 중복 제거, abort, lifecycle, broadcast를 책임진다. "이 요청을 실행할까/중단할까"를 결정하되 모델이 무엇을 보는지는 모른다. 세션당 메시지를 직렬(one-at-a-time)로 처리해 tool 충돌과 히스토리 불일치를 막는다.
IN: NormalizedMessage  OUT: 실행 결정 + Runtime 호출 + 진행상황 broadcast
auth / scopededupeabort controllercommand queuebroadcast
하지 않는 것: transcript 소유, prompt 조립. 컨텍스트 상태는 ③이 갖는다 — 섞으면 동시성 버그.
③ Agent Runtime / Loopthe turn loop
엔진의 심장. payload 조립 → provider 호출 → tool 실행 → tool_result 추가 → 재호출tool_use가 없을 때까지 반복(ReAct 루프). ContextEngine·budget gate·system prompt 조립이 모두 여기에 붙는다. 모델에 보이는 transcript를 소유.
IN: sessionKey + 실행 요청  OUT: assistant 응답 + tool 실행 + transcript append
turn loop (ReAct)context assemblebudget gatetool dispatchtranscript 소유
하지 않는 것: provider별 wire 포맷 처리(→④), 채널 전송(→①). 루프 로직은 모델·채널 중립이어야 한다.
④ Provider Adapterwire format
내부 공통 포맷 ↔ provider별 API 포맷을 양방향 변환. tool 스키마(parameters vs input_schema), role 처리, streaming 청크 파싱, cache_control 주입, retry/백오프가 전부 여기 갇힌다. 새 provider 추가 = adapter 1개 추가, 코어는 무변경.
IN: InternalRequest{system, messages, tools}  OUT: AsyncIterable<Chunk> (정규화된 응답 스트림)
format 변환stream 파싱cache_controlretry / backoffusage 집계
하지 않는 것: 컨텍스트 정책 결정(무엇을 보낼지는 ③). adapter는 "받은 걸 그 provider 말로 번역"만.
⑤ Store / Memorysource of truth
두 종류의 기억. 단기 = append-only transcript(JSONL): 현재 세션, 매 턴 자동 주입. 장기 = Memory(markdown 원본 + vector/FTS 인덱스): 세션을 넘나들며, memory_search/memory_get tool로만 접근. 원본 markdown이 진실이고 인덱스는 검색용 파생물.
IN: append(event) / search(query)  OUT: 시간순 이벤트 스트림 / 관련 chunk
append-only logmarkdown 원본vector + FTS indexvisibility 정책
하지 않는 것: update/delete(불변!), 자동으로 장기기억 주입(반드시 tool 경유). 단기≠장기를 혼동하면 컨텍스트가 폭발한다.
관측 레이어는 횡단(cross-cutting): trajectory/trace는 ②~⑤ 모든 경계를 가로지른다. 특정 레이어가 아니라 모든 레이어가 동일한 이벤트 버스에 기록하도록 설계하라.

C. 가장 먼저 정의해야 할 4개의 추상화

Provider

LLM 공급자 계약
interface Provider {
  // 공통 포맷 → wire → stream
  stream(req: InternalRequest)
    : AsyncIterable<Chunk>;
  toolFormat: "input_schema"
    | "parameters";
  supportsCache: boolean;
}

제일 먼저 만들어라. 이게 있어야 나머지가 모델과 무관하게 돌아간다.

필수 stream() — 내부 요청 1개를 받아 정규화된 청크(text/tool_use/usage)의 비동기 스트림 반환. 모든 provider가 이 시그니처로 통일.
필수 toolFormat — tool 스키마를 어느 필드로 보낼지(parameters/input_schema). 변환 분기의 단일 기준점.
선택 supportsCache — prompt caching 지원 여부. true면 stable prefix에 cache_control 주입.
💡 retry/백오프는 이 인터페이스 밖에서 stream()을 감싸는 데코레이터로. provider 구현이 재시도를 몰라도 되게.

Tool

호출 가능 능력 계약
interface Tool {
  name: string;
  description: string;
  parameters: JSONSchema;
  execute(input, signal)
    : Promise<ToolResult>;
}

parameters(스키마)와 execute(구현)가 한 객체에 있어야 한다. 분리되면 "있는데 못 부르는" 버그가 생긴다.

name · description — 모델이 어떤 tool을 부를지 선택할 때 읽는 정보. description이 곧 프롬프트다.
parameters — JSON Schema. 모델이 input을 만들 때 따르는 계약. provider로 나갈 때 adapter가 형식 변환.
execute(input, signal) — 실제 작업 수행. signal로 abort 지원(②의 abort controller와 연결). 결과는 정규화된 ToolResult.
💡 effectiveTools(이번 턴 호출 가능 목록)는 toolsAllow 필터 후 확정. "정의됨"과 "이번 턴에 제공됨"을 구분하라.

ContextEngine

컨텍스트 조립 계약
interface ContextEngine {
  assemble(session, budget, tools)
    : AssembledContext;
  compact(history, budget)
    : CompactedHistory;
  ingest(message): void;
  // optional 생명주기 훅
  bootstrap?; afterTurn?;
  maintain?; dispose?;
}

assemble은 필수, 나머지는 optional. legacy pass-through 기본 구현부터 시작하라.

필수 assemble() — budget 안에서 최종 messages[] 조립. guarded: 실패 시 폴백 없이 throw(핵심이라).
필수 compact() — 초과 시 요약/제거. assemble과 달리 실패 시 폴백 허용.
필수 ingest() — 메시지 1개를 엔진 스토어에 수집.
선택 bootstrap(과거 로드) · maintain(턴 후 정리) · afterTurn · prepareSubagentSpawn/onSubagentEnded · dispose.
💡 처음엔 sanitize→limit를 그대로 통과시키는 legacy pass-through 하나만. plugin 교체는 나중.

Store

append-only 기록 계약
interface SessionStore {
  append(sessionId, event)
    : Promise<void>; // 불변!
  load(sessionId)
    : AsyncIterable<Event>;
  // 절대 update/delete 없음
}

append와 load만. update/delete를 두지 마라. 불변성이 곧 재현성이다.

필수 append(sessionId, event) — 이벤트를 끝에만 추가. 각 tool_use/tool_result 발생 즉시 호출(턴 끝이 아니라).
필수 load(sessionId) — 시간순 이벤트 스트림. 매 턴 이걸로 messages[]를 재구성.
🚫 update/delete 없음 — Pruning/Compaction은 이 저장소가 아니라 read model(메모리)에서만.
💡 JSONL 한 줄 = 한 이벤트. id/parentId로 트리 구조를 만들면 분기·재생이 쉬워진다.

D. LLM Provider와의 관계 — 3가지 핵심

관점본질설계 함의
Stateless 매 호출이 독립적. 모델은 이전 호출을 모름. 엔진이 100% 컨텍스트를 책임진다. "모델이 기억하겠지"는 항상 틀린 가정.
Adapter 경계 provider마다 role/tool/stream 포맷이 다름. 코어는 1개 내부 포맷만. 차이는 adapter에 가둠. 새 provider 추가 = adapter 1개 추가.
불안정성 네트워크·rate limit·5xx가 일상. retry + 백오프 + 에러 분류를 호출 경로에 기본 내장. 일시적 에러만 재시도.

D-1. 같은 tool이 provider마다 이렇게 달라진다

동일한 memory_search tool을 3개 provider로 보내면 wire 포맷이 전부 다르다. 이 차이를 코어가 알면 안 되고, adapter가 흡수해야 한다.

Anthropic
{
  "name": "memory_search",
  "description": "...",
  "input_schema": {
    "type": "object",
    "properties": {...}
  }
}
OpenAI
{
  "type": "function",
  "name": "memory_search",
  "description": "...",
  "parameters": {
    "type": "object",
    "properties": {...}
  }
}
Google Gemini
{
  "functionDeclarations": [{
    "name": "memory_search",
    "description": "...",
    "parameters": {...}
  }]
}
차이는 필드명만이 아니다: system 메시지 처리(Anthropic은 top-level system, OpenAI는 role system/developer, Codex는 instructions), tool_result를 담는 role, streaming 이벤트 구조, cache_control 마킹 방식까지 전부 다르다. 그래서 transform-messages.ts 같은 공통 변환 + provider별 어댑터 2층 구조가 필요하다.

D-2. 새 provider 추가 체크리스트

adapter 경계가 제대로 잡혀 있으면, 새 provider 추가는 아래 5가지만 구현하면 끝난다 — 코어·루프·tool 정의는 무변경.

① 요청 변환내부 {system, messages, tools} → provider API 바디. role·tool 필드명·system 위치 매핑.
② 응답 파싱provider streaming 청크 → 정규화된 {text, tool_use, usage} 스트림.
③ tool 포맷toolFormat 지정(parameters/input_schema/functionDeclarations) + 변환.
④ 캐시·인증cache_control 마킹 방식, API 키/OAuth 헤더 처리.
⑤ 등록register-builtins에 1줄 등록. 에러 분류 테이블은 공통 retry가 재사용.

E. 구현 순서 로드맵 (MVP → Full)

아래 순서대로 쌓으면 각 단계가 동작하는 상태로 다음으로 넘어갈 수 있다. 처음부터 전부 만들지 마라.

1
Provider + 단일 턴 (MVP의 코어)

Provider 인터페이스 + adapter 1개(예: Anthropic). f(system, messages, tools) → response 한 번 호출이 되게. 아직 루프·저장 없음.

완료 기준하드코딩된 system/messages/tools로 1회 호출 → text 응답 수신.
검증"안녕"에 모델 응답이 콘솔에 출력된다.
함정처음부터 여러 provider 지원하려 하지 마라. 1개로 인터페이스를 굳힌 뒤 확장.
2
Tool 루프

tool_use 반환 → execute → tool_result를 messages에 추가 → 재호출. tool_use 없을 때까지 반복. tool_use_id ↔ tool_result_id 매칭이 핵심.

완료 기준tool_use→execute→tool_result→재호출이 자동 반복되고, text 반환 시 루프 종료.
검증"2+3 계산해줘"에 calc tool이 호출되고 그 결과로 답한다.
함정tool_use_idtool_result_id가 어긋나면 provider가 통째로 거부. 무한 루프 방지용 max-turns도 필수.
3
Append-only Store

모든 이벤트(user/assistant/tool_use/tool_result)를 발생 즉시 JSONL에 append. 매 턴 이 로그에서 messages[]를 재구성해 전송.

완료 기준모든 이벤트가 JSONL에 한 줄씩 쌓이고, 재시작 후 load로 대화가 복원된다.
검증tail -f로 보면 tool 실행 즉시 한 줄씩 추가됨. 프로세스 죽였다 켜도 이어짐.
함정턴 끝에 일괄 저장하면 중간 크래시 시 유실. 반드시 이벤트마다 즉시 append.
4
관측 레이어 (이걸 4번째로! 미루지 마라)

각 경계(load/assemble/wire/result)에서 trajectory 이벤트 기록. 이게 없으면 5번부터는 디버깅이 불가능해진다.

완료 기준각 변환 경계에서 이벤트 기록, default-on. context.compiled로 systemPrompt+tools 확인 가능.
검증한 턴 실행 후 trajectory 파일에서 user.message→context.compiled→tool.call→model.completed 흐름이 보인다.
함정"나중에 붙이지" 하면 5번부터 컨텍스트가 복잡해져 추적 불가. 지금 넣는 게 가장 싸다.
5
Budget Gate + Pruning + Compaction

토큰 추정기 → 예산 초과 시 sanitize→limit(pruning)→compaction. 순서 주의: Pruning이 Budget Gate보다 먼저.

완료 기준토큰 추정 후 초과 시 sanitize→limit(pruning) 적용, 그래도 초과면 compaction 후 재시도.
검증긴 세션에서 stream:context의 messageCount가 loaded보다 줄어든다.
함정Pruning(트리밍)을 먼저, Compaction(요약)을 나중. 순서 거꾸로면 안 버려도 될 걸 요약하느라 비용 낭비.
6
System Prompt 조립 + cache_control

stable prefix(지시/tools/오래된 history)와 dynamic suffix 분리. prefix에 캐시 마킹 + LRU 캐싱.

완료 기준stable prefix와 dynamic suffix가 분리되고, prefix에 cache_control이 붙는다.
검증2번째 턴부터 cache_read_tokens > 0. 토큰 비용이 눈에 띄게 준다.
함정매 턴 바뀌는 내용(타임스탬프 등)을 prefix에 넣으면 캐시가 매번 무효화돼 오히려 손해.
7
Memory (장기 기억)

markdown 원본 + 검색 인덱스. memory_search/memory_get tool로만 접근. 시스템 프롬프트에 "먼저 검색하라" 지시 주입.

완료 기준memory_search/get tool 동작, markdown→인덱스 sync, "prior work 전 검색" 지시가 프롬프트에 있음.
검증새 세션에서 이전 세션의 정보를 memory_search로 회수해 답한다.
함정장기기억 전체를 자동으로 프롬프트에 넣으면 컨텍스트 폭발. 반드시 tool 검색 경유로만 끌어와라.
8
Skill 시스템 + Plugin 확장점

SKILL.md(=스코프 제한된 프롬프트) 로딩. ContextEngine/Provider/Tool/Channel을 plugin으로 교체 가능하게 + 안전한 fallback.

완료 기준SKILL.md 로딩 + 시스템 프롬프트 주입. 확장점이 인터페이스로 정의되고 legacy/builtin fallback 존재.
검증스킬 추가가 파일 추가만으로 되고, 플러그인을 일부러 죽여도 엔진이 기본 구현으로 계속 돈다.
함정확장점을 필수 의존으로 만들면 플러그인 하나의 버그가 전체를 멈춘다. plugin은 향상이지 의존이 아니다.
9
Multi-channel + 동시성 직렬화

Channel adapter로 외부 표면 확장. 세션당 직렬 처리로 tool 충돌·히스토리 불일치 방지.

완료 기준channel adapter로 새 표면(메신저 등) 추가, 세션당 command queue로 직렬 처리.
검증같은 세션에 메시지 2개를 연속으로 보내도 충돌·순서 꼬임 없이 차례로 처리된다.
함정한 세션을 동시 실행하면 tool 충돌과 히스토리 불일치 발생. 세션 단위 직렬화는 선택이 아니라 필수.

F. 미리 알면 좋은 함정

실제로 부딪히는 순서대로. 각 함정은 증상원인해결로 정리했다.

① "저장됨 ≠ 모델이 봄"

증상JSONL에 분명히 있는데 모델이 "그런 말 한 적 없다"고 한다.
원인sanitize/limit/compaction 단계에서 해당 메시지가 payload에서 빠졌다. 저장(write model)과 전송(read model)은 다른 것.
해결transcript와 context.compiled(전송본)를 분리 관측. 둘을 비교하면 어느 단계에서 빠졌는지 즉시 보인다.

② tool_use ↔ tool_result 쌍 깨짐

증상provider가 400으로 요청 전체를 거부. "tool_use ids must have corresponding tool_result" 류 에러.
원인limit/pruning이 tool_use는 남기고 tool_result만 잘랐거나(혹은 반대), id 매칭이 어긋남. 가장 흔한 런타임 에러.
해결sanitize 단계에서 orphan(짝 없는) tool_use/tool_result를 쌍 단위로 제거. 자를 땐 항상 쌍으로.

③ Pruning과 Compaction 순서 혼동

증상토큰 비용이 예상보다 크고, 멀쩡한 최근 대화가 요약돼버린다.
원인Compaction(요약)을 Pruning(트리밍)보다 먼저 돌렸다. 버려도 될 걸 비싸게 요약한 것.
해결순서 고정: sanitize → limit(pruning) → budget gate → (초과 시)compaction. 요약은 최후의 수단.

④ 관측을 나중으로 미룸

증상컨텍스트 버그가 났는데 "어디서 빠졌는지" 알 방법이 없다.
원인로그/trajectory를 "기능 다 만들고 붙이자"고 미뤘다. 그 사이 파이프라인이 5단계를 넘어섰다.
해결관측 레이어를 로드맵 4번째 안에 넣어라. default-on으로. 가장 비싼 실수이자 가장 싸게 막는 방법.

⑤ provider 차이가 코어로 새어나옴

증상새 provider 추가하려는데 루프·tool·컨텍스트 코드를 전부 뜯어고쳐야 한다.
원인루프 코드에 if (provider === 'openai') 분기가 박혔다. 변환 로직이 비즈니스 로직과 섞임.
해결provider 차이(role/tool 필드명/stream/cache)는 전부 adapter 경계 안으로. 코어는 내부 포맷 1개만 안다.

⑥ 무한 루프 / max-turns 누락

증상모델이 같은 tool을 계속 부르며 멈추지 않고 토큰을 태운다.
원인"tool_use 없을 때까지 반복"만 구현하고 상한을 안 둠. tool이 계속 실패하면 영원히 재시도.
해결턴당 max-turns 상한 + 동일 tool 반복 감지. 상한 도달 시 graceful하게 중단하고 사용자에게 보고.

⑦ tool_result 크기 폭발

증상파일 읽기/검색 한 번에 컨텍스트가 갑자기 가득 차 다음 턴이 멈춘다.
원인큰 tool_result(긴 파일, 100만 경로 목록)를 그대로 messages에 넣음. 단일 결과가 윈도우를 잠식.
해결tool_result에 크기 상한을 두고 초과 시 truncate + "전체는 X로 조회" 안내. Pruning의 1순위 대상으로.

⑧ 세션 동시 실행으로 컨텍스트 오염

증상빠르게 연속 입력하면 답이 섞이거나 히스토리 순서가 꼬인다.
원인같은 세션의 두 요청이 동시에 transcript를 읽고 쓴다. append 경합 + 중간 상태 노출.
해결세션당 command queue로 직렬화(one-at-a-time). 동시성은 세션 간에만 허용.
핵심 교훈: agentic engine의 난이도는 "모델을 호출하는 것"이 아니라 "매 턴 올바른 컨텍스트를 재조립하고, 그 과정을 관측 가능하게 유지하는 것"에 있다. 모델 호출은 전체의 5%다. 나머지 95%는 컨텍스트 엔지니어링과 상태 관리다.