[에이전트] 4. 추론 : 별도 서버 없이 프로세스 안에서 LLM 돌리기

폐쇄망 사내 에이전트 4편 - 추론

작성자

카테고리:

* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

(폐쇄망 사내 에이전트 ④) 추론

처음엔 관례대로 했다. llama.cpp의 llama-server를 자식 프로세스로 띄우고 HTTP로 붙는 구조. 돌긴 도는데 폐쇄망 사무 PC 기준으로 거슬리는 게 한둘이 아니었다 — 실행 파일이 두 개, 포트가 하나 열리고, 서버가 죽었는지 살았는지 관리하는 코드가 따로 필요하다. 그래서 2일차 저녁, 추론을 프로세스 안으로 들여왔다.

commit c314d21  (2026-08-02 19:02)
node-llama-cpp 인프로세스 추론으로 전환한다

  llama-server를 걷어내고 Air 프로세스 안에서 GGUF를 직접 로드해 추론한다.
  별도 서버·포트·자식 프로세스가 사라진다.

배포 관점에서 이게 얼마나 큰지는 폐쇄망을 겪어본 사람이면 안다. 반입 심사 대상이 실행 파일 하나로 줄고, “포트를 여는가”라는 질문 자체가 사라진다.

한 층 아래 API를 쓴 이유 — 분리 원칙의 실전

이 커밋에서 제일 좋아하는 결정은 라이브러리의 편한 API를 일부러 안 쓴 것이다. node-llama-cpp의 LlamaChatSession은 함수(도구) 핸들러까지 알아서 실행해주는 고수준 API인데, 그걸 쓰면 추론 라이브러리가 도구를 실행하게 된다. 그러면 2편에서 세운 정책·승인·감사 게이트를 통째로 우회한다. 그래서 한 층 아래 LlamaChat.generateResponse를 썼다 — 이건 호출 목록만 돌려주고, 실행은 여전히 런타임 몫이다. 편의 API가 아키텍처 원칙과 충돌하면 원칙이 이긴다.

웜 캐시 실측 — 36배

인프로세스 전환의 보너스는 KV 캐시가 프로세스와 함께 산다는 것이다. 도구 정의 수천 토큰이 실린 프롬프트 앞부분을 매번 다시 계산할 필요가 없어진다. 커밋에 실측이 그대로 적혀 있다.

웜캐시 실측: 34툴 7,286토큰 프롬프트
  콜드 23.37초 → 웜 1.86초
  prefill만 보면 20,769ms → 567ms (36.6배)
  새 conversationId 대조군은 22.93초 — 캐시 효과임을 확인

대조군까지 돌려서 ‘캐시 덕인지’를 확인한 게 포인트다. 이 36배가 이후 모든 컨텍스트 설계의 전제가 된다 — 프롬프트 앞부분을 건드리는 순간 이 이득이 사라지므로, 9편의 압축 설계가 전부 “앞부분을 안 건드리는 방향”으로 굴러간다.

메모리는 지시서가 아니라 실측으로

다음 날 커밋 하나가 또 교과서적이다. 세션 캐시 기본값을 문서 예시(32세션/4GB) 그대로 뒀는데, 8GB 예산의 사무 PC에서 그게 맞는지 RSS를 하나씩 재봤다.

실측 (Qwen 4B Q4_K_M · ctx 10000, 세션을 하나씩 더하며 RSS 측정):
  모델만     3,220 MiB
  세션 1개   3,656  (+435)
  세션 2개   4,044  (+388)
  세션 3개   4,432  (+388)
  세션 4개   4,820  (+388)
→ 세션당 약 388 MiB. 종전 기본값이면 ~10세션에서 RSS 7.1GB(예산의 87%)
→ 4세션/1,600MiB로 확정 — 59% 사용, 3.4GB 여유

“브라우저 하나 띄울 자리가 남지 않는다”는 이유로 기본값을 낮춘 것이다. 두 한도(세션 수·KV 총량)가 같은 지점에서 걸리게 맞추고(388×4=1,552≤1,600), 그 성질을 테스트로 고정했다. 한쪽만 바꾸면 다른 쪽이 죽은 설정이 된다는 것까지 커밋에 적혀 있다.

