AEGLOSLAB ChatGPT 같은 AI를 만들고 서비스하기

제7부 제품·서비스(Product/Service)

7.1 챗 제품 설계 — 대화 UX·스트리밍·세션·메모리

챗 제품을 열면 커서가 깜빡이는 입력창 하나와 위로 쌓인 말풍선이 보인다. 그 단순함이 이 장이 깨려는 착각이다. 화면 뒤의 모델은 대화를 하지 않는다 — 모델은 상태가 없는(stateless) 함수에 가깝다. 프롬프트를 받아 토큰을 출력할 뿐, 방금 전 자기가 무슨 말을 했는지, 어제 이 사용자와 무슨 얘기를 나눴는지 스스로는 하나도 기억하지 못한다. "ChatGPT가 나를 기억한다"는 감각, "어제 하던 얘기를 이어 간다"는 연속감, 매 답변에서 유지되는 말투와 인격 — 이 셋 중 어느 것도 모델 안에 들어 있지 않다. 전부 그 상태 없는 함수를 감싼 제품이 매 호출마다 다시 만들어 낸다. 사용자가 만나는 것은 모델이 아니라 이 껍데기다.

그래서 챗 제품 설계의 절반은 "상태 없는 함수 위에 어떻게 그럴듯한 대화·기억·인격을 조립하나"이고, 나머지 절반은 "그것을 지연·비용·프라이버시 예산 안에서 어떻게 하나"이다. 이 장은 그 껍데기를 짓는다 — 토큰을 스트리밍해 체감 지연을 감추고(5.1의 TTFT를 실제 UX로 바꾸는 작업이다), 대화를 저장하고, 컨텍스트 창을 토큰 예산으로 관리하고(긴 히스토리는 5.6이 말하는 반복 프리픽스의 대표 사례다), 세션을 넘어 기억하고, 재생성·편집·분기를 받아 주고, 응답이 좋았는지 나빴는지를 모은다. 앞 부(제6부)가 "모델에게 무엇을 어떻게 물어 좋은 답을 얻나"였다면 — 프롬프트, RAG, 도구·에이전트, 구조화 출력 — 이 장부터는 그 답을 사람이 실제로 쓰는 물건으로 바꾸는 일이다.

이 장에 나오는 컨텍스트 길이·토큰 예산·턴당 토큰 수·TTFT/TPOT 수치는 원리를 손에 쥐여 주기 위한 개략 예시다. 실제 값은 모델·시점·구현마다 다르고 수시로 바뀐다. 특정 벤더의 메모리 기능·컨텍스트 한도·스트리밍 이벤트 형식 같은 구체 스펙은 각 벤더 공식 문서를, Anthropic/Claude는 claude-api를 출처로 확인해야 한다. 이 장의 무게는 특정 숫자가 아니라 그 숫자들이 가리키는 트레이드오프에 있다.

7.1.1 상태 없는 모델 위에 대화를 짓기

챗 제품의 거의 모든 설계 결정이 한 사실에서 파생된다. 모델 호출은 순수 함수에 가깝다 — 입력 프롬프트를 받아 토큰을 출력할 뿐 이전 호출을 기억하지 않는다. 여기서 프리픽스 캐시(5.6)가 예외처럼 보이지만 아니다. 프리픽스 캐시는 같은 앞부분을 다시 계산하지 않으려고 KV를 재사용하는 성능 최적화일 뿐, 의미상의 상태가 아니다 — 캐시 전체가 비어 있어도 같은 입력이면 같은 출력이 나온다. 모델은 여전히 아무것도 "기억"하지 않는다. 그러니 "대화"는 전적으로 제품이 만든다. 매 턴, 제품이 과거를 다시 프롬프트에 담아 보내기 때문에 모델이 마치 앞 얘기를 아는 것처럼 답하는 것이다.

그 조립되는 프롬프트는 대략 네 부품으로 이뤄진다. 앞에서부터 ① 시스템 프롬프트(페르소나·정책·도구 규칙, 6.1) → ② 주입된 메모리·개인화(있다면) → ③ 과거 대화 히스토리(예산 안에서 잘라낸) → ④ 이번 사용자 메시지. 응답이 나오면 그것을 히스토리에 덧붙여 저장하고, 다음 턴에 ③으로 다시 로드한다. 이 순환이 대화의 전부다.

턴 2 요청 프롬프트 (매 턴 재조립) append 다음 턴에 재로드 ① 시스템 프롬프트 (페르소나·정책·도구) ② 주입된 메모리 (사용자 사실·선호) ③ 과거 히스토리 (예산 안으로 잘림) ④ 이번 사용자 메시지 상태 없는 모델 호출 응답 토큰 스트림 세션 저장소

이 순서는 임의가 아니다. 앞쪽일수록 안정적인 것을 둔다. 시스템 프롬프트는 모든 사용자·모든 턴에서 거의 같고, 메모리는 한 사용자 안에서 안정적이며, 히스토리는 턴마다 뒤에만 자라고, 이번 메시지는 매번 새롭다. 안정적인 것을 앞에 몰아 두면 프리픽스 캐시(5.6)가 그 공유 앞부분의 KV를 재사용해 재전송·재계산 비용을 크게 줄인다. 그리고 뒤에 볼 다중 턴 일관성(7.1.11)도 정확히 같은 배치를 요구한다 — 오래 지속돼야 하는 규칙일수록 잘려 나가지 않는 앞자리에 둬야 한다. 캐시 친화 순서와 일관성 순서가 우연히 같은 것이 아니라, 둘 다 "변하지 않는 것은 앞에"라는 같은 원리의 두 측면이다.

이 프레임을 쥐면 이 장의 나머지가 세 질문으로 정리된다. 무엇을 보내나 — 히스토리 전부인가 일부인가 요약인가, 세션을 넘어 무엇을 기억하나. 어떻게 보여 주나 — 완성까지 기다리나 스트리밍하나, 중단·재생성·편집을 어떻게 받나. 무엇을 환류시키나 — 좋았는지 나빴는지를 어떻게 모아 다음 개선으로 환류시키나. 나머지 절이 이 셋을 하나씩 다룬다.

7.1.2 토큰 스트리밍이 체감 지연을 정한다

LLM 응답은 한 번에 완성되지 않는다. 모델은 프롬프트를 순전파하는 prefill을 한 뒤, 토큰을 하나씩 자기회귀로 뽑는 decode를 반복한다(5.1). 그래서 응답을 다 만드는 데 걸리는 총 시간은 두 지연 지표로 분할된다. TTFT(Time To First Token)는 요청부터 첫 토큰까지 — prefill과 큐 대기가 지배한다. TPOT(Time Per Output Token)는 그 뒤 토큰 하나당 간격 — decode 속도, 곧 메모리 대역폭이 지배한다. 출력이 $N_{\text{out}}$ 토큰이면 완성까지의 시간은

$$T_{\text{완성}} \approx \text{TTFT} + N_{\text{out}} \times \text{TPOT}$$

