🎁 신규 무료 가입, 10회 호출 제공. 최대 $1, 카드 불필요.
LLM 챗봇 구축: 스트리밍, 컨텍스트 압축, 메모리

LLM 챗봇 구축: 스트리밍, 컨텍스트 압축, 메모리

모델 선택기부터 provider에 직접 전달되는 Stop 기능, 측정된 컨텍스트 예산 관리, 대화 내용을 메모리로 압축하는 방식, gateway 기반 웹 검색까지 챗봇 구축 전 과정을 단계별로 구현합니다.

추천 모델

실시간 가격
모델 평가 가격
Gemini 3.6 Flash 빠르고, 실제 1M 컨텍스트(972K에서 니들 회수)를 지원하며, 추론을 최소로 설정하면 채팅 형태의 단일 단계 작업에서 측정된 호출당 비용이 91-97% 감소합니다. 기본 추천 $1.5/100만부터
DeepSeek V4 Flash 이 페이지에서 채팅을 지원하는 가장 저렴한 선택이며, 표에서 캐시 읽기 할인율이 가장 높아 긴 대화 기록을 다시 읽는 비용이 거의 들지 않습니다. 또한 MVP의 기본 요약 모델입니다. 예산 우선 $0.138/100만부터
Claude Sonnet 5 네 모델 중 페르소나와 글쓰기 일관성이 가장 뛰어납니다. 예산 참고: 동일한 텍스트가 Sonnet 4.6보다 토큰이 41% 더 많이 생성되므로, 표시 가격이 아니라 토큰 수를 비교하세요. 품질 우선 $2/100만부터
GLM-5.2 웜 상태의 도구 호출 턴은 claude-opus-4-8의 $0.0051 대비 $0.0009로 측정되었습니다. 웜 턴 지연 시간 중앙값은 6.6s이므로, 사용자가 대기를 예상하는 흐름에만 사용하세요. 저렴한 도구 호출 $1.4/100만부터
목차
  1. MVP 챗봇에 필요한 기능은 무엇인가?
  2. 전체 구조는 어떻게 되어 있는가?
  3. Picker에는 어떤 모델을 넣어야 하는가?
  4. 모델은 매 turn마다 실제로 무엇을 보는가?
  5. Prompt caching은 어떻게 동작하며, 어떤 모델이 지원하는가?
  6. 컨텍스트 한계에 가까워졌는지 어떻게 아는가?
  7. 한계에 도달하면 어떻게 되는가?
  8. Session이 바뀌어도 memory를 유지하려면 어떻게 해야 하는가?
  9. 사용자가 Stop을 누르면 어떻게 되는가?
  10. Turn이 실패하면 어떻게 되는가?
  11. Code에 tool loop를 만들지 않고 웹 검색을 구현하려면 어떻게 해야 하는가?
  12. Streaming 중에도 Markdown이 깨지지 않게 rendering하려면 어떻게 해야 하는가?
  13. 대화 비용은 얼마인가?
  14. 모든 설정은 어디에 두는가?
  15. 무엇을 제외했고, 추가하려면 어디에 연결해야 하는가?
  16. 함께 읽을 글

이 글에서는 약 2분이면 로컬에서 실행하고, 반나절이면 코드를 모두 읽을 수 있는 완전한 챗봇을 구축한다. 구성은 FastAPI 서버와 정적 페이지 하나뿐이며, 데이터베이스도 build 단계도 없다. 각 subsystem의 설계, 매 turn의 처리 순서, 구현 중 마주친 failure mode를 차례로 살펴본다. 전체 소스는 github.com/synthorai-io/use-caseschatbot/에 있다. 모든 요청이 하나의 OpenAI 호환 endpoint를 거치므로 모델별 integration을 따로 만들 필요 없이 picker에서 선택하면 된다.

git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env    # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

대화 중인 demo 화면. 상단 bar에 model picker와 웹 검색 pill이 있고, chat 위에는 session 합계와 구간별 컨텍스트 bar가 표시된다. 접힌 activity trail, source chip이 붙은 Markdown 답변, 대화 sidebar도 보인다

MVP 챗봇에 필요한 기능은 무엇인가?

8가지다. 하나라도 없으면 챗봇이 특정한 방식으로 실패한다.

기능방지하는 문제구현 위치
대화 영구 저장(목록, 검색, 이름 변경)reload할 때마다 모든 내용을 잊는 chat은 도구가 아니라 demo다storage.py
대화 중에도 바꿀 수 있는 모델 선택가장 어려운 질문에 맞춘 모델 하나만 쓰면 쉬운 질문에도 과도한 비용이 든다config.py
preset을 제공하는 편집 가능한 personasystem prompt가 곧 제품이다. 변경할 때마다 deploy해서는 안 된다presets/
provider까지 중단시키는 Stop이 포함된 streaming첫 token 전까지 아무 반응이 없으면 고장으로 보인다. 가짜 Stop은 billing을 멈추지 못한다server.py
한계에 도달하면 압축하는 컨텍스트 예산모든 모델에는 context window가 있다. 조용히 한계를 넘으면 bot이 갑자기 “멍청해진다”context.py
장기 메모리session마다 자기소개를 반복하는 일은 사용자가 가장 크게 체감하는 챗봇의 불편이다context.py + .data/memory.md
웹 검색과 웹 fetch모델 지식은 학습 시점에 멈추지만 chat 질문은 최신 정보에 치우쳐 있다tools.py
turn별, session별 비용 표시대화 기록이 길어질수록 비용도 늘어난다. 보이지 않으면 조정할 수 없다server.py

