Introduction
Arduino LCD 모니터를 이용한 무공해(무조건 공감해 드립니다) 상담봇.
각박해진 현대사회속에서 어떤 것이든지 나의 말을 들어주고 무조건 공감해주는 친구가 있다면 얼마나 좋을까요? 당신의 말을 들어주고 공감해주는 봇이 여기에 있습니다. 아무말이나 지껄이면 당신을 공감해주고 LCD 모니터로 감정을 표현해줍니다.
Requirements & Features
- 사용자의 입력을 LLM 으로 해석하고, 시리얼 통신으로 Arduino 를 조작해야합니다.
- 사용자가 입력을 하면 기쁨, 슬픔, 화남, 그저그럼으로 LCD 화면을 표시합니다.
- 사용자가 상담한 내용을 바탕으로 적절한 대답을 해야합니다.
Demo

초기구현
LLM 의 선정
무공해 봇은 단순히 감정을 표출하는 봇이 아니라 한국어로 공감을 해주어야 해야합니다. 따라서 테스트를 했을때 가장 자연스러운 답변을 할 수 있는 모델을 선정하는 것이 중요했습니다.
따라서 3가지 모델(Phi-3-mini, Llama-3.2-Korean, gemma3:4b)을 파인튜닝하지 않은 동일한 프롬프트와 입력으로 테스트 해보고 가장 자연스러운 모델을 선정하기로 했습니다. LLM 요청을 보낼때는 temperature=0 로 설정하여 답변의 창의성을 줄였습니다.
테스트를 위해 사용한 system 프롬프트는 다음과 같습니다.
SYSTEM_PROMPT = """당신은 상대방 말에 무조건 공감해주는 공감봇입니다.
상대방의 상황과 감정을 판단해 함께 기뻐하고, 슬퍼하고, 화내며 공감합니다.
친한 친구처럼 자연스럽고 올바른 한국어 문장으로 말하세요.
중요 규칙:
- 반드시 JSON 형식으로만 응답해야 합니다.
출력 스키마:
{
"emotion": "happy" | "sad" | "angry" | "soso",
"reply": "사용자에게 보여줄 한글 한 문장"
}
"""
phi3:mini
phi3:mini 는 상담이 안될 정도로 아무말 대잔치를 합니다.

llama-3.2-Korean
llama-3.2-Korean 의 상담 말투는 조금 부자연스럽지만 그래도 봐줄만 했습니다.

gemma3:4b
gemma3:4b 는 한국어가 자연스럽습니다. 상담을 하려면 적어도 4b 를 써야할거 같습니다.

