728x90

사내에서 "에이전트 붙이자"는 말이 나오면 저는 요즘 반사적으로 한 가지를 묻는다. "그거 플로우차트 그릴 수 있어요?" 그릴 수 있으면 에이전트가 아니라 파이프라인이다. 이 질문 하나로 회의 시간이 40분 줄어든 경험이 여러 번 있다.

Dev.to에 올라온 James Anderson의 글 "Most 'AI Agents' Are Just If-Statements in a Trench Coat"이 정확히 그 얘기를 한다. 저자는 플래너, 툴, 리즈닝 루프까지 갖춘 에이전트를 만들어 데모에서 박수를 받았다. 그리고 프로덕션에 올렸더니 느리고, 비싸고, 재현 안 되는 방식으로 깨졌다. 화요일과 수요일에 같은 입력이 다르게 동작했고, 장애 원인은 자기가 통제하지도 관측하지도 못한 상류의 "자율적 결정" 세 개였다.

결국 그는 지루한 선형 파이프라인으로 다시 썼다. 고정 스텝, 리즈닝 루프 없음. 그리고 중요한 모든 축에서 더 나아졌다 — 더 빠르고, 더 싸고, 테스트 가능하고, 디버깅 가능해졌다. 그러고 나서 옛 "에이전트"의 로그를 보니 매 실행마다 똑같은 세 스텝을 밟고 있었다. Extract, transform, respond. 자율성을 단 한 번도 쓰지 않았다. 그가 만든 건 시스템 프롬프트를 얹은 for 루프였다.

인프라 담당자 입장에서 이 글이 중요한 이유는 감성적 공감 때문이 아니다. 자율성이 비용이라는 점, 그리고 그 비용이 청구서에 항목별로 찍힌다는 점을 명확하게 짚기 때문이다.

경계선은 딱 하나다: 제어 흐름을 누가 정하는가

원문의 정의는 단순하고, 저는 이보다 나은 정의를 아직 못 봤다.

  • 에이전트: 런타임에 모델이 자기 제어 흐름을 결정한다. 어떤 툴을 부를지, 다음 스텝이 무엇인지, 다시 루프를 돌지, 언제 멈출지를 모델이 본 것에 따라 동적으로 고른다.
  • 파이프라인: 제어 흐름을 설계 시점에 내가 고정한다. 스텝 1, 2, 3. 매번 같은 경로. LLM은 각 스텝 안에서 일하지만 스텝을 고르지는 못한다.

여기서 사람들이 자주 건너뛰는 부분: 고정된 스텝 안에서 LLM이 똑똑한 일을 하는 건 자율성이 아니다. 필드 추출, 티켓 분류, 요약 생성 — 그냥 LLM을 쓰는 것이다. 똑똑한 함수 호출이다. 자율성은 모델에게 핸들을 넘겼을 때만 발생한다.

대부분의 "에이전트"는 핸들을 넘기지 않는다. 고정된 경로를 유창한 자연어로 서술하고, 그 서술을 "추론"이라고 부른다.

리트머스 시험지는 이거다. 실행 전에 플로우차트를 그릴 수 있으면 파이프라인이다. 컨텍스트 검색 → 툴 호출 → 응답 포맷팅. 코드 한 줄 쓰기 전에 화이트보드에 그릴 수 있었나? 그럼 모델은 경로를 결정하는 게 아니다. 내가 이미 결정했고, 모델은 각 노드에서 일하면서 결정하는 것처럼 들리게 말하고 있을 뿐이다.

실무에서 이 구분을 코드로 보면 더 선명하다. 아래는 "에이전트"라고 불리던 코드를 파이프라인으로 벗겨낸 형태다.

# before: "에이전트" (실제로는 매번 같은 3스텝)
while not done:
    plan = llm.chat(f"다음에 뭘 해야 해? 상태: {state}")   # 매 턴 토큰 소비
    tool = parse_tool(plan)                                  # 파싱 실패 위험
    state = TOOLS[tool](state)
    done = "FINISH" in plan

# after: 파이프라인 (LLM은 가치 있는 지점에만)
def run(doc: str) -> dict:
    fields  = llm_extract(doc)            # LLM: 구조화 추출
    norm    = normalize(fields)           # 순수 함수, 테스트 가능
    answer  = llm_answer(norm)            # LLM: 문장 생성
    return {"fields": norm, "answer": answer}

after 쪽에서 LLM이 하는 똑똑한 일은 하나도 줄지 않았다. 여전히 추출하고 생성한다. 다만 작업의 구조를 즉흥적으로 만들게 하는 걸 멈췄다. 구조는 애초에 지능이 필요한 부분이 아니었다. 내가 이미 알고 있던 부분이다.

코스튬 값이 청구서에 찍히는 방식

"그래도 동작하는데 뭐가 문제냐"는 반박이 나온다. 문제 되는 사람은 그걸 운영하고, 돈 내고, 새벽 2시에 디버깅하는 사람들이다. 원문이 정리한 항목을 인프라 관점으로 다시 풀어보면 이렇다.

1. 비결정성 → 버그가 재현되지 않는다. 모델이 경로를 고르면 같은 입력이 실행마다 다른 경로를 탄다. "제가 해봤을 땐 됐는데요"가 영구 상태가 된다. 장애 대응 프로세스가 성립하지 않는다.

2. 디버깅 붕괴. 고정 파이프라인이 깨지면 어느 스텝이 실패했는지 안다. 에이전트는 스텝 4에서 내린 결정 때문에 스텝 12에서 깨진다. 코드를 디버깅하는 게 아니라 선택에 대한 포렌식을 하게 된다.

3. 실패 표면의 곱셈. 고정 5스텝 파이프라인은 확인할 게 5개다. 결정 5개를 하는 에이전트는 각 결정이 틀릴 수 있고, 조합으로 틀릴 수 있고, 그 순서가 매 실행 바뀐다.

4. 비용과 지연. 리즈닝 루프는 고정 시퀀스보다 훨씬 많은 모델 호출을 만든다. 생각하고, 다시 생각하고, 반성하고, 또 돌기로 한다. 이미 답을 알고 있던 경로를 모델이 숙고하는 데 토큰당 돈을 낸다.

5. 테스트 불가. 회귀 테스트는 고정된 경로 집합이 있어야 한다. 에이전트는 정의상 그게 없다. 프로덕션에서 자율적 결정을 하는 그 컴포넌트가 동시에 신뢰할 만한 테스트를 쓸 수 없는 컴포넌트다.

인프라 쪽에서 실제로 아픈 지점을 하나 더 붙이자면 멱등성이다. 파이프라인은 스텝별 재시도 정책을 내가 정한다. 스텝 2가 타임아웃 나면 스텝 2만 재시도한다. 에이전트는 루프 안에서 어떤 툴이 몇 번 호출됐는지가 실행마다 다르다. 그 툴이 결제 API나 티켓 생성 API라면? 중복 실행 방어를 에이전트 바깥에 깔아야 한다. 툴 호출 단위 idempotency key를 강제하는 래퍼를 씌우는 게 최소 방어선이다.

# 툴 래퍼: 멱등성 + 호출 상한 + 예산 상한
import hashlib, time

CALL_BUDGET = {"n": 0, "max": 12}
SEEN = {}   # 실제로는 Redis SETNX 권장

def guarded(fn):
    def wrap(**kw):
        CALL_BUDGET["n"] += 1
        if CALL_BUDGET["n"] > CALL_BUDGET["max"]:
            raise RuntimeError("tool call budget exceeded")
        key = hashlib.sha1(f"{fn.__name__}:{sorted(kw.items())}".encode()).hexdigest()
        if key in SEEN:            # 같은 인자 재호출은 캐시 반환
            return SEEN[key]
        SEEN[key] = fn(**kw)
        return SEEN[key]
    return wrap

호출 상한(max)이 없으면 무슨 일이 벌어지는지는 한 번 겪어보면 안다. 루프가 자기 출력을 잘못 파싱해서 같은 툴을 계속 부르고, 토큰 청구서가 조용히 불어난다. 상한은 기능이 아니라 서킷 브레이커다.

실무 도입: 관측성부터 깔고, 자율성은 마지막에 준다

비결정적 시스템을 운영하려면 순서가 있다. 저는 이 순서를 지키지 않아서 두 번 고생했다.

먼저 트레이싱. LLM 호출을 하나의 span으로 잡고, 실행 전체를 trace로 묶는다. 스텝 이름, 모델명, 입력/출력 토큰 수, 재시도 횟수, 그리고 결정 근거를 속성으로 남긴다. 에이전트에서 가장 아쉬운 로그는 "왜 이 툴을 골랐는지"가 없는 로그다. 최소한 모델이 반환한 tool_call 원문은 그대로 저장해야 나중에 리플레이가 된다.

# 스텝 단위 span (OpenTelemetry Python)
from opentelemetry import trace
tracer = trace.get_tracer("llm-pipeline")

with tracer.start_as_current_span("llm.extract") as sp:
    out = llm_extract(doc)
    sp.set_attribute("llm.model", "gpt-4o-mini")
    sp.set_attribute("llm.tokens.in", out.usage.prompt_tokens)
    sp.set_attribute("llm.tokens.out", out.usage.completion_tokens)
    sp.set_attribute("app.step", "extract")

실행 결과 로그는 대충 이런 모양으로 남는다. 스텝별로 지연과 토큰이 분리돼야 어디가 비싼지 보인다.

$ python run_pipeline.py --doc sample.txt
[trace 4f1a...c9] llm.extract   ok   842ms  in=1_204 out=186
[trace 4f1a...c9] normalize      ok     3ms
[trace 4f1a...c9] llm.answer    ok   1_310ms in=402  out=311
[trace 4f1a...c9] TOTAL         ok   2_155ms tokens=2_103

그다음 평가(eval). 파이프라인은 경로가 고정이라 스텝별 골든 데이터셋을 만들 수 있다. 추출 스텝은 필드 정확도, 생성 스텝은 규칙 기반 체크 + 사람 샘플링. 이게 회귀 테스트 역할을 한다. 에이전트로 가면 경로가 달라지니 "최종 결과만" 평가하게 되고, 어디서 틀렸는지는 다시 포렌식이다.

흔한 함정

툴 호출을 자유 텍스트에서 파싱하면 반드시 만난다. 실제로 가장 자주 보는 에러는 이 계열이다.

Traceback (most recent call last):
  File "agent/loop.py", line 88, in parse_tool
    return json.loads(raw)
  File "/usr/lib/python3.11/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

