-
로컬 챗봇 시리즈 #1 — 메시지 편집은 왜 그렇게 단순해야 하나: 컨텍스트 엔지니어링 관점에서IT 2026. 5. 8. 21:00
들어가며 — 메시지 편집이 가장 재미있는 기능이었다
집에 띄워둔 로컬 LLM 챗봇에 ChatGPT/Claude.ai 수준 UX를 한 묶음으로 채워넣었다. 풀텍스트 검색, Custom Instructions, Cmd-K 팔레트, 대화 export. 다 들어가니 챗봇이 "쓸 만한" 인상을 풍긴다.
그중에서 가장 단순해 보이는데 가장 재미있었던 것이 메시지 편집이다. 사용자가 과거 자기 메시지의 ✎를 누르고 한 줄 고쳐서 다시 보내는 그것. 막상 만들어보니 "챗봇의 기억이란 무엇인가"라는 본질적 질문에 닿았다. 이번 글은 그 메서드 한 개와 그 너머의 의미를 풀어 쓴다.
1. 사용자가 ✎를 누를 때 무엇이 일어나야 하나
요구사항은 직관적이다.
- 과거 user 메시지에 마우스 올리면 ✎ 버튼 노출
- 클릭 → 그 자리에서 textarea로 변형, 텍스트 수정 가능
- 저장 → 그 이후 모든 메시지를 컨텍스트에서 잘라내고 새 user 메시지로 재추론
마지막 줄이 핵심이다. "그 이후 모든 메시지"가 정확히 무엇을 뜻하는가. 그리고 그것을 누가 어떻게 잘라내는가.
2. 구현 핵심 — 메서드 한 개의 의미
memory-store(메시지 본문이 사는 곳)에 메서드를 하나 추가했다. 정말 그것뿐이다.
# short_term.py def truncate_after(self, index: int) -> int: if not self._current_session or index < 0: return 0 msgs = self._current_session.messages if index >= len(msgs): return 0 removed = len(msgs) - index self._current_session.messages = msgs[:index] return removed코드 자체는 6줄. 하지만 이 메서드의 의미는 한 줄로 요약된다 — "index 위치에서부터 끝까지 메시지 리스트를 잘라낸다. 그게 전부다."
여기서 의도적으로 "안 한" 것들이 더 중요하다.
- 새 user 메시지를 추가하지 않는다
- LLM 호출을 트리거하지 않는다
- 잘라낸 메시지에서 파생된 메모리·RAG·도구 호출 결과를 정리하지 않는다
- 되돌리기 위한 backup을 두지 않는다
왜 이렇게 옹졸하게 만들었는가? 편집 엔드포인트가 "잘라내기 한 가지"만 책임지면, 새 user 메시지를 받는 일은 평소 채팅 흐름이 그대로 한다. 즉 클라이언트 입장에서는 다음 두 단계로 이뤄진다.
POST /api/sessions/{id}/edit-and-resubmit— 잘라내기만POST /v1/chat/completions— 평상시처럼 새 메시지로 호출
"메시지 편집"이라는 한 줄짜리 사용자 의도가 백엔드에서는 이미 존재하는 두 코드 경로의 조합으로 끝난다. 새 코드 경로를 만들지 않는다는 것 — 이게 이 메서드의 진짜 가치다.
3. 컨텍스트 엔지니어링 관점에서 본 이 메서드
여기서 한 발 물러나 본다. LLM은 stateless다. 챗봇이 "기억한다"고 느껴지는 것은 매 요청마다 messages 배열을 통째로 다시 보내기 때문이지, 모델 안에 무엇이 남아있어서가 아니다.
그러니까 챗봇 UX의 거의 모든 "기억" 관련 행동은 사실 "이번 요청의 messages 배열을 어떻게 조립할까"의 문제다. 새 대화는 빈 배열로 시작, 평소 채팅은 누적, 재생성은 마지막 assistant만 빼고 다시, 그리고 메시지 편집은 — 특정 인덱스부터 잘라낸 뒤 새 user 메시지로 이어 보내기.
같은 사용자, 같은 시작점, 다른 분기 — Git의 force-push와 비슷한 멘탈 모델이다. 어떤 분기를 모델에 보여주느냐에 따라 모델의 "현재 사고"가 달라진다.
이런 관점에서 보면 편집 엔드포인트의 단일 책임이 왜 그렇게 중요한지가 드러난다. 만약 편집이 "메시지 자르기 + 새 user 메시지 추가 + LLM 호출"을 한 번에 했다면, 컨텍스트 조립 로직이 두 군데(평상 채팅 / 편집)에 중복된다. 두 곳 중 한쪽만 도구 호출 컨텍스트(
set_search_context)나 프로젝트 시스템 프롬프트를 잊으면 모델은 다른 컨텍스트를 본다. 디버깅 지옥."잘라내기"만 분리하면 그 다음은 평상시 코드가 알아서 한다. 컨텍스트 엔지니어링은 "조립 책임을 한 곳으로 모으기"가 절반이다.
4. 트레이드오프 — 단순함의 비용
4-1. 첫 응답이 살짝 느려진다 (vLLM prefix caching 무효화)
vLLM은 매 요청마다 system prompt + 첫 N개 메시지를 처리한 결과(KV cache라고 부르는 중간 텐서)를 메모리에 보관한다. 다음 요청이 같은 prefix를 보내면 그 부분의 계산을 스킵하고 새 토큰부터 처리한다 — 이걸 prefix caching이라 한다.
편집 후엔 prefix(system + 잘라내지 않은 앞부분 메시지들)는 그대로지만 잘라내기 시점부터 끝까지 새 시퀀스가 들어가므로, 그 새 부분에 한해 다시 계산해야 한다. 사용자 체감으로는 "편집 후 첫 응답만 평소보다 1~2초 늦게 시작" 정도. 그 다음 메시지부터는 새 분기의 prefix가 캐시에 다시 쌓여 평소 속도.
4-2. 편집은 되돌릴 수 없다
잘려나간 메시지들은 영구 손실된다. undo 버튼은 없다. 사용자가 "어, 그 답이 사실 더 좋았는데" 했을 때 곤란하다.
이걸 완화하는 두 가지 방안:
- 대화 export 기능과의 짝꿍 — 편집 전에 마크다운/JSON 다운로드를 권장. 같은 페이지에 노출.
- "재생성" 버튼은 다른 멘탈 모델 — 사용자가 직전 답만 마음에 안 들 때는 ✎ 편집 대신 ↻ 재생성을 쓰면 컨텍스트는 그대로 두고 마지막 assistant만 다시 만든다. UI에서 두 버튼이 가깝게 있으니 선택을 안내.
4-3. "분기"인데 시간선은 단일하다
Git에서는 force-push 하더라도 reflog가 남아 옛 분기를 되찾을 수 있다. 이 챗봇은 그렇지 않다. "하나의 세션 = 하나의 시간선"이라는 단순한 모델을 유지하기 위해서다.
두 분기를 나란히 보고 비교하고 싶다면? 더 좋은 도구는 "세션 복제 후 편집"이다 (Phase 1 묶음에는 미포함, 후속 작업). 그러면 원본도 살아있고 새 분기도 따로. 다만 멘탈 모델이 복잡해져서 일반 사용자에겐 ✎ 한 버튼이 직관적이라 우선 단일 시간선으로 시작했다.
5. 마무리 — 단순함이 컨텍스트 엔지니어링이다
처음에는 "메시지 편집" 엔드포인트에 자르기·새 메시지 추가·LLM 호출까지 다 넣을 뻔했다. 그러면 평상 채팅 흐름의 코드가 두 곳으로 갈라지고, 도구 컨텍스트(
set_search_context)·프로젝트 시스템 프롬프트·메모리 회상 같은 장치가 어느 한쪽에서 빠질 가능성이 생긴다."잘라내기 한 가지만 한다"로 책임을 줄이니, 평상시 만들어둔 컨텍스트 조립 로직이 그대로 재사용된다. 새로 추가된 코드는 6줄짜리 메서드 하나. 그런데 이 6줄이 챗봇이 사용자의 "한 번 더 해보자"에 응답하는 핵심 primitive가 됐다.
모델이 stateless라는 사실을 진지하게 받아들이면, 챗봇 UX는 결국 "이번 요청에 보낼 messages 배열을 어떻게 만들 것인가"의 디자인이다. 메시지 편집은 그 시간선을 사용자가 명시적으로 분기시키는 도구다 — 그 도구는 단순할수록 안전하고, 컨텍스트 조립 책임이 한 곳에 모일수록 신뢰할 만하다.
다음 편에서는 세션을 묶는 Project(Workspace) 계층을 다룬다. 같은 모델이 작업 맥락을 알아채는 첫 번째 장치다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
로컬 챗봇 시리즈 #6 — LLM을 우회하는 슬래시 커맨드: 작은 모델의 메타 판단 한계를 사용자가 보완하는 채널 (0) 2026.05.08 로컬 챗봇 시리즈 #5 — 다른 프로젝트의 venv를 subprocess로 빌려쓰기: 의존성 추가를 거부하는 영리함 (0) 2026.05.08 로컬 챗봇 시리즈 #4 — Vision 32B에서 7B로, 그리고 포기까지 — 두 vLLM 동거 시행착오 (0) 2026.05.08 로컬 챗봇 시리즈 #3 — 도구에 '지금 누구의 첨부인지' 어떻게 알려주나: ContextVar 패턴 (0) 2026.05.08 로컬 챗봇 시리즈 #2 — Project 시스템 프롬프트는 왜 글로벌 Custom Instructions '다음에' 와야 하나 (0) 2026.05.08 Ralph Loop — bash while true + LLM CLI가 만든 어이없게 강력한 에이전트 패턴 (0) 2026.05.07 GPU 스케줄러를 Ollama warmup에서 vLLM 컨테이너로 옮긴 과정 — 시작·종료 시퀀스를 다시 짜다 (0) 2026.05.06 RAG 청크 맥락에서 thinking을 꺼야 하는 이유 — enable_thinking=False가 필요한 순간 (0) 2026.05.06 OpenAI-compat 표준화로 어댑터 100줄 들어내기 — passthrough가 가져온 코드 청결도 (0) 2026.05.06 vLLM reasoning_parser — <think> 블록을 정규식 말고 구조로 받는 법 (0) 2026.05.06