LLM에게 권한을 덜 주기: 제한형 큐레이션

#vercel-ai-sdk#llm-safety#structured-output

Chapter 2가 끝나는 시점에, 검색은 모든 하드 제약을 만족하는 강좌를 최대 30개 뽑아 놓았다. 활성, 미만료, 카테고리 일치, 예산 이내, 우회 타원 안 — 결정론적 스코어러로 정렬까지 되어 있다.

그러면 정직한 질문이 남는다. 언어 모델에게 남은 유용한 일은 무엇인가?

카탈로그 검색은 아니다. 검색이 이미 모델보다 더 잘, 더 싸게 했다. 자격도 아니다. 그건 이미 결정됐고 그대로 있어야 한다. 남는 건 진짜로 언어 문제인 것들이다.

  • 주관적 선호 정제. “집에 가까운 쪽? 회사에 가까운 쪽?” “정규 과정? 자유 참여?” 어떤 입력 필드도 묻지 않았고 어떤 랭킹 함수도 추론할 수 없는 질문들.
  • 근거 있는 표현. detourM: 1200, scheduleState: "known_compatible" 를 왜 이 강좌가 맞는지에 대한 정직한 한국어 한 문장으로 바꾸는 일.

좁고 잘 정의된 일이다. 엔지니어링의 대부분은 모델이 그보다 넓은 일을 할 수 없게 만드는 데 들어간다.


두 에이전트 필터 루프

런타임 경로는 자유 대화가 아니라 구조화 생성 루프다. 질문 에이전트가 한국어 질문과 선택지를 만들고, 게스트가 답하면 필터 에이전트가 후보별 keep/discard를 결정하고, 생존 후보가 7개 미만이 되거나 질문 3회가 되면 최종 랭커가 근거 있는 추천 카드를 만든다. 셋 다 역할·Zod 스키마·temperature가 다른 generateObject() 호출이고, 어느 한 에이전트도 전부를 결정하지 않는다.

이 루프에서 하중을 받는 결정 세 가지.

서버는 완전히 무상태다. 클라이언트가 매 턴 생존 ID 집합을 되돌려 보내고, 서버는 그것을 실제 후보 ID와 교집합한다. 낡거나 위조된 ID는 신뢰되지 않고 버려진다.

필터는 단조롭게 좁힌다. 생존 집합은 이전 집합에서 걸러지므로 줄어들기만 한다. 모델은 강좌를 다시 넣을 수 없다. 그리고 전부 버리는 과도한 필터는 통째로 무시된다. 지나치게 공격적인 모델이 만든 빈 결과는 모델 실패가 빈 상태로 나타나는 것이고, 사용자는 빈 상태를 “당신에게 맞는 게 없다”로 읽는다. 없다고 거짓말하느니 조금 덜 정제된 집합을 보여 주는 편이 낫다.

종료는 턴이 아니라 개수 기반이다. 질문은 집합을 좁히려고 존재하는 것이고, 이미 작아졌다면 더 묻는 건 마찰일 뿐이다.

질문 에이전트는 질문 카탈로그 없이 문장·선택지·자유 입력 허용 여부까지 직접 쓴다. Zod는 형태를 보장하지만 어투는 보장하지 못하므로, 서버가 별도로 재검증하고 실패하면 null을 반환한다. 모델이 좋은 질문을 못 만들면 경험이 한 턴 나빠질 뿐, 흐름이 깨지지는 않는다.


네 겹의 봉쇄

흥미로운 건 루프가 아니라, 모델이 건드릴 수 있는 범위를 제한하는 네 개의 독립 메커니즘이다. 각각 단독으로도 충분하고, 그중 어느 것도 시스템 프롬프트가 아니다.

1겹 — 스냅샷으로 닫힌 도구

후보 집합은 도구 인자가 아니라 자바스크립트 클로저 안에 있다. listCandidates는 빈 객체를 받는다. 필터 파라미터도, limit도, 쿼리도 없다 — 넓힐 것이 없다. getCandidateDetail은 스냅샷에서 유도된 z.enum만 받고, 환각된 ID는 실행 전에 입력 검증에서 실패한다.

클로저 안을 점검해 보면 없는 것들이 눈에 띈다. DB 핸들 없음, SQL 문자열 없음, URL fetcher 없음, 세션 ID 없음. 서버가 이미 가져온 평범한 데이터뿐이다. 범위가 넓어질 수 없는 이유는 넓어질 표면 자체가 없기 때문이다.