# 또는 스키마 강제 시
openai.BadRequestError: Error code: 400 - {'error': {'message':
"Invalid schema for response_format 'ToolCall': ... 'additionalProperties'
is required to be supplied and to be false", 'type': 'invalid_request_error'}}

대응은 두 갈래다. (a) 자유 텍스트 파싱을 버리고 function calling / structured output 스키마를 쓴다 — 다만 스키마 제약(필수 필드, additionalProperties 등)이 프로바이더마다 다르니 공식 문서 확인이 필요하다. (b) 파싱 실패 시 재시도 횟수를 못 박고, 초과하면 폴백 경로로 빠진다. 무한 재시도는 위에서 말한 청구서 사고로 직행한다.

② 타임아웃이 중첩된다. 에이전트 루프는 "전체 예산"이 있어야 한다. 툴 호출 하나에 30초 타임아웃을 걸어도 루프가 10번 돌면 5분이다. API 게이트웨이 타임아웃(보통 훨씬 짧다)에 먼저 잡혀서 클라이언트는 504를 받고, 백엔드 루프는 계속 돌면서 토큰을 태운다. 요청 단위 deadline을 만들어 컨텍스트로 내려보내고, 남은 시간이 없으면 루프를 즉시 종료해야 한다.

③ "if문 3개짜리 분기"를 에이전트라고 부르기. 원문 표현대로, 케이스 세 개짜리 if문은 그냥 switch 붙은 파이프라인이다. 진짜 자율성이 필요한 건 미리 열거할 수 없을 만큼 분기 공간이 큰 경우다. 분기를 셀 수 있으면 명시적으로 쓰자. 코드로 쓴 switch는 테스트가 되고, 프롬프트에 숨긴 switch는 안 된다.

④ 컨텍스트가 조용히 커진다. 루프가 돌 때마다 이전 관찰 결과를 프롬프트에 다 넣으면 토큰이 스텝 수에 비례해 늘어난다. 요약 압축이나 슬라이딩 윈도우를 안 넣으면 어느 순간 컨텍스트 초과 에러로 실패하고, 그 실패는 입력 데이터 길이에 따라 들쭉날쭉해서 재현이 어렵다.

그럼 언제 진짜 에이전트인가

원문도 "에이전트는 항상 나쁘다"고 하지 않는다. 자율성이 값을 하는 경우를 셋으로 정리한다.

  • 스텝을 미리 알 수 없을 때. 열린 조사, 탐색, 원인 모르는 문제 디버깅. 플로우차트 자체가 발견 대상이라 그릴 수가 없다.
  • 진짜 멀티홉. "찾고, 찾은 것의 정체에 따라 다음을 판단한다." 스텝 1을 돌리기 전엔 스텝 2를 정말로 알 수 없는 경우.
  • 분기가 사실상 무한할 때. 미리 열거하기엔 가능성 공간이 너무 큰 경우.

그리고 이 경우에도 원칙은 자율성 최소화다. 하드코딩할 수 있는 건 다 하드코딩하고, 런타임 결정은 정말 필요한 한 지점에만 남긴다. 원문의 관찰이 인상적인데, 프로덕션에서 실제로 잘 돌아가는 "에이전틱" 시스템을 들여다보면 대개 거의 고정된 파이프라인에 조심스럽게 제약된 결정 지점이 한두 개 있는 형태다. 자유롭게 돌아다니는 리즈닝 루프가 아니다.

왜 그럼 다들 에이전트를 만드냐. 원문의 답이 불편하지만 정확하다. 에이전트는 작업이 아니라 만드는 사람을 위해 만들어진다. 데모가 잘 되고, 진짜 AI처럼 느껴지고, "agentic"은 이력서와 IR 자료에 쓰기 좋은 단어다. "결정론적 파이프라인"으로는 그 신호가 안 나온다. 그 이유 중 어느 것도 내 작업이 에이전트를 필요로 하는지와는 관계가 없다.

정리

한 줄 요약: 플로우차트를 미리 그릴 수 있으면 파이프라인으로 만들어라. 자율성은 정당화해야 하는 비용이다.

누가 언제 써야 하는지 정리하면:

  • 지금 파이프라인으로 가라 — 문서 처리, 분류, 요약, 티켓 라우팅, 정형 추출. 즉 업무 형태를 이미 이해하고 있는 대부분의 백오피스 작업. LLM은 각 스텝 안에서만 쓴다.
  • 제약된 결정 지점 하나만 추가 — 분기 조건을 규칙으로 못 쓰겠고, 케이스가 열 개 안팎으로 셀 수 있을 때. 단, 분기는 코드에 명시하고 모델은 라벨만 고르게 한다.
  • 진짜 에이전트 — 조사·탐색·원인 규명처럼 경로가 실행 중 발견되는 작업. 이때는 툴 호출 상한, 요청 deadline, 멱등성 키, trace 기반 리플레이를 먼저 깔고 시작한다. 이 네 개 없이 에이전트를 프로덕션에 올리면 새벽 2시에 후회한다.

원문의 마지막 문장을 저도 빌리고 싶다. 데모에서 이기는 시스템보다, 수요일에도 여전히 돌아가는 시스템이 목표였다. 코트를 벗겨보면 안에 있는 게 더 마음에 들 것이다.

참고 자료

728x90
728x90

1. 6주에 Flash 세 번, 이게 무슨 상황인가

2026년 9월 2일에 Google이 Gemini 3.8 Flash와 3.8 Flash Cyber를 발표했다. 공지 첫 문단이 좀 웃긴데, "3주 전에 나온 3.7 Flash의 기세를 이어" "6주 만에 세 번째 Flash 릴리스"라고 스스로 적어놨다. 모델 벤더가 릴리스 주기를 자랑거리로 쓰는 시대다.

운영하는 입장에서 이건 자랑이 아니라 리스크다. 6주에 세 번 바뀌면 우리가 붙여둔 프롬프트, 토큰 예산, 레이턴시 SLO가 6주에 세 번 흔들린다. 실제로 3.7 → 3.8 올리면서 겪을 문제 중 가장 현실적인 게 토큰 소비량 증가인데, 이건 뒤에서 자세히 다루겠다.

발표 내용을 실무 관점으로 압축하면 세 줄이다.

  • 3.8 Flash: 3.7과 같은 가격($0.75/1M input, $3.75/1M output, 프로모션 가격은 2026-12-31까지. 2027-01-01부터 $1.50 / $7.50)에 코딩·에이전트 성능만 올린 버전. DeepSWE v1.1, Vals Finance Agent V2, Harvey Legal Agent Benchmark에서 개선, HLE-Verified 54.9%.
  • 3.8 Flash Cyber: 취약점 탐지·자동 패치 특화 변종. 아무나 못 쓰고 Fairwind Program 승인된 "신뢰할 수 있는 방어자"에게만 열린다.
  • 둘 다 같은 기반 모델을 공유하고, 사이버보안 도메인 훈련이 오히려 일반 코딩·추론 성능까지 끌어올렸다는 게 Google 주장이다.

마지막 줄이 개인적으로 가장 흥미롭다. 취약점을 찾고 고치는 훈련은 "코드를 끝까지 읽고, 가설 세우고, 반증하고, 다시 읽는" 일이다. 이건 그냥 CRUD 코드 짜기보다 훨씬 긴 호흡의 추론이라 일반 SWE 태스크에 전이가 될 만하다. 물론 이건 Google의 설명이고 우리가 검증한 건 아니다.

2. "더 열심히 일하는 모델"이라는 말의 진짜 의미

공지에서 제일 정직한 문장은 이거다.

These performance gains stem from a core design choice: 3.8 Flash works harder. ... At times, the model might use more tokens to maximize performance, especially at higher effort levels.

번역하면 "성능은 올랐는데 토큰을 더 씁니다"다. 단가는 같은데 소비량이 늘면 청구서는 늘어난다. 이건 버그가 아니라 설계다.

비유하자면 이렇다. 3.7이 "주니어인데 시키는 만큼만 딱 하고 퇴근"이라면, 3.8은 "시켰더니 관련 파일 12개를 더 뒤져보고 테스트도 돌려보고 온" 사람이다. 결과물 품질은 좋은데 인건비(토큰)가 더 나간다. 그래서 Google도 대놓고 "효율이 최우선이면 effort level을 낮추거나 그냥 3.7 Flash 계속 쓰세요, 3.7은 계속 지원합니다"라고 써놨다. 모델 벤더가 구버전 계속 쓰라고 권하는 건 흔치 않다.

실무에서 이게 어떻게 드러나는지 보려면, 같은 프롬프트를 3.7과 3.8에 던져서 usageMetadata를 비교하는 게 제일 빠르다.

curl -s "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent" \
  -H "x-goog-api-key: $GEMINI_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "contents": [{"parts": [{"text": "이 Terraform 모듈의 IAM 정책에서 과도한 권한을 찾아 수정안을 제시해줘: ..."}]}]
  }' | jq '.usageMetadata'
{
  "promptTokenCount": 1842,
  "candidatesTokenCount": 1130,
  "thoughtsTokenCount": 4207,
  "totalTokenCount": 7179
}

여기서 봐야 할 건 thoughtsTokenCount다. 사고 토큰은 출력 단가로 과금되는데, 위 예시라면 실제 답변(1,130)보다 사고 과정(4,207)이 4배 가까이 많다. 3.7에서 3.8로 올리고 나서 "응답은 비슷한데 비용이 왜 튀지?" 싶으면 십중팔구 여기다. 필드 이름과 정확한 과금 정책은 릴리스마다 달라질 수 있으니 본인 프로젝트의 결제 콘솔과 최신 API 문서로 반드시 재확인해야 한다.

3. Flash Cyber: 취약점 찾기와 패치, 그리고 Fairwind라는 게이트

Cyber 변종 쪽 숫자를 정리하면 이렇다.

  • CyberGym(C/C++ 중심 취약점 발견 벤치마크)에서 3.5 Flash Cyber는 물론 훨씬 큰 프런티어 모델들도 앞섰다.
  • C/C++만으론 현실을 못 담으니 20개 언어 코드베이스로 만든 내부 벤치마크도 돌렸고, 성공률 70% 초과.
  • 패치 쪽 외부 벤치마크 CWE-Bench(Collinear 운영)에서 pass@1 47.2%. 1위 모델 47.8%에 살짝 못 미치지만 비용이 훨씬 싸서 Pareto frontier에 있다는 표현을 썼다.
  • 실사례: Chrome Security 팀 기준 훨씬 큰 상용 모델 대비 정확한 패치 2.6배. Wiz 내부 침투테스트 벤치마크에서 recall +7.5~9.7%, 비용은 2.3~5.2배 저렴. Google Cloud 취약점 연구팀은 보통 몇 달 걸리는 핵심 취약점을 2시간 안에 찾았다고.

여기서 실무자가 오해하기 쉬운 지점. pass@1 47.2%는 "패치 절반은 틀린다"는 뜻이다. Chrome 팀 사례도 "2.6배 더 많은 correct patch"지 "전부 correct"가 아니다. 즉 이건 사람 리뷰어를 없애는 도구가 아니라, 리뷰 큐에 올릴 후보를 대량으로 뽑아주는 도구다. 자동 패치 파이프라인을 짤 거면 반드시 사람 승인 단계를 남겨야 한다.

그리고 Cyber 모델은 API 키만 있다고 못 쓴다. Fairwind Program 심사를 통과한 정부기관, 핵심 인프라 운영자, 소프트웨어 메인테이너에게만 우선 접근이 주어진다. 공지에도 "3.8 Flash Cyber는 사이버 영역에서 더 느슨한(permissive) 완화 정책을 적용하기 때문에 trusted defender에게만 제공한다"고 명시돼 있다. 공격 능력(exploitation)보다 수정(fixing)을 우선 투자했다는 서술도 함께 나온다.

그래서 일반 팀이 당장 할 수 있는 건 일반 3.8 Flash로 코드 리뷰/시크릿 스캔 보조를 붙이는 것까지다. 예를 들어 PR diff를 던져서 위험 패턴만 뽑는 CI 스텝은 이렇게 짤 수 있다.

# .github/workflows/ai-review.yml (핵심만)
- name: AI security review on diff
  env:
    GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
  run: |
    DIFF=$(git diff origin/main...HEAD -- '*.tf' '*.yaml' '*.py' | head -c 60000)
    jq -n --arg d "$DIFF" '{
      contents:[{parts:[{text:("아래 diff에서 하드코딩된 크리덴셜, 과도한 IAM 권한, 미검증 입력만 지적. 없으면 NONE만 출력.\n"+$d)}]}],
      generationConfig:{temperature:0}
    }' > req.json
    curl -sf -X POST \
      "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent" \
      -H "x-goog-api-key: $GEMINI_API_KEY" -H 'Content-Type: application/json' \
      --data @req.json | jq -r '.candidates[0].content.parts[-1].text'

