LangChain #34974 디버깅 사례: ContextVar 스레드 친화성(Thread Affinity) 연구
요약
LangChain의 Human-in-the-Loop 기능에서 발생하는 RuntimeError의 원인을 Python의 ContextVar 스레드 친화성 문제로 규명하고 해결책을 제시합니다. async 함수가 ThreadPoolExecutor로 전달될 때 컨텍스트가 유실되는 현상을 copy_context()로 해결하는 과정을 다룹니다.
핵심 포인트
- LangChain의 특정 에러는 Python ContextVar가 스레드 경계를 넘지 못해 발생함
- asyncio.to_thread 사용 시 새로운 스레드에서 ContextVar가 상속되지 않는 문제
- copy_context()를 사용하여 스레드 간 컨텍스트를 안전하게 전달 가능
- Python 버전 및 이벤트 루프 정책에 따른 비동기-동기 상호작용 차이 이해 필요
How I Debugged LangChain #34974: A Case Study in ContextVar Thread Affinity
요약 (TL;DR): 5개월 동안 Human-in-the-Loop 기능을 망가뜨렸던 버그를 해결하기 위한 10줄짜리 수정안. 근본 원인: async def가 ThreadPoolExecutor로 작업을 전달할 때 Python의 ContextVar가 스레드 경계를 넘지 못함. 해결책: copy_context() 사용.
이틀 전, 저는 2026년 2월부터 열려 있었던 GitHub Issue를 발견했습니다.
LangChain #34974: HumanInTheLoopMiddleware + ainvoke() → RuntimeError: Called get_config outside of a runnable context.
5개월. 병합되지 않은 2개의 PR. 체크포인터(checkpointer) 백엔드를 교체하거나 Python 버전을 업그레이드하는 등, 근본 원인이 아닌 증상만을 해결하려는 수많은 개발자들의 시도.
저는 이를 제대로 추적하기 위해 진단 도구를 만들기로 했습니다. 그 과정은 다음과 같았습니다.
1단계: 에러 체인 추적 (Trace the Error Chain)
에러 스택은 명확한 이야기를 들려주었습니다:
langchain/agents/middleware/human_in_the_loop.py:381 → aafter_model (async wrapper)
langchain/agents/middleware/human_in_the_loop.py:331 → after_model (sync) → interrupt()
langgraph/types.py:515 → interrupt → get_config()["configurable"]
...
크래시는 langgraph/config.py의 29번 라인에서 발생합니다:
def get_config():
config = _get_config_var.get(None)
if config is None:
...
_get_config_var는 ContextVar입니다. 따라서 질문은 이것이 되었습니다: 왜 interrupt()가 호출될 때 이 값이 None인가?
2단계: 스레드 추적 (Follow the Thread (Literally))
HumanInTheLoopMiddleware에는 두 가지 메서드가 있습니다:
class HumanInTheLoopMiddleware(BaseMiddleware):
async def aafter_model(self, state, runtime):
# async version
...
aafter_model은 async def로, asyncio 이벤트 루프(event loop) 스레드에서 실행됩니다. 이 메서드는 asyncio.to_thread(self.after_model, ...)를 호출하며, 이는 동기(sync) 메서드를 ThreadPoolExecutor로 전달합니다.
문제는 다음과 같습니다: Python의 ContextVar는 스레드 친화적(thread-affine)입니다. after_model()이 스레드 풀 워커(thread pool worker)에서 실행될 때, 새로운 ContextVar 네임스페이스를 상속받게 되어 _get_config_var가 설정되지 않은 상태가 됩니다. 이때 interrupt()가 이를 읽으려고 시도하면 → 충돌(crash)이 발생합니다.
이것이 발생하는 이유는 다음과 같습니다:
- Python 3.10 ❌ — 서로 다른 기본 이벤트 루프 정책(Windows의
ProactorEventLoop, Linux/macOS의SelectorEventLoop)이 스레드와 asyncio가 상호작용하는 방식을 변화시킵니다. - Python 3.11 ✅ — keenborder786이 순수 스크립트 모드(FastAPI 미사용)에서는 재현할 수 없었던 이유는 스레드 풀이 관여하지 않았기 때문입니다.
- FastAPI는 이를 일관되게 만듭니다 — FastAPI의 ASGI 서버는 항상 스레드 풀을 통해 디스패치(dispatch)하므로, 프로덕션 환경에서는 이 버그가 100% 재현됩니다.
3단계: 해결책 — 10줄의 코드, 의존성 제로
from contextvars import copy_context
class HumanInTheLoopMiddleware(BaseMiddleware):
...
copy_context()는 호출 스레드의 ContextVar 상태를 캡처합니다. ctx.run()은 함수를 실행하기 전에 대상 스레드에서 해당 상태를 복구합니다. 이는 PEP 567에서 제시하는 표준 패턴이며, CPython 자체에서도 사용하는 방식입니다.
대안으로, 만약 interrupt()가 비동기(async)를 지원한다면(langgraph 1.0.x에서는 지원함), 모든 로직을 aafter_model 내부로 인라인(inline)화하고 after_model을 완전히 삭제하는 것이 더 깔끔한 해결책입니다.
ARK를 구축하며 배운 점
이 디버깅에는 보고서를 생성하는 진단 도구를 구축하는 시간을 포함하여 약 2시간이 소요되었습니다. 그 도구인 ARK는 제가 작업해 온 오픈 소스 에이전트 상태 모니터링(agent health monitoring) 시스템입니다.
ARK의 작동 방식은 다음과 같습니다:
- 리스닝(Listening): 에이전트 충돌 패턴을 찾기 위해 GitHub Issues를 모니터링합니다.
- 트레이싱(Tracing): (단순한 충돌 지점이 아닌) 근본 원인을 찾기 위해 에러 스택을 추적합니다.
- 생성(Generating): 상태 점수(health scores)와 수정 제안이 포함된 구조화된 진단 보고서를 생성합니다.
- 발행(Publishing): 보고서를 CDN과 Issue 스레드에 게시합니다.
이 이슈(Issue)에 대한 보고서는 42/100점을 기록했습니다. HITL(Human-in-the-loop) 핵심 기능은 비동기(async) 경로에서 완전히 작동하지 않기 때문에 15점에 그쳤습니다. 하지만 근본 원인은 단 한 줄의 ContextVar 코드에 있습니다. 어디를 살펴봐야 할지만 안다면 해결하기 매우 쉬운 문제입니다 (Low-hanging fruit).
유사한 에이전트 충돌 문제를 겪고 있다면, 증거 추적(evidence tracing)이 포함된 전체 진단 보고서를 다음에서 확인하십시오:
또는 귀하의 설정에 대해 빠른 상태 점검을 실행해 보세요:
🛡️ 에이전트의 화재 진압(Firefighting)을 중단하십시오
에이전트의 충돌은 업무 시간에 맞춰 발생하지 않습니다. 당신이 잠든 사이, 제품을 배포하는 중, 혹은 바쁜 와중에 발생합니다.
→ 무료 30초 진단 실행 — 무엇이 곧 고장 날지 정확히 확인하십시오.
- 평생 라이선스 ¥360 — 모든 문제를 단 한 번에 해결하십시오.
- 구독 ¥65/월 — 24/7 충돌 모니터링 + 실시간 알림 + 자동 업데이트되는 보호 규칙. 언제든 취소 가능합니다.
지속적인 모니터링을 추가하기 가장 좋은 시점은 첫 번째 충돌이 발생한 직후입니다. 두 번째로 좋은 시점은 바로 지금입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기