cs-agent · v2 rewrite report

두 갈래의 챗봇을
하나의 코드베이스

snaps와 ohprintme로 분기되어 살아온 두 챗봇을 단일 코드베이스로 통합하고, LangGraph 1.x로 업그레이드, Lambda에서 ECS Fargate로 런타임을 옮긴 4주 리라이트 기록

Project
cs-agent
Branch
rewrite/v2
Brands
2 → 1
Eval
79 / 79

4주 일정

계획부터 배포까지 — 각 주차의 핵심 결과만

Week 1
Brand 추상화 스캐폴딩 + LangGraph 영향 분석
Pydantic 기반 BrandConfig, OrderAdapter Protocol 도입. LangGraph 1.x 호환성 평가, 평가 하니스 뼈대 작성
Week 2
코드 통합 + 평가 베이스라인
tool/order.py를 brand-specific 어댑터로 분리, zendesk/main_graph가 모두 current_brand()를 통해 라우팅. langgraph 1.0.8 + langfuse 3.14로 업. 79건 미니 테스트 100% 통과
Week 3
런타임 통합 (Lambda + Fargate)
handler.process_event를 공유 코어로 분리. lambda_function.py와 app.py(FastAPI) 모두 같은 코드 경로. ECR/ECS Fargate/ALB 인프라 구축, 양 브랜드 task 분리 운영
Week 4
섀도 런 + prd 컷오버 (예정)
Sunshine webhook을 새 ALB로 전환, 3-5일 섀도 비교 후 prd 트래픽을 단계적으로 이전

두 브랜드, 두 갈래로 자란 코드

스냅스와 오프린트미는 같은 회사의 두 브랜드지만, 챗봇 코드는 git 브랜치로 갈라져 각자 살아왔어. 같은 함수의 두 사본이 양쪽에서 따로 진화했고, 한쪽에 추가된 기능은 손으로 옮겨 붙여야 했어.

snaps
main 브랜치
국내 포토북·폰꾸 메인 브랜드. JWT로 mobile/web 구분, X-SNAPS-* 헤더, epost+CJ 송장 분기, thumbnailImagePath 키
ohprintme
ohprintme 브랜치
글로벌 굿즈/POD 브랜드. 항상 WEB 채널, X-OHPRINT-* 헤더, CJ 송장 단일, thumbnailImageUrl 키
unified into

단일 코드베이스 + Brand 추상화

실행 시 BRAND 환경변수에 따라 어댑터를 선택. 비즈니스 로직은 모두 공유, 브랜드 차이는 어댑터에만 격리

rewrite/v2

세 가지 축으로 다시 짠 구조

코드, 에이전트, 런타임 — 각 층마다 단일 진입점을 두고 분기는 가장자리로 밀어냈어

01

Brand Adapter

Protocol 인터페이스 + per-brand 구현. 호출자는 브랜드를 모름. current_brand().order.cancel_order(...)

02

Supervisor Agent

LLM 라우터가 4명의 워커(order/support/document/common) 중 하나를 골라 위임. 각 워커는 자기 도메인 도구만 들고 있어

03

Shared Handler

Lambda와 FastAPI 두 런타임이 handler.process_event 단일 함수를 공유. 환경 차이는 진입점만

Brand Adapter Pattern

Protocol(Python의 구조적 서브타이핑)로 인터페이스를 정의하고, 브랜드별 클래스가 이를 구현. 호출자는 어떤 어댑터인지 모르고 어댑터의 메서드만 알면 됨

호출 흐름 — current_brand() resolver
graph LR A[handler.py
zendesk/order.py] -->|current_brand().order| B{Loader} B -->|BRAND=snaps| C[SnapsOrderAdapter] B -->|BRAND=ohprintme| D[OhprintmeOrderAdapter] C -->|JWT 분기·X-SNAPS-*
epost+CJ| E[Order API] D -->|WEB 고정·X-OHPRINT-*
CJ only| E style B fill:#F4E9D4,stroke:#C9A875,stroke-width:1.5px style C fill:#FBE5E1,stroke:#E89B92 style D fill:#DEEBF6,stroke:#8FB4D6 style E fill:#fff,stroke:#A39A8E
brands/base.py — Protocol 정의 @runtime_checkable class OrderAdapter(Protocol): delivery_required_fields: Mapping[str, str] empty_order_message: str def retrieve_orders(self, user_token, *, start_date=None, ...): ... def get_delivery_address(self, user_token, order_code): ... def update_delivery_address(self, user_token, order_code, fields): ... def cancel_order(self, user_token, order_code): ... def thumbnail_url(self, detail, cdn_url) -> str | None: ... def tracking_url(self, invoice_number) -> str: ...
Before · 두 브랜치의 두 사본
  • tool/order.py (snaps)
  • tool/order.py (ohprintme)
  • 같은 버그 두 곳에 패치
  • cherry-pick으로 동기화
  • ~370 LOC 중복
