
컨텍스트 길이(Context Length)가 부족하면 늘리면 된다고 생각했던 시절이 저에게도 있었습니다
요약
ReAct 루프 기반의 로컬 LLM 에이전트 개발 중 컨텍스트 길이 초과 문제를 해결하는 과정을 다룹니다. 단순히 컨텍스트 길이를 늘리거나 Reasoning 기능을 끄는 대신, 토큰 예산 내에서 동작하도록 설계하는 근본적인 접근법을 제시합니다.
핵심 포인트
- Reasoning ON/OFF는 속도와 정밀도의 트레이드오프를 사용자에게 전가하는 임시방편임
- 컨텍스트 길이를 무작정 늘리기보다 토큰 예산 내에서 동작하는 설계가 중요함
- Gemma 4 12B QAT 모델을 활용한 로컬 에이전트 구현 및 검증 사례 공유
- LangGraph를 이용한 ReAct 루프 구현 시 토큰 상한 처리에 대한 대비 필요
이전에 ReAct 루프(THOUGHT → ACTION → 결과 → ... → DONE)로 작동하는 자체 제작 로컬 LLM 에이전트를 구축했습니다. 이번에는 그 개수(改修)를 진행했던 이야기입니다.
태스크를 길게 돌리다 보면, 설정한 컨텍스트 길이(Context Length, 4096 토큰)를 초과했을 때 LLM의 문장 생성이 글자 수 초과로 인해 중단되는 일이 발생합니다.
이런 일이 5회 정도 연속으로 발생하여, 프로그램상에서 설정한 스텝 상한에 도달해 에러로 종료되었습니다. ReAct 루프는 대화 이력을 매 턴 쌓아가는 구조이므로, 태스크가 길어질수록 확실하게 상한에 가까워집니다. "그럼 초과했을 때 어떻게 처리할 것인가"를 만들어두지 않았던 것이 애초의 발단이었습니다.
처음에 시도한 것은 모델의 Reasoning (Thinking) 기능을 OFF로 하는 것이었습니다. 이것은 효과가 매우 뛰어나서 실행 시간이 눈에 띄게 짧아졌습니다. 하지만 AI 에이전트를 개발하고 있었기에, 백그라운드에서 돌아가는 LLM 측의 설정을 직접 조작하는 것은 옳지 않다고 생각했습니다. 현재의 LLM은 Reasoning이나 Thinking 기능으로 정밀도를 향상시키고 있으며, 태스크의 분해나 검토의 반추에 사용됩니다. 그것을 통째로 잘라내는 것은 개발자 측의 편의를 사용자에게 강요하는 것이 아닐까 하는 생각이 들었습니다.
즉, "Reasoning ON/OFF"로 해결하는 것은 속도와 정밀도의 트레이드오프(Trade-off)를 사용자에게 떠넘기는 것일 뿐, 근본적인 해결책이 아닙니다. 게다가 앞으로 공개할 이상, 사용하는 모델이나 API를 사용자 측에서 자유롭게 선택할 수 있도록 하고 싶습니다. "우리 모델은 Reasoning을 끌 수 없는데요"라는 케이스도 당연히 발생할 것입니다. 토글 하나에 의존하는 설계는 파탄 납니다.
얼마 전 공개된 Gemma 4 12B QAT(Google DeepMind, Quantization-Aware Training 버전)로 검증하고 있었는데, 이것이 전형적인 "Reasoning이 기본적으로 강력하게 작용하는 모델"이었기에 문제가 단번에 현재화되었습니다. 오늘은 이 늪에서 빠져나오기까지의 기록입니다.
결론부터 말씀드리면
- Reasoning ON/OFF 전환은 대증요법이며, 속도와 정밀도의 트레이드오프를 사용자에게 떠넘길 뿐임
- 본질적인 문제는 "토큰 예산을 초과했을 때의 처리를 만들지 않았다"는 것
- "토큰 상한에서 끊기면 다음 내용을 가져온다"는 대책도 구현이 미흡하면 상황을 악화시킴
- 근본적으로는 "Context Length를 늘리는 것"보다 "예산 내에 맞추는 설계"가 더 효과적임
- 덧붙여 LM Studio 설정을 재검토했더니 추론 속도가 8 t/s → 11 t/s로 향상됨
전제
- LangGraph로 ReAct 루프(THOUGHT → ACTION → 결과 → ... → DONE)를 구현한 에이전트
- LM Studio에서
Gemma 4 12B QAT(lmstudio-community/gemma-4-12B-it-QAT-GGUF, Q4_0, 7.4GB)를 로컬 실행- Google DeepMind의 최신 오픈 모델군 「Gemma 4」의 중간 사이즈. Tool use · Vision 입력 · Reasoning을 네이티브 지원
- QAT(Quantization-Aware Training) 버전은 양자화(Quantization)를 전제로 다시 학습되었기 때문에, 동일한 4bit 양자화라도 통상적인 경우보다 품질이 높음
- RTX 4060 (8GB VRAM) + Ryzen 7 5700X3D
- Context Length는 검증을 위해 4096으로 고정 (본 기사의 최종 권장값은 8192. 일부러 엄격한 조건인 채로 돌려서 어떤 함정이 있는지 파악함)
애초에 "Context Length를 늘리면 된다"는 것이 그렇게 단순한 이야기가 아니라는 점이 로컬 LLM의 전제 조건으로 있습니다. Context Length를 늘리면 그만큼 KV 캐시(KV Cache, 대화의 문맥을 유지하기 위한 메모리 영역)가 VRAM을 소비합니다. 8GB VRAM급 GPU라면 Context Length를 늘린 만큼 모델 본체를 GPU에 올릴 수 있는 양(GPU Offload)이 줄어들어, 추론 속도가 떨어지거나 최악의 경우 모델 전체가 VRAM에 들어가지 않게 됩니다.
API 서비스(Claude, GPT 등)를 사용하는 동안에는 거의 의식하지 않는 제약이지만, 로컬에서 구동하는 이상 Context Length는 "늘리면 늘릴수록 좋은" 파라미터가 아니라, VRAM · 추론 속도와의 트레이드오프 속에서 결정해야 하는 것입니다. 이번에 "예산 내에 맞추는 설계"에 집착한 것은, 파고들면 이 물리적인 천장이 처음부터 존재했기 때문이기도 합니다.
1. Reasoning ON/OFF만으로는 해결되지 않는다
가장 먼저 확인한 것은 실제로 토큰을 얼마나 소비하고 있는가였습니다. 응답(Response) JSON을 보면 다음과 같은 필드가 있습니다.
"reasoning_content": "The user wants to know the pros and cons of buying/selling Kioxia stock...",
"completion_tokens_details": {
"reasoning_tokens": 753
...
reasoning_content는 ReAct 루프 측의 THOUGHT와는 별개로, 모델이 내부에서 영어로 끊임없이 자기 대화(Self-dialogue)하듯 사고하는 내용입니다. 한 번의 요청(Request)에서 753토큰이나 Thinking에 녹아버린다면, 컨텍스트 길이(Context Length)의 상한선은 순식간에 채워집니다. ReAct의 THOUGHT와 모델 내부의 Thinking이 이중으로 실행되고 있었고, 후자가 먼저 예산을 잡아먹고 있었던 것입니다.
여기서 단순히 "그럼 Thinking을 끄면 되지"라고 생각한 것이 첫 번째 실패였습니다. 끄면 확실히 빨라지고 토큰 소비도 줄어듭니다. 하지만 복잡한 판단이 수반되는 태스크(모순되는 검색 결과의 선택 등)에서는 명확하게 정밀도(Accuracy)가 떨어졌습니다. ReAct의 THOUGHT만으로는 다 담아낼 수 없는 검토를 모델 내부의 Thinking이 담당하고 있었던 것입니다.
즉, 이것은 "버그를 고치는" 문제가 아니라 "속도와 정밀도의 트레이드오프(Trade-off)를 어디서 감수할 것인가"라는 설계 판단의 문제였습니다. 사용자에게 토글(Toggle)을 넘겨주며 "원하는 쪽을 선택하세요"라고 끝내는 것은 편하겠지만, 그것은 문제를 떠넘기는 것일 뿐 해결하는 것이 아닙니다.
대책: 요청 단위로 Thinking을 제어할 수 있도록 준비해 두기
그렇다고 해도, 전환 기능 자체는 마련해 둘 가치가 있습니다. 사용자가 속도를 우선시하고 싶은 상황도 있기 때문입니다. LM Studio(llama.cpp 기반)의 OpenAI 호환 API는 표준 파라미터 외에 chat_template_kwargs라는 필드를 받아들입니다.
extra_body = {
"chat_template_kwargs": {"enable_thinking": False},
"reasoning_effort": "none",
...
미지원 서버는 이 필드를 단순히 무시하므로, 다른 API(OpenAI 본사나 Bedrock 경유 등)로 교체해도 망가지지 않습니다. 사용자가 원하는 모델을 선택할 수 있는 설계로 가는 이상, 코드 측에서 안전하게 보내둘 필요가 있습니다.
하지만 이것으로 "해결되었다"고는 말할 수 없습니다. 토글은 어디까지나 사용자의 선택지를 늘려주었을 뿐, 토큰 예산을 초과했을 때 시스템이 어떻게 동작할 것인가라는 본질적인 문제는 아직 아무것도 손대지 않은 상태였습니다.
2. 토글만으로는 불충분하다는 것을 알게 되었다
Thinking을 억제하는 필드를 마련한 상태에서 실운영 시 OFF로 설정하고 돌려보았더니, 또다시 같은 에러를 마주했습니다. 로그를 보니 요청 본문(Request Body)에서 chat_template_kwargs가 사라져 있었습니다.
여기서 깨달은 것은 "사용자가 선택한 설정과 관계없이 시스템은 망가지지 않고 동작해야 한다"는 당연한 사실입니다. 특히 사용하는 모델이나 API를 사용자 측에서 자유롭게 선택할 수 있는 구성으로 만들면, 우리가 예상하지 못한 조합은 반드시 나타납니다. "억제 플래그를 보내면 고쳐진다"를 전제로 한 설계는 그 자체로 단일 장애점(Single Point of Failure)이 되어 있었습니다.
대책: Thinking이 감지되면 상한을 완화한다
억제할 수 있는지에 도박을 거는 것이 아니라, LLM으로부터의 응답 내에서 Thinking이 수행되었음을 감지할 수 있다면 MaxToken이나 루프 횟수의 상한을 늘리는 설계로 변경했습니다.
구체적으로는 응답의 completion_tokens_details.reasoning_tokens가 0보다 크면 "이 요청은 Thinking을 사용했다"라고 판정하고, 이후에는 max_tokens를 1500에서 3500으로 높이며, "이제 슬슬 요약해 주세요"라는 수렴 지시를 내리는 타이밍도 앞당깁니다(통상적으로 남은 단계가 3단계일 때, 감지 시에는 5단계 전부터).
토글의 상태를 신뢰하지 않고, 실제로 소비된 토큰을 보고 판단하는 것이 핵심입니다.
3. "다음 내용을 가져오기" 처리를 단순하게 만들면 역효과가 난다
Thinking을 허용한 상태에서도 작동하도록 하기 위해, 「토큰 상한(finish_reason: "length")에서 응답이 끊기면, 다음 내용을 자동으로 가져온다」라는 처리를 넣었습니다. 처음에는 이렇게 작성했습니다.
# 소박한 구현 (잘 작동하지 않음)
current_messages = current_messages + [
{"role": "assistant", "content": piece},
...
]
"끊긴 부분을 더해서 계속해달라고 말하면 되잖아"라는 직관적인 구현입니다. 같은 발상을 한 사람이 많을 것이라 생각하기에, 왜 이것이 잘 작동하지 않는지를 정리해 두겠습니다.
문제는, 계속할 때마다 대화 이력 전체를 재전송하고 있다는 점입니다. 첫 번째 프롬프트가 2500 토큰이라고 가정하면, 두 번째는 「2500 토큰 + 첫 번째 출력」을 보내게 되어, 남은 완료 프레임(completion window)이 점점 좁아집니다. Thinking이 유효한 모델이라면, 계속할 때마다 다시 새롭게 긴 사고(reasoning)가 실행되므로 상황은 더욱 악화됩니다.
로그를 보면, 계속할 때마다 reasoning_content가 "The user is pointing out that my previous response did not follow the required format..."와 같이, 아주 성실하게 상황을 처음부터 다시 생각하고 있었습니다. "계속 써줘"라고 부탁한 의도가, 모델 입장에서는 "또 새로운 태스크가 왔다"라고밖에 보이지 않았던 것입니다. 이래서는 몇 번을 계속해도 끝나지 않습니다.
즉, **"끊기면 이력에 쌓아서 재전송한다"라는 계속 처리 방식 자체가 토큰 예산을 압박하는 안티 패턴(anti-pattern)**입니다. 계속 로직을 직접 만든다면, 처음부터 이것을 피하는 설계로 해두었어야 했습니다.
대책: 계속 요청은 가볍게 만들기
두 번째 이후부터는 원래의 거대한 이력을 보내지 않고, "직전 출력의 끝부분만" 전달하는 가벼운 프롬프트로 바꾸었습니다. 끝부분 몇 글자를 전달할지는, 너무 적으면 문맥이 끊겨 이상하게 이어지고, 너무 많으면 프롬프트 예산을 압박하므로, 대략 400자 정도를 기준으로 삼고 있습니다 (일본어는 1글자가 1 토큰이 되지 않는 경우가 많아, 400자는 모델이나 토크나이저(tokenizer)에 따라 실제로는 500~800 토큰 정도가 됩니다. 어디까지나 대략적인 기준치입니다).
def invoke_with_continuation(llm, messages, max_continuations=3):
full_content = ""
last_response = None
...
프롬프트 크기가 거의 일정해지므로, 계속할 때마다 완료 프레임이 줄어드는 문제가 사라졌습니다. 계속하기 위한 메시지는 원래의 대화와는 별개의 것으로 구성한다는 것이 이번의 교훈입니다. ReAct 루프와 같이 "매 턴 이력을 쌓아 올리는" 설계를 하고 있다면, 계속 처리에도 똑같은 방식으로 이력을 쌓기 쉬우므로 주의가 필요합니다.
4. "글자 수를 제한하면 되지 않을까"를 한 번 시도했다가 철회한 이야기
지금까지의 대책과 병행하여, "애초에 DONE 답변을 800~1200자로 제한하면 예산 안에 들어오기 쉽지 않을까"라는 프롬프트 제약도 시도했습니다.
결과적으로, 이것은 그만두었습니다. 이유는 단순합니다. "심층적으로 다뤄야 할 사항을 나열해줘"와 같은 태스크는 애초에 망라성(comprehensiveness)이 요구됩니다. 글자 수를 강제하면 그 요구를 충족할 수 없게 되고, 후술할 리뷰어(reviewer) 기구가 "정보가 부족하다"며 반려를 반복하는 또 다른 무한 루프를 만들어냈습니다.
대증요법으로서 "짧게 해"라는 명령을 전체에 거는 것이 아니라, "예산이 정말 빠듯할 때만" 조건부로 적용하는 것이 더 타당하다는 판단을 내렸습니다. 지금은 "남은 단계가 적을 때만 간결함을 우선시하라는 지시가 들어가는" 형태로 구성하여, 평상시에는 제약을 걸지 않습니다.
제약은 코드로 묶기보다 동작 요건으로서 문서에 명시하는 것이 더 정직한 방법이라고 생각합니다. README에 "Context Length 8192 이상 권장"이라고 적어두고, 그 미만에서도 작동할 수 있도록 위의 내성책(resilience measures)으로 뒷받침하는 역할 분담을 했습니다.
5. 덤으로 LM Studio 설정도 재검토했다
본론과는 별개로, 추론 속도도 개선했습니다.
Max Concurrent Predictions(병렬 슬롯 수)를 4에서 1로 변경
로그에 다음과 같이 찍혀 있었습니다.
LlamaV4::load config: n_parallel=4 n_ctx=4096 kv_unified=true
혼자서만 사용하는데 4 슬롯 분량의 KV 캐시(KV cache)를 확보하고 있었기에 통째로 낭비였습니다. 1로 줄여서 남는 VRAM을 다른 곳에 할당합니다.
CPU 스레드(Threads)를 1→4로
은근히 잊기 쉬운 설정입니다. GPU가 감당하지 못하는 나머지 처리를 담당하므로, 코어 수에 맞춰 올려둡니다.
이 두 가지를 수정한 것만으로 8 t/s → 11 t/s까지 올라갔습니다. 화려한 개선은 아니지만, 체감되는 대기 시간은 꽤 달라집니다.
요약: 컨텍스트 길이(Context Length)를 늘리기 전에 확인할 것
Thinking 기능의 소비 토큰을 확인한다. reasoning_tokens가 예상보다 크지 않은지
중단되었을 때의 동작을 만든다. 늘리는 것이 아니라, 끊기더라도 망가지지 않는 설계로 만든다
지속 처리(Continuation processing)는 이력을 비대하게 만들지 않는 설계로 한다. "끊기면 추가해서 재전송"하는 방식은 직관적이지만 함정에 빠지기 쉽다
제약을 코드로 묶기 전에, 요구사항으로 명시한다. 무조건적인 제약은 품질을 조용히 깎아먹는다
애초에 컨텍스트 길이(Context Length)는 VRAM과의 트레이드오프(Trade-off)이다. 로컬에서 구동하는 이상, 무한히 늘릴 수 없다는 전제로 설계한다
모두 "내 코드가 미숙했다"는 이야기가 아니라, ReAct 루프형 에이전트(Agent)를 자체 LLM으로 구동하는 사람이라면 구성과 상관없이 한 번쯤은 겪게 되는 종류의 함정이라고 생각합니다. THOUGHT/ACTION을 통해 대화 이력을 쌓아가는 설계는, 그대로 "지속 처리"나 "에러 발생 시의 재시도(Retry) 처리"에도 동일한 습성을 가져오기 쉽기 때문입니다.
특히 Gemma 4와 같이 추론(Reasoning) 기능이 기본적으로 내장된 최근 모델들은, 이 글에서 다룬 "Thinking이 토큰 예산을 조용히 침식하는" 문제에 앞으로 더욱 자주 직면하게 될 것입니다. 로컬에서 구동하는 이상, 이러한 종류의 함정은 "모델이 똑똑해질수록 늘어난다"고 생각하는 편이 좋을지도 모릅니다.
마찬가지로 로컬 LLM으로 에이전트를 구축하고 있는 분들의 디버깅 시간을 줄이는 데 도움이 되기를 바랍니다.
다음에는 이 에이전트 기반(AI Agent Studio, 가칭) 자체의 전체 설계에 대해 쓸 예정입니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기