포인트는 두 개다. diff를 head -c로 자르는 것(안 자르면 대형 PR에서 토큰 폭탄), 그리고 parts[-1]로 마지막 파트를 꺼내는 것. 사고 과정이 앞쪽 파트로 오는 구성일 때 parts[0]만 보면 엉뚱한 게 나온다.

4. 도입 시 고려사항과 흔한 함정

함정 1. 모델 이름 하드코딩. 6주에 세 번 나오는 속도면 코드에 gemini-3.7-flash가 박혀 있는 순간 기술부채다. 환경변수나 ConfigMap으로 빼고, 카나리로 5% 트래픽만 신모델에 태우는 구조가 맞다. 그리고 아직 안 열린 이름을 미리 박아두면 이런 걸 만난다.

{
  "error": {
    "code": 404,
    "message": "models/gemini-3.8-flash-cyber is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.",
    "status": "NOT_FOUND"
  }
}

Cyber 변종은 Fairwind 승인 전엔 이 404가 정상이다. 권한 문제인지 이름 오타인지 헷갈리면 먼저 ListModels로 확인하자.

$ curl -s "https://generativelanguage.googleapis.com/v1beta/models" \
    -H "x-goog-api-key: $GEMINI_API_KEY" | jq -r '.models[].name' | grep flash
models/gemini-3.7-flash
models/gemini-3.8-flash

함정 2. 타임아웃 설정을 그대로 둠. "works harder" 모델은 실제로 더 오래 생각한다. 3.7 기준으로 맞춰둔 인그레스/게이트웨이 타임아웃(예: nginx 60초)에 그대로 물리면 이런 게 뜬다.

upstream timed out (110: Connection timed out) while reading response header from upstream

스트리밍(streamGenerateContent)으로 바꾸거나 타임아웃을 늘리거나, effort level을 낮추는 세 갈래 중 골라야 한다. 사용자 대면 API라면 스트리밍이 정답에 가깝다.

함정 3. 429를 리트라이로만 때움. 토큰당 요금이 같아도 요청당 토큰이 늘면 TPM(분당 토큰) 쿼터를 먼저 때린다.

{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}

여기서 무지성 지수 백오프 리트라이를 걸면 같은 무거운 요청이 반복돼 상황이 악화된다. 리트라이 전에 이 요청이 정말 3.8이어야 하는가를 먼저 물어야 한다. 라우팅 계층에서 요약·분류 같은 가벼운 작업은 3.7이나 낮은 effort로 내리는 게 실질적인 해법이다.

함정 4. 프롬프트 인젝션은 "개선"이지 "해결"이 아니다. 공지에 Gray Swan 측정 기준으로 프롬프트 인젝션 견고성이 크게 좋아졌다고 나오는데, 이걸 "이제 외부 문서 그냥 먹여도 된다"로 읽으면 안 된다. 에이전트가 셸이나 클라우드 API를 쥐고 있는 구조라면 모델 방어와 별개로 권한 자체를 좁히는 게 1차 방어선이다. 읽기 전용 크리덴셜, 별도 서비스 계정, 네트워크 이그레스 제한. 이건 모델 버전이 올라가도 안 바뀌는 원칙이다.

대안 관점. 코딩 에이전트라면 3.8 Flash가 "프런티어 모델 급 성능을 Flash 가격에"라는 포지션이라 비용 대비 매력이 있지만, 지연시간과 토큰 예산이 빡빡한 벌크 처리(로그 요약, 분류, 임베딩 전처리)는 3.7 Flash를 유지하는 게 낫다. Google 스스로 그렇게 권한다. 보안 스캐닝은 Cyber 모델 접근권이 없으면 기존 SAST/DAST(Semgrep, CodeQL, Trivy 등)를 1차로 두고 LLM은 트리아지 보조로 쓰는 조합이 현실적이다.

5. 정리

한 줄 요약: 3.8 Flash는 "같은 단가에 더 똑똑하지만 토큰을 더 쓰는" 모델이고, 3.8 Flash Cyber는 심사받은 방어자만 쓸 수 있는 취약점 탐지·패치 특화 변종이다.

  • 지금 바꿔라: 장기 실행 코딩 에이전트, 멀티스텝 리서치/리포팅, 복잡한 리팩터링 파이프라인. 품질 차이가 비용 증가분을 상쇄할 가능성이 높다.
  • 그대로 둬라: 분류·요약·라우팅처럼 대량·저지연이 핵심인 워크로드. 3.7 Flash는 계속 지원된다.
  • 먼저 할 일: 모델 이름 설정화 → 카나리 5% → usageMetadata로 토큰 변화 측정 → 타임아웃/쿼터 재산정. 이 순서를 건너뛰면 비용 알람으로 배운다.
  • Cyber는: Fairwind 신청 대상이 되는 조직인지부터 확인. 아니라면 일반 3.8 Flash + 기존 SAST 조합으로 트리아지 자동화를 먼저 만들어두는 게 순서다.

마지막으로, 벤치마크 수치는 전부 벤더 발표 기준이다. CWE-Bench pass@1 47.2%처럼 절반 가까이 틀리는 숫자를 보고도 "자동 패치니까 머지"로 가면 사고 난다. 우리 코드베이스에서 20~30건 샘플로 직접 재현해보고 정확도를 재는 게, 남의 벤치마크 표 열 개보다 낫다.

참고 자료

728x90
728x90

사내에서 LLM 에이전트를 프로덕션에 올려본 사람이면 한 번쯤 겪는 상황이 있다. 로컬에서 프롬프트 몇 개 던져보면 기가 막히게 잘 답하는데, 실제 서비스에 붙여놓고 세션이 길어지면 갑자기 바보가 된다. 그러면 팀에서 나오는 얘기는 대체로 이렇다. "모델을 더 좋은 걸로 바꾸자", "프롬프트를 더 잘 써보자".

Dev.to에 올라온 "Your Agent Doesn't Have a Reasoning Problem, It Has a Memory Problem"은 정확히 이 지점을 찌른다. 저자가 만든 멀티 에이전트 게임(Horcrux Hunt)에서 에이전트에게 사람이 직접 다듬은 완벽한 컨텍스트를 주면 95% 이상 최적 판단을 했는데, 실제 게임 승률은 23%였다는 것이다. 추론 능력은 멀쩡했고, 문제는 그 모델에게 무엇을 기억시켜서 넣어주느냐였다.

왜 지금 이 얘기가 중요한가

인프라 하는 입장에서 보면 이건 사실 익숙한 문제다. 애플리케이션은 멀쩡한데 캐시 무효화를 잘못해서 stale 데이터를 뿌리는 상황, 로그가 너무 많아서 정작 필요한 에러 라인을 못 찾는 상황과 구조가 똑같다. 다만 LLM에서는 이 문제가 돈과 레이턴시로 즉시 환산된다는 게 차이다.

원문에서 관찰된 실패 유형은 네 가지로 정리된다. 이 분류가 꽤 실용적이라 그대로 옮긴다.

  • Stale Memory — 12턴에 받은 긍정 신호를 25턴에도 믿고 행동. 그 사이 상대가 위치를 바꿔놨다. 캐시 TTL 안 걸어둔 것과 같다.
  • Retrieval Failure — 8턴에 "여긴 비었음"이라는 신호가 컨텍스트 안에 분명히 있었는데, 그 뒤 4,800토큰에 파묻혀서 모델이 못 찾음. 6,000토큰 안에서 20토큰짜리 신호는 사실상 없는 것과 같았다.
  • Memory Overload — 40턴쯤 컨텍스트가 6,000토큰이 되니 생성에 25초. Lambda 타임아웃 30초에 걸려서 게임이 통째로 죽음.
  • Decay Problem — 3턴의 중요한 신호가 40턴에도 유효했는데, 시간 가중치나 관련도 스코어링 없이 그냥 시간순으로 쌓기만 하니 노이즈에 묻힘.

