LLM 에이전트 입력 토큰 70% 줄이기: OpenAI 툴 지연 로딩과 한국어 설명의 함정
- 툴을 쓰는 LLM은 호출할 때마다 모든 툴 정의를 입력으로 보낸다. 툴이 44개인 챗봇은 "안녕" 한마디에도 입력이 약 7,800토큰이었다. 캐시는 단가를 낮출 뿐 토큰 수는 그대로 센다.
- OpenAI Responses API의 툴 지연 로딩(툴 묶음 +
tool_search)은 묶음 이름과 설명만 먼저 보내고, 필요한 묶음만 그 응답 안에서 불러온다. 같은 질문 12개로 재니 입력 합계가 절반으로 줄고 툴 선택은 거의 같았다. - 남은 토큰을 하나씩 빼 가며 재 보니 한국어로 쓴 묶음 설명이 로컬 계산의 약 3.7배로 청구되고 있었다. 설명만 영어로 바꾸자 인사 한 번이 약 1,350토큰이 됐다. 처음 대비 질문 12개 합계로 약 70% 감소다.
- 설명을 영어로 바꿔도 한국어 질문에는 한국어로 답했다. 다만 모델이 가끔 다른 언어 단어를 섞는 문제는 프롬프트로 막히지 않아 코드로 찾아 고쳤다.
자동매매 시스템의 운영 챗봇이다. 계좌·시세 조회, 연구 작업 접수, 주문 제안, 서비스 상태 확인 같은 툴 44개를 부를 수 있다. 모델은 OpenAI의 gpt-5.6-terra와 gpt-6-astra, API는 Responses API다. 수치는 모두 API 응답에 서버가 적어 준 실제 사용량(usage)이다.
- 툴 정의: 모델에게 "이런 함수가 있다"고 알리는 이름·설명·파라미터 스키마. API가 system 메시지에 넣어 입력 토큰으로 센다.
- 툴 묶음(namespace): 관련 툴을 한 이름 아래 모은 단위. 모델은 묶음의 이름과 설명을 보고 필요한 묶음을 고른다.
- 지연 로딩: 툴 정의를 처음부터 싣지 않고, 모델이 필요하다고 판단한 묶음만 그때 불러오는 방식.
1.인사 한마디에 입력 토큰은 얼마나 드나
툴을 쓰는 챗봇을 만들면 요청마다 툴 목록(tools)을 함께 보낸다. 질문이 "안녕"이어도 마찬가지다. 모델이
어떤 툴이 있는지 알아야 쓸지 말지 판단할 수 있기 때문이다.
답이 짧으니 싸겠거니 하기 쉽다. OpenAI 문서는 반대로 적는다. 함수 정의는 system 메시지에 주입되어 컨텍스트 한도를 차지하고 입력 토큰으로 청구된다. 그래서 한 턴을 시작할 때 쓸 수 있는 함수는 20개 미만을 권한다(강제가 아니라 권장이다). 툴이 늘수록 모든 호출이 그만큼 무거워진다.
프롬프트 캐시가 알아서 줄여 준다고 기대하는 것도 조심한다. 같은 앞부분이 반복되면 캐시가 적용되어 단가와 지연이 줄지만, 입력 토큰 수 자체는 그대로 센다. 무료 한도도 마찬가지다. OpenAI는 요청 데이터 공유에 동의한 조직에 하루 무료 토큰을 주는데, 이 한도는 입력과 출력의 합계로 세고 캐시된 입력을 빼 준다는 문구가 없다. 캐시가 무료 한도를 아껴 준다고 보지 않는 편이 안전하다.
툴 44개의 정의가 약 7,200토큰이었고, "안녕" 한 번의 입력이 약 7,800토큰이었다. 같은 요청을 연달아 두 번 보내면 두 번째는 입력의 거의 전부가 캐시로 처리됐지만(뒤에서 설명할 묶음 방식의 약 1,900토큰짜리 요청에서 1,922토큰), 사용량에 찍히는 입력 토큰 수는 첫 번째와 같았다.
2.필요한 툴만 싣는 법: 묶음과 지연 로딩
툴 수를 줄이자니 기능이 빠진다. 그런데 질문 하나에 실제로 필요한 툴은 대개 한두 개다. 흔한 해법은 가벼운 모델을 한 번 더 불러 질문에 맞는 툴을 고르게 하는 라우터다. 호출이 하나 늘고, 그만큼 느려지고, 라우터가 틀리면 필요한 툴이 아예 빠진다.
OpenAI Responses API는 이 일을 API 안에서 한다. gpt-5.4 이후 모델에서 툴을 묶음(namespace)으로 모으고, 각 함수에
defer_loading: true를 달고, 툴 목록에 tool_search를 넣는다. 그러면 모델은 묶음의
이름과 설명만 먼저 보고, 필요한 묶음을 불러와 바로 호출한다.
[
{
"type": "namespace",
"name": "market",
"description": "Instrument marks, order specs (tick, step, min qty), latest signals.",
"tools": [
{ "type": "function", "name": "market_marks",
"description": "종목의 기준가를 읽는다.",
"parameters": { … },
"strict": false, // 생략하면 엄격 모드로 시도한다
"defer_loading": true } // ① 처음에는 싣지 않는다
]
},
… // 다른 묶음들
{ "type": "tool_search" } // ② 모델이 묶음을 찾아 불러온다
]
기본 방식에서는 검색을 서버가 실행한다. "BTC 기준가 알려줘"를 받으면 모델이 한 응답 안에서 market
묶음을 불러오고 곧바로 market_marks를 호출한다. 라우터처럼 호출이 늘지 않는다. 불러온 툴은 컨텍스트의 끝에
붙으므로 앞부분(system 프롬프트 등)의 캐시도 유지된다.
Responses API는 대화를 서버에 저장해 이어 가거나(previous_response_id), 저장하지 않고 매 요청에 전체 대화를
싣는(store: false) 두 방식을 쓸 수 있다. 뒤의 방식이라면, 응답에 나온 tool_search_call과
tool_search_output 항목을 다음 요청의 입력에 그대로 넣어야 한다. 빼면 불러온 툴이 다음 요청에서 사라져,
툴 결과를 받은 모델이 그 툴을 다시 찾거나 호출을 이어 가지 못한다.
지원 범위는 좁다. gpt-5.4 이후 모델의 Responses API뿐이고, Chat Completions만 받는 서버(로컬 서버, 다른 호스팅)는 이 형식을 모른다. 그래서 툴 목록은 하나로 두고, 연결마다 전송 형식만 바꾸는 편이 낫다. 지연 로딩을 지원하는 연결에는 묶음으로, 나머지에는 기존처럼 전부 싣는다.
3.묶음은 어떻게 나누고 설명에는 무엇을 쓰나
모델은 묶음의 설명만 보고 무엇을 불러올지 고른다. 설명에 없는 기능은 모델이 그 묶음에 있는지 알 수 없다.
흔히 코드의 디렉터리 구조대로 묶고 설명을 "시장 도구"처럼 짧게 단다. 사용자는 코드 구조로 묻지 않는다. "계좌 잔고랑 포지션 보여줘"처럼 하고 싶은 일로 묻는다. 그래서 묶음은 사용자가 들고 올 질문 단위로 나누고, 설명에는 그 묶음의 툴이 하는 일을 빠짐없이 적는다.
| 묶음 (예) | 설명에 적은 것 |
|---|---|
| 포트폴리오 | 계좌·잔고·포지션·수익률·성과 조회 |
| 시장 | 기준가, 주문 규격, 최신 신호 |
| 운영 | 수집기·자동화 상태와 제어, 서비스 재시작 |
이런 묶음이 일곱 개다. 사용자가 "잔고"를 물으면 포트폴리오, "기준가"를 물으면 시장을 고르면 되게 나눴다.
묶음을 손으로 관리하면 새 툴을 어디에도 넣지 않는 실수가 생긴다. 지연 로딩에서 묶음 밖의 툴은 모델이 영영 찾을 수 없다. 그래서 모든 툴이 정확히 한 묶음에 속하는지 기동할 때 검사한다.
# GROUPS = {묶음 이름: (설명, [툴 이름…])}, TOOLS = {툴 이름: 툴 정의} index = {} for group, (_, names) in GROUPS.items(): for name in names: if name in index: raise RuntimeError(f"툴이 두 묶음에 있다: {name}") index[name] = group if index.keys() != TOOLS.keys(): # 묶음 밖 툴은 찾을 수 없다 raise RuntimeError(f"묶음 불일치: {sorted(index.keys() ^ TOOLS.keys())}")
"BTC 기준가 알려줘"에는 시장 묶음 하나, "계좌 상태랑 BTC 기준가"에는 포트폴리오와 시장 두 묶음만 불러왔다. 인사에는 아무 묶음도 불러오지 않았다.
4.남은 토큰은 어디서 나오나: 하나씩 빼 가며 재기
지연 로딩을 켜자 인사 한 번이 약 7,800토큰에서 약 2,500토큰으로 줄었다. 그래도 인사 한마디치고는 크다. 무엇이 차지하는지 알아야 더 줄일 수 있다.
보통은 보내는 텍스트를 로컬 토크나이저(OpenAI 모델이면 tiktoken의 o200k_base)로 센다. 그런데 서버가 툴 정의 같은 구조를 모델에 넣을 때는 보낸 JSON 그대로가 아니라
자기 형식으로 바꿔서 넣는다. 로컬 계산은 그 차이를 모른다. 확실한 방법은 구성 요소를 하나씩 빼거나 바꾼 요청을 보내고,
서버가 보고한 입력 토큰의 차이를 보는 것이다.
| 보낸 요청 | 입력 토큰 | 차이로 알 수 있는 것 |
|---|---|---|
| 사용자 메시지만 | 8 | — |
| + 시스템 프롬프트 | 623 | 시스템 프롬프트 615 (로컬 계산 611 — 정상) |
| + 툴 묶음 (시스템 없이) | 1,910 | 툴 묶음 1,902 |
| 툴 묶음에서 지연 함수의 설명·파라미터를 비움 | 1,910 | 지연된 정의는 0토큰 — 지연 로딩은 동작한다 |
| 툴 묶음에서 묶음 설명을 이름만 남김 | 503 | 묶음 설명 7개가 약 1,400토큰 |
지연된 함수는 정말로 한 토큰도 차지하지 않았다. 남은 1,900토큰의 대부분은 묶음 설명 일곱 개였다. 그런데 이 설명은 로컬 토크나이저로 세면 377토큰밖에 안 된다.
5.한국어로 쓴 묶음 설명이 3.7배로 청구된다
한국어가 영어보다 토큰을 조금 더 쓴다는 것은 알려져 있다. 여기서는 그 정도가 아니었다. 같은 내용을 영어로 바꿔 보내고 청구된 입력을 비교했다.
| 묶음 설명 7개 | 로컬 토크나이저 계산 | 서버가 청구한 입력 |
|---|---|---|
| 한국어 | 377 | 약 1,400 (약 3.7배) |
| 영어 (같은 내용) | 241 | 약 230 (거의 같음) |
부풀림은 묶음 설명에만 있었다. 한국어 시스템 프롬프트는 로컬 계산과 청구가 같았다(611 대 615). 검색으로 불러온
툴의 설명도 한국어와 영어의 청구 차이가 로컬 계산의 차이(약 30토큰)와 같아서 부풀지 않았다. 서버가 묶음 설명을 모델에 넣을 때 한글 한
글자를 \uD55C처럼 여러 토큰짜리 코드로 바꿔 넣는 것으로 보인다. 추정이지만, 대응은 원인과 상관없이 같다.
묶음 설명은 영어로 쓴다. 이 설명은 모델만 읽는 문자열이라 화면에 영향이 없다. 툴 자체의 설명과 시스템 프롬프트는 한국어 그대로 둬도 된다.
로컬 토크나이저의 계산은 서버가 실제로 넣는 형식을 모른다. 같은 한국어라도 어느 자리에 들어가느냐에 따라 청구가 달랐다. 새 API 기능에 한국어(또는 다른 비 ASCII 문자열)를 넣으면 영어로 바꾼 요청과 사용량을 한 번 비교해 본다. 서버 쪽 동작은 바뀔 수 있으니 가끔 다시 잰다.
6.설명을 영어로 바꾸면 영어로 답하지 않나
프롬프트에 영어가 섞이면 답도 영어로 나오지 않을까 걱정된다. 답의 언어는 시스템 프롬프트가 정한다. 시스템 프롬프트에 "답변은 한국어로 쓴다"가 있으면 묶음 설명의 언어는 답에 영향을 주지 않았다.
- 한국어 질문 10개를 두 번씩, 두 모델에 보냈다. 답이 온 것은 모두 한국어였다.
- "hello", "what can you do?"처럼 영어로 물어도 한국어로 답했다. 시스템 프롬프트의 지시대로다.
대신 다른 문제가 보였다. 한 모델이 가끔 한국어 답에 힌디어 단어를 섞었다. "연결이 उपलब्ध하지 않아"처럼 "사용 가능"을 뜻하는 단어 하나가 다른 언어 토큰으로 나온다. 조회가 안 되는 상황을 설명하는 답에서 약 10% 나왔고, 툴 정의를 전부 싣던 때에도 있었으니 영어 설명 때문은 아니다.
먼저 시스템 프롬프트에 "한글과 영문 식별자 외의 문자를 쓰지 않는다"를 넣었다. 거의 줄지 않았다. 모델이 토큰 수준에서 가끔 내는 오류는 지시로 막히지 않는다. 그래서 코드가 최종 답을 검사하고, 섞였으면 그 단어를 짚어 한 번만 다시 쓰게 했다.
# 히브리·아랍·인도계·태국·키릴·일본 가나. 한자는 한국어 문장에 쓰일 수 있어 뺀다 FOREIGN = re.compile(r"[-ۿऀ--Ѐ-ӿ-ヿ]+") if FOREIGN.search(answer): words = ", ".join(f"'{w}'" for w in dict.fromkeys(FOREIGN.findall(answer))) fix = f"직전 답변의 {words}는 한국어가 아니다. 뜻이 같은 한국어로 바꿔 다시 쓴다." retry = call_model(messages + [assistant(answer), system(fix)]) if not FOREIGN.search(retry.text): answer = retry.text # 또 섞이면 원래 답을 쓴다
| 대응 (조회 불가 답을 부르는 질문 40건) | 최종 답에 섞인 건수 |
|---|---|
| 없음 | 4 / 40 |
| 시스템 프롬프트에 금지 규칙 | 3 / 40 |
| 코드가 찾아서 "한국어로 다시 써" 1회 | 3 / 40 — 같은 단어를 또 냈다 |
| 코드가 찾아서 그 단어를 짚어 다시 쓰기 1회 | 0 / 40 (두 차례 모두. 원래 답에 섞였던 2건을 고쳤다) |
"다시 써"만으로는 모델이 같은 문맥에서 같은 단어를 또 냈다. 무엇이 틀렸는지 구체적으로 짚어야 고쳐졌다. 섞이지 않은 답에는 아무것도 하지 않으므로 평소 호출 수는 늘지 않는다.
7.결과: 같은 질문 12개로 비교하면
인사, 기능 질문, 기준가·잔고·전략 목록 조회, 연구 요청, 최적화 결과 질문, 시장조사, 운영 상태까지 질문 12개를 같은 모델로 세 방식에서 돌렸다. 모델은 gpt-5.6-terra이고, 쓰기 툴은 실제로 실행하지 않고 어떤 툴을 골랐는지만 기록했다.
| 툴 정의 전부 | 묶음 + 한국어 설명 | 묶음 + 영어 설명 | |
|---|---|---|---|
| 질문 12개 입력 토큰 합계 | 약 17만~18만 (두 번 잼) | 약 88,000 | 약 53,000 (약 -70%) |
| 인사 한 번 | 약 7,800 | 약 2,500 | 약 1,350 |
| 기대한 툴을 고른 질문 | 12개 중 11~12 | 12개 중 10 | 12개 중 11 |
| 총 소요 시간 (12개) | 약 44초 | 약 50초 | 약 50초 |
빗나간 경우는 둘이었다. "삼성전자 시장조사 해줘"에 새 조사를 접수하기 전에 기존 보고서부터 조회했고, "계좌 잔고랑 포지션"에 계좌 목록 대신 계좌별 현금·보유 구성을 읽는 툴을 골랐다(잔고는 거기에도 있다). 요청을 틀리게 처리한 것은 아니었다. 대신 묶음을 찾는 단계만큼 응답이 10~20% 느려졌다. 입력이 크게 줄어든 대가다.
8.이 결론이 안 통하는 조건
- 툴이 적은 에이전트. 툴이 10개 안팎이면 정의가 작아 지연 로딩의 이득이 작고, 검색 단계의 지연만 늘 수 있다.
- 지연 로딩을 지원하지 않는 모델과 서버. gpt-5.4 이전 모델, Chat Completions만 받는 로컬 서버나 다른 호스팅에서는 쓸 수 없다. 전부 싣는 경로를 함께 둬야 한다.
- 응답 지연이 가장 중요한 경우. 첫 응답까지의 시간이 비용보다 중요하면 자주 쓰는 몇 개는 처음부터 싣는(지연하지 않는) 쪽이 낫다. 두 방식은 한 요청 안에서 섞을 수 있다.
- 한국어 설명 부풀림은 이 시점의 관측이다. 서버가 형식을 바꾸면 달라질 수 있다. 결론은 "영어로 쓰라"보다 "실제 사용량으로 확인하라"에 가깝다.
9.정리
- 인사 한 번의 사용량을 본다. 툴 정의가 입력의 얼마를 차지하는지부터 확인한다.
- 툴이 20개를 넘으면 사용자 질문 단위로 묶고 지연 로딩한다. 지원하지 않는 연결에는 전부 싣는 경로를 남긴다.
- 묶음 설명에는 그 묶음의 툴이 하는 일을 빠짐없이 쓴다. 모든 툴이 정확히 한 묶음에 속하는지 기동할 때 검사한다.
- 남은 토큰은 구성 요소를 하나씩 빼 가며 실제 사용량으로 잰다. 로컬 토크나이저를 믿지 않는다.
- 비 ASCII 설명이 부풀면 그 부분만 영어로 바꾼다. 답의 언어는 시스템 프롬프트가 정한다.
- 모델의 간헐적 오류는 코드로 검출해 구체적으로 짚어 고치게 한다. 지시만으로는 막히지 않는다.
툴을 줄이지 않고도 입력은 줄일 수 있었다. 필요한 것은 무엇이 토큰을 차지하는지 서버의 숫자로 확인하는 습관이었다.
'프로그래밍 > AI' 카테고리의 다른 글
| 로컬 LLM 서버가 살아 있는데 틀리게 돌 때: 스트림 오류·빠진 의존성·설정 차이 (0) | 2026.09.26 |
|---|---|
| 추론 모델 에이전트가 느린 이유는 생각 토큰: thinking을 끄기 전에 확인할 것 (0) | 2026.09.26 |
| 로컬 LLM이 매번 입력을 처음부터 다시 읽을 때: prefix 캐시 확인하는 법 (0) | 2026.09.25 |
| 32GB 맥에서 큰 로컬 LLM 돌릴 때 메모리 나누는 법: GPU 한도와 서버 캐시 (0) | 2026.09.25 |
| 맥에서 로컬 LLM 속도 미리 가늠하기: 메모리 대역폭과 투기적 디코딩 (0) | 2026.09.25 |
댓글