# 끼리(KKIRI) — AI 설계서 (기술 아키텍처)

작성: 페이블 · 2026-09-06 · P0 산출물 ②
목적: 「AI가 아니라 규칙 아닌가」에 답한다. LLM이 하는 일, 규칙이 하는 일, 그 경계를 왜 그렇게 그었는지, 비용·지연·실패 시 동작까지 한 문서에 둔다.
기준 코드: `mockup/engine.js` (207줄) · `mockup/app.js` (419줄), 2026-09-06 실행 확인 (`node check.mjs` 3~8인 assertion 통과, `node --test` 8건 통과).

---

## 0. 한 장 요약

| 질문 | 답 |
|---|---|
| LLM이 하는 일 | ① 자유서술 → 조건 구조화, ② 집계 사실 → 설명 문장 생성. (NEXT) ③ 후보안 생성, ④ 대리인 라운드 |
| 규칙이 하는 일 | 거부권 필터, 만족도 계산, 최저 만족도 우선 정렬, 공개/비공개 경계, 개인 리포트 |
| 왜 결정은 규칙인가 | 같은 입력이면 같은 결과(재현성), 모든 숫자를 화면에서 검증 가능(설명 가능성), LLM 문장이 개인 답변을 새게 할 통로가 없음(비공개) |
| 「대리인」의 실체 | 사람마다 하나씩 만들어지는 **구조화된 조건 객체** `{선호, 상한, 금지}`와, 그 객체를 대변해 후보를 평가하는 함수. 자유 대화하는 챗봇이 아니다 |
| 현재 목업 | LLM 호출 0회. 구조화는 키워드 규칙 7종, 설명은 템플릿. 30초 폴백 UI만 있음 |
| MVP에서 붙일 것 | LLM 호출 2종(①②), 서버·저장소·개인 토큰. 결정 규칙은 그대로 |
| 방 하나당 LLM 호출 | 최대 `참가자 수 + 1`회 (자유서술 쓴 사람 수 + 설명 1회). 8명 방 ≤ 9회 |

---

## 1. 설계 원칙

1. **LLM은 이해하고 설명한다. 결정하지 않는다.** 순위·탈락·우승은 `engine.js`의 결정적 규칙이 낸다. 이유: 발표 Q&A에서 「왜 양평이냐」에 숫자로 답할 수 있어야 하고, 같은 방을 다시 돌려도 같은 답이 나와야 한다.
2. **원문은 LLM 구조화 호출 한 번만 본다.** 구조화 결과(닫힌 어휘의 태그)만 저장·계산·표시한다. 설명 생성 LLM은 원문을 받지 않는다.
3. **LLM 출력은 닫힌 스키마로만 받는다.** 자유 텍스트가 공개 화면에 그대로 가는 경로는 없다. 설명 문장도 「입력 사실에 있는 숫자·명사만 쓴다」 검사를 통과해야 표시된다.
4. **LLM이 죽어도 방은 끝난다.** 구조화 실패 → 사용자가 목록에서 직접 고름. 설명 실패 → 템플릿 문장. (둘 다 목업에 이미 구현)

---

## 2. 현재 목업 아키텍처 (AS-IS)

```
[브라우저 1개]  index.html ── app.js (화면·시계·상태) ── engine.js (규칙)
                 └── localStorage (방 상태 전체)
LLM 호출: 없음
  · 자유서술 구조화  = AVOID_RULES 정규식 7종 (운전·술·숙박·격한 야외·장거리·날음식·이른 출발)
  · 설명 생성        = reasons()/concessions()/kept() 템플릿
  · LLM 타임아웃     = 패널 토글로 연출 (LLM_TIMEOUT_MS = 30000)
다기기 동기화·인증: 없음 (참가자 전환은 시뮬레이션 패널)
```

한계: 키워드 7종 밖의 문장(「사람 많은 데 싫어요」)은 구조화 실패로 떨어진다. 설명은 항상 같은 문형이다. 이 두 자리가 정확히 LLM이 들어갈 자리다.

---

## 3. 목표 아키텍처 (MVP)

```
 방장·참가자 브라우저 (기존 app.js 화면 재사용)
      │ HTTPS · 개인 토큰(/p/{token}) 또는 공용 링크+6자리 코드
      ▼
 API (Cloudflare Workers)
   ├─ /rooms            방 생성·상태·마감·리마인드 스케줄
   ├─ /answers          답변 저장 (구조화 태그만, 원문은 별도 테이블·만료 시각 포함)
   ├─ /structure  ──►  LLM ①  구조화 (원문 → 태그 JSON)      p95 3s · 타임아웃 8s
   ├─ /negotiate        engine.js 그대로 서버 실행 (결정적)
   ├─ /explain    ──►  LLM ②  설명 생성 (집계 사실 → 문장)   p95 10s · 타임아웃 30s → 템플릿
   └─ /ratify           재확인·재조율(1회)·확정
 저장소: D1(방·답변 태그·결과) + KV(토큰·코드·TTL)  ※ 원문은 결정 완료 후 삭제 (보관 기간 정책 미정)
```

