개요
본 문서는 Spring Boot 3와 LangChain4j를 결합하여 전문 의료 도우미 챗봇을 구현하는 과정을 설명합니다. 이 시스템은 대규모 언어 모델(LLM)의 인지 능력과 외부 데이터 소스 및 비즈니스 로직의 통합을 통해 복잡한 사용자 요청에 응답할 수 있습니다.
기반 기술 스택
- Spring Boot 3.2.6: JDK 17 이상을 기반으로 하는 최신 애플리케이션 프레임워크.
- LangChain4j (1.0.0-beta3): LLM과 상호작용하기 위한 Java 라이브러리.
- 다양한 LLM 백엔드: 알리바바 Qwen, DeepSeek, Ollama 등 다양한 모델과의 연결 지원.
- 상태 관리: MongoDB를 활용한 세션별 대화 기록 지속화.
- 지식 검색: ElasticSearch 8의 벡터 검색 기능을 활용한 RAG(Retrieval-Augmented Generation) 구현.
- 실시간 피드백: WebFlux를 이용한 스트리밍 응답 출력.
LLM 연동 설정
다른 LLM 공급업체에 연결하려면 각각의 API 키와 엔드포인트가 필요합니다. 다음은 알리바바 클라우드의 Qwen 모델을 구성하는 예입니다.
# application.properties
# Qwen 모델 설정
DASH_SCOPE_API_KEY=your_actual_api_key_here
langchain4j.community.dashscope.chat-model.api-key=${DASH_SCOPE_API_KEY}
langchain4j.community.dashscope.chat-model.model-name=qwen-plus
# 임베딩 모델 설정 (벡터화 용도)
langchain4j.community.dashscope.embedding-model.api-key=${DASH_SCOPE_API_KEY}
langchain4j.community.dashscope.embedding-model.model-name=text-embedding-v3
# 스트리밍 출력 활성화
langchain4j.community.dashscope.streaming-chat-model.api-key=${DASH_SCOPE_API_KEY}
langchain4j.community.dashscope.streaming-chat-model.model-name=qwen-plus
대화 메모리와 지속성
사용자마다 고유한 대화 맥락을 유지하기 위해 `@MemoryId`를 사용하여 세션 ID를 기반으로 메모리를 분리합니다. 이를 MongoDB에 저장하여 서버 재시작 후에도 기록이 유지되도록 합니다.
@Configuration
public class ConversationConfig {
@Autowired
private MongoChatMemoryStore memoryStore;
@Bean
public ChatMemoryProvider chatMemoryProvider() {
return memoryId -> MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(20)
.chatMemoryStore(memoryStore)
.build();
}
}
// 메모리 저장소 구현 예
@Component
public class MongoChatMemoryStore implements ChatMemoryStore {
@Autowired
private MongoTemplate template;
@Override
public List<ChatMessage> getMessages(Object sessionId) {
var query = Query.query(Criteria.where("sessionId").is(sessionId));
var record = template.findOne(query, MongoChatRecord.class);
return record != null ?
ChatMessageDeserializer.messagesFromJson(record.getMessageJson()) :
new ArrayList<>();
}
@Override
public void updateMessages(Object sessionId, List<ChatMessage> messages) {
var json = ChatMessageSerializer.messagesToJson(messages);
var update = new Update().set("messageJson", json);
template.upsert(
Query.query(Criteria.where("sessionId").is(sessionId)),
update,
MongoChatRecord.class
);
}
}
프롬프트 엔지니어링과 역할 정의
AI 에이전트의 행동을 정의하기 위해 `@SystemMessage` 어노테이션을 사용하여 역할과 규칙을 명확히 설정합니다. 프롬프트는 코드 내부에 직접 작성하거나 외부 파일에서 불러올 수 있습니다.
@AiService(
wiringMode = EXPLICIT,
streamingChatModel = "qwenStreamingChatModel",
chatMemoryProvider = "chatMemoryProvider",
tools = {"medicalTools", "appointmentTools"},
contentRetriever = "knowledgeRetriever"
)
public interface MedicalAssistant {
@SystemMessage(fromResource = "prompts/medical_assistant.txt")
Flux<String> chat(
@MemoryId Long conversationId,
@UserMessage String userQuery
);
}
외부 프롬프트 파일(medical_assistant.txt) 내용:
당신의 이름은 '메디헬퍼'이며, '지혜로운 건강 병원'의 AI 의료 안내원입니다.
환자의 건강 문의에 대해 친절하고 전문적인 조언을 제공하세요.
[지침]
1. 첫 대화에서는 반드시 자신을 소개하세요.
2. 진단이나 처방은 하지 마세요. 일반적인 정보만 제공하세요.
3. 병원 방문이 필요한 경우, 해당 진료과를 추천하세요.
4. 약물 정보는 성분명과 일반적인 용도만 언급하세요.
5. 항상 환자의 개인정보 보호를 강조하세요.
오늘 날짜: {{current_date}}
외부 도구(Function Calling) 통합
LLM은 계산이나 외부 시스템 조회와 같은 작업을 수행할 수 없습니다. 이러한 기능은 별도의 도구 클래스로 정의하고, AI가 필요 시 자동으로 호출하도록 합니다.
@Component
public class AppointmentTools {
@Autowired
private AppointmentService service;
@Tool(description = "주어진 환자 정보로 예약 가능한지 확인하고 새로운 예약을 생성합니다.")
public String createAppointment(
@P("환자의 이름") String name,
@P("주민등록번호") String idNumber,
@P("진료과 이름") String department,
@P("예약 날짜, 형식: YYYY-MM-DD") String date,
@P("예약 시간, 오전 또는 오후") String timeSlot) {
// 중복 예약 확인
if (service.exists(name, idNumber, department, date, timeSlot)) {
return "해당 시간에 이미 예약이 존재합니다.";
}
// 새 예약 생성
Appointment appointment = new Appointment(name, idNumber, department, date, timeSlot);
boolean success = service.save(appointment);
return success ? "예약이 성공적으로 완료되었습니다." : "예약 처리 중 오류가 발생했습니다.";
}
@Tool(description = "주어진 조건에 맞는 예약 가능 여부를 조회합니다.")
public boolean checkAvailability(
@P("진료과 이름") String department,
@P("날짜, 형식: YYYY-MM-DD") String date,
@P("시간, 오전 또는 오후") String timeSlot) {
return service.isAvailable(department, date, timeSlot);
}
}
ElasticSearch를 활용한 지식 기반 검색
병원 운영 시간, 진료과 위치 등 정형화된 정보는 LLM의 파라미터에 포함되지 않으므로, 별도의 지식 베이스에서 검색해야 합니다. ElasticSearch의 KNN(K-Nearest Neighbors) 검색 기능을 사용합니다.
@Configuration
public class KnowledgeConfig {
@Value("${es.index-name}")
private String indexName;
@Autowired
private RestClient client;
@Autowired
private EmbeddingModel embeddingModel;
@Bean
public ContentRetriever knowledgeRetriever() {
var store = ElasticsearchEmbeddingStore.builder()
.restClient(client)
.indexName(indexName)
.configuration(ElasticsearchConfigurationKnn.builder()
.numCandidates(1000)
.build())
.build();
return EmbeddingStoreContentRetriever.builder()
.embeddingModel(embeddingModel)
.embeddingStore(store)
.maxResults(1)
.minScore(0.5)
.build();
}
}
스트리밍 응답 구현
사용자 경험을 향상시키기 위해 AI의 답변을 생성되는 즉시 실시간으로 전송합니다. Spring WebFlux의 `Flux`를 반환 타입으로 사용합니다.
// 컨트롤러
@RestController
@RequestMapping("/api/chat")
public class ChatController {
@Autowired
private MedicalAssistant assistant;
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody ChatRequest request) {
return assistant.chat(request.getConversationId(), request.getMessage());
}
}
// 요청 DTO
record ChatRequest(Long conversationId, String message) {}
이렇게 하면 클라이언트는 `/api/chat/stream` 엔드포인트에 POST 요청을 보내고, AI의 답변을 단어 단위로 점진적으로 수신할 수 있습니다.