제 4 장

토큰 예산과 컨텍스트 엔지니어링

사용자가 “그건 언제부터야?”라고 물었다. 답하려면 이전 대화가 필요하고, 검색한 정책과 티켓 조회 결과도 후보다. 전부 붙이면 답이 좋아질 것 같지만 길이 제한을 넘거나, 개정 전 정책이 섞이거나, 다른 사용자의 대화가 들어간다. 필요한 정보가 들어 있다는 것과 문맥을 제대로 구성했다는 것은 다르다.

컨텍스트 엔지니어링은 모델이 이번 작업에 쓸 정보를 고르고, 배치하고, 갱신하고, 격리하는 일이다. 프롬프트 문장을 다듬는 일도 그 일부지만, 정보가 어디서 왔고 지금 읽을 권한이 있고 언제까지 유효하고 빠지면 어떻게 할지가 함께 있어야 한다. 예제는 권한을 먼저 검사하고, 질문과 출력 공간을 확보한 뒤, 우선순위대로 자료를 넣는 구성기다.

토큰 수의 세 가지 값

한국어 글자 수를 고정 비율로 나눠 토큰 수라고 부르면 안 된다. 같은 길이라도 토크나이저와 문자 조합에 따라 다르고, 실제 요청에는 코드·식별자·숫자·여러 언어가 섞인다.

토큰 수라고 부르는 세 값요청 전사전 추정치로컬 토크나이저 · 문자 수크기를 가늠한다생성 직전계수 결과responses.input_tokens.count예산 판정에 쓴다생성 후실제 사용량응답의 usage비용을 기록한다시점과 출처가 다르다. 셋을 한 필드에 덮어쓰면 어느 값을 보고 있는지 알 수 없다

로컬 토크나이저는 본문의 토큰만 계산하지만, 최종 요청에는 지시문·역할·구분자가 더 들어간다. Responses API의 계수 기능은 이 차이를 모델 기준으로 알려 준다. 입력 토큰 계수 참조

def provider_counter(client, model, instructions):
    def count(text):
        result = client.responses.input_tokens.count(
            model=model,
            instructions=instructions,
            input=text,
        )
        return result.input_tokens
    return count

계수 호출도 데이터를 밖으로 보내는 요청이다. 읽을 권한이 없는 문서를 계수 API에 보내고 생성 직전에만 빼는 순서는 안 된다. 빠른 단위 테스트에서는 len을 계수 함수로 넣고 결과의 count_methodtest-character-count를 남겨, 이 값을 실제 토큰 수로 오해하지 않게 한다.

예산 규칙

모델의 문맥 한도를 WW, 출력에 남길 예산을 OO, 여유분을 MM, 최종 입력의 계수값을 II라고 하면 구성기가 지키는 규칙은 하나다.

I+O+MWI + O + M \leq W
토큰 예산 I + O + M ≤ WW · 모델의 문맥 한도지시문 + 질문먼저 넣는다문서 · 이력우선순위대로 채운다출력 예약 O입력을 고르기 전에 뺀다M여유문서 · 이력 자리를 채우는 순서정책 v2티켓 결과긴 대화 이력① 정책 v2 채택 → ② 긴 대화 이력은 자리에 안 맞아 건너뜀 (omitted_for_budget) → ③ 티켓 결과 채택질문과 지시문이 예산을 넘으면 자르지 않고 생성을 중단한다

후보를 하나 더한 전체 요청을 다시 계수한다. 개별 자료 길이를 더하기만 하면 구분자와 메타데이터, 이어 붙인 문자열의 토큰 경계가 빠진다. 큰 자료가 안 들어간다고 뒤의 작은 자료까지 포기하지 않고 건너뛰고 다음을 시도한다. 이 정책이 늘 최선은 아니다. 꼭 필요한 큰 문서가 빠지면 답을 못 하고, 그 차이는 평가와 작업별 필수 근거 규칙으로 잡는다.