네 개 다 "추론 실패로 위장한 메모리 아키텍처 문제"다. 그리고 세 번째 항목 — Lambda 타임아웃 — 은 완전히 인프라 담당자 티켓으로 떨어지는 종류의 장애다.

핵심: 컨텍스트를 3계층으로 쪼갠다

원문이 제시하는 해법은 메모리를 비용이 다른 세 계층으로 분리하는 것이다. 인프라 하는 사람한테는 L1 캐시 / 애플리케이션 연산 / 영구 스토리지 3단 구조로 보면 바로 이해된다.

Layer 3  Event Store (DynamoDB)   — 전부 저장, 원가 ¢     → LLM에 절대 안 들어감
   ↓ 계산
Layer 2  Computation (Python)     — 확률/엔트로피 계산, 0 토큰
   ↓ 압축 주입
Layer 1  Context Window           — 지금 이 판단에 필요한 것만, $$$

포인트는 Layer 3는 절대 컨텍스트 윈도우에 직접 들어가지 않는다는 것. 전체 이력은 DynamoDB에 TTL 걸어서 쌓아두되, LLM한테는 Layer 2가 계산해낸 결과값만 준다.

Layer 2의 핵심은 신념(belief) 갱신 로직이다. 원문 코드를 최소한으로 다듬으면 이렇다.

class BeliefMap:
    def __init__(self, locations):
        self.beliefs = {loc: 1.0 / len(locations) for loc in locations}
        self.last_updated = {loc: 0 for loc in locations}

    def update(self, location, signal, turn):
        if signal == "positive":
            self.beliefs[location] *= 3.0
        elif signal == "negative":
            self.beliefs[location] *= 0.1
        elif signal == "destroyed":
            self.beliefs[location] = 0.0
        self.last_updated[location] = turn

        # 오래된 신념은 감쇠 — 그 사이 상황이 바뀌었을 수 있다
        for loc in self.beliefs:
            if self.last_updated[loc] < turn - 10:
                self.beliefs[loc] *= 0.7
        self._normalize()

여기서 *= 0.7 감쇠가 바로 캐시 TTL의 확률 버전이다. Stale Memory 문제를 코드 두 줄로 막는다. 이 계산은 파이썬으로 수십 마이크로초, 토큰 비용 0원이다. 같은 판단을 "50턴치 서사를 읽고 추론해줘"로 LLM에 맡기면 토큰과 레이턴시를 다 지불하게 된다.

그리고 Layer 1에 주입되는 건 이렇게 압축된다.

# Before: 50턴 서사 나열 → 2,000+ 토큰, $0.015, 8초
# After: 55 토큰, $0.0002, 1초
"Horcrux likely at: Hogwarts (34%), Azkaban (22%), Ministry (18%).
 Entropy: 1.4 bits (medium confidence). Budget: 3 actions remaining.
 Ron available in 2 turns. Last signal: negative @ Godric's Hollow."

97% 압축인데 왜 손실 압축이 통하느냐. 원문의 설명이 정확하다. 확률분포가 충분통계량(sufficient statistic)이기 때문이다. Hogwarts가 34%라면, 그게 3턴의 긍정 신호에서 왔든 20턴간 부정 신호가 없어서 왔든 판단에는 영향이 없다. 의사결정에 필요한 정보는 이미 확률 안에 다 들어있다.

엔트로피로 호출 자체를 게이팅한다

여기서 한 발 더 나간 게 재밌다. 모든 판단이 같은 비용을 쓸 필요는 없다는 것.

def decide(belief_map):
    h = calculate_entropy(belief_map)   # Shannon entropy, bits

    if h < 1.0:            # 확신 있음 → LLM 안 부름
        return heuristic(belief_map.top_target())        # 0 토큰
    elif h < 2.5:          # 애매함 → 압축 컨텍스트
        return llm_decide(compress_to_55_tokens(belief_map))
    else:                  # 진짜 어려움 → 투자할 가치 있음
        return llm_decide(build_full_context(belief_map))  # 200-500 토큰

원문 기준으로 전체 판단의 약 35%가 엔트로피 1.0 미만 구간에 들어가서 토큰 0원으로 처리됐다. 45%는 55토큰, 나머지 20%만 200~500토큰을 쓴다. 확률이 이미 한 곳에 쏠려 있는데 "그래도 LLM한테 한번 물어볼까"는 그냥 낭비라는 얘기다.

인프라 용어로 번역하면 이건 불확실성 기반 캐시 히트다. 우리가 CDN 앞단에서 "정적 리소스는 오리진까지 안 간다"고 판단하는 것과 같은 결정을, LLM 호출에 대해 하는 것. 참고로 엔트로피가 낮아도 10%는 일부러 다른 선택을 하는 ε-greedy 탐색을 넣어서 패턴 고착을 막았다고 한다.

실무 관점: 도입할 때 실제로 부딪히는 것들

1. "요약 압축"과 "상태 압축"을 헷갈리지 마라

가장 흔한 착각이다. 대화가 길어지면 보통 "지난 대화를 LLM으로 요약해서 넣자"로 간다. 그런데 이건 Layer 1으로 Layer 1 문제를 푸는 것이라 비용이 안 줄고, 요약할 때마다 정보가 비가역적으로 뭉개진다. 요약의 요약의 요약을 5번 하면 원본과 무관한 텍스트가 된다.

원문 방식은 다르다. 자연어를 요약하는 게 아니라 구조화된 상태(확률분포, 카운터, 플래그)를 코드로 유지하고, 그걸 렌더링해서 넣는다. 상태는 언제든 Layer 3에서 재계산 가능하니까 손실이 누적되지 않는다. 이게 핵심 차이다.

실무 적용 예를 들면 — 고객상담 에이전트라면 대화 전문 대신 이런 걸 유지한다.

{
  "intent": "refund_request",       "intent_confidence": 0.82,
  "verified_identity": true,
  "order_ids": ["A-1024"],
  "attempted_solutions": ["reship_declined"],
  "escalation_score": 0.4,
  "turns": 14
}

14턴 대화 원문이 3,000토큰이라면 이건 60토큰 남짓이다. 그리고 intent가 바뀌면 attempted_solutions를 감쇠시키는 로직(위의 *= 0.7)을 넣으면 stale 문제도 같이 해결된다.

2. 흔한 함정: 타임아웃은 항상 컨텍스트가 최대일 때 터진다

원문의 3번 실패 모드가 바로 이거다. 컨텍스트가 커질수록 생성 시간이 늘어나는데, 로컬 테스트는 짧은 세션으로만 하니까 절대 안 걸린다. 프로덕션에서 장시간 세션이 쌓이는 시점에 갑자기 터진다.

Lambda에서는 이런 식으로 나온다.

$ aws logs tail /aws/lambda/agent-orchestrator --since 10m

2024-XX-XX 14:22:31 START RequestId: 8f3a... Version: $LATEST
2024-XX-XX 14:23:01 2024-XX-XXT14:23:01.442Z 8f3a... Task timed out after 30.02 seconds
2024-XX-XX 14:23:01 END RequestId: 8f3a...
2024-XX-XX 14:23:01 REPORT RequestId: 8f3a... Duration: 30020.11 ms
    Billed Duration: 30000 ms  Memory Size: 512 MB  Max Memory Used: 214 MB

여기서 Max Memory Used: 214 MB가 함정이다. 메모리는 널널하니까 "메모리 문제 아니네" 하고 넘어가는데, 실제 원인은 컨텍스트 토큰 수에 비례한 LLM 응답 지연이다. Lambda 메모리를 올려도 안 고쳐진다. 컨텍스트를 줄여야 고쳐진다.

API Gateway를 앞에 뒀다면 그쪽 상한(기본 29초로 알려져 있으나 최신 설정값은 AWS 공식 문서 확인 필요)에 먼저 걸려서 이렇게 나오기도 한다.

{"message": "Endpoint request timed out"}
HTTP/1.1 504 Gateway Timeout

대응은 두 갈래다. 근본 대응은 컨텍스트 압축, 임시 대응은 동기 요청을 끊고 SQS/Step Functions로 비동기 전환 + 스트리밍 응답. 다만 비동기로 도망치면 토큰 비용은 그대로 남는다는 걸 잊지 말자.

3. 벡터 스토어를 만능으로 보지 마라

"컨텍스트가 넘친다 → RAG 붙이자 → 벡터 DB"는 반사신경처럼 나오는 답인데, 원문의 2번(Retrieval Failure)과 4번(Decay) 실패 모드는 벡터 검색만으로는 안 풀린다.

  • 임베딩 유사도는 최신성을 모른다. 12턴의 신호와 25턴의 신호가 의미상 비슷하면 둘 다 똑같이 잘 검색된다. 시간 가중치를 스코어에 직접 섞거나, 메타데이터 필터로 turn/timestamp 범위를 걸어야 한다.
  • 애초에 계산으로 얻을 수 있는 값은 검색할 이유가 없다. 확률분포처럼 결정론적으로 산출되는 상태를 벡터로 저장하고 유사도 검색으로 꺼내오는 건, RDB에 있는 값을 Elasticsearch 전문검색으로 찾는 것만큼 어색하다.

정리하면 계층별 도구 선택은 이렇게 나뉜다. 확정적 상태(카운터, 플래그, 확률, 잔여 예산)는 Redis 같은 세션 스토어, 전체 감사 로그와 재계산 소스는 DynamoDB/RDB에 TTL, 비정형 참조 지식(문서, 과거 유사 케이스)만 벡터 스토어. 하나로 다 하려다 셋 다 어정쩡해진다.

4. 계측 없이 압축하면 사고 난다

손실 압축은 "무엇을 버려도 되는지" 확신이 있을 때만 안전하다. 원문 사례에서는 확률분포가 충분통계량이라는 근거가 명확했다. 우리 서비스에서 그게 성립하는지는 별개 문제다.

그래서 도입 순서는 반드시 이래야 한다. 먼저 턴별 토큰 수와 레이턴시를 로깅해서 어디서 폭발하는지 본다. 그다음 압축 버전과 원본 버전을 같은 입력으로 돌려 판단이 갈리는 케이스를 세어본다(shadow 비교). 마지막에 전환한다. 순서를 뒤집으면 "왜 이번 달부터 에이전트가 이상하죠?" 소리를 듣는다.

728x90
728x90

1. 도입: GPU 없이 LLM을 돌린다는 것

