멀티스텝 AI 파이프라인 구현 검토 - 100-hours-a-week/KTB4-4th-wiki GitHub Wiki
3단계는 AI 서버를 api·worker·batch·model-runtime 네 배포 단위로 나눴다. 무엇을 따로 배포하고 따로 스케일할지까지 정했고, 그 단위 안에서 무슨 일이 어떤 순서로 도는지는 정하지 않았다. 4단계는 그 안쪽을 정한다.
| 앞 단계에서 넘어온 것 | 이 문서에서 답하는 곳 |
|---|---|
잡 큐 계약이 정한 pending → running → succeeded / failed의 running 안쪽 |
파이프라인 3 |
| 1단계가 범위 밖으로 미룬 "한 번의 잡 안에서 이어지는 모델 호출과 상품 검색"의 단계 구성 | 파이프라인 3 |
| 1단계가 확정과 리스트 생성을 두 구간으로 나눈 결정 | 파이프라인 2 · 파이프라인 3 |
| 오케스트레이션 프레임워크를 쓸 것인가 | 흐름을 누가 정하는가 · 프레임워크 판단 |
상품 카탈로그를 채우는 배치도 여러 단계로 이어지는 오프라인 파이프라인이지만 여기서 다루지 않는다. 크롤링과 속성 추출을 어떤 순서로 돌릴지는 무엇을 수집할 것인가가 정해진 다음의 문제이고, 그 수집 전략은 추후에 확정한다. 3단계가 batch라는 배포 단위로 자리만 잡아뒀다.
4단계 과제가 상정하는 LLM 서비스의 질의 처리 흐름은 사용자 질의를 받아 검색하고 응답을 만드는 모양이다. 니쥬의 대화는 방향이 반대다. 사용자가 묻는 것이 아니라 AI가 물어서 취향을 캐낸다. 그래서 설계의 중심 질문이 들어온 질의를 어떻게 처리할까가 아니라 이번 턴에 무엇을 물을까가 되고, 그 결정을 모델이 아니라 서버 규칙이 한다.
무엇을 물을지는 규칙이 정하고 어떻게 물을지는 모델이 정한다. 규칙은 지금까지 채워진 취향 칸과 턴 수, 사용자 발화 길이를 보고 목표 하나를 고른다. 모델은 그 목표에 해당하는 지시문 한 줄을 받아 문장을 쓴다. 모델은 흐름에 관여하지 않는다.
| 규칙이 맡는 이유 | 모델에 맡기면 |
|---|---|
| 판단이 재현된다 | 같은 상태에서 다른 질문이 나온다. 대화가 이상하게 흘렀을 때 원인을 찾을 수 없다 |
| 세면 되는 값이다 | 검색용 항목 개수, 필터 항목이 0개인지, 턴 상한에 닿았는지는 세어보면 아는 것이다. 추론을 쓸 이유가 없다 |
| 호출이 늘지 않는다 | 판단용 호출이 하나 더 붙거나 응답 호출의 출력이 길어진다. 대화 턴은 TTFT p95 1초가 목표인 유일한 사용자 대기 구간이다 |
| 프롬프트 접두사가 고정된다 | 프롬프트 구조가 턴마다 달라져 프리픽스 캐시 조건이 깨진다 |
| 종료 조건이 결정적이다 | 무한히 묻거나 너무 일찍 끝낸다 |
대가도 있다. 규칙은 사용자가 방금 한 말에 자연스럽게 이어지는 질문을 고르지 못한다. 목표와 문장을 나눈 구조가 그 대가를 흡수한다. 무엇을 물을지의 폭은 규칙이 좁히고, 그 안에서 자연스럽게 잇는 일은 모델이 문장 단계에서 한다.
이 결정은 대화에만 있는 것이 아니라 세 파이프라인에서 같은 모양으로 반복된다.
| 지점 | 모델이 정하지 않는 것 |
|---|---|
| 대화 턴 | 이번 턴에 무엇을 물을지, 어떤 항목을 버릴지 |
| 리스트 생성 잡 | 검색을 언제 부를지. 서버가 부른다 |
| 리스트 생성 잡 | 0건일 때 쿼리를 완화해 다시 검색할지. 완화하지 않고 emptyReason으로 반환한다 |
| 리스트 생성 잡 | 어느 상품을 몇 개 고를지. 스코어와 다양성 선별은 순수 계산이다 |
모델이 하는 일은 문장을 쓰거나 JSON을 내는 것뿐이다.
이것이 뒤의 프레임워크 판단의 전제다. 니쥬에 멀티스텝이 없다는 뜻이 아니다. 파이프라인은 셋이고 단계는 각각 일곱·셋·여섯이다. 없는 것은 흐름 제어를 모델에 넘길 때 생기는 문제이고, 오케스트레이션 프레임워크가 잘 푸는 것이 정확히 그 문제다. 조건부 분기, 재시도 순환, 도구 호출 루프는 다음에 무엇을 할지 모델이 정할 때 필요해진다. 그 전제를 처음부터 받지 않았으므로 그래프가 풀 문제가 남지 않는다.
1. 대화 턴 — 다이어그램과 설계 이유
한 턴은 모델 호출 두 번을 포함해 일곱 단계로 처리된다. 이 중 사용자가 화면에서 기다리는 것은 응답 생성뿐이고, 나머지는 앞이나 뒤에서 돈다.
이 문서에서 1개 대화 사이클은 사용자 입력 1회와 AI 응답 1회의 왕복이다. 최대 20개 대화 사이클은 총 40개 발화이며, 대화 구간 모델 호출도 응답 20회 + 추출 20회인 40회다.
flowchart LR
L["세션 상태 로드<br/>Redis"] --> G["턴 목표 판정<br/>규칙 · LLM 미사용"]
G --> R["응답 생성<br/>응답 모델 · SSE"]
R --> P["응답 후처리<br/>지시문 절단 · 질문 1개"]
P --> X["델타 추출<br/>추출 모델 · 구조화 출력"]
X --> M["병합 · 정정 · 방출<br/>서버 로직"]
M --> S["세션 상태 저장<br/>Redis"]
R -.->|사용자 대기 구간| P
style R fill:#f0f7ff
style G fill:#fff4e6
style M fill:#fff4e6
- 목표 판정과 병합·정정은 LLM을 부르지 않는 결정적 규칙이다. 일곱 단계 중 모델을 부르는 것은 둘뿐이고 나머지 다섯은 서버 규칙이다. 무엇을 물을지와 무엇을 버릴지를 모델에 맡기면 매 턴 판단이 흔들리고 토큰도 는다.
- 응답과 추출이 한 호출이 아니라 두 호출이다. 그래서 추출이 실패해도 답변은 이미 사용자에게 나갔다. 파이프라인을 나눈 것이 실패 격리를 구조로 만든다.
- 추출은 대화 전체가 아니라
[STATE]블록과 발화 두 개만 받는다. 단계마다 필요한 것만 넘기면 뒤 단계의 입력이 앞 단계의 길이에 끌려가지 않는다. - 이 파이프라인은
api안에서 인프로세스 함수 호출로 이어진다. 일곱 단계가 항상 같이 배포되고 순차로 실행되므로 사이에 네트워크를 넣을 이유가 없다.
2. 프로필 확정 — 다이어그램과 설계 이유
close 요청 안에서 도는 구간이다. 세 단계이고 그중 모델 호출은 요약문 생성 하나다.
flowchart LR
C["① 프로필 확정<br/>세션 상태 → TasteProfile"] --> SUM["② 요약문 생성<br/>응답 모델"]
SUM --> K["③ 표시 키워드 선별<br/>순수 계산"]
K --> R["profile + summary + keywords"]
style SUM fill:#f0f7ff
style C fill:#fff4e6
style K fill:#fff4e6
- 세 단계 중 둘이 순수 계산이고 모델 호출은 하나다. 지연 편차가 작아 잡으로 등록하지 않고 요청 안에서 처리한다. 사용자는 대화 종료 후 곧바로 확인 화면을 본다.
- ②가 실패해도 ①③의 결과는 유효하다. 요약문 없이 프로필과 키워드만 반환한다. 요약문은 친구가 읽는 글이고, 프로필은 검색에 쓰는 값이라 실패를 함께 묶을 이유가 없다.
- ③은
visibility가friends이상인 항목만 확신도 순으로 고른다. 규칙 계산이라 GPU가 필요 없고 조정도 배포 없이 된다. - 검색 문장 재작성은 이 구간에 없다. 사용자가 확인 화면에서 정정을 요청하면 프로필이 바뀌므로, 확정 전에 만든 쿼리는 버려진다.
- 이 구간은
api안에서 인프로세스로 이어진다.worker로 넘기지 않는 이유는 요청 안에서 끝나기 때문이다.
3. 리스트 생성 잡 — 다이어그램과 설계 이유
사용자가 프로필을 승인하면 등록되는 잡이다. 검색 문장 재작성부터 응답 조립까지 여섯 단계이고 갈래가 없다.
flowchart TD
Q["① 검색 문장 재작성<br/>취향 항목 → 파생 쿼리"] --> E["② 임베딩 배치<br/>Upstage Embed 2 · 1회 호출"]
E --> V["③ 벡터 검색 · 하드필터<br/>pgvector 단일 쿼리"]
V --> SC["④ 스코어 · 다양성 선별<br/>순수 계산"]
SC --> RE["⑤ 추천 이유 생성<br/>응답 모델 · 쿼리 단위"]
RE --> J["⑥ 응답 조립"]
J --> D["self + gift 두 리스트"]
DEL["세션 원문 삭제"]
style RE fill:#f0f7ff
style SC fill:#fff4e6
style DEL stroke-dasharray: 4 4
원문 삭제는 잡 등록과 같은 시점에 병렬로 실행하며 잡의 단계에 들어가지 않는다. 삭제 결과를 기다리면 리스트 생성이 삭제 지연에 묶인다.
- 갈래가 없어진 것은 요약문 생성이 앞 구간으로 옮겨갔기 때문이다. 이전 설계에서는 요약문이 검색 결과를 쓰지 않는다는 점 때문에 병렬 갈래를 뒀는데, 확인 구간이 생기면서 요약문은 확인 대상이 되어 앞으로 이동했다. 그 결과 이 잡은 앞 단계의 출력이 다음 단계의 입력이 되는 직선이 됐다.
- 병렬 갈래가 사라져 요약과 이유 생성이 같은 GPU를 두고 경쟁하지 않는다. 2단계가 E7 실측 뒤로 미뤄뒀던 병렬화 채택 판단이 필요 없어졌다.
- ①을 여기 두는 이유는 확정된 프로필로 쿼리를 만들어야 하기 때문이다. 정정 대화로 항목이 바뀌면 쿼리도 달라진다.
- ③에서 하드필터와 벡터 검색이 한 쿼리에 들어간다. 유사한 것을 먼저 뽑고 알레르기로 거르면 후보가 전부 걸려 0건이 될 수 있다. 이 순서 제약이 뒤의 프레임워크 판단에 그대로 걸린다.
- ④는 모델을 부르지 않는 순수 계산이다. 스코어 가중치를 조정할 때 GPU가 필요 없다.
-
self와gift를 한 잡에서 만든다. ①~③과 ④의 다양성 선별을 공유하고 스코어 함수에서만 갈라지므로, 모드별로 잡을 나누면 임베딩과 벡터 검색이 두 번 돈다. - 여섯 단계가 하나의 잡이고
worker안에서 인프로세스로 이어진다.api와worker사이만 큐를 탄다.
4. 프레임워크를 쓰지 않는 판단 — 근거
LangChain과 LangGraph를 검토했고 도입하지 않기로 했다. 판단 기준은 프레임워크를 이 구조에서 쓸수 있는지가 아니라 쓰는게 더 나은가로 생각했다.
flowchart LR
subgraph N["니쥬의 세 파이프라인"]
direction TB
N1["분기 — 없음<br/>목표 8종은 지시문 문자열만 바뀜"]
N2["순환 — 없음<br/>0건은 emptyReason으로 반환"]
N3["병렬 — 없음<br/>세 파이프라인 모두 직선"]
N4["상태 — Redis 직접 관리<br/>항목 상한 · 방출 규칙이 서버 로직"]
end
subgraph F["프레임워크가 필요할때"]
direction TB
F1["조건부 분기가 여러 개"]
F2["재시도·완화 순환"]
F3["복잡한 fan-out/fan-in"]
F4["체크포인터 기반 재개"]
end
N1 -.->|해당 없음| F1
N2 -.->|해당 없음| F2
N3 -.->|gather 한 줄로 충분| F3
N4 -.->|원문 미저장과 충돌| F4
- 프레임워크는 추상화를 얻는 대신 통제를 내준다. 니쥬는 프롬프트 접두사를 바이트 단위로 고정해야 캐시가 먹고, 하드필터가 벡터 검색과 한 쿼리에 들어가야 상품 호출이 0이 되는걸 방지한다. 둘 다 프레임워크가 중간에서 문자열과 쿼리를 조립하는게 좋은 결정이 아니라고 판단했다.
- 도입하지 않는 것이 영구 결정은 아니다. 다시 볼 조건을 아래에 명시해 둔다.
| 경로 | 단계 수 | 모델 호출 | 사용자 대기 | 이 문서에서 |
|---|---|---|---|---|
| 대화 턴 | 7 | 2 (응답 · 추출) | 있음 (SSE) | 파이프라인 1 |
| 프로필 확정 | 3 | 1 (요약) | 있음 (동기 응답) | 파이프라인 2 |
| 리스트 생성 잡 | 6 | 2종 (임베딩 · 이유) | 없음 (메인 화면에서 SSE 대기) | 파이프라인 3 |
| 리스트 재생성 | 6 | 2종 (임베딩 · 이유) | 없음 | 리스트 생성 잡과 동일 |
| 피드백 수집 | 1 | 0 | 없음 | 멀티스텝 아님 |
| 카탈로그 배치 | 4 | 2종 | 없음 (오프라인) | 5단계로 넘김 |
리스트 재생성은 리스트 생성 잡과 단계가 같다. 진입점이 사용자 확정이 아니라 백엔드 트리거라는 점만 다르고, 확정된 프로필을 받아 도는 것은 동일하다. 같은 코드가 두 진입점을 처리하므로 별도 파이프라인으로 세지 않는다.
다만 이 경로를 잡으로 등록할지 요청 안에서 동기로 끝낼지는 아직 갈려 있다. 1단계 계약은 POST /v1/recommendations를 동기로 분류해 200에 결과를 바로 담고, 3단계는 사용자 확정과 백엔드 재생성 트리거가 같은 잡을 등록한다고 적었다. 단계 구성은 같아도 api에서 도는지 worker에서 도는지가 갈리므로 미확정으로 남긴다.
파이프라인 2와 3 사이에는 사용자 확인이 들어간다. 두 구간이 하나의 잡이 아닌 이유가 여기에 있다. 확인 없이 이어 붙이면 사용자가 정정할 수 없고, 정정 대화로 프로필이 바뀌면 뒤 구간의 산출물이 전부 버려진다.
| 단계 | 입력 | 출력 | 처리 주체 | 실패하면 | |
|---|---|---|---|---|---|
| ① | 세션 상태 로드 | sessionId |
이력 · items · axes | Redis | 404 SESSION_NOT_FOUND
|
| ② | 턴 목표 판정 | 항목 수 · 턴 수 · 발화 길이 | 목표 1개 + 지시문 | 서버 규칙 | 발생하지 않음 (순수 계산) |
| ③ | 응답 생성 | 시스템(불변) + 누적 이력 + 지시문 + 발화 | 토큰 스트림 | 응답 모델 | 타임아웃 20초, 재시도 1회. 그 턴 실패 |
| ④ | 응답 후처리 | 생성된 문장 | 절단된 문장 | 서버 규칙 | 발생하지 않음 |
| ⑤ | 델타 추출 |
[STATE] + 직전 AI 발화 + 이번 발화 |
델타 JSON | 추출 모델 | 스키마 위반 2회 재시도. 실패하면 빈 델타 |
| ⑥ | 병합 · 정정 · 방출 | 델타 + 기존 상태 | 갱신된 상태 | 서버 규칙 | 발생하지 않음 |
| ⑦ | 세션 상태 저장 | 갱신된 상태 | — | Redis | 다음 턴이 이전 상태로 시작 |
②의 목표 8종은 조건부 분기처럼 보이지만 파이프라인이 갈라지는 것이 아니다. 어느 목표가 나오든 뒤의 단계는 동일하고, 달라지는 것은 ③에 넘기는 지시문 문자열 하나다. 그래프로 그리면 노드 8개가 같은 다음 노드로 모이는 모양이 되어 표현이 늘 뿐 실행이 달라지지 않는다.
⑤가 실패해도 ③은 이미 사용자에게 전달됐다. 부분 실패를 전체 실패로 만들지 않는다는 1단계 원칙이 파이프라인을 나눈 결과로 성립한다.
20번째 사용자 입력도 ③ 응답부터 ⑦ 저장까지 모두 처리한다. 그 뒤 입력을 잠그고 현재 State를 프로필로 확정한다. 스키마가 덜 채워졌더라도 21번째 질문을 만들지 않으며 completionReason=MAX_CYCLES, profileCompleteness=partial|sufficient로 종료한다. 마지막 추출이 실패하면 직전 State를 사용하고 실패 플래그를 남긴다.
close 요청 안에서 동기로 처리한다.
| 단계 | 입력 | 출력 | 처리 주체 | 실패하면 | |
|---|---|---|---|---|---|
| ① | 프로필 확정 | 세션 상태 (items ≤ 12 · axes ≤ 3) | TasteProfile |
서버 규칙 | 발생하지 않음 (순수 계산) |
| ② | 요약문 생성 |
friends 항목 |
요약문 1개 | 응답 모델 | 요약 없이 반환 (summary: null) |
| ③ | 표시 키워드 선별 | 확정된 프로필 |
taste · interest 각 최대 3개 |
서버 규칙 | 발생하지 않음 |
②만 모델 호출이고 나머지는 순수 계산이다. ②가 실패해도 ①③의 결과는 유효하므로 부분 실패로 처리한다. 세 단계 모두 api 안에서 인프로세스로 이어진다.
세션 원문은 이 구간에서 삭제하지 않는다. 사용자가 확인 화면에서 정정을 요청하면 같은 세션을 재개해야 하기 때문이다.
파이프라인 2와 3 사이에 사람이 들어간다. AI 서버가 도는 구간이 아니므로 단계로 세지 않지만, 앞뒤 파이프라인의 입출력을 결정하므로 여기 적는다.
| 사용자 행동 | 다음에 일어나는 일 |
|---|---|
| 맞습니다 | 백엔드가 프로필·요약문·키워드를 저장하고 확정 요청을 보낸다. 리스트 생성 잡이 등록되고 세션 원문 삭제가 병렬로 실행된다 |
| 다릅니다 | 같은 세션을 재개해 목표 CORRECT로 정정 대화를 시작한다. 세션당 1회만 허용한다. 대화를 마치면 close를 다시 호출한다 |
| 확인 없이 이탈 | 미확정. 프로필만 있고 리스트가 없는 상태가 된다 |
정정 대화가 세션 TTL(유휴 30분 · 최대 2시간) 안에서만 가능하다는 제약이 여기 걸린다. 확인 대기 중 TTL을 연장할지는 미확정이다.
확정 요청으로 등록되어 worker에서 돈다.
| 단계 | 입력 | 출력 | 처리 주체 | 실패하면 | |
|---|---|---|---|---|---|
| ① | 검색 문장 재작성 | 확정된 프로필의 query 역할 항목 |
파생 쿼리 최대 6개 | 8B 응답 모델 | 잡 실패 |
| ② | 임베딩 배치 | 파생 쿼리 전체 (5~12건) | 벡터 목록 | Upstage Embed 2 API | 재시도 1회 후 잡 실패 |
| ③ | 벡터 검색 · 하드필터 | 벡터 + constraints + owned + 제외 목록 |
후보 300~500 | pgvector | 503 CATALOG_UNAVAILABLE
|
| ④ | 스코어 · 다양성 선별 | 후보 + 프로필 | 모드당 20개 | 순수 계산 | 발생하지 않음 |
| ⑤ | 추천 이유 생성 | 쿼리 + 걸린 상품 | 쿼리당 문장 1개 | 응답 모델 | 신호 나열 기본 문구로 대체 |
| ⑥ | 응답 조립 | 두 리스트 | 잡 결과 | 서버 | 잡 실패 |
갈래가 없다. 이전 설계에서는 요약문 생성이 검색 갈래와 병렬로 갈라졌는데, 요약문이 사용자 확인 대상이 되어 파이프라인 2로 옮겨가면서 병렬 지점이 사라졌다. estimatedMs를 맞추기 위한 병렬화가 필요했던 이유도 여덟 단계를 한 구간에 넣어야 했기 때문인데, 구간이 나뉘면서 그 제약도 함께 없어졌다.
③에서 후보가 0건이거나 ④에서 전량이 최소 점수에 미달하면 빈 리스트와 emptyReason을 반환한다. 쿼리를 완화해 ②로 돌아가지 않는다. 카탈로그에 상품이 없어서 0건인 경우 쿼리를 넓혀도 같은 결과가 나오고, 대기 시간만 두 배가 되기 때문이다. 부족한 구간은 카탈로그 커버리지로 집계해 수집으로 해결한다.
①의 입력은 1.3B 추출 모델이 방금 반환한 원시 델타가 아니다. 델타 → 서버 검증·병합 → Redis 누적 State → 종료 시 TasteProfile 확정 → 사용자 승인을 거친 최종 프로필이다. 8B는 그중 query 역할 항목을 상품 문서와 비교하기 좋은 queryText로 바꾼다. 서버는 모델 출력의 tasteNodeIds를 검증하고 원본 프로필에서 confidence, mode, hardFilters를 복원한 뒤에만 ② 임베딩 API로 보낸다. 따라서 추출 모델은 사용자 상태를 기억하거나 임베딩 벡터를 만들지 않고, 8B도 알레르기·기피 조건을 바꿀 권한이 없다.
잡이 실패하면 백엔드가 같은 프로필로 확정 요청을 다시 보낸다. 프로필이 이미 백엔드에 저장되어 있으므로 대화를 다시 하지 않는다. 세션 원문은 이미 지워졌지만 이 경로에는 필요하지 않다.
잡 등록과 같은 시점에 병렬로 실행한다. 잡의 단계에 넣지 않는 이유는 두 가지다.
- 삭제 결과를 기다리면 리스트 생성이 삭제 지연에 묶인다. 두 작업은 서로의 입력을 쓰지 않는다.
- 삭제가 실패해도 잡을 실패시킬 이유가 없다. 실패는 로그로 남기고 세션 TTL이 최종 삭제를 보장한다.
LangChain이 제공하는 것을 니쥬에서 그 자리를 이미 무엇이 채우고 있는지와 나란히 놓으면 이렇다.
| LangChain 구성요소 | 니쥬 구성요소 | 바꾸면 생기는 일 |
|---|---|---|
| LLM 래퍼 |
gateway 모듈의 ChatModel · StructuredExtractor 포트 |
교체 이득이 없다. OpenAI 호환 HTTP라 vLLM과 상용 API가 이미 같은 클라이언트로 바뀐다 |
| 프롬프트 템플릿 |
prompts/ 4개 파일 + prompts.py 조립 |
조립 순서가 KV 캐시 조건이다. 고정 문자열로 쓰면 템플릿으로도 지킬 수 있지만, 접두사가 바이트 단위로 유지되는지를 버전이 오를 때마다 확인해야 한다. 그 확인 비용이 얻는 것보다 크다 |
| 출력 파서 |
response_format: json_schema(strict) + 서빙 단 guided decoding |
파서는 생성된 뒤에 검사하고 실패하면 다시 부른다. 스키마 강제는 애초에 어긋난 토큰을 생성하지 않는다. 낭비되는 추론이 없는 쪽이 낫다 |
| 리트리버 | pgvector 단일 쿼리 (하드필터 + 벡터) | 리트리버도 사전 필터링을 지원하므로 순서 자체는 표현된다. 문제는 걸어야 할 조건이 constraints·owned·제외 목록·최소 점수 컷이 섞인 도메인 로직이라는 점이다. 추상화를 거쳐 조립하는 것보다 쿼리를 직접 쓰는 편이 짧고 실행 계획도 눈에 보인다 |
| 메모리 | Redis 세션 상태 + 서버의 병합·정정·방출 규칙 | 항목 12개 상한과 방출 우선순위, 정정 처리가 서비스 고유 규칙이다. 범용 메모리로 대체되지 않는다 |
| 체인 조립 | 인프로세스 함수 호출 | 세 파이프라인 모두 순차 실행이다. 조립할 것이 없다 |
여섯 항목 중 다섯은 이미 있는 것을 대체하는 일이고, 그중 셋은 대체하면 손해다. 남는 하나인 체인 조립은 함수를 순서대로 부르는 것 이상이 아니다.
| LangGraph가 푸는 문제 | 니쥬에 그 요구가 있는가 |
|---|---|
| 조건부 분기 | 없다. 턴 목표 8종은 지시문 문자열만 바꾸고 실행 경로는 하나다 |
| 순환 (재시도 · 완화 후 재실행) | 없다. 0건은 emptyReason으로 반환하고 재검색하지 않는다. 스키마 위반 재시도는 gateway 안에서 같은 호출을 다시 하는 것이라 그래프 순환이 아니다 |
| 병렬 fan-out / fan-in | 없다. 파이프라인 안에 갈래가 없고, 원문 삭제만 잡 바깥에서 병렬로 돈다 |
| 체크포인터 기반 재개 | 쓸 수 없다. 체크포인터는 그래프 상태를 영속화하는 것이 기본 동작이고, 그 상태에 대화 이력이 들어간다. 대화 원문을 저장하지 않는다는 1단계 원칙과 충돌한다 |
| 도구 호출 루프 | 없다. 모델이 도구를 고르는 경로가 없다. 검색은 서버가 부른다 |
마지막 항목이 판단의 핵심이다. 니쥬의 파이프라인에서 다음에 무엇을 할지는 전부 서버 코드가 정한다. 모델은 문장을 쓰거나 JSON을 내는 일만 하고 흐름에 관여하지 않는다. 흐름 제어를 모델에 맡기지 않기로 한 것은 1단계와 2단계에서 여러 번 반복한 결정이고, 그 결정이 유지되는 한 그래프 오케스트레이션이 풀 문제가 남지 않는다.
| 필요한 것 | 쓰는 것 |
|---|---|
| 단계 연결 | 파이썬 함수 호출. 단계마다 입력과 출력 타입을 명시한다 |
| 모델 교체 |
gateway의 포트 인터페이스. 어댑터 한 파일을 바꾼다 |
| 잡과 무관한 병렬 작업 |
asyncio.create_task. 원문 삭제가 여기 해당한다 |
| 잡 실행과 재시도 | Redis 잡 큐. jobId가 멱등 키다 |
| 진행률 | 단계가 끝날 때마다 progress 갱신 |
| 형식 강제 |
response_format: json_schema(strict), 서빙 단 guided decoding |
| 관측 | 단계별 소요 시간을 잡 결과에 기록. 지표 수집과 대시보드는 파이프라인 바깥의 문제이므로 7단계에서 다룬다 |
의존성이 늘지 않는다는 점이 부수 효과로 따라온다. ranking과 profile 모듈은 순수 파이썬이라 GPU도 DB도 없이 단위 테스트가 된다. 프레임워크를 넣으면 이 모듈들이 프레임워크 타입을 import하게 되어 그 성질이 깨진다.
다만 실제 서비스에서 아래 중 하나라도 성립하면 지금 내용을 재검토한다. 지금은 셋 다 성립하지 않는다.
| 조건 | 지금 상태 | 성립하면 |
|---|---|---|
| 검색 0건일 때 카탈로그를 즉시 수집하고 재검색하는 온디맨드 폴백을 넣는다 | 3단계에서 미확정으로 남김 | ④~⑤에 순환이 생긴다. 재시도 상한과 중간 상태 관리가 필요해진다 |
| 모델이 도구를 골라 부르는 경로가 생긴다 | 없음 | 6단계 MCP 설계에서 판정한다. 도구 루프는 그래프가 잘 푸는 문제다 |
| 잡이 길어져 단계별 재개가 필요해진다 | 여섯 단계이고 사용자 대기 경로가 아니다 | 통째 재실행 비용이 커지면 체크포인트를 도입한다 |
두 번째 조건은 6단계에서 다시 다룬다. 외부 도구를 표준 프로토콜로 붙이기로 하면 흐름 제어를 모델에 넘기는 첫 지점이 되고, 그때 오케스트레이션 판단이 함께 바뀐다.
| 구분 | 선택 | 쓰는 단계 | 고른 이유 |
|---|---|---|---|
| 응답 모델 |
kakaocorp/kanana-1.5-8b-instruct-2505 (BF16) |
대화 ③, 확정 ②, 잡 ①⑤ | 로컬 대화 품질을 확인했고 24GB GPU 한 장에 양자화 없이 배포할 수 있다 |
| 추출 모델 |
kakaocorp/kanana-2-1.3b-instruct (BF16) |
대화 ⑤ | 고정 JSON Schema와 서버 검증·병합을 사용하므로 턴별 델타 추출에 충분한지 골드셋으로 검증한다 |
| 임베딩 | Upstage Embed 2 (solar-embedding-2, 1024차원) |
잡 ②, 카탈로그 배치 | 유일하게 자체 서빙하지 않는 모델이다. 이유는 아래에 |
| 서빙 스택 | vLLM | 전체 | prefix caching · guided decoding · continuous batching이 파이프라인 전제다 |
| 벡터 저장소 | PostgreSQL + pgvector | 잡 ③ | 하드필터와 벡터 검색이 한 쿼리에 들어간다 |
| 세션 · 큐 | Redis | 대화 ①⑦, 잡 진입 | 세션 상태와 잡 큐, 임베딩 캐시를 같은 저장소가 맡는다 |
| 오케스트레이션 | 없음 (직접 조립) | 전체 | 위 판단 |
한 파이프라인에 모델이 셋 들어가지만 자체 서빙하는 것은 둘이다. 어느 모델을 몇 개 인스턴스에 나눠 올릴지는 7단계에서 정한다.
2단계는 비용과 데이터, 조정 여지 셋을 들어 상용 API에서 자체 서빙으로 옮겼다. 그 셋이 임베딩에는 걸리지 않는다.
| 자체 서빙으로 옮긴 근거 | 임베딩에 걸리는가 |
|---|---|
| 사용자당 호출이 많아 토큰 단가가 원가가 된다 | 걸리지 않는다. 대화는 최대 20개 사이클 × 2회로 사용자당 40회지만 임베딩은 잡당 배치 1회다. 상품 1만 개에 벡터 3종을 처음 적재해도 100만 토큰당 0.02달러 기준으로 1달러가 안 된다 |
| 대화 원문이 외부로 나간다 | 걸리지 않는다. ②에 들어가는 것은 원문이 아니라 ①에서 재작성된 파생 쿼리다. 세션 원문은 사용자 승인과 잡 등록 시점에 삭제되며 잡 입력에는 포함되지 않는다 |
| 프롬프트 캐시와 배치를 조정할 수 없다 | 걸리지 않는다. 배치와 캐시를 API 바깥에서 우리가 건다. O2가 그것이고, 한 요청에 문자열 100건까지 받으므로 프로필당 5~12건이 상한에 걸리지 않는다 |
여기에 GPU 이득이 붙는다. 임베딩 모델을 올리지 않는 만큼이 KV 캐시 몫으로 가고, 더 낮은 성능의 GPU도 사용 가능하다. 이는 8세션 동시성 목표에 직접 기여한다.
async def handle_turn(session_id: str, utterance: str) -> AsyncIterator[str]:
state = session_store.load(session_id) # ①
goal = policy.decide(state) # ② LLM 미사용
messages = prompts.build_reply( # ③ 접두사 고정
system=SYSTEM_FIXED, # 불변
history=state.history, # 누적만
instruction=goal.instruction, # 매 턴 바뀜
utterance=utterance,
)
buffer = []
async for token in chat_model.stream(messages, temperature=0.7):
buffer.append(token)
yield token # SSE
reply = postprocess.trim(''.join(buffer)) # ④ 지시문 절단·질문 1개
try: # ⑤ 스트림 종료 후
delta = await extractor.extract(
prompts.build_extract(state, reply, utterance),
schema=DELTA_SCHEMA, # strict
)
except SchemaViolation:
delta = Delta.empty() # 그 턴만 버린다
state = profile.merge(state, delta) # ⑥ 병합·정정·방출
session_store.save(session_id, state, ttl_s=1800) # ⑦async def close_session(session_id: str) -> CloseResult:
state = session_store.load(session_id)
prof = profile.finalize(state) # ① 순수 계산
try: # ② 모델 호출 1회
summary = await summarizer.write(
prof.friends_visible(), timeout_s=5
)
except (Timeout, LLMUnavailable):
summary = None # 부분 실패로 처리
keywords = profile.display_keywords(prof) # ③ 순수 계산
session_store.mark_closed(session_id) # 원문은 남긴다
return CloseResult(
profile=prof,
summary=summary,
keywords=keywords,
correction_available=not state.correction_used,
)원문을 지우지 않고 mark_closed만 거는 것이 이 함수의 핵심이다. 사용자가 확인 화면에서 정정을 요청하면 같은 세션을 재개해야 한다.
async def confirm_profile(req: ConfirmRequest) -> Job:
job = job_queue.enqueue(build_lists, req.profile, req.feedback_summary)
if req.session_id: # 잡과 병렬. 결과를 기다리지 않는다
asyncio.create_task(purge_session(req.session_id))
return job
async def build_lists(prof, feedback_summary, job) -> dict[str, RecoList]:
queries = await rewriter.derive(prof) # ①
job.progress(0.2)
vectors = await embedder.embed_batch(queries) # ② 1회 호출
candidates = catalog.search(vectors, prof.hard_filter(), k=500) # ③ 단일 쿼리
job.progress(0.5)
out = {}
for mode in ("self", "gift"):
picked = ranking.score_and_diversify(candidates, prof, mode) # ④ 순수
if not picked:
out[mode] = RecoList.empty(reason="NO_RELEVANT_CANDIDATE")
continue
reasons = await reasoner.per_query(picked, queries, mode) # ⑤ 쿼리 단위
out[mode] = RecoList(items=picked, reasons=reasons)
job.progress(0.9)
return out # ⑥ 조립asyncio.gather가 없어진 것이 이전 설계와 달라진 부분이다. 요약문 생성이 앞 구간으로 옮겨가면서 합류를 기다릴 갈래가 남지 않았다. create_task는 남아 있지만 잡의 결과에 들어가지 않는 원문 삭제에만 쓰인다.
두 모드가 같은 candidates를 공유한다. 모드별로 잡을 나누면 ①~③이 두 번 돈다.
build_lists에 재검색 순환이 없다는 것이 앞의 판단을 코드로 보여주는 지점이다. 후보가 비면 완화 후 재시도하지 않고 빈 리스트에 이유를 붙여 그대로 넘긴다.
1단계는 부분 실패를 전체 실패로 만들지 않는다는 원칙을 정했고, 3단계는 jobId가 멱등 키라는 것까지 정했다. 어느 단계 실패가 어디까지 번지는지는 정해진 바가 없어 여기서 정한다.
| 실패 지점 | 그 단계 처리 | 구간 전체 |
|---|---|---|
| 대화 ③ 응답 생성 | 재시도 1회 후 그 턴 실패 | 해당 없음. 세션은 유지 |
| 대화 ⑤ 델타 추출 | 재시도 2회 후 빈 델타 | 해당 없음. 대화 계속 |
| 확정 ② 요약문 생성 | 타임아웃 5초, 재시도 없음 |
200. summary: null로 반환 |
| 잡 ① 검색 문장 재작성 | 재시도 1회 | failed |
| 잡 ② 임베딩 | 재시도 1회 |
failed. 큐가 잡을 다시 집는다 |
| 잡 ③ 벡터 검색 | 없음 |
failed (CATALOG_UNAVAILABLE) |
| 잡 ⑤ 이유 생성 | 재시도 없음 |
succeeded. 기본 문구로 대체 |
| 원문 삭제 | 재시도 없음 | 잡에 영향 없음. 로그로 남기고 TTL이 최종 삭제를 보장한다 |
프로필 확정 구간은 실패해도 잡이 아니므로 큐 재시도가 없다. ①③이 순수 계산이라 실패 지점이 ② 하나뿐이고, 그 하나가 부분 실패로 흡수된다.
리스트 생성 잡의 재실행 단위는 잡 하나다. 단계별 체크포인트를 두지 않는다. 근거는 셋이다.
- ④는 순수 계산이라 다시 돌아도 같은 결과가 나오고 비용이 거의 없다.
- ②는 임베딩 캐시가 있어 재실행이 대부분 캐시 히트다. 외부 API 호출이지만 재실행 비용이 호출 수에 비례하지 않는다.
- ③은 카탈로그를 읽기만 하고 쓰지 않는다. 파이프라인 전체에 외부 부작용이 없어서 중간부터 이어 붙일 이유가 없다.
잡이 여섯 단계로 짧아져 통째 재실행 비용이 체크포인트 관리 비용보다 싸다. 잡이 길어지면 이 판단을 다시 본다.
잡이 실패하면 백엔드가 같은 프로필로 확정 요청을 다시 보낸다. 세션 원문은 이미 지워졌지만 이 경로에는 필요하지 않다. 사용자에게 대화를 다시 시키지 않는다는 점이 구간을 나눈 부수 효과다.
| 측면 | 효과 |
|---|---|
| 지연 | 사용자가 화면 앞에서 기다리는 단계가 대화 턴 7개 중 1개, 확정 3개 중 전부다. 리스트 생성 잡은 사용자가 메인 화면으로 이동한 뒤에 돈다 |
| 실패 격리 | 응답과 추출을 나눈 덕에 추출 실패가 대화를 끊지 않는다. 확정과 리스트 생성을 나눈 덕에 리스트 생성이 실패해도 프로필은 이미 저장되어 있다 |
| 교체 | 모델 호출이 gateway 포트 뒤에 있어 서빙 스택 교체가 어댑터 한 파일이다 |
| 테스트 | 스코어 계산, 목표 판정, 키워드 선별이 순수 함수라 GPU 없이 검증된다 |
| 관측 | 단계별 소요 시간이 기록되므로 2단계 실험(E1·E6·E7)이 이 파이프라인 위에서 그대로 돈다 |
| 의존성 | 오케스트레이션 프레임워크를 넣지 않아 도메인 모듈이 순수 파이썬으로 남는다 |
| 넘기는 것 | 왜 여기서 정하지 않는가 | 어디서 |
|---|---|---|
| 카탈로그를 무엇으로 어디까지 채울 것인가 | 수집 대상이 정해져야 배치 파이프라인의 단계가 정해진다 | 5단계 |
| 검색 문장 재작성의 쿼리 생성 전략 | 지식 소스와 임베딩 모델에 묶여 있다 | 5단계 |
| 온디맨드 수집 폴백 도입 여부 | 도입하면 순환이 생겨 오케스트레이션 판단이 바뀐다 | 5단계 |
| 모델이 도구를 부르는 경로 | 도구 통합 설계에 속한다. 도입하면 흐름 제어 원칙이 바뀐다 | 6단계 |
| 모델별 서빙 인스턴스 배치 | 파이프라인이 아니라 인프라 문제다 | 7단계 |
| 파이프라인 계측과 대시보드 | 프레임워크 트레이싱이 아니라 인프라 계측으로 푼다. 수집 경로와 대시보드 구성이 인프라 설계에 속한다 | 7단계 |
| 항목 | 확정 시점 |
|---|---|
| 리스트 재생성을 잡으로 통일할지, 동기를 유지할지 | 1단계 계약 문서와 함께 |
정정 대화 턴 예산과 CORRECT 지시문 |
프롬프트 작성 단계 |
| 확인 없이 이탈했을 때의 처리 | 기획 협의. 프로필만 있고 리스트가 없는 상태가 된다 |
| 확인 대기 중 세션 TTL 연장 여부 | 백엔드 협의 |
| 임베딩 API 장애 시 처리 | 잡 실패로 둘지, 캐시된 벡터만으로 축소 검색할지. 카탈로그 적재 후 캐시 히트율을 보고 |
| AI 서버 아웃바운드 경로 | 프라이빗 서브넷에서 외부 API를 부르려면 NAT이 필요하다. 클라우드 담당과 합의 |
| 이유 생성 단위 (쿼리 단위 vs 모드당 배치) | E6 결과 |
두 구간의 estimatedMs와 progress 배분 |
단계별 소요 시간 측정 후 |
| 잡 재시도 횟수 상한 | 실패율 관측 후 |