지저분한 RFQ 이메일을 가격이 책정된 견적서로 변환하는 에이전트를 구축하고 Alibaba Cloud에 배포했습니다
요약
비정형 RFQ(견적 요청) 이메일과 PDF를 정형화된 견적서로 변환하는 AI 에이전트 구축 사례를 소개합니다. Alibaba Cloud의 Qwen 모델을 활용하여 파싱부터 매칭, 가격 책정까지 이어지는 7단계 파이프라인을 구현했습니다.
핵심 포인트
- 비정형 데이터를 처리하기 위한 7단계 파이프라인(parse, extract, match 등) 설계
- 신뢰도 임계값(0.70)을 설정하여 불확실한 항목은 사람의 검토로 넘기는 Human-in-the-loop 방식 적용
- Qwen-Plus와 임베딩 모델을 활용한 도구 호출 및 카탈로그 매칭 구현
- OpenAI 호환 엔드포인트를 사용하여 모델 교체가 용이한 유연한 아키텍처 구축
우리가 대화한 모든 유통업체는 동일한 조용한 병목 현상을 겪고 있었지만, 그들 중 누구도 이를 문제라고 부르지 않았습니다. 그들은 그저 일상적인 일(Tuesday)이라고 부릅니다.
견적 요청(RFQ, Request for Quote)이 공유 편지함에 도착합니다. 때로는 깔끔한 글머리 기호 목록 형태일 때도 있습니다. 하지만 더 빈번하게는 누군가의 휴대폰에서 작성된 세 줄짜리 텍스트이거나, 비스듬하게 스캔된 PDF 파일인 경우가 많습니다. 영업 데스크의 누군가가 이를 읽고, 각 줄이 실제로 어떤 카탈로그 부품을 가리키는지 파악하며, 가격을 확인하고, 견적서를 작성합니다. 바쁜 데스크에서는 하루에 이런 일을 30~40번씩 반복합니다.
이 과정은 느리고 지루하며, 금요일 오후에 피곤한 직원이 잘못된 볼트(bolt)의 가격을 책정해도 배송이 도착할 때까지 아무도 알아차리지 못하는 바로 그런 종류의 작업입니다.
우리는 이 작업을 수행하기 위해 Distill.ai를 구축하는 데 3주를 보냈습니다. 잘 풀리지 않았던 부분들을 포함하여 우리가 배운 것들은 다음과 같습니다.
우리가 실제로 구축한 것
이메일을 붙여넣거나 PDF를 업로드하면, 다음과 같은 7단계 파이프라인(pipeline)이 실행됩니다:
parse -> extract -> classify -> match -> price -> policy -> score
Parse(파싱)는 문서를 텍스트로 정제합니다. Extract(추출)는 개별 품목, 수량 및 사양을 뽑아냅니다. Classify(분류)는 이것이 어떤 종류의 요청인지 파악합니다. Match(매칭)는 각 줄을 실제 카탈로그 SKU에 매핑합니다. Price(가격 책정)는 가격 규칙을 적용합니다. Policy(정책)는 비즈니스 체크를 수행합니다. Score(점수화)는 모든 매칭에 신뢰도 값을 부여합니다.
흥미로운 부분은 순조로운 경로(happy path)가 아닙니다. 모델이 확신하지 못할 때 어떤 일이 발생하는가 하는 점입니다.
0.70 매칭 임계값(match threshold) 미만의 점수를 받은 모든 줄은 견적에 포함되지 않습니다. 대신 이유와 함께 플래그(flag)가 지정되어 사람의 검토 대기열(human review queue)로 전달됩니다. 사람이 이를 확인하거나 수정하면, 깨끗한 견적서가 발송됩니다.
이 단 하나의 결정이 데모용 제품과 영업 데스크에서 실제로 이름을 걸고 사용할 수 있는 제품 사이의 차이를 만듭니다. 조달(procurement) 분야에서 5%의 확률로 자신 있게 틀리는 에이전트는 무용지물보다 못합니다. 어차피 누군가가 출력물의 100%를 모두 확인해야 하기 때문입니다. 반면 "50개 항목 중 47개를 처리했습니다. 해결하지 못한 3개는 여기 있습니다"라고 말하는 에이전트는 실제로 수많은 시간을 절약해 줍니다.
왜 Qwen인가, 그리고 어떻게 연결했는가
우리는 Alibaba Cloud Model Studio의 두 가지 모델을 사용했습니다:
- Qwen-Plus: 도구 호출 (tool calling)을 포함한 추출 및 분류용
- text-embedding-v4: 카탈로그 매칭 (catalog matching)의 기반이 되는 1024 차원 임베딩 (embeddings)용
Model Studio는 OpenAI 호환 엔드포인트 (OpenAI-compatible endpoint)를 제공하는데, 이는 우리가 예상했던 것보다 더 중요했습니다. 우리의 프로바이더 레이어 (provider layer)는 얇은 fetch 래퍼 (wrapper)로 구성되어 있어, 모델을 교체하는 것이 코드 재작성이 아닌 설정 변경만으로 가능합니다:
const response = await fetch(`${env.LLM_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
...
LLM_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1
LLM_MODEL=qwen-plus
EMBEDDINGS_MODEL=text-embedding-v4
...
임베딩은 pgvector가 설치된 Postgres에 저장되므로, 카탈로그 매칭은 키워드 추측이 아닌 실제 SKU 행에 대한 유사도 검색 (similarity search)으로 이루어집니다. "M8 hex bolt, grade 8.8, zinc plated"와 "Bolt, hexagon head, M8x50, 8.8, ZP"는 문자열 매칭은 아니지만, 벡터 공간 (vector space) 내에서는 가까운 이웃 (neighbours)입니다.
의도적인 선택 중 하나는, Model Studio 엔드포인트 바로 옆에 위치하기 위해 특별히 싱가포르 (ap-southeast-1) 지역에 배포했다는 점입니다. 요청당 여러 번의 모델 호출을 수행하는 파이프라인 (pipeline)에서는 왕복 시간 (round trips)이 누적되기 때문입니다.
스택 (The stack)
TypeScript로 작성된 NestJS 모듈형 모놀리스 (modular monolith)이며, 두 개의 프로세스로 분리되어 있습니다:
- API: 요청을 받고, 검증하며, 저장하고, 작업을 큐에 넣습니다 (enqueues a job)
- Worker: BullMQ를 통해 Redis에서 작업을 소비하며 7개 단계를 모두 실행합니다
이들을 분리하는 것이 중요한 이유는 40페이지 분량의 PDF를 파싱하고 모델을 6번 호출하는 작업은 HTTP 요청 내부에서 처리할 수 있는 일이 아니기 때문입니다. API는 즉시 응답하고 브라우저는 서버 전송 이벤트 (Server-Sent Events)를 통해 진행 상황을 모니터링하므로, 사용자는 로딩 스피너를 바라보는 대신 각 단계가 실시간으로 활성화되는 것을 볼 수 있습니다.
모든 것은 컨테이너화되어 있습니다: api, worker, client (Nginx 뒤의 React 및 Vite), pgvector가 포함된 Postgres, 그리고 Redis입니다. 전체 시스템은 docker-compose를 통해 단일 Alibaba Cloud ECS 인스턴스에서 실행되며, TLS를 위해 앞단에 Caddy를 배치했습니다.
실제로 문제가 발생한 지점
파이프라인을 구축하는 것은 즐거운 과정이었습니다. 하지만 배포 단계에서 며칠을 허비했습니다.
결코 작동할 수 없었던 마이그레이션 단계
우리의 배포 작업(deploy job)은 프로덕션 컨테이너 내부에서 데이터베이스 마이그레이션 (database migrations)을 실행했습니다:
docker compose exec -T api pnpm migration:run
해당 스크립트는 ts-node -r tsconfig-paths/register node_modules/typeorm/cli.js migration:run -d src/database/data-source.ts로 해석됩니다.
이제 프로덕션 Dockerfile을 살펴보겠습니다. 러너 스테이지 (runner stage)는 --prod 의존성만 설치하며, src/가 아닌 dist/를 복사합니다. 따라서 프로덕션 이미지에는 ts-node가 없으며, TypeScript 데이터 소스 (data source)도 존재하지 않습니다. 이 명령은 실제 서버에 닿는 순간 실패할 것이 확실시되었으나, 아직 아무도 실제 배포를 실행하지 않았기에 워크플로 (workflow)에 그대로 방치되어 있었습니다.
해결책은 TypeORM CLI가 컴파일된 데이터 소스를 가리키도록 하는 것이며, 이는 런타임 이미지 (runtime image)에 존재하는 패키지만 필요로 합니다:
docker compose exec -T api \
node node_modules/typeorm/cli.js migration:run -d dist/database/data-source.js
우리는 dist/database/data-source.js가 __dirname 상대적 글로브 (globs)를 통해 엔티티 (entities)와 마이그레이션 (migrations)을 제대로 해석하는지 확인했으며, 컴파일 후에도 @ 별칭 (alias)이 필요하지 않음을 확인했습니다. 그 결과 21개의 마이그레이션이 모두 깔끔하게 적용되었습니다.
교훈: 프로덕션 이미지는 별개의 컴퓨터입니다. 배포 파이프라인 (deploy pipeline)에 있는 명령 중 로컬에서만 실행해 본 명령이 있다면, 그것은 테스트되지 않은 명령입니다.
빈 문자열로 인한 부트 루프 (Boot loop)
api와 worker 컨테이너가 실행되자마자 반복적으로 종료되었습니다:
Invalid environment variables: { SENTRY_DSN: [ 'Invalid url' ] }
우리의 환경 변수 스키마 (env schema)는 SENTRY_DSN을 URL로 검증합니다. 이번 배포에서는 Sentry를 사용하지 않았기 때문에 해당 값은 빈 문자열이었고, 빈 문자열은 유효한 URL이 아닙니다. 이 변수는 의도상으로는 선택 사항(optional)이었지만, 스키마상으로는 그렇지 않았습니다.
해당 라인을 완전히 삭제함으로써 문제를 해결했습니다. 선택 사항(Optional)이라는 것은 값이 없어야 함을 의미하는 것이지, 공백이어야 함을 의미하는 것이 아닙니다.
4 GB 사양의 서버와 메모리 부족으로 인한 프로세스 종료 (Out of memory kill)
저희는 인스턴스 자체에서 이미지를 빌드합니다. 2 vCPU / 4 GB 사양의 박스에서, 클라이언트 빌드가 진행 도중 OOM (Out of memory) 종료되었습니다. 4 GB의 스왑 (swap)을 추가함으로써 빌드를 완료할 수 있었습니다. 우아한 방식은 아니었지만, 배포를 하느냐 마느냐의 차이를 만드는 결정적인 방법이었습니다.
브랜치 보호 (Branch protection) 대 마감 기한
저희의 staging 및 main 브랜치는 풀 리퀘스트 (pull request)와 세 개의 통과된 체크 (checks)를 요구하며, 우리 중 누구도 스스로 머지 (self merge)할 수 없습니다. 올바른 정책입니다. 또한 마감 기한이 다가오는 밤 11시에는 전혀 타협할 수 없는 정책이기도 합니다.
그래서 저희는 첫 번째 릴리스를 수동으로 올렸습니다: git archive를 실행하고, scp로 박스에 전송한 뒤, docker compose build, 마이그레이션 (migrate), up -d를 수행했습니다. 그 후 파이프라인 수정을 적절히 리뷰된 PR (pull request)로 다시 작성하여, 다음 배포는 마땅히 그래야 하듯 CI (Continuous Integration)를 통해 진행되도록 했습니다.
저희는 브랜치 보호 정책을 후회하지 않습니다. 자신의 안전 가드레일 (safety rails)을 한 번, 의도적으로 우회하여 배포한 뒤 나중에 그 틈을 제대로 메우는 것은, 가드레일 자체가 없는 것과는 매우 다릅니다.
도메인 비용 없이 HTTPS 사용하기
저희는 DuckDNS 서브도메인과 함께 Caddy를 사용했습니다. Caddy는 ACME 챌린지 (ACME challenge)와 인증서 갱신을 스스로 처리하므로, 전체 TLS 설정은 다음과 같습니다:
distill-ai.duckdns.org {
reverse_proxy client:8080
}
단 두 줄로 실제 Let's Encrypt 인증서를 사용할 수 있습니다. 그 후 SSH를 단일 운영자 IP로 제한하고 원시 API 포트를 닫아서, 유일한 공개 접점(public surface)은 HTTPS 정문뿐이 되도록 했습니다.
3주 전의 우리 자신에게 해주고 싶은 말
19일째가 아니라 2일째에 배포하라. 위의 모든 버그는 로직 버그가 아니라 배포 버그였습니다. 그 중 어떤 것도 로컬에서 찾아낼 수 없었습니다. 파이프라인은 내내 저희의 로컬 머신에서는 잘 작동했습니다.
신뢰도 점수 (Confidence scoring)는 기능이 아니라 제품 그 자체다. 저희는 리뷰 큐 (review queue) 없이 출시할 뻔했습니다. 리뷰 큐를 추가한 순간, 전체 시스템은 단순한 눈속임(party trick)에서 고객에게 보여줄 수 있는 무언가로 변했습니다.
OpenAI 호환 엔드포인트 (OpenAI-compatible endpoint)는 엔지니어링 시간 측면에서 실제 돈만큼의 가치가 있다. 저희는 Qwen 전용 클라이언트를 작성한 적이 없습니다. 저희는 HTTP 클라이언트를 작성하고 베이스 URL (base URL)만 변경했습니다.
"로컬에서 실행된다(It runs locally)"와 "프로덕션에서 실행된다(it runs in production)" 사이에는 놀라울 정도로 화려하지 않은 작업들이 존재합니다. 스왑 공간 (Swap space), 빈 문자열 (Empty strings), 누락된 개발 의존성 (dev dependencies) 등입니다. 이 중 흥미로운 것은 하나도 없지만, 이 모든 것들이 반드시 필요합니다.
직접 시도해보기
배포된 서비스는 현재 라이브 상태이며 로그인 없이 공개되어 있습니다:
https://distill-ai.duckdns.org
시드 데이터가 포함된 카탈로그는 아연 도금된 패스너(fasteners)이므로, M6, M8 또는 M10 볼트, 너트, 와셔를 중심으로 RFQ (견적 요청서)를 작성해 보세요. 아래 내용을 복사해서 붙여넣으셔도 됩니다:
안녕하세요 팀, 저희의 새로운 조립 라인을 위해 다음 항목에 대한 견적을 부탁드립니다:
- 1500 x M10 육각 볼트, grade 8.8, 아연 도금
- 1500 x M10 육각 너트, 아연 도금
...
트레이스 (trace)가 실행되는 것을 지켜본 후, 리뷰 페이지를 열어보세요. 만약 항목 중 하나가 견적 (quote)이 아닌 리뷰 대기열 (review queue)에 들어간다면, 그것은 버그가 아닙니다. 그것이 바로 이 시스템의 핵심 목적입니다.
이 프로젝트는 NestJS, TypeScript, React, pgvector를 사용한 PostgreSQL, Redis, BullMQ, Docker, Caddy로 구축되었으며, Alibaba Cloud ECS 위에서 Alibaba Cloud Model Studio를 통해 Qwen-Plus와 text-embedding-v4를 사용하여 실행됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기