LangChain.js
LangChain은 AI 모델과 다양한 데이터 소스, 도구들을 연결해 복잡한 애플리케이션을 쉽게 구축할 수 있도록 돕는 프레임워크이다. 원래 파이썬 기반으로 많이 알려져 있지만, 최근에는 타입스크립트 지원도 활발해져 프론트엔드 개발자나 Node.js 환경에서도 효율적으로 활용할 수 있다. 타입스크립트 LangChain은 강력한 타입 시스템과 친숙한 문법 덕분에 안정적이고 생산성 높은 AI 통합 개발을 가능하게 한다.
Basic Usage
모델
ChatOpenAI 인스턴스를 생성하는 것은 LangChain에서 OpenAI 기반 챗 모델을 사용하기 위한 첫 번째 단계이다. 이때 modelName, temperature, openAIApiKey 등의 옵션을 설정하여 사용할 모델의 종류, 응답의 창의성 정도, 인증에 사용할 API 키 등을 지정할 수 있다. 이렇게 생성한 인스턴스는 .invoke() 메서드를 통해 실제 사용자 메시지를 전달하고 응답을 받을 수 있으며, 프롬프트 템플릿, 출력 파서, 메모리와 같은 LangChain의 구성 요소들과 결합하여 복잡하고 유연한 대화 흐름을 구현하는 데 활용된다.
const model = new ChatOpenAI({
modelName: "gpt-4o-mini",
temperature: 1,
openAIApiKey: process.env.OPENAI_API_KEY
});
ChatOpenAI를 사용할 때는 modelName 옵션을 통해 사용할 OpenAI 모델을 지정할 수 있다. 예를 들어 "gpt-4o-mini"라는 값을 설정하면 해당 이름의 모델로 동작하게 되지만, 이는 실제 존재하지 않는 모델일 수 있으므로 주의해야 한다. OpenAI에서 공식적으로 지원하는 모델은 gpt-4o, gpt-4-turbo, gpt-3.5-turbo 등이 있으며, 정확한 모델명을 사용하지 않으면 에러가 발생하거나 예상과 다른 결과가 나올 수 있다. 따라서 항상 OpenAI의 공식 문서를 참고하여 지원되는 모델 이름을 확인한 후 사용하는 것이 바람직하다.
프롬프트 템플릿
LLM은 기본적으로 문자열 입력을 받아 그에 대한 응답을 생성하는 구조다. 하지만 실제 서비스에서는 프롬프트 문자열이 고정되어 있지 않고, 사용자의 입력이나 외부 조건에 따라 유동적으로 생성되어야 하는 경우가 많다. 이런 상황에서 단순한 문자열을 그대로 쓰는 대신, 일정한 형식과 변수 자리에 값을 넣어 동적으로 프롬프트를 생성할 수 있도록 도와주는 것이 바로 PromptTemplate이다.
LangChain에서는 프롬프트를 단순 문자열이 아니라, 재사용 가능하고 파라미터화된 객체로 다룬다. 예를 들어 "너는 친절한 도우미야. {name}에게 인사해줘."라는 템플릿을 만들고, {name}에 "철수"를 주입하면 최종적으로 "너는 친절한 도우미야. 철수에게 인사해줘."라는 문장이 완성된다. 이 과정을 코드로 표현하면 다음과 같다:
const prompt = PromptTemplate.fromTemplate("너는 친절한 도우미야. {name}에게 인사해줘.");
const result = await prompt.format({ name: "철수" });
// 결과: "너는 친절한 도우미야. 철수에게 인사해줘."
PromptTemplate은 단순한 문자열 치환 이상의 기능을 제공한다. 입력 파라미터를 명시할 수 있고, 다수의 입력을 조합하거나, 조건에 따라 다른 템플릿을 선택하는 방식으로도 확장할 수 있다. 또한 Chat 모델(대화 형식의 입력과 출력을 처리하도록 설계된 LLM)에 사용할 경우에는 ChatPromptTemplate을 사용하여 system, human, ai와 같은 역할 기반 메시지를 구조적으로 정의할 수 있다. 이는 대화형 모델과의 상호작용에서 매우 중요한 역할을 하며, 여러 메시지를 배열로 조합하여 자연스러운 대화 흐름을 설계할 수 있게 해준다.
const chatPrompt = ChatPromptTemplate.fromMessages([
["system", "너는 친절하고 정직한 도우미야."],
["human", "안녕, 나는 {name}이야."],
]);
const messages = await chatPrompt.formatMessages({ name: "영희" });
ChatPromptTemplate이 전체 메세지 흐름을 구조적으로 정의한다면, ChatMessagePromptTemplate은 LangChain에서 대화형 AI와의 상호작용을 위한 개별 메시지를 구조화하는 템플릿이다. 이를 통해 다양한 역할(시스템, 사용자, AI)에 따른 메시지 템플릿을 생성하고 변수를 삽입하여 동적인 대화 내용을 구성할 수 있다.
// 기본적인 ChatMessagePromptTemplate 사용 예시
import { ChatMessagePromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate, ChatPromptTemplate } from "langchain/prompts";
// 시스템 메시지 템플릿 생성
const systemTemplate = SystemMessagePromptTemplate.fromTemplate(
"당신은 {role}입니다. 사용자의 {topic} 관련 질문에 답변해주세요."
);
// 사용자 메시지 템플릿 생성
const userTemplate = HumanMessagePromptTemplate.fromTemplate(
"안녕하세요, {question}에 대해 알려주실 수 있나요?"
);
const chatPrompt = ChatPromptTemplate.fromMessages([
systemTemplate,
userTemplate
]);
const messages = await chatPrompt.formatMessages({
role: "프로그래밍 튜터",
topic: "타입스크립트",
question: "NoInfer란 무엇인가요?",
});
invoke
LangChain에서는 언어 모델을 직접 호출하는 방법으로 invoke 메소드를 제공한다. 이 방식은 간단하고 직관적이며, 모델과의 상호작용을 빠르게 구현할 수 있다. 단, 복잡한 로직이나 출력 형식 변환이 필요한 경우에는 추가 작업이 요구된다.
const response = await model.invoke(messages);
/**
AIMessage {
"id": "chatcmpl-BiCZLPo9RJrISJTNQPLWmARA7zhjH",
"content": "안녕하세요! `NoInfer`는 TypeScript에서 사용되는 일종의 유틸리티 타입으로, 주로 타입 유추를 비활성화하는 데 사용됩니다. TypeScript에서는 일반적으로 변수나 함수의 타입을 자동으로 추론하려고 시도하는데, 이 경우 때때로 예상과 다른 타입이 추론될 수 있습니다. `NoInfer`는 이러한 상황을 제어하고 명시적인 타입을 강제하는 데 유용합니다.\n\nTypeScript의 더 advanced한 타입 시스템과 관련이 있는 부분이므로, 일반적으로 잘 사용되지 않지만, 복잡한 제네릭 타입 정의나 고급 타입 조작을 할 때 도움이 될 수 있습니다.\n\n간단한 예시를 통해 이해를 돕겠습니다:\n\n```typescript\ntype NoInfer<T> = T extends infer U ? never : T;\n\nfunction example<T>(arg: NoInfer<T>) {\n // 여기서 arg의 타입이 T로 고정되어, 자동 타입 추론이 방지됩니다.\n}\n```\n\n위의 코드에서 `NoInfer` 타입은 `T`가 어떤 타입이든지, 그것의 추론을 방지합니다. 결과적으로, `arg`의 타입이 `T`로 고정되도록 할 수 있습니다. 이러한 방식으로 특정한 타입의 변형이나 조작을 할 때 유용합니다.\n\n이해가 어려운 부분이 있다면 추가로 질문해 주세요!",
"additional_kwargs": {},
"response_metadata": {
"tokenUsage": {
"promptTokens": 54,
"completionTokens": 299,
"totalTokens": 353
},
"finish_reason": "stop",
"model_name": "gpt-4o-mini-2024-07-18",
"usage": {
"prompt_tokens": 54,
"completion_tokens": 299,
"total_tokens": 353,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"system_fingerprint": "fp_34a54ae93c"
},
"tool_calls": [],
"invalid_tool_calls": [],
"usage_metadata": {
"output_tokens": 299,
"input_tokens": 54,
"total_tokens": 353,
"input_token_details": {
"audio": 0,
"cache_read": 0
},
"output_token_details": {
"audio": 0,
"reasoning": 0
}
}
}
*/
parse
LangChain에서 parse는 언어 모델의 출력을 원하는 형식으로 변환하는 과정이다. OutputParser를 사용하면 텍스트 응답을 JSON, 배열, 객체 등 구조화된 데이터로 변환할 수 있다. 주로 사용되는 OutputParser의 종류는 아래와 같으며, 이 외에도 직접 BaseOutputParser 를 상속해 커스텀 파서를 만들어 쓸 수 있다.
- StringOutputParser : 단순 텍스트 입·출력
- StructuredOutputParser : 스키마 기반 구조화 및 검증
- JsonOutputParser : JSON 블록만 추출 후 파싱해 JS 객체로 변환
- RegexParser : 정규표현식으로 원하는 부분만 캡처
- OutputFixingParser : LLM이 JSON·구조화 형식을 어긋나게 냈을 때 내부에서 “잘못된 부분만” 다시 모델에 물어보며 복구
const result = await model.pipe(new StringOutputParser()).invoke(messages);
return result;
/**
Result: 안녕하세요! TypeScript에서 `NoInfer`는 타입을 추론하지 않도록 하는 유틸리티 타입입니다. 주로 제너릭을 사용할 때 타입 추론이 의도와 다르게 이루어지는 상황에서 사용됩니다.
TypeScript에서는 일반적으로 제너릭 타입의 인수를 추론하지만, 때로는 개발자가 명시적으로 타입을 지정하고 싶을 때가 있습니다. `NoInfer`는 이러한 상황에서 유용하게 사용됩니다.
예를 들어, `NoInfer`를 사용하여 타입을 고정시키고 싶을 때는 다음과 같이 사용할 수 있습니다:
```typescript
type NoInfer<T> = [T] extends [infer U] ? U : T;
function example<T>(value: NoInfer<T>): void {
// 이제 value는 T로 고정됩니다.
}
```
`NoInfer`를 사용하면 `example` 함수에 전달된 `value`는 타입을 명시적으로 지정하지 않는 한 추론되지 않습니다.
실제 사용 사례는 다양하지만, 주로 타입 안정성을 높이고 예상하지 못한 타입 추론 문제를 피하고자 할 때 사용됩니다.
*/
const zodParser = StructuredOutputParser.fromZodSchema(
z.object({
title: z.string().describe("제목"),
points: z.array(z.string()).describe("핵심 요점들"),
difficulty: z.number().min(1).max(5).describe("난이도 (1-5)")
})
);
// 파싱 지시사항 생성
const formatInstructions = zodParser.getFormatInstructions();
const chatPrompt = ChatPromptTemplate.fromMessages([
new SystemMessage(`응답은 반드시 JSON 형식으로만 내려주세요:\n${formatInstructions}`),
systemTemplate,
userTemplate
]);
const response = await model.invoke(messages);
const parsedResult = await parser.parse(result.content as string);
/**
{
"title": "NoInfer의 개념",
"points": [
"NoInfer는 TypeScript의 유틸리티 타입 중 하나로, 타입 추론 과정을 방지합니다.",
"주로 제너릭 타입에서 사용되며, 컴파일러가 타입을 추론하지 않도록 지시합니다.",
"이는 특정 상황에서 타입 안전성을 제공하기 위해 필요할 수 있습니다.",
"예를 들어, 고차 함수나 클로저를 사용할 때 타입 추론의 착오를 막는 데 유용합니다."
],
"difficulty": 3
}
*/
chain과 Runnable
Runnable 프로토콜은 작업 실행 단위를 추상화한 핵심 인터페이스이다. 이 프로토콜은 특정 작업을 실행하는 invoke() 메서드를 반드시 구현하도록 요구하며, 이를 통해 다양한 종류의 컴포넌트가 일관된 방식으로 실행될 수 있도록 설계되었다. 즉, Runnable 프로토콜을 구현한 객체는 입력값을 받아 처리하고, 결과를 반환하는 실행 가능한 단위로 동작한다. 이는 LangChain의 워크플로우, 체인(chain), 도구(tool) 등이 공통적으로 갖는 인터페이스로, 상호 교환 가능성과 확장성을 높인다.
또한, Runnable 프로토콜은 비동기 처리, 에러 핸들링, 입력 및 출력 타입 관리 등 실행 환경과 관련된 공통 기능을 표준화하는 기반을 제공한다. LangChain의 여러 고수준 추상화 계층은 Runnable 프로토콜을 확장하거나 구현하여, 복잡한 AI 워크플로우를 체계적이고 재사용 가능하게 만든다. 따라서 Runnable 프로토콜은 LangChain의 모듈화와 확장성을 뒷받침하는 중추적인 설계 요소라 할 수 있다.
Chain은 여러 Runnable을 파이프라인 형태로 연결해 단일 흐름으로 묶은 구현체이다. 프롬프트 생성 → 모델 요청 → 출력 후처리 같은 일련의 단계를 Chain에 정의해 두면, 최종적으로 .invoke() 한 번만 호출해도 내부의 각 Runnable이 순차적으로 실행되면서 입력부터 최종 결과까지 자동으로 처리된다. 즉, Chain은 개별 Runnable을 조합해 복잡한 워크플로우를 하나의 실행 유닛처럼 다룰 수 있도록 하는 것이다.
const chatPrompt = ChatPromptTemplate.fromMessages([
systemTemplate,
userTemplate
]);
const messages = await chatPrompt.formatMessages({
role: "프로그래밍 튜터",
topic: "타입스크립트",
});
const result = await chatPrompt.pipe(this.langchainRepository.model).pipe(new StringOutputParser()).invoke({
role: "프로그래밍 튜터",
topic: "타입스크립트",
question: "NoInfer란 무엇인가요?",
});
const result = await model.pipe(new StringOutputParser()).invoke(messages);
Memory
LangChain에서 프롬프트를 invoke 할 때는 기본적으로 1회성 대화로 처리된다. 즉, 이전에 어떤 대화가 오갔는지에 대한 맥락 없이, 사용자가 입력한 단일 메시지에만 반응하여 답변을 생성한다. 이런 방식은 단순한 Q&A나 문서 요약 등에는 적합하지만, 챗봇처럼 연속적인 대화를 요구하는 경우에는 한계가 있다. 이를 보완하기 위해 LangChain은 대화의 흐름을 기억할 수 있는 Memory 기능을 제공하며, 이를 통해 모델이 과거 발화를 참조하여 더 자연스럽고 일관된 응답을 생성할 수 있도록 한다.
LangChain에서 메모리는 RunnableWithMessageHistory와 BaseChatMessageHistory 기반의 메시지 저장소를 통해 구성된다. BaseChatMessageHistory는 메시지 저장소 인터페이스로, ChatMessageHistory와 같은 인메모리 구현체부터 Redis, DynamoDB, MongoDB 등 외부 저장소 기반 구현체까지 다양하게 확장 가능하다. 이 구조 덕분에 개발자는 상황에 맞게 저장소를 선택할 수 있으며, RunnableWithMessageHistory를 통해 프롬프트 체인에 메모리 기능을 간편하게 연결할 수 있다.
CustomChatMessageHistory
LangChain에는 다양한 빌트인 메시지 저장소가 제공되지만, 복잡한 요구사항을 만족하려면 결국 BaseChatMessageHistory를 기반으로 커스텀 구현이 필요하다. 나의 경우, 각 사용자 요청과 모델 응답을 동시에 Redis와 PostgreSQL에 저장해야 했기 때문에, 기존 구현만으로는 요구사항을 충족할 수 없었다. Redis는 빠른 조회를 위해 사용하고, PostgreSQL은 장기 보관 및 분석을 위한 영속 저장소로 활용하는 구조였다. 이러한 목적에 맞게 BaseChatMessageHistory를 상속받아 addMessage, getMessages 등의 메서드를 직접 구현했고, 이를 통해 두 저장소에 동시 기록하는 메시지 히스토리 시스템을 구성할 수 있었다. LangChain의 추상화 구조 덕분에, 이렇게 커스텀한 구현체도 RunnableWithMessageHistory에 그대로 연결하여 사용할 수 있다는 점이 인상적이었다.
export class LangChainInterviewMessageHistory extends BaseChatMessageHistory {
public lc_namespace = ["langchain", "stores", "message", "redis-prisma"];
private readonly TTL = 1800; // Redis 캐시 30분 유지
constructor(
private readonly sessionId: string,
private readonly userId: string,
private readonly interviewRepository: InterviewRepository,
) {
super();
}
async getMessages(): Promise<BaseMessage[]> {
const array = await this.interviewRepository.getInterviewMessages(this.userId, this.sessionId)
if (!array || array.length === 0) {
return [];
}
// 직렬화된 메시지를 LangChain 메시지 객체로 변환
const messages = this.deserializeMessages(array);
return messages;
}
async addMessage(message: AIMessage | HumanMessage | SystemMessage): Promise<void> {
const serializedMessage: Omit<InterviewMessage, "createdAt" | "id"> = {
type: message.getType() as $Enums.InterviewMessageType,
name: message.name || null,
content: message.content as string,
interviewId: this.sessionId,
toolCalls: message.getType() === "ai" ? (message as AIMessage).tool_calls : null
};
await this.interviewRepository.createInterviewMessage(serializedMessage, this.TTL);
}
// 편의 메서드
async addSystemMessage(message: string): Promise<void> {
await this.addMessage(new SystemMessage(message));
}
async addUserMessage(message: string): Promise<void> {
await this.addMessage(new HumanMessage(message));
}
async addAIChatMessage(message: string): Promise<void> {
await this.addMessage(new AIMessage(message));
}
async clear(): Promise<void> {
}
// 도우미 메서드: 직렬화된 메시지를 LangChain 메시지 객체로 변환
private deserializeMessages(serializedMessages: InterviewMessage[]): BaseMessage[] {
return serializedMessages.map(data => {
switch (data.type) {
case "human":
return new HumanMessage(data);
case "ai":
return new AIMessage(data);
case "system":
return new SystemMessage(data);
default:
console.warn(`Unknown message type: ${data.type}`);
return new HumanMessage({
content: `[Unknown Type: ${data.type}] ${data.content}`,
id: data.id
});
}
});
}
}
RunnableWithMessageHistory
RunnableWithMessageHistory는 LangChain에서 Runnable 프로토콜을 확장한 인터페이스로, 실행 단위가 이전 대화 내역과 연동되어 작동하도록 설계되었다. 즉, 단순히 입력값을 처리하는 것에 그치지 않고, 대화의 맥락이나 히스토리를 함께 관리하며 이를 기반으로 응답을 생성하거나 작업을 수행한다. 이 기능은 챗봇이나 대화형 AI 시스템에서 매우 중요하며, 대화 흐름을 자연스럽고 일관되게 유지하는 데 필수적이다. RunnableWithMessageHistory는 메시지 히스토리를 인자로 받아 처리함으로써, 과거 대화 내용에 기반한 적절한 의사결정과 응답 생성이 가능하게 한다. 따라서 LangChain에서 대화형 워크플로우를 구현할 때 대화 상태를 체계적으로 관리하고, 이전 대화 맥락을 반영한 동적인 처리를 지원하는 중요한 역할을 한다.
// RunnableWithMessageHistory 설정
const chainWithHistory = new RunnableWithMessageHistory({
runnable: prompt.pipe(this.langchainRepository.model).pipe(new StringOutputParser()), // 실제 실행될 체인
getMessageHistory: (sessionId: string) => new LangChainInterviewMessageHistory(sessionId, userId, this.interviewRepository), // 세션 ID별로 CustomChatHistory 인스턴스 반환
inputMessagesKey: "input", // 사용자의 현재 입력을 나타내는 키
historyMessagesKey: "history", // 프롬프트 내의 MessagesPlaceholder 키와 일치
});
RAG
RAG는 "Retrieval-Augmented Generation"의 약자로, AI 언어 모델의 생성 능력에 외부 지식 검색(Retrieval) 기능을 결합한 기술이다. 기존의 생성형 모델은 학습된 데이터에 기반해 답변을 생성하지만, 최신 정보나 특정 도메인 지식을 바로 반영하기 어려운 한계가 있다. RAG는 이 문제를 해결하기 위해, 먼저 관련 문서나 데이터베이스에서 필요한 정보를 검색한 후, 그 결과를 바탕으로 보다 정확하고 풍부한 답변을 생성한다.
즉, RAG는 검색과 생성 단계를 결합하여 AI의 응답 품질과 신뢰성을 크게 향상시킨다. 예를 들어, 대용량 문서나 웹사이트에서 실시간으로 필요한 내용을 찾아내어, 사용자의 질문에 대해 최신 정보와 맥락을 반영한 답변을 제공할 수 있다. 이러한 특성 덕분에 챗봇, 고객 지원, 지식 관리 시스템 등 다양한 분야에서 RAG가 널리 활용되고 있다.
이 부분은 나중에 더 알아보고자 한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden