Recent Posts

All Posts →

LLM에 정보를 전달하는 계층 — 분류와 회색지대

LLM에 정보를 주입하는 방법을 모델 레이어와 프롬프트 레이어로 나누어 보고, 그 사이의 회색지대와 RAG도 이 분류에서 어디에 놓이는지 정리해봅니다.

LLM에 정보를 전달하는 방법을 정리할 때 저는 보통 모델 가중치 vs. 프롬프트라는 2계층으로 나눠서 생각해 왔습니다. "파라미터에 정보를 새기는가, 컨텍스트에 정보를 띄우는가"라는 구분입니다. 최근 몇 년 사이에 그 사이를 비집고 들어온 기법들도 많아졌습니다.

1. 모델 레이어 — 파라미터에 정보를 새기는 방법

가중치 자체를 변경하거나, 가중치에 준하는 영구적 상태를 만드는 방식입니다.

  • Pre-training: 가장 근본적인 정보 주입. 코퍼스의 분포 자체가 모델의 세계관이 됩니다.

  • Continued pre-training (도메인 적응): 의료/법률/코드처럼 특정 도메인 코퍼스로 추가 학습. 파인튜닝보다 더 광범위한 분포 이동을 노립니다.

  • Supervised fine-tuning (SFT): 입출력 페어로 행동을 맞춥니다. 지식 주입보다는 형식/스타일/태스크 수행 능력에 더 효과적입니다.

  • 선호 학습 (RLHF, DPO, KTO, IPO 등): "어떤 응답이 더 나은가"라는 정보를 가중치에 새깁니다. 사실 지식이 아니라 가치/취향을 주입한다는 점에서 결이 다릅니다.

  • Model editing (ROME, MEMIT, MEND 등): 특정 사실 하나를 가중치 차원에서 직접 수정. "에펠탑은 로마에 있다"로 바꾸는 수술 같은 기법입니다. 본격적인 학습이 아니어서 회색지대로 보는 시각도 있습니다.

  • Knowledge distillation: 큰 모델의 지식을 작은 모델의 가중치로 압축.

  • Merging (TIES, DARE, SLERP 등): 여러 파인튜닝 결과의 가중치를 평균/합성. 학습 없이 "정보를 섞는다"는 점에서 새로운 결입니다.

2. 프롬프트 레이어 — 컨텍스트에 정보를 띄우는 방법

추론 시점에 컨텍스트 윈도우로 들어가는 모든 것. 가중치는 손대지 않습니다.

  • 시스템 프롬프트 / 페르소나 지시

  • Few-shot 예시 (in-context learning)

  • Chain-of-Thought, ReAct, self-consistency 같은 reasoning 패턴 유도

  • Tool use / function calling의 결과를 컨텍스트로 되먹임

  • RAG로 검색해 온 문서 청크

  • 외부 메모리 시스템에서 불러온 과거 대화 (Claude의 memory 같은 것)

  • 구조화된 출력 강제 (스키마, XML 태그)

  • MCP/connector를 통해 들어오는 외부 데이터

이 모두의 공통점은 inference time에 토큰으로 입력된다는 것입니다. 모델 입장에서 보면 출처를 구분할 방법이 없습니다.

3. 회색지대

PEFT 계열 (LoRA, QLoRA, adapters, prefix tuning, prompt tuning, P-tuning): 형식상 가중치를 학습시키니 모델 레이어 같지만, 베이스 모델은 그대로 두고 작은 어댑터만 붙였다 뗐다 합니다. 특히 prefix tuning / prompt tuning은 학습 가능한 "가상 토큰"을 컨텍스트 앞에 붙이는 방식이라, 소프트 프롬프트(soft prompt)라는 이름이 붙을 정도로 프롬프트 레이어의 사촌에 가깝습니다.

KV cache 주입 / cache steering: 특정 컨텍스트로 만든 KV cache를 저장해 두고 추론 시 재사용. 매번 프롬프트로 넣지 않지만, 효과는 프롬프트와 동일합니다. 캐싱은 비용 최적화이지만, "캐시된 페르소나"처럼 쓰기 시작하면 준영구적 상태가 됩니다.

Activation steering / representation engineering: 추론 중 특정 레이어의 활성값에 벡터를 더해 행동을 바꾸는 기법. 가중치도 안 바꾸고 프롬프트도 안 바꿉니다. 완전히 새로운 결입니다.