After · 단일 인터페이스
  • brands/base.py (Protocol)
  • brands/snaps_order.py
  • brands/ohprintme_order.py
  • brands/_order_common.py (공유 로직)
  • +87 / -374 net

Supervisor Agent Pattern

LLM이 라우터 역할을 맡아 사용자 질의를 4명의 도메인 워커 중 한 명에게 위임. 각 워커는 자신의 도구로 응답 생성, 마지막에 Zendesk 노드가 메시지를 사용자에게 전달

StateGraph — supervisor + 4 workers
graph TD START([START]) --> R{route_node
LLM 라우터} R -->|next=order| O[order_node
react_agent] R -->|next=support| S[support_node
react_agent] R -->|next=document| D[document_node
react_agent] R -->|next=common| C[common_node
react_agent] O --> Z[send_message_to_zendesk] S --> Z D --> Z C --> Z Z --> END([END]) style R fill:#F4E9D4,stroke:#C9A875,stroke-width:2px style O fill:#FBE5E1,stroke:#E89B92 style S fill:#F4E9D4,stroke:#C9A875 style D fill:#DEEBF6,stroke:#8FB4D6 style C fill:#DDE9DA,stroke:#9DB89A style Z fill:#fff,stroke:#A39A8E,stroke-dasharray:4 4

네 명의 워커

각자 도메인 prompt + 도구 세트만 가짐. 라우터는 Pydantic 구조화 출력으로 다음 노드 이름을 반환

order
retrieve · update_address · cancel
주문 조회, 배송지 변경, 주문 취소. 어댑터를 통해 brand별 API 호출
support
connect_to_agent
상담사 연결 — 영업시간 확인 후 1:1 문의 URL 반환
document
search_documents (Chroma)
FAQ/도움말 검색 — saida.snaps.com Chroma 256dim-v101 컬렉션에서 top-3
common
— (no tools)
인사말, 일상 대화 — 도구 없이 LLM 단독
graph/main_graph.py — supervisor def route_node(state: State): messages = [{"role": "system", "content": system_prompt}, ...] + state["messages"] response = llm.with_structured_output(Router).invoke(messages) next_ = response["next"] # "order" | "support" | "document" | "common" langfuse_client.update_current_trace(metadata={"next": next_}, tags=[next_]) return {"next": next_} builder.add_node("route", route_node) for member in ["order", "support", "document", "common"]: builder.add_node(member, ...) builder.add_edge(member, "send_message_to_zendesk") builder.add_conditional_edges("route", lambda s: s["next"]) builder.add_edge("send_message_to_zendesk", END)

Shared Handler Pattern

Lambda와 FastAPI 두 런타임을 동시에 운영하면서도 코드 분기는 없도록 — 진입점 두 개가 같은 process_event() 함수를 호출

Runtime split — single core, two entrypoints
graph TD Z1[Zendesk Sunshine
Function URL] --> L[lambda_function.py
SQS/HTTP unwrap] Z2[Zendesk Sunshine
via ALB] --> A[app.py
FastAPI route] L -->|process_event
post_error_to_zendesk=True| H[handler.py
::process_event] A -->|process_event
post_error_to_zendesk=False| H H --> G[graph/main_graph
::app.invoke] G --> R[response] style L fill:#FBE5E1,stroke:#E89B92 style A fill:#DEEBF6,stroke:#8FB4D6 style H fill:#F4E9D4,stroke:#C9A875,stroke-width:2px style G fill:#DDE9DA,stroke:#9DB89A
Before · Lambda 단일 (브랜드별 함수)
  • ai-cs-chatbot (ohprintme)
  • ai-cs-chatbot-snaps
  • 5분마다 warmup cron
  • cold start 부담
  • lambda_function.py 164 LOC, 단일 파일에 다 박혀있음