얼마 전 Hacker News에 올라온 글 하나가 인프라 하는 사람들 사이에서 돌았다. 제목이 "Running Gemma 4 26B at 5 tokens/sec on a 13-year-old Xeon with no GPU"다. 요약하면 이렇다. 2013년산 HP StoreVirtual 스토리지 박스(듀얼 Ivy Bridge Xeon E5-2690 v2, DDR3, GPU 없음)에서 Google의 Gemma 4 26B MoE 모델을 초당 5토큰가량으로 돌렸다는 얘기다. 박스 값은 300달러도 안 든다.

왜 이게 우리한테 의미가 있냐. 온프레미스 굴리는 팀이라면 데이터센터나 사무실 랙에 은퇴 직전이거나 이미 은퇴한 엔터프라이즈 서버가 한두 대씩 굴러다닌다. 스토리지 노드 교체하고 남은 구형 박스, 리스 만료돼서 반납 안 하고 방치한 서버 같은 것들. 이걸 폐기하거나 창고에 처박아 두는 대신 "LLM 폴백 서버"나 "야간 배치용 추론 노드"로 재활용할 수 있다면 얘기가 달라진다.

여기서 짚고 넘어갈 점. 원문 저자도 강조하지만 이건 "GPU 대신 CPU 쓰면 됩니다" 같은 단순한 얘기가 아니다. MoE 아키텍처 모델이라 활성 파라미터가 적어서(26B-A4B, 즉 총 26B지만 토큰당 활성 4B) CPU에서도 견딜 만한 속도가 나온 거고, 저자의 CPU는 AVX2도 FMA3도 없는 세대라 기존 최적화 코드가 아예 빌드조차 안 됐다. 그걸 패치해서 겨우 돌린 사례다. 그냥 따라한다고 되는 게 아니라는 뜻이다.

2. 핵심: CPU 추론이 왜 되고, 왜 느린가

MoE 덕분에 26B가 CPU에서 견딘다

Gemma 4 26B-A4B는 Mixture-of-Experts 모델이다. 전체 파라미터는 26B지만 토큰 하나 생성할 때 실제로 계산에 참여하는 건 4B(A4B의 A가 active) 정도만 활성화된다. 이게 CPU 추론에서 결정적이다.

비유하자면 이렇다. Dense 모델(모든 파라미터가 매 토큰마다 다 도는 모델)이 26명 전원이 매번 회의에 들어오는 조직이라면, MoE는 안건마다 관련 전문가 8명(원문 기준 layer당 8 active experts)만 불러 모으는 구조다. 나머지는 메모리에 대기만 하고 연산은 안 한다. CPU는 GPU만큼 병렬 연산이 세지 않으니, 매 토큰마다 도는 실제 연산량이 줄어드는 MoE 구조가 CPU에겐 큰 선물이다.

진짜 병목은 연산이 아니라 메모리 대역폭

여기가 CPU 추론의 핵심이다. LLM 디코딩(토큰 하나씩 뽑는 과정)은 대부분 연산 성능(FLOPS)이 아니라 메모리 대역폭에 묶인다(memory-bandwidth-bound). 원문 저자도 fused 커널을 못 써서 matmul을 두 번 나눠 돌리게 됐지만 "이 CPU는 어차피 메모리 대역폭에 묶여 있어서 손해 본 게 없다"고 명시했다.

왜 그런가. 토큰 하나 생성하려면 활성화된 가중치(Q8_0면 파라미터당 약 1바이트)를 전부 메모리에서 읽어와야 한다. 4B 활성 파라미터면 대략 4GB를 매 토큰마다 RAM에서 CPU로 긁어와야 한다는 뜻이다. DDR3 메모리 대역폭이 실효 40~50GB/s 수준이라 치면, 산술적으로 초당 10토큰 언저리가 물리적 상한선이 된다. 저자가 5.2 tok/s를 얻은 게 이 대역폭 벽을 감안하면 납득이 간다.

실무에서 이걸 기억해야 하는 이유. CPU 추론 튜닝할 때 "코어 수를 늘리면 빨라지겠지"라고 생각하기 쉬운데, 대역폭에 묶인 워크로드는 코어를 아무리 늘려도 메모리 컨트롤러가 못 따라와서 어느 지점부터는 스레드를 늘려도 속도가 안 오르거나 오히려 캐시 경합으로 느려진다. 물리 코어 수보다 메모리 채널 수와 대역폭이 더 중요하다.

3. 실무 관점: 세팅, 트레이드오프, 흔한 함정

llama.cpp / ollama 기본 세팅

먼저 AVX2가 있는 평범한(비교적 최신) CPU라면 굳이 원문의 고생을 따라할 필요 없다. Ollama나 llama.cpp 최신 빌드를 그대로 쓰면 된다. 가장 흔한 진입 경로는 Ollama다.

# Ollama 설치 (Linux)
curl -fsSL https://ollama.com/install.sh | sh

# CPU 정보 먼저 확인 - AVX2 지원 여부가 갈림길
lscpu | grep -o 'avx[0-9a-z_]*' | sort -u

출력이 이렇게 나오면 최신 최적화 커널을 쓸 수 있는 세대다.

avx
avx2
avx512f
avx512_vnni

반대로 원문 저자의 박스처럼 이렇게만 나오면 얘기가 완전히 달라진다.

avx

avx2가 안 보이고 avx만 있으면 Ivy Bridge(2013) 혹은 그 이전 세대다. 이 경우 대부분의 최신 추론 엔진 빌드가 런타임에 죽거나 아예 컴파일이 안 된다. 원문의 핵심 고생이 정확히 이 지점이다.

llama.cpp를 직접 빌드해서 스레드를 조절하려면 이런 식이다. (모델 경로와 파일명은 각자 환경에 맞게)

# llama.cpp 빌드 (AVX2 있는 일반적인 서버 기준)
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
cmake -B build
cmake --build build --config Release -j$(nproc)

# 실행: 물리 코어 수에 맞춰 스레드 지정
./build/bin/llama-cli \
  -m ./models/gemma-model-Q8_0.gguf \
  -p "온프레미스 서버 재활용 아이디어 3가지" \
  -t 20 \
  -c 4096

여기서 -t 20이 스레드 수다. 흔한 함정 하나: -t를 논리 코어(하이퍼스레딩 포함) 전부에 맞추면 오히려 느려지는 경우가 많다. 물리 코어 수부터 맞춰보고, 거기서 하나씩 조절하며 tok/s를 실측하는 게 정석이다. 앞서 말했듯 대역폭 병목이라 코어 다 갈아넣는다고 안 빨라진다.

양자화(Quantization): Q4 vs Q8 트레이드오프

원문은 Q8_0으로 돌렸다. 여기서 실무자가 알아야 할 트레이드오프를 정리한다. (아래 원리는 일반적으로 통용되는 내용이고, 원문에 없는 구체 벤치마크 수치는 지어내지 않았다.)

  • Q8_0: 파라미터당 약 8비트(1바이트). 품질 손실이 거의 없다시피 하지만 메모리를 많이 먹고, 매 토큰마다 읽어야 할 데이터가 크다 → 대역폭 병목 환경에선 속도가 상대적으로 느려진다.
  • Q4_K_M 등 4비트 계열: 파라미터당 약 4비트. 메모리 사용량과 대역폭 부담이 절반 수준으로 줄어 같은 하드웨어에서 디코딩 속도가 더 나올 여지가 크다. 대신 품질(특히 정밀한 추론, 코드 생성)에서 미세한 손실이 생길 수 있다.

대역폭에 묶인 CPU 환경에서는 "읽어야 할 바이트 수 = 속도"에 직결되기 때문에, Q8에서 Q4로 내리면 속도 이득이 GPU 환경보다 더 체감된다. 다만 원문 저자가 Q8_0을 쓴 이유가 있을 수 있다 — AVX1 폴백 경로에서 특정 양자화 포맷만 검증됐거나, 4비트 K-quant 계열이 해당 하드웨어의 스칼라 폴백에서 제대로 도는지 별도 확인이 필요하다. Q4로 바꿔 돌릴 땐 반드시 출력 품질을 눈으로 검증하고 나서 프로덕션에 올려라.

흔한 함정: AVX2 없는 하드웨어의 조용한 오작동

원문에서 가장 무서운 대목이 이거다. 빌드 자체가 죽으면 차라리 낫다. 진짜 문제는 빌드도 되고 실행도 되는데 출력이 조용히 쓰레기가 나오는 경우다.

저자의 경우 GGML_USE_IQK_MULMAT를 끄고 빌드했는데, 그래프 빌더는 여전히 MOE_FUSED_UP_GATE 같은 fused 연산을 뱉는데 dispatcher의 switch문엔 해당 op에 대한 case가 없어서 default로 빠졌고, 그 결과 모든 expert FFN의 결과 텐서가 계산되지 않고 메모리 쓰레기값으로 남았다. 30 layer × 8 active experts = 매 forward pass마다 약 240개 텐서가 초기화 안 된 버퍼값이었다는 것.

증상이 특히 교활하다. NaN도 안 뜨고, 크래시도 안 나고, temperature 0에서 결정적(deterministic)으로 나오는데 결과물이 이런 식이다:

# 증상: 유창해 보이는 다국어 gibberish
# 태국어, 한국어, 안 쓰는 sentinel 토큰, 영어 조각이
# 262K vocabulary 전체에 균일하게 흩뿌려짐
Prompt: What is the capital of France?
Output: สวัสดี 국가  the の paris ที่ 답 ▁▁ करता...

이게 왜 무서우냐. 크래시가 나면 "뭔가 잘못됐네"하고 바로 알지만, 그럴싸한 텍스트가 나오면 세팅이 잘 된 줄 착각하기 쉽다. 저자는 sampling 직전 raw logits를 찍어서 첫 토큰의 mean logit이 0 근처여야 하는데 +16으로 튀어 있고 vocabulary의 약 80%가 양수 logit인 걸 보고 "residual stream의 큰 덩어리가 초기화 안 된 메모리(작은 양수 float들)"라고 진단했다.

실무 교훈: 비표준 하드웨어에서 추론 엔진을 돌릴 땐 반드시 "정답을 아는 프롬프트"로 sanity check를 먼저 해라. "2 + 2는?", "한국의 수도는?" 같은 걸로 정상적인 답이 나오는지부터 확인하고, 그 다음 실제 워크로드를 태워야 한다. 크래시 안 났다고 정상이라 믿으면 안 된다.

또 하나의 함정: --run-time-repack