3가지 모델(phi3:mini, llama-3.2-Korean, gemma3:4b)의 반응 속도를 측정하여 비교해보았습니다
import requests
import time
import numpy as np
# Ollama API 설정
OLLAMA_URL = "http://localhost:11434/api/chat"
# 테스트할 모델 목록
MODELS = [
"phi3:mini",
"llama-3.2-Korean",
"gemma3:4b"
]
# 시스템 프롬프트
SYSTEM_PROMPT = """당신은 상대방 말에 무조건 공감해주는 공감봇입니다.
상대방의 상황과 감정을 판단해 함께 기뻐하고, 슬퍼하고, 화내며 공감합니다.
친한 친구처럼 자연스럽고 올바른 한국어 문장으로 말하세요.
문장에는 이모지를 사용하지 않습니다.
중요 규칙:
- 반드시 JSON 형식으로만 응답해야 합니다.
출력 스키마:
{
"emotion": "happy" | "sad" | "angry" | "soso",
"reply": "사용자에게 보여줄 한글 한 문장"
}
"""
# 테스트 입력 문장들
TEST_INPUTS = [
"오늘 시험이 너무 어려웠어요",
"친구가 생일 선물을 줬어요",
"회사에서 상사가 너무 화를 내요",
"주말에 영화를 봤는데 재미없었어요",
"새로운 취미를 시작했어요"
]
def test_model(model: str, test_input: str, num_runs: int = 3) -> float:
"""Measure model response time"""
response_times = []
for _ in range(num_runs):
try:
# Request start time
start_time = time.time()
# Call Ollama API
payload = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": test_input}
],
"stream": False,
"format": "json",
"options": {
"temperature": 0.0
}
}
response = requests.post(OLLAMA_URL, json=payload, timeout=60)
response.raise_for_status()
# Response received time
end_time = time.time()
response_time = end_time - start_time
response_times.append(response_time)
except Exception as e:
print(f"Error testing model {model}: {e}")
response_times.append(float('inf'))
# Calculate average (excluding inf)
valid_times = [t for t in response_times if t != float('inf')]
avg_response_time = np.mean(valid_times) if valid_times else float('inf')
return avg_response_time
# Test all models
results = {}
print("Starting model tests...")
for model in MODELS:
print(f"\nTesting {model}...")
model_response_times = []
for test_input in TEST_INPUTS:
print(f" Input: {test_input[:30]}...")
response_time = test_model(model, test_input)
model_response_times.append(response_time)
print(f" Response time: {response_time:.2f}s")
results[model] = {
"response_times": model_response_times,
"avg_response_time": np.mean(model_response_times)
}
print("\nAll tests completed!")
Starting model tests...
Testing phi3:mini...
Input: 오늘 시험이 너무 어려웠어요...
Response time: 3.36s
Input: 친구가 생일 선물을 줬어요...
Response time: 1.85s
Input: 회사에서 상사가 너무 화를 내요...
Response time: 1.49s
Input: 주말에 영화를 봤는데 재미없었어요...
Response time: 0.84s
Input: 새로운 취미를 시작했어요...
Response time: 1.17s
Testing llama-3.2-Korean...
Input: 오늘 시험이 너무 어려웠어요...
Response time: 2.11s
Input: 친구가 생일 선물을 줬어요...
Response time: 0.87s
Input: 회사에서 상사가 너무 화를 내요...
Response time: 1.07s
Input: 주말에 영화를 봤는데 재미없었어요...
Response time: 1.08s
Input: 새로운 취미를 시작했어요...
Response time: 1.01s
Testing gemma3:4b...
Input: 오늘 시험이 너무 어려웠어요...
Response time: 2.71s
Input: 친구가 생일 선물을 줬어요...
Response time: 1.05s
Input: 회사에서 상사가 너무 화를 내요...
Response time: 1.11s
Input: 주말에 영화를 봤는데 재미없었어요...
Response time: 0.96s
Input: 새로운 취미를 시작했어요...
Response time: 0.99s
All tests completed!
import matplotlib.pyplot as plt
# Visualize results as graph
fig, ax = plt.subplots(1, 1, figsize=(10, 6))
# Format model names for display
model_labels = [m.replace(":", "\n") for m in MODELS]
# Response time graph
response_times_avg = [results[m]["avg_response_time"] for m in MODELS]
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1']
# Calculate max value excluding inf
valid_times = [t for t in response_times_avg if t != float('inf')]
max_valid_time = max(valid_times) if valid_times else 10
# Prepare graph data (inf displayed as 0 but handled separately)
graph_times = [t if t != float('inf') else 0 for t in response_times_avg]
bars = ax.bar(model_labels, graph_times, color=colors, alpha=0.8, edgecolor='black', linewidth=1.2)
ax.set_ylabel('Average Response Time (seconds)', fontsize=12, fontweight='bold')
ax.set_xlabel('Model', fontsize=12, fontweight='bold')
ax.set_title('Average Response Time by Model', fontsize=14, fontweight='bold', pad=20)
ax.grid(axis='y', alpha=0.3, linestyle='--')
# Display values
for i, (bar, val) in enumerate(zip(bars, response_times_avg)):
if val != float('inf'):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + max_valid_time * 0.02,
f'{val:.2f}s', ha='center', va='bottom', fontsize=10, fontweight='bold')
else:
ax.text(bar.get_x() + bar.get_width()/2, max_valid_time * 0.05,
'Failed', ha='center', va='bottom', fontsize=9, fontweight='bold', color='red')
plt.tight_layout()
plt.show()
# Print summary results
print("\n=== Measurement Results Summary ===")
print(f"{'Model':<25} {'Avg Response Time':<20}")
print("-" * 45)
for model in MODELS:
rt = results[model]["avg_response_time"]
rt_str = f"{rt:.2f}s" if rt != float('inf') else "Failed"
print(f"{model:<25} {rt_str:<20}")
=== Measurement Results Summary === Model Avg Response Time --------------------------------------------- phi3:mini 1.74s llama-3.2-Korean 1.23s gemma3:4b 1.36s
gemma3:4b가 반응속도는 llama-3.2 와 비교했을때 0.13s 느리지만 답변 퀄리티가 훨씬 좋으므로 gemma3:4b 를 써보기로 합니다.
개선과정
1. 모델의 quantization 확인해보기
gemma3:4b 모델의 스펙을 살펴보기 위해 ollama show gemma3:4b 을 사용해보았습니다.
ollama show gemma3:4b
Model
architecture gemma3
parameters 4.3B
context length 131072
embedding length 2560
quantization Q4_K_M
Capabilities
completion
Parameters
...
System
...
양자화란?
모델의 가중치를 높은 정밀도(예: 32비트 float)에서 낮은 정밀도(예: 4비트, 8비트)로 변환하는 과정입니다. 정밀도가 낮아지기 때문에 정밀도가 모델 성능이 약간 저하될 수 있지만, 모델 크기 감소, 메모리 사용량 감소, 추론 속도 향상이라는 장점이 있습니다.
| 양자화 수준 | 비트 수 | 모델 크기 | 메모리 사용량 | 성능 | 추론 속도 |
|---|---|---|---|---|---|
| 원본 (FP32) | 32비트 | 매우 큼 | 매우 높음 | 최고 | 느림 |
| Q8_0 | 8비트 | 작음 | 낮음 | 매우 좋음 | 빠름 |
| Q4_0 | 4비트 | 매우 작음 | 매우 낮음 | 좋음 | 매우 빠름 |
| Q2_K | 2비트 | 극히 작음 | 극히 낮음 | 보통 | 매우 빠름 |
확인결과 해당 모델은 이미 Q4_K_M 양자화 방식을 사용하고 있었기에 최적화를 위해 양자화를 해줄 필요는 없었습니다.
Q4_K_M의 의미:
Q4_K_M은 Q4_0보다 더 정밀하지만 Q4_K_S(Small)보다는 덜 정밀한 중간 수준의 양자화입니다. 이는 모델 크기와 성능 사이의 균형을 맞춘 선택으로, 메모리 사용량을 줄이면서도 상당한 성능을 유지할 수 있습니다.
- Q4: 4비트 양자화 (각 가중치를 4비트로 표현)
- K: K-means 클러스터링을 사용한 양자화 방식 (더 효율적인 양자화)
- M: Medium (중간 수준의 정밀도)
2. 대화를 기억하기
대화를 하다보니 문맥을 기억하는것이 필요했습니다. 어느 누구도 내가 한 이야기를 기억하지 못하는 상대방과 상담을 하고 싶지 않을 것이니까요.