정직하게 밝힐 점: 현재 런타임 라우트는 구조화 생성 프로토타입을 직접 호출하고, 이 닫힌 도구들은 유닛 테스트와 평가 하네스에서 사용된다. 매 요청에 실행되는 것이 아니라 안전 아키텍처로 소개하는 것이며, 이 구분은 흐리지 않고 유지하는 편이 낫다.

2겹 — 유도된 출력 enum

출력 스키마는 courseId를 현재 스냅샷의 ID 튜플로 닫는다. 원칙은 이것이다. ID enum은 선언되는 것이 아니라 유도된다 — 스냅샷 밖의 ID는 거부되는 게 아니라 표현 불가능하다.

빈 스냅샷 가드가 튜플 단언 앞에 오는 것도 의도적이다. 후보 0개는 크래시가 아니라 타입이 있는 empty 상태다. 빈 배열로는 z.enum을 만들 수 없고, 그 사실을 프로덕션에서 배우는 건 좋은 방식이 아니다.

3겹 — 근거 검증

스키마 준수는 진실성이 아니다. 모델은 형태가 완벽하면서 레코드에 없는 근거를 인용하는 pick을 낼 수 있다. 그래서 각 pick을 그 후보가 실제로 뒷받침할 수 있는 것과 대조한다. 그리고 ‘모름’은 모델의 산문이 아니라 사실로부터 계산된다. 자격·가격·지원금은 검색 경계가 그 사실들을 나르지 않으므로 항상 모름이다. 없는 사실은 모름이지 가정이 아니다. 이걸 인용하면 그 턴 전체가 폴백으로 떨어진다.

어휘 검사도 있다. 금지어 목록(“무료”, “국비지원”, “가장 잘 맞는”, 원 단위 금액, URL…)은 이 도메인에 특화되어 있고 하나하나가 실제 리스크다. “무료”와 “국비지원”은 자격 주장이다 — 사용자가 자격 없는 지원 자리를 기대하고 찾아간다면 제품이 구체적인 피해를 일으킨 것이다. 원 단위 금액은 구조적으로 지어낸 것이다 — 이 경계에서 가격은 모름이기 때문이다. “가장 잘 맞는”은 모델이 여집합을 볼 수 없는 집합에 대한 최상급이다.

4겹 — 프롬프트 채널 분리

강좌 설명은 공공 소스에서 오고, 강좌 설명을 게시할 수 있는 사람은 누구나 인젝션을 시도할 수 있다. 지시문은 신뢰 불가 텍스트를 절대 보간하지 않는 바이트 단위 고정 서버 리터럴이고, 신뢰할 수 없는 텍스트는 제어·서식 문자를 제거하고 이스케이프한 뒤 명시적 서문이 붙은 <fact> 블록으로만 모델에 도달한다.

여기서의 이스케이프는 보완 통제이지 보증이 아니다. 위협 모델을 정직하게 따져 보자. 인젝션이 완전히 성공했다고 가정하면 공격자가 얻는 게 뭘까? 이미 SQL 하드 게이트를 통과한 강좌 최대 30개의 순서를 바꾸고 더 나쁜 한국어를 쓰는 것이다. DB를 조회할 수 없고, 자격 없는 강좌를 노출할 수 없고, 키를 빼낼 수 없고, ID를 지어낼 수 없다. 에이전트에게는 쿼리로 돌아가는 경로가 없기 때문이다.


채팅이 아닌 UI

클라이언트는 Vercel AI SDK의 useChat을 쓰지만, 대화록(messages)은 렌더링되지 않는다. 패널은 활성 질문 하나, 접힌 답변 요약, 스트리밍된 카드, 제한된 컨트롤만 보여 준다. 대화록이 아니라 모델이 구동하는 구조화된 폼이다.

이건 의도적인 제품 선택이다. 채팅 대화록은 시스템이 들어줄 수 없는 열린 입력을 유도하고, 모든 어포던스는 어떤 능력을 암시한다. 자유 텍스트 입력창을 그리는 순간 “이 시스템에 무엇이든 말해도 된다”고 약속하는 것이다.

카드는 먼저 pending 껍데기로 스트리밍되고 안정적인 id로 제자리에서 채워지므로 레이아웃 이동이 없다. 그리고 생성된 내용이 나오기 전에 사전 고지가 표시된다.

이 기능은 생성형 AI를 사용해 추천 설명을 만들어요.

평이하고, 홍보성이 없고, 사후가 아니라 사전이다. AI 기본법상 요구사항이기도 하고, 그냥 정직한 일이기도 하다.