원문이 명시적으로 경고하는 지점. --run-time-repack 플래그는 시작할 때 양자화 가중치를 Q8_0_R8이라는 AVX2 전용 인터리브 레이아웃으로 재배열한다. AVX1 CPU에서 이걸 켜면 위와 똑같은 gibberish가 다시 나온다. AVX2 없는 박스에서 돌릴 거면 이 플래그를 빼야 한다. (AVX2 있는 서버라면 오히려 켜서 이득 보는 옵션이니 무조건 빼라는 얘기는 아니다.)

대안

  • 그냥 최신 CPU 서버를 쓴다: Haswell(2014, v3) 이상이면 AVX2/FMA3가 있어서 이 고생을 안 해도 된다. 사내에 놀고 있는 v3~v4 세대 Xeon이 있다면 그걸 먼저 노려라. 패치 없이 Ollama 바로 돌아간다.
  • MoE 모델을 우선 고른다: Dense 26B를 CPU에서 돌리면 A4B MoE보다 훨씬 느리다. CPU 서빙 계획이면 애초에 MoE 계열 모델을 선택하는 게 전략적으로 맞다.
  • 배치/비동기 워크로드로 한정: 5 tok/s는 사람이 읽는 속도지 대화형 실시간 서비스엔 부족하다. 야간 배치 요약, 로그 분류, 문서 태깅 같은 지연 시간이 관대한 작업에 붙이는 게 현실적이다.

4. 정리: 한 줄 요약과 적용 대상

한 줄 요약: MoE 모델과 메모리 대역폭 특성 덕분에 GPU 없는 구형 Xeon에서도 26B LLM을 읽는 속도(약 5 tok/s)로 돌릴 수 있지만, AVX2 없는 세대는 엔진 패치와 조용한 오작동 검증이라는 대가가 따른다.

이럴 때 써라:

  • 사무실/데이터센터에 놀고 있는 구형 서버가 있고, 유료 API가 다운됐을 때의 로컬 폴백이나 비용 민감한 야간 배치 추론이 필요한 경우
  • 토큰당 과금이 부담스럽고 지연 시간에 관대한 대량 텍스트 처리(분류, 요약, 태깅) 워크로드
  • 데이터를 외부로 못 내보내는 규제 환경에서 저비용 온프레미스 추론이 필요한 경우

이럴 땐 쓰지 마라:

  • 실시간 대화형 챗봇처럼 응답 속도가 UX인 서비스 (5 tok/s는 답답하다)
  • 가진 서버가 Haswell 이전 세대인데 C++ 커널 디버깅까지 감당할 팀이 없는 경우 — 이땐 그냥 GPU 인스턴스 빌리거나 최신 CPU 서버 쓰는 게 인건비 대비 싸다

가장 중요한 건 원문 저자가 던진 메시지다. "AI를 잘한다"는 게 구독료 내는 걸 뜻하는 시대에, 진짜 레버리지는 남이 안 만들어준 문제에 모델을 겨눌 줄 알고, 나온 답이 진짜 맞는지 판별할 줄 아는 것이라는 점. 이번 사례에서도 실제 커널 패치는 Claude가 짰지만, 실험을 설계하고 "logits mean이 +16이면 이상하다"를 아는 건 사람의 몫이었다. 인프라 하는 우리한테 딱 필요한 감각이다.

참고 자료

728x90
728x90

1. 도입: 왜 지금 이 얘기를 꺼내는가

질문 하나 던지고 시작하자. 오늘 당신 프로젝트의 의존성 하나가 털렸다고 치자. 공격자가 OpenAI, Anthropic, Gemini API 키를 빼갈 수 있을까?

이 질문의 답은 어떤 LLM 프로바이더를 쓰느냐, 코드가 얼마나 견고하냐에 달려 있지 않다. 대부분의 팀이 한 번도 진지하게 고민 안 한 딱 하나의 아키텍처 결정에 달려 있다. 바로 "앱이 돌아가는 동안 프로바이더 API 키가 실제로 어디에 존재하느냐"다.

요즘 실무에서 LLM API 도입이 폭발적으로 늘고 있다. 사내 챗봇, RAG 파이프라인, 코드 리뷰 봇까지. 그런데 대부분은 OPENAI_API_KEY를 환경변수에 박고 SDK 초기화해서 끝낸다. 나도 처음엔 그랬다. 문제는 이 방식이 공급망 공격(supply chain attack)에 통째로 노출된다는 점이다.

원문(Dev.to, Hadil Ben Abdallah)에서는 2026년 3월 LiteLLM 공급망 사고를 예로 든다. 이 사고가 주목받은 건 LiteLLM이 유독 취약해서가 아니라, AI 인프라가 이제 얼마나 값진 공격 타깃이 됐는지, 그리고 털린 의존성 하나가 얼마나 빠르게 고가치 크레덴셜에 도달하는지를 보여줬기 때문이다. 이 글에서는 두 가지 게이트웨이 아키텍처를 비교하고, 실제로 돌려볼 수 있는 데모, 그리고 실무에서 시크릿을 어떻게 관리할지까지 정리한다.

2. 핵심: 키가 어디 사느냐가 전부다

모든 LLM 애플리케이션은 결국 같은 일을 한다. 모델 프로바이더에 요청 보내고, API 키로 인증한다. 중요한 건 "API 키를 쓰느냐"가 아니라 "요청하는 순간 그 키가 어디에 존재하느냐"다. 크게 두 가지 패턴이 있다.

패턴 A: 앱이 프로바이더 키를 직접 들고 있다

우리가 제일 익숙한 방식이다. 환경변수에서 키 읽고, SDK 초기화하고, 프로바이더로 직접 쏜다.

import os
from openai import OpenAI

api_key = os.environ["PROVIDER_API_KEY"]
client = OpenAI(api_key=api_key)
response = client.responses.create(...)

구현 빠르고 이해하기 쉽다. 많은 프로젝트에서 충분히 합리적이다. 하지만 핵심은 이거다. 프로바이더 키가 이제 애플리케이션 프로세스 안에 산다. 그 프로세스에서 돌아가는 모든 패키지, 프레임워크, 플러그인, 의존성이 같은 권한으로 실행된다. 그중 하나만 털려도 악성 코드는 당신 앱과 똑같은 환경에서 키를 읽을 수 있다.

패턴 B: 앱이 네트워크 프록시와 대화한다

인증을 앱에서 분리하는 방식이다. 앱은 프로바이더로 직접 쏘지 않고 게이트웨이/프록시로 보낸다. 프록시가 프로바이더 키를 들고 있다가, 자기 프로세스 안에서만 키를 주입해 업스트림 요청을 대신 처리한다.

Application
   │
   ▼
Gateway / Proxy   ← 여기에만 프로바이더 키가 산다
   │
   ▼
LLM Provider

앱 관점에서 흐름은 거의 똑같다. 차이는 앱이 무엇을 안 갖고 있느냐다. 앱은 프로바이더 키 대신 스코프가 제한된 게이트웨이 토큰만 들고 있다. 프록시가 이 토큰을 검증하고, 라우팅/정책을 적용한 뒤, 자기 프로세스 안에서 진짜 프로바이더 키를 붙여 보낸다.

이게 사고의 결과를 바꾼다. 악성 코드가 앱 프로세스에서 실행돼도, 프로바이더 API 키는 애초에 거기 없다. 물론 게이트웨이 토큰이 털리는 것도 보안 사고다. 하지만 게이트웨이 토큰은 좁게 스코프를 걸 수 있고, 중앙에서 즉시 폐기할 수 있고, 앱 재배포 없이 로테이션할 수 있고, 특정 작업만 허용하도록 제한할 수 있다. 프로바이더 키를 갈아끼우는 것과는 회복 난이도가 완전히 다르다.

같은 악성 의존성을 두 아키텍처에 돌려보자

원문의 데모를 재현해보자. 아래는 임포트되는 순간 프로세스 환경변수를 스캔해서 크레덴셜처럼 생긴 걸 출력하는 "악성" 패키지다. 실제로는 네트워크 요청도, 파일 쓰기도 안 하지만 개념 증명엔 충분하다.

# malicious_dep.py
import os

_INTERESTING = ("API_KEY", "SECRET", "TOKEN", "PASSWORD", "PRIVATE_KEY")

def harvest():
    found = {
        k: v for k, v in os.environ.items()
        if any(marker in k.upper() for marker in _INTERESTING)
    }
    for name, value in found.items():
        shown = value[:8] + "..." if len(value) > 12 else value
        print(f"[malicious_dep@import] EXFILTRATED {name}={shown}")

harvest()

이제 시나리오 A. 앱이 프로바이더 키를 직접 환경변수에 들고 있는 경우다.

$ export PROVIDER_API_KEY="sk-provider-abc123xyz"
$ python -c "import malicious_dep"
[malicious_dep@import] EXFILTRATED PROVIDER_API_KEY=sk-provi...3xyz

별 대단한 짓을 한 게 아니다. 인증 우회도, 메모리 손상 익스플로잇도, 다른 서비스 침투도 없다. 그냥 이미 자기가 실행되는 프로세스 안에 있던 데이터를 읽었을 뿐이다. 공격자 입장에선 그걸로 충분하다.

이번엔 시나리오 B. 앱은 게이트웨이 토큰만 들고 있다.

$ unset PROVIDER_API_KEY
$ export GATEWAY_TOKEN="gw-scoped-read-only-789"
$ python -c "import malicious_dep"
[malicious_dep@import] EXFILTRATED GATEWAY_TOKEN=gw-scope...-789

주목할 건 무엇이 안 나왔는지다. 프로바이더 API 키가 없다. 애초에 앱 프로세스에 존재한 적이 없으니까. 앱은 여전히 모델 응답을 정상적으로 받지만, 프로바이더 인증은 프록시 안에서 일어난다.

여기서 "그럼 프록시가 문제를 해결했네"라고 결론 내리면 안 된다. 의존성은 여전히 크레덴셜을 하나 훔쳤다. 게이트웨이 토큰은 진짜고, 공격자가 이걸로 프록시를 통해 요청을 날릴 수도 있다. 핵심은 "뭐가 샜냐"가 아니라 "샌 게 뭘 할 수 있고, 얼마나 빨리 복구되냐"다. 프로바이더 키가 새면 프로바이더 콘솔 들어가서 키 재발급하고 전 서비스 재배포해야 한다. 게이트웨이 토큰은 프록시에서 한 줄로 폐기하고 새 토큰 발급하면 끝이다.

3. 실무 관점: 도입 시 고려사항과 흔한 함정

키 노출 경로별 위험도 비교

실무에서 키가 새는 경로는 생각보다 다양하다. 경로별로 정리하면 이렇다.

  • 앱 프로세스 환경변수: 위에서 봤듯 같은 프로세스의 모든 의존성이 읽는다. 가장 흔하고 가장 넓은 공격면.
  • CI/CD 파이프라인 변수: GitHub Actions Secrets, GitLab CI Variables 등. 로그에 실수로 echo $OPENAI_API_KEY 찍으면 그대로 노출된다. PR에서 돌아가는 워크플로에 시크릿 노출되면 포크에서 탈취 가능.
  • 컨테이너 런타임: docker inspect/proc/1/environ로 환경변수 다 보인다. 이미지 레이어에 ENV OPENAI_API_KEY=... 박아넣으면 이미지 pull 가능한 누구나 본다.
  • .env 파일 커밋: .gitignore에 안 넣고 커밋했다가 GitHub 스크래핑 봇한테 몇 분 만에 털리는 클래식.

공통점은 "키가 앱 프로세스가 도달 가능한 위치에 평문으로 존재"한다는 것이다. 패턴 B(프록시)는 이 문제를 프록시 프로세스 하나로 격리시켜 폭발 반경(blast radius)을 줄인다.

흔한 함정 1: 컨테이너 환경변수는 다 보인다

많은 사람이 컨테이너 안에 환경변수로 넣으면 "격리됐다"고 착각한다. 아니다. 같은 호스트에서 docker 접근 권한만 있으면 이렇게 보인다.

$ docker inspect my-llm-app --format '{{range .Config.Env}}{{println .}}{{end}}'
PATH=/usr/local/bin:/usr/local/sbin
PROVIDER_API_KEY=sk-provider-abc123xyz
LANG=C.UTF-8

또 이미지 빌드 단계에서 ENV로 박으면 레이어 히스토리에 영구히 남는다. docker history로 확인 가능하다. 빌드 시점 시크릿은 반드시 --secret 마운트(BuildKit)를 쓰거나 런타임 주입해야 한다.

흔한 함정 2: Vault 붙였는데 결국 환경변수로 다시 흘린다

Vault를 도입해놓고 앱 시작 스크립트에서 이렇게 하는 경우가 진짜 많다.

export PROVIDER_API_KEY=$(vault kv get -field=api_key secret/llm)
python app.py

이러면 Vault를 쓰든 말든 결국 키가 앱 프로세스 환경변수로 다시 들어온다. 의존성 공격 관점에선 아무것도 안 바뀐 것이다. Vault의 진짜 가치는 중앙 관리, 감사 로그, 자동 로테이션, 동적 시크릿이지, "환경변수에 넣기 전 잠깐 안전"이 아니다.

흔한 함정 3: AWS Secrets Manager 권한 에러

ECS/EKS에서 Secrets Manager 붙이다 보면 십중팔구 이 에러를 만난다.

ResourceInitializationError: unable to pull secrets or registry auth:
execution resource retrieval failed: unable to retrieve secret from asm:
service call has been retried 1 time(s):
AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/ecsTaskExecutionRole/...
is not authorized to perform: secretsmanager:GetSecretValue
on resource: arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:llm/openai-key-AbCdEf
because no identity-based policy allows the secretsmanager:GetSecretValue action

범인은 대부분 Task Execution Rolesecretsmanager:GetSecretValue 권한이 없어서다. 그리고 KMS 커스텀 키로 암호화한 시크릿이면 kms:Decrypt도 추가로 필요하다. 이걸 놓쳐서 한참 헤매는 사람 많다. 최소 권한 정책 예시는 이렇다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:llm/openai-key-*"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:ap-northeast-2:123456789012:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
  ]
}

안전한 시크릿 관리 옵션 비교

  • HashiCorp Vault: 동적 시크릿, 자동 로테이션, 세밀한 정책. 자체 운영 부담이 크다. Vault Agent나 CSI Provider로 파일 마운트하면 환경변수보다 낫다(단, 파일도 프로세스가 읽으면 의존성이 읽음).
  • AWS Secrets Manager: AWS 생태계면 통합이 편하다. 자동 로테이션 지원. IAM 권한 설정이 함정.
  • SOPS: 암호화된 시크릿을 Git에 커밋해서 GitOps로 관리. KMS/age로 암호화. 배포 시점 복호화. 인프라 부담이 적어 소규모 팀에 좋다.
  • LLM 프록시 게이트웨이(패턴 B): LiteLLM Proxy, 자체 프록시 등. 근본적으로 폭발 반경을 줄이는 유일한 방법. 다만 프록시 자체가 새 SPOF가 되고 운영 포인트가 하나 늘어난다.

정직하게 말하면, 어느 것도 만능이 아니다. 프록시를 써도 게이트웨이 토큰은 여전히 프로세스 안에 있다. 관건은 "완전 제거"가 아니라 "털렸을 때 피해를 얼마나 좁히고, 얼마나 빨리 복구하느냐"다.

SOPS로 키를 Git에 안전하게 넣는 실전 예시

소규모 팀이라면 프록시 세우기 전에 SOPS만 붙여도 평문 커밋 사고는 막는다.

$ cat secrets.yaml
openai_api_key: sk-provider-abc123xyz

$ sops --encrypt --age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j \
    secrets.yaml > secrets.enc.yaml

$ cat secrets.enc.yaml
openai_api_key: ENC[AES256_GCM,data:xJk2...,iv:...,tag:...,type:str]
sops:
    age:
        - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ...
            -----END AGE ENCRYPTED FILE-----

이제 secrets.enc.yaml은 안심하고 커밋해도 된다. 배포 시점에 복호화한다.

$ export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
$ sops --decrypt secrets.enc.yaml
openai_api_key: sk-provider-abc123xyz

4. 정리: 한 줄 요약과 선택 기준

한 줄 요약: LLM API 키 보안의 90%는 "키가 앱 프로세스가 읽을 수 있는 곳에 있느냐"로 결정된다. 프록시로 프로바이더 키를 앱 밖으로 빼내면 의존성이 털려도 폭발 반경이 줄어든다.

  • 혼자/소규모, 빠른 프로토타입: 최소한 SOPS나 Secrets Manager로 평문 커밋·환경변수 하드코딩부터 막아라.
  • 여러 앱이 같은 프로바이더 키 공유, 감사·로테이션 필요: 프록시 게이트웨이(패턴 B)를 진지하게 검토하라. 앱은 스코프 토큰만 들고, 폐기·로테이션을 중앙화한다.
  • 규제/보안 요구가 높은 조직: Vault 동적 시크릿 + 프록시 조합. 운영 부담은 크지만 회복 속도와 감사 추적이 최고다.

핵심은 마인드셋 전환이다. "우리 코드는 안전하다"가 아니라 "우리 의존성 중 하나가 이미 털렸다고 가정하면, 그때 무슨 크레덴셜이 손에 잡히나?"를 물어야 한다. 이 질문에 답이 안 나온다면, 오늘 docker inspect 한 번 돌려보는 것부터 시작하자.

참고 자료

728x90
728x90

지난주에 사내 코드 리뷰 자동화 PoC 돌리다가 또 한 번 깨달은 게 있다. 클로드든 GPT든 외부 API로 보내는 순간, 보안팀에서 "그 코드 어디로 나갑니까"라는 질문이 날아온다는 거다. 사내 레포지토리 코드를 그대로 미국 회사 서버로 토큰화해서 던지는 걸, 규제 산업에선 절대 통과 못 시킨다. 그래서 로컬 LLM을 계속 만지작거리는데, 솔직히 그동안은 다 실망스러웠다. 7B, 13B 모델들은 "데모용"이지 실무용이 아니었으니까.

그런데 Quesma 블로그의 Piotr Migdał이 쓴 "Qwen 3.6 27B is the sweet spot for local development" 글이 HN 프론트에 올라온 걸 보고, 이건 좀 진지하게 봐야겠다 싶었다. 정리해본다.

왜 지금 로컬 LLM인가 — 비용·보안·레이턴시 트레이드오프

로컬 LLM을 고민하게 되는 시점은 보통 셋 중 하나다.

  • 비용: 원문에서도 지적하듯, 지금 프론티어 모델들은 "massive subsidy(대규모 보조금)" 위에서 돌아간다. 월 100달러 내면 토큰 가치로는 수천 달러어치를 쓴다. 이 할인이 영원할 거라 가정하고 파이프라인을 설계하면, 가격 정책 바뀌는 날 CI 비용이 폭발한다. CI에서 PR마다 LLM 코드 리뷰를 돌리면 토큰 소모가 생각보다 빠르게 누적된다.
  • 보안: 사내 코드, 의료 데이터, 미공개 데이터를 외부로 안 보내야 하는 경우. 원문 저자도 "미국이나 중국과 내 깊은 비밀, 의료 데이터를 공유하는 게 불편할 때"라고 직설적으로 쓴다.
  • 레이턴시·오프라인: 네트워크 왕복 없이 로컬에서 즉시 응답. 오프라인 환경, 폐쇄망 개발 환경.

핵심은, 로컬 모델은 "뺏기지 않는다"는 점이다. 원문에서 언급한 Claude Fable 5가 내려간 것처럼, 의존하던 모델이 어느 날 사라지면 워크플로우 전체가 망가진다. 로컬 가중치는 한번 받아두면 내 디스크에 남는다.

Qwen 3.6 27B 아키텍처 해부 — Dense가 MoE보다 나은 상황

Qwen 3.6은 두 가지 변형으로 나온다. 이게 선택의 핵심이다.

  • Qwen 3.6 35B A3B — Mixture-of-Experts(MoE). 전체 35B 파라미터지만 추론 시엔 일부 전문가(A3B = active 3B 추정)만 활성화. 그래서 빠르다.
  • Qwen 3.6 27B — Dense 모델. 모든 파라미터를 매번 쓴다. 느리지만 더 강력하다. 원문 저자가 추천하는 쪽이다.

비유하자면 MoE는 "회의에 필요한 전문가만 호출하는 컨설팅 회사"고, Dense는 "전원이 항상 모든 회의에 들어오는 작은 정예팀"이다. 전자는 빠르고 효율적이지만, 가끔 엉뚱한 전문가가 호출되거나 지시를 놓친다. 후자는 느리지만 일관되게 깊다.