이다. 여기까지는 5.1의 복습이다. 챗 제품에서 결정적인 것은 이 다음이다 — 사용자가 무엇을 기다리느냐.

비스트리밍은 서버가 $N_{\text{out}}$ 토큰을 다 만든 뒤 한 번에 보낸다. 사용자는 그동안 빈 화면을 본다. 체감 지연이 $T_{\text{완성}}$ 전체다. 스트리밍은 토큰이 생성되는 대로 스트리밍한다. 첫 글자가 TTFT 만에 표시되고, 그 뒤로는 사람이 읽는 속도에 맞춰 텍스트가 흐른다. 체감 지연이 사실상 TTFT다.

$$T_{\text{체감}} \approx \begin{cases} \text{TTFT} & \text{(스트리밍 — 첫 글자에 화면이 움직임)}\\[2pt] \text{TTFT} + N_{\text{out}}\cdot\text{TPOT} & \text{(비스트리밍 — 완성까지 대기)} \end{cases}$$

숫자를 넣어 보자. TTFT 0.5초, TPOT 20 ms(즉 초당 50토큰), 응답 500토큰이라 하자. 완성 시간은 $0.5 + 500\times 0.02 = 10.5$초다. 비스트리밍이면 사용자는 10.5초 동안 빈 화면을 보고, 스트리밍이면 0.5초 만에 글자가 뜨기 시작한다 — 같은 응답, 같은 연산, 같은 비용인데 체감 지연이 약 21배 차이 난다. 응답이 길수록 격차가 벌어진다. 2,000토큰 답변이면 완성은 $0.5 + 40 = 40.5$초, 스트리밍 체감은 여전히 0.5초로 80배가 된다. 이것이 스트리밍이 챗 UX의 사실상 표준이 된 이유다.

그런데 왜 스트리밍에서는 첫 글자만 빨리 뜨면 나머지 지연이 거의 사라진 것처럼 느껴질까. 사람의 읽기 속도가 생성 속도보다 느리기 때문이다. 사람은 초당 열몇 토큰보다 빨리 못 읽는다(5.1). 위 예에서 모델은 초당 50토큰을 뽑으니 생성이 읽기보다 서너 배 빠르다. 즉 첫 문장을 읽는 동안 그다음 몇 문장이 이미 버퍼에 쌓인다 — 사용자는 늘 이미 도착한 텍스트를 읽고 있어서 기다림을 느낄 겨를이 없다. 반대로 TPOT가 읽기 속도보다 느려지면(초당 몇 토큰) 스트리밍의 체감 효과가 깨진다 — 글자가 사람이 읽는 것보다 늦게 떠서 "버벅거리는" 체감이 된다. 그래서 챗 서빙의 TPOT 목표는 "가능한 한 빠르게"가 아니라 "사람 읽기 속도를 넉넉히 넘기게"다. 그 이상은 사용자에게 안 보이는 잉여다(5.1.7).

사용자 클라이언트 (브라우저) 앱 서버 모델 서버 질문 입력·전송 POST /chat (stream=true) prefill (프롬프트 순전파) TTFT — 첫 토큰까지 (5.1) 토큰 1 SSE 청크(토큰 1) 즉시 첫 글자 표시
챗 스트리밍 1/2 — 요청부터 첫 토큰(TTFT)까지
반복 decode (토큰마다 TPOT) 사용자 클라이언트 (브라우저) 앱 서버 모델 서버 토큰 k SSE 청크(토큰 k) 이어붙여 표시 생성 종료 종료 이벤트 (done·usage) 커서 제거· 피드백 UI 노출
챗 스트리밍 2/2 — 디코드 루프(TPOT)부터 종료까지

여기서 분명히 해 둘 것이 있다. 스트리밍은 지연을 숨길 뿐 없애지 않는다. 총 생성 시간 $T_{\text{완성}}$, 총 연산, 총 토큰 비용(7.5)은 스트리밍이든 아니든 똑같다 — 모델은 같은 수의 토큰을 같은 속도로 만든다. 스트리밍이 바꾸는 것은 오직 체감과, 아래에서 볼 조기 중단 가능성뿐이다. "스트리밍하면 빨라진다"는 말은 체감에 대해서만 참이다. 실제 처리량·비용을 줄이려면 배칭·양자화·캐시(5.2·5.3·5.6)로 가야 한다. 이 구분을 흐리면 "우리 응답이 느린데 스트리밍을 켰으니 괜찮다"는 자기기만에 빠진다 — 켜야 하지만, 켠다고 느린 모델이 빨라지지는 않는다.

7.1.3 스트리밍을 실어 나르기 — SSE·버퍼링·메타데이터

토큰을 서버에서 클라이언트로 점진적으로 전송하려면 스트림 전송이 필요하다. 흔한 선택지가 셋이다.

전송방향특징챗에 맞나
SSE(Server-Sent Events)서버→클라 단방향HTTP 위 텍스트 스트림(text/event-stream), 자동 재연결, 프록시 친화챗 응답 스트리밍의 사실상 표준
WebSocket양방향풀 듀플렉스, 상태 유지 연결음성·실시간 협업 등 양방향이 필요할 때
청크 HTTP(chunked)서버→클라Transfer-Encoding: chunked로 본문을 조각내 전송SSE의 하부, 저수준 제어가 필요할 때

챗 텍스트 응답은 서버가 클라이언트로 토큰을 밀어 주기만 하면 되므로 대부분 SSE로 충분하고, 실제로 이 분야의 표준이 됐다. HTTP 하나로 서서 프록시·로드밸런서를 그대로 통과하고, 끊기면 브라우저가 알아서 재연결한다. WebSocket은 사용자가 말하는 도중 모델이 반응해야 하는 양방향 상황 — 음성 대화(6.8)나 실시간 인터럽트 — 에서 값을 한다. 그런 필요가 없는데 WebSocket을 쓰면 상태 유지 연결을 관리하는 복잡도만 부담한다. SSE 프레임은 개념상 이렇게 생겼다(실제 필드명·이벤트 형식은 벤더·구현마다 다르니 공식 문서 확인).

event: message
data: {"delta": "안녕"}

event: message
data: {"delta": "하세요"}

event: done
data: {"finish_reason": "stop", "usage": {"output_tokens": 42}}

각 청크가 "새로 추가된 조각(delta)"만 담는다는 것이 첫 설계 포인트다. 매번 지금까지의 누적 텍스트 전체를 보내면 응답이 길어질수록 프레임이 커져 대역폭이 이차로 낭비된다 — 델타만 보내고 클라이언트가 이어붙이는 것이 옳다. 둘째, 스트리밍을 가장 자주 중단시키는 원인이 중간의 버퍼링이다. 리버스 프록시·API 게이트웨이(7.2)·CDN이 응답을 모아 뒀다 한 번에 넘기면, 서버는 토큰을 전송하는데 사용자 화면에는 완성될 때까지 아무것도 표시되지 않는다 — 스트리밍이 비스트리밍으로 조용히 퇴화한 것이다. 이것은 로컬에서는 프록시가 없어 안 보이다가 배포 뒤에야 발생하는 전형적 버그다. 경로상의 모든 홉에서 버퍼링을 끄고(X-Accel-Buffering: no 같은 신호, 프레임워크의 flush) 응답 압축·정적 캐싱 규칙이 이 엔드포인트에 적용되지 않게 해야 한다. 셋째, 유휴 연결이 프록시 타임아웃에 끊기지 않도록 주기적 keep-alive 코멘트(하트비트)를 전송한다 — 특히 prefill이 긴 롱컨텍스트 요청은 첫 토큰이 나오기 전 침묵이 길어 타임아웃에 걸리기 쉽다.

