요약
cold-start 집계 경로에서 출력 summary가 dedup 이전 값으로 합산되는 정확성 버그 1건과, dedup 설계 관련 효율화 여지 3건을 정리한다. 배경 이해를 위해 이 모든 문제의 근원인 Claude Code의 jsonl 저장 방식도 함께 기록한다.
- 영향 범위: cold-start 초기 스캔 시
emit_summary로 출력되는 값. (쿼리 리포트는 DB에서 읽으므로 정상)
- 분석 기준: toki
v2.1.0, Claude Code 네이티브 v2.1.169
배경: Claude Code의 데이터 저장 방식 (왜 dedup이 필요한가)
toki의 dedup 설계 의도를 정확히 이해하려면 원본 데이터가 왜 중복을 갖는지부터 짚어야 한다. Claude Code 바이너리(v2.1.169, Bun 컴파일 단일 실행파일)를 추출해 확인한 사실:
1) 한 응답이 content block 수만큼 jsonl 줄로 쪼개진다
한 API 응답(requestId 1개, message.id 1개)이 thinking + text + tool_use × N 같은 여러 content block을 반환하면, Claude Code는 이를 block마다 별도 assistant 줄로 기록한다. 그리고 각 줄에 전체 응답의 동일한 usage를 그대로 박는다.
실측 (실제 세션 1개, assistant 1141줄 / 486 requestId):
| 합산 방식 |
cache_read |
output |
| 단순 줄 합산 (naive) |
506,292,368 |
2,134,414 |
message.id dedup |
215,130,529 |
834,118 |
| 부풀림 배수 |
2.35× |
2.56× |
486개 requestId 중 351개가 2줄 이상으로 분할됨 (분포: 2줄 162, 3줄 146, 최대 10줄). tool_use가 많은 턴(병렬 Read/Grep 등)일수록 더 크게 부풀려진다.
2) Claude Code 자체 /usage도 dedup하지 않는다
Claude Code의 /usage 대시보드(캐시: ~/.claude/stats-cache.json)를 그리는 집계 함수는 디스크의 세션 jsonl을 줄 단위로 순회하며 usage를 그대로 누적한다 — message.id 기반 dedup 가드가 전혀 없다. (함수 내 미사용 Set 변수만 선언돼 있고 dedup에 쓰이지 않음.) 같은 바이너리 안에 dedup 키 추출기(message.id 반환)와, context window 계산 시 "마지막 usage만" 취하는 로직은 존재하지만, /usage 집계 경로엔 적용돼 있지 않다. → Claude Code /usage의 토큰 수치는 위 배수만큼 과대계상된다.
3) OTEL export는 정상
OTEL 메트릭(claude_code.token.usage)은 스트리밍 핸들러에서 응답당 1회만 counter에 add된다 (최종 message.usage 기준). transcript를 줄 단위로 도는 별도 기록 함수는 테스트 픽스처 전용 경로라 실런타임에 타지 않는다. → OTEL은 중복 없음. /cost·statusline도 라이브 인메모리 누적기 기반이라 정상.
결론: jsonl을 직접 파싱하는 모든 소비자(toki 포함)는 반드시 message.id 단위 dedup을 해야 한다. toki는 이걸 올바르게 하고 있고(아래), 오히려 Claude Code 자체 /usage보다 정확하다.
toki의 dedup 설계 (현행)
핵심: parser.rs가 event_key = "{message_id}:{timestamp}"를 만들고, 실제 dedup은 bare msg_id(: 앞 = msg_XXX) 단위로 "한 msg_id당 마지막 1건만 keep".
db.rs의 버전 히스토리가 설계 의도를 보여준다:
// - v2: added idx_msg keyspace for msg_id dedup (streaming snapshot handling)
// - v3: added message_id to event key, changed dedup to bare_msg_id
content block 분할 줄은 같은 msg_id, 스트리밍 스냅샷은 같은 msg_id에 timestamp만 다르게 여러 번 기록되므로, msg_id:timestamp 단위 dedup으로는 스냅샷이 안 합쳐진다. v3에서 bare msg_id 단위로 바꿔 둘 다 잡도록 한 것. "마지막 1건"이 옳은 이유: 분할 줄은 usage가 전부 동일, 스냅샷은 마지막이 최종값.
dedup이 걸리는 3개 계층:
| 계층 |
위치 |
방식 |
| 파일 내 (cold start) |
engine.rs::dedup_cold_start_events (L902) |
HashMap<msg_id, last_idx>, 파일 단위 마지막만 |
| 배치 내 (watch/live) |
writer.rs::flush_pending (L198~) |
flush 배치 안에서 HashMap<&str,usize> 마지막만 |
| DB 영속 (cross-batch) |
db.rs::insert_event_dedup (L186) |
idx_msg(bare_msg_id → 최신 event key)로 이전 이벤트 삭제 후 삽입 |
- cold start마다
cleanup_old_idx_msg(L470)로 24h 지난 idx_msg 정리. 이 설계 자체는 정확하고 견고하다 (분할·스트리밍·재읽기·세션 fork/resume 중복까지 커버).
🐛 버그: cold-start 출력 summary가 dedup 이전에 합산됨
cold_start_provider에서 into_summary_and_event가 합산과 이벤트 생성을 한 함수에 묶어 줄마다 호출되어, summary는 분할 줄까지 다 더한 값이 된다. 이벤트는 이후 dedup되어 DB로 가지만 summary는 보정되지 않는다.
engine.rs L194, L255, L279:
// handle_parsed! 매크로 — 파싱된 줄마다 호출 (dedup 전)
file_events.push(parsed.into_summary_and_event(summary, ...)); // summary += 모든 줄
...
let deduped = dedup_cold_start_events(file_events); // 이벤트만 dedup → DB
...
self.sink.emit_summary(&result_summaries, ...); // ← dedup 전 누적값을 출력
providers/mod.rs L99~124 — 합산과 이벤트 생성이 한 몸:
pub fn into_summary_and_event(self, summary: &mut ModelUsageSummary, ...) -> ColdStartEvent {
summary.input_tokens += self.tokens.input_tokens; // ← dedup 전 모든 줄 누적
summary.output_tokens += self.tokens.output_tokens;
summary.cache_creation_input_tokens += self.tokens.cache_creation_input_tokens;
summary.cache_read_input_tokens += self.tokens.cache_read_input_tokens;
summary.event_count += 1;
ColdStartEvent { message_id: self.event_key, ... }
}
결과: 초기 스캔 시 찍히는 summary(토큰 합계 + event_count)가 Claude Code /usage와 동일하게 부풀려진다(위 실측 기준 cache_read 2.35×, output 2.56× 수준). DB 기반 쿼리 리포트는 정상이라 사용자가 두 숫자의 불일치를 보게 된다.
참고: 레거시 경로 cold_start(L343/358/380)도 동일 패턴.
수정 방향: 합산과 이벤트 생성을 분리해, dedup 이후의 deduped 이벤트로 summary를 합산한다. 정확성을 잡으면서 중복 줄에 대한 합산 연산도 절약된다.
⚡ 효율화 여지
#1 event_key의 :timestamp는 사실상 죽은 데이터 (가장 깔끔한 개선)
DB 이벤트 키는 [ts_ms 8B BE][event_key 문자열]인데 event_key가 "msg_XXX:2026-...Z"라서 timestamp가 키에 두 번 들어간다 (앞 8B + 문자열 내부). 그런데:
- dedup은
bare_msg_id(: 앞)만 사용 → :timestamp는 dedup에 안 쓰임 (db.rs::bare_msg_id L178)
- ts는 별도 8B prefix에 이미 존재 → 시간 정렬/조회에도 문자열 timestamp 불필요
즉 parser.rs L100/121/142의 format!("{}:{}", message_id, timestamp)로 이벤트당 String을 할당하고, bare_msg_id에서 매번 .split(':')로 도로 떼어내는 게 전부 불필요한 왕복이다. msg_id(bare)와 ts_ms를 따로 전달하면:
- 이벤트당 String 할당 1회 제거
- 모든
.split(':') 제거
- DB 키마다 ISO timestamp(~24B) 제거 → LSM 키스페이스 크기·비교 비용 감소
v2→v3에서 dedup 단위가 event_key→bare msg_id로 바뀌며 남은 레거시 잔재로 보인다.
#2 cold-start dedup을 인접 기반 single-pass로
dedup_cold_start_events(L902)는 유니크 이벤트까지 포함해 전부 msg_id.to_string() 할당 → HashMap 구축 → HashSet(indices) → 재필터링의 다중 패스다. 그런데 위 배경 #1에서 보듯 분할 줄·스트리밍 스냅샷은 jsonl에서 항상 연속으로 나타난다. 이 보장을 쓰면 **"인접한 동일 msg_id 런에서 마지막만 keep"**하는 단일 패스로 끝난다 — HashMap·to_string()·HashSet 전부 불필요. 대용량 히스토리 cold start에서 의미 있는 절감.
#3 cold-start의 DB read-back 중복
cold start는 engine에서 이미 파일 단위 dedup을 끝냈는데, writer.rs::flush_bulk_events(L312)도 insert_event_dedup을 호출해 이벤트마다 idx_msg.get + events.get + 조건부 remove를 또 한다 (주석 L310도 "engine already deduped … but idx_msg still needs populating"이라 인정). cold start 한정으로 "plain insert + idx_msg만 채우기" 경로를 분리하면 이벤트당 DB read 2회를 제거할 수 있다. 크로스파일 중복(fork/resume) 안전망은 cold start 후 1회 정리 패스로 대체 가능.
부차: cleanup_old_idx_msg(L470)는 idx_msg 전체 full-scan. 키가 bare msg_id라 시간 range-scan이 불가한 구조적 제약이며, 키를 [ts_ms][msg_id]로 바꾸면 range 삭제가 가능하나 get-by-msg_id가 깨져 secondary index가 필요 — 득실 미묘하여 보류 권장.
우선순위 제안
- (정확성) cold-start summary를 dedup 이후로 합산 — 사용자가 보는 숫자 불일치 해소
- (효율, 큰 효과·낮은 리스크)
event_key의 timestamp 잔재 제거
- (효율, 핫패스 미세) cold-start dedup single-pass / DB read-back 제거
🤖 Generated with Claude Code
요약
cold-start 집계 경로에서 출력 summary가 dedup 이전 값으로 합산되는 정확성 버그 1건과, dedup 설계 관련 효율화 여지 3건을 정리한다. 배경 이해를 위해 이 모든 문제의 근원인 Claude Code의 jsonl 저장 방식도 함께 기록한다.
emit_summary로 출력되는 값. (쿼리 리포트는 DB에서 읽으므로 정상)v2.1.0, Claude Code 네이티브v2.1.169배경: Claude Code의 데이터 저장 방식 (왜 dedup이 필요한가)
toki의 dedup 설계 의도를 정확히 이해하려면 원본 데이터가 왜 중복을 갖는지부터 짚어야 한다. Claude Code 바이너리(
v2.1.169, Bun 컴파일 단일 실행파일)를 추출해 확인한 사실:1) 한 응답이 content block 수만큼 jsonl 줄로 쪼개진다
한 API 응답(
requestId1개,message.id1개)이thinking + text + tool_use × N같은 여러 content block을 반환하면, Claude Code는 이를 block마다 별도 assistant 줄로 기록한다. 그리고 각 줄에 전체 응답의 동일한usage를 그대로 박는다.message.id+ 같은requestId+ 동일usage,uuid만 다르게parentUuid로 체이닝됨실측 (실제 세션 1개, assistant 1141줄 / 486 requestId):
message.iddedup486개 requestId 중 351개가 2줄 이상으로 분할됨 (분포: 2줄 162, 3줄 146, 최대 10줄). tool_use가 많은 턴(병렬 Read/Grep 등)일수록 더 크게 부풀려진다.
2) Claude Code 자체
/usage도 dedup하지 않는다Claude Code의
/usage대시보드(캐시:~/.claude/stats-cache.json)를 그리는 집계 함수는 디스크의 세션 jsonl을 줄 단위로 순회하며usage를 그대로 누적한다 —message.id기반 dedup 가드가 전혀 없다. (함수 내 미사용Set변수만 선언돼 있고 dedup에 쓰이지 않음.) 같은 바이너리 안에 dedup 키 추출기(message.id반환)와, context window 계산 시 "마지막 usage만" 취하는 로직은 존재하지만,/usage집계 경로엔 적용돼 있지 않다. → Claude Code/usage의 토큰 수치는 위 배수만큼 과대계상된다.3) OTEL export는 정상
OTEL 메트릭(
claude_code.token.usage)은 스트리밍 핸들러에서 응답당 1회만 counter에 add된다 (최종message.usage기준). transcript를 줄 단위로 도는 별도 기록 함수는 테스트 픽스처 전용 경로라 실런타임에 타지 않는다. → OTEL은 중복 없음./cost·statusline도 라이브 인메모리 누적기 기반이라 정상.결론: jsonl을 직접 파싱하는 모든 소비자(toki 포함)는 반드시
message.id단위 dedup을 해야 한다. toki는 이걸 올바르게 하고 있고(아래), 오히려 Claude Code 자체/usage보다 정확하다.toki의 dedup 설계 (현행)
핵심:
parser.rs가event_key = "{message_id}:{timestamp}"를 만들고, 실제 dedup은 bare msg_id(:앞 =msg_XXX) 단위로 "한 msg_id당 마지막 1건만 keep".db.rs의 버전 히스토리가 설계 의도를 보여준다:content block 분할 줄은 같은 msg_id, 스트리밍 스냅샷은 같은 msg_id에 timestamp만 다르게 여러 번 기록되므로,
msg_id:timestamp단위 dedup으로는 스냅샷이 안 합쳐진다. v3에서 bare msg_id 단위로 바꿔 둘 다 잡도록 한 것. "마지막 1건"이 옳은 이유: 분할 줄은 usage가 전부 동일, 스냅샷은 마지막이 최종값.dedup이 걸리는 3개 계층:
engine.rs::dedup_cold_start_events(L902)HashMap<msg_id, last_idx>, 파일 단위 마지막만writer.rs::flush_pending(L198~)HashMap<&str,usize>마지막만db.rs::insert_event_dedup(L186)idx_msg(bare_msg_id → 최신 event key)로 이전 이벤트 삭제 후 삽입cleanup_old_idx_msg(L470)로 24h 지난 idx_msg 정리. 이 설계 자체는 정확하고 견고하다 (분할·스트리밍·재읽기·세션 fork/resume 중복까지 커버).🐛 버그: cold-start 출력 summary가 dedup 이전에 합산됨
cold_start_provider에서into_summary_and_event가 합산과 이벤트 생성을 한 함수에 묶어 줄마다 호출되어, summary는 분할 줄까지 다 더한 값이 된다. 이벤트는 이후 dedup되어 DB로 가지만 summary는 보정되지 않는다.engine.rsL194, L255, L279:providers/mod.rsL99~124 — 합산과 이벤트 생성이 한 몸:결과: 초기 스캔 시 찍히는 summary(토큰 합계 +
event_count)가 Claude Code/usage와 동일하게 부풀려진다(위 실측 기준 cache_read 2.35×, output 2.56× 수준). DB 기반 쿼리 리포트는 정상이라 사용자가 두 숫자의 불일치를 보게 된다.수정 방향: 합산과 이벤트 생성을 분리해, dedup 이후의
deduped이벤트로 summary를 합산한다. 정확성을 잡으면서 중복 줄에 대한 합산 연산도 절약된다.⚡ 효율화 여지
#1
event_key의:timestamp는 사실상 죽은 데이터 (가장 깔끔한 개선)DB 이벤트 키는
[ts_ms 8B BE][event_key 문자열]인데 event_key가"msg_XXX:2026-...Z"라서 timestamp가 키에 두 번 들어간다 (앞 8B + 문자열 내부). 그런데:bare_msg_id(:앞)만 사용 →:timestamp는 dedup에 안 쓰임 (db.rs::bare_msg_idL178)즉
parser.rsL100/121/142의format!("{}:{}", message_id, timestamp)로 이벤트당 String을 할당하고,bare_msg_id에서 매번.split(':')로 도로 떼어내는 게 전부 불필요한 왕복이다.msg_id(bare)와ts_ms를 따로 전달하면:.split(':')제거v2→v3에서 dedup 단위가
event_key→bare msg_id로 바뀌며 남은 레거시 잔재로 보인다.#2 cold-start dedup을 인접 기반 single-pass로
dedup_cold_start_events(L902)는 유니크 이벤트까지 포함해 전부msg_id.to_string()할당 → HashMap 구축 → HashSet(indices) → 재필터링의 다중 패스다. 그런데 위 배경 #1에서 보듯 분할 줄·스트리밍 스냅샷은 jsonl에서 항상 연속으로 나타난다. 이 보장을 쓰면 **"인접한 동일 msg_id 런에서 마지막만 keep"**하는 단일 패스로 끝난다 — HashMap·to_string()·HashSet 전부 불필요. 대용량 히스토리 cold start에서 의미 있는 절감.#3 cold-start의 DB read-back 중복
cold start는 engine에서 이미 파일 단위 dedup을 끝냈는데,
writer.rs::flush_bulk_events(L312)도insert_event_dedup을 호출해 이벤트마다idx_msg.get+events.get+ 조건부 remove를 또 한다 (주석 L310도 "engine already deduped … but idx_msg still needs populating"이라 인정). cold start 한정으로 "plain insert + idx_msg만 채우기" 경로를 분리하면 이벤트당 DB read 2회를 제거할 수 있다. 크로스파일 중복(fork/resume) 안전망은 cold start 후 1회 정리 패스로 대체 가능.우선순위 제안
event_key의 timestamp 잔재 제거🤖 Generated with Claude Code