Recent Posts

All Posts →

Hermes와 Modal로 AI agent 챗봇을 0원에 운영하기

Hermes 에이전트와 Modal 서버리스, Telegram webhook, LLM provider를 조합해 AI 챗봇을 무료로 운영한 기록을 정리합니다.

제가 GitHub에 정리한 개인 기록을 근거로 답하는 텔레그램 챗봇을 고정 비용 0원으로 운영하고 싶었습니다. 항상 켜 둘만한 개인 PC가 있지도 않았습니다. 그래서 LLM 에이전트인 Hermes와 서버리스 플랫폼인 Modal, Telegram 웹훅 등의 여러 기술을 조합하여 챗봇을 띄웠습니다. 그 선택 과정과 핵심 코드를 이 글에서 정리했습니다.

이 구성은 트래픽이 많지 않고, Modal의 월별 무료 크레딧 안에서 호출과 저장소 사용량이 감당되며, K-QMD 임베딩 갱신을 아주 자주 돌리지 않는다는 전제에서 0원에 가깝게 운영할 수 있습니다.

1. 챗봇의 기능

제가 그동안 개인적으로 기록해둔 블로그, 독서 감상문, 일기, 개발 관련 링크 모음은 Markdown이나 Asciidoc 형태로 GitHub 저장소에 쌓여 있습니다. 일종의 개인 지식 DB입니다. 해당 지식 저장소들을 한 번에 검색해서 대답을 하는 Telegram 챗봇을 만들었습니다.

아래와 같은 질문들에 재미있는 답을 해주었습니다.

  • 내가 가장 재미있게 읽은 만화책은?

  • 내가 가장 감명 깊게 보낸 한 해는?

  • 나의 주 관심사와 어울리는 책을 추천해줘.

Telegram 챗봇 응답 예시

2. 구성 요소

챗봇은 다음과 같은 구성요소로 설계했습니다.

컴포넌트 역할 설명

Hermes

LLM 에이전트

Telegram webhook gateway, MCP 클라이언트, 에이전트 루프를 모두 제공

K-QMD

검색 엔진

Markdown, Asciidoc 을 임베딩으로 인덱싱하고 의미 검색을 MCP로 제공. QMD에 Kiwi 형태소 분석을 결합한 fork

Modal

실행 환경

컨테이너 실행, 데이터 저장 공간

OpenClaw와 Hermes를 비교하는 글에서 Hermes가 클라우드에 올리기 편하다는 설명을 보고 선택했습니다. 검색 엔진은 처음엔 QMD를 썼는데, 한글 문서에서 검색 recall이 낮아서 한글 형태소 분석기를 결합한 K-QMD fork로 옮겼습니다. K-QMD는 binary 이름과 CLI를 그대로 유지하는 drop-in replacement라 교체 비용이 거의 없었습니다. Modal은 Gemini의 추천을 받아서 선택했습니다. Hermes의 소개글에서 클라우드 실행 환경으로는 Daytona와 Modal을 사용할 수 있다는 설명을 먼저 보고, Gemini에게 무료로 챗봇 운영을 하기에 유리한 플랫폼을 추천해달라고 요청한 결과입니다.

LLM 에이전트는 처음엔 OpenClaw를 썼는데, 위 구성으로 가려고 Hermes로 옮겼습니다. Hermes가 OpenClaw 데이터 마이그레이션을 지원해서 큰 어려움은 없었습니다.

2.1. LLM 엔진

LLM 엔진은 GPT 5.5로 연동했습니다. 평소 주로 쓰는 모델은 Claude 계열이지만, 해당 시점에서는 개인 구독하는 Claude 상품이 Hermes와 연동이 되지 않아서 ChatGPT 구독을 통한 GPT 5.5를 선택했습니다.

2.2. Modal의 운영 모델

Modal은 실제 사용자 호출을 처리하는 동안에만 컨테이너가 깨어나는 서버리스 플랫폼입니다. 대기 시간에는 비용이 발생하지 않고, 현재 Starter 플랜에서는 월 $30의 무료 크레딧이 제공되므로 트래픽이 띄엄띄엄한 Telegram 챗봇은 그 범위 안에서 0원으로 운영할 수 있을 것으로 예상합니다. 대신 서버가 깨어나는 첫 호출에는 cold start 지연이라는 트레이드오프가 있습니다. 첫 메시지는 몇 초~십수 초 늦게 답이 옵니다. 챗봇이 지연에 민감하지 않다면 받아들일 만한 단점입니다.

OpenClaw에서는 기본적으로는 long polling으로 Telegram과 연동을 합니다. long polling을 하기 위해서는 서버가 계속 켜져 있어야 하기에 Modal을 통해 추구하는 "필요할 때만 깨어나는" 운영 모델과는 맞지 않았습니다. 그래서 Hermes로 전환하면서 Telegram의 webhook을 통해 연동하는 구조로 바꿨습니다.