단계 상한도 없앴다 — 대신 다른 안전장치로

캐시 판정 코드 — ‘앞에서부터 몇 개나 같은가’

웜 캐시 36배의 실체는 의외로 소박한 함수다. 이전 턴의 메시지 목록과 이번 턴의 목록을 앞에서부터 비교해, 같은 구간까지는 KV를 재사용하고 그 뒤만 새로 평가한다.

// providers/node-llama-cpp/src/MessageDiff.ts
const prefix = commonPrefixLength(previous, current);
if (prefix === previous.length) {
    // 이전 목록이 통째로 접두사다 — 뒤에 붙은 것만 새로 평가 (KV Hit)
    return { kind: "reuse", firstNewMessageIndex: prefix };
}
// 여기 오면 이전 목록의 prefix번째가 사라졌거나 바뀐 것 — 재생성
if (changed.role === Role.SYSTEM) {
    return { kind: "replay", reason: SessionChangeReason.SYSTEM_PROMPT_CHANGED };
}

이 코드가 말해주는 게 있다. 대화는 ‘덧붙이기만’ 할 때 가장 싸다. 중간을 고치거나 앞을 밀면 그 지점부터 전부 재평가다. 재생성 사유(시스템 프롬프트 변경, 메시지 삭제, 도구 정의 변경 등)를 enum으로 남겨서, 캐시가 깨졌을 때 왜 깨졌는지가 로그에 찍힌다 — 9편의 revert 판단도 이 로그가 근거였다.

인프로세스 vs 외부 서버 — 언제 무엇을

인프로세스(기본)외부 서버(vLLM 등)
프로세스1개 (포트 없음)2개 + HTTP
배포·반입바이너리 복사로 끝서버 별도 관리
KV 캐시프로세스와 함께 산다서버 재시작이면 소멸
GPU 공유PC 한 대 전용여럿이 서버 공유 가능

에어는 둘 다 지원하되 기본은 인프로세스다 — 폐쇄망 사무 PC의 그림이니까. 나중에 조직에 GPU 서버가 생기면 설정 한 줄(model.provider)로 외부 모드로 갈아탄다. 2편의 얇은 ChatModel 경계가 여기서 또 값을 한다.

✅ 확인

  • 내 에이전트의 프롬프트에서 매 턴 바뀌는 부분이 어디인지 안다 — 그 앞까지만 캐시가 산다.
  • air doctor처럼 컨텍스트 예산·메모리를 숫자로 보여주는 진단이 있다.
  • 메모리 기본값을 문서 예시가 아니라 실측으로 정했다 — 세션 하나 늘 때 RSS가 얼마나 느나.

같은 커밋에서 에이전트 루프의 최대 단계 수 제한(5단계)도 삭제했다. 탐색→읽기→수정→검증만 해도 5단계가 모자라서, 상한에 걸리면 그때까지의 작업을 통째로 버렸기 때문이다. 종료는 상한이 아니라 중복 호출 차단·연속 실패 상한·컨텍스트 소진이 맡는다. 그런데 이 결정이 다음 날 새로운 문제(무한 루프)를 낳는데, 그 썰은 8편에서 푼다.

자주 묻는 질문 (FAQ)

인프로세스 추론과 외부 추론 서버, 뭐가 낫나요?

단일 PC 배포라면 인프로세스가 압도적으로 단순합니다 — 실행 파일 하나, 포트 없음, 프로세스 관리 코드 없음. 여러 사용자가 GPU 서버 하나를 공유하는 구조라면 외부 서버(vLLM 등)가 맞습니다. 이 프로젝트는 둘 다 지원하되 기본을 인프로세스로 뒀습니다.

KV 캐시가 왜 그렇게 중요한가요?

에이전트 프롬프트는 시스템 프롬프트+도구 정의 수천 토큰이 매 턴 앞부분에 반복됩니다. KV 접두사 캐시가 있으면 이 부분을 재계산하지 않아 실측 36배가 났습니다. 반대로 앞부분을 조금이라도 바꾸면 캐시가 깨지므로, 컨텍스트 설계 전체가 이 제약을 중심으로 돌게 됩니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다