LLM / 에이전트 / 디코딩 / 실측
로컬 LLM으로 코딩 에이전트를 돌리며 배운 디코딩 설정과 가드 설계
집 서버의 로컬 모델로 코딩 에이전트를 돌리면서 생긴 같은 명령 370번 반복, 한국어 답의 한자 섞임, echo로 도구를 흉내 내는 문제를 디코딩 설정과 실행 직전 훅으로 고쳤습니다. 기본값을 어느 층이 정하는지 확인하고, 꼭 지켜야 할 규칙은 지침이 아니라 훅에 둔다는 원칙을 정리합니다.

코딩 에이전트는 모델이 낸 도구 호출을 하네스(harness)가 실행하고 결과를 다시 모델에 넣는 루프입니다. Anthropic의 Claude Code 문서 “Hooks reference”는 도구 실행 직전에 외부 명령을 끼워 호출을 막는 훅을 정의합니다. Qwen 팀은 모델 카드 “Best Practices”에 사고 모드용 샘플링 권장값을 적어 둡니다. 둘 다 모델 앞뒤의 설정입니다.
이 글은 집 서버의 Strata 엔진과 Qwen3.8 Flash-Next로 코딩 에이전트를 돌리며 생긴 세 사건을 다룹니다. 에이전트가 같은 명령을 수백 번 되풀이하고, 한국어 답에 한자를 섞고, 브라우저 도구 대신 echo를 쳤습니다. 세 사건을 디코딩 설정과 실행 직전 가드로 나눠 살펴보겠습니다.
디코딩 설정과 가드
이 글에서 디코딩 설정은 엔진이 다음 토큰을 고르는 방식을 정하는 요청 값 전체로, 샘플링 값·다중 토큰 예측·로짓 편향(logit bias)이 들어갑니다. 가드는 모델이 무엇을 읽었든 실행 직전에 도구 호출을 통과시키거나 거부하는 훅입니다.
하네스 두 개는 하네스 A와 하네스 B로 부릅니다. 측정 환경은 다음과 같습니다.
| 항목 | 값 |
|---|---|
| CPU | Intel i9-12900K (16코어 24스레드) |
| RAM | DDR4 128GB |
| GPU | RTX 3090 24GB |
| 모델 | Qwen3.8 Flash-Next, IQ3_XXS 양자화 GGUF, 약 80GB |
디코딩 설정
샘플링 기본값
샘플링 기본값은 요청에 값이 빠졌을 때 엔진이 쓰는 값입니다.
temperature: 확률 분포를 얼마나 펼지 정합니다. 0이면 매번 확률이 가장 높은 토큰을 고릅니다.top_p·top_k: 후보 토큰을 누적 확률과 개수로 자릅니다.reasoning_effort: 답 전 사고의 양입니다.none이면 끕니다.- 우선순위: 요청에 값이 있으면 그 값이 이기고, 없으면 엔진 기본값이 쓰입니다.
문제는 root 소유 폴더에 sudo 없이 파일을 만들라는, 반드시 실패하는 과제에서 드러났습니다.
- 하네스 A: temperature를 보내지 않았고, 같은 명령을 되풀이하다 시간 초과로 끝났습니다.
- 하네스 B:
temperature: 1.0을 보냈고, 18번 호출, 272초 만에 스스로 멈췄습니다.
하네스 A의 빈자리는 엔진 기본값 temperature 0(결정적 디코딩)이 채웠습니다.
초기 기본값은 엔진 교체 때 앱 동작이 흔들리지 않게 정한 사고 끔과 temperature 0이었습니다. 이후 Qwen 팀의 사고 모드 권장값으로 바꿨습니다.
reasoning_effort: high
temperature: 1.0
top_p: 0.95
top_k: 20
presence_penalty: 0
비용은 사고 모드에서 나왔습니다.
| 같은 짧은 질문 | 응답 시간 | 사고 |
|---|---|---|
reasoning_effort: none |
0.3초 | 끔 |
| 기본값 (high) | 2.2초 | 233자 |
max_tokens가 작은 앱은 사고만 하다 본문이 빌 수 있어 호출 가이드에 none을 적었습니다. temperature 0은 재현성이지 정확성이 아닙니다.
다중 토큰 예측
다중 토큰 예측(Multi-Token Prediction, MTP)은 작은 예측 헤드가 다음 토큰 몇 개를 미리 제안하고 본 모델이 한 번에 검증하는 방식입니다. 샘플링으로 바꾸면 제안이 덜 맞아 느려질 수 있었습니다.
- 제안 개수: 한 번에 내미는 토큰 수로, 많을수록 버리는 비용도 늘어납니다.
- 수락률: 제안 중 본 모델이 받아들인 비율로, 속도 이득을 정합니다.
- 품질: 본 모델이 검증하므로 출력 분포는 그대로입니다.
다음은 9월에 같은 카드에서 다른 엔진과 모델로 잰 값입니다.
| 9월 구성 (Ollama · 27B 밀집 모델 · 컨텍스트 32K) | 값 |
|---|---|
| MTP 끔 | 40.48 tok/s |
| 제안 2개 | 58.25 tok/s |
| 제안 4개 | 46.55 tok/s |
| 수락률, 긴 문서 정리 / 짧은 범용 질문 | 0.78 / 0.56~0.64 |
| 비용 | VRAM +2GB, 병렬 슬롯 2 → 1 |
제안 개수는 2가 가장 빨랐고, 수락률은 설정이 아니라 프롬프트가 정했습니다. 지금 구성에서 샘플링 전후로 잰 값은 다음뿐입니다.
| 지금 구성 (Strata · 125B MoE) | 값 |
|---|---|
| 생성 속도, temperature 0 (전) | 115~135 tok/s |
| 생성 속도, 샘플링 (후) | 122~134 tok/s |
| MTP 수락률 (전후를 합친 범위) | 78~89% |
범위가 겹쳐 손해는 없다고 봤습니다. 근거는 Leviathan 외(Google)의 “Fast Inference from Transformers via Speculative Decoding”과 Gloeckle 외(Meta)의 “Better & Faster Large Language Models via Multi-token Prediction”입니다.
토큰 억제
토큰 억제는 특정 토큰의 점수를 낮춰 뽑힐 확률을 줄이는 일입니다. OpenAI API Reference의 logit_bias가 토큰 id마다 점수에 값을 더하는 형식이며 Strata도 요청 단위로 받습니다.
- 대상 목록: 막을 토큰 id 집합이며 토크나이저마다 다릅니다.
- 세기: 점수에 더하는 값으로 금지와 누르기를 가릅니다.
- 붙이는 곳과 끄는 길: 얹을 층과, 한자가 꼭 필요한 요청을 빼는 방법입니다.
한국어 답 중간에 한자가 한 단어씩 끼었습니다.
캐시된 데이터가 원본 데이터와不一致(일치하지 않게) 되었을 때
이 모델은 드문 한자를 바이트 조각 토큰 여럿으로 만들어, 완성된 한자 토큰만 막으면 뒷길이 남습니다. 그래서 UTF-8 첫 바이트 0xE4~0xE9가 든 토큰과 확장 영역 한자 토큰을 모두 모았고, 한글과 가나는 건드리지 않았습니다.
측정은 개발·일상 질문을 두 바퀴 돌렸고, 조선 과거 제도 질문의 한자 병기는 섞임에서 뺐습니다.
| 방법 | 한자 든 응답 (병기 포함) | 그중 섞임 | 문제 |
|---|---|---|---|
| 없음 | 9 / 60 | 7 / 60 | |
| min_p 0.05 | 4 / 60 | 2 / 60 | 0이 되지 않음 |
| min_p 0.1 | 5 / 60 | 3 / 60 | 0이 되지 않음 |
logit_bias -100 |
0 / 60 | 0 / 60 | 중국어 요청에 빈 답 |
logit_bias -5 |
0 / 60 | 0 / 60 | 의도한 한자까지 막음 |
logit_bias -3 |
0 / 60 | 0 / 60 | 채택 |
초기에는 min_p로 확률 꼬리를 자르려 했지만 섞임이 사라지지 않았습니다. 확률이 아니라 글자가 문제였으므로 로짓 편향으로 바꿨습니다.
- 금지(-100): 중국어로 써 달라는 요청에도 갈 길이 막혀 빈 답이 왔습니다.
- 누르기(-3): 의도한 不一致·大韓民國은 그대로 썼습니다. 다른 선택지가 있을 때만 비켜 갑니다.
이 값은 별칭마다 다르게 할 수 있는 게이트웨이 훅이 얹고, 끄는 길은 다음과 같습니다.
- 호출자 값 존중: 요청에
logit_bias가 있으면 빈{}라도 얹지 않으므로, 번역 같은 기능은 이 한 줄로 끕니다. - 질문에 한자: 마지막 사용자 메시지에 한자가 있으면 건너뜁니다.
- 대상 별칭: 지정한 별칭에만 얹습니다.
- 환경 변수: 코드를 고치지 않고 전체를 끕니다.
부작용으로 애매한 자리에서는 한글을 고르므로, 동아시아 언어로 ’물’을 써 달라면 水 대신 물이 나옵니다.
| 생성 속도 | 평균 | 중앙값 |
|---|---|---|
| 없음 | 103.8 tok/s | 103.9 tok/s |
| -100 | 104.4 tok/s | 104.3 tok/s |
| -3 | 98.4 tok/s | 100.5 tok/s |
목록 크기가 같은 -100이 기준선과 같아 목록을 얹는 비용은 없다고 봤습니다.
실행 직전 가드
디코딩 설정은 인자만 바꿔 같은 벽에 부딪히는 일도, 일을 한 척하는 명령도 막지 못합니다. 두 하네스 모두 Claude Code 형식의 명령 훅을 받아 파이썬 스크립트 하나를 양쪽에 붙였습니다.
실패 서명 세기
이 훅은 도구 결과를 보고 같은 실패가 되풀이되는지 셉니다. 하네스의 반복 감지는 대부분 인자가 같은지를 봐서, 봇 확인 페이지(HTTP 403)에 막힌 뒤 주소만 바꾼 호출을 놓칩니다.
- 실패 서명: 실패를 “종류:어디” 꼴로 줄인 문자열로, 숫자·id·쿼리 문자열은 지우고 경로는 앞 두 칸만 남깁니다.
- 턴 경계: 사용자가 새 말을 하면 카운터를 비웁니다.
- 차단: 질문하기와 할 일 목록 도구만 열어 둡니다.
- 거부 문구: 시도한 것, 막힌 이유, 대안 두 개, 질문으로 정리하라고 적습니다.
http403:example.com
eacces:/usr/local
같은 폴더에 파일 이름만 바꿔 써도 같은 서명이 됩니다. 문턱은 다른 오픈소스 에이전트들의 값을 참고했습니다.
| 조건 | 하는 일 |
|---|---|
| 같은 서명 2번째 | 원인을 한 줄로 적고 방법을 바꾸라고 안내 |
| 같은 서명 3번째 | 차단 |
| 종류와 상관없이 연속 실패 5번 | 차단 |
| 한 턴의 40번째 호출 | 10번 남았다고 안내 |
| 한 턴의 50번째 호출 | 차단 |
권한 없는 파일을 6번 따로 읽게 하자 두 하네스 모두 세 번째 실패에서 차단했습니다. 훅 비용은 인터프리터 기동을 포함해 호출당 약 20ms입니다. 하네스 B는 바꾼 뒤에도 문턱 전에 스스로 멈췄습니다.
도구 흉내 거부
이 규칙은 셸 명령이 일을 한 척만 하는지 봅니다. 대상은 로그인이나 캡차처럼 사람이 화면을 같이 보는 브라우저 도구입니다. 에이전트는 페이지를 열었다고 보고했지만 호출 기록에 브라우저 도구는 없었습니다.
모델은 도구 이름을 셸에 쳐서 command not found를 받거나, echo "실행: 브라우저 이동 https://…"로 할 일을 찍거나, true를 쳤습니다. echo와 true는 항상 성공해 틀렸다는 신호가 오지 않습니다. 원인은 다음과 같습니다.
- 도구 설명의 CLI 문구: 설명 속 “OO CLI”라는 말을 모델이 셸에서 치라는 뜻으로 읽었습니다.
- 지연 로딩: 처음 부르기 전까지 핵심 도구만 목록에 올라, 보이는 것은 설명 속 CLI 이름뿐이었습니다.
- 지침 고정: 이 하네스는 지침을 세션 시작 때 한 번만 읽습니다.
초기에는 지침에 상태 확인 도구를 실제로 불러 보라는 문장을 넣었지만, 문제의 세션은 이 문장을 본 적이 없었습니다. 그래서 같은 훅에 규칙 두 개를 더했습니다.
| 규칙 | 걸리는 것 | 거부 문구의 안내 |
|---|---|---|
| 셸로 도구 흉내 | 도구 이름이나 내부 CLI 이름이 든 셸 명령 | 공간 열기 → 주소 이동 → 페이지 읽기 도구 부르기 |
| 아무것도 하지 않는 셸 | 명령 전체가 true, :, echo, printf뿐이고 리디렉션·파이프·$( )가 없음 |
도구를 부르거나 한 일과 안 한 일을 말하기 |
리디렉션이 있는 echo "..." > 파일은 정상 작업이라 통과합니다. 거부 문구에 다음 행동까지 적는 것은, 안 된다고만 하면 모델이 다른 우회로를 찾기 때문입니다.
설계 원칙
반복과 echo 두 사례 모두 지침의 문장은 지켜지지 않았습니다. 훅에 둔 같은 규칙은 실행 전에 걸렸습니다. 두 경우의 차이는 규칙이 모델의 협조를 거쳐야 하는지에 있습니다. 즉, 지침은 부탁이고 훅은 문입니다.
echo 사례는 보고가 아니라 호출을 하나씩 센 뒤에 드러났습니다. 즉, 세는 것은 보고가 아니라 호출 기록입니다.
temperature 0 사건은 잊힌 엔진 기본값이 하네스가 비운 값을 채운 결과였고, 로짓 편향은 반대로 게이트웨이가 기본으로 얹되 호출자 값이 이기게 했습니다. 즉, 기본값은 어느 층이 정하는지 먼저 확인하고 덮어쓸 길을 함께 둡니다.
결과
세 사건 전후의 값입니다.
| 항목 | 전 | 후 |
|---|---|---|
| 하네스 A 도구 호출 (권한 실패 과제) | 393번 (실패 386번) | 3번 |
| 걸린 시간 | 15분 시간 초과, 최종 답 없음 | 36초 |
| 한자 섞임 | 7 / 60 | 0 / 60 |
| 브라우저 도구 흉내 | 66번 중 브라우저 도구 0번 | 훅 거부 규칙 2개, 규칙 시험 38건 통과 |
한계
측정하지 않았거나 가를 수 없는 것입니다.
- MTP 수락률: 샘플링 전후로 나뉜 수치가 없습니다.
- -3의 속도: 기준선보다 낮은 값을 회차 흔들림으로 봤고, 반복 측정은 하지 않았습니다.
- 도구 흉내 거부: 훅이 거부한 뒤 모델이 실제 도구를 부르는 장면은 실측하지 않았습니다.
- 도구 호출 감소: 별칭, 사고 high, 하네스의 반복 경고 문턱, 훅을 한꺼번에 바꿔서 기여도를 가를 수 없습니다.
- 하네스 B: 원래 샘플링을 하고 있어서 전후 차이를 말할 수 없습니다.
- 한자 섞임: temperature 0 시절에도 섞임이 있었는지는 재지 않았습니다.
마무리
디코딩 설정에서는 엔진 기본값을 사고 모드 권장 샘플링 값으로 바꿨고, 속도 손해는 없었습니다. 한자 섞임은 게이트웨이에서 로짓 편향으로 누르고 끄는 길을 함께 두어 막았습니다.
실행 직전 가드에서는 같은 실패 서명이 되풀이되면 멈추고, 도구를 흉내 내는 셸 명령을 거부하게 했습니다. 지침에만 있던 규칙은 지켜지지 않았고, 훅에 둔 뒤에는 지켜졌습니다.
로컬 모델로 에이전트를 돌릴 때는 하네스가 보내지 않는 값을 어느 층의 기본값이 채우는지, 꼭 지켜야 할 규칙이 지침에만 있지 않은지 확인해보시길 바랍니다.
참고자료
- Anthropic, Claude Code 문서 “Hooks reference” — https://code.claude.com/docs/en/hooks
- Qwen 팀, Qwen3.8-Flash-Next 모델 카드 “Best Practices” — https://huggingface.co/Qwen/Qwen3.8-Flash-Next
- OpenAI, API Reference “Create chat completion”
logit_bias— https://platform.openai.com/docs/api-reference/chat/create - Leviathan·Kalman·Matias (Google), “Fast Inference from Transformers via Speculative Decoding” (2022) — https://arxiv.org/abs/2211.17192
- Gloeckle 외 (Meta), “Better & Faster Large Language Models via Multi-token Prediction” (2024) — https://arxiv.org/abs/2404.19737