스트리밍 웹 채팅 만들기 (Express 프록시 + 일반 JS)
이 튜토리얼에서는 약 100줄의 작은 채팅 페이지를 만들어봅니다. 작은 Express 서버가 비밀 키를 보관하고 요청을 Uncensored Chatbot API으로 중계하며, 일반 JavaScript 프론트엔드는 스트리밍되는 응답을 읽습니다. 프레임워크나 빌드 단계가 필요 없으며, 키가 브라우저에 전달되지 않습니다.
업데이트
주요 사항
- 브라우저 코드에서 API를 호출하지 마세요. 서버 라우트는 키를 비공개로 유지하고 입력을 검증할 수 있게 합니다.
- 프록시는 서버 전송 이벤트만 통과시키면 됩니다. 페이지는 [DONE]까지 데이터 라인을 파싱합니다.
- 스트리밍 중에는 textContent를 사용하고 마크다운 렌더링 전에 sanitization을 수행하세요.
- 서버에서 대화 기록 길이와 max_tokens을 제한하여 한 사용자가 전체 잔액을 소모하지 못하게 하세요.
우리가 만드는 것과 프록시가 필요한 이유
텍스트 상자와 스크롤 로그가 있는 단일 페이지를 상상해 보세요. 줄을 입력하면 페이지가 자신의 서버의 /api/chat에 대화 내용을 게시하고, 서버는 실제 키를 첨부하여 업스트림으로 전달합니다. 응답이 스트리밍되어 도착하는 즉시 각 단편이 표시됩니다. 데모에 생동감을 주기 위해 Wren이라는 등대 지기가 있는 약간의 성격을 봇에 부여합니다.
내장 fetch를 위해 Node 18 이상이 필요하며 API 키가 필요합니다. 아직 없다면 키 페이지에서 무료 체험을 받으세요. 새 계정에는 7일 동안 $0.50의 크레딧이 제공되며 결제 정보는 필요하지 않습니다. 베이스 URL은 https://api.uncensoredchatbotapi.com/v1이고 모델 ID는 uncensored입니다.
프록시가 필요한 이유
키를 프론트엔드 코드에 붙여넣고 API를 직접 호출하고 싶을 수 있습니다. 하지만 하지 마세요. 브라우저로 전송된 것은 개발자 도구를 연 모든 사람이 읽을 수 있으며, 키가 유출되면 잔액이 고갈됩니다. 프록시는 이를 수정하고 세 가지 추가 이점을 제공합니다.
- 비밀성. 키는 서버의 환경 변수에 저장됩니다.
- 제어. 시스템 프롬프트, 히스토리 길이 및
max_tokens을 결정하여 사용자가 이를 재정의할 수 없습니다. - 규칙을 위한 공간. 사용자별 제한, 연령 제한 및 로깅 정책은 모두 이 레이어에 속합니다. 안전 가이드는 이 라우트를 기반으로 합니다.
1단계: 프로젝트 설정
폴더를 만들고 ES 모듈 프로젝트로 초기화한 후 Express를 설치하세요. 나머지는 모두 내장되어 있습니다.
mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir public루트에 server.js와 페이지 및 스크립트를 포함하는 public 폴더가 생성됩니다.
2단계: Express 프록시 작성
서버는 정적 파일을 제공하며 하나의 POST 라우트를 노출합니다. 세 부분으로 나누어 읽어보세요. 먼저 들어오는 대화 기록을 정리합니다. user와 assistant 역할의 메시지만 통과하며, 각 메시지는 4,000자로 잘리고 마지막 30회 대화만 유지됩니다. 두 번째로 브라우저가 변경할 수 없는 시스템 메시지를 앞에 붙입니다. 세 번째로 stream: true 옵션으로 엔드포인트에 요청하고 바이트를 그대로 연결합니다.
import express from "express";
const app = express();
app.use(express.json({ limit: "256kb" }));
app.use(express.static("public"));
const UPSTREAM = "https://api.uncensoredchatbotapi.com/v1/chat/completions";
const SYSTEM = "You are Wren, a dry-witted night-shift lighthouse keeper. Stay in character.";
app.post("/api/chat", async (req, res) => {
const history = Array.isArray(req.body.messages) ? req.body.messages : [];
// Keep only well-formed turns and cap the history we forward.
const turns = history
.filter((m) => ["user", "assistant"].includes(m.role) && typeof m.content === "string")
.slice(-30)
.map((m) => ({ role: m.role, content: m.content.slice(0, 4000) }));
if (turns.length === 0) return res.status(400).json({ error: "no messages" });
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "uncensored",
messages: [{ role: "system", content: SYSTEM }, ...turns],
stream: true,
max_tokens: 500,
temperature: 0.8,
}),
});
if (!upstream.ok) {
const info = await upstream.json().catch(() => ({}));
return res.status(upstream.status).json(info);
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
for await (const chunk of upstream.body) res.write(chunk);
res.end();
});
app.listen(3000, () => console.log("http://localhost:3000"));두 가지 선택을 설명할 가치가 있습니다. 원시 바이트를 통과시키면 서버에서 이벤트 형식을 이해할 필요가 없습니다. 그리고 실패 시 업스트림 상태를 반환하면 페이지가 합리적으로 반응할 수 있습니다. 예를 들어 402는 잔액이 비어 있음을, 429는 누군가가 너무 빠르게 요청하고 있음을 의미합니다.
3단계: 페이지 및 스트리밍 리더
이제 프론트엔드입니다. public/index.html로 저장하세요. 나중에 스타일을 변경할 수 있도록 의도적으로 단순하게 작성되었습니다.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lantern Chat</title>
<style>
body { font: 16px system-ui; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
#log { min-height: 320px; border: 1px solid #ccc; padding: 1rem; white-space: pre-wrap; }
.me { color: #234; font-weight: 600; }
form { display: flex; gap: .5rem; margin-top: 1rem; }
input { flex: 1; padding: .6rem; }
</style>
</head>
<body>
<h1>Lantern Chat</h1>
<div id="log"></div>
<form id="form">
<input id="text" autocomplete="off" placeholder="Say something...">
<button>Send</button>
</form>
<script src="chat.js"></script>
</body>
</html>다음으로, public/chat.js입니다. 흥미로운 부분은 읽기 루프입니다. 네트워크 청크는 줄 경계를 따르지 않으므로 buffer를 유지하고 줄바꿈 기준으로 나누며, 더 많은 데이터가 올 때까지 마지막 부분 줄을 보류합니다. data: 로 시작하는 완전한 줄은 JSON이지만, 마지막 [DONE] 마커는 제외됩니다.
const log = document.getElementById("log");
const form = document.getElementById("form");
const input = document.getElementById("text");
const history = [];
function addLine(cls, text) {
const div = document.createElement("div");
div.className = cls;
div.textContent = text; // textContent, never innerHTML, for untrusted text
log.appendChild(div);
return div;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = "";
history.push({ role: "user", content: text });
addLine("me", "You: " + text);
const bubble = addLine("bot", "");
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
});
if (!res.ok) {
bubble.textContent = "(the chat is unavailable right now)";
history.pop();
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", reply = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep a partial line for the next read
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
const json = JSON.parse(data);
const piece = json.choices?.[0]?.delta?.content;
if (piece) {
reply += piece;
bubble.textContent = reply;
}
}
}
history.push({ role: "assistant", content: reply });
});history 배열에 주목하세요. API는 호출 간 메모리를 유지하지 않으므로 페이지는 매번 전체 대화를 다시 보내며 서버는 이를 다듬습니다. 앱을 시작하고 브라우저에서 엽니다:
export API_KEY="paste-your-key-here"
node server.js
마크다운 렌더링에 대한 참고 사항
채팅 모델은 별표, 목록 및 때때로 코드 울타리를 좋아합니다. 데모는 안전한 원본 텍스트를 보여줍니다. 예쁜 출력을 원한다면 라이브러리로 마크다운을 렌더링하되 두 가지 규칙을 따르세요. innerHTML로 삽입하기 전에 HTML을 정화기 통과시키기, 모델이나 이를 속이는 사용자가 의도하지 않은 태그와 속성을 생성할 수 있기 때문입니다. 그리고 주의해서 증분 렌더링하기: 모든 청크에 대해 전체 응답을 다시 파싱하는 것은 짧은 메시지에는 적합하지만, 완성되지 않은 마크다운은 깜빡일 수 있으므로 일부 빌더는 스트리밍 중에는 일반 텍스트를 표시하고 스트림이 끝나면 서식 있는 출력으로 전환합니다.
역할극 서식은 별개의 특징입니다. 많은 캐릭터는 별표로 동작을 감쌉니다. 예: *adjusts the lamp*. 앱에서 이를 기울임꼴로 스타일링할지 결정하고 시스템 프롬프트에서 캐릭터가 따를 규약을 알려주세요. 페르소나 디자인 가이드에서 이를 위한 프롬프트 어휘를 확인하세요.
4단계: 라우트 연소 테스트
브라우저를 탓하기 전에 프록시를 직접 테스트하세요. 터미널이 작동하면 서버는 양호하며 나머지 버그는 페이지에 있습니다.
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'-N 플래그는 curl 자체 버퍼링을 비활성화하므로 data: 줄이 점진적으로 나타나고 data: [DONE]로 끝나는 것을 볼 수 있습니다. JSON 오류가 발생하면 상태를 확인하세요: 401은 환경의 키가 잘못되었음을, 402는 잔액이 비어있음을, 404은 upstream URL에 오타가 있음을 의미합니다.
| 증상 | 잠재적 원인 | 해결 방법 |
|---|---|---|
| 페이지에 사용 불가 메시지가 표시됨 | 서버가 200이 아닌 상태를 반환함 | curl 테스트를 실행하고 상태 읽기 |
| 텍스트가 마지막에만 표시됨 | 프록시 레이어가 응답을 버퍼링함 | 라우트에 대한 버퍼링 비활성화 |
| 문자가 깨짐 | stream: true 없이 디코더 사용 | TextDecoder.decode 옵션을 설정된 상태로 유지하세요 |
| 응답이 문장 중간에 끊김 | max_tokens 도달 | 서버의 한도 상향 조정 |
이 네 가지 수정으로 1분 이내에 대부분의 첫 실행 문제를 진단할 수 있습니다. curl 명령어를 메모장에 저장해 두세요. 나중에 프록시를 변경한 후에도 유용한 상태 확인에 사용할 수 있습니다.
완성도를 높이는 작은 디테일
스트리밍이 되는 채팅은 이미 만족스럽지만, 몇 가지 세부 사항이 데모와 사용자가 다시 찾게 되는 제품 사이의 차이를 만듭니다.
- 응답이 도착하는 동안 전송 버튼을 비활성화합니다. 중복 전송은 사용자와 모델 모두를 혼란스럽게 하는 교차된 기록을 생성합니다.
- 중지 버튼을 추가합니다.
AbortController를 생성하고, 그 시그널을fetch에 전달한 후 클릭 시abort()을 호출합니다. 지금까지 도착한 텍스트를 어시스턴트 턴으로 저장합니다. - 로그를 유지합니다.
sessionStorage에 히스토리를 저장하여 새로고침 시 대화가 지워지지 않도록 하고, 빈 채팅 버튼을 제공하여 이를 비웁니다. - 적절하게 자동 스크롤합니다. 사용자가 이미 하단에 있는 경우에만 하단을 따라가고, 그렇지 않으면 사용자가 오래된 메시지를 편하게 읽을 수 있도록 합니다.
- 부드러운 오류 처리를 표시합니다. 빈 버블 대신 마지막 사용자 메시지를 다시 전송하는 재시도 링크로 대체합니다.
이러한 기능들은 모두 평범한 자바스크립트 몇 줄로 구현할 수 있으므로, 인터페이스에 컴포넌트, 라우팅 또는 공유 상태가 정말로 필요할 때까지 프레임워크에 의존하지 마십시오.
견고성 강화 및 다음 단계
이제 작동하는 채팅이 완성되었습니다. 실제 사용자가 접근하기 전에 server.js에 몇 가지 안전 장치를 추가하세요. IP 또는 세션당 요청 수를 제한하세요. 하나의 키는 총 1분에 300회 요청이 허용되므로, 한 명의 성급한 사용자가 모든 할당량을 소진할 수 있습니다. upstream 오류를 명시적으로 처리하세요: upstream_busy 상태의 503 오류에는 친절한 재시도 버튼을, 403 content_blocked 오류에는 빈 말풍지 대신 명확한 메시지를 표시하세요. max_tokens은 적당히 유지하세요. 채팅에는 500이면 충분하며, 요청당 허용된 최대치는 32,000입니다.
그런 다음 비용을 고려하십시오. 예시로, 각 턴에서 1,200개의 프롬프트 토큰을 전송하고 250개의 완성 토큰을 받는다고 가정해 봅시다. 이는 입력에 대해 약 $0.0003, 출력에 대해 $0.00025, 턴당 약 $0.00055입니다. 이러한 토큰 수는 가정값이므로 usage 섹션을 사용하여 자체적으로 측정하십시오. 문서에서 해당 필드를 확인할 수 있으며, 호스팅된 무검열 LLM 가이드에서 이러한 종류의 서비스에서 기대할 수 있는 내용을 설명합니다.
질문과 답변
브라우저에서 직접 API를 호출할 수 있나요?
기술적으로는 가능하지만 모든 방문자에게 키가 노출됩니다. 이 튜토리얼의 서버 라우트와 같이 키가 환경 변수에 유지되도록 하세요.
서버에서 스트림을 파싱해야 하나요?
아니요. 바이트를 변경 없이 전달할 수 있으며 페이지는 데이터 줄을 읽습니다. 텍스트를 로깅하거나 출력을 필터링하려면 서버에서만 파싱하세요.
왜 응답이 한 번에 도착하나요?
그 사이의 무엇인가가 버퍼링 중입니다. 요청에 stream이 true로 설정되어 있고, 리버스 프록시 또는 압축 레이어가 응답을 보유하지 않는지 확인하세요.
봇에 기억력을 어떻게 부여하나요?
매번 호출할 때 대화 내용을 메시지 단위로 다시 전송하고, 길어지면 가장 오래된 턴을 잘라냅니다. 100,000 토큰 컨텍스트는 응답과 공유됩니다.