HOME
NOTE

LangChain으로 채팅 구현하기 with Typescript

CREATED
2025. 3. 17. 오후 3:59:56
UPDATED
2025. 3. 24. 오후 1:22:33
TAGS
#LLM#AI#LangChain

설치

pnpm add langchain @langchain/core

LangSmith 세팅

먼저 가입 후 API 키 발급이 필요하다. 링크 사용하지 않는다면 패스해도 무방

# env
LANGSMITH_TRACING=...
LANGSMITH_API_KEY=...

LLM 모델 통합시키기

원하는 모델의 통합 패키지를 사용하면 된다. 여기서는 이미 결제했던 OpenAI를 사용

패키지를 먼저 설치하고

pnpm add @langchain/openai @langchain/core langsmith 

환경변수 셋업

OPENAI_API_KEY=...

간단한 코드, 터미널 환경 변수만 잘 설정되어 있다면 LangSmith가 동작하지만 추후 배포나 유지보수를 위해 지금처럼 명시적으로 설정하는 것이 좋은 것 같다. 예제에서는 OpenAI의 [[ 챗 모델 ]]을 사용한다.

import {
  OPENAI_API_KEY,
  LANGSMITH_PROJECT,
  LANGSMITH_API_KEY,
  LANGSMITH_ENDPOINT,
} from 'astro:env/server';

import { Client } from 'langsmith';
import { ChatOpenAI } from '@langchain/openai';
import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';

const langSmithClient = new Client({
  apiKey: LANGSMITH_API_KEY,
  apiUrl: LANGSMITH_ENDPOINT,
});

const langSmithTracer = new LangChainTracer({
  client: langSmithClient,
  projectName: LANGSMITH_PROJECT,
});

const model = new ChatOpenAI({
  model: 'gpt-4o-mini',
  openAIApiKey: OPENAI_API_KEY,
  callbacks: [langSmithTracer],
});

export const openai = {
  hello: async (message: string) => {
    const response = await model.invoke([
      { role: 'system', content: 'You are a helpful assistant. You must answer in Korean.' },
      { role: 'user', content: message },
    ]);

    return JSON.stringify(response.content);
  },
};

프롬프트 템플릿 사용하기

[[ 프롬프트 템플릿 ]]을 사용하면 유저 입력에 따라 일관되게 재사용 가능한 메세지를 구성할 수 있다. 이전에는 로직 안에서 시스템 메세지와 유저 메세지를 직접 다루었지만, 템플릿을 사용하면 invoke에 주입시키는 형태로 바꿀 수 있다.

const promptTemplate = ChatPromptTemplate.fromMessages([
  ['system', 'You are a helpful assistant. You must answer in {language}.'],
  ['user', '{text}'],
]);

const promptValue = async (text: string) => {
  return await promptTemplate.invoke({
    language: 'korean',
    text,
  });
};

export const openai = {
  hello: async (message: string) => {
    const response = await model.invoke(await promptValue(message));
    return JSON.stringify(response.content);
  },
};

Stream 구현

먼저 invoke가 아닌 stream을 사용한다.

export const openai = {
  stream: async (message: string) => {
    const prompt = await promptValue(message);
    return await model.stream(prompt);
  },
};

astro 앱에서 api로 사용할 수 있도록 작성했다. nextjs, astro 같은 풀스택 프레임워크를 사용하면 지금처럼 통합된 환경에서 간단하게 검증하기에 좋다. ReadableStream으로 스트림 객체를 생성해서 반환한다.

import type { APIRoute } from 'astro';
import { openai } from '../../adapters/openai';

export const POST: APIRoute = async ({ request }) => {
  const { message } = await request.json();
  const stream = await openai.stream(message);

  return new Response(
    new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();

        for await (const chunk of stream) {
          controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk.content)}\n\n`));
        }

        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
        controller.close();
      },
    }),
    {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
      },
    },
  );
};

클라이언트는 getReader() 메서드로 데이터를 읽어올 수 있다. getReaderReaderStream을 생성한다.

type Message = {
  role: 'user' | 'ai';
  content: string;
};

export const Chats = () => {
  const [messages, setMessages] = useState<Message[]>([
    {
      role: 'ai',
      content: '어떻게 도와드릴까요?',
    },
  ]);
  const [input, setInput] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);

  const handleClick = async () => {
    // clear input
    setInput('');

    // user message
    const userMessage = input;
    setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);

    // empty ai message for stream
    setMessages((prev) => [...prev, { role: 'ai', content: '' }]);
    setIsStreaming(true);

    try {
      const response = await fetch('/api/openai', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: input }),
      });

      if (!response.ok) throw new Error('Stream response error');
      const reader = response.body?.getReader();
      if (!reader) return;

      let fullContent = '';
      const decoder = new TextDecoder();
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        const text = decoder.decode(value, { stream: true });
        const lines = text.split('\n\n');
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const content = line.slice(6);
            if (content === '[DONE]') continue;

            try {
              // JSON 파싱 시도
              const parsed = JSON.parse(content);
              // 파싱된 내용이 문자열인 경우 줄바꿈 보존
              if (typeof parsed === 'string') {
                fullContent += parsed;
              } else {
                // 객체인 경우 (필요에 따라 조정)
                fullContent += parsed;
              }
            } catch (e) {
              // JSON이 아닌 경우 그대로 사용
              fullContent += content;
            }

            setMessages((prev) => {
              const newMessages = [...prev];
              newMessages[newMessages.length - 1].content = fullContent;
              return newMessages;
            });
          }
        }
      }
      setIsStreaming(false);
    } catch (error) {
      console.error('Streaming error:', error);
    }
  };
  
  // .. UI 생략
};

참고