컴포넌트 책임:

| 컴포넌트 | 책임 | 목업 대응 |
|---|---|---|
| 화면 | S0~S9, 집계 진행 4단계, 개인 리포트 | `app.js` 그대로 |
| 규칙 엔진 | `vetoes → satisfaction → compareRows` | `engine.js` 그대로, 서버에서 실행 |
| LLM ① 구조화 | 자유서술 1문항 → `{tags[], unknown[]}` | `structureAvoid()` 대체 |
| LLM ② 설명 | 결과 집계 → 공개 이유 3문장 + 탈락 사유 1문장/후보 | `reasons()` 보강 (템플릿은 폴백으로 유지) |
| 저장소 | 방 상태, 태그, 결과, 토큰 | `localStorage` 대체 |

---

## 4. LLM 호출 ① — 자유서술 구조화

입력: 참가자 1명의 「이것만은 피하고 싶어요」 한 문장 (질문 6). 다른 참가자 답변은 절대 함께 넣지 않는다.
출력: 닫힌 어휘 태그. 어휘 밖이면 `unknown`으로 돌려주고 사용자에게 목록 선택을 받는다.

```json
{ "tags": [ { "tag": "drinking", "topic": "식사 분위기", "confidence": 0.92, "span": "술자리 중심은" } ],
  "unknown": [ "사람 많은 데" ] }
```

허용 태그(현재 7종, `AVOID_RULES`와 동일): `driving` 운전 · `drinking` 술자리 중심 · `overnight` 숙박 · `outdoor-heavy` 격한 야외 · `long-travel` 장거리 이동 · `raw` 날음식 · `early` 이른 출발. 태그를 늘리려면 후보 데이터의 `tags`에도 같은 값이 있어야 한다(둘은 같은 어휘).

프롬프트 골격(시스템):

```
너는 모임 조건 분류기다. 사용자가 피하고 싶은 것을 아래 태그 중에서만 고른다.
태그: driving, drinking, overnight, outdoor-heavy, long-travel, raw, early
규칙: (1) 목록에 없는 조건은 unknown 배열에 원문 조각으로 넣는다. (2) 추측하지 않는다. confidence < 0.6이면 태그 대신 unknown. (3) JSON만 출력한다.
```

후처리 검증(코드): 태그가 허용 목록 밖이면 버림. `confidence < 0.6`은 unknown으로 강등. `unknown`이 비어 있지 않으면 화면에서 「이 조건은 이해하지 못했어요 — 목록에서 골라주세요」(현재 목업의 구조화 실패 UI 재사용). 사용자는 구조화 결과를 **제출 전에 반드시 확인**한다(현재 목업 Q6 흐름과 동일). 즉 LLM 오분류는 사용자 확인 단계에서 걸러지고, 걸러지지 못한 오분류는 개인 리포트의 「지켜진 조건」에 드러난다.

품질 목표(발표 전 측정): 문장 70개(태그당 10개) 테스트 세트에서 태그 정밀도 ≥ 0.95, 재현율 ≥ 0.85. 재현율보다 정밀도를 높게 잡는 이유: 없는 거부권을 만들어 후보를 잘못 탈락시키는 쪽이 더 나쁘다.

---

## 5. LLM 호출 ② — 설명 생성

입력: **집계 사실만.** 우승 후보 스펙, `reasons()`가 낸 사실 목록(「팀워크 1순위 2명」), `concessions()`의 2명 이상 집계, `kept()`의 조건 건수, 탈락 후보별 `topics`. 개인 답변, 개인 만족도, 단독 조건은 넣지 않는다 — 넣을 수 없게 입력 스키마에서 필드 자체를 없앤다.
출력: 공개 이유 3문장 이내 + 탈락 후보마다 사유 1문장 + 「근거 신뢰도」 라벨(계산 / 집계 / 추정).

```json
{ "reasons": ["재충전을 1순위로 낸 분이 2명이라 몰입형 하루 일정이 맞았어요.", "..."],
  "eliminated": { "c3": "예산 상한 1건과 술자리 제외 조건 1건에 걸렸어요." },
  "labels": { "reasons": "집계", "eliminated": "계산" } }
```