3. 챗봇 앱의 핵심 코드

전체 소스는 benelog/hermes-modal 저장소에 있습니다. 이 글에서는 그중 설계 의도를 보여주는 코드 조각만 발췌해 설명합니다. Modal로 실행하는 파이썬 앱의 설계와 구현은 Claude Code의 도움을 받았습니다.

3.1. 최소 재현 절차

처음부터 같은 구성을 따라 하려면 아래 순서로 접근하는 편이 시행 착오가 적습니다.

  1. Telegram bot token, LLM provider 인증 정보, GitHub token을 준비합니다.

  2. Modal의 Secret과 Volume을 생성합니다.

  3. TELEGRAM_WEBHOOK_URL 없이 먼저 modal deploy 해서 외부 URL을 발급받습니다.

  4. 발급받은 URL을 Telegram webhook과 Modal secret에 반영한 뒤 다시 modal deploy 합니다.

  5. `modal run modal_app.py::sync_qmd --embed`를 실행해서 지식 저장소를 가져오고 인덱스를 만듭니다.

이후에는 Telegram에서 메시지를 보내 챗봇을 호출하고, 문서가 바뀌면 `sync_qmd`만 다시 돌리면 됩니다.

3.2. 메시지가 올 때만 프로세스가 깨어나게

@app.function(
    image=image,
    secrets=[secret, github_secret],
    volumes=volume_mounts,
    timeout=60 * 60,
    max_containers=1,
    scaledown_window=60 * 10,
    env=common_env,
)
@modal.concurrent(max_inputs=50)
@modal.web_server(WEBHOOK_PORT, startup_timeout=120)
def gateway():
    ...
  • `min_containers`를 지정하지 않았습니다. 24/7 고정 컨테이너가 없어 대기 시의 비용이 0이 됩니다.

  • `scaledown_window=600`으로 10분 동안 요청이 없으면 자동 종료합니다.

  • `max_containers=1`로 두 개 이상 컨테이너가 동시에 webhook을 받지 않게 막았습니다. Telegram bot token은 webhook을 받는 주체가 둘 이상이면 동작이 불안정합니다. 한 컨테이너 안에서의 동시 처리는 `@modal.concurrent(max_inputs=50)`로 받습니다.

3.3. 갱신 주기에 따른 Volume 분리

영속적으로 저장되어야할 파일은 Modal의 Volume에 저장했습니다. 이를 이용해서 Hermes 설정 파일, K-QMD 인덱스, 지식 DB 역할의 Git 저장소가 컨테이너 재시작과 무관하게 유지됩니다. Volume은 아래 4개로 나눴습니다.

hermes_volume    = modal.Volume.from_name("hermes-home", ...)
workspace_volume = modal.Volume.from_name("hermes-workspace", ...)
qmd_cache_volume = modal.Volume.from_name("qmd-cache", ...)
qmd_config_volume = modal.Volume.from_name("qmd-config", ...)

각 Volume의 수명과 갱신 주기가 다릅니다.

  • hermes-home: Hermes config, runtime skill, OAuth auth.json. 크기는 작고, 컨테이너 재시작마다 일부가 갱신됩니다.

  • hermes-workspace: K-QMD가 인덱싱하는 git 저장소들. 크기가 가장 크고, sync_qmd 호출 때만 갱신됩니다.

  • qmd-cache: 임베딩/리랭크 GGUF 모델과 캐시. 거의 안 바뀌지만 용량이 큽니다.

  • qmd-config: K-QMD index 설정. 작고 자주 바뀌지 않습니다.

한 Volume에 다 몰아넣으면 데이터 저장, 갱신을 할 때 상관없는 큰 데이터까지 함께 끌려옵니다. 갱신 주기가 비슷한 것끼리 묶는 편이 안전하고 효율적입니다.

3.4. 지식 DB 소스

봇이 답할 때 참고하는 문서는 scripts/qmd_repos.yml에 정의했습니다.

repos:
  - name: blog
    repo_url: git@github.com:benelog/blog.git
    branch: master
    collection_path: src/content
    pattern: "**/*.{md,adoc}"
  - name: wiki
    ...

sync_qmd 함수는 이 manifest를 기준으로 repo를 clone/pull하고, repo 내부의 `collection_path`만 K-QMD config에 연결합니다. 내가 평소 쓰는 노트 저장소(블로그, 위키, devnote, 책장, 일기 등)가 그대로 챗봇의 지식 베이스가 됩니다.

문서를 갱신하고 싶을 때는 텔레그램 채팅창에서 봇에게 "qmd 갱신해줘", "최신 내용 가져와" 같이 요청합니다. sync-qmd 스킬이 git pull과 K-QMD 인덱싱을 실행해줍니다. 동일한 동작은 로컬에서 다음 명령으로도 트리거할 수 있습니다.