예산 구성처리잘못 처리했을 때
필수 지시문과 질문먼저 계수하고 초과 시 중단질문 조건을 잃는다
문서·이전 대화허용 여부와 우선순위로 선택불필요한 정보가 핵심 근거를 밀어낸다
출력 공간입력 선택 전에 예약입력은 들어가도 답변이 중간에 끊긴다
여유분확인한 계수 오차에 맞춰 설정과소 확보 또는 불필요한 입력 축소

ContextPlan에는 최종 입력 문자열, 선택한 식별자, 예산 때문에 빠진 식별자(omitted_for_budget), 입력 계수값, 출력 예약량, 계수 방법이 들어간다. 권한이 없어 빠진 식별자는 사용자에게 돌려주지 않는다. 그런 자료가 있다는 사실도 정보다. 계획을 그대로 생성 호출에 넘기고, 계수 뒤에 문서나 지시문을 덧붙이지 않는다. 테스트는 마지막 계수 요청과 생성 요청의 input·instructions가 같은지 확인한다.

잘라내기와 요약의 손실

“교육비를 지원한다. 단, 구매 전 승인받은 경우에 한한다.” 뒷문장만 잘리면 적용 범위가 바뀐다. 길이를 줄이는 일과 뜻을 지키는 일은 다르다.

줄이는 방법마다 잃는 것이 다르다대화줄이는 방법 → 사라질 수 있는 것1. 교육비 신청해 줘2. 아니, 신청은 취소. 한도만 알려 줘3. 그럼 얼마까지야?끝부분 자르기“단, 승인받은 경우에 한한다”처럼 문서 끝의 조건마지막 메시지만“그럼”이 가리키는 대상요약“신청은 취소”라는 최신 의도

구성기는 자료를 문장 중간에서 자르지 않고 블록 단위로 넣거나 뺀다. 블록 안의 뜻은 지키지만 큰 문서를 통째로 버릴 수 있다. 문서 구조를 따라 잘게 나누는 청킹이 필요해지는 이유다. 요약은 모델이 만든 데이터라 원본보다 믿을 수 없다. 사용자가 “구매하지 말고 절차만”이라고 정정했는데 요약에 최초 요청만 남으면 잘못된 행동을 부른다. 요약에는 출처 범위·생성 시점·원본 판을 함께 두고, 티켓 확인 여부나 인증된 사용자처럼 결정적인 상태는 서버 데이터로 둔다. 요약은 업무 데이터베이스를 대신하지 못한다.

핵심 근거가 예산 때문에 빠졌으면 모델이 일반 지식으로 빈자리를 채우기를 기대하지 말고, 다시 검색하거나 질문 범위를 좁히거나 근거 부족으로 처리한다. 예산을 지켰다는 것과 답할 정보가 충분하다는 것은 다르다.

사용량과 비용

입력 토큰 II, 그중 캐시로 처리된 입력 CC, 출력 토큰 OO, 백만 토큰당 단가를 각각 rir_i, rcr_c, ror_o라고 하면 단순 요금 모형은 다음과 같다.

cost=(IC)ri+Crc+Oro106\text{cost} = \frac{(I-C)r_i + Cr_c + Or_o}{10^6}

캐시 입력은 전체 입력의 부분집합이라 따로 더하면 중복이다. 캐시 쓰기·도구 실행·저장처럼 별도 요금이 있으면 이 식만으로 청구액이 나오지 않는다. 입력 1,000, 캐시 400, 출력 200토큰에 가상 단가 2·0.5·8을 넣으면 다음과 같다.

일반 입력: (1000 - 400) × 2 / 1000000 = 0.0012
캐시 입력: 400 × 0.5 / 1000000        = 0.0002
출력:      200 × 8 / 1000000          = 0.0016
합계:                                  0.0030
cost = token_cost(
    1000, 200, 400,
    input_rate=Decimal("2"),
    cached_rate=Decimal("0.5"),
    output_rate=Decimal("8"),
)