넷째, 스트림은 텍스트만 나르지 않는다. 도구 호출(6.5), 인용 메타데이터(7.1.13), 최종 usage(과금·7.5) 같은 구조화 이벤트도 같은 스트림에 타입을 달아 함께 흘린다. 그래서 클라이언트는 하나의 이벤트 루프에서 delta가 오면 텍스트를 잇고, tool_call이 오면 도구 실행 UI를 그리고, citation이 오면 각주를 붙이고, done이 오면 커서를 지운다. 스트림을 "글자만 흐르는 관"이 아니라 "타입 붙은 이벤트의 순차 채널"로 설계해 두면 나중에 인용·아티팩트·도구를 추가할 때 프로토콜을 다시 구현하지 않아도 된다.

7.1.4 취소·중단 — 사용자가 멈출 때 무엇이 멈추나

스트리밍이 주는 두 번째 실질 이득은 조기 중단이다. 사용자가 답이 산으로 가는 것을 보고 정지 버튼을 누르면 생성을 끊을 수 있어야 한다. 이것은 UX일 뿐 아니라 비용 절감이다 — 아무도 안 읽을 300토큰을 계속 decode하는 것은 GPU 시간의 순수한 낭비다(7.5). 그런데 취소가 실제로 비용을 아끼려면 정지가 모델 서버까지 전파돼야 한다. 여기서 순진한 구현이 자주 실패한다.

사용자 정지 클릭 클라이언트: 스트림 연결 abort (AbortController) 앱 서버: 클라 끊김 감지 모델 서버: 해당 시퀀스 생성 취소 (디코드 중단·배치에서 제거) 부분 응답 저장 여부 (정책 결정)

전파 경로는 클라이언트가 연결을 끊고(AbortController), 앱 서버가 그 끊김을 감지하고, 모델 서버(vLLM 등, 5.4)에 해당 요청의 취소를 신호하면, 그 시퀀스가 continuous batch(5.3)에서 제거되는 사슬이다. 이 사슬 중 한 곳이라도 끊기면 취소가 효과를 내지 못한다. 흔한 실패는 앱 서버가 클라이언트 끊김을 감지하지 못하고 계속 모델 응답을 읽어 버리는 경우다 — 화면은 멈췄는데 GPU는 끝까지 실행된다. 사용자는 절약했다고 믿지만 청구서는 그대로다.

부분 응답을 어떻게 처리할지도 정해야 한다. 중단 시점까지 생성된 텍스트를 히스토리에 남길지(맥락·재개용) 버릴지는 정책 결정이다. 남긴다면 다음 턴 프롬프트에 "…(중단됨)" 상태의 반쪽 답이 들어가므로, 모델이 그것을 자기가 완결한 답으로 오해하지 않게 표식을 붙이거나 시스템 프롬프트에서 처리해야 한다. 그리고 과금 면에서 명시할 것 — 중단해도 그 시점까지 생성된 토큰은 이미 계산됐고 과금됐다. "중단=무비용"이 아니라 "중단 이후분만 절약"이다. 사용자가 3초 뒤에 멈췄으면 3초어치 토큰은 냈다.

7.1.5 대화 상태·세션 — 로그와 컨텍스트를 가르기

모델이 상태가 없으니 대화 상태는 제품이 보관한다. 여기서 초보 설계가 저지르는 가장 값비싼 혼동이 하나 있다 — 화면에 보이는 대화와 모델에 보내는 컨텍스트를 같은 것으로 취급하는 것. 이 둘은 분리해야 한다.

한쪽은 원문 히스토리(로그)다. 사용자·어시스턴트 메시지 원문 전체 — 화면에 다시 그리고, 감사·디버깅·피드백 플라이휠(8.1)의 원천이 되는, 대화의 완전한 기록이다. 사용자는 위로 스크롤해 100턴 전 메시지도 그대로 본다. 다른 쪽은 모델에 보낼 컨텍스트다. 토큰 예산(7.1.6) 안으로 압축·선별한 부분집합으로, 매 턴 재구성된다. 화면에는 대화 전체가 있어도 모델은 그중 예산만큼만 본다. "화면에 보이는 것 = 모델이 보는 것"이라는 순진한 가정이 나중에 "모델이 왜 앞 얘기를 잊었지?"라는 버그의 정체다 — 잊은 것이 아니라 애초에 그 부분을 안 보냈을 뿐이다. 이 둘을 처음부터 다른 저장소·다른 수명으로 분리해 두면 이 부류의 혼란 전체가 사라진다.

저장 위치와 수명은 상태의 성격에 따라 갈린다.

상태무엇저장수명
진행 중 세션현재 대화 메시지들인메모리·캐시(예: Redis)세션 활성 동안
대화 로그종료된 대화 전체영속 DB(문서·관계형)사용자 삭제까지(보존 정책·9.3)
파생 상태롤링 요약·추출 메모리DB(세션·사용자 키)세션·영구
첨부·아티팩트업로드 파일·생성물오브젝트 스토리지참조 수명

대화 로그는 이 제품이 쥐는 가장 민감한 사용자 데이터라는 것을 처음부터 인정해야 한다. 사람들은 챗에 건강·재정·관계·직장 문제를 아무렇지 않게 털어놓는다 — 검색창에도 안 치던 것을 챗에는 문장으로 쓴다. 그래서 무엇을·얼마나·어디에 보관하고 언제 지울지는 저장·보존·삭제·접근통제·리전 전부가 프라이버시·법(9.3, GDPR·개인정보보호법)의 정면 대상이다. "일단 다 저장하고 나중에 생각하자"는 이 도메인에서 가장 위험한 기본값이다 — 나중은 규제 위반이 드러난 뒤가 되기 쉽다.

한 턴의 생애주기를 상태 머신으로 보면 UI·에러·중단 처리가 한꺼번에 정리된다.

세션 로드 사용자 전송 세션 종료·영속화 첫 토큰 수신(TTFT) 타임아웃·거부·오류 사용자 정지 스트림 중 오류 정상 완료 (응답 저장) 부분응답 처리 재시도· 에러 표시 Idle Submitting Streaming Cancelled Error
한 턴의 생애주기 — 챗 UI 상태 머신