modal run modal_app.py::sync_qmd          # repo만 갱신
modal run modal_app.py::sync_qmd --embed  # 임베딩까지

이걸 cron이나 GitHub Actions로 주기 호출하면 챗봇 지식이 자동 최신화됩니다.

3.5. 연동 시스템을 위한 Secret 관리

GitHub, ChatGPT, Telegram 등 다른 시스템과 연동을 하기 위한 Secret도 2개로 나눴습니다. secret의 원본 저장소와 갱신하는 프로세스를 분리하기 위해서입니다.

  • ChatGPT, Telegram 인증을 위한 secret: `scripts/create_modal_secret.py`는 로컬 `~/.hermes/.env`와 `auth.json`에서 Modal secret을 만듭니다. 내부적으로 `modal secret create --force`를 써서 secret 전체를 통째로 교체합니다.

  • GitHub Access token : Modal의 UI에서 직접 입력하도록 의도했습니다.

`modal_app.py`는 두 그룹의 secret을 함께 주입합니다.

secret = modal.Secret.from_name("hermes-modal-secrets")
github_secret = modal.Secret.from_name("github-secret")

@app.function(secrets=[secret, github_secret], ...)
def gateway(): ...

3.6. idempotent한 부팅 스크립트

gateway, sync_qmd, doctor 함수 모두 시작할 때 `scripts/prepare_runtime.py`를 호출합니다. 이 스크립트가 하는 일은 다음과 같습니다.

  • auth.json 작성 (base64로 secret에 들어 있는 OAuth 토큰)

  • Hermes config.yaml, .env 작성

  • ~/.hermes/SOUL.md, ~/.hermes/skills/, ~/.hermes/hooks/`를 image의 `scripts/ 트리와 동기화

  • (옵션) 지식 repo clone/pull과 K-QMD 인덱싱

핵심은 컨테이너가 어떤 상태에서 시작하더라도 같은 결과가 나오게 하는 것입니다. Modal Volume에 이전 실행의 부산물이 남아 있어도, image에서 새로 실어 보낸 skill이 있어도, 이 스크립트 한 번이면 정상 상태로 수렴합니다.

3.7. 런타임에 추가되는 skill을 git으로 관리하기

Hermes는 실행 중에 새로운 skill이 생기거나 기존 skill이 업데이트될 수 있는 Agent입니다. agent의 주인인 제가 그 변화를 추적하고 필요하면 직접 수정할 필요성도 있다고 판단했습니다. 그래서 Modal Volume에 저장되는 skill도 변화가 있을 때 git으로 자동으로 커밋되고 푸시되도록 구성했습니다.

Hermes가 실행 중 만든 skill을 다시 git repo로 push하는 흐름은 scripts/hooks/skills-autocommit/HOOK.yaml 훅으로 걸어 두었습니다.

name: skills-autocommit
description: Push HERMES_HOME/skills changes back to the hermes-modal git repo after each agent turn.
events:
  - agent:end

agent:end 이벤트가 매 에이전트 종료마다 발생할 때 commit_skills.py가 호출됩니다. 이 스크립트는 Modal Volume의 ~/.hermes/skills/ 변경분을 hermes-modal repo의 scripts/skills/`로 복사하고 push합니다. 결과적으로 `git log`에 `Sync skills from Modal volume 커밋이 자동으로 쌓입니다.

이 구조에는 두 가지 의미가 있습니다.

  • runtime에서 학습한 동작이 image로 흡수됩니다. 다음 컨테이너 부팅 때 `prepare_runtime.py`가 image의 `scripts/skills/`를 그대로 Volume에 sync하므로, 결국 Volume은 image를 따라가게 됩니다.

  • 에이전트가 스스로를 진화시키는 사이클이 git에 그대로 남습니다. 어떤 skill이 언제 추가됐는지를 commit log로 확인하고, 사람이 review/revert 할 수 있습니다.

`commit_skills.py`에는 안전장치 두 개를 두었습니다.

LOCK_FILE = Path("/tmp/hermes-modal-commit-skills.lock")
FINGERPRINT_FILE = SKILLS_DIR / ".last_committed_hash"
  • flock으로 동시에 여러 호출이 들어와도 직렬화합니다.

  • SHA256 fingerprint로 skill 트리가 안 바뀌었으면 git clone조차 하지 않고 빠르게 끝냅니다. `agent:end`는 매 에이전트 실행마다 발생하므로 변경이 없을 때의 비용을 0에 가깝게 두는 것이 중요합니다.

디버깅 등 수동으로 트리거하고 싶을 때는 `modal run modal_app.py::commit_skills`를 사용합니다.

4. 배포, 운영