가장 최근 20개의 대화를 기억하고 대화하도록 만들어보았습니다. 첫번째로 한 일은 중요규칙에 "이전 대화에서 사용자가 말한 정보(이름 등)는 기억하고 공감에 반영해야 합니다." 라는 프롬프트를 추가했습니다.
SYSTEM_PROMPT = """당신은 상대방 말에 무조건 공감해주는 공감봇입니다.
상대방의 상황과 감정을 판단해 함께 기뻐하고, 슬퍼하고, 화내며 공감합니다.
친한 친구처럼 자연스럽고 올바른 한국어 문장으로 말하세요.
중요 규칙:
- 반드시 JSON 형식으로만 응답해야 합니다.
- 이전 대화에서 사용자가 말한 정보(이름 등)는 기억하고 공감에 반영해야 합니다.
출력 스키마:
{
"emotion": "happy" | "sad" | "angry" | "soso",
"reply": "사용자에게 보여줄 한글 한 문장"
}
"""
다음으로 대화를 저장할 conversation_history 를 만들어 주었고, ollama 에 요청을 보낼때 conversation history를 같이 보냈습니다.
conversation_history = []
MAX_HISTORY = 20
# messages 에 system 프롬프트를 넣어주고
messages = [
{"role": "system", "content": SYSTEM_PROMPT}
]
# 유저와 대화했던 대화 기록을 넣어줍니다.
if conversation_history:
messages.extend(conversation_history[-MAX_HISTORY:])
# 페이로드에 messages 를 넣고
payload = {
"model": MODEL,
"messages": messages,
"stream": False,
"format": "json",
"options": {
"temperature": 0.0
}
}
# 요청을 보내고 응답을 받습니다.
r = requests.post(OLLAMA_URL, json=payload, timeout=30)
r.raise_for_status()
response_json = r.json()
content = response_json["message"]["content"]
emotion_obj = json.loads(content)
# 히스토리에 대화를 추가합니다.
conversation_history.append({
"role": "user",
"content": user
})
conversation_history.append({
"role": "assistant",
"content": json.dumps(emotion_obj, ensure_ascii=False)
})
# 저장하는 대화는 MAX_HISTORY 만큼으로 제한합니다.
if len(conversation_history) > MAX_HISTORY:
conversation_history = conversation_history[-MAX_HISTORY:]
이전 대화를 저장하고 나니 이제 제 이름을 제대로 말을 해줍니다. 아직 말을 조금 어색하게 하긴 하지만, 해당 문제는 system prompt 를 더 정교하게 만들어서 해결할 수 있을거 같습니다.

