[AI] LLM 02-4


HuggingFace

LLM 02 - HuggingFace (LMStudio) - practice

Created: August 24, 2026 2:31 PM Class: LLM

reply_text 감정 분류 파이프라인

NSMC(네이버 영화 리뷰) 댓글을 MySQL에 저장해두고, LM Studio에 로드한 로컬 LLM(EXAONE 기반 LoRA 파인튜닝 모델)으로 각 댓글의 감정(긍정/부정)을 판별해 good/bad 컬럼을 채우는 파이프라인입니다.


1. SQL 스크립트

create database if not exists ai;
use ai;
show tables;

drop table if exists reply_text;
create table reply_text(
    id int auto_increment,
    reply varchar(100),
    good int,
    bad int,
    primary key(id)
);
구문설명
create database if not exists ai;ai라는 이름의 데이터베이스(스키마)를 생성한다. 이미 존재하면 아무 동작도 하지 않아, 스크립트를 여러 번 실행해도 에러가 나지 않는다.
use ai;이후 모든 명령이 ai 데이터베이스를 대상으로 실행되도록 현재 세션의 기본 스키마를 전환한다.
show tables;현재 선택된 데이터베이스(ai) 안에 어떤 테이블이 있는지 확인하는 조회용 명령. 실행 결과가 이후 로직에 영향을 주지는 않는다.
drop table if exists reply_text;reply_text 테이블이 이미 있으면 완전히 삭제한다. 스크립트를 재실행해 테이블을 처음부터 다시 만들 때 “테이블이 이미 존재합니다” 에러를 피하기 위한 방어 코드다. 주의: 기존 데이터가 전부 사라진다.
create table reply_text(...)댓글 데이터를 저장할 테이블을 새로 만든다.

reply_text 테이블 컬럼

컬럼타입설명
idint auto_increment행 고유 번호. 값을 지정하지 않으면 MySQL이 자동으로 1씩 증가시켜 채운다.
replyvarchar(100)댓글 원문. 최대 100자까지 저장 가능 (그 이상은 잘려야 하므로, 적재 코드에서 100자 초과 시 자르는 처리가 필요하다).
goodint긍정 여부 플래그. 이 파이프라인에서는 긍정으로 판별되면 1, 아니면 0으로 채운다. NOT NULL 제약이 없어 분류 전에는 NULL 상태로 남아있을 수 있다.
badint부정 여부 플래그. good과 반대로 채워진다 (부정이면 1, 아니면 0).
primary key(id)id를 기본키로 지정.

good/bad가 하나의 label 컬럼이 아니라 두 개로 분리되어 있는 이유는, 이 테이블 설계가 “긍정 개수/부정 개수”처럼 집계 가능한 카운트 컬럼 형태를 의도했기 때문이다. 이 프로젝트에서는 한 댓글당 정확히 한쪽만 1이 되도록 사용한다.


2. ReplySentimentUpdater.java

목적

reply_text 테이블에서 아직 감정이 채워지지 않은(good IS NULL AND bad IS NULL) 댓글을 읽어, LM Studio에 로드된 로컬 LLM에게 감정을 물어본 뒤 결과를 다시 DB에 반영한다.

/v1/chat/completions가 아니라 /v1/completions를 쓰는가

이 프로젝트의 모델은 Python 학습 스크립트에서 SFTTrainer로 파인튜닝됐는데, 이때 학습 데이터는 messages(system/user/assistant) 형태의 채팅 템플릿이 아니라 다음과 같은 순수 텍스트 완성(completion) 형식이었다.

### 지시문:
다음 영화 리뷰의 감성을 분석해줘

### 입력:
{리뷰 텍스트}

### 응답:
{긍정적인 리뷰입니다. / 부정적인 리뷰입니다.}

모델이 실제로 학습한 형식과 다른 방식(채팅 템플릿)으로 질문하면 판별 정확도가 떨어지므로, 추론도 학습과 동일하게 /v1/completions 엔드포인트에 같은 템플릿의 프롬프트를 그대로 보낸다. (LMStudioRestClient.java의 기존 /v1/chat/completions 호출 로직은 다른 데모용 코드에서 그대로 쓰이고 있어 손대지 않았다.)