4.1. webhook URL을 위한 두 단계 배포

Modal에서 webhook 봇을 띄울 때 자주 부딪히는 닭-달걀 문제가 있습니다.

  • Telegram에 webhook URL을 등록하려면 Modal URL이 필요합니다.

  • 그런데 Modal URL은 deploy 후에야 알 수 있습니다.

이 문제를 해결하기 위해 gateway 함수가 `TELEGRAM_WEBHOOK_URL`이 비어 있으면 placeholder HTTP 서버만 띄우고 끝냅니다.

if not os.environ.get("TELEGRAM_WEBHOOK_URL", "").strip():
    print("TELEGRAM_WEBHOOK_URL is not set; starting placeholder server ...")
    subprocess.Popen(["python", "-m", "http.server", str(WEBHOOK_PORT), ...])
    return

subprocess.Popen(["bash", "-lc",
    "while true; do hermes gateway run --replace; ... done"], env=os.environ.copy())

흐름은 이렇습니다.

  1. webhook URL 없이 modal deploy → Modal URL 발급

  2. 그 URL을 secret에 넣고 다시 modal deploy → Hermes가 정상 webhook 모드로 전환

Hermes를 while true; do …​ done 루프로 감싼 까닭은, 일시적인 네트워크 문제나 OAuth refresh 실패로 프로세스가 죽었을 때 곧바로 다시 띄우기 위해서입니다.

4.2. ChatGPT(Codex) OAuth refresh token 충돌

챗봇이 다음과 같이 에러 응답을 하는 상황이 있었습니다.

Provider authentication failed: Codex refresh token was already consumed by
another client (e.g. Codex CLI or VS Code extension). Run codex in your
terminal to generate fresh tokens, then run hermes auth to re-authenticate.

OAuth refresh token이 갱신되면서 Modal의 앱이 참조하는 토큰이 무효화된 것이 원인이었습니다. 당장의 복구는 ChatGPT 인증을 다시 하고 `~/.hermes/auth.json`을 새로 받아 Modal Secret을 다시 올리고 재배포하는 것입니다. 근본 해결은 provider를 OpenRouter/Anthropic/OpenAI API key 같은 단순 키 방식으로 바꾸는 것입니다. 토큰 회전이 없는 provider는 이 충돌이 아예 없습니다. 무료에 가깝게 쓰겠다는 처음 목표 때문에 OAuth provider를 일단 유지하고 있습니다.

5. 핵심 팁 정리

이 프로젝트를 통해 얻은 서버리스 플랫폼에서의 AI 챗봇 운영 팁은 다음과 같습니다.

  • 고정 비용을 피하려면 대기 비용을 최소화 : Modal에서는 `min_containers`를 두지 않고 `scaledown_window`만 짧게 잡는 것이 free tier 친화적입니다.

  • 상태를 다시 만들 수 있게 부팅 과정을 idempotent하게 : Volume에 이전 실행 흔적이 남아도 prepare_runtime.py 한 번으로 원하는 상태로 수렴하게 하는 것이 운영 부담을 줄입니다.

  • 갱신 주기가 다른 데이터는 Volume도 분리 : 설정, 캐시, 지식 저장소를 따로 두면 큰 데이터를 괜히 함께 흔들지 않게 됩니다.

  • 에이전트의 runtime 변경이 의미가 있다면 git으로 관리 : 서버 Volume에만 남으면 데이터를 잃기 쉽고, 사람이 추적, 수정 하기가 어렵습니다.

  • Long polling 보다는 Webhook 방식으로 : 챗봇을 위한 프로세스가 항상 켜져 있을 필요가 없는 Webhook 방식이 서버리스 플랫폼과 더 잘 맞습니다.

    • 초기 배포는 두 단계 : webhook URL이 deploy 이후에 생기는 구조라면 URL 확보를 위한 배포가 먼저 필요합니다.

6. 참고 자료

  • hermes-modal — 이 글에서 설명한 저장소

  • Modal docs — Volume, Secret, web_server, concurrent 등의 공식 문서

  • Hermes Agent — Nous Research의 LLM 에이전트 프레임워크

  • K-QMD — QMD에 Kiwi 한글 형태소 분석을 결합한 fork (실제 사용 중)

  • QMD — K-QMD의 upstream인 markdown/asciidoc 임베딩 검색 MCP

  • Telegram Bot API: Webhooks — webhook 모드 공식 문서


이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.

Linux 바이너리 역어셈블로 커널 모듈 패치 확인하기

objdump를 사용한 Linux ELF 바이너리 역어셈블 기법과, 소스 없이 두 커널 모듈(.ko)의 차이를 비교한 실제 사례를 정리합니다.