After · 두 런타임 공존
  • ECS Fargate 상시 실행
  • 같은 이미지, BRAND env로 분기
  • ALB path-based 라우팅
  • Lambda는 fallback으로 유지
  • lambda_function.py 25 LOC + handler.py 156 LOC + app.py 27 LOC

디렉토리 구조

새로 추가된 모듈은 초록색, 의미가 바뀐 파일은 금색

cs-agent/ ├── brands/ Brand 추상화 (NEW) │ ├── base.py OrderAdapter Protocol, BrandConfig (Pydantic) │ ├── loader.py current_brand() · BRAND env 기반 싱글톤 │ ├── _order_common.py shape_orders / shape_address 공유 로직 │ ├── snaps.py Brand 인스턴스 + adapter 와이어링 │ ├── snaps_order.py SnapsOrderAdapter (JWT mobile 분기) │ ├── ohprintme.py │ └── ohprintme_order.py OhprintmeOrderAdapter (WEB 고정) ├── config/brands/ Brand별 설정 yml (NEW) │ ├── snaps.yml │ └── ohprintme.yml ├── graph/ │ └── main_graph.py Supervisor + 4 workers (LangGraph 1.x) ├── agent/prompt/ │ ├── common.md Langfuse fallback, {brand_name} 파라미터 │ ├── document.md │ ├── order.md │ └── support.md ├── tool/ │ ├── auth.py │ └── document.py ├── zendesk/ │ ├── common.py Sunshine API 래퍼 (post_message, post_typing) │ ├── interactions.py current_brand().order로 라우팅 │ ├── order.py brand-aware shape (어댑터 호출) │ └── support.py ├── tests/eval/ Langfuse Dataset 평가 하니스 (NEW) │ ├── seed.py prd traces → stratified sample → dataset │ ├── runner.py graph_task로 dataset 실행 │ └── judges.py 3축 LLM judge (helpful/correct/brand) ├── handler.py 공유 process_event() (NEW) ├── lambda_function.py 163 LOC → 25 LOC 얇은 래퍼로 축소 ├── app.py FastAPI /webhook 진입점 (NEW) ├── Dockerfile Lambda Image 그대로 유지 ├── Dockerfile.fargate python:3.11-slim + uvicorn (NEW) └── requirements.txt langgraph 1.0.8, langfuse 3.14, fastapi 추가

평가 파이프라인

prd 트레이스를 카테고리별로 stratified-샘플링해 Langfuse Dataset에 저장. 같은 데이터셋으로 v1과 v2를 동일 평가자(LLM judge)로 채점해 회귀 여부를 봐

Eval flow
graph LR P[prd Langfuse traces] -->|seed.py·stratified| DS[(cs-agent-eval-v1
Dataset)] DS -->|runner.py| EX[Experiment Run] G[graph/main_graph
eval_mode=True] --> EX EX -->|judges.py| J{LLM Judge
gemini-3-flash} J --> H[helpfulness 0~1] J --> C[correctness 0~1] J --> B[brand_consistency 0~1] style DS fill:#F4E9D4,stroke:#C9A875 style J fill:#DEEBF6,stroke:#8FB4D6 style EX fill:#FBE5E1,stroke:#E89B92
79/79
미니 테스트 통과
2026-04-20 prd 대화 79건
0.93 / 1.00 / 1.00
snaps 베이스라인 (3축)
helpful · correct · brand
0.91 / 0.93 / 0.97
ohprintme 베이스라인
helpful · correct · brand
12개
카테고리 커버
상품·배송·편집·주문 등

리라이트 임팩트

코드 양과 구조의 변화 — 단순한 LOC 감소가 아니라 "한 곳에 한 번만 적힌" 코드의 비율이 늘어난 게 핵심

+87 / -374
Phase C 순수 변경
중복 제거된 라인 수
163 → 25
lambda_function LOC
얇은 진입점만 남김
2 → 1
코드베이스 갈래
브랜치 통합
5 min → 0
warmup cron
Fargate 상시 실행