주요 상수

상수값/기본값의미
MODEL"exaone-nsmc-lora-blueholez"LM Studio에 요청을 보낼 때 지정하는 모델 식별자. LM Studio Developer 탭의 모델 목록에 표시된 이름과 정확히 일치해야 한다. 다르면 요청이 실패한다.
COMPLETIONS_URLhttp://localhost:1234/v1/completionsLM Studio 로컬 서버의 완성형 엔드포인트 주소.
PARALLELISM4동시에 LM Studio로 보낼 요청 개수. LM Studio 서버 설정에서 병렬 처리(동시 슬롯)를 켜지 않으면 서버 쪽에서 결국 순차 처리되어 체감 속도 이득이 없을 수 있다.
UPDATE_BATCH_SIZE500이 개수만큼 분류 결과를 모은 뒤 한 번에 UPDATE를 실행하고 커밋한다. 매 행마다 커밋하면 DB 왕복 오버헤드가 커지므로 배치로 묶어 줄인다.

전체 흐름 (mainprocessUnclassifiedReplies)

  1. 환경변수/접속 정보로 MySQL 연결: DB_HOST, DB_PORT는 환경변수(기본값 localhost/3306)에서 읽고, dbName/user/password는 현재 코드에 직접 값이 들어있다 (로컬 테스트용, 운영 전에는 환경변수로 되돌리는 것을 권장).
  2. 대상 행 조회 (processUnclassifiedReplies 1단계): SELECT id, reply FROM reply_text WHERE good IS NULL AND bad IS NULL로 아직 분류되지 않은 행만 골라 List<Object[]>로 메모리에 적재한다. ResultSet을 열어둔 채로 오래 걸리는 LLM 호출을 하지 않기 위해, 조회를 먼저 끝내고 커넥션 점유를 최소화한다.
  3. LLM 병렬 호출 (2단계): ExecutorService(스레드 PARALLELISM개)에 댓글마다 하나씩 분류 작업(Callable<int[]>)을 제출한다. 각 작업은 classifyReply를 호출해 "good"/"bad"/null을 얻고, null(판별 불가)이면 건너뛴다는 로그만 남기고 결과에서 제외한다. 순차 호출 시 한 요청씩 기다리며 지연이 그대로 누적되던 것을, 여러 요청을 동시에 보내 대기 시간을 겹치게 만든다.
  4. 배치 UPDATE (3단계): 모든 Future의 결과를 모아 UPDATE reply_text SET good = ?, bad = ? WHERE id = ?addBatch()로 쌓다가, UPDATE_BATCH_SIZE(500)건마다 executeBatch() + commit()을 실행하고 "N replies classified"를 출력한다. 루프가 끝난 뒤 남은 나머지도 마지막에 한 번 더 executeBatch() + commit() + 로그 출력으로 마무리한다.

댓글 하나를 분류하는 과정 (classifyReply)

  1. buildInstructionPrompt(reply)로 학습 때와 동일한 ### 지시문: / ### 입력: / ### 응답: 템플릿 문자열을 만든다 (개행 문자를 그대로 유지 — 학습 형식과 어긋나면 모델이 제대로 답하지 못한다).
  2. buildCompletionRequestBody(MODEL, prompt)/v1/completions가 요구하는 JSON({model, prompt, temperature, max_tokens})을 만든다.
    • temperature: 0.0 — 같은 입력에 항상 같은 결과가 나오도록 무작위성을 제거.
    • max_tokens: 6 — “긍정적인”/“부정적인”이라는 단어가 응답 맨 앞에 나오므로, 판별에 필요한 최소한의 토큰만 생성하게 해 응답 시간을 줄인다.
  3. LMStudioRestClient.sendRequest(requestBody, COMPLETIONS_URL)로 HTTP POST 요청을 보내고, LMStudioRestClient.extractCompletionText(...)로 응답 JSON에서 생성된 텍스트만 뽑아낸다.
  4. parseSentiment(content)로 최종 판별.