각 상태가 화면을 규정한다. Submitting은 아직 첫 토큰 전이라 로딩 인디케이터, Streaming은 타이핑 커서와 정지 버튼, Error는 재시도 가능한 에러 카드, Cancelled는 "중단됨" 표식이다. 이 상태들을 하나의 스피너로 뭉뚱그리면 사용자는 "멈춘 건지 생각 중인지"를 구분 못 해 새로고침하거나 이탈한다. 특히 Submitting과 Streaming을 구분하는 것이 중요하다 — 전자는 첫 글자가 아직 안 나온 실제 대기고, 후자는 이미 답이 흐르는 중이라 사용자 심리가 완전히 다르다. 상태별 UX의 디테일은 7.1.13에서 마저 다룬다.

7.1.6 컨텍스트 창을 토큰 예산으로 관리하기

대화가 길어지면 히스토리가 컨텍스트 창을 넘본다. 두 종류의 압박이 있다. 하나는 하드 한도 — 모델의 최대 컨텍스트 창을 넘으면 아예 못 보낸다. 오래된 것을 버리거나 압축해야 한다. 다른 하나는 더 미묘한 소프트 예산이다. 창에 물리적 여유가 있어도 히스토리를 무한정 실으면 세 가지가 동시에 나빠진다 — 비용(입력 토큰 과금, 7.5), 지연(prefill이 $O(n^2)$ 로 커져 TTFT를 증가시킴, 5.6), 품질(관련 신호가 무관한 잡음에 묻힘, 7.1.15). 그래서 창 한도와 별개로 작동 예산 $B$ 를 스스로 정한다 — "이 제품은 프롬프트를 최대 $B$ 토큰까지만 조립한다."

예산 $B$ 는 부품으로 분해된다.

$$B = \underbrace{B_{\text{sys}}}_{\text{시스템·도구}} + \underbrace{B_{\text{mem}}}_{\text{메모리}} + \underbrace{B_{\text{hist}}}_{\text{대화 히스토리}} + \underbrace{B_{\text{out}}}_{\text{출력 예약}}$$

여기서 가장 자주 잊는 항이 출력 예약 $B_{\text{out}}$ 이다. 컨텍스트 창은 입력과 출력이 함께 쓰는 공간이다 — 입력으로 창을 꽉 채우면 모델이 답을 쓸 자리가 없어 응답이 중간에 잘린다. 그래서 "최대 출력 토큰"만큼을 미리 떼어 두고 나머지를 입력에 배분한다. 히스토리에 실제로 쓸 수 있는 예산은

$$B_{\text{hist}} = B - B_{\text{sys}} - B_{\text{mem}} - B_{\text{out}}$$

이다. 이 뺄셈을 안 하고 "창이 8K니까 8K까지 히스토리를 채우자"고 하면, 긴 답을 요구하는 순간 응답이 문장 중간에 툭 끊긴다. 사용자에게는 "답을 하다 말았다"로 보이고, 원인은 예산 계산을 틀린 것이다.

시스템·도구 2,000 메모리 500 대화 히스토리 (관리 대상) 4,000 출력 예약 1,500 고정 프리픽스 매 턴 변동 답할 자리
토큰 예산 분할 — 총 8,000 토큰을 부품에 배분(개략값). 고정 프리픽스(캐시 친화, 5.6).

예산을 부품으로 분해하는 순간 배치의 논리가 딸려 온다. $B_{\text{sys}}$ 와 $B_{\text{mem}}$ 은 앞쪽 고정 프리픽스라 캐시가 잘 듣고, $B_{\text{hist}}$ 는 그 뒤에서 매 턴 변동하며, $B_{\text{out}}$ 은 뒤에 비워 둔 답할 자리다. 관리의 대상은 오직 가운데 $B_{\text{hist}}$ 하나 — 나머지는 대체로 고정이거나 정책으로 정한 상수다. 그러니 컨텍스트 관리라는 문제는 결국 "히스토리를 $B_{\text{hist}}$ 안으로 어떻게 욱여넣느냐"로 좁혀진다.

7.1.7 잘라내기·요약·회수 — 히스토리를 줄이는 세 길

히스토리가 $B_{\text{hist}}$ 를 넘으면 줄여야 한다. 근본적으로 방법은 셋뿐이고, 각각 무언가를 다룬다.

전략방법장점단점
잘라내기·슬라이딩 윈도최근 N턴만 남기고 오래된 것은 버림단순·무비용·캐시 친화(뒤만 붙음)버린 앞 내용을 완전히 망각
요약(summarization)오래된 턴을 LLM으로 압축해 요약으로 대체전체 대화의 요지를 유지추가 LLM 호출 비용, 손실·왜곡 위험
선별 회수(retrieval)과거 턴을 벡터로 색인해 관련 것만 회수(6.2·6.3)아주 긴 대화에서 관련 조각만회수 실패 시 잘못된 맥락, 구현 복잡

실무의 표준은 앞의 둘을 섞은 "요약 + 최근 N턴"이다. 최근 몇 턴은 원문 그대로 둬서 세밀한 맥락과 인용 정확도를 지키고, 그보다 오래된 것은 롤링 요약 하나로 압축한다. 창을 넘어가는 턴이 생길 때마다 그 턴만 요약에 접어 넣는 점진적(rolling) 방식이면 요약 호출 비용이 대화 전체에 얇게 분산된다 — 대화가 끝날 때 34턴을 한 번에 요약하는 것보다 낫다.

LLM으로 압축 오래된 턴 1..k (창 밖으로 밀림) 롤링 요약 (상한 토큰 이하) 다음 프롬프트 최근 N턴 (원문 유지) 이번 사용자 메시지

세 전략 모두에서 무비용 이득은 없다는 것을 정확히 봐야 한다. 요약은 손실 압축이면서 그 자체가 또 하나의 LLM 호출이다 — 비용과 지연을 더하고, 하필 나중에 중요해질 그 한 줄("내 이름은 지훈", "예산은 500만 원")을 저하시키거나 왜곡·환각할 수 있다. 슬라이딩 윈도는 압축조차 안 하고 아예 버린다. 회수는 관련 조각을 되찾아 오지만, 회수가 빗나가면 잘못된 옛 맥락을 끌어와 오히려 답을 망친다. 결국 정보를 버리거나(윈도), 압축해 흐리거나(요약), 다시 찾아오거나(회수) 중 하나를 택하는 트레이드오프다. "긴 대화를 공짜로 이어 가는" 비법은 없다. 그래서 사용자가 명시적으로 말한 절대 잊으면 안 되는 사실은 요약의 손실에 맡기지 말고 따로 붙잡아야 하는데, 그 자리가 다음 절의 구조화 메모리다.

7.1.8 40턴 대화를 직접 계산하기

식만 보면 미끄러진다. 40턴 길이의 대화를 직접 끝까지 계산해, 왜 관리가 필요하고 "요약 + 최근 N턴"이 정확히 무엇을 사고파는지 확인하자. 아래 값은 전부 원리를 보이기 위한 개략값이다.