소스 코드를 구할 수 없는 Linux 바이너리를 분석해야 할 때가 있습니다. 이 글에서는 objdump 기반의 역어셈블 기법을 먼저 정리하고, 활용 예시로 Ubuntu 패키지의 intel_cvs.ko 커널 모듈과 DKMS 패치 버전을 비교한 사례의 핵심을 설명합니다.

1. 역어셈블의 기본 개념

1.1. ELF과 .ko

Linux의 실행 파일과 공유 라이브러리, 커널 모듈은 모두 ELF(Executable and Linkable Format) 포맷입니다. 특히 커널 모듈(.ko)은 relocatable object 파일로, 커널에 로드되는 시점에 주소가 확정됩니다. 따라서 역어셈블에서 call 대상이 `0x0000`으로 보이는 것은 비정상이 아니라 재배치가 아직 적용되지 않았기 때문입니다.

ELF는 섹션 단위로 구성됩니다.

  • .text — 코드

  • .rodata — 읽기 전용 데이터, 문자열 리터럴

  • .data — 전역 변수

  • .symtab — 심볼 테이블

최근 Ubuntu OEM 커널은 모듈을 zstd로 압축하므로 `zstd -d`로 먼저 해제해야 합니다.

1.2. 핵심 도구

도구 용도

objdump

ELF 역어셈블. `-d -M intel`이 가장 많이 쓰는 조합

nm

심볼 테이블 조회. 함수 주소와 이름 확인

readelf

ELF 헤더, 섹션, 재배치 정보 확인

strings

바이너리 안의 문자열 추출. 힌트 얻기 용도

modinfo

커널 모듈 메타데이터

pahole

DWARF 디버그 정보에서 구조체 레이아웃 추출

1.3. x86-64 호출 규약 (System V AMD64 ABI)

역어셈블을 읽기 위해 가장 먼저 알아야 하는 것은 호출 규약입니다. Linux는 System V AMD64 ABI를 따르며, 함수 인자는 정해진 순서의 레지스터로 전달됩니다.

인자 순서 레지스터 비고

1번째

rdi / edi

2번째

rsi / esi

3번째

rdx / edx

4번째

rcx

5번째

r8

6번째

r9

반환값

rax

예를 들어 다음 시퀀스는 foo(0x805, NULL, 0) 호출과 정확히 대응됩니다.

xor    edx, edx            ; 3번째 인자: 0
xor    esi, esi            ; 2번째 인자: NULL
mov    edi, 0x805          ; 1번째 인자: 0x805
call   foo

1.4. 자주 보이는 x86-64 명령어

명령어 의미

mov

값 복사. mov edi, 0x805edi = 0x805

movzx

작은 값을 0 확장하여 복사. 1바이트를 32비트로 읽을 때

lea

주소 계산만 수행 (메모리 접근 없음). 포인터 전달 용도

test reg, reg

같은 레지스터로 AND. 0인지 검사

cmp

뺄셈 후 플래그만 설정

je / jne

조건 점프 (equal / not equal)

xor reg, reg

레지스터를 0으로 초기화 (`mov reg, 0`보다 짧음)

call

함수 호출

1.5. 상수로 호출 지점 찾기

소스에서 #define 값을 알고 있으면 역어셈블에서 해당 상수를 검색해 호출 지점을 좁힐 수 있습니다. 다만 같은 값이 다른 맥락(예: 길이, 인덱스)에서 쓰일 수 있으므로, 그 상수가 어느 레지스터에 들어가는지와 주변 call 대상을 함께 확인해야 합니다.

2. 활용 예시: 커널 모듈 바이너리 비교

2.1. 문제 상황

Ubuntu의 linux-modules-vision-*-oem 패키지에 포함된 `intel_cvs.ko`가 Intel 업스트림의 특정 버그 픽스를 반영하고 있는지 확인해야 했습니다. 패키지 changelog에는 어느 커밋까지 포함되었는지 명시되어 있지 않고, DKMS로 설치한 패치 버전과 어떻게 다른지를 확인해야 했습니다.

2.2. 왜 GitHub 소스 비교 대신 역어셈블을 선택했나

Intel의 업스트림 저장소가 GitHub에 공개되어 있으므로 소스 레벨 `diff`가 더 쉬운 선택처럼 보입니다. 그런데도 역어셈블을 택한 이유는 다음과 같습니다.

  • 어떤 커밋에서 빌드되었는지 모른다. Ubuntu OEM 패키지의 changelog에는 Intel 업스트림의 어떤 시점 스냅샷인지가 명시되어 있지 않았습니다. 임의의 커밋에서 찍은 소스와 비교하면 "차이"가 진짜 패치 누락 때문인지 단순한 버전 갭 때문인지 구분되지 않습니다.

  • Ubuntu가 자체 패치를 얹었을 수 있다. 패키지 빌드는 업스트림 tarball에 distro 패치를 덧붙이는 경우가 흔합니다. 업스트림 소스만으로는 실제로 빌드된 결과를 재현할 수 없습니다.

  • 바이너리가 ground truth다. 커널이 실제로 로드하는 것은 .ko 바이너리입니다. "이 시스템에서 지금 돌고 있는 코드에 해당 가드가 있는가"라는 질문의 답은 바이너리 안에만 있습니다.

  • 확인하려는 범위가 작다. 특정 한 가지 패치(특정 상수 호출 앞의 분기 유무)만 확인하면 충분했습니다. 소스 트리 전체를 받아 빌드 환경을 맞추는 수고보다 objdump 한 번이 더 빠른 상황이었습니다.

