apme.md는 무엇을 만들었나, apme-pipeline.md는 어떻게 동작하나. 이 문서는 왜 만들었나 — 현재 내가 풀고 있는 문제, 지금까지의 한계, 그리고 APME가 어떤 구조로 그 문제를 풀어나가는지에 대한 생각을 정리한다.
현재 내 작업 루틴에 들어와 있는 모델은 최소 6개다.
┌───────────────────────────────────────────────────────────┐
│ 내가 쓰는 모델 라인업 │
├──────────────────┬────────────────────────────────────────┤
│ Claude Opus 4.6 │ 메인 드라이버. 비싸고 느리지만 정확 │
│ Claude Sonnet 4.6│ Opus 리밋 소진 시 대체 │
│ Codex (GPT-5.4) │ 세컨드 오피니언, 대안 구현 │
│ Gemini Antigrav │ 대용량 context, research │
│ GLM-5.1 │ 중간 난이도 작업 │
│ Qwen3-1.7B MLX │ 로컬, 비용 0, 요약/분류 fallback │
└──────────────────┴────────────────────────────────────────┘
이걸 내가 어떻게 배분하고 있나?
Opus 리밋 다 쓸 것 같다 (감)
│
▼
"요건 Codex 한테" (감) ─────┐
"저건 Gemini 한테" (감) │
"이건 GLM 한테" (감) ├─── 🎲 감에 의한 라우팅
"요약은 Qwen MLX" (감) │
┘
이게 효율적으로 돌아가는지 알 방법이 없다. 두 가지 구체적인 불편함이 있다:
- "OpenClaw에 붙인 로컬 모델로 요약 돌리는 게 정말 괜찮은가?"
- "debugging은 Opus한테 가야 하나? Codex가 더 빠르지 않나?"
- "planning 단계는 Gemini가 맞나? 아니면 Opus의 reasoning이 필요한가?"
답이 없다. 경험 스냅샷(지난주에 Codex가 한 번 잘했다)은 있지만 누적 데이터가 없다.
- Opus는 비싸다. 어디까지 안 써도 괜찮은지 모른다.
- 로컬 MLX는 공짜지만 어느 수준까지 품질이 나오는지 모른다.
- "비용 대비 품질"이라는 값을 한 번도 계산해본 적이 없다.
결과적으로 두려움 기반 과잉 지출이 생긴다. 잘못되면 안 되는 작업은 무조건 Opus로 돌리고, 나중에 "정말 Opus가 필요했나?"를 돌이켜볼 수 없다.
범용 벤치마크(HumanEval, SWE-bench 같은)는 답이 아니다. 세 가지 이유로:
범용 벤치마크 내가 필요한 것
───────────── ─────────────
표준 태스크 ≠ 내 실제 작업
평균적 사용자 ≠ 내 스타일/선호
"overall 점수" ≠ "내 코드베이스에서 debugging 잘함?"
내 작업에 대한 개인화된 평가 시스템이 필요하다. 즉:
- 내가 실제로 시키는 태스크에서 평가 데이터 수집
- 카테고리별로 모델의 강점/약점 프로파일
- 내 기준(품질+속도+비용)에 맞춰 추천
이게 APME가 풀려는 문제다.
APME는 "한 번에 완성된 자동 평가 시스템"이 아니다. 단계적으로 성숙하는 구조로 설계했다:
Stage 1 — 데이터 수집 (모든 세션 기록)
│
▼
Stage 2 — 자동 분류 (태스크 카테고리 판별)
│
▼
Stage 3 — 사람 레이블링 (대시보드 vibe check)
│
▼
Stage 4 — LLM Judge 튜닝 (사람과 일치하는 자동 평가)
각 단계가 이전 단계의 데이터를 연료로 삼는다.
모든 에이전트 세션이 끝날 때까지 아무것도 평가하지 않는다. 일단 기록만 한다.
| 수집 대상 | 출처 |
|---|---|
| 프롬프트 | UserPromptSubmit hook / chat_start timeline |
| 응답 | PTY ⏺ 마커 tail / chat_response / Stop hook |
| 툴 호출 히스토리 | PreToolUse/PostToolUse hooks |
| 파일 변경 | git diff (before/after) |
| 토큰 사용량 / 비용 | /usage 명령, Codex bash 로그 |
| 세션 시간 | 시작/종료 타임스탬프 |
3가지 서로 다른 에이전트(Claude Code / OpenClaw·OpenCode / Codex)를 하나의 스키마로 수렴시키는 게 관건이다. 각 에이전트의 이벤트 형태가 완전히 달라서 3개의 인제스트 경로를 만들고 ApmeCollector 공통 API로 수렴시켰다.
┌───────────────┬────────────────────────┬─────────────────────────────┐
│ 에이전트 │ 수집 경로 │ 특이사항 │
├───────────────┼────────────────────────┼─────────────────────────────┤
│ Claude Code │ hook HTTP POST │ Stop 훅 발화율 ~18% (불안정) │
│ │ /hook/:event │ → PTY ⏺ tail 파싱이 1차 경로 │
│ │ │ → pendingPtyResponse 3경로 │
│ │ │ race 해결 │
├───────────────┼────────────────────────┼─────────────────────────────┤
│ OpenClaw │ adapter timeline │ chat_start/response/end │
│ OpenCode │ events │ → collector 이벤트 매핑 │
│ │ (source:'timeline') │ │
├───────────────┼────────────────────────┼─────────────────────────────┤
│ Codex CLI │ PTY parser │ hook/timeline 없음 │
│ │ (spinner_stop + ⏺) │ → PTY tail 파싱 유일 경로 │
└───────────────┴────────────────────────┴─────────────────────────────┘
│
▼
┌──────────────────┐
│ ApmeCollector │ ingestHook()
│ (공통 API) │ setTurnResponse()
└────────┬─────────┘ closeTurn() / closeRun()
▼
~/.agentdeck/apme.sqlite
Claude Code Stop 훅이 왜 안정성 문제인가? v2.1.104 기준 발화율이 11회 중 2회 수준. 그래서 PTY 링버퍼에서 ⏺ 마커를 찾아 응답 텍스트를 직접 뽑는 자체 파서가 1차 경로가 되었다. 훅이 오면 더 깨끗한 텍스트로 덮어쓰는 2차 보강만 한다.
이 단계의 핵심은 평가에 앞서 기록이 있다는 것. 평가 로직이 나중에 바뀌더라도 원본 데이터가 있으면 재평가가 가능하다.
수집된 run을 10가지 카테고리 중 하나로 분류한다:
coding refactoring debugging review
planning research conversation ops
multi_agent unknown
왜 분류가 먼저인가? 카테고리마다 "좋은 응답"의 정의도, 평가 방법도 다르기 때문이다. APME의 가장 중요한 아키텍처 결정 하나가 이 지점에 있다.
Rules (priority-ordered)
│
├─ plan mode 사용? → planning
├─ ≤2 tools, <120s? → conversation
├─ web search + grep? → research
├─ tests + edits + bash? → debugging
├─ >50% Edit, 3+ files? → refactoring
└─ ...
│
▼
unknown?
│
▼
Local MLX classifier (fallback, 공짜)
데몬이 30초마다 task_category IS NULL인 run을 재분류한다. 세션 프로세스가 일찍 kill되어 async classify가 중단된 경우를 이 루프가 복구한다.
overall score 하나 같은 단일 평가는 의미가 없다. 카테고리에 따라 ground truth의 형태 자체가 다르기 때문이다.
┌─────────────────┬──────────────────────┬──────────────────────────────┐
│ 카테고리 │ Ground Truth │ 평가 타이밍 + 루브릭 │
├─────────────────┼──────────────────────┼──────────────────────────────┤
│ coding │ │ │
│ refactoring │ git diff 존재 │ ▶ run 종료 후 (run-level) │
│ debugging │ + 커밋/테스트 │ + 결정론 레이어 (lint/ │
│ │ │ build/tests) │
│ │ │ + llm_judge (카테고리별) │
├─────────────────┼──────────────────────┼──────────────────────────────┤
│ conversation │ │ │
│ planning │ 응답 텍스트 자체 │ ▶ 턴마다 즉시 (turn-level) │
│ research │ (git 무관) │ + llm_judge only │
│ review │ │ (결정론 레이어 없음) │
├─────────────────┼──────────────────────┼──────────────────────────────┤
│ ops │ Bash 결과 │ ▶ run-level, general rubric │
│ multi_agent │ 에이전트 위임 성공 │ ▶ run-level, general rubric │
│ unknown │ fallback │ ▶ run-level, general rubric │
└─────────────────┴──────────────────────┴──────────────────────────────┘
왜 이렇게 나눴나?
-
코딩 3종 (coding/refactoring/debugging)
- git diff가 있어야 "무엇을 했는지"를 명확히 평가할 수 있다 → run이 끝나야 outcome 판정
- 결정론 레이어(lint/build/tests)가 실제로 값을 준다 — 통과/실패는 직접 측정 가능
- LLM judge는 결정론 레이어 위에 품질을 덧대는 역할
-
비코딩 4종 (conversation/planning/research/review)
- git diff는 의미 없다 (파일이 안 바뀜)
- 응답 텍스트 자체가 결과물이므로 턴마다 평가 가능
- 결정론 레이어는 사용 불가 (테스트할 게 없음)
- turn-level mid-session eval — 세션이 끝나기를 기다릴 필요 없음. 턴 완료 직후에 즉시 judge 호출,
evals테이블에layer='turn_judge' + turn_id로 저장 - 대시보드에 실시간으로 턴 카드 + score + reasoning이 찍힌다
-
나머지 (ops/multi_agent/unknown)
- 명확한 ground truth 형태가 없거나 혼합형이므로 general rubric + run-level로 fallback
각 카테고리는 고유한 **평가 축(axis)**을 가진 별도 루브릭을 사용한다. store.ts의 CATEGORY_RUBRICS가 이걸 DB에 시드한다:
┌──────────────┬──────────────────────────────────────────────────┐
│ general │ task_completion · code_quality · efficiency │
│ (coding) │ (weights: 0.50 · 0.30 · 0.20) │
├──────────────┼──────────────────────────────────────────────────┤
│ conversation │ accuracy · helpfulness · conciseness │
│ │ (0.50 · 0.30 · 0.20) │
├──────────────┼──────────────────────────────────────────────────┤
│ planning │ completeness · feasibility · clarity │
│ │ (0.40 · 0.35 · 0.25) │
├──────────────┼──────────────────────────────────────────────────┤
│ research │ thoroughness · relevance · synthesis │
│ │ (0.30 · 0.40 · 0.30) │
├──────────────┼──────────────────────────────────────────────────┤
│ debugging │ diagnosis · fix_quality · verification │
│ │ (0.35 · 0.40 · 0.25) │
├──────────────┼──────────────────────────────────────────────────┤
│ refactoring │ safety · improvement · scope │
├──────────────┼──────────────────────────────────────────────────┤
│ review │ coverage · insight · accuracy │
└──────────────┴──────────────────────────────────────────────────┘
이 구조의 함의: 같은 모델이 "debugging 잘함 + conversation은 장황함"일 수 있고, 그게 정상이다. 카테고리별 스코어카드가 모델 선택의 근거가 된다.
단일 점수(judge 점수 하나)는 신뢰할 수 없지만, 여러 차원의 독립 신호를 합치면 훨씬 안정적이다. APME의 composite_score는 이렇게 계산된다:
composite = 0.40 × outcomeScore ← "실제로 끝났는가"
+ 0.40 × judgeScore ← "품질이 좋은가" (LLM judge)
+ 0.15 × efficiencyScore ← "자원을 낭비하지 않았는가"
+ 0.05 × vibeScore ← "사람이 승인했는가"
각 차원이 독립적이라서 한 쪽이 오류를 내도 다른 차원이 잡아준다. judge가 너무 후하게 줘도 outcome이 "abandoned"면 전체 점수는 낮아진다.
outcome은 카테고리별로 다르게 판정된다:
┌── 코딩 카테고리 (git diff 기반) ──┬─ score ─┐
│ committed 실제 커밋까지 감 │ 1.00 │
│ ab_winner A/B 테스트 승자 │ 1.00 │
│ iterated 여러 번 시도 후 │ 0.60 │
│ exploratory 변경 있으나 미커밋 │ 0.50 │
│ pending 아직 진행 중 │ 0.50 │
│ interrupted 사용자가 중단 │ 0.30 │
│ abandoned 변경 없음 │ 0.20 │
│ ab_loser A/B 테스트 패자 │ 0.10 │
└────────────────────────────────────┴─────────┘
┌── 비코딩 카테고리 ────────────────┬─ score ─┐
│ committed 응답 텍스트 캡처됨 │ 1.00 │
│ (git 무관) │ │
└────────────────────────────────────┴─────────┘
비코딩은 "git commit 없이도 응답만 있으면 성공"으로 본다. Research나 planning에서 "어떤 파일도 안 바뀌었다"가 abandoned가 되면 안 되니까.
이 단계가 현재 APME의 병목. LLM judge를 먼저 만들지 않고 사람이 먼저 레이블링하는 이유는?
"LLM judge가 정답이다" ❌
"사람이 레이블링한 데이터가 ground truth" ✓
Judge 모델의 점수는 그 자체로는 믿을 수 없다. 같은 응답에 대해 모델마다, 프롬프트마다, 온도마다 다른 점수를 낸다. 내 취향이 기준이 되어야 한다.
대시보드에서 run 하나하나를 훑으면서 👍 / 👎 / 중립으로 vibe feedback을 입력한다. vibe_feedback 테이블에 쌓이는 이 데이터가:
- 모델별 실제 성능 프로파일 — "debugging에서 Opus가 내 기준으로 80%, Codex가 70%"
- Composite score의 5% 가중치 — 직접 반영
- Stage 4의 ground truth — judge 튜닝의 학습 신호
┌────────────┬──────────────────────────────┬─────────┐
│ backend │ 엔드포인트 │ 역할 │
├────────────┼──────────────────────────────┼─────────┤
│ mlx │ 127.0.0.1:8800 │ 기본 │
│ openclaw │ 127.0.0.1:18789 (gateway) │ 보조 │
└────────────┴──────────────────────────────┴─────────┘
Judge는 모든 run에 돌아간다. 클라우드 API를 쓰면 비용이 폭발하거나 사용자가 judge를 끄게 되고, 끄면 데이터가 축적 안 된다. 그래서 App Store Swift daemon 은 Apple Intelligence Foundation Models 를 기본으로 쓰고, CLI-only 환경은 Swift daemon proxy 가 없을 때 MLX Qwen3-1.7B-4bit fallback 또는 OpenClaw Gateway를 쓴다. 모두 로컬 경로라 전수 평가(sampleRate: 1.0)가 가능하다.
Stage 3 데이터가 충분히 쌓이면, 사람-judge 불일치를 learning signal로 사용할 수 있다:
tests_pass=1 but judge.overall < 0.5 ← 결정론적 테스트는 통과했는데
LLM judge는 나쁘다고 함
tests_pass=0 but judge.overall > 0.8 ← 테스트 실패인데 judge는 좋다고 함
vibe=rejected but judge > 0.8 ← 내가 거부했는데 judge는 고득점 줌
vibe=approved but judge < 0.4 ← 내가 승인했는데 judge는 저득점 줌
이런 disagreement 샘플을 모아서 judge에게 "왜 이런 차이가 났는지 설명하고, 더 나은 루브릭을 제안하라"고 요청한다. 제안된 루브릭을 shadow mode로 기존 데이터에 적용해서 vibe와의 상관관계가 실제로 개선되는 경우에만 accept한다.
이게 OPRO 스타일 루프다. 결과는 시간이 지날수록 내 판단과 일치하는 judge가 된다.
┌─────────────────────────────────────────────────────────────────────┐
│ WHY (지금 문제) │
│ │
│ 6개 모델 · 감 기반 라우팅 · 비용 최적화 근거 없음 · 범용 벤치마크로 │
│ ──────────────────────────────── 해결 불가 (개인화 필요) │
└─────────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ HOW (APME 구조 — 8 레이어) │
│ │
│ [L1] 3-path ingestion │
│ Claude hook / OC timeline / Codex PTY ──┐ │
│ ▼ │
│ [L2] ApmeCollector → SQLite │
│ runs · turns · steps · artifacts · evals · vibe · rubrics │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ [L3] Classifier [L4] Runner [L5] Tuner │
│ rules+MLX category- disagreement │
│ 10 categories aware eval → new rubric │
│ ┌─────────────────┴─────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ run-level (coding) turn-level (non-coding) │
│ det + llm_judge llm_judge only │
│ git-diff outcome response-captured outcome │
│ │ │
│ ▼ │
│ [L6] Recommender — scorecard → 모델 추천 │
│ │ │
│ ▼ │
│ [L7] Daemon 30s loop + HTTP /apme/* + WS apme_eval broadcast │
│ │ │
│ ▼ │
│ [L8] Device rendering — ★ eval_result timeline │
│ Stream Deck / Apple / Android / ESP32 / TUI │
└─────────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ WHAT (최종 산출물) │
│ │
│ 1. 카테고리별 모델 스코어카드 (v_category_scorecard) │
│ "debugging 에서 Opus 0.82, Codex 0.75, GLM 0.68" │
│ │
│ 2. Composite score — 4차원 가중합 │
│ 0.40 outcome + 0.40 judge + 0.15 efficiency + 0.05 vibe │
│ │
│ 3. 비용 효율 지표 (cost_per_quality) │
│ "같은 품질이라면 Codex가 Opus 대비 40% 저렴" │
│ │
│ 4. 자동 라우팅 추천 (recommender.ts) │
│ "이 태스크는 GLM → 실패 시 Opus로 escalate" │
│ │
│ 5. 내 취향에 맞춰 진화하는 LLM judge │
│ "수동 레이블링 없이도 신뢰할 수 있는 자동 평가" │
└─────────────────────────────────────────────────────────────────────┘
평가는 세션 프로세스의 수명과 분리되어 있어야 한다. 세션이 kill되거나 크래시되면 진행 중이던 async 작업(classify, judge)이 날아가기 때문이다. 그래서 데몬이 30초마다 복구 루프를 돈다:
┌─── 30초 주기 데몬 루프 (daemon-server.ts) ───┐
│ │
│ 1. 미평가 run 큐 enqueue │
│ 2. 10초 이상 닫힌 run의 outcome 계산 │
│ 3. task_category IS NULL 재분류 │
│ 4. orphan run 태깅 │
│ │
└──────────────────────────────────────────────┘
이 루프 덕분에 세션이 비정상 종료돼도 평가 데이터가 결국 채워진다.
평가 결과가 대시보드 안에서만 보이면 사용자는 그걸 열람하지 않는다. 그래서 eval_result 를 timeline entry type으로 만들어 모든 디바이스로 broadcast한다:
apme_eval WS ──▶ BridgeTimeline ──▶ ★ timeline entry
├─ Stream Deck (amber ★)
├─ Apple (ledAmber EVAL)
├─ Android (LEDAmber)
├─ ESP32 (@ TLToolReq)
└─ TUI dashboard
run이 끝나면 Stream Deck + iPhone + ESP32 LED가 동시에 ★ debugging 82%를 띄운다. 시선이 가니까 "👍/👎 누를까?"라는 생각이 든다. 데이터 수집 행동을 트리거하는 UX가 아키텍처의 일부다.
| 단계 | 상태 | 비고 |
|---|---|---|
| L1 3-path ingestion | ✅ 완료 | Claude(hook+PTY)/OpenClaw/OpenCode/Codex 모두 가동 |
| L2 Collector + Store | ✅ 완료 | 7개 테이블 + 3개 집계 뷰. rubrics.version PK 충돌 버그 2026-04-13 수정 |
| L3 Classifier | ✅ 완료 | 10 카테고리 rule + MLX fallback + 30초 재분류 루프 |
| L4 Runner (coding run-level) | ✅ 완료 | 결정론 레이어(lint/build/tests) + llm_judge 카테고리별 루브릭 |
| L4 Runner (non-coding turn-level) | ✅ 완료 | 턴 완료 직후 즉시 평가, layer='turn_judge' |
| L4 Judge backends | ✅ 완료 | MLX(default) / OpenClaw gateway (둘 다 로컬) |
| L5 Tuner | 🧪 구조 완료, 가동 대기 | tuner.ts 구현. vibe 데이터 30개 이상 쌓이면 첫 tune |
| L6 Recommender | ✅ 완료 | scorecard 기반 top-3 제안 |
| L7 HTTP + WS broadcast | ✅ 완료 | /apme/* 8개 라우트 + apme_eval WS |
| L8 Device rendering | ✅ 완료 | Stream Deck / Apple / Android / ESP32 / TUI 전부 eval_result 렌더링 |
| Vibe labeling 데이터 축적 | 🔧 병목 | 인프라는 완성. 내가 실제로 👍/👎 누르는 습관이 남았음 |
병목은 Stage 3다. 기술적으로 어려운 게 아니라 내가 실제로 레이블링을 해야 한다는 행동 습관의 문제다. 디바이스 전반(Stream Deck, Apple, Android, ESP32)에 ★ eval_result 렌더링을 붙인 것도 이 때문이다 — run이 끝나면 시선에 바로 들어와야 "👍/👎 누를까?"라는 생각이 든다.
몇 가지는 일부러 안 했다. 그 이유도 기록해둘 가치가 있다:
LLM judge를 잘 만들어두면 사람이 레이블링할 필요 없지 않나? — 이 생각은 틀렸다. judge가 내 기준에 맞는지 검증할 방법이 없기 때문이다. 사람 데이터가 없으면 "judge가 잘못되고 있다"는 것조차 발견할 수 없다. 그래서 vibe 데이터가 먼저.
대시보드에 "이번 주 Top Model"같은 UI를 넣고 싶은 유혹이 있었지만 안 했다. 데이터가 30개도 안 쌓인 상태에서 랭킹을 보여주면 사용자(나)가 잘못된 결론을 내린다. 최소 표본 크기가 확보되기 전까지는 raw run 리스트만 보여준다.
Judge는 로컬 backend 만 쓴다. App Store 앱은 Apple Intelligence, CLI-only fallback 은 MLX, 보조로 OpenClaw Gateway가 있지만 그것도 로컬이다. 이유:
- 비용 — judge는 모든 run에 돌아가야 한다. 클라우드 API면 즉시 비용 문제
- 신뢰 loop — 비용이 두려우면 judge를 끄게 되고, 끄면 데이터가 축적 안 된다
- 죄책감 없는 전수 평가 —
sampleRate: 1.0이 기본. 로컬이니까 가능
성능 trade-off(로컬 < 프론티어 모델)는 Stage 4의 auto-tune이 채운다는 게 가설이다. 싼 judge + 튜닝 루프 > 비싼 judge + 정적 루브릭.
APME 초기 설계는 git diff 기반 coding 평가가 중심이었다. 그런데 내가 실제로 모델을 가장 자주 쓰는 건 코딩이 아니라 다른 것이었다:
- "이 에러 로그 설명해줘" (conversation)
- "이 라이브러리 뭐 쓰는지 조사해줘" (research)
- "이걸 어떻게 리팩토링할까" (planning)
이런 non-coding 카테고리가 평가 안 되면 APME는 내 실제 사용 패턴의 절반도 못 담는다. 그래서 category rubrics와 turn-level mid-session eval을 만든 것이다 (코딩은 run 종료 후, 나머지는 턴마다).
APME가 성공했다고 말할 수 있는 조건은 무엇일까?
6개월 후 어느 날, 새 태스크를 시작할 때:
"이건 debugging이니까 Codex" ← 감이 아니라
"지난 50개 debugging run에서 Codex가 Opus 대비 품질 95%,
비용 40% — 이 태스크도 Codex로 가자" ← 데이터 기반
혹은:
"이 작업은 중요하니까 Opus" ← 감이 아니라
"이 카테고리는 내 vibe 기준으로 모델 간 갭이 크다
→ Opus, 반대로 카테고리는 갭이 작음 → MLX" ← 데이터 기반
이렇게 판단의 근거가 SQLite 안에 있게 되는 것이 목적이다.
- apme.md — APME 개요, 이론적 배경, 활성화 방법
- apme-pipeline.md — 8-layer 파이프라인 구현 상세
- architecture.md — AgentDeck 전체 아키텍처