설정은 이렇다. 고정 시스템·도구 $B_{\text{sys}} = 2{,}000$, 주입 메모리 $B_{\text{mem}} = 500$, 출력 예약 $B_{\text{out}} = 1{,}500$, 총 작동 예산 $B = 8{,}000$ 토큰(모델 최대 창보다 훨씬 작게, 비용·지연 때문에 스스로 정한 값). 대화는 40턴, 한 턴(사용자+어시스턴트)이 평균 $A = 600$ 토큰. 그러면 히스토리에 쓸 수 있는 예산은

$$B_{\text{hist}} = 8{,}000 - 2{,}000 - 500 - 1{,}500 = 4{,}000 \text{ 토큰}$$

이다. 먼저 순진하게 매 턴 전체 히스토리를 다시 보낸다고 하자. 왜 이것이 위험한지부터 봐야 한다. 턴 $k$ 를 생성할 때 입력은 시스템 + 앞선 $k-1$ 턴이다. 대화 전체에서 처리되는 입력 토큰을 누적하면

$$\sum_{k=1}^{40}\big[B_{\text{sys}} + (k-1)A\big] = 40\,B_{\text{sys}} + A\sum_{k=0}^{39}k = 40\times 2{,}000 + 600\times\frac{39\times 40}{2}$$

$$= 80{,}000 + 600\times 780 = 80{,}000 + 468{,}000 = 548{,}000 \text{ 입력 토큰-단위}$$

가 된다. 대화의 최종 크기는 겨우 $2{,}000 + 40\times 600 = 26{,}000$ 토큰인데, 매 턴 다시 보내느라 누적 입력이 약 21배로 불었다. 이것이 "대화가 길수록 비싸다"의 산술적 근원이다 — 히스토리를 다시 보내는 비용이 턴 수에 이차($O(K^2)$)로 큰다. 히스토리를 나르는 항 $A\sum k$ 가 $K^2$ 에 비례하기 때문이다.

프리픽스 캐시(5.6)가 이 대부분을 되찾는다. 턴 $k$ 의 프리픽스는 턴 $k-1$ 에서 이미 처리한 히스토리라(뒤에만 자란다) 캐시 읽기로 값이 저렴해진다. 하지만 완전히 지우지는 못한다 — 출력(decode)은 캐시가 없고, 중간 턴을 편집하면(7.1.12) 그 뒤 캐시가 전부 무효가 되며, TTL·축출로 미스가 난다. 캐시는 강력하지만 이차 증가의 관성을 완전히 없애 주지는 않으므로 예산 관리가 여전히 필요하다.

이제 24,000 토큰(40턴 × 600) 규모의 히스토리를 $B_{\text{hist}} = 4{,}000$ 안에 넣어 본다. 6배 초과다. 두 전략을 비교하자.

전략 A — 슬라이딩 윈도. 최근 턴만 남긴다. $4{,}000 / 600 \approx 6.6$ 이니 최근 6턴(3,600토큰)만 보내고 나머지 34턴은 버린다. 히스토리는 $3{,}600 \le 4{,}000$ 으로 예산에 맞는다. 추가 호출은 0이고 캐시 친화적이다. 대가는 34턴 전의 모든 내용을 완전히 망각한다는 것 — 대화 초반에 말한 요구사항·이름·제약을 모델이 못 본다.

전략 B — 요약 + 최근 N턴. 최근 4턴은 원문, 나머지 36턴은 요약으로 접는다. 최근 4턴 원문이 $4\times 600 = 2{,}400$ 토큰, 남은 여유가 $4{,}000 - 2{,}400 = 1{,}600$ 이니 오래된 36턴을 1,200토큰 이하 롤링 요약으로 압축한다. 히스토리는 $2{,}400 + 1{,}200 = 3{,}600 \le 4{,}000$ 으로 역시 맞는다. 대가는 요약 생성에 드는 추가 LLM 호출(점진적이면 창을 벗어나는 턴당 소량)과 요약의 손실·왜곡 가능성이다. 이득은 36턴의 요지 — 주제·결정·제약 — 를 유지해 초반 맥락을 어느 정도 기억한다는 것이다.

항목다 보내기(순진)슬라이딩 윈도요약 + 최근 N턴
턴당 히스토리 토큰최대 24,000 (창 초과)3,6003,600
예산 4,000 충족초과(불가)충족충족
초반 맥락 보존(보내면) 전부망각요지 유지(손실 압축)
추가 비용이차 증가(캐시로 완화)없음요약 호출

읽는 법은 하나다. 순진한 "다 보내기"는 예산을 깨거나(창 초과) 이차로 비용이 커지고, 슬라이딩 윈도는 저렴하지만 잊고, 요약은 요지를 지키되 손실·비용을 부담한다. 그래서 대부분의 성숙한 챗 제품은 요약 + 최근 N턴을 기본으로 하고, 절대 잊으면 안 되는 사실은 요약에 맡기지 않고 장기 메모리로 승격하며, 이 모든 것 위에 프리픽스 캐시를 적용해 재전송 비용을 낮춘다. 그리고 여기서 흔한 오해 하나가 저절로 붕괴한다 — "컨텍스트 창이 128K·1M으로 크니 그냥 다 넣으면 된다"는 말. 넣는 것 자체가 무비용이 아니고(입력 과금·prefill $O(n^2)$), 아주 긴 컨텍스트에서 모델이 중간에 위치한 정보를 잘 못 쓰는 "lost in the middle" 현상도 보고돼 있다(연구 주장이고 모델·조건에 따라 다르다). 창이 커져도 예산은 여전히 필요하다.

7.1.9 세션을 넘는 기억 — 메모리의 세 층

앞의 히스토리·요약은 전부 한 대화 안의 기억이다. 새 대화를 시작하면 사라진다. "지난주에 내 이름 알려 줬잖아"가 통하려면 세션을 넘는 기억이 필요하다 — 이것이 메모리·개인화다. 스코프와 수명으로 세 층을 나누면 설계가 명료해진다.

추출(write) ②→③ 사실 추려 저장 주입(read) ③→① 프롬프트에 삽입 ① 인컨텍스트 작업 메모리 스코프: 이번 요청 · 수명: 휘발(창 안) 예: 현재 프롬프트 ② 세션 대화 로그 스코프: 이 대화 · 수명: 세션 예: 원문 히스토리·롤링 요약 ③ 장기 사용자 메모리 스코프: 모든 세션 · 수명: 영속 예: 추출된 사실·선호
메모리 계층 — 스코프·수명·무엇을 담나

① 인컨텍스트 작업 메모리는 지금 이 요청의 프롬프트 창 자체다. 모델이 "기억"하는 유일한 것이고, 요청이 끝나면 사라진다. ② 세션 로그·요약은 이 대화 안에서 지속되지만(7.1.5·7.1.7) 새 대화엔 안 넘어간다. ③ 장기 사용자 메모리는 대화에서 추출한 지속적 사실·선호 — "이름은 지훈", "존댓말 선호", "파이썬을 씀" — 로, DB에 사용자 키로 저장돼 모든 세션에 재주입된다.