응답 해석 (parseSentiment)

순수 함수로, DB나 네트워크 없이 문자열만 보고 판별한다 (그래서 ReplySentimentUpdaterTest.java에서 별도 테스트가 가능하다).

  • 응답에 "긍정"(또는 영어 "positive", 대소문자 무관)이 포함되면 → "good"
  • 응답에 "부정"(또는 영어 "negative")이 포함되면 → "bad"
  • 둘 다 아니면 → null (판별 불가로 간주하고 해당 행은 이번 실행에서 건너뛰며, good/bad가 계속 NULL로 남으므로 다음 실행 때 다시 시도된다)

실행 방법

mvn compile
java -cp target/classes ReplySentimentUpdater

사전 조건: LM Studio Developer 탭에서 서버가 켜져 있고, MODEL 상수와 동일한 이름의 파인튜닝 모델이 로드되어 있어야 한다.


3. LMStudio 설정

llm03

llm04


고객 문의 자동응대 파이프라인 (CustomerInquiryResponder)

reply_test2 게시판에서 아직 답변이 안 달린 고객 문의를 찾아 LM Studio의 채팅형 모델(google/gemma-4-12b-qat)로 고객응대 답변을 생성하고, 그 답변을 댓글로 DB에 삽입하는 파이프라인이다.

1. 대상 테이블 (reply_test2)

create table reply_test2( # 고객 문의 게시판
    id int auto_increment not null,
    origin_no int,
    write_date datetime,
    content text,
    primary key(id)
);

원글과 댓글이 같은 테이블에 섞여 있는 구조다. 고객이 처음 올린 글(원글)은 origin_no = 0으로 저장되고, 그 글에 달리는 댓글은 origin_no에 원글의 id를 넣어 서로 연결한다. 예를 들어 id = 5인 원글에 답변을 달면, 그 답변 행의 origin_no5가 된다.

2. CustomerInquiryResponder.java

주요 상수

상수의미
MODEL"google/gemma-4-12b-qat"LM Studio에 로드된 채팅형 모델 식별자. Developer 탭의 모델 목록 이름과 일치해야 한다.
SYSTEM_PROMPT(아래 참고)모델에게 “컴퓨터회사 고객응대 담당자” 역할과 확인해야 할 항목(연락처, 모델명/구입처)을 지시하는 고정 프롬프트.
private static final String SYSTEM_PROMPT = """
        너는 컴퓨터회사의 고객응대 부서의 담당자야
        고객에게 연락할 전화번호가 있는지 확인하고 없으면 전화번호를 비밀댓글로 남겨달라고 해
        노트북이나 컴퓨터 모델명과 구입처도 있는지 확인하고 없으면 보내달라고 해
        고객의 건의사항에 친절하게 응대하여야 해
        다음 문장을 보고 응답을 해줘
        """;

전체 흐름 (mainrespondToNewInquiries)