즉, 소스 비교는 "업스트림에 그 패치가 존재하는가"에 답하지만, 이번에 필요한 답은 "내 시스템에 설치된 바이너리에 그 패치가 반영되어 있는가"였고, 이 질문에는 바이너리를 직접 보는 것이 가장 확실한 방법이었습니다.

2.3. 모듈 추출과 역어셈블

# 압축 해제
zstd -d -c /lib/modules/$(uname -r)/ubuntu/vision/intel_cvs.ko.zst > /tmp/pkg.ko
zstd -d -c /lib/modules/$(uname -r)/updates/dkms/intel_cvs.ko.zst > /tmp/dkms.ko

# 동일성 확인
md5sum /tmp/pkg.ko /tmp/dkms.ko

# 관심 함수만 Intel 문법으로 역어셈블
objdump -d -M intel --no-show-raw-insn /tmp/pkg.ko \
  | sed -n '/<cvs_common_probe>:/,/^$/p' > /tmp/pkg_probe.asm

objdump -d -M intel --no-show-raw-insn /tmp/dkms.ko \
  | sed -n '/<cvs_common_probe>:/,/^$/p' > /tmp/dkms_probe.asm

-M intel`은 `mov dst, src 순서의 Intel 문법을 사용합니다 (기본은 `mov src, dst`의 AT&T 문법). `--no-show-raw-insn`은 기계어 바이트를 생략해 가독성을 높입니다.

2.4. 핵심 차이 찾기

확인하고 싶은 패치는 아래 형태의 가드였습니다.

if (icvs->magic_num_support) {
    ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);
}

SET_HOST_IDENTIFIER = 0x0805`는 첫 번째 인자이므로 `mov edi, 0x805 패턴으로 역어셈블에서 검색할 수 있습니다.

DKMS 버전 (가드 있음)

movzx  eax, BYTE PTR [r12+0x4c]  ; icvs->magic_num_support 로드
and    ebx, 0x1
test   bl, bl                    ; 0인지 검사
je     12c7                      ; 0이면 호출 건너뜀
xor    edx, edx                  ; len = 0
xor    esi, esi                  ; buf = NULL
mov    edi, 0x805                ; SET_HOST_IDENTIFIER
call   cvs_write_i2c

패키지 버전 (가드 없음)

xor    edx, edx
xor    esi, esi
mov    edi, 0x805
call   cvs_write_i2c             ; 무조건 실행됨

mov edi, 0x805 바로 앞에 test + `je`로 된 분기가 있느냐 없느냐가 두 바이너리의 실질적 차이였습니다. 즉 패키지 버전에는 해당 패치가 반영되어 있지 않다는 결론이 나옵니다.

2.5. 재배치 정보로 call 대상 확인

.ko`의 `call 대상이 0x0000`이거나 어색한 주소로 보일 때는 `-r 옵션으로 재배치 정보를 함께 출력하면 원래 타깃 심볼을 알 수 있습니다.

objdump -d -r -M intel /tmp/pkg.ko
127f: call   1284 <cvs_common_probe+0x104>
      1280: R_X86_64_PLT32  cvs_write_i2c-0x4

3. 주의할 점

컴파일러 최적화로 같은 소스여도 명령어 순서와 레지스터 선택이 달라집니다. 정확히 같은 시퀀스가 아니라 논리적 흐름(분기 구조) 을 비교해야 합니다. DKMS 빌드와 패키지 빌드의 GCC 버전이 같으면 차이가 적고, 다르면 코드 구조 자체가 꽤 달라 보일 수 있습니다.

재배치 주소 차이는 무시해야 합니다. diff`를 돌리면 `je 1553 vs `je 12c7`처럼 점프 대상 주소가 대량으로 다르게 나오는데, 이것은 함수 배치 차이일 뿐이고 주목해야 할 것은 명령어 자체의 추가/삭제입니다.

상수 검색의 false positive: mov edi, 0x805`가 반드시 원하는 호출은 아닐 수 있습니다. 어떤 함수 안에 있는지, 직후의 `call 대상이 무엇인지로 맥락을 확인해야 합니다.