작동 사이클은 추출(write) → 저장 → 주입(read)이다. 대화 중 기억할 만한 사실을 뽑아(②→③) 저장하고, 새 대화가 시작되면 관련 메모리를 프롬프트에 넣는다(③→①). 추출은 보통 별도 LLM 호출("이 대화에서 장기 기억할 사용자 사실을 뽑아라")이거나 명시적 "이거 기억해" 명령이다. 여기서 주입은 반드시 관련성으로 선별해야 한다 — 사용자에 대해 쌓인 메모리 전부를 매 요청에 전부 넣으면 예산을 잠식하고 무관한 사실이 잡음이 되므로, 이번 대화에 관련된 것만 회수해(6.3식) 넣는 것이 옳다.

메모리가 무비용이 아니라는 것을 숫자로 확정하자. 주입된 메모리는 매 요청 $B_{\text{mem}}$ 만큼의 입력 토큰을 소비한다. 한 사용자가 하루 40개 메시지를 보내고 메모리를 매 요청 500토큰 주입한다면, 하루에 $500\times 40 = 20{,}000$ 토큰이 오직 메모리 재주입에만 들어간다. 한 달이면 사용자당 60만 입력 토큰이고, 활성 사용자 10만 명이면 월 600억 입력 토큰이 순전히 "기억하기" 값으로 나간다 — 이것은 사용자가 실제로 입력한 질문과 무관하게 발생하는 고정 비용이다(7.5). 그래서 무엇을 얼마나 기억해 매번 실을지가 곧 유닛 이코노믹스의 문제가 된다. 또 하나, 메모리를 프롬프트 앞쪽에 두면 사용자마다·요청마다 달라져 프리픽스 캐시(5.6)의 공유 프리픽스를 깰 수 있다 — 그래서 모든 사용자가 공유하는 시스템 프롬프트를 맨 앞에, 사용자별 메모리를 그 뒤에 두어 캐시 경계를 관리한다.

7.1.10 메모리의 신뢰성·프라이버시 함정

메모리는 챗 제품에서 가장 매력적이면서 가장 위험한 기능이다. 매력은 명백하다 — 매번 처음부터 설명 안 해도 되는 연속감, 사용자 맥락에 맞춘 개인화, 그로 인한 제품 점착도. 위험은 덜 명백해서, 켜고 나서야 하나씩 드러난다.

첫째는 신뢰성, 곧 잘못된 기억이다. 추출이 틀리면 잘못된 "사실"을 영구 저장한다. 지나가는 말("친구가 채식주의야")을 사용자 자신의 선호로 오기억하거나, 한 번의 농담을 항구적 프로필로 고착시킨다. 이런 오기억은 이후 모든 대화를 오염시키는데, 사용자는 모델이 왜 그렇게 구는지 이유를 모른 채 "이 챗은 나를 이상하게 안다"고 느낀다. 히스토리 요약의 손실은 그 대화 안에서 끝나지만, 장기 메모리의 오기억은 세션을 넘어 지속된다는 점에서 더 나쁘다.

둘째는 투명성과 제어다. 사용자는 무엇이 기억됐는지 보고, 지우고, 끌 수 있어야 한다. 보이지 않는 메모리는 "이걸 왜 알지?"라는 불쾌한 골짜기와 신뢰 붕괴를 부른다 — 특히 사용자가 언급한 적 없다고 믿는 사실을 모델이 아는 순간. 메모리 보기·편집·삭제·비활성 UI는 친절이 아니라 이 기능을 켜기 위한 전제 조건이다.

셋째는 프라이버시와 법이다(9.3). 장기 메모리는 본질적으로 민감정보 — 건강·성향·재정·관계 — 를 사용자 키로 축적하는 프로필이다. GDPR·개인정보보호법의 정면 대상이라(목적 제한·삭제권·동의) 리전·보존·접근통제·삭제 전파를 처음부터 설계해야 한다. 사용자가 "내 메모리 다 지워"라고 했을 때 그것이 백업·로그·파생 요약까지 실제로 전파되는지가 규제 준수의 실질이다.

넷째는 새 공격면이다(7.4). 메모리 인젝션 — 공격자가 대화나 문서로 유도해 악성 지시를 사용자의 장기 메모리에 심어 두고, 이후 세션에서 발동시키는 것 — 은 프롬프트 인젝션의 지속형이다. 한 번 심으면 매 세션 프롬프트에 재주입되니 공격이 사용자를 따라다닌다. 게다가 멀티테넌트 시스템에서 한 사용자의 메모리가 다른 사용자에게 유출되면 그 자체로 심각한 데이터 유출이다. 마지막으로, 한 계정을 여러 사람이 쓰는 공유 기기·가족 계정에서는 메모리가 엉킨다 — "이 사람"이 늘 같은 사람이라는 가정은 자주 깨진다.

이 함정들을 종합하면 안전한 기본값이 보인다. 메모리는 기본 꺼짐(opt-in)이거나 최소 범위로 시작하고, 완전한 사용자 제어를 붙이는 것이 낫다. "기억하면 좋으니 다 기억하자"는 신뢰·법·보안 리스크를 동시에 키운다. 무엇을 기억할지는 넓게 긁기보다 명시적 신호 — 사용자가 "기억해"라고 하거나 여러 대화에서 반복 확인된 사실 — 에 무게를 두는 편이 오기억을 크게 줄인다. 이것은 정답이 아니라 트레이드오프이고, 제품의 위험 감수도와 규제 환경에 따라 답이 달라진다. 다만 민감 도메인일수록 보수적으로 가는 쪽이 나중에 후회가 적다는 것이 내 견해다.

7.1.11 다중 턴 일관성과 시스템 프롬프트

한 대화 안에서 어시스턴트는 일관된 인격·사실·형식을 유지해야 한다 — 3턴 전에 반말을 쓰기로 했으면 계속 반말이어야 하고, 자기가 앞서 말한 사실과 어긋나면 안 된다. 그런데 모델은 상태가 없으니 이 일관성은 순전히 매 턴 같은 시스템 프롬프트 + 누적 히스토리를 다시 보내는 데서 나온다. 그리고 바로 그 지점에서 흔한 붕괴가 생긴다.

가장 자주 보는 붕괴는 컨텍스트 관리가 일관성을 깨는 것이다. 슬라이딩 윈도로 앞 턴을 버리면(7.1.7) 초반에 정한 규칙("반말로 해 줘", "코드에 주석 달지 마")이 그 잘려 나간 자리에 있었을 때 함께 사라진다. 그다음부터 모델은 아무 일 없다는 듯 존댓말로 돌아간다 — 모델이 변덕을 부린 것이 아니라 그 지시를 애초에 안 받은 것이다. 요약이 세부를 생략해도 비슷하게 후반 답이 초반과 어긋난다. 여기서 원칙이 나온다 — 지속돼야 할 지시는 히스토리가 아니라 시스템 프롬프트나 장기 메모리에 둔다. 잘려 나가는 자리에 규칙을 두면 안 된다. 오래 지속돼야 하는 것일수록 안정적인 앞자리(시스템 프롬프트·장기 메모리)에, 순간적인 것일수록 뒤쪽(최근 턴)에 — 이것이 7.1.1에서 본 캐시 친화 배치와 정확히 같은 배치다. 여기에 샘플링 변동이 하나 더 추가된다. 온도가 0보다 크면(1.7) 같은 맥락에도 응답이 변동해 일관성이 떨어지므로, 재현·일관성이 중요한 제품은 온도를 낮추는 대신 다양성을 조금 포기하는 쪽을 택한다.