후처리 검증(코드): 출력에 나온 모든 숫자와 후보명·조건명이 입력 사실 안에 있는지 대조한다. 하나라도 없으면 그 문장을 버리고 템플릿 문장으로 대체한다. 30초 안에 끝나지 않으면 전체를 템플릿으로 대체하고 결과 배지에 「설명은 간략하게 제공돼요」(목업 구현 그대로).

이 검증이 있어서 「LLM이 없는 이유를 지어냈다」는 지적에 「지어낼 수 없다」고 답할 수 있다.

---

## 6. 결정 규칙 (LLM 아님, `engine.js`)

1. 거부권: 시간 상한, 예산 상한, 금지 태그 중 하나라도 걸리면 탈락. 한 명만 걸려도 탈락.
2. 만족도(0~100): 목적 40 (1순위 일치 1.0 / 2순위 0.5) + 방식 25 + 지역 20 + 시간 15 − 예산 선호 초과 페널티(1.5배 이내 5, 초과 10).
3. 정렬: 최저 개인 만족도 ↓ → 평균 ↓ → 비용 ↑ → 일정 길이 ↑ → 후보 ID.
4. 공개 경계: 2명 이상 공통인 것만 공개 이유·차이에 구체화. 단독은 「그 외 단독 선호 차이 N건」. 개인 만족도와 정확한 상한은 본인 리포트만.

「최저 만족도 우선」을 고른 이유: 다수결·평균은 소수를 버린다. 최저를 올리는 규칙(맥시민)은 「가장 덜 만족한 사람의 만족을 최대화」하므로 끼리의 약속(소수 의견 보호)과 수식이 같다. 쏠 검토에서 `0.5·min+0.5·mean`이 이 약속을 어기는 반례(양평 최저 52.5 < 가평 최저 60인데 양평 우승)가 재현돼 규칙을 바꿨다(`docs/corrections`).

---

## 7. 「대리인 협상」은 어디에 있나 — 정직한 답과 NEXT

지금 설계에 **대리인끼리 메시지를 주고받는 라운드는 없다.** 「대리인」은 개인 조건 객체 + 그 조건으로 후보를 채점하는 함수다. 협상에 해당하는 것은 「모든 대리인의 거부권을 통과한 후보 중 가장 덜 만족한 대리인의 점수를 최대화」하는 정렬이다. 제출 원문의 「대리인 간 협상」 표현보다 좁다. 발표에서는 이 좁은 정의를 먼저 말한다.

NEXT ④ 대리인 라운드(LLM, 범위 한정): 후보가 0개일 때만 작동. 각 대리인 LLM이 자기 조건 객체를 받아 「상한을 어디까지 완화할 수 있는가」를 **JSON으로만** 제안(예: `{"budgetCap": "50→60", "reason_tag": "one-time"}`). 규칙 엔진이 완화안을 적용해 재계산. 대리인 발언 텍스트는 만들지 않고 공개하지 않는다(쏠 검토의 재식별 문제 회피). 본인에게는 「내 대리인이 예산 상한 완화를 제안했어요 — 동의?」로 묻고, 동의한 완화만 반영. 이 설계면 「AI 대리인이 양보 지점을 협상」이라는 말을 과장 없이 쓸 수 있다.

NEXT ③ 후보 생성(LLM + 장소 데이터): 현재 후보 5개는 고정. 제품에서는 주제·지역·인원으로 후보 5~8개를 생성하고, 각 후보에 `tags`·`cost`·`time`을 구조화해 붙인다. 후보 태그의 정확도가 거부권 정확도를 좌우하므로 사람 검수 단계를 둔다.

---

## 8. 데이터 흐름과 비공개 경계

| 데이터 | 기기 | 서버 저장 | LLM ① | LLM ② | 공개 화면 | 본인 리포트 | 방장 |
|---|---|---|---|---|---|---|---|
| 자유서술 원문 | O | 임시(만료 시각 포함) | O (본인 것만) | X | X | X | X |
| 구조화 태그·상한·선호 | O | O | X | X | 2명 이상 집계만 | O | X |
| 개인 만족도 | 계산 | O | X | X | X | O | X |
| 응답 여부 | O | O | X | X | 인원수 | — | 이름별 (개인 링크) / 닉네임 (공용) |
| 집계 사실 | 계산 | O | X | O | O | O | O |

원문 보관 기간·삭제 시점은 정책 미정(요구사항·정책 정의서 §6). 이 표에서 「X」인 칸은 코드 경로가 없어야 하며, 테스트로 고정한다(설명 입력 스키마에 원문 필드 없음).

---

## 9. 실패 모드와 폴백