그리고 당연한 이야기지만, 소스를 구할 수 있다면 역어셈블보다 `diff`가 훨씬 빠르고 정확합니다. 역어셈블은 소스를 확보할 수 없을 때의 최후 수단입니다.

4. 참고 자료


이 포스트는 Claude Code가 정상혁의 지시에 따라서 작성했습니다.

Claude Code의 Prompt Cache 버그 (v2.1.69~v2.1.89)

Claude Code v2.1.69~v2.1.89에서 발생한 Prompt Cache 버그의 원인, 영향 범위, 비용 영향을 정리합니다.

변경 이력
  • 2026-04-06: Prompt Cache TTL 기본값(5분)과 1시간 옵션에 대한 설명 추가

2026년 3월 초부터 약 한 달간 Claude Code에 Prompt Cache가 제대로 작동하지 않는 버그가 있었습니다. Claude Code 토큰 비용과 프롬프트 캐싱에서 정리했듯이 Prompt Caching은 입력 토큰 비용을 90%까지 절약하는 핵심 수단인데, 이 캐시가 작동하지 않으면 비용이 크게 증가합니다. 이 글에서는 해당 기간에 발생한 캐시 버그의 원인과 영향을 정리합니다.

1. 버그 요약

항목 내용

영향 버전

v2.1.69 (2026-03-05) ~ v2.1.89

수정 버전

v2.1.90 (2026-04-01)

근본 원인

v2.1.69에서 도입된 deferred_tools_delta`로 인해 resume 시 `messages[0] 구조가 새 세션과 달라짐

영향 대상

--resume 또는 `/resume`으로 세션을 재개하는 사용자

GitHub Issue

#34629, #28899

2. 원인: Resume 시 메시지 구조 불일치

2.1. 근본 원인

v2.1.69에서 도입된 deferred_tools_delta 기능이 원인입니다. 이 기능은 deferred tools 목록, MCP 서버 설정, skills 정보를 메시지에 첨부하는 방식을 변경했는데, 새 세션과 재개 세션에서 `messages[0]`의 구조가 달라지는 문제가 생겼습니다.

세션 유형 messages[0] 내용 크기

새 세션

deferred tools 목록, MCP 설정, skills, 날짜 컨텍스트

~13.4KB

재개 세션

날짜 컨텍스트만 포함 (나머지는 messages 끝에 추가)

~352B

Prompt Cache는 prefix의 해시값으로 캐시를 조회합니다. `messages[0]`의 내용이 13.4KB에서 352B로 바뀌면 해시값이 완전히 달라지므로, 이전 세션에서 쌓은 캐시를 전혀 재사용할 수 없었습니다.

2.2. MCP 서버, deferred tools와의 관계

이 버그는 MCP 서버나 deferred tools를 사용하는 환경에서 더 큰 영향을 받았습니다. 다만 이것은 MCP 서버의 tool schema가 불안정해서가 아니라, deferred tools/MCP 설정/skills 정보가 resume 시 `messages[0]`이 아닌 다른 위치에 배치되어 prefix가 달라지기 때문입니다. MCP 서버가 매번 완전히 동일한 schema를 반환하더라도, resume만 하면 메시지 구조가 달라져서 캐시 미스가 발생했습니다.

v2.1.90의 수정 내용도 이를 뒷받침합니다.

Fixed --resume causing a full prompt-cache miss on the first request for users with deferred tools, MCP servers, or custom agents

2.3. 부수적 문제: 매 턴 tool schema 재직렬화

근본 원인과 별개로, Claude Code가 매 턴마다 MCP tool schema를 `JSON.stringify`로 재계산하여 캐시 키를 만드는 비효율도 있었습니다. 이것은 캐시 미스를 유발하는 직접적인 원인은 아니었지만 성능 오버헤드를 발생시켰고, v2.1.90에서 tool schema를 한 번만 계산하고 재사용하도록 개선되었습니다.

3. 영향 범위

이 버그는 --resume (CLI 플래그)과 /resume (슬래시 커맨드) 모두에 해당합니다. 두 방식 모두 세션 재개 시 동일한 메시지 구조 불일치가 발생하기 때문입니다.

단, Prompt Cache의 TTL이 기본 5분 [1] 이므로 실질적인 비용 손해가 발생하는 경우는 제한적입니다.

시나리오 영향

일반 연속 세션 (resume 없이)

영향 없음. `messages[0]`이 세션 내내 안정적으로 유지

5분 이내에 resume

캐시가 살아있어야 하는데 prefix 불일치로 캐시 미스 발생 → 비용 손해

5분 이후에 resume

어차피 TTL 만료로 캐시 소멸 → 버그와 무관하게 비용 동일

--print --resume 자동화

매 호출이 새 프로세스이므로 캐시를 전혀 재활용하지 못함 → 가장 큰 피해

4. 비용 영향