단가는 가정이고 화폐 계산에는 Decimal을 쓴다. token_cost는 사용량이 하나라도 미상이면 None을 돌려주고, 캐시 입력이 전체 입력보다 크거나 값이 음수면 데이터 오류로 본다. 비용을 기록하는 기능과 요청 전에 한도를 강제하는 기능은 다르다. 예제에는 여러 동시 요청의 금액을 예약하는 중앙 예산 관리자가 없다. 제품 지표로는 요청당 비용 외에 성공한 업무당 비용을 보되, 실패한 요청의 비용도 분자에 넣는다. 성공이 0건이면 계산 불가로 표시한다.

대화 상태와 세 가지 캐시

‘캐시’라고 부르는 세 가지의 자리사용자애플리케이션문맥 구성제공자모델응답 캐시과거에 만든 결과를 다시 준다키에 조직·사용자·권한·문서 판·프롬프트 판이 들어간다대화 이력다음 요청에 필요한 정보를 보관한다조직·사용자·세션이 다 일치할 때만 넣는다프롬프트 캐시같은 접두사의 계산을 재사용한다적중 여부는 사용량으로 확인한다

이력 블록은 조직·사용자·세션이 모두 일치할 때만 허용한다. 사용자만 비교하면 같은 사람의 다른 대화가 섞이고, 세션만 비교하면 다른 조직에서 우연히 같은 문자열을 쓴 대화가 섞인다. Principal은 인증과 권한 조회를 거친 값이라는 전제로 만든다. HTTP 본문의 userreadable_docs를 그대로 Principal로 만들면 이 검사가 있어도 접근 통제가 없는 것과 같다. 대화 상태 관리 안내

응답 캐시의 키에 질문 문자열만 쓰면 “내 신청 상태는?”에 다른 사용자의 결과가 나간다. 권한이 회수되면 그 문서로 만든 응답과 요약도 무효가 되어야 한다. 검색에서만 새 권한을 보고 오래된 캐시를 돌려주면 통제가 우회된다. 제공자의 프롬프트 캐시는 같은 접두사를 재사용하므로 안정된 부분을 앞에 두면 유리하지만, 적중 여부와 요금 효과는 사용량으로 확인한다. 프롬프트 캐시 안내

문맥 구성 순서

문맥 구성기의 순서사용자 범위 + 후보 자료인증을 거친 Principal과 문서·이력 블록조직 · 문서 권한 · 대화 범위 검사다른 조직·다른 세션 자료는 여기서 사라진다허용한 자료의 우선순위 정렬정책 문서 > 긴 대화. 같은 순위는 식별자순질문 · 지시문 계수, 출력 공간 확보질문이 안 들어가면 자르지 않고 중단후보 추가 → 전체 계수 → 채택 / 제외안 맞으면 건너뛰고 다음 후보같은 입력으로 생성 요청계수한 입력과 보낸 입력이 같다

문맥 블록에는 식별자·조직·종류·텍스트·우선순위·필요한 접근 범위가 있다. 문서 블록은 허용된 문서 식별자에 속해야 하고, 이력 블록은 사용자와 세션이 일치해야 하고, 모르는 종류는 뺀다. 우선순위는 지금은 호출자가 주는 정수다. 시연에서는 정책 문서가 긴 대화보다 먼저 시도되고, 같은 순위는 식별자순이라 입력 순서가 바뀌어도 결과가 같다.

입력은 JSON으로 직렬화한다. 문서의 따옴표와 개행이 구조를 깨지 못하게 하려는 것이지, 문서 안의 지시를 모델이 따르지 못하게 막는 장치는 아니다. 테스트는 JSON을 닫는 것처럼 보이는 문자열을 문서에 넣고, 파싱한 결과에서 그 문자열이 문맥 텍스트 안에 그대로 있고 최상위 지시문 필드가 생기지 않았는지 본다. 이 테스트가 통과해도 프롬프트 주입에 대한 모델의 내성은 별개다.