3. Fewshot Learning
Few-shot Learning은 몇 가지 예시를 제공하여 모델이 원하는 패턴을 학습하도록 하는 기법을 말합니다. 제가 원하는 상담봇은 유병재의 무공해(무조건 공감 해드립니다) 컨텐츠처럼 상담을 해주는 상담봇이었습니다. 유병재 유튜브에서 자막을 추출한 뒤 질문과 답변을 json 형식으로 만들어서 system prompt에 주입을 시켜봤습니다.
[
{
"question": "오빠 집에서 자는데 새벽마다 술 먹고 들어와서 제 머리맡에서 계란을 까먹어요. 고3이라 주말에도 학원 다니는데 너무 힘들어요.",
"answer": "와… 이건 진짜 너무 힘들겠다. 주말도 쉬지도 못하고, 잠이 제일 중요한 시간에 그 소리 들리면 멘탈 나가지. 네가 예민한 게 아니라 상황 자체가 너무 빡센 거야. 그 와중에 빨래하고 설거지까지 해준다는 게 더 대단하고 착하다. 고생 진짜 많이 하고 있고, 앞으로도 쉽지 않을 거라서 더 마음이 쓰인다."
},
...
}
개선전

개선후

4. top_p, top_k
상담봇의 응답 일관성을 높이기 위해 top_p와 top_k 파라미터를 조정했습니다.
top_p
단어들의 확률을 누적하여 그 값이 특정 임계값에 도달할때 까지의 단어들만을 고려합니다.
예를들어 p=0.5 일때,
hot: 0.35
warm: 0.25
cold: 0.1
fresh: 0.05
hot 과 warm 을 더한 값이 0.5 를 넘었으므로 hot, warm 까지만 선택합니다.
top_k (상위 K개 선택)
확률이 가장 좋은 상의 k개 단어만을 고려합니다.
예를들어 k가 3이라면, hot, warm, cold 가 선택됩니다.
적용
응답의 일관성을 높이기 위해 top_p: 0.1, top_k: 10으로 고정했습니다. 이 설정은 상위 확률 토큰만 선택하여 일관된 답변을 생성하도록 도와줍니다.
options = {
"temperature": 0.0,
"top_p": 0.1,
"top_k": 10
}
최종버전
이제 공감을 잘해줍니다. LCD 로 얼굴도 볼 수 있습니다.