원문의 실제 일화가 이 차이를 잘 보여준다. OpenCode에서 "pnpm으로 헥사고날 지뢰찾기 만들어줘"라고 시켰을 때:

  • 27B (Dense): 단일 프롬프트로 한 번에 성공. 제대로 된 Node 패키지로 만들어냈다.
  • 35B A3B (MoE): 더 빨랐지만, "패키지로 만들라"는 지시를 무시하고 그냥 단일 index.html에 박아넣었다.

여기서 실무 교훈. 제약 조건 준수(instruction following)가 중요한 태스크일수록 Dense가 유리하다. CI에서 "이 컨벤션 지켜서 리팩토링해", "이 디렉토리 구조로 생성해" 같은 까다로운 지시를 던질 거면, 속도를 좀 포기하더라도 27B가 안전하다. 반면 빠른 초안 생성, 채팅형 보조에는 35B A3B가 낫다.

벤치마크로 보면 (원문의 Artificial Analysis 인용):

Gemma 4 31B        : 29  (≈ 2024 말, o1 / Claude 3.5 Sonnet 급)
Qwen3.6-35B-A3B    : 32  (≈ 2025 초, o3 / Claude 4 Sonnet 급)
Qwen3.6-27B        : 37  (≈ 2025 중, GPT-5 / Claude Sonnet 4.5 급)
DeepSeek-V4-Flash  : 40  (≈ 2025 말, GPT-5.2 / Claude Opus 4.5 급)

주의: 이 점수와 "≈ 급" 비교는 원문 기준이며, 미래 시점 모델명이 섞여 있다(원문 작성일이 2026년). 그대로 인용한 거지 내가 검증한 수치는 아니다. 다만 27B가 같은 로컬 디폴트로 많이 쓰이는 Gemma 4 31B를 벤치마크·여론 양쪽에서 큰 차이로 앞선다는 게 원문의 주장이다.

하드웨어 요구사항과 추론 런타임 — 실제로 돌려보기

원문 저자는 llama.cpp를 추천한다. Ollama는 윤리적 이유로 권하지 않는다고 명시했는데, 이 부분은 저자 개인 입장이니 각자 판단하면 된다. 다만 기술적으로 llama.cpp는 직접적이고 오픈소스이며, Apple Silicon 전용인 mlx-lm보다도 더 빨랐다는 게 원문 측정 결과다.

먼저 모델 받아서 서버 띄우기. 8-bit 양자화에 멀티토큰 예측(MTP) 지원 버전을 쓴다:

llama-server -hf unsloth/Qwen3.6-27B-MTP-GGUF:Q8_0 \
  --spec-type draft-mtp -ngl 999 -fa on -c 65536 --port 8080

각 옵션 의미:

  • -hf unsloth/Qwen3.6-27B-MTP-GGUF:Q8_0 — Hugging Face에서 8-bit 양자화 모델을 받는다. 다음 실행부터는 캐시 재사용.
  • --spec-type draft-mtp — 빠른 보조 모델로 다음 토큰을 미리 예측(speculative decoding). 속도 향상.
  • -ngl 999 — 모든 레이어를 GPU에 올린다.
  • -fa on — flash attention 켜기.
  • -c 65536 — 컨텍스트 64k 토큰. Qwen 3.6 27B의 네이티브 컨텍스트는 256k라 더 늘릴 수도 있다.
  • --port 8080 — 포트 고정. 다른 설정에서 재사용하니까.

서버 뜨면 http://127.0.0.1:8080에서 바로 채팅 가능하다. 성능 측정치(MacBook M5 Max 128GB 기준, 원문):

모델                          tok/s    RAM
Qwen3.6-27B (llama.cpp)       18       41 GB
Qwen3.6-27B (llama.cpp+MTP)   32       42 GB
Qwen3.6-35B-A3B (llama.cpp+MTP) 105     45 GB

여기서 짚을 점 두 가지.

첫째, MTP가 27B에서 거의 2배 속도(18→32 tok/s)를 낸다. MTP 빼고 돌리면 답답할 수 있으니 꼭 켜라.

둘째, 35B A3B가 27B보다 3배 빠르다(105 vs 32). 그래도 원문 저자는 27B를 택한다. "1/3만큼 코드를 생성하더라도 더 높은 품질을 원한다"는 이유다. 이 선택은 팀 성향에 달렸다 — 빠른 반복이 중요하냐, 한 방의 정확도가 중요하냐.

Nvidia RTX 카드라면 양자화를 더 공격적으로 해야 한다. HN 댓글의 gfosco는 RTX 5090에서 Q6_K 양자화 + Q4_0 KV로 123k 컨텍스트에서 50 tok/s를 LM Studio로 뽑았다고 한다(약 28/32GB VRAM 사용). 즉 27B는 48GB Apple Silicon 공유 메모리 안에서 돌고, 소비자급 GPU에서도 양자화만 잘 맞추면 충분히 돌아간다.

OpenCode 같은 에이전트에 물리려면 ~/.config/opencode/opencode.jsonc에:

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "llama": {
      "name": "llama.cpp (local)",
      "npm": "@ai-sdk/openai-compatible",
      "options": {
        "baseURL": "http://127.0.0.1:8080/v1",
        "apiKey": "local"
      },
      "models": {
        "qwen3.6-27b": { "name": "Qwen3.6-27B Q8 +MTP" }
      }
    }
  },
  "model": "llama/qwen3.6-27b"
}

핵심은 llama-server가 OpenAI 호환 엔드포인트(/v1)를 노출한다는 거다. 그래서 baseURL만 로컬로 돌리면 기존 OpenAI SDK 쓰는 코드/툴 대부분이 그대로 붙는다.

실무 관점 — 흔한 함정과 트레이드오프

함정 1: VRAM 초과로 레이어가 CPU로 떨어진다

-ngl 999로 전부 GPU에 올리려 했는데 VRAM이 모자라면, llama.cpp가 일부 레이어를 CPU로 오프로드한다. 그럼 속도가 절벽처럼 떨어진다. 보통 이런 로그가 뜬다:

llama_model_load: error loading model: unable to allocate CUDA0 buffer
ggml_backend_cuda_buffer_type_alloc_buffer: allocating 8192.00 MiB on device 0: cudaMalloc failed: out of memory

이때 해법은 (1) 더 공격적인 양자화로 내리거나(Q8_0 → Q6_K → Q4_K_M), (2) -ngl 값을 실제 올릴 수 있는 레이어 수로 줄이거나, (3) 컨텍스트 -c를 줄여서 KV 캐시 메모리를 절약하는 것이다. 컨텍스트 64k가 메모리를 꽤 먹으니, 코드 리뷰처럼 짧은 입력이면 -c 16384 정도로도 충분할 때가 많다.

함정 2: MTP 옵션 누락 또는 draft 모델 미지원

--spec-type draft-mtp를 줬는데 받은 GGUF가 MTP를 지원 안 하는 버전이면 speculative decoding이 동작 안 하거나 경고가 뜬다. 반드시 모델 이름에 -MTP-가 들어간 변형(Qwen3.6-27B-MTP-GGUF)을 받아야 한다. 일반 양자화 받아놓고 MTP 옵션만 켜면 속도 이득(18→32)을 못 본다.

함정 3: 양자화 수준과 품질의 함정

원문 기준 8-bit(Q8_0)는 품질 손실이 거의 없다. 하지만 RTX 카드에서 메모리 맞추려고 Q4 이하로 내리면 코드 생성 품질이 눈에 띄게 떨어질 수 있다. 특히 긴 컨텍스트에서 더 그렇다. "왜 우리 로컬 모델은 클로드보다 한참 멍청하지?"의 범인이 사실은 모델이 아니라 과도한 양자화인 경우가 많다. 벤치마크 점수도 8-bit 기준이라는 걸 기억하자.

함정 4: CI에 통합할 때 동시성

llama-server 한 개 인스턴스에 PR 빌드 여러 개가 동시에 붙으면 큐가 밀린다. Dense 27B는 그렇잖아도 32 tok/s 수준이라, 동시 요청이 쌓이면 CI 잡이 타임아웃 난다. 이럴 땐 처리량(throughput) 최적화가 강한 vLLM으로 갈아타거나, GPU를 여러 장 두고 인스턴스를 늘리는 걸 고려해야 한다. 원문은 단일 개발자 워크플로우 중심이라 이 부분은 다루지 않으니, 팀 단위 CI 통합이라면 처리량 벤치마크를 별도로 잡아봐야 한다(공식 문서 확인 필요).

트레이드오프 정리

  • 품질 우선, 지시 준수 중요 → Qwen 3.6 27B Dense + Q8_0 + MTP
  • 속도 우선, 채팅/초안 → Qwen 3.6 35B A3B (MoE)
  • 소비자 GPU(VRAM 24~32GB) → Q6_K 정도로 타협, KV는 Q4_0
  • 팀 CI 처리량 필요 → vLLM 계열 검토

정리 — 누가 언제 써야 하나

한 줄 요약: Qwen 3.6 27B는 "외부로 코드 못 보내는 환경에서, 단일 개발자/소규모 팀이 품질 좋은 로컬 코딩 어시스턴트를 쓰고 싶을 때" 현실적인 첫 선택지다.

이런 사람에게 권한다:

  • 48GB Apple Silicon이나 24GB+ VRAM GPU를 가진 개발자 — 바로 돌려볼 수 있다.
  • 보안/규제 때문에 클라우드 API를 못 쓰는 백엔드·인프라 팀의 PoC 단계.
  • 외부 API 비용 보조금이 사라질 위험에 대비해 로컬 백업 워크플로우를 두려는 팀.

반대로 지금 당장은 보류할 사람: 수백 명이 동시에 붙는 대규모 CI 처리량이 필요하거나, 프론티어 최상위 모델 품질이 반드시 필요한 경우. 그땐 원문이 언급한 GLM 5.2 같은 더 큰 오픈웨이트(단일 맥북/5090으론 안 돌고 회사 예산급 하드웨어 필요)를 보거나, 그냥 클라우드 API가 답이다.

개인적으로는, "내가 통제하고 뺏기지 않는 모델"이라는 가치 하나만으로도 한 번 세팅해둘 값어치는 충분하다고 본다. 명령어 몇 줄이면 끝나니, 주말에 5090이든 맥북이든 한번 띄워보고 사내 코드 한 조각 리뷰시켜보는 걸 추천한다.

참고 자료

※ 본문의 벤치마크 점수, tok/s, RAM 수치는 모두 원문(Quesma 블로그) 측정값을 인용한 것이며, 미래 시점 모델명이 섞여 있으니 실제 도입 전 최신 공식 문서로 재확인 바란다.

728x90

+ Recent posts