문맥 실패의 여섯 유형

실패 유형필요한 관측다음 조치
후보 누락검색 또는 자료 조회 결과공급 단계 개선
권한 제외제한된 내부 권한 진단권한 정책 검토, 임의 우회 금지
예산 제외허용된 후보와 제외 사유청킹·선택 정책·예산 재검토
요약 손실원본과 요약의 조건 비교요약 규칙·상태 분리
생성 오류실제로 전달한 문맥모델·프롬프트·평가 개선
문맥 혼입조직·사용자·세션 범위데이터 경계 수정

문맥 길이를 늘리면 예산 제외는 줄지만 비용·지연·불필요한 정보가 늘어난다. 늘린 뒤에는 같은 질문 집합으로 다시 비교한다. 계수 횟수도 비용이다. 후보마다 계수 API를 부르면 후보 수만큼 요청과 지연이 늘어서, 문서가 많아지면 로컬 추정으로 후보를 줄인 뒤 최종 요청만 계수하는 개선을 검토한다. 이때 추정치와 확정 계수값의 차이를 기록해야 그 개선의 위험이 보인다. 계수 요청이 실패하면 ‘아마 들어갈 것’이라고 보고 생성하지 않는다. 호출자에게 실패를 넘긴다.

정책 문서에는 소유 부서와 시행일, 티켓 결과에는 조회한 사용자와 시점, 대화 요약에는 원본 범위와 생성 시점이 함께 있어야 한다. 이 정보를 잃으면 문장만 남은 자료가 어디에 쓰일 수 있는지 판단할 수 없다. 문맥 저장소나 데이터 카탈로그를 들여도 요청 시점의 권한 검사는 따로 연결해야 한다. 예제는 작은 Python 데이터 구조로 이 규칙을 구현하고, 필요가 커지면 데이터 수명 관리·출처 연결·접근 통제·조회 지연·운영 부담을 기준으로 도구를 고른다.

실습: 권한 제외와 예산 제외 구별하기

context_demo.py는 외부 API 없이 구성기를 돌린다. 같은 조직의 교육비 문서, 다른 조직의 자료, 같은 사용자의 다른 대화, 길이가 긴 현재 대화 블록을 넣는다.

python books/ai-engineering/examples/context_demo.py
  1. 출력에서 교육비 문서는 선택되고, 다른 조직과 다른 대화의 자료는 선택 목록에도 omitted_for_budget에도 없어야 한다. 긴 현재 대화는 읽을 수 있지만 예산에 안 맞아 omitted_for_budget에 나온다. 두 제외는 사용자에게 줄 정보와 운영 조치가 다르다.
  2. test_context_budget.py를 읽고 전체 테스트를 돌린다. 예산과 정확히 같은 크기는 허용하고 한 단위 넘으면 제외하는지, 질문이 너무 크면 조용히 자르지 않는지, 큰 블록을 건너뛴 뒤 작은 블록을 시도하는지, 권한을 회수하면 다시 구성한 입력에서 빠지는지 본다.
  3. test_filters_before_counting은 계수 함수가 받은 모든 문자열을 본다. 권한 밖의 비공개 텍스트가 그 어디에도 없어야 한다. 생성 입력만 보는 검사보다 한 단계 앞을 본다.
  4. 실제 모델을 붙일 때는 lenprovider_counter로 바꾸고, 모델 문서에서 확인한 한도와 출력 예산을 넣는다. ContextPlan.input_textgenerateinput_text로, output_reserveoutput_limit로 넘긴다.

이 장의 결과물은 권한을 확인한 문맥 계획, 토큰 예산 검사, 가상 단가의 비용 계산이다. 답변의 사실성은 아직 그대로다.

모델 호출을 애플리케이션으로 만들기4 / 23