[AI] LLM 02-4

- LLM 02 - HuggingFace (LMStudio) - practice
- reply_text 감정 분류 파이프라인
- 고객 문의 자동응대 파이프라인 (CustomerInquiryResponder)
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 테이블 컬럼
| 컬럼 | 타입 | 설명 |
|---|---|---|
id | int auto_increment | 행 고유 번호. 값을 지정하지 않으면 MySQL이 자동으로 1씩 증가시켜 채운다. |
reply | varchar(100) | 댓글 원문. 최대 100자까지 저장 가능 (그 이상은 잘려야 하므로, 적재 코드에서 100자 초과 시 자르는 처리가 필요하다). |
good | int | 긍정 여부 플래그. 이 파이프라인에서는 긍정으로 판별되면 1, 아니면 0으로 채운다. NOT NULL 제약이 없어 분류 전에는 NULL 상태로 남아있을 수 있다. |
bad | int | 부정 여부 플래그. 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_URL | http://localhost:1234/v1/completions | LM Studio 로컬 서버의 완성형 엔드포인트 주소. |
PARALLELISM | 4 | 동시에 LM Studio로 보낼 요청 개수. LM Studio 서버 설정에서 병렬 처리(동시 슬롯)를 켜지 않으면 서버 쪽에서 결국 순차 처리되어 체감 속도 이득이 없을 수 있다. |
UPDATE_BATCH_SIZE | 500 | 이 개수만큼 분류 결과를 모은 뒤 한 번에 UPDATE를 실행하고 커밋한다. 매 행마다 커밋하면 DB 왕복 오버헤드가 커지므로 배치로 묶어 줄인다. |
전체 흐름 (main → processUnclassifiedReplies)
- 환경변수/접속 정보로 MySQL 연결:
DB_HOST,DB_PORT는 환경변수(기본값localhost/3306)에서 읽고,dbName/user/password는 현재 코드에 직접 값이 들어있다 (로컬 테스트용, 운영 전에는 환경변수로 되돌리는 것을 권장). - 대상 행 조회 (
processUnclassifiedReplies1단계):SELECT id, reply FROM reply_text WHERE good IS NULL AND bad IS NULL로 아직 분류되지 않은 행만 골라List<Object[]>로 메모리에 적재한다.ResultSet을 열어둔 채로 오래 걸리는 LLM 호출을 하지 않기 위해, 조회를 먼저 끝내고 커넥션 점유를 최소화한다. - LLM 병렬 호출 (2단계):
ExecutorService(스레드PARALLELISM개)에 댓글마다 하나씩 분류 작업(Callable<int[]>)을 제출한다. 각 작업은classifyReply를 호출해"good"/"bad"/null을 얻고,null(판별 불가)이면 건너뛴다는 로그만 남기고 결과에서 제외한다. 순차 호출 시 한 요청씩 기다리며 지연이 그대로 누적되던 것을, 여러 요청을 동시에 보내 대기 시간을 겹치게 만든다. - 배치 UPDATE (3단계): 모든
Future의 결과를 모아UPDATE reply_text SET good = ?, bad = ? WHERE id = ?를addBatch()로 쌓다가,UPDATE_BATCH_SIZE(500)건마다executeBatch()+commit()을 실행하고"N replies classified"를 출력한다. 루프가 끝난 뒤 남은 나머지도 마지막에 한 번 더executeBatch()+commit()+ 로그 출력으로 마무리한다.
댓글 하나를 분류하는 과정 (classifyReply)
buildInstructionPrompt(reply)로 학습 때와 동일한### 지시문: / ### 입력: / ### 응답:템플릿 문자열을 만든다 (개행 문자를 그대로 유지 — 학습 형식과 어긋나면 모델이 제대로 답하지 못한다).buildCompletionRequestBody(MODEL, prompt)로/v1/completions가 요구하는 JSON({model, prompt, temperature, max_tokens})을 만든다.temperature: 0.0— 같은 입력에 항상 같은 결과가 나오도록 무작위성을 제거.max_tokens: 6— “긍정적인”/“부정적인”이라는 단어가 응답 맨 앞에 나오므로, 판별에 필요한 최소한의 토큰만 생성하게 해 응답 시간을 줄인다.
LMStudioRestClient.sendRequest(requestBody, COMPLETIONS_URL)로 HTTP POST 요청을 보내고,LMStudioRestClient.extractCompletionText(...)로 응답 JSON에서 생성된 텍스트만 뽑아낸다.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 설정


고객 문의 자동응대 파이프라인 (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_no는 5가 된다.
2. CustomerInquiryResponder.java
주요 상수
| 상수 | 값 | 의미 |
|---|---|---|
MODEL | "google/gemma-4-12b-qat" | LM Studio에 로드된 채팅형 모델 식별자. Developer 탭의 모델 목록 이름과 일치해야 한다. |
SYSTEM_PROMPT | (아래 참고) | 모델에게 “컴퓨터회사 고객응대 담당자” 역할과 확인해야 할 항목(연락처, 모델명/구입처)을 지시하는 고정 프롬프트. |
private static final String SYSTEM_PROMPT = """
너는 컴퓨터회사의 고객응대 부서의 담당자야
고객에게 연락할 전화번호가 있는지 확인하고 없으면 전화번호를 비밀댓글로 남겨달라고 해
노트북이나 컴퓨터 모델명과 구입처도 있는지 확인하고 없으면 보내달라고 해
고객의 건의사항에 친절하게 응대하여야 해
다음 문장을 보고 응답을 해줘
""";
전체 흐름 (main → respondToNewInquiries)
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 + " 답변 삽입 완료");
}
}
}
- 대상 선정:
origin_no = 0(원글)이면서,origin_no가 자기id를 가리키는 행(=이미 달린 답변)이 하나도 없는 글만 골라온다.NOT EXISTS조건이 없으면 재실행할 때마다 이미 답변한 글에 또 답변을 달게 된다. - 답변 생성: 원글
content를generateReply에 넘겨 LLM 답변을 받는다. - 빈 답변 방어:
answer가 비어있으면(null또는 공백) DB에 넣지 않고 건너뛴다. 아래 4절에서 설명하듯, 이 모델은 답변 앞에 내부적으로 “생각(reasoning)”을 하는데,max_tokens가 부족하면 생각만 하다 끝나 최종 답변이 빈 문자열로 나오는 경우가 있었다. 건너뛴 글은NOT EXISTS조건 덕분에 다음 실행 때 자동으로 재시도된다. - 삽입:
origin_no에 원글id,write_date에NOW(),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.extractContent는 message.content만 읽으므로, 이 상태에선 항상 빈 답변을 돌려준다. max_tokens를 1024로 늘려 생각과 답변을 모두 쓸 수 있는 여유를 준 것이 이 문제의 해결책이고, 3절의 빈 답변 방어 코드는 그래도 부족할 경우를 대비한 안전장치다.
3. LMStudioRestClient.java에서 참조하는 함수
CustomerInquiryResponder는 LMStudioRestClient의 함수 세 개를 그대로 재사용한다 (원래 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 상수와 동일한 이름의 모델이 로드되어 있어야 한다.
