설치
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() 메서드로 데이터를 읽어올 수 있다. getReader
는 ReaderStream
을 생성한다.
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 생략
};