-
로컬 챗봇 시리즈 #2 — Project 시스템 프롬프트는 왜 글로벌 Custom Instructions '다음에' 와야 하나IT 2026. 5. 8. 21:30
들어가며 — 70개 세션을 묶을 컨테이너가 필요해졌다
로컬 챗봇 사이드바에 세션이 70개쯤 쌓이자 평면 리스트로는 도저히 안 됐다. 가족 일정, vLLM 디버깅, 여행 계획이 시간 순서대로 뒤섞여 있다. ChatGPT와 Claude.ai의 Projects는 같은 문제에 대한 답이다 — 여러 세션을 묶고, 그 묶음에 공통 시스템 프롬프트와 메모리를 부여하면 모델이 작업 단위로 다른 페르소나처럼 동작한다. 같은 발상을 로컬에 가져왔다.
UI 설계나 데이터 모델은 평이하다. 진짜 흥미로웠던 결정은 따로 있다 — "프로젝트별 시스템 프롬프트(
instructions)를 LLM에게 보낼 때 어디에 끼워 넣을 것인가". 글로벌 Custom Instructions, 프리셋, 첨부 목록, 메모리, 사용자 메시지가 다 한 컨텍스트에 들어가는데 그 순서가 모델의 행동을 바꾼다. 이번 글은 그 한 가지 결정을 풀어쓴다.
1. 시스템 프롬프트는 단일 문자열이 아니라 '조립된 결과물'이다
외부에서 보면 챗봇은 system 메시지 하나, user 메시지 하나를 보내는 것 같지만, 실제로는 system 메시지가 다음과 같이 여러 조각이 정해진 순서로 합쳐진 결과다. 다섯 단계로 나뉜다.
각 단계의 의미를 풀어 보면:
- ① BASE_SYSTEM_PROMPT — 챗봇 자체의 신분과 행동 규칙. "너는 가정용 로컬 어시스턴트다, 한국어로 답해라" 같은 프레임워크 레벨 지시. 사용자가 무엇을 묻든 항상 들어간다. 프리셋(코드 리뷰어, 글쓰기 도우미 등)을 고르면 BASE 대신 프리셋 프롬프트가 들어간다.
- ② 글로벌 Custom Instructions — 사용자가 "모든 대화에서 이렇게 답해줬으면 좋겠어"라고 한 번 적어둔 일반 선호. ChatGPT의 Custom Instructions와 같다. 예: "코드는 짧게", "한국어 우선", "근거 인용 필수". 이 단계는 모든 세션·모든 프로젝트에 공통으로 들어간다.
- ③ [프로젝트 지침] — 활성 프로젝트의
instructions필드. 이 단계가 이번 글의 주제. "이 프로젝트에서는 가족 캘린더를 먼저 보고, 가족 구성원 일정 우선" 같은 작업별 컨텍스트. 활성 프로젝트가 default가 아닐 때만 들어간다. - ④ [첨부 파일/이미지 목록] — 현재 활성 프로젝트·세션에 업로드된 파일들의 메타. "이 PDF가 있으니 필요하면 search_attachment 도구로 본문을 검색해라"라는 힌트도 같이. 업로드가 없으면 이 단계는 비어있다.
- ⑤ [장기 메모리] —
long_term에 저장된 사용자 사실들 전부.long_term은 scope를 받지 않는 전역 저장소라, 어떤 세션·프로젝트에서 호출하든 같은 항목들이 보인다(텔레그램 게이트웨이와도 공유). 현재 시각도 여기서 주입. 자세한 설계 의도는 §3.
여기서 핵심 결정 — 프로젝트 지침(③)이 글로벌 Custom Instructions(②)의 다음 자리에 온다. 반대로 두면 안 되는가? 충분히 그럴듯한 디자인 같지만, 실제로 LLM의 행동을 보면 차이가 분명히 난다.
2. "뒤에 오는 지시를 더 강하게 따르는" LLM의 특성
LLM은 학습 과정에서 instruction-following이 강화될 때 "더 가까운(=뒤에 있는) 지시를 우선시"하는 패턴을 익힌다. 같은 system 메시지 안에서도 마찬가지다. 두 지시가 서로 살짝 충돌하면 보통 뒤쪽 지시가 이긴다.
이 효과를 가장 분명하게 보려면 두 지시가 같은 영역에서 정반대 방향을 가리키는 시나리오가 필요하다. 예를 들어 응답 상세도에 관해 — 글로벌 CI는 "응답은 짧게, 한 문장 위주", 프로젝트(학습용)는 "단계별로 자세히, 예시 충분히". 두 지시 모두 "응답 길이"라는 같은 영역을 다루지만 방향이 정반대. 모델은 어느 쪽을 따를까?
그림 왼쪽(올바른 순서)에서는 글로벌 "짧게"가 먼저 들어가고 마지막에 프로젝트 "자세히"가 온다. 모델이 마지막에 본 "자세히"를 더 강하게 의식하니 결과는 자세한 답 — 학습용 프로젝트의 의도대로다. 글로벌의 "짧게"는 뒤집혔지만 그게 의도된 동작이다. 사용자가 글로벌 CI를 일반 규칙으로 두고 특정 프로젝트에서만 예외를 적용하고 싶을 때 정확히 작동.
그림 오른쪽(반대 순서)에서는 프로젝트 "자세히"가 먼저 들어가고 마지막에 글로벌 "짧게"가 온다. 같은 두 지시인데 순서만 바꿨는데 결과는 정반대 — 모델이 마지막의 "짧게"를 의식해 한 문장 답변. 학습용 프로젝트에서 자세한 설명을 기대했던 사용자는 "왜 이 프로젝트만 짧은 답이 나오지? 프로젝트 지침을 무시하나?"라며 혼란을 겪는다. 같은 두 지시, 같은 의도, 다른 결과. 차이는 system 메시지 안 두 줄의 순서뿐.
코드로 보면 한 줄짜리 결정이지만 —
# proxy.py — build_system_prompt sections = [BASE_SYSTEM_PROMPT] # ① if global_ci := _load_custom_instructions(): sections.append(f"[사용자 지정 지침]\n{global_ci}") # ② if project_id != DEFAULT_PROJECT_ID: if proj := _get_project(project_id): sections.append( f"[프로젝트 지침]: {proj['name']}\n" f"{proj['instructions'].strip()}") # ③ ← 글로벌 다음 # ④ 첨부, ⑤ 장기 메모리 차례로 append return "\n\n".join(sections)이 코드의 핵심은
sections.append의 호출 순서다. Python 리스트는 append 순서대로 보존되고, 마지막"\n\n".join(sections)이 그 순서대로 합친다. 즉 코드의 줄 순서가 곧 모델이 보는 system 메시지의 순서다. 그래서append(global_ci)가append(project)보다 먼저 와야 한다 — 이 한 줄을 잘못 잡으면 사용자가 "왜 프로젝트 지침이 무시되지?"라며 시스템 프롬프트를 점점 길게 쓰기 시작하는 무의미한 군비 경쟁이 시작된다. 정작 LLM은 글이 길어서가 아니라 "마지막에 본 지시"를 우선했을 뿐이다.
3. 메모리는 세션 단위로만 격리하고 long_term은 전역 공유로 둔 이유
두 번째로 흥미로웠던 결정은 메모리 격리 단위. 기술적으로는 short_term/episodic/long_term 세 계층 모두에 프로젝트 단위 scope를 끼워 넣을 수 있었다. 그런데 의도적으로 그렇게 하지 않았다. memory-store는 다음과 같이 두 가지 격리 레벨만 둔다.
처음에는 ChatGPT/Claude.ai의 Projects를 따라
gemma:proj:<project_id>같은 프로젝트 scope를 추가하려 했다. 그런데 long_term에 들어갈 사실들을 하나씩 검토해 보니 — "한국어로 답을 받고 싶어 한다", "코드 예시는 간결하게 선호", "DGX Spark에서 운영 중" 같은 것들 — 이것들은 작업 종류와 무관하게 항상 참인 사실들이다. 가족 일정 작업 중이라고 "한국어 우선"이 안 되는 건 아니고, vLLM 디버깅 중이라고 사용자가 평소에 짧은 답을 선호한다는 사실이 사라지는 건 아니다. 프로젝트별로 분리하면 같은 사실을 프로젝트마다 다시 적어줘야 하는 부조리가 생긴다.그러면 "프로젝트 고유 사실"(예: 이 프로젝트에서만 쓰는 용어 정의)은 어디에 두는가? 답은 시스템 프롬프트의 ③ [프로젝트 지침] 자리. 메모리에 동적으로 적재하지 않고, 프로젝트 metadata에 정적으로 박아두는 쪽이 맞다 — 프로젝트 고유 사실은 변하지 않으니 메모리의 "추출/회상" 메커니즘이 필요 없고, 그냥 프로젝트 지침에 한 번 적어두면 활성 프로젝트일 때마다 시스템 프롬프트에 들어간다. "메모리는 사용자 단위로 전역, 프로젝트 단위는 지침으로" — 격리 단위와 메커니즘을 분리한 결정.
물론 단점도 있다. 다른 사람과 공유하는 챗봇이라면 long_term이 전역인 건 위험하다 — A 사용자의 사실이 B 사용자에게 새기 때문이다. 하지만 이 챗봇은 단일 사용자 로컬 전제. 사용자가 한 명이라는 가정이 있으니 long_term을 전역으로 두는 게 자연스러운 모델링이다. ChatGPT가 Projects별로 메모리를 격리해야 하는 건 같은 OpenAI 계정 안에서도 작업 맥락이 회사·개인으로 강하게 구분되기 때문 — 우리 챗봇은 그런 강한 구분이 필요 없다.
4. 트레이드오프 — 자유와 안전 사이
4-1. 시스템 프롬프트가 매 요청마다 길어진다 — 그래도 비용이 거의 없는 이유
① + ② + ③ + ④ + ⑤를 모두 합치면 한 요청의 system 메시지가 1,500~3,000 토큰에 이르기도 한다. 처음에는 이게 매 요청 누적되면 비용·지연이 신경 쓰이는 수준이라고 걱정했다. 그런데 실제로는 그렇지 않다. vLLM의 prefix caching이 같은 system prefix를 캐싱해서 두 번째 요청부터는 그 부분 처리를 스킵한다 — 사실상 "첫 요청만 무겁고 그 다음은 거의 공짜"다.
구체적으로 보면, vLLM은 같은 토큰 시퀀스를 받으면 그 시퀀스의 KV cache(중간 어텐션 텐서)를 메모리에 보관한다. 다음 요청이 동일한 prefix(예: 같은 system + 같은 메시지 일부)를 보내면 그 prefix 처리를 건너뛰고 새 토큰부터 계산한다. system 메시지가 같은 사용자 안에서는 거의 변하지 않으니 실질적으로 매번 캐시 hit. 이걸 알고 나면 system 메시지 길이에 인색할 이유가 줄어든다 — "한 번 비싸고 다음부터 공짜"의 비대칭성이 컨텍스트 풍부함을 정당화한다.
다만 첫 요청이 여전히 무겁다는 건 사실. 챗봇을 막 부팅했을 때 첫 응답 지연이 평소보다 1~2초 더 걸린다. 두 번째 요청부터는 거의 사라진다.
4-2. 세션을 다른 프로젝트로 옮겨도 메모리는 그대로 따라온다 — 격리를 세션 단위로 둔 결과
세션을 사이드바의 ↪ 버튼으로 다른 프로젝트로 옮기면, 그 세션의 short_term/episodic 메모리는 그대로 함께 따라온다. scope가
gemma:<session_id>이지gemma:proj:<project_id>가 아니기 때문 — 프로젝트는 메모리 격리 단위가 아니라 단순한 그루핑 메타데이터다. long_term은 어차피 전역이라 따라오고 말고 할 것도 없다.처음에는 "프로젝트를 옮기면 그 컨텍스트의 휘발성 기억은 끊겨야 하지 않나" 싶었는데, 실제 사용 시나리오를 짚어 보면 그 반대가 자연스럽다. 보통 세션을 옮기는 이유는 "이 대화를 잘못 분류했다" 또는 "여기서 시작했지만 사실은 저쪽 프로젝트의 일이었다"인데, 두 경우 모두 대화의 맥락은 그대로 살리는 게 맞다. 세션을 옮겼더니 모델이 "방금 업로드한 PDF가 뭔지" 까먹는다면 사용자가 옮긴 직후에 같은 정보를 다시 알려줘야 하는 부조리가 생긴다.
대안적 디자인은 (a) 세션 이동 시 휘발성 메모리 초기화, (b) "메모리도 옮길까요?" 다이얼로그 두 가지를 검토했다. (a)는 위에서 말한 부조리, (b)는 사용자에게 결정을 강요하는데 그 결정의 의미를 즉각 알기 어렵다. "세션은 컨텍스트 컨테이너고 프로젝트는 그 컨테이너에 붙은 라벨"로 단순하게 가는 쪽을 골랐다 — 라벨을 바꿔도 컨테이너 내용은 그대로.
4-3. 기본 프로젝트(
default)는 삭제 불가 — 데이터 손실 방지의 안전판다른 프로젝트가 삭제될 때 그 안의 세션들을 어디로 옮길 것인가? 답은
default프로젝트로 떨구는 것. 이게 동작하려면 default가 항상 존재해야 한다. 그래서 default는 자동 생성·삭제 금지. UI에서 default 프로젝트의 ⚙ → 삭제 버튼은 disabled 상태로 표시된다.이 결정의 함의는 — "사용자가 '깨끗한 빈 챗봇'을 만들 수 없다". 항상 최소한 default 프로젝트는 있고, 거기에 미분류 세션들이 쌓일 수 있다. 처음에는 이걸 "쓸데없는 제약"으로 느낄 수 있다. 그런데 반대 디자인을 상상해 보면 ("default도 삭제 가능"), 사용자가 default를 삭제하면 어디에 미분류 세션을 떨굴 것인가? 자동으로 새 default를 만든다? 그러면 사용자가 다시 그것도 삭제할 수 있고 무한 루프. 또는 "삭제 시 모든 세션 함께 삭제"? 그건 명백한 데이터 손실 위험.
"default 삭제 불가"는 데이터 모델의 자기 일관성을 위한 단순한 안전판이다. 사용자 자유의 작은 제약과 데이터 손실 가능성 0의 트레이드오프 — 후자가 압도적으로 가치 있다.
5. 마무리
"프로젝트별 시스템 프롬프트"라는 한 줄 기능은 단순히 텍스트 한 토막을 추가하는 게 아니라, 여러 정보 조각을 어떤 순서로 모델에 보여줄 것인가를 결정하는 일이다. 그 순서가 모델의 행동을 바꾼다는 게 컨텍스트 엔지니어링의 본질에 가깝다.
다음 편은 파일 첨부와 키워드 RAG. 사용자가 PDF를 던졌을 때 "도구가 어떻게 그 파일의 존재를 알게 되는가"라는 비슷한 질문을 ContextVar 패턴으로 풀어낸 이야기다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
로컬 챗봇 시리즈 #7 — 도구 11개가 모이면 모델이 헷갈리기 시작한다: 풀 격리와 _safe_tool 안전판 (0) 2026.05.09 로컬 챗봇 시리즈 #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 로컬 챗봇 시리즈 #1 — 메시지 편집은 왜 그렇게 단순해야 하나: 컨텍스트 엔지니어링 관점에서 (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