웹 검색과 fetch는 MVP 기능 목록에서 가장 먼저 빠지기 쉽지만, 더 자세히 짚을 필요가 있다. Chat 모델의 지식은 오늘보다 몇 달 전인 학습 cutoff에서 멈춘다. 반면 chat 질문은 가격, 버전, release, “X가 이제 Y를 지원하는가”처럼 최신 정보에 크게 치우쳐 있다. 이런 질문에 학습 데이터만으로 답하는 챗봇은 성능이 조금 떨어지는 정도가 아니다. 확신에 차서 틀린 답을 내놓고, 사용자는 어느 답이 오래됐는지 구분할 수 없다. 사실 몇 개를 놓치는 것보다 더 깊은 문제도 있다. 구현 과정에서 날짜 anchor가 없는 모델은 학습 cutoff를 “현재”로 간주한다는 사실을 확인했다. 검색 query에 오래된 연도를 직접 넣어 잘못 검색하기까지 했다. Retrieval은 챗봇에 덧붙이는 부가 기능이 아니다. 과거 snapshot을 조회하는 interface와 assistant를 가르는 기능이다.

도구가 하나가 아니라 둘인 이유는 검색과 fetch가 서로 다른 질문에 답하기 때문이다. 검색은 수백 자 길이의 snippet을 반환하며, 결과끼리 내용이 엇갈리는 경우도 많다. “무엇이 존재하는가”를 찾는 도구다. Fetch는 페이지 하나의 전체 내용을 가져와 “그 페이지에 무엇이 적혀 있는가”를 답한다. 정확한 수치를 확인할 때 필요하다. 하나는 발견하고, 다른 하나는 검증한다. 모델은 매 turn마다 둘 중 하나를 쓸지 결정한다. 둘 다 필요 없는 turn에는 추가 비용이 없고, 사용 횟수 제한으로 필요한 turn의 비용도 제어한다.

나머지 기본 기능도 포함된다. Regenerate, streaming 도중 깨지지 않는 Markdown rendering, 실패 유형별 복구 경로다. 아래에서는 요청이 코드를 통과하는 순서에 맞춰 표의 항목을 위에서부터 살펴본다.

전체 구조는 어떻게 되어 있는가?

정적 페이지 하나, 작은 Python 파일 6개, gateway로 구성된다. 페이지가 message를 전송하면 서버가 컨텍스트를 조립하고, 필요하면 압축한 뒤 server-sent event로 completion을 streaming한다. 마지막에는 측정된 usage를 기록한다. 대화는 cat으로 바로 읽을 수 있는 JSON 파일이다.

static/index.html   the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py           routes; the per-turn pipeline: project → compress → stream → record
context.py          token budgeting, compression call, memory file, message assembly
tools.py            the /v1/messages transport used by tool-enabled turns
storage.py          one JSON file per conversation under .data/ (settings included)
config.py           env-driven settings: model lineup, budgets, prompts
presets/            system-prompt presets, one .txt each

server.py의 turn별 pipeline은 반드시 이해해야 할 핵심이다. 사용자가 보내는 모든 message는 동일한 5단계를 거친다. 각 단계는 아래의 한 section과 연결된다.

user message


[1] project the next request's size     last measured prompt_tokens
    │                                   + estimate(new message, pessimistic)
    │                                   + completion reserve

[2] over budget? ──yes──▶ compress      old turns ──▶ rolling summary
    │                                       └───────▶ durable facts ──▶ memory.md

[3] assemble and send                   [persona][memory][summary][date][history]
    │                                   cache mark after the system blocks

[4] stream SSE back to the page         delta / reasoning / search / fetch /
    │                                   compression notice / warning / error

[5] record measured usage               usage.prompt_tokens becomes step [1]'s
                                        input on the next turn

이 loop는 스스로 닫힌다. 5단계에서 측정한 값이 다음 turn의 1단계에서 신뢰하는 입력이 된다. 따라서 컨텍스트 예산은 로컬 추정치가 아니라 API가 실제로 계산한 수치를 기준으로 움직인다.

먼저 알아둘 architecture 분기가 하나 있다. 일반 turn은 /v1/chat/completions로 보내고, 웹 검색이나 fetch를 활성화한 turn은 /v1/messages로 보낸다. 선호에 따른 선택이 아니다. 도구 section에서 설명할 측정 결과 때문에 반드시 나눠야 한다.

Picker에는 어떤 모델을 넣어야 하는가?

.env에는 7개 모델이 포함되며 picker에서 tier별로 묶인다. 구분은 빠르고 저렴한 모델, 균형형, frontier다. Settings에서 gateway가 제공하는 id를 추가할 수도 있으며, 추가한 id는 .data/models.json에 저장된다. Lineup보다 중요한 것은 기본 모델이다. 이 MVP는 Gemini 3.6 Flash를 기본으로 사용하고 reasoning 값을 최저로 고정한다. Chat 답변은 single-step 작업이다. Single-step task에서 reasoning_effort: "minimal"을 적용하자 독자가 구분할 수 없을 정도로 동일한 품질을 유지하면서 측정된 호출당 비용이 기본값보다 91-97% 줄었다. 모든 모델이 이 값을 지원하지는 않으므로 model별 config에 둔다.

# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
    "gemini-3.6-flash": {"reasoning_effort": "minimal"},
}

DeepSeek V4 Flash는 두 역할로 lineup에 들어간다. Picker의 저가형 선택지이면서, 압축에 사용하는 기본 summarizer다. 요약 역시 single-step이며 어느 정도 품질 오차를 허용할 수 있는 작업이기 때문이다. 글쓰기 품질이 제품의 핵심이라면 Claude Sonnet 5를 선택한다. 다만 표시 가격이 아니라 token 수를 기준으로 예산을 잡아야 한다. 같은 text가 Sonnet 4.6보다 41% 많은 token으로 계산된다. GLM-5.2는 tool 사용이 많은 flow에서 가치가 있다. Warm tool-call turn의 측정 비용은 $0.0009로, Claude Opus 4.8의 $0.0051보다 낮았다. 다만 warm turn latency 중앙값이 6.6s이므로 사용자가 기다릴 것으로 예상하는 flow에만 쓰는 편이 좋다.

모델별 capability는 추정하지 않고 측정한다. 차이를 없는 것처럼 다루지 않고 code에서 그에 맞게 route를 나눈다. 이 lineup에서는 DeepSeek와 GLM만 작업 과정을 reasoning_content로 streaming한다. Claude 모델은 thinking parameter를 설정해도 이 gateway에서 thinking block을 반환하지 않는다. 따라서 UI는 실제로 표시할 수 있는 모델에만 실시간 “Thinking” panel을 제공한다.

대화 중 model을 바꿔도 구조적인 비용은 없다. History는 provider에 종속되지 않는 {"role", "content"} message로 저장된다. 저렴한 모델로 대화를 시작하고 어려운 질문이 들어오면 품질형 모델로 올릴 수 있다. 다만 보이지 않는 실제 비용이 하나 있다. Model을 바꾸면 이전 모델의 prompt cache를 버리게 되므로 전환 후 첫 turn은 전체 컨텍스트를 cold 가격으로 다시 읽는다.

모델은 매 turn마다 실제로 무엇을 보는가?

Prompt는 여러 layer로 구성하며, 한곳에서 의도한 순서대로 조립한다. 가장 안정적인 내용을 앞에 둔다.