관측 쪽에도 같은 원칙이 적용된다. AI SDK는 프롬프트와 출력을 기본으로 기록하는데, 여기서는 둘 다 사용자 답변과 신뢰 불가 공공데이터를 담고 있어서 프로덕션에서는 둘 다 강제로 끈다. 기록되는 것은 지연, 질문 수, 토큰 사용량, 검증 실패, 폴백 사유, 버전 — 제한된 운영 증거뿐이다.


폴백 퍼널은 하나

모든 실패 — 프로바이더 불가, 타임아웃, 스키마 실패, 근거 검증 실패, 레이트 리밋, 알 수 없는 throw — 가 같은 곳으로 모이고, 게스트가 원래 받기로 되어 있던 것을 똑같이 낸다. 결정론적 검색 목록과 고정된 안내 문구 하나.

맞춤 설명을 준비하지 못해 기본 추천으로 보여드려요. 강좌 정보는 그대로 확인할 수 있어요.

실패 사유 코드는 운영자의 집계에만 쓰이고 절대 서버를 떠나지 않는다. 게스트는 어떤 카운터가 올랐는지 알 수 없다.

이 모듈에서 하중을 받는 구분이 둘 있다.

빈 상태는 폴백이 아니다. 후보 0개 스냅샷은 수정·재시작 어포던스를 가진 별개의 empty 상태다. 빈 스냅샷에 오래된 목록을 내보내면 방금 정제된 조건이 배제한 강좌들을 보여 주게 된다 — 적극적으로 오도하는 결과다.

임베딩 불가는 두 번째 퍼널이 아니다. 쿼리 임베딩 누락은 Chapter 2의 중립 의미 degradation이고, 검색은 여전히 전체 목록을 반환한다.


깨뜨려 보지 않은 가드레일은 출시할 수 없다

npm run eval:curation은 오프라인·크리덴셜 프리 적대적 평가 하네스다. 라이브 LLM·임베딩·DB 호출을 한 번도 하지 않지만, 프로덕션 로직을 재구현하지도 않는다. 실제 FSM을 구동하고, 스크립트된 적대적 출력을 MockLanguageModelV4를 통해 실제 AI SDK로 흘려보낸다. 12개 인젝션 fixture는 실제 무해화 경로를 통과한다.

커버리지에는 소스 레코드 내 프롬프트 인젝션, 스냅샷 밖 ID, 뒷받침되지 않는 근거 참조, 빈 후보 집합, 프로바이더 실패가 포함되고, 위반이 하나라도 있으면 비정상 종료한다. 하네스 자체도 오용에 대비해 막혀 있다. --provider는 mock만 받고, 리포트에는 promotable: false가 찍힌다. 라이브 평가로 오인될 수 있는 fixture 평가는 평가가 없는 것보다 나쁘다. 근거 없는 확신을 제조하기 때문이다.


정리

모델의 안전 경계는 시스템 프롬프트에 맡길 것이 아니라 제어 흐름, 닫힌 스키마, 스냅샷 검증, 결정론적 폴백으로 강제해야 한다.

보장강제 수단
존재하지 않는 강좌를 추천할 수 없음스냅샷에서 유도된 ID enum
DB에 접근할 수 없음frozen 인메모리 데이터로 닫힌 도구
뒷받침 없는 사실을 인용할 수 없음후보별 근거 가용성 검사
자격 주장을 할 수 없음금지 주장 어휘
주입된 텍스트로 탈출할 수 없음채널 분리 + 무해화 + 쿼리로 가는 경로 부재
실패해도 제품을 망가뜨릴 수 없음결정론적 목록으로 가는 단일 폴백 퍼널

이 중 어느 것도 모델의 협조에 의존하지 않는다. 그게 핵심이다. 프롬프트는 의도를 표현하고, 타입·enum·클로저·제어 흐름이 그것을 강제한다.

세 편에 걸쳐 같은 결정이 반복해서 돌아왔다. 하위 문제마다, 실제로 맞는 도구는 무엇인가. 자격·기하·랭킹·폴백은 결정론적 소프트웨어, 의미와 분류는 임베딩, 우주를 제한하는 건 검색, 주관적 정제와 근거 있는 표현은 생성 — 그리고 그 외에는 아무것도. 이 배분을 제대로 하는 것이 개별 모델 선택보다 훨씬 중요했다. 더 강한 모델이라도 생성에게 자격을 결정하게 둔 시스템을 고쳐 주지 못하고, 더 약한 모델이라도 이 경계 안에서는 충분히 잘 작동한다.

남은 질문은 하나다. 이 전부 — 수집, 임베딩, 분류, 적재 — 를 매일 밤 누가 돌리는가? Chapter 4가 그 답이다.