| 실패 | 감지 | 동작 | 사용자에게 보이는 것 | 목업 |
|---|---|---|---|---|
| 구조화 어휘 밖 | `unknown` 비어 있지 않음 | 목록 선택 요청 | 「이해하지 못했어요 — 골라주세요」 | 구현 (규칙 실패와 동일 UI) |
| 구조화 타임아웃 8s | 서버 | 목록 선택 요청 | 동일 | 미구현 |
| 구조화 오분류 | 사용자 확인 단계 | 사용자가 수정 | 제출 전 확인 카드 | 구현 |
| 설명 타임아웃 30s | 서버 | 템플릿 문장 | 배지 「설명은 간략하게」 | 구현 (연출) |
| 설명 검증 실패(없는 숫자) | 후처리 | 해당 문장 템플릿 대체 | 표시 안 함 | 미구현 |
| 후보 0개 | 엔진 | 탈락 사유 집계 + 재조율 안내 | 「후보가 없어요」 화면 | 구현 |
| 응답 3명 미만 | 마감 시 | 연장(24h, 최대 2회) 또는 방 닫기 | 방장 선택 | 구현 |

---

## 10. 비용·지연 (추정, 측정 후 갱신)

| 호출 | 회수/방 | 입력 토큰 | 출력 토큰 | 지연 목표 |
|---|---|---|---|---|
| ① 구조화 | 자유서술 쓴 사람 수 (≤ 8) | 약 400 (시스템 300 + 문장 ≤ 100) | 약 80 | p95 3s, 타임아웃 8s |
| ② 설명 | 1 | 약 700 (집계 사실) | 약 250 | p95 10s, 타임아웃 30s |
| 합계 (8명 방) | ≤ 9 | ≤ 3,900 | ≤ 890 | — |

단가는 사용하는 모델의 공식 가격표를 곱해 기입한다(이 문서에 숫자를 적지 않는 이유: 확인하지 않은 단가를 쓰지 않기 위해). 모델 추천: ①은 지연이 중요하고 출력이 작으므로 소형 모델(예: Claude Haiku 4.5), ②는 문장 품질이 중요하므로 중형 모델(예: Claude Sonnet 5). 토큰 추정은 §4·§5 프롬프트 골격 기준이며, 실제 프롬프트 확정 후 `count_tokens`로 다시 잰다.

방 하나가 LLM 호출 9회 이하이므로, 비용은 방 수에 선형이고 참가자 대화 길이에 무관하다 — 챗봇형 협상과 가장 다른 지점이다.

---

## 11. 검증 계획 (발표 전)

| 항목 | 방법 | 통과 기준 |
|---|---|---|
| 구조화 정확도 | 문장 70개 라벨 세트, 모델 2종 비교 | 정밀도 ≥ 0.95 · 재현율 ≥ 0.85 |
| 설명 충실도 | 결과 30건 × 후처리 검증기 | 없는 숫자·명사 0건 통과 (검증기가 잡은 건수 보고) |
| 결정 재현성 | 같은 입력 100회 | 결과 동일 100/100 (규칙이므로 당연히 통과, 문서화 목적) |
| 비공개 경계 | 설명 입력 스키마 테스트 | 원문 필드 포함 시 테스트 실패 |
| 지연 | 8명 방 20회 | 구조화 p95 ≤ 3s, 설명 p95 ≤ 10s |
| 폴백 | LLM 차단 상태로 방 1개 완주 | 결과·리포트·재확인 모두 도달 |

---

## 12. 발표 Q&A 답변 스크립트

**Q. 이거 그냥 규칙 기반 아닌가요?**
A. 결정은 규칙입니다. 일부러 그렇게 했습니다. 같은 답을 다시 넣으면 같은 결과가 나와야 하고, 왜 양평인지 숫자로 보여줘야 하니까요. AI는 두 군데에서 씁니다. 사람이 자기 말로 쓴 조건을 이해하는 것과, 결과를 사람 말로 설명하는 것. 이 둘은 규칙으로 못 합니다.

**Q. 대리인이 협상한다면서요?**
A. 지금 버전의 대리인은 「내 조건을 들고 후보를 채점하는 나의 대변자」입니다. 서로 말을 주고받지는 않습니다. 후보가 하나도 안 남을 때 각 대리인이 「내 상한을 어디까지 풀 수 있는지」를 제안하고 본인 동의를 받는 라운드가 다음 단계입니다. 그때도 발언은 공개하지 않습니다 — 소규모 방에서는 발언이 곧 신원이라서요.

**Q. LLM이 없는 이유를 지어내면요?**
A. 설명 생성기는 개인 답변을 못 받고 집계 사실만 받습니다. 출력의 모든 숫자와 이름을 입력과 대조해서, 입력에 없는 것이 하나라도 있으면 그 문장은 버리고 템플릿으로 바꿉니다.