Layer출처변경 주기
System prompt(persona)presets/*.txt 또는 자유 입력 text편집하지 않는 한 변경 없음
장기 메모리.data/memory.md드물게 변경(fact 추가)
Rolling summary압축압축이 실행될 때만 변경
날짜 anchor서버 clock매일
Tool instructionsSettings, tool turn에만 사용드물게 변경
최근 turn대화매 turn

가장 안정적인 내용을 먼저 둔다는 규칙은 caching 때문이다. Persona는 바뀌지 않고, memory는 드물게 바뀐다. Summary는 압축할 때만 바뀌며 history는 매 turn마다 바뀐다. 따라서 각 layer는 자신보다 변경 주기가 긴 모든 항목 뒤에 배치된다.

날짜 anchor는 이 stack에 반드시 필요하며, 위치도 중요하다. 날짜 anchor가 없으면 모델은 학습 cutoff를 “현재”라고 가정한다. 오래된 연도를 넣어 검색하고, release 표의 최신 행을 현재 정보처럼 해석한다. 다만 anchor에는 timestamp가 아니라 날짜만 넣는다. Prompt prefix에 포함되므로 일 단위보다 세밀하면 모든 요청에서 cache가 깨진다. 또한 persona, memory, summary 뒤에 배치해 자정이 지나도 이 항목들의 cache는 유지한다.

Prompt caching은 어떻게 동작하며, 어떤 모델이 지원하는가?

원리는 간단하다. Provider는 요청에서 byte 단위로 동일한 prefix를 cache하고, 이후에는 큰 폭으로 할인된 가격에 다시 제공한다. 처음 쓸 때만 작은 premium이 붙는다. Chat은 이 방식에 가장 잘 맞는 workload다. 새 요청은 이전 요청 뒤에 message 2개가 붙는 구조이므로 과거 전체가 자연스럽게 안정적인 prefix가 된다. Write premium은 바로 다음 turn에 회수된다. Anthropic 모델 기준 5분 TTL cache write는 1.25x이고 read는 약 0.1x다. Write 측 비용은 여기서 측정했다. cache_control marker를 사용하면 측정 비용이 Claude 3개 tier에서 88-89% 절감됐다. 실제 memory와 summary를 포함한 대화에서는 후반 turn 비용이 cold 첫 turn의 약 10분의 1로 줄어든다.

문제는 “caching”이 하나의 mechanism이 아니라는 점이다. Provider는 두 부류로 나뉘며, 이 lineup에는 둘 다 포함된다.

모델Caching 방식해야 할 일Hit 보고 field
Claude family명시적: cache_control breakpointmark 배치cache_read_input_tokens
DeepSeek V4 Flash암묵적: 자동 prefix없음prompt_tokens_details.cached_tokens
GLM-5.2암묵적: 자동 prefix없음prompt_tokens_details.cached_tokens
Gemini 3.6 Flash암묵적, marker를 받지만 무시동작하는 설정 없음이 gateway에서는 보고되지 않음

명시적 caching은 Anthropic 방식이다. Breakpoint를 직접 지정해야 하지만 어떤 일이 있었는지 정확히 알 수 있다. 쓴 token과 읽은 token을 별도 field로 제공하므로 turn별 할인 내역을 검증할 수 있다. OpenAI 계열의 암묵적 caching은 marker가 필요 없으며 prefix가 반복되면 자동으로 동작한다. 다만 provider가 결정하는 방식 그대로 받아들여야 하고, 사후에 cached_tokens field를 확인하는 것만이 유일한 증거다. 실제 hit 신뢰도는 provider마다 크게 다르다. MVP는 두 방식을 동시에 지원한다. Prompt를 안정적인 순서대로 배치해 암묵적 caching의 조건을 맞추고, 항상 명시적 marker도 보낸다. 암묵적 방식을 쓰는 provider는 marker를 문제없이 무시한다. 하나의 요청 형태로 각 모델이 지원하는 수준까지 caching을 활용한다.

명시적 mark의 위치는 문서가 아니라 측정으로 정했다. 이 gateway에서 cache_control은 system block에 있을 때만 적용되며, 다른 위치에서는 아무 경고 없이 무시된다. 흔히 사용하는 multi-turn pattern처럼 최신 user message에 mark하면 전체 history를 cache할 수 있을 것 같지만, 실제로는 cache read token이 0으로 나오고 전체 가격을 낸다. 따라서 MVP는 system section 끝에 mark를 배치하며, cache 가능한 prefix는 persona + memory + summary다. 여기서 두 가지 결과가 나온다. 첫째, prefix가 모델의 최소 기준인 약 1,024 tokens를 넘어야 caching이 시작된다. 새 대화에서는 아무것도 cache되지 않지만 memory와 summary가 쌓인 대화에서는 전체가 cache된다. 둘째, prefix를 편집하면 비용이 든다. System prompt를 바꾸면 첫 byte부터 cold 상태가 된다. 압축하면 summary를 다시 쓰므로 1회 cold read가 발생한다. Model을 바꾸면 이전 model의 cache 전체를 버린다.

TTL은 어디서나 비슷한 형태의 config 결정이다. 기본 5분 cache는 활발히 이어지는 대화를 처리한다. 잠시 자리를 비웠다가 돌아오는 사용자를 지원하려면 write premium을 2배 내고 1시간 tier를 쓸 수 있다. CACHE_TTL=1h 한 줄만 바꾸면 된다. 실제 이득 여부는 사용자가 1시간 안에 돌아오는지에 달려 있다.

컨텍스트 한계에 가까워졌는지 어떻게 아는가?

추정하지 말고 측정해야 한다. 현재 대화 중인 모델의 정확한 token 수는 이전 요청에서 API가 반환한 usage.prompt_tokens뿐이다. Vendor마다 local estimator의 오차가 다르며 그 폭도 작지 않다. 같은 vendor의 두 모델조차 동일한 영문 text의 token 수가 41% 차이 났고(측정 결과), vendor가 다르면 차이는 더 커진다. MVP가 로컬에서 token을 세는 대상은 API가 아직 보지 못한 현재 message 하나뿐이다. 이 추정치는 의도적으로 올림한다.

# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
    if message_count <= config.KEEP_RECENT_MESSAGES:
        return False  # nothing old enough to fold away
    projected = (
        last_prompt_tokens                    # measured, last response
        + estimate_tokens(pending_text)       # estimated, pessimistic
        + config.MAX_COMPLETION_TOKENS        # worst-case reply
    )
    return projected > (budget or config.CONTEXT_BUDGET_TOKENS)

과대 추정하면 한 turn 일찍 압축하고, 저렴한 summary 호출 1회가 추가된다. 과소 추정하면 window를 넘겨 요청이 실패하거나 조용히 잘릴 수 있다. 손실의 비대칭 때문에 올림하는 편이 맞다.

“측정” 자체에도 함정이 있다. Provider마다 prompt_tokens에 cached token을 포함하는지가 다르다. 이 수치를 잘못 해석하면 단순한 표시 오류로 끝나지 않는다. 컨텍스트 예산의 기준값이기 때문이다. Cache 크기만큼 과소 계산하면 압축이 영원히 실행되지 않고 window가 조용히 넘친다. 서버는 값을 정규화한다. prompt_tokens가 보고된 cached token보다 작다면 전체 prompt일 수 없으므로 각 부분을 더한다. 자체 추정값도 측정값의 하한으로 유지한다. Provider별 usage 보고 방식은 별도의 주제이며, LLM Token Usage 구조에서 다룬다.

기본 예산은 102,400 tokens다. 일반적인 대화로는 도달하기 어렵다. 이 값은 모델의 한계가 아니라 운영 예산이며, turn 비용이 가치보다 커지는 지점에 맞춰야 한다. 동작을 확인하려면 Settings에서 한 대화의 window를 수천 token으로 낮춘 다음 긴 message 몇 개를 붙여 넣으면 된다. 예산은 대화별로 적용된다. Header의 컨텍스트 bar는 측정된 값으로 그리며, persona, memory, summary, history가 window를 얼마나 차지하는지 구간별로 보여준다.

Header strip. Cached 비율과 비용을 포함한 session 합계가 보이고, 컨텍스트 bar는 16k 예산을 기준으로 system, memory, summary, history 구간을 표시한다

한계에 도달하면 어떻게 되는가?

잘라내지 않고 압축한다. Truncation은 대화 앞부분을 잊게 만들며, 사용자는 이를 bot이 멍청해진 것으로 받아들인다. 대신 가장 최근 message를 제외한 모든 항목을 저렴한 summarizer 모델로 rolling summary에 합친다. 기본 설정은 마지막 8개 message를 남긴다. Summary는 system block으로 이후 요청에 계속 포함된다. 최근 window는 원문 그대로 유지하므로 bot의 단기적인 말투는 바뀌지 않는다. 먼 과거만 손실 압축된다. 아무 내용도 조용히 버리지 않는다. 압축이 실행되면 transcript에 접힌 message 수와 summary 크기를 알리는 notice가 나타난다.

한 번의 압축 과정은 다음과 같다.

before   (projected next request 103.1k > 102.4k budget)

  [persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
                            old enough to fold            last 8, kept

one summary call to deepseek-v4-flash:
  in:   prior summary + m1 ... m34
  out:  {"summary": "one dense paragraph: topics, decisions,
                     open questions, promises",
         "facts":   ["prefers Python", "timezone is UTC+8"]}

after

  [persona][memory + new facts][summary][date][ m35 ....... m42 ]
   unchanged  grows rarely      replaced         verbatim

압축 호출의 신뢰성을 좌우하는 설계 포인트는 3가지다.

  • 새 summary가 이전 summary를 흡수한다. 두 번째 압축에서는 첫 번째 summary와 새로 오래된 message를 함께 합친다. Summary의 summary가 끝없이 이어지는 chain 대신 rolling paragraph 하나만 유지된다.
  • 실패해도 turn은 계속된다. Summary 호출이 실패하면 서버는 가장 오래된 turn을 요약 없이 버리고, 어떤 내용을 잃었는지 사용자에게 정확히 알린 뒤 message에 계속 답한다. 부정확한 memory가 죽은 챗봇보다 낫다. Demo에서는 실행되지 않는 error path가 production에서는 호출을 발생시킨다.
  • Prompt는 설정으로 제공하되 guard 하나를 둔다. Summarizer에 무엇을 보존하라고 요청하는지가 대화의 기억을 결정하므로 compression prompt는 대화별로 편집할 수 있다. 서버는 {transcript} placeholder를 제거한 편집을 거부한다. 그런 prompt는 아무것도 요약하지 않으며, 첫 overflow가 발생할 때 한참 늦게 문제를 드러낸다. COMPRESS_MAX_TOKENS에도 같은 원칙을 적용한다. Output cap은 prompt가 요구하는 양보다 여유 있게 잡는다. 문장 중간에서 잘린 summary를 이후 모든 turn이 물려받게 해서는 안 된다.

Cache 관점에서 압축은 cold re-read 1회만 발생시킨다. Summary block이 바뀌므로 memory block 이후는 한 요청 동안 cold 상태가 되고, 이후에는 더 작아진 새 prefix가 다시 cache된다. 비용은 summary 호출 1회와 cold read 1회다. 그 대가로 이후 모든 turn이 더 작고 warm 상태인 컨텍스트를 사용한다.

Session이 바뀌어도 memory를 유지하려면 어떻게 해야 하는가?

한 단계 뒤에서 보면 bot에는 scope와 lifetime이 다른 3개의 memory store가 있다. 이 구분에서 이 section의 모든 설계가 나온다.

StoreScopeLifetime전송 크기
원문 그대로의 최근 turn현재 대화압축으로 합쳐질 때까지마지막 8개 message의 전체 text
Rolling summary현재 대화대화와 함께 삭제200단어 미만의 paragraph 하나
memory.md fact모든 대화사람이 정리할 때까지몇 줄

Summary와 fact는 비슷해 보이지만 수명이 다르므로 분리한다. Rolling summary는 결정, 미해결 질문, assistant가 약속한 일처럼 대화에 종속된 내용이다. 대화와 함께 사라지는 것이 맞다. 반면 사용자에 관한 지속적인 fact인 “Python을 선호함”, “timezone은 UTC+8” 같은 정보는 다음 주에도 유효하다. 현재 대화와 함께 삭제하면 낭비다.

Compression prompt는 두 결과를 동시에 요청하고 JSON으로 반환한다. summary string과 facts array다. Fact는 .data/memory.md에 append되며, 모든 대화가 이 파일을 system block으로 load한다. 별도 pass가 아니라 압축 시점에 추출하는 이유는 비용 때문이다. 압축할 때는 이미 모델이 오래된 turn을 다시 읽고 있다. 어차피 지불하는 token으로 fact도 함께 수집할 수 있다. 호출 한 번으로 결과 두 개를 얻는다.

MVP의 중복 제거는 완전히 같은 line만 비교한다. 한계는 금방 드러난다. 한 번의 압축에서 “User prefers Python over Node.js”를 저장하고, 나중에는 “The user prefers Python over Node.js.”를 또 추가할 수 있다. Semantic dedup을 하려면 각 후보와 store를 embedding하거나 LLM으로 비교해야 한다. 실제 비용이 드는 별도 기능이므로 MVP는 단순한 방식을 쓰고 그 사실을 명시한다. Memory file이 Markdown인 이유도 사람이 읽고 정리할 수 있게 하기 위해서다. Settings에서는 편집 가능한 text box로 노출한다. 동시에 투명성 panel 역할도 한다. Bot이 사용자에 대해 아는 내용은 직접 열어볼 수 있는 파일이다.

사용자가 Stop을 누르면 어떻게 되는가?

Provider가 생성을 멈춘다. 이것이 이 기능의 핵심이지만 많은 chat UI는 실제로 구현하지 않는다. Output만 숨기고 completion을 계속 실행하는 Stop은 아무도 읽지 않을 token 비용을 계속 청구한다.

동작은 3단계로 이어진다. Reply가 streaming되는 동안 Send button은 같은 위치에서 확인 절차 없이 Stop으로 바뀐다. 이를 누르면 browser의 fetch가 abort된다. 서버는 event 사이마다 disconnect를 확인하고 감지되면 streaming loop를 종료한다. 그러면 upstream 연결도 닫혀 provider가 생성을 멈춘다. 이미 도착한 text는 interrupted 상태로 저장되며 transcript에서 dashed border로 표시된다. Tab을 닫거나 연결이 끊겨도 같은 경로가 실행된다. 서버 입장에서는 모두 같은 event이기 때문이다.

이 과정에서 persistence는 정확히 한 번만 실행되어야 한다. Code는 이를 명시적으로 처리한다. 정상 완료 시 streaming loop 내부에서 저장하고, stream이 깨지면 exception handler에서 저장한다. Hard disconnect에서는 framework가 generator를 취소해 loop 이후 code가 실행되지 않으므로 finally block에서 저장한다. Function은 idempotent하므로 먼저 실행된 경로만 유효하다.

Stop은 Regenerate와 함께 쓴다. 원하지 않는 답변을 중단한 뒤 다시 입력하지 않고 재생성할 수 있다. Regenerate는 중단된 답변을 포함한 마지막 assistant turn을 제거해 대화가 다시 user message로 끝나게 한 다음, 현재 model로 다시 답한다. Model dropdown은 다음 turn에 적용되므로 Stop과 Regenerate를 함께 쓰면 같은 질문을 더 강한 모델에 다시 물을 수도 있다.

Turn이 실패하면 어떻게 되는가?

실패는 6가지 중 하나로 분류되며, 빨간 error line 하나로 퉁치지 않고 각각 다른 복구 경로를 제공한다. server.pyclassify()는 upstream exception을 failure kind로 변환하고, UI는 kind를 action에 연결한다.

실패사용자에게 보이는 내용복구 방법
Network error유형을 명확히 표시Retry button
Rate limitretry-after가 있으면 대기 시간 표시Retry button
잘못된 API key수정할 env var 이름을 직접 표시.env 편집
Content filter”같은 text로 다시 시도해도 거부된다”고 표시Edit & resend
Streaming 중 실패partial reply를 유지하고 interrupted로 표시Retry
압축 실패버린 내용을 명시한 warning불필요, turn은 계속 진행

두 가지 invariant가 대부분의 문제를 해결한다. 첫째, user message를 절대 잃지 않는다. 첫 event 전에 요청이 실패하면 서버는 대화에서 해당 message를 rollback하고, UI는 draft를 composer에 되돌린다. 따라서 retry해도 중복 전송되지 않는다. Text가 일부 도착한 뒤 stream이 깨지면 partial reply를 보존하고 표시한다. 둘째, 거부된 reply에는 retry가 아니라 rewind를 제공한다. 서버가 user message를 history에서 꺼내 composer로 돌려보내 사용자가 편집하게 한다. Content filter에 같은 내용을 그대로 retry하면 영원히 같은 결과만 나온다.

두 invariant 뒤에는 더 미묘한 저장 규칙이 있다. 빈 assistant turn은 절대 저장하지 않는다. 빈 assistant message를 upstream에 replay하면 일부 provider가 강제하는 user/assistant 교대 규칙을 깨뜨린다. 이후 다른 turn에서 잘못된 message를 지목하는 error가 발생하므로 debug하기 매우 어렵다. MVP는 wire format에서 빈 content를 제거하고 text가 없는 reply를 저장하지 않는다. 다만 보존할 가치가 있는 search나 reasoning activity가 있다면 activity는 저장하고, 외부로 보낼 때 빈 text만 제거한다.

Code에 tool loop를 만들지 않고 웹 검색을 구현하려면 어떻게 해야 하는가?

Gateway가 server-side에서 tool을 실행하므로 담당하는 code가 달라진다. 전통적인 function calling은 직접 관리하는 loop다. 모델이 tool_calls block을 반환하면 code가 tool을 실행하고 tool_result message를 append한 뒤 전체 대화를 다시 보낸다. 호출할 때마다 이 과정을 반복한다. Server-side tool은 이 loop를 gateway로 옮긴다. 요청에 synthorai:web_search 또는 synthorai:web_fetch를 선언하면 gateway가 LLM과 tool provider 사이에서 모델의 tool call을 third-party 검색 및 fetch API로 전달하고, 결과를 다시 model context에 넣는다. Tool이 특별한 방식으로 동작하는 것은 아니다. 검색과 fetch는 각각 외부 API 호출이며, 그래서 사용 횟수에 따라 과금된다. 이 codebase에는 tool_result round trip이 없고 rendering할 event만 있다.

browser        server (tools.py)     Synthorai gateway       LLM / tool providers
   │ POST /chat    │                     │
   ├──────────────▶│ declare tools +     │
   │               │ budget note         │
   │               ├────────────────────▶│── question + tools ──▶ [LLM]
   │               │                     │◀── tool_use: search ── [LLM]
   │               │                     │── query ─────────────▶ [search API]
   │               │  search results     │◀── results ─────────── [search API]
   │ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
   │               │                     │◀── tool_use: fetch ─── [LLM]
   │               │                     │── URL ───────────────▶ [fetch API]
   │               │  fetch result       │◀── page text ───────── [fetch API]
   │ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
   │ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
   │ SSE: done     │                     │

역할은 명확히 나뉜다. 모델은 검색할지, snippet만으로 충분한지 아니면 페이지를 fetch해야 하는지, 언제 멈추고 답을 쓸지 결정한다. Gateway는 검색 또는 fetch provider를 호출하고 결과를 모델에 다시 전달한다. 서버는 지나가는 event를 streaming해 보여주기만 한다. 두 tool은 기본으로 활성화되며, 필요하지 않은 turn에는 추가 비용이 없다. 따라서 산술 질문은 무료로 유지되고 “현재 무엇인가” 같은 질문만 검색한다. Gateway 측 loop에도 제한이 있다. Server-side 검색 loop가 자체 반복 제한에서 멈춰 pause_turn이 발생하면 transport가 해당 turn을 다시 보내 이어서 실행하며, 최대 3번까지 반복한다.

구현하면서 확인한 4가지 사실은 정상 동작 방식보다 중요하다.

  • 무엇을 볼 수 있는지는 endpoint가 결정한다. 두 endpoint 모두 검색을 실행하고 비용을 청구한다. Anthropic과 Gemini channel에서 2026-08-04에 측정했다. 하지만 검색 자체를 노출하는 endpoint는 /v1/messages뿐이다. Query와 결과 URL이 typed block으로 도착하므로 code에서 표시하고 저장할 수 있다. /v1/chat/completions에서는 같은 검색이 보이지 않게 실행된다. HTTP 200이 반환되고 답변은 “Based on the search results…”로 시작하지만, streaming과 non-streaming 어느 경로에도 citation이나 annotation이 없다. 비용을 지불한 검색을 검증할 수 없다면 아무 error 없이 출처만 사라지는 silent degradation이다. tools.py를 일반 요청에 field 하나만 추가하는 대신 별도 transport로 만든 이유가 바로 이것이다.
  • 모델에게 예산을 문장으로 알려야 한다. 기본 제한은 turn당 검색 3회, fetch 2회이며 gateway가 조용히 적용한다. 제한을 모르는 모델은 tool을 무제한으로 쓸 수 있다고 가정해 계획하고, 생각 도중 마지막 횟수를 소진한다. 그러면 “Let me search for…”에서 turn이 끝나고 답변이 나오지 않는다. MVP는 예산을 설명하는 문장 하나를 주입한다. 문구 자체가 측정 가능한 비용 조절 수단이다. 테스트에서 안내가 없으면 tool 예산을 전부 쓰고 문장 중간에 끝났다. 지나치게 엄격한 안내는 모델이 포기하고 사용자에게 페이지를 직접 읽으라고 했다. 현재 사용하는 문구는 검색을 완전히 건너뛰고 권위 있는 페이지 2개를 fetch했으며, 세 경우 중 비용도 가장 낮았다. Settings에서 편집할 수 있으므로 문구를 바꾸고 turn별 비용 line을 확인할 수 있다.
  • 횟수 제한만이 유일한 제동 장치다. 두 tool 모두 사용 횟수에 따라 과금되고, 몇 번 실행할지는 모델이 결정한다. 테스트에서 제한 없는 turn 하나가 output token 하나를 billing하기 전에 검색 3회와 fetch 2회를 실행했다.
  • 실패하더라도 turn 전체를 죽이지 않는다. 두 failure는 같은 방식으로 처리한다. Tool을 제거하고 한 번 retry한 뒤 사용자에게 알린다. Web fetch를 사용하려면 key에 entitlement가 필요하다. 권한이 없으면 기능만 빠지는 대신 전체 요청이 web_fetch_not_enabled로 실패한다. Gemini도 tool 선언은 받아들이지만 실제 호출 순간 error를 반환한다. Function call is missing a thought_signature다. 선택 기능 하나 때문에 user turn 전체를 잃는 것은 잘못된 trade-off다.

모델이 답에 도달하기까지 수행한 모든 작업, 즉 생각하고 검색하고 읽은 과정은 reply 위 activity trail에 실시간으로 streaming된다. 기본 상태에서는 “Thought for 5s · Searched the web · Read 2 pages” 같은 흐린 한 줄로 접혀 있으며, 펼치면 query, 결과 domain, reasoning text가 포함된 timeline을 볼 수 있다. Trail은 message와 함께 저장된다. Reload 후 사라지는 trail로는 나중에 답변을 audit할 수 없기 때문이다. Citation은 reply 아래 번호가 붙은 domain chip으로 표시된다.

Reply 위에 펼쳐진 activity trail. Query와 결과 2개가 포함된 검색 단계, 페이지와 크기가 표시된 fetch 단계, 아래쪽 source chip이 보인다

Streaming 중에도 Markdown이 깨지지 않게 rendering하려면 어떻게 해야 하는가?

누적 text를 안전하게 rendering할 수 있는 안정된 prefix와 아직 안전하지 않은 tail로 나눈다. 닫히지 않은 construct를 rendering하면 다음 token이 이를 닫을 때 layout이 튄다. 따라서 renderer는 마지막으로 완성된 line에서 자른다. Opening code fence가 아직 닫히지 않았다면 해당 fence 이후 전체를 닫힐 때까지 plain text로 유지한다. Bubble은 안정된 prefix가 실제로 늘어날 때만 다시 rendering하므로 token마다 DOM을 재구성하지 않는다. Code block에는 copy button이 붙는다. Renderer는 library 없이 vanilla JS 약 100줄로 구현했다. Markdown library에 대한 의견이라기보다 MVP에 필요한 수준을 보여주는 선택이다.

대화 비용은 얼마인가?

Gateway의 usage에 기록된 만큼이다. MVP의 역할은 이를 정확히 해석하는 것이다. 두 가지 정규화 문제가 특히 중요했다.

  • Vendor마다 field 이름이 다르다. Cached token만 봐도 Anthropic 모델은 cache_read_input_tokens, DeepSeek와 GLM은 prompt_tokens_details.cached_tokens만 보고하며 Gemini는 둘 다 제공하지 않는다. Stats line은 존재하는 field를 읽어 “cached”가 어디서나 같은 의미가 되게 한다.
  • 비용 field가 없다고 0은 아니다. Gateway는 일부 turn에서 cost를 보고하고 다른 turn에서는 생략한다. Tool을 선언했지만 사용하지 않은 turn에서 재현된다. Cost field가 없는 turn은 0으로 더하지 않는다. 대신 개수를 세어 “+N unreported”로 표시한다. 누락된 turn을 조용히 제외한 합계는 실제 bill처럼 보이지만 사실 하한일 뿐이다.

Header는 input, output, cached 비율, 검색, fetch, 비용, turn의 session 누적 합계를 표시한다. 각 reply에도 자체 line이 있다. 확인해야 할 핵심 수치는 cached 비율이다. 앞서 설명한 caching이 실제 대화에서 얼마나 동작했는지 보여준다.

모든 설정은 어디에 두는가?

하나의 Settings panel에 둔다. 여기서 참고할 만한 설계 결정은 scope다. 대부분의 설정은 대화 하나에 속한다.

Settings panel. 제거 가능한 chip 형태의 model lineup과 추가 field, 대화별 context window, 편집 가능한 system prompt 위 preset dropdown, 아래쪽 memory section이 보인다

Global 설정은 사용자 자체를 설명하는 2개뿐이다. Gateway가 제공하는 모든 id를 추가할 수 있고 .data/models.json에 저장되는 model lineup, 그리고 편집 가능한 text box로 노출되는 장기 memory file이다. 나머지는 모두 현재 대화에만 적용된다. presets/의 preset을 dropdown으로 고를 수 있는 system prompt, 컨텍스트 예산, 웹 검색과 fetch toggle, tool instructions, compression prompt다. 따라서 같은 모델을 쓰면서도 persona, 예산, tool이 서로 다른 대화 2개를 나란히 실행할 수 있다. 이 글의 주장을 그대로 믿는 대신 직접 재현할 수 있다.

각 field에는 작은 info marker가 있다. Hover하면 잠깐 보고 click하면 고정할 수 있다. 설정 변경에 따른 비용이 UI에서 보이지 않기 때문에 이를 설명한다. Persona를 편집하면 첫 byte부터 cache가 무효화되어 cold turn 1회가 든다. Tool instructions의 문구를 바꾸면 turn별 bill이 달라진다. 예산을 낮추면 압축이 더 일찍 실행된다. 상단 bar의 model dropdown만 즉시 저장된다. 대화 중 모델 전환은 configuration 변경이 아니라 핵심 action이기 때문이다.

무엇을 제외했고, 추가하려면 어디에 연결해야 하는가?

의도적으로 제외한 기능과 연결 지점은 다음과 같다.

  • Auth와 multi-user — route 앞에 session layer를 둔다. 대화에는 이미 id가 있으므로 user별 scope를 적용하는 일은 filename prefix를 붙이는 수준이며 redesign이 아니다. 그전까지는 localhost 도구다. 처리하는 모든 요청이 API key를 사용하므로 현재 상태로 public internet에 노출하면 안 된다.
  • Rate limiting — 같은 위치에 같은 이유로 둔다. Shared deployment를 보호하는 기능이며, 현재는 shared deployment가 아니다.
  • RAG — 검색된 document는 summary 뒤, 최근 message 앞에 둔다. 자주 바뀌는 content를 prefix 뒤쪽에 배치해야 cache된 persona와 memory block이 흔들리지 않는다. Model 측에서는 1M 컨텍스트 모델이 window의 가치를 발휘하는 지점이다.
  • Client-side function calling — streaming loop에 tool_calls branch와 executor를 추가한다. GLM-5.2 구현기에서는 이때 마주칠 provider별 contract 차이를 다룬다. 위의 gateway-side tool은 의도적으로 이 loop를 피한다.
  • Semantic memory dedup — 후보 fact마다 embedding을 만들고 store와 비교해 새로운 항목만 남긴다. Memory file format은 바뀌지 않는다.
  • Syntax highlighting — 실제로 사람들이 쓰는 기능은 copy button이다. Highlighting은 정식 frontend를 만들 때 library를 선택할 문제다.
  • 실제 databasestorage.py는 함수 몇 개뿐이다. SQLite로 옮기는 데 반나절이면 충분하다. 사용자가 생기기 전까지는 JSON file 자체가 의도한 선택이다.

함께 읽을 글

← 활용 사례