-
로컬 챗봇 시리즈 #3 — 도구에 '지금 누구의 첨부인지' 어떻게 알려주나: ContextVar 패턴IT 2026. 5. 8. 22:00
들어가며 — "이 PDF에서 5장 요약해줘"의 진짜 어려움
PDF·DOCX·코드 파일을 챗봇에 던져서 그것에 관해 묻고 싶다는 욕구는 평범하지만, 구현은 의외로 까다롭다. 본문을 통째로 시스템 프롬프트에 박으면 컨텍스트가 폭발한다 — 300페이지 PDF면 50만 토큰. 그렇다고 모델이 PDF에 접근할 수단이 없으면 답을 못 한다.
해법 자체는 RAG 풀의 표준이다 — "본문은 디스크에 두고, 모델이 필요할 때 도구로 검색하게 한다".
search_attachmentLangChain Tool 하나만 있으면 끝. 그런데 정작 그 도구를 만들면서 가장 흥미로운 문제가 따로 튀어나왔다 — "도구가 '지금 누구의 첨부를 검색해야 하는지' 어떻게 알지?". 이번 글은 이 한 문제와 ContextVar라는 표준 라이브러리 한 줄로 푼 패턴을 다룬다.
1. 도구 시그니처에 owner를 넣는 순진한 방법, 그리고 왜 안 되는지
가장 직관적인 디자인부터. LangChain Tool은 결국 함수다. 그 함수 인자에 owner 정보를 넣으면 된다.
# 순진한 디자인 — 도구 시그니처에 session_id 노출 @tool def search_attachment(query: str, owner_type: str, owner_id: str) -> str: """현재 대화의 첨부 파일을 검색합니다.""" return att_mod.search_attachments(owner_type, owner_id, query)위 코드의 시그니처는 인자 셋이 모두 필수다. 모델이 이 도구를 호출할 때 세 인자를 모두 채워야 한다.
query는 사용자 질문에서 자연스럽게 추론되지만 —owner_type과owner_id는 어떻게 채울까? 모델은 자기가 어느 세션·어느 프로젝트에서 동작 중인지 모른다. 그건 "런타임 컨텍스트"고 모델은 그것을 학습 시점에 본 적이 없다.해결로 시스템 프롬프트에 "현재 session_id는 abc123입니다"라고 박는 방법이 있다. 그러면 모델이 그것을 정확히 복사해서 인자에 채워야 한다. 작은 모델일수록 자주 실수하거나 환각한다 — 비슷한 문자열을 만들어 넣거나 숫자 하나 빠뜨린다. 그리고 메타 정보를 시스템 프롬프트에 노출하는 것 자체가 컨텍스트 낭비. 두 디자인을 비교한 그림:
이 그림이 보여주는 핵심 — 왼쪽(A)은 모델에게 추론·기입 책임을 떠넘긴다. 도구 시그니처가 단순해 보이지만 사실 시스템 프롬프트에 메타 정보를 박아 넣어야 동작한다. 오른쪽(B)은 모델 시야에서 메타 정보를 완전히 빼버린다 — 도구는
query하나만 받고 owner는 도구 내부에서 알아서 찾는다. 모델 입장에서는 "내가 무엇을 검색할지만 정하면 된다"로 단순화된다. 인지 부담이 줄면 호출 정확도가 올라간다.
2. ContextVar의 핵심 — "함수 호출 트리 전체에 보이는 변수"
Python 표준 라이브러리
contextvars는 사실 좀 마이너한 도구지만, 비동기/스레드 환경에서 "한 요청의 흐름 전체에 같이 묻어다니는 변수"를 만들 수 있다. 글로벌 변수와 다른 점은 asyncio가 task별로 자동 격리해준다는 것 — 한 워커가 여러 요청을 병렬 처리해도 컨텍스트가 섞이지 않는다.구체적으로 어떻게 동작하는지 그림으로 보면:
이 그림이 보여주는 마법 — 같은
_active_owners라는 변수 하나가 두 요청에서 동시에 다른 값을 가질 수 있다. 글로벌 변수라면 두 요청이 서로의 값을 덮어쓰면서 race condition이 나겠지만, ContextVar는 그렇지 않다. 이유는 asyncio가 새 task를 만들 때 현재 컨텍스트의 복사본을 같이 만들고, 그 task가 ContextVar를 set/get하면 자기 복사본만 건드리기 때문. 한 task의 변경이 다른 task에 안 보인다.코드는 거짓말처럼 단순하다.
# agent/tools/attachment_search.py from contextvars import ContextVar from langchain_core.tools import tool # 활성 검색 컨텍스트 — proxy가 채팅 시작 시 주입 # 형식: [(owner_type, owner_id), ...] _active_owners: ContextVar[list[tuple[str, str]]] = \ ContextVar("attach_owners", default=[]) def set_search_context(owners): return _active_owners.set(owners) def reset_search_context(token): _active_owners.reset(token) @tool def search_attachment(query: str) -> str: """현재 대화의 프로젝트/세션에 첨부된 파일을 검색합니다.""" import attachments as att_mod owners = _active_owners.get() if not owners: return "이 대화에 활성 첨부 컨텍스트가 없습니다." hits = [] for owner_type, owner_id in owners: for r in att_mod.search_attachments(owner_type, owner_id, query, max_hits=3): label = "프로젝트" if owner_type == "project" else "세션" hits.append(f"[{label}] {r['filename']}\n{r['snippet']}") return "\n\n---\n\n".join(hits[:5]) or f"'{query}' 매칭 없음"이 코드의 핵심 부분은 세 줄이다 — ContextVar 객체 정의(
_active_owners = ContextVar(...)), 호출자가 set하는 헬퍼(set_search_context), 도구 안에서 get하는 호출(_active_owners.get()). 도구는 owner를 받지 않지만 owner를 정확히 안다 — 호출자가 진입 시점에 set해뒀으니까. "누가 set 책임을 지고 누가 get 책임을 지는가"가 분리된 게 이 패턴의 진짜 가치다. 도구는 set의 존재를 모르고, 호출자는 도구가 어떻게 get하는지 모른다.요청 핸들러는 진입에서 set, 종료에서 reset만 한다. 깔끔하다.
# proxy.py — handle_agent_chat _attach_owners = [] if session.project_id: _attach_owners.append(("project", session.project_id)) _attach_owners.append(("session", session.id)) _attach_token = set_search_context(_attach_owners) try: # ...스트리밍 응답... finally: reset_search_context(_attach_token)여기서
try/finally가 핵심이다. set한 ContextVar는 명시적으로 reset하지 않으면 같은 task의 다음 호출까지 살아있을 수 있다 — 다음 사용자 요청에서 엉뚱한 owner의 첨부가 검색될 위험.finally는 응답이 정상 종료되든 예외로 떨어지든 항상 reset이 실행되도록 보장한다. 이 한 줄이 멀티 사용자 시나리오의 보안 안전판이다.
3. 같은 패턴이 vision 도구에도 그대로 — "한 번 만든 패턴은 두 번째부터 거의 공짜"
이 패턴의 진짜 가치는 시리즈 #4에서 vision 도구를 추가할 때 드러났다.
analyze_image도 같은 질문이다 — "지금 누구의 이미지를 분석해야 하나?". 같은 ContextVar 패턴을 그대로 복사해 30초 만에 적용했다.# agent/tools/vision.py — 패턴 그대로 복제 _active_images: ContextVar[list[tuple[str, str]]] = \ ContextVar("vision_images", default=[]) def set_vision_context(images): return _active_images.set(images) def reset_vision_context(token): _active_images.reset(token) @tool def analyze_image(filename: str = "", prompt: str = "") -> str: images = _active_images.get() # ... 같은 패턴호출자(
handle_agent_chat)는 두 ContextVar를 모두 set/reset만 하면 된다._attach_token = set_search_context(_attach_owners) _vision_token = set_vision_context(_vision_images) try: async for event in _run_agent_stream(...): ... finally: reset_search_context(_attach_token) reset_vision_context(_vision_token)"도구를 추가할 때마다 시스템 프롬프트가 부풀고 LLM이 헷갈린다"는 일반적 패턴을 ContextVar 한 패턴이 막아준다. 도구가 늘어도 모델은 매번 query/filename/prompt 같은 의도 인자만 추론하면 되고, 컨텍스트 메타는 백그라운드에 묻혀있다.
4. 트레이드오프 — 단순함에 숨은 함정
4-1. ContextVar는 "보이지 않는" 의존성이라 디버깅이 어렵다
도구의 시그니처만 보면
search_attachment(query: str). owner가 어디서 오는지 시그니처에 안 보인다. 새로 합류한 개발자가 도구 코드를 처음 볼 때 "이게 어떻게 현재 세션을 알지?"라며 한 번 헤맨다. 보통 IDE의 "Find References"로 거꾸로 추적해야 호출자가 set하는 곳을 발견한다.이건 ContextVar 패턴 자체의 비용이다. 시그니처가 깔끔한 만큼 의존성이 숨는다. 완화책은 두 가지. 첫째, 각 도구의 docstring 위에 "이 도구는 ContextVar로 owner를 받습니다 — proxy.handle_agent_chat의 set_search_context 참고"라고 명시 주석. 둘째, ContextVar를 set하지 않은 상태에서 도구가 호출되면 default 값(빈 리스트)이 나오므로 도구 안에서 "활성 컨텍스트가 없습니다"라고 명확하게 답변해야 한다 — 빈 결과를 그냥 반환하면 호출자가 "왜 검색 결과가 없지?"라고 추적하기 어렵지만, 명확한 안내 메시지면 즉시 디버깅 가능.
이 두 가지 완화책을 일관되게 적용하면 보이지 않는 의존성이 만드는 디버깅 비용을 상당히 줄일 수 있다.
4-2. set 해놓고 reset 안 하면 멀티 사용자 보안 사고가 가능하다
위 코드에서
try/finally를 쓴 이유가 여기 있다. ContextVar는 task가 끝나면 자동 정리되는 게 일반적인 동작이지만, 같은 task 안에서 여러 요청을 처리하는 시나리오(예: 워커 한 개가 여러 요청을 순차 처리)에서는 set한 값이 다음 호출까지 살아있다. reset을 빠뜨리면 다음 요청이 "이전 사용자의 첨부"를 검색하게 된다.구체적인 시나리오 — 사용자 A가 채팅을 시작하면서 ContextVar에 A의 owner를 set한다. 응답 처리 중에 예외가 나서 핸들러가 비정상 종료된다. reset이 호출되지 않은 채 task가 마무리. 다음 요청이 사용자 B인데 같은 워커 task에 분배되면, ContextVar에는 여전히 A의 owner가 남아있고, B의 도구 호출이 A의 첨부를 검색한다 — 명백한 데이터 누출.
1인 가정용 챗봇에서는 위험이 작지만(사용자가 본인뿐), 회사 환경이나 다중 사용자 시스템에 옮길 때는 이 점이 보안 사고가 된다. 그래서
try/finally가 단순한 코드 스타일이 아니라 보안 의무다. 모든 ContextVar set 호출은 무조건 finally로 reset을 짝지어야 한다는 룰을 코드 리뷰 단계에서 체크.
5. 마무리
"도구에 컨텍스트 주입"은 LLM 에이전트 시스템에서 거의 매번 만나는 문제다. 시그니처에 노출하는 순진한 답을 거부하고 ContextVar 한 단계로 풀면, 도구가 늘어날수록 그 가치가 더 커진다. 시리즈 #4에서 vision 도구가 거의 "복사·붙여넣기"로 합류한 게 그 증거.
다른 부수 기능 — 드래그&드롭 업로드, PDF/DOCX 텍스트 추출(pypdf/python-docx), 시스템 프롬프트의 첨부 목록 표기 — 는 표준 도구로 끝났다. 흥미로운 결정은 ContextVar 한 줄에 다 들어있었다.
다음 편은 이미지 + Vision. 같은 ContextVar 패턴이 vision에도 적용되고, 거기에 "vLLM dual instance를 GPU 스케줄러로 어떻게 동거시키는가"라는 인프라 질문이 더해진다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
로컬 챗봇 시리즈 #8 — 봇은 도구 풀을 좁히는 장치다: '지식금고 검색가' 한 도구 봇이 가장 효과적인 이유 (0) 2026.05.09 로컬 챗봇 시리즈 #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 로컬 챗봇 시리즈 #2 — Project 시스템 프롬프트는 왜 글로벌 Custom Instructions '다음에' 와야 하나 (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