정상 상태에서는 캐시가 쌓이면서 턴이 진행될수록 메시지당 비용이 감소합니다.

정상 (v2.1.68):  메시지 1 → $0.15,  메시지 4 → $0.02 (캐시 적중률 증가)
버그 (v2.1.69+): 모든 메시지 → $0.35~0.40 (매번 전체 히스토리를 Cache Write)

--print --resume 워크플로우에서는 cache_read 토큰이 ~11,000(시스템 프롬프트 분량)에 머물고, cache_creation 토큰이 300,000 이상으로 치솟는 현상이 관찰되었습니다. 메시지당 약 20배의 비용 증가가 보고되었습니다.

5. 수정 내용 (v2.1.90)

v2.1.90 (2026-04-01)에서 다음과 같이 수정되었습니다.

  • 메시지 구조 정규화: 세션 재개 시 `messages[0]`에 deferred tools, MCP 설정, skills를 새 세션과 동일한 위치에 삽입하도록 변경

  • tool schema 재직렬화 제거: MCP tool schema를 한 번만 계산하고 캐시하여, 매 턴 `JSON.stringify`를 호출하지 않도록 성능 개선

현재 버전 확인:

claude --version

v2.1.90 이상이면 수정된 상태입니다.

6. 영향 받았는지 확인하는 방법

2026년 3월 5일~4월 1일 사이에 Claude Code를 사용했다면, 다음 방법으로 영향 여부를 확인할 수 있습니다.

6.1. Resume 사용 여부 확인

이 버그는 resume 시에만 발생하므로, 해당 기간에 resume을 사용했는지가 핵심입니다.

6.1.1. /resume (슬래시 커맨드) 확인

대화형 세션 안에서 `/resume`을 입력한 기록은 `~/.claude/history.jsonl`에 남습니다.

Claude Code 프롬프트
~/.claude/history.jsonl에서 2026년 3월 5일~4월 1일 사이의 /resume 기록을 찾아줘.
각 resume 시점에 대해, 같은 프로젝트의 ~/.claude/projects/ 아래 JSONL 세션 로그에서
직전 세션의 마지막 timestamp와의 시간 간격을 계산해줘.
5분 이내에 resume한 경우가 있는지 확인하고 싶어.

6.1.2. --resume (CLI 플래그) 확인

`claude --resume`처럼 CLI 인자로 실행한 경우는 `history.jsonl`에 기록되지 않습니다. 이 경우는 shell history에서 확인합니다.

bash 사용자
grep "claude.*--resume" ~/.bash_history
zsh 사용자
grep "claude.*--resume" ~/.zsh_history

bash는 기본적으로 명령 실행 시각을 기록하지 않으므로 [2], --resume 사용 여부만 확인할 수 있고 5분 간격 분석은 어렵습니다. zsh는 EXTENDED_HISTORY 옵션이 켜져 있으면 `~/.zsh_history`에 타임스탬프가 함께 기록됩니다.

--resume`을 주로 자동화 스크립트(--print --resume`)에서 사용했다면 해당 스크립트의 실행 로그를 확인하는 것이 더 정확합니다.

5분 이내에 resume한 기록이 없다면, Prompt Cache의 TTL(5분)이 만료된 후 재개한 것이므로 이 버그로 인한 추가 비용은 발생하지 않은 것입니다.

6.2. API 사용량 확인

Anthropic Console에서 해당 기간의 토큰 사용량을 확인합니다. 평소 대비 `cache_creation_input_tokens`가 비정상적으로 높고 `cache_read_input_tokens`가 낮다면 이 버그의 영향을 받았을 가능성이 있습니다.

6.3. 캐시 메트릭 모니터링

세션 중 API 응답의 캐시 관련 토큰을 확인합니다.

  • cache_creation_input_tokens: 대화가 진행될수록 감소해야 정상

  • cache_read_input_tokens: 대화가 진행될수록 증가해야 정상

버그 발생 시에는 `cache_read_input_tokens`가 낮은 값에 머물고, `cache_creation_input_tokens`가 계속 높게 유지됩니다.


1. Anthropic API는 기본 5분(ephemeral)과 1시간("ttl": "1h") 두 가지 TTL 옵션을 제공합니다. 1시간 옵션은 cache write 비용이 기본 입력 토큰의 2배(5분은 1.25배)로 더 높습니다. Claude Code에서는 구독 플랜에 따라 자동 결정되며(Max 플랜: 1시간, Pro 플랜/API 키: 5분), 사용자가 직접 설정할 수는 없습니다. 자세한 내용은 Anthropic 공식 문서를 참고하세요.
2. HISTTIMEFORMAT`을 설정하면 `history 명령에서 시각이 표시되지만, ~/.bash_history 파일 자체에는 타임스탬프가 저장되지 않습니다.