Decoding 제어 (constrained decoding, logit bias, guided generation, speculative decoding): 출력 확률 분포를 직접 조작. 정보를 "주입"한다기보다 출력을 제한하지만, 도메인 지식을 grammar 형태로 강제하면 사실상 정보 주입이 됩니다.

Tool use의 양면성: 도구 호출 자체는 프롬프트 레이어지만, 도구가 외부 세계의 상태를 바꾼다면 "정보를 LLM에 주는 것"이 아니라 "LLM이 정보를 만들어내는 것"입니다. 이 경계도 모호합니다.

4. 두 레이어 외에 추가로 생각해볼 만한 축

위의 2계층 분류는 정보가 어디에 저장되는가를 기준으로 한 것입니다. 다른 축으로도 분류할 수 있습니다.

아키텍처 레이어: 모델 구조 자체. MoE로 전문가를 분리하거나, retrieval head를 내장하거나(REALM, RETRO), 메모리 토큰 슬롯을 두는 식. 가중치 학습 이전에 "어떤 형태로 정보를 담을 것인가"를 결정합니다.

표현 / 활성값 레이어: 앞에서 언급한 activation steering, probing, representation engineering. 가중치도 컨텍스트도 아닌, forward pass 중간 상태에 개입합니다.

디코딩 / 샘플링 레이어: temperature, top-p, constrained decoding, beam search. 정보 주입보다는 정보 추출 방식의 제어지만, 모델이 "무엇을 말할 수 있는가"를 좌우합니다.

오케스트레이션 / 에이전트 레이어: 여러 LLM 호출을 엮는 외부 통제 흐름. 단일 추론 안에서는 안 보이지만 시스템 전체 동작을 결정합니다. 멀티 에이전트, 워크플로우, planner-executor 패턴 등.

5. RAG는 어느 레이어인가

RAG는 결국 프롬프트 레이어에 데이터를 공급하는 장치입니다. 검색해 온 청크는 결국 컨텍스트에 토큰으로 들어갑니다. 그리고 이 관점이 실용적으로도 가치가 있는 이유는, RAG의 한계가 곧 프롬프트 레이어의 한계(컨텍스트 윈도우, 어텐션의 long-range 약점, 토큰 비용, "lost in the middle")로 환원되기 때문입니다.

다만 RAG를 별개의 레이어로 분리하는 시각에도 일리가 있습니다. 이유는 두 가지입니다.

  1. 시스템 구성요소가 훨씬 많음. 임베딩 모델, 벡터 DB, 청킹 전략, 재순위 모델, 쿼리 재작성. 이것은 프롬프트 엔지니어링의 디테일과는 결이 다른, 검색 시스템 설계의 영역입니다.

  2. 모델과 무관하게 진화함. 베이스 LLM이 그대로여도 RAG 파이프라인만 개선해 성능을 끌어올릴 수 있습니다. 독립적인 최적화 표면이 있다는 뜻이고, 별도 레이어로 다룰 실용적 이유가 됩니다.

RAG는 개념적으로는 프롬프트 레이어의 하위 범주이지만 실무적으로는 독립된 서브시스템으로 볼 수 있습니다. 즉 "LLM에 정보가 들어가는 경로"를 논할 때는 프롬프트 레이어로 묶고, "이 RAG 파이프라인을 어떻게 개선할까"를 논할 때는 별도 레이어로 다루는 식입니다. 세상에 있는 데이터 → 검색/필터링 → 프롬프트 윈도우라는 흐름에서, 앞단(검색/필터링)이 충분히 복잡해서 별도의 시스템으로 구성됩니다.

같은 논리로, memory 시스템, MCP, tool use 결과 통합도 모두 "프롬프트 레이어로 흘러드는 데이터 파이프라인"이라는 같은 범주에 속합니다. 이들을 묶어서 컨텍스트 공급 레이어(context provisioning layer) 같은 이름을 붙이면, 처음의 2계층이 자연스럽게 3계층으로 확장됩니다.

  1. 모델 레이어 (파라미터)

  2. 컨텍스트 공급 레이어 (RAG, 메모리, 도구, 커넥터)

  3. 프롬프트 레이어 (실제로 컨텍스트에 들어간 토큰들의 구성과 배치)

여기에 회색지대(PEFT, activation steering)와 아키텍처/디코딩 축까지 보태면, 처음의 단순한 2계층 지도가 더 풍부하게 세분화됩니다.


이 포스트는 Claude(claude.ai)와 정상혁이 함께 작성했습니다.

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가 정상혁의 지시에 따라서 작성했습니다.