static void respondToNewInquiries(Connection conn) throws Exception {
    String selectSql =
            "SELECT t1.id, t1.content FROM reply_test2 t1 " +
            "WHERE t1.origin_no = 0 " +
            "AND NOT EXISTS (SELECT 1 FROM reply_test2 t2 WHERE t2.origin_no = t1.id)";
    String insertSql = "INSERT INTO reply_test2 (origin_no, write_date, content) VALUES (?, NOW(), ?)";

    try (PreparedStatement selectStmt = conn.prepareStatement(selectSql);
         ResultSet rs = selectStmt.executeQuery();
         PreparedStatement insertStmt = conn.prepareStatement(insertSql)) {

        while (rs.next()) {
            int originId = rs.getInt("id");
            String content = rs.getString("content");

            String answer = generateReply(content);

            if (answer == null || answer.isBlank()) {
                System.out.println("origin_no=" + originId + " 답변이 비어있어 건너뜀 (max_tokens 부족 가능성)");
                continue;
            }

            insertStmt.setInt(1, originId);
            insertStmt.setString(2, answer);
            insertStmt.executeUpdate();
            System.out.println("origin_no=" + originId + " 답변 삽입 완료");
        }
    }
}
  1. 대상 선정: origin_no = 0(원글)이면서, origin_no가 자기 id를 가리키는 행(=이미 달린 답변)이 하나도 없는 글만 골라온다. NOT EXISTS 조건이 없으면 재실행할 때마다 이미 답변한 글에 또 답변을 달게 된다.
  2. 답변 생성: 원글 contentgenerateReply에 넘겨 LLM 답변을 받는다.
  3. 빈 답변 방어: answer가 비어있으면(null 또는 공백) DB에 넣지 않고 건너뛴다. 아래 4절에서 설명하듯, 이 모델은 답변 앞에 내부적으로 “생각(reasoning)”을 하는데, max_tokens가 부족하면 생각만 하다 끝나 최종 답변이 빈 문자열로 나오는 경우가 있었다. 건너뛴 글은 NOT EXISTS 조건 덕분에 다음 실행 때 자동으로 재시도된다.
  4. 삽입: origin_no에 원글 id, write_dateNOW(), content에 답변 텍스트를 넣어 새 댓글 행을 만든다.

답변 생성 (generateReply)

static String generateReply(String customerContent) throws Exception {
    String requestBody = LMStudioRestClient.buildRequestBody(
            MODEL, SYSTEM_PROMPT, "", customerContent,
            0.7, 1024, 1.0, 0.0, 0.0
    );
    String responseBody = LMStudioRestClient.sendRequest(requestBody);
    return LMStudioRestClient.extractContent(responseBody);
}
  • SYSTEM_PROMPT는 역할/지시사항, 세 번째 인자(빈 문자열 "")는 assistant 참고 프롬프트(안 씀), customerContent는 고객이 쓴 원글 내용을 user 프롬프트로 넣는다.
  • temperature: 0.7은 어느 정도 자연스러운 표현이 나오도록 약간의 무작위성을 허용한 값이다 (ReplySentimentUpdater의 감정 판별처럼 항상 같은 답이 나와야 하는 경우가 아니므로 0.0을 쓰지 않았다).
  • max_tokens: 1024는 아래 4절에서 설명하는 이유로 넉넉하게 잡았다.

max_tokens가 1024까지 필요한가

google/gemma-4-12b-qat은 최종 답변(content)을 쓰기 전에 내부적으로 reasoning_content라는 별도 필드에 “생각 과정”을 먼저 채우는 방식으로 동작한다. 실제로 LM Studio 로그에서 다음과 같은 응답이 확인됐다.

{
  "choices": [{
    "message": {
      "content": "",
      "reasoning_content": "Customer Service Representative for a computer company...(중략, 253토큰)"
    },
    "finish_reason": "length"
  }],
  "usage": {
    "completion_tokens": 256,
    "completion_tokens_details": { "reasoning_tokens": 253 }
  }
}

max_tokens를 256으로 제한했을 때, 생성한 256토큰 중 253토큰이 “생각”에 소모되고 3토큰만 남아 최종 답변을 한 글자도 쓰지 못한 채 finish_reason: "length"(토큰 한도 도달)로 잘렸다. 그 결과 content가 빈 문자열로 반환됐다. LMStudioRestClient.extractContentmessage.content만 읽으므로, 이 상태에선 항상 빈 답변을 돌려준다. max_tokens를 1024로 늘려 생각과 답변을 모두 쓸 수 있는 여유를 준 것이 이 문제의 해결책이고, 3절의 빈 답변 방어 코드는 그래도 부족할 경우를 대비한 안전장치다.

3. LMStudioRestClient.java에서 참조하는 함수

CustomerInquiryResponderLMStudioRestClient의 함수 세 개를 그대로 재사용한다 (원래 private이었던 것을 같은 패키지에서 호출 가능하도록 접근 제한자만 없앤 것이고, 내부 로직은 그대로다).

buildRequestBody(...) — 요청 JSON 조립

