LangGraph 챗봇 스트리밍 아키텍처
2026년 5월 25일, Lumie는 챗봇 streaming을 위해 worker-local LangGraph 아키텍처를 선택했습니다. 첫 production version에서는 queue-driven chat path, Redis resumable stream, LangGraph Server를 채택하지 않았습니다.
핵심 요구사항은 좁지만 중요했습니다. 원장이 browser tab을 닫아도 분석 작업이 사라지면 안 됩니다.
배경
챗봇은 기존 경계를 보존해야 했습니다.
- frontend SSE contract는
token,pending,done,errorevent로 고정됩니다. - monolith는 conversation, assistant message, pending action의 system of record로 남습니다.
- tenant-scoped SQL은 Python이 아니라 RLS 아래의
lumie-backend에서 실행됩니다. - Python worker는 LangGraph execution과
langgraphschema의 checkpoint state만 소유합니다. - write tool은 실행 전에 HITL confirmation이 필요합니다.
현재 코드도 이 분리를 반영합니다.
lumie-worker/services/chatbot/src/usecase.py는 graph output을 SSE event로 매핑하고 final text를 backend callback으로 저장합니다.lumie-worker/services/chatbot/src/graph/agent.py는 write tool을interrupt()로 멈추고Command(resume=...)로 재개합니다.lumie-backend/modules/ai/src/main/java/com/lumie/ai/application/service/ChatService.java는@Transactional아래에서 conversation persistence를 소유합니다.lumie-worker/services/chatbot/main.py는 FastAPI lifespan에서 compiled graph와 checkpointer를 wiring합니다.
검토한 선택지
| 선택지 | 해결하는 문제 | 최종 선택이 아니었던 이유 |
|---|---|---|
| request-bound SSE only | 가장 단순한 live token delivery | client disconnect가 run을 취소합니다 |
| detached SSE producer plus persisted result | frontend contract 변경 없이 tab-close safety 제공 | 선택한 방식입니다 |
| Redis resumable streams | 진행 중인 token 재생과 재연결 | 아직 요청되지 않은 요구사항을 위해 infra와 frontend reconnect 작업이 늘어납니다 |
| MQ-driven chat execution | 더 강한 job durability | low-latency token streaming에 맞지 않고 write-tool duplicate risk가 큽니다 |
| LangGraph Server | managed durable run과 resumable stream | Lumie의 self-hosted posture와 원본 결정 기록의 제약 때문에 제외했습니다 |
결정은 너무 일찍 섞이기 쉬운 세 관심사를 분리했습니다.
| 관심사 | 선택한 소유자 |
|---|---|
| pod가 살아 있는 동안의 execution | usecase.py의 detached background task |
| live delivery | ChatbotClient와 backend stream endpoint를 통한 SSE proxy |
| durable conversation result | backend save_message, save_pending_action callback |
선택한 아키텍처
usecase.py의 대표 runtime shape는 다음입니다.
task = asyncio.create_task(
self._run_graph(graph, inputs, config, conversation_id, req, queue, consumer_gone)
)
try:
while True:
item = await asyncio.wait_for(queue.get(), timeout=1.0)
...
finally:
consumer_gone.set()
HTTP response는 queue를 drain합니다. Graph run 자체는 detached되어 있으므로 client disconnect는 live delivery를 멈추지만 execution이나 final persistence를 자동으로 멈추지는 않습니다.
Graph boundary는 명시적입니다.
scope_check는 명백한 out-of-scope 또는 prompt-injection request를 거부합니다.agent는 직접 답변할지 tool을 호출할지 결정합니다.tools는 read tool을 backend API로 실행하거나 write tool을interrupt()로 멈춥니다.confirm_tool()은 confirm 또는 reject 이후 checkpointed run을 재개합니다.
Lumie에 맞았던 이유
선택한 설계는 가능한 platform option보다 의도적으로 작았습니다.
- frontend와 backend streaming contract를 보존했습니다.
- tenant-table access를 RLS와 validator가 이미 있는 monolith 안에 유지했습니다.
- 주로 live request-response semantic을 요구하는 상호작용에 Redis나 MQ 동작을 추가하지 않았습니다.
- Graph는 여전히 일반 compiled
StateGraph이므로 LangGraph portability를 유지했습니다. - Resumable token replay를 약속하지 않으면서 final result durability를 제공했습니다.
이후 PgBouncer checkpointer issue는 connection-management 구현을 바꿨지만, 아키텍처 결정 자체를 바꾸지는 않았습니다. 해당 후속 작업은 Chatbot Checkpointer Single Connection Behind PgBouncer에서 다룹니다.
잘된 점
- 가장 강력한 runtime이 아니라 실제 요구사항에 집중했습니다.
- SQL ownership을 backend에 유지해 Python에 tenant/authorization rule을 중복하지 않았습니다.
- Graph와 transport boundary가 단순해서 이후 hardening을 점진적으로 적용할 수 있었습니다.
- Resumable stream replay가 실제 product requirement가 되면 Redis나 다른 orchestrator를 추가할 여지를 남겼습니다.
아쉬웠던 점
- 초기 결정은 production-grade connection management를 나중 hardening에 의존했습니다.
- Redis resumability와 execution durability가 서로 다른 문제라는 점을 명확히 설명해야 했습니다.
- 원본 조사는 정확한 외부 research link를 보존하지 않아, 이 문서는 평가한 모든 자료가 아니라 결정 결과를 문서화합니다.