OpenClaw Runtime · End-to-End · 코드 + 로그 매핑
사용자가 skill/tool을 요청하는 순간부터 payload 조립, transcript 저장, 캐싱, pruning, compaction을 거쳐 답변이 전달되는 전 과정 — 각 단계별 실제 코드 위치와 로그 관측 명령어까지.
01 · Big Picture
각 단계 옆 🔍 관측 패널에서 실제 로그 파일과 jq 명령어를 확인한다.
dispatchInboundMessage로 전달.# 현재 세션 파일 찾기 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}'
# 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}'
# 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}'
cache_control: ephemeral을 붙임. cacheStablePromptPrefix()가 LRU 캐시(64개)로 관리.# 조립된 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'
prompt.submitted → model.completed 쌍으로 기록.# 전송된 실제 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}'
tool.execute() 실행 → tool_result를 role: "user"로 messages[]에 추가. 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")'
# 실시간으로 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
# 세션 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'
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 네이티브인가".
modelSupportsVision() 분기. vision 모델이면 이미지 블록을 그대로 주입, 아니면 별도 모델로 "텍스트 설명"으로 변환해 주입. 그 뒤부터는 기존 파이프라인과 동일하게 흐른다.
채널 어댑터가 이미지를 받으면 chat.send 핸들러가 첨부를 파싱·staging한다. 정책(허용 여부·크기) 검사 후 통과한 것만 다음 단계로.
HEIC/HEIF → JPEG 변환, EXIF 방향 보정, base64 인코딩. 그리고 핵심 — 바이트 예산(DEFAULT_MAX_BYTES.image)·픽셀 상한(MAX_IMAGE_INPUT_PIXELS)을 넘으면 반복 축소(resize)한다. 자르는(crop/tile) 게 아니라 줄인다 — 아래 상세 참고.
modelSupportsVision() — 여기서 두 갈래로 갈린다모델 카탈로그에서 활성 모델의 vision 지원 여부를 조회. 이 결과에 따라 아래 B의 두 경로 중 하나로 진행한다.
흔한 오해 — "큰 이미지를 조각내(tile/crop) LLM에 보낸다". src/media/image-ops.ts를 확인하면 타일링·크롭·분할 코드는 없다. 실제로는 최대 변(longest side)을 단계적으로 축소 + JPEG 품질을 사다리로 하향해 바이트 예산에 맞춘다.
실제 축소 알고리즘 (image-ops.ts)
buildImageResizeSideGrid()최대 변(가장 긴 쪽) 픽셀을 내림차순 그리드로 단계 축소. 가로세로 비율은 유지(찌그러지지 않음).
IMAGE_REDUCE_QUALITY_STEPS차원 조정으로도 부족하면 JPEG 품질을 85부터 35까지 단계적으로 낮춰 바이트를 더 줄인다.
DEFAULT_MAX_BYTES.image 이하가 될 때까지 반복 · ceiling: MAX_IMAGE_INPUT_PIXELS = 25,000,000 (25MP)Rastermill 라이브러리가 (차원 우선) 최적 조합을 탐색해 maxBytes 제약을 만족시킨다. resizeToJpeg() / optimizeImageToPng().
이미지를 image content block으로 그대로 주입한다. 텍스트 변환 단계를 완전히 스킵.
{ "role": "user",
"content": [
{ "type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": "<base64>" } },
{ "type": "text",
"text": "이 사진 설명해줘" }
] }
별도 vision 모델/CLI로 이미지 → 텍스트 설명 변환 후, 그 텍스트를 메인 모델에 주입.
// media-understanding가 먼저 처리 // → "텍스트 설명" 생성 { "role": "user", "content": [ { "type": "text", "text": "[이미지 설명: 강아지가\n 잔디밭에서 뛰는 사진]\n이 사진 설명해줘" } ] }
audio-transcription-runner.ts(whisper / sherpa-onnx 로컬 또는 API). 즉 이미지는 모델에 따라 갈리지만, 오디오는 무조건 텍스트화.
전처리가 끝나면 기존 파이프라인(sanitize→limit→assemble→wire)을 그대로 탄다. 단, 이미지 때문에 달라지는 지점이 셋 있다.
# 이미지 처리 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_use → 생성 런타임 → asset 저장 → tool_result가 그 asset을 참조. 최종 답변과 함께 deliverReplies가 채널로 내보낸다.
// 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.ts → provider-registry.ts → image-assets.ts(저장) → normalization.ts(형식 정규화).image-generation — 이미지 생성. OpenAI 호환 provider.tts — 텍스트 → 음성(speech). 음성 답장.music-generation — 음악 생성.video-generation — 영상 생성.media/store.ts에 둔다 — 히스토리에 base64를 쌓으면 컨텍스트가 폭발하므로.
deliverReplies모델의 최종 응답(ReplyPayload)을 채널 규격에 맞춰 변환·전송한다. 채널마다 메시지 크기·포맷·미디어 API가 다르므로 이 단계가 그 차이를 흡수한다 — 입력의 channel adapter와 대칭.
루프 결과(text + 생성된 미디어 asset 참조 + 메타)를 전달용 페이로드로 묶는다. final / block / progress 종류가 있다.
채널 메시지 크기 제한(예: Telegram 4096자)에 맞춰 쪼갠다. 동시에 HTML/markdown 등 채널 포맷으로 변환. 코드블록·마크다운이 경계에서 깨지지 않게 분할하는 게 핵심.
생성된 이미지/음성 asset을 채널 첨부로 변환. 전송 실패 시 retry(미디어 업로드는 특히 불안정). 입력의 정규화와 대칭되는 outbound 정규화.
reply_to, thread_id(forum topic/DM topic) 등 "어느 대화에 붙일지" 메타데이터 부착. 세션 키와 연결.
채널 API로 최종 전송(sendMessage / editMessage). 보낸 assistant 응답도 append-only transcript에 기록(다음 턴의 history가 됨).
응답이 길거나 생성이 느릴 때, 완성을 기다리지 않고 점진적으로(progressive) 보여준다.
# 미디어 생성 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
messages[]는 매 턴 sessions/*.jsonl에서 재조립된다. tool_use / tool_result 쌍이 쌓일수록 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")'
"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'
// 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
디스크 JSONL은 불변(append-only) 원본. messages[]는 매 턴 재조립되며 아래 단계를 거친다. cache-trace의 stage 이름이 각 단계와 정확히 매핑된다.
# 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}'
오래된 tool_result만 in-memory 제거.
JSONL 원본 유지.
정보 유지하면서 줄임.
최근 N턴 보존.
04 · Prompt Caching
stable prefix에 cache_control을 붙여 Anthropic 서버 캐싱 활용. cache-trace의 cache:result stage로 hit 여부 확인.
거의 변하지 않는 부분. cache_control: ephemeral. TTL: short=5분, long=1시간. cacheStablePromptPrefix() LRU 64개.
매 턴마다 바뀌는 부분. 캐싱 없이 매번 새로 전송.
# 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
# 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
Telegram bot → buildTelegramMessageContext → runInboundReplyTurn
"prior work 전 반드시 memory_search" 지시에 따라 LLM이 먼저 메모리를 검색.
스킬 목록에서 "weather" 발견. SKILL.md 읽고 exec: curl wttr.in/Suwon 결정.
curl 실행 후 결과를 role:"user"로 추가. JSONL append.
tool_use 없이 text만 반환. deliverReplies로 Telegram 전송.
# 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 명령어를 한 곳에 모았다. 위에서 아래로 순서대로 실행하면 전체 파이프라인을 직접 관측할 수 있다.
채널에서 메시지 도착. content, role, sessionId 포함.
cat <id>.trajectory.jsonl | jq 'select(.type=="user.message")'
가장 중요한 이벤트. data.systemPrompt, data.tools 로 조립 결과 확인.
cat <id>.trajectory.jsonl \
| jq 'select(.type=="context.compiled") | {
systemPromptLen: (.data.systemPrompt | length),
toolNames: [.data.tools[].name]
}'provider, modelId, 전송 메시지 수 확인 가능.
cat <id>.trajectory.jsonl | jq 'select(.type=="prompt.submitted")'
data.name (tool 이름), data.input (tool 입력 파라미터) 확인.
cat <id>.trajectory.jsonl \
| jq 'select(.type=="tool.call") | {name: .data.name, input: .data.input}'data.content (실행 결과), data.toolUseId (매핑 ID) 확인.
cat <id>.trajectory.jsonl \
| jq 'select(.type=="tool.result") | {content: .data.content}'context budget 초과 시 발생. 요약 전 메시지 수와 후 메시지 수 비교.
cat <id>.trajectory.jsonl | jq 'select(.type=="session.compaction")'
data.usage: inputTokens, outputTokens, cacheReadTokens 확인.
cat <id>.trajectory.jsonl \ | jq 'select(.type=="model.completed") | .data.usage'
전체 턴이 완료. 다음 사용자 메시지 대기 상태로 전환.
cat <id>.trajectory.jsonl | jq 'select(.type=="session.ended")'
# 활성화: 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")" '
#!/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의 순서가 일반적인 오해와 반대다.
디스크의 JSONL에서 raw messages[]를 읽어 메모리에 올린다. 이 시점의 내용이 source of truth.
cat ~/.openclaw/logs/cache-trace.jsonl | jq 'select(.stage=="session:loaded") | {messageCount}'
[Sanitize] provider가 거부할 수 있는 thinking 블록, 불완전한 tool-call 구조, 내부 details 제거.
[Validate] Anthropic/OpenAI/Gemini별 turn contract 위반 메시지 제거.
⚠️ 이 두 단계는 하나의 session:sanitized stage로 기록된다. 별도 stage 없음.
cat ~/.openclaw/logs/cache-trace.jsonl | jq '
select(.stage | test("loaded|sanitized"))
| {stage, messageCount}'
# loaded: 42 → sanitized: 40 ← 2개 제거됨
limitHistoryTurns()가 오래된 tool_result를 in-memory에서만 제거. 캐시 TTL 만료 조건. 디스크 JSONL은 절대 수정 안 됨.
⚠️ 사용자가 "Budget Gate → Pruning" 으로 알고 있지만, 실제 코드 순서는 Pruning(limit) → Budget Gate다.
cat ~/.openclaw/logs/cache-trace.jsonl | jq '
select(.stage=="session:limited") | {messageCount}'
# sanitized: 40 → limited: 30 ← 10개 pruning
context-engine/legacy.ts에 명시된 4단계 파이프라인의 마지막: "sanitize → validate → limit → repair".
limit 후에도 provider 규칙을 위반하는 엣지 케이스를 복구. 별도 cache-trace stage 없이 session:limited 이후 처리.
Pruning 후 남은 messages + system prompt + current turn의 예상 토큰을 계산.
초과 시: Compaction 발동 → session.compaction trajectory 기록 → step ①부터 재시작.
정상 시: Context Engine으로 진행.
cat <id>.trajectory.jsonl | jq 'select(.type=="session.compaction")' # 발생하지 않았으면 빈 출력 = 정상 범위 내
정리된 messages[]를 받아 이번 턴의 최종 context를 조립. context-engine/registry.ts가 plugin 엔진 또는 legacy fallback을 선택.
출력: assembled.messages, systemPromptAddition, promptAuthority.
이 시점에 trajectory의 context.compiled 이벤트가 기록됨. systemPrompt + tools 확인의 핵심 포인트.
cat <id>.trajectory.jsonl | jq '
select(.type=="context.compiled") | {
systemPromptLen: (.data.systemPrompt | length),
toolNames: [.data.tools[].name]
}'
resolveCacheRetention() → applyAnthropicPayloadPolicyToParams()가 stable prefix에 cache_control: ephemeral 마킹.
cacheStablePromptPrefix(): LRU 캐시(최대 64개)로 stable prefix 관리.
LRU = Least Recently Used: 가장 오랫동안 참조되지 않은 항목을 먼저 교체.
anthropic-transport-stream.ts가 OpenClaw 내부 형식을 provider API 형식으로 변환.
Anthropic: input_schema / OpenAI: parameters 형식.
⚠️ stream:context는 이 시점 단 한 번만 기록됨. Agent Runner 단계에서 기록되지 않음.
cat ~/.openclaw/logs/cache-trace.jsonl | jq '
select(.stage=="stream:context") | {
messageCount, # 실제 전송 메시지 수
systemLen, # system prompt 길이
toolCount # tools 배열 크기
}'
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 반환 시 루프 종료.
# 이번 세션의 tool 호출 순서 (tool.use 아님!)
cat <id>.trajectory.jsonl \
| jq 'select(.type | test("tool\\.call|tool\\.result"))
| {type, name: .data.name, ts: .ts[11:19]}'
⚠️ JSONL 저장은 파이프라인 끝에 한 번만 일어나는 것이 아님.
user message 도착 시 → 즉시 append
tool_use 반환 시 → 즉시 append
tool_result 생성 시 → 즉시 append
assistant 최종 응답 시 → 즉시 append
appendUserTurnTranscriptMessage()가 각 시점에 호출됨. 디스크 원본은 절대 수정되지 않는다.
# 실시간으로 JSONL append 추적
tail -f ~/.openclaw/agents/main/sessions/<id>.jsonl | jq '{type, ts}'
# tool_use, tool_result가 실행 즉시 한 줄씩 쌓이는 걸 확인할 수 있음
deliverReplies가 채널로 최종 전송. cache-trace에 session:after, cache:result, cache:state 기록.
cache:result에서 이번 턴의 캐시 hit 여부와 절감된 토큰을 확인할 수 있음.
cat ~/.openclaw/logs/cache-trace.jsonl \
| jq 'select(.stage | test("cache:result|cache:state")) | {
stage, cacheHit,
cacheReadTokens, # 캐시 hit으로 절감된 토큰
cacheWriteTokens, # 이번에 새로 캐싱된 토큰
inputTokens # 총 입력 토큰
}'
09 · Building Your Own Agentic Engine
OpenClaw 코드베이스에서 반복적으로 나타나는 설계 결정을 소프트웨어 개발론 관점으로 추출했다. 비슷한 agentic engine을 만들 때 어떤 철학으로, 무엇을 중심으로, 어떤 순서로 구현해야 하는가에 대한 청사진이다.
f(system, messages, tools) → response 형태의 순수 함수다.anthropic-transport-stream.tsanthropic-payload.jsonl은 그 파생 결과의 스냅샷.OPENCLAW_TRAJECTORY default-on. context.compiled, stream:context 등 경계마다 이벤트.trajectory(context.compiled)는 "조립한 것", cache-trace(stream:context)는 "실제로 보낸 것". 둘을 비교하면 "조립까진 됐는데 전송에서 빠졌다" 같은 문제를 한 줄로 특정할 수 있다. 그래서 default-on이다 — 끄는 게 예외.limitHistoryTurns도 예산 연동.toolsAllow 필터 후 providerVisibleTools로 확정.skillsPrompt는 system prompt에 들어가는 텍스트(LLM이 읽는 지식), effectiveTools는 provider의 tools[]에 들어가는 스키마(LLM이 호출하는 수단). 이 둘은 서로 다른 파이프라인에서 독립적으로 필터된다. toolsAllow가 tool을 빼도 prompt의 스킬 설명은 남을 수 있다.input_schema, OpenAI는 parameters.llm/providers/에 anthropic.ts, openai-completions.ts, google.ts 등. 공통 transform-messages.ts + register-builtins.ts 중앙 등록.AnyAgentTool은 parameters를 가진 공통 객체. transport 단계에서 OpenAI는 parameters로 유지, Anthropic은 input_schema로 변환한다. transform-messages.ts가 role/content 구조 차이를, register-builtins.ts가 provider 등록을 중앙에서 관리한다. 코어는 이 변환을 전혀 모른다.if (provider === 'openai') 분기 삽입. provider 3개만 돼도 코어가 if문 지옥이 되고, 새 provider 추가 시 전역을 뒤져 수정해야 한다. 변환 로직이 비즈니스 로직과 섞이면 테스트도 불가능.context-engine/registry.ts: plugin 실패→quarantine→legacy fallback. memory: qmd→builtin fallback.assemble은 guarded라 실패 시 throw(부분 폴백 불가), compact는 폴백 허용. "어디까지 폴백할지"가 설계 결정이다.provider-runtime/operation-retry.ts: 250ms→1s 백오프, 5xx/ECONNRESET은 재시도, 4xx/invalid key는 즉시 실패.250ms → 500ms → 1s(max) 지수 백오프. 에러의 cause 객체까지 재귀 검사해서 ECONNRESET·ETIMEDOUT·5xx·timeout만 일시적 에러로 분류해 재시도. "같은 API 키로 다시 시도할지"를 콜백으로 결정해 키 rotation과도 연동된다.appendUserTurnTranscriptMessage()가 각 이벤트 시점마다 호출. Pruning은 in-memory만.appendUserTurnTranscriptMessage()가 각 시점에 호출된다. 그래서 루프 중간에 크래시가 나도 그 직전까지의 진행이 디스크에 남아 복구·재개가 가능하다.context-engine/types.ts: bootstrap, ingest, maintain, afterTurn, prepareSubagentSpawn, onSubagentEnded, dispose.bootstrap(세션 시작 시 과거 컨텍스트 로드), ingest(메시지 수집), maintain(턴 후 정리), afterTurn(라이프사이클 후처리), prepareSubagentSpawn/onSubagentEnded(서브에이전트), dispose(자원 정리). 각 "언제"가 명시적 메서드라 메모리 자동 기록, 압축 후 후처리 같은 기능이 자연스럽게 들어간다.assemble 하나에 욱여넣음. 서브에이전트 시작이나 압축 후 정리 같은 기능을 끼울 자리가 없어, 결국 플래그로 분기하는 수백 줄짜리 거대 함수가 된다.server.ts lazy entry, cli/route.ts fast path, cacheStablePromptPrefix() LRU.server.ts는 가벼운 lazy wrapper일 뿐, 실제 구현은 server.impl.ts에 있어 필요할 때 로드된다. CLI는 Commander 전체 초기화 전에 route.ts에서 --version 같은 명령을 fast path로 처리한다. stable prompt prefix는 LRU 64개로 캐싱해 매 턴 재조립을 피한다.--version이나 --help 같은 단순 명령조차 수 초 지연되고, 자주 쓰는 경로일수록 누적 비용이 커진다.위에서 아래로 = 요청이 흐르는 방향. 각 레이어는 자기 책임만 지고, 인접 레이어와는 계약(인터페이스)으로만 대화한다.
payload 조립 → provider 호출 → tool 실행 → tool_result 추가 → 재호출을 tool_use가 없을 때까지 반복(ReAct 루프). ContextEngine·budget gate·system prompt 조립이 모두 여기에 붙는다. 모델에 보이는 transcript를 소유.
parameters vs input_schema), role 처리, streaming 청크 파싱, cache_control 주입, retry/백오프가 전부 여기 갇힌다. 새 provider 추가 = adapter 1개 추가, 코어는 무변경.
memory_search/memory_get tool로만 접근. 원본 markdown이 진실이고 인덱스는 검색용 파생물.
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 주입.stream()을 감싸는 데코레이터로. provider 구현이 재시도를 몰라도 되게.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.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.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(메모리)에서만.동일한 memory_search tool을 3개 provider로 보내면 wire 포맷이 전부 다르다. 이 차이를 코어가 알면 안 되고, adapter가 흡수해야 한다.
{
"name": "memory_search",
"description": "...",
"input_schema": {
"type": "object",
"properties": {...}
}
}
{
"type": "function",
"name": "memory_search",
"description": "...",
"parameters": {
"type": "object",
"properties": {...}
}
}
{
"functionDeclarations": [{
"name": "memory_search",
"description": "...",
"parameters": {...}
}]
}
system, OpenAI는 role system/developer, Codex는 instructions), tool_result를 담는 role, streaming 이벤트 구조, cache_control 마킹 방식까지 전부 다르다. 그래서 transform-messages.ts 같은 공통 변환 + provider별 어댑터 2층 구조가 필요하다.
adapter 경계가 제대로 잡혀 있으면, 새 provider 추가는 아래 5가지만 구현하면 끝난다 — 코어·루프·tool 정의는 무변경.
아래 순서대로 쌓으면 각 단계가 동작하는 상태로 다음으로 넘어갈 수 있다. 처음부터 전부 만들지 마라.
Provider 인터페이스 + adapter 1개(예: Anthropic). f(system, messages, tools) → response 한 번 호출이 되게. 아직 루프·저장 없음.
tool_use 반환 → execute → tool_result를 messages에 추가 → 재호출. tool_use 없을 때까지 반복. tool_use_id ↔ tool_result_id 매칭이 핵심.
모든 이벤트(user/assistant/tool_use/tool_result)를 발생 즉시 JSONL에 append. 매 턴 이 로그에서 messages[]를 재구성해 전송.
각 경계(load/assemble/wire/result)에서 trajectory 이벤트 기록. 이게 없으면 5번부터는 디버깅이 불가능해진다.
토큰 추정기 → 예산 초과 시 sanitize→limit(pruning)→compaction. 순서 주의: Pruning이 Budget Gate보다 먼저.
stable prefix(지시/tools/오래된 history)와 dynamic suffix 분리. prefix에 캐시 마킹 + LRU 캐싱.
markdown 원본 + 검색 인덱스. memory_search/memory_get tool로만 접근. 시스템 프롬프트에 "먼저 검색하라" 지시 주입.
SKILL.md(=스코프 제한된 프롬프트) 로딩. ContextEngine/Provider/Tool/Channel을 plugin으로 교체 가능하게 + 안전한 fallback.
Channel adapter로 외부 표면 확장. 세션당 직렬 처리로 tool 충돌·히스토리 불일치 방지.
실제로 부딪히는 순서대로. 각 함정은 증상 → 원인 → 해결로 정리했다.
context.compiled(전송본)를 분리 관측. 둘을 비교하면 어느 단계에서 빠졌는지 즉시 보인다.sanitize → limit(pruning) → budget gate → (초과 시)compaction. 요약은 최후의 수단.if (provider === 'openai') 분기가 박혔다. 변환 로직이 비즈니스 로직과 섞임.