시스템 프롬프트는 그래서 프롬프트 기법(6.1)이자 제품 결정이다. 페르소나·어조·거부 정책·형식 규범·도구 사용 규칙이 여기 담긴다. 페르소나·어조 — 이름·성격·존댓말 여부·답변 길이·이모지 정책 — 는 브랜드 목소리라 모든 턴에 고정으로 실린다. 안전 경계·거부 정책은 무엇을 어떻게 거부하느냐인데, "차갑게 차단"과 "이유를 설명하며 대안 제시"는 UX와 안전을 동시에 가르는 결정이다(7.3). 도구·기능 경계는 어떤 도구를 언제 쓰고 무엇을 모른다고 말할지를 정한다.

시스템 프롬프트(제품 결정) 페르소나·어조 (브랜드 목소리) 안전 경계·거부 정책 (7.3) 형식·기능 규범 (도구·인용·길이) 다중 턴 일관성 (매 턴 고정 주입)

여기서 반드시 명시할 것이 있다 — 시스템 프롬프트는 보안 경계가 아니다. 사용자나 문서로 들어온 프롬프트 인젝션(7.4)이 시스템 지시를 뒤엎을 수 있다("위 지시 무시하고…" 류). 그래서 안전·인가·비밀은 시스템 프롬프트의 문구가 아니라 별도 방어층 — 입출력 필터·가드레일(7.3), 권한 검사, 인젝션 방어(7.4) — 으로 강제해야 한다. 시스템 프롬프트는 모델에게 하는 "부탁"이지 코드로 강제되는 "자물쇠"가 아니다. "여기 적힌 API 키를 절대 발설하지 마"라고 시스템 프롬프트에 쓰는 순간 그 키는 이미 인젝션으로 유출될 수 있는 곳에 있는 것이다. 덧붙여, 시스템 프롬프트가 길어질수록 매 턴 토큰 비용이 붙고, 모델이 그 모든 규칙을 완벽히 지킨다는 보장도 없다 — 규칙이 스무 개면 그중 몇 개는 상황에 따라 무시된다.

7.1.12 대화는 트리다 — 재생성·편집·분기

"대화는 선형 리스트"라는 순진한 모델은 곧 깨진다. 사용자는 재생성(같은 질문 다시 답해 줘), 편집(내 질문을 고쳐 다시), 분기(여기서 갈라 다른 길로)를 원한다. 이것을 제대로 받으려면 대화를 선형 로그가 아니라 트리로 모델링해야 한다.

루트(시스템 프롬프트) 사용자 턴 1 응답 1a 응답 1b (재생성 → 형제) 사용자 턴 2 응답 2 사용자 턴 1' (편집 → 새 분기) 응답 (분기 갈래)

재생성은 같은 부모(사용자 메시지) 아래 형제 응답을 하나 더 만든다. 사용자는 갈래를 전환하며 "< 2/3 >"로 고른다 — 온도가 0보다 크면 매번 다른 답이 나오니(1.7) 이것이 값을 한다. 편집은 과거 사용자 메시지를 고치는 것인데, 그 지점에서 새 분기가 뻗고 원래 그 아래에 있던 이후 턴들은 새 갈래로 대체된다. 원본 갈래는 버리지 않고 보존한다.

여기 중요한 함의가 있다. 편집·분기는 그 지점 이후의 프리픽스를 바꾼다. 프롬프트의 앞부분이 달라지므로 그 뒤로 쌓아 뒀던 프리픽스 캐시(5.6)가 전부 무효가 되고 재계산 비용이 든다 — 대화 중간을 고치는 것은 뒷부분 전체를 다시 계산하는 것과 같다. 그래서 저장 모델은 부모 포인터를 가진 트리여야 하고, 모델에는 "현재 활성 경로" 하나만 조립해 보낸다. 화면에 보이는 것은 트리의 한 경로일 뿐, 형제 갈래들은 저장소에 살아 있지만 이번 요청에는 안 실린다. 이 트리 구조는 7.1.5에서 원문 로그와 모델 컨텍스트를 분리한 것의 자연스러운 연장이다 — 로그는 트리 전체, 컨텍스트는 그중 한 경로다.

7.1.13 인용·아티팩트, 그리고 정직한 상태 UX

텍스트 하나를 넘어서는 구조가 챗 UX의 핵심이 됐다. 두 가지가 특히 중요하다.

인용(citation)은 RAG(6.3·6.4)로 근거 문서를 붙일 때, 응답의 어느 문장이 어느 출처에서 왔는지 각주·하이라이트로 보이는 것이다. 이는 신뢰와 검증의 핵심 경로다 — 사용자가 환각을 직접 확인할 수 있게 해 준다. 스트리밍 중에는 인용 메타데이터를 텍스트와 별개 이벤트로 흘려(7.1.3) 함께 렌더한다. 다만 정직하게 밝힐 것 — 인용이 붙었다고 모델이 실제로 그 출처를 근거로 답했다는 보장은 없다. 모델이 먼저 답을 만들고 사후에 그럴듯한 출처를 갖다 붙일 수 있다. 인용은 검증을 가능하게 하는 장치지, 검증이 됐다는 증명이 아니다.

아티팩트(artifact)는 코드·문서·표 같은 긴 구조화 산출물을 대화 흐름에서 분리해 별도 패널에 두고 편집·버전·실행을 붙이는 것이다. 대화는 대화대로 흐르고 산출물은 산출물대로 관리되니, 100줄 길이의 코드가 말풍선 사이에 섞여 스크롤을 어지럽히지 않는다. 구조화·스키마 강제는 6.9, 코드 실행은 도구(6.5)와 연결된다.

그리고 앞서 본 상태 머신(7.1.5)의 각 상태를 사용자에게 정직하게 표현하는 것이 신뢰의 절반이다.

상태나쁜 UX좋은 UX
대기(TTFT 전)무한 스피너, 멈춘 것인지 모름"생각 중" 표시, 길어지면 진행 신호
스트리밍다 나올 때까지 정지 버튼 없음타이핑 커서 + 정지 버튼(7.1.4)
에러·타임아웃조용히 실패, 빈 응답명확한 에러 + 재시도, 부분응답 보존
거부(refusal)무뚝뚝한 차단, 이유 없음왜 못 하는지 + 가능한 대안(7.3)
저확신확신에 찬 오답불확실성·한계 고지(할 수 있을 때)