@SuppressWarnings("unchecked")
static String buildRequestBody(
        String model,
        String systemPrompt, String assistantPrompt, String userPrompt,
        double temperature, int maxTokens, double topP,
        double frequencyPenalty, double presencePenalty
) {
    JSONObject root = new JSONObject();
    root.put("model", model);

    JSONArray messages = new JSONArray();
    messages.add(buildMessage("system", reqData(systemPrompt)));
    messages.add(buildMessage("assistant", reqData(assistantPrompt)));
    messages.add(buildMessage("user", reqData(userPrompt)));
    root.put("messages", messages);

    root.put("temperature", temperature);
    root.put("max_tokens", maxTokens);
    root.put("top_p", topP);
    root.put("frequency_penalty", frequencyPenalty);
    root.put("presence_penalty", presencePenalty);

    return root.toJSONString();
}

model, system/assistant/user 세 프롬프트, 생성 옵션값들을 받아 LM Studio(OpenAI 호환 Chat Completions API)가 요구하는 형식의 JSON 문자열을 만든다. 세 프롬프트는 내부적으로 reqData()를 거치는데, 이 함수는 여러 줄로 편하게 쓴 문자열의 줄바꿈을 공백으로 바꿔 한 줄로 합친다 (JSON 문자열 안에 실제 개행 문자가 들어가는 걸 피하기 위함). 각 역할(role)-내용(content) 쌍은 buildMessage(){"role": ..., "content": ...} 형태의 JSON 객체로 만든다.

sendRequest(...) — 실제 HTTP 요청 전송

static String sendRequest(String requestBody) throws Exception {
    return sendRequest(requestBody, URL_ADDR);
}

static String sendRequest(String requestBody, String urlAddr) throws Exception {
    // HttpURLConnection으로 POST 요청을 보내고 응답 body를 문자열로 읽어 반환
    // 200이 아니면 에러코드/메시지로 문자열을 구성, 네트워크 예외(타임아웃 등)는 그대로 다시 던짐(throw ex;)
    // finally에서 스트림과 커넥션을 정리
}

buildRequestBody가 만든 JSON을 HttpURLConnection으로 LM Studio에 POST 요청으로 보내고, 응답 body(JSON 문자열)를 그대로 반환한다. CustomerInquiryResponder는 인자 하나짜리 sendRequest(requestBody)를 쓰는데, 이건 내부적으로 기본 주소(URL_ADDR = http://localhost:1234/v1/chat/completions)를 채워 두 인자짜리 버전을 호출하는 것뿐이다 (URL을 직접 지정하는 두 인자 버전은 /v1/completions를 쓰는 ReplySentimentUpdater 쪽에서 사용한다). 네트워크 오류가 나면 예외를 삼키지 않고 그대로 다시 던지도록 되어 있어(throw ex;), 타임아웃 같은 문제가 나면 원인이 그대로 드러난다.

extractContent(...) — 응답에서 답변 텍스트 추출

static String extractContent(String responseBody) throws Exception {
    JSONParser parser = new JSONParser();
    JSONObject root = (JSONObject) parser.parse(responseBody);
    JSONArray choices = (JSONArray) root.get("choices");
    JSONObject firstChoice = (JSONObject) choices.get(0);
    JSONObject message = (JSONObject) firstChoice.get("message");
    return (String) message.get("content");
}

sendRequest가 받아온 JSON 문자열 전체에서 choices[0].message.content 경로를 따라가 실제 모델 답변 텍스트만 꺼낸다. 2절에서 설명했듯, 이 함수는 content만 읽고 reasoning_content는 보지 않기 때문에, 모델이 생각만 하다 끝나면 빈 문자열을 돌려준다는 점이 이번에 확인된 특징이다.

4. 실행 방법

mvn compile
java -cp target/classes CustomerInquiryResponder

사전 조건: LM Studio Developer 탭에서 서버가 켜져 있고 MODEL 상수와 동일한 이름의 모델이 로드되어 있어야 한다.







© 2017. by isme2n

Powered by aiden