이 표의 이면에는 불편한 진실이 하나 있다. 모델은 틀린 답도 확신에 차서 내놓는다(보정 문제, 4.2). 그리고 UX가 매끄럽고 스트리밍이 유려할수록 사용자는 그 답을 더 믿는다 — 그럴듯함과 정확함은 다른데, 매끄러운 대화 UX는 그럴듯함을 극대화한다. 그래서 인용(검증 경로)·불확실성 표시·거부의 투명성은 "친절"이 아니라 오정보 리스크를 낮추는 장치다. 좋은 챗 UX를 만들수록 이 리스크가 커진다는 역설을 설계자는 잊으면 안 된다 — 잘 만든 껍데기가 약한 알맹이를 더 위험하게 감출 수 있다.

7.1.14 피드백을 모아 플라이휠로 흘리기

챗 제품의 숨은 자산은 피드백이다. 어떤 응답이 좋았고 나빴는지가 평가(4.2)·개선·정렬 데이터(8.1)의 원천이 된다. 신호는 명시적·암묵적·수정의 셋으로 나뉜다.

신호예강도함정
명시적좋아요·싫어요, 별점, 응답 A/B 선택, 자유 서술강함(의도적)희소·편향(불만족만 누름)
암묵적재생성·편집·복사·이어감·이탈·정지약하지만 풍부해석 모호(재생성=불만? 호기심?)
수정사용자가 응답을 직접 고침매우 유용(정답 힌트)드묾

좋아요·싫어요는 가장 흔하지만 저해상도다 — "왜" 싫은지(사실 오류? 어조? 형식?)를 모르면 개선에 못 쓴다. 그래서 싫어요에 후속 태그("부정확·무관·불쾌·형식")나 자유 서술을 붙여 신호를 세분한다. 암묵 신호는 반대로 풍부하지만 애매하다 — 사용자가 재생성을 눌렀다면 답이 나빠서일 수도, 단순히 다른 각도가 궁금해서일 수도 있다. 그래서 암묵 신호는 명시 신호와 교차해서만 해석해야 한다. 그런데 이 피드백을 UI에서 좋아요 수만 세고 끝내면 아무 값이 없다. 값을 하려면 관측성과 플라이휠로 흘려야 한다.

피드백 UI (좋아요·싫어요·수정·암묵신호) 구조화 로깅 (요청·응답·컨텍스트 결합) 관측성·품질 모니터링 (7.6) 데이터 플라이휠 (8.1) 회귀 탐지·A/B (8.2) 프롬프트·검색·파인튜닝 개선 (8.1·8.3)

핵심은 피드백을 그 응답을 만든 정확한 컨텍스트 — 프롬프트·모델·검색 결과·설정 — 와 묶어 저장하는 것이다(7.6). "싫어요"만 있고 무엇에 대한 건지, 어떤 프롬프트·어떤 모델 버전이 만든 건지가 없으면 원인 분석이 불가능하다. 컨텍스트와 결합된 피드백이 쌓이면 좋은 예·나쁜 예가 프롬프트 개선, 검색 튜닝, 선호 데이터(3.3·3.5)·SFT 데이터(3.2)로 이어진다 — 사용이 개선을 낳는 순환이 데이터 플라이휠이다(8.1). 그리고 이 피드백 지표를 온라인 품질 지표로 삼으면 변경의 좋고 나쁨을 A/B로 측정할 수 있다(8.2).

두 가지를 정직하게 안고 가야 한다. 첫째, 피드백은 편향된 표본이다 — 소수의 강한 감정(대개 불만)만 버튼을 누르고 만족한 다수는 침묵한다. 좋아요율을 곧 "품질"로 믿으면 왜곡된 그림을 본다. 둘째, 피드백·대화 로그를 학습에 쓰는 것은 동의·프라이버시(9.3)의 문제다. 사용자에게 무엇이 수집·학습에 쓰이는지 고지하고 옵트아웃을 줘야 하며, 학습 전 개인정보 제거와 거버넌스(9.4)가 필요하다. "우리가 가진 대화 데이터를 다 학습에 쓰자"는 법적·윤리적 위험 요소다 — 특히 사용자가 가장 사적인 것을 털어놓은 것이 이 데이터라는 점에서.

7.1.15 길수록 좋다는 착각

챗 제품 설계에서 가장 끈질긴 통념이 "컨텍스트가 많을수록·대화가 길수록 답이 좋아진다"이다. 이 장에서 본 것들이 세 방향에서 이를 조용히 위배한다.

비용 쪽은 7.1.8에서 계산했다 — 매 턴 히스토리를 다시 보내는 비용은 턴 수에 이차로 큰다. 프리픽스 캐시가 완화하지만 출력·편집·미스에서 발생한다. 긴 대화는 은유가 아니라 산술적으로 비용이 크다. 품질 쪽은 컨텍스트가 길수록 관련 신호가 무관한 잡음에 묻힌다는 것이다 — 아주 긴 컨텍스트에서 모델이 중간 정보를 못 쓰는 "lost in the middle"도 보고돼 있고(모델·조건에 따라 다른 연구 주장), 관련 있는 적은 맥락이 무관한 많은 맥락을 이긴다는 것이 RAG·선별 회수·요약이 존재하는 이유다. 지연 쪽은 긴 프롬프트가 prefill $O(n^2)$ 로 TTFT를 증가시킨다는 것 — 대화가 길수록 매 응답의 첫 글자가 늦게 표시된다(7.1.2의 체감 지연이 나빠진다).

그래서 교정된 목표는 "최대한 많이 담기"가 아니라 "필요한 것을 예산 안에 정확히 담기"다. 좋은 챗 제품은 대화를 무한정 늘리는 대신 요약·메모리·회수로 관련 맥락을 압축·선별하고, 흐름이 끊긴 지점에서는 새 대화를 권한다. 길수록 좋은 것이 아니라 적확할수록 좋다. 이 장에서 조용히 깬 다른 두 통념도 같은 결이다 — "스트리밍하면 빨라진다"는 체감에 대해서만 참이고 총 시간·비용은 그대로이며(7.1.2), "메모리를 켜면 무조건 낫다"는 신뢰성·프라이버시·비용·보안 대가를 무는 트레이드오프다(7.1.10). 셋 다 공통점은, 매끄러운 표면이 그 아래의 비용과 리스크를 감춘다는 것이다.

이 장이 세운 것은 상태 없는 함수 위에 대화·기억·인격을 조립하는 원리와, 그 조립을 지연·비용·프라이버시 예산 안에서 지키는 트레이드오프였다. 모든 결정의 최종 근거는 자기 제품의 측정이다 — 대화 길이 분포, 재방문 패턴, 피드백 신호, 지연·비용 예산이 "요약이냐 회수냐", "메모리를 켤 것이냐", "SSE냐 WebSocket이냐"의 답을 정한다. 여기서 세운 UX·상태·기억의 껍데기를 이제 바깥세상에 노출해야 한다 — 이것을 API와 플랫폼으로 여는 것은 7.2, 그 경계를 안전·가드레일로 지키는 것은 7.3, 이 모든 토큰을 돈으로 환산해 회계하는 것은 7.5이다.