# AI 에이전트

에이전트 루프는 몇 단계에서 멈춰야 할까?

@Hong2026-10-04

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
로컬 LLM 에이전트 루프의 최대 단계 수는 몇으로 정해야 할까?
답
135번 실행에서 최대 단계에 닿은 과제는 0개였고 성공은 4단계에서 13개가 된 뒤 늘지 않았으니, 최대 단계는 성공한 과제가 쓴 단계 수에 두어 단계 여유를 더해 정한다.
조건
Ollama 0.34.4, qwen3:1.7b(Q4_K_M, CPU), temperature 0, seed 42, macOS Docker Desktop, 과제 45개, 본 실행 3회

환경

실행
macOS(Apple Silicon)의 Docker Desktop, 두 컨테이너 합쳐 메모리 3GB·CPU 2.5개 상한, GPU 없이 CPU 추론
Ollama
ollama/ollama:0.34.4, num_thread 2, num_ctx 4096
모델
qwen3:1.7b(Q4_K_M, 1.4GB), think: false, temperature: 0, seed: 42
클라이언트
python:3.12-slim, httpx==0.28.1, jsonschema==4.26.0
데이터
가짜 사이트 DB(글 60개, 콘텐츠 12개, 저자 8명), 과제 45개
반복
본 실행 3회(135번 실행)
목차
  1. 실습 준비
  2. 실습 1: 단계 수 분포와 멈춘 이유
  3. 실습 2: 최대 단계를 바꾸면
  4. 실습 3: 같은 호출을 되풀이할 때
  5. 정리

이번 시간에는 직접 짠 100줄짜리 에이전트 루프에 도구를 1~4번 이어서 불러야 풀리는 과제 45개를 맡겨 보면서, 최대 단계와 반복 호출 감지가 무엇을 바꾸는지를 측정하며 배워가는 시간입니다.

과제마다 몇 단계에서 멈췄는지, 왜 멈췄는지를 기록하고, 최대 단계와 반복 호출 감지 규칙을 바꿔 볼게요.

실습 준비

이 글의 명령은 모두 실습 코드의 agent-loop-stop/ 디렉터리에서 실행합니다.

  • compose.yaml: ollama와 client 서비스 정의(프로젝트 aloop)
  • requirements.txt: client 컨테이너가 설치하는 패키지 두 개
  • agentlab/: 에이전트 루프·도구·가짜 DB·과제·채점·실행기 패키지(파일 11개)
  • site.db: 가짜 사이트 DB
  • tasks.jsonl: 과제 45개
  • summary.py: 결과 파일 요약(깊이별 성공, 단계 수, 멈춘 이유)
  • repro.py: 세 회차의 호출 목록과 답 비교
  • truncate.py: 1회차 궤적을 k단계에서 잘라 max_steps=k 결과 계산
  • lab3_ids.txt: 실습 2에서 max_steps=4로 실제로 돌릴 과제 id 20개
  • make_faults_d1.py: 오류 주입 설정 파일 생성
  • compare.py: 설정별 결과를 한 줄씩 비교

compose.yaml은 ollama와 client 두 서비스를 정의하고, 서비스마다 mem_limit과 cpus를 걸어 둬요.

  • ollama: ollama/ollama:0.34.4입니다. OLLAMA_NUM_PARALLEL=1로 요청을 한 번에 하나만 처리하고, OLLAMA_KEEP_ALIVE=-1로 모델을 메모리에서 내리지 않아요.
  • LLAMA_ARG_CACHE_RAM: 이 버전은 CPU에서 모델을 llama-server 프로세스로 띄우는데, 처리한 프롬프트를 쌓아 두는 캐시의 기본 상한이 8192MiB라서 컨테이너 메모리 상한을 넘을 수 있어요. 그래서 256MiB로 낮췄습니다.
  • client: 과제를 보내고 채점하는 Python 컨테이너예요. python:3.12-slim에 httpx==0.28.1, jsonschema==4.26.0 두 패키지만 설치합니다.

compose 파일에 name: aloop이 있어서 프로젝트 이름은 따로 주지 않아요. 클라이언트 컨테이너에서 Python을 실행하는 함수 두 개를 정의해 둘게요. agent는 에이전트 루프 실행기를 부르는 함수예요.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

docker compose up -d
docker compose exec ollama ollama pull qwen3:1.7b

lab() { docker compose exec -T client python "$@"; }

agent() { lab -m agentlab.run --tasks tasks.jsonl --tool-examples "$@"; }

모든 호출은 temperature: 0, seed: 42로 했어요. 같은 입력에 같은 출력이 나오게 하려는 설정이에요. 실제로 같았는지는 실습 1에서 확인할게요.

site.db는 이 사이트처럼 보이는 가짜 데이터(글 60개, 콘텐츠 12개, 저자 8명)를 담은 SQLite 파일이에요. 에이전트에게는 도구 다섯 개를 줍니다.

  • search_articles(query, tag?, limit?): 제목이나 태그에 검색어가 들어간 글의 slug와 제목 목록
  • get_article(slug): 글 하나의 제목, 태그, 저자 id, 연결된 콘텐츠 course_slug, 발행일
  • get_like_count(slug): 글 하나의 좋아요 수
  • get_course(course_slug): 콘텐츠 하나의 제목, 가격, 수강생 수
  • get_author(author_id): 저자 이름과 그 저자가 쓴 글 slug 목록

과제는 서로 의존하는 도구 호출을 몇 번 이어야 풀리는지(깊이)로 나눴어요. 앞 호출의 결과에 있는 값을 다음 호출의 인자로 써야 합니다.

  • 깊이 1: "slug terraform-22 글의 좋아요 수는?" get_like_count 한 번
  • 깊이 2: "제목이 「…」인 글의 좋아요 수는?" search_articles로 slug를 찾고 get_like_count
  • 깊이 3: 제목으로 찾은 글에 연결된 콘텐츠의 수강생 수. search_articles, get_article로 course_slug, get_course
  • 깊이 4: 태그 Redis가 붙은 글 가운데 좋아요가 가장 많은 글에 연결된 콘텐츠의 가격. 태그로 찾고, 후보마다 get_like_count, 가장 많은 글의 get_article, get_course

깊이별 10개씩 40개에, 이 네 형태에 맞지 않는 질문 5개(두 글의 좋아요 차이, 저자 이름 등)를 더했어요. 이 글은 네 형태를 템플릿이라 부르고, 40개를 템플릿 안, 5개를 템플릿 밖 과제라고 할게요. 과제 id는 T<깊이>-<번호>(예: T3-02는 깊이 3의 두 번째 과제)이고, 템플릿 밖 과제는 T0-<번호>예요.

정답은 DB에서 계산한 정수나 slug라서 사람 판단 없이 채점해요. 모델의 마지막 줄 답: <값>에서 값을 꺼내 정답과 비교하고, 형식은 어겼지만 정답 값이 본문에 들어 있는 경우는 loose로 따로 세요.

루프 본체는 agentlab/loop.py의 run_agent예요. 한 단계는 LLM 호출 한 번이고, 응답에 도구 호출이 있으면 모두 차례로 실행해 결과를 tool 메시지로 붙인 뒤 다음 단계로 가요.

도구 호출 없이 글이 오면 answer로, max_steps번 불렀는데 마지막 응답도 도구를 부르면 max_steps로 끝나요. 시스템 프롬프트에는 몇 단계 안에 끝내라는 말은 넣지 않았어요. 최대 단계의 효과를 재야 하기 때문이에요.

너는 기술 아티클 사이트의 운영 도우미다. 질문에 답하려면 도구를 불러 값을 확인하고, 도구 결과에 나온 값만 근거로 답한다. 필요한 값을 모두 얻었으면 도구를 더 부르지 말고 답한다. 답의 마지막 줄에는 `답: <값>` 형식으로 값만 쓴다. 도구 결과의 slug 값을 다음 도구 인자로 그대로 쓴다.

실습 1: 단계 수 분포와 멈춘 이유

45개 과제를 max_steps=10, 반복 감지 끔으로 돌렸어요. 결과 파일은 summary.py로 요약합니다.

출력의 depth1~depth4는 깊이별, oot는 템플릿 밖 5개, in-template은 템플릿 안 40개의 합계예요. 깊이별 성공 수와 성공한 과제의 단계 수 분포, 멈춘 이유별 개수를 세고, 그 아래 줄에서 반복 호출 수와 과제당 평균 토큰, 시간을 찍어 줍니다.

agent --config main
lab summary.py results/main.jsonl 1
results/main.jsonl run=1 tasks=45
  oot     success 1/5  loose 1  steps min 2 / median 2 / max 2
  depth1  success 10/10  loose 0  steps min 2 / median 2 / max 2
  depth2  success 0/10  loose 0  steps -
  depth3  success 0/10  loose 0  steps -
  depth4  success 2/10  loose 0  steps min 4 / median 4 / max 4
  in-template success 12/40
  stop_reason: {'answer': 37, 'format_fail': 8}
  repeated calls: after ok 0, after error 13 (tasks with a repeat 5)
  replies with 2+ tool calls: 15
  prompt_tokens per task: mean 1943
  cached_tokens per task: mean 1767
  eval_tokens per task: mean 95
  wall per task: mean 14.7s (min 5.2, max 76.2), total 11.1 min
  max prompt tokens in one call: 1151
  • 깊이 1은 10개 모두 2단계(도구 호출 1번, 답 1번)에 풀었어요. 깊이 d 과제는 최소 d+1단계가 필요하니 가장 짧은 경로예요.
  • 깊이 2와 3은 하나도 풀지 못했어요. 깊이 4는 2개를 4단계에 풀었어요.
  • 멈춘 이유는 answer 37개, format_fail 8개였어요. max_steps에 닿은 과제는 하나도 없었습니다.

format_fail은 모델이 도구 호출을 본문 글자로 쓴 경우예요. 이걸 answer로 처리하면 JSON 덩어리가 최종 답이 되니까, 따로 format_fail로 끊었습니다.

같은 설정으로 두 번 더 돌려서 세 번의 결과를 비교했어요. repro.py는 과제마다 회차별로 (도구 호출 목록, 최종 답, 정답 여부)를 모으고, 세 회차의 호출 목록과 답이 모두 같은 과제와 하나라도 다른 과제를 나눠요.

agent --config main --runs 3
lab repro.py results/main.jsonl
runs [1, 2, 3]: tasks in all runs 45, identical calls and answer 29, different 16 ['T0-01', 'T0-03', 'T0-04', 'T2-01', 'T2-02', 'T2-03', 'T2-06', 'T2-07', 'T2-08', 'T3-02', 'T3-03', 'T3-05', 'T3-09', 'T4-04', 'T4-08', 'T4-10']
success in all runs 13, in at least one run 15
  run 1: success 13/45
  run 2: success 15/45
  run 3: success 15/45

temperature 0, seed 고정인데도 이 환경은 결정적이지 않았어요. 45개 중 16개에서 도구 호출 순서나 최종 답이 회차마다 달랐습니다.

그래서 이후 비교에서는 성공 수가 2개 이하로 차이 나면 "구분하기 어렵다"고 볼게요. 성공 수 자체도 회차에 따라 13개와 15개로 2개 차이가 났기 때문이에요.

그런데 135번 실행에서 max_steps로 끝난 과제가 하나도 없었어요. 이 모델은 루프를 오래 돌기 전에 스스로 멈춰 버렸어요. 깊이 2, 3 실패 과제 대부분은 2~3단계 만에 근거 없는 값이나 오류 문구를 답으로 쓰고 끝났습니다.

실습 2: 최대 단계를 바꾸면

이 루프는 최대 단계에 닿으면 답 없이 끝나요. 그러니 1회차 궤적을 k단계에서 자르면 max_steps=k로 돌렸을 때의 결과를 계산할 수 있어요.

truncate.py가 그 계산이에요. k단계 안에 정답으로 끝난 과제만 성공으로 치고, 앞 k단계의 토큰과 시간을 더합니다. 두 번째 인자로 실제 max_steps=4 실행 결과를 주면, 자른 궤적과 호출 목록, 정답 여부가 같은지도 비교해 줘요.

lab3_ids.txt에는 깊이 3, 4 과제 id 20개가 한 줄에 하나씩 들어 있어요.

agent --config check4 --max-steps 4 --only @lab3_ids.txt
lab truncate.py results/main.jsonl results/check4.jsonl
max_steps  success  failed: prompt tokens (all)  new+generated tokens  LLM time
        2   11/45                        50824                  8105     7.5 min
        3   11/45                        62479                  9813     8.9 min
        4   13/45                        60448                  9415     8.7 min
        6   13/45                        62430                  9649     8.9 min
        8   13/45                        64668                  9866     9.1 min
       10   13/45                        64668                  9866     9.1 min
max_steps=4 real run vs cut: same 16/20  different ['T3-02', 'T4-04', 'T4-08', 'T4-10']
  • 성공 수는 4단계에서 13개가 되고, 그 뒤로는 늘지 않아요. 깊이 4의 4단계 성공 2개가 들어오는 지점이에요.
  • 4단계를 넘기면 실패 과제가 쓰는 토큰과 시간만 조금씩 늘어나요. 실패 과제 대부분이 일찍 멈췄기 때문에 늘어나는 양은 크지 않았어요.
  • 깊이 3, 4 과제 20개를 max_steps=4로 실제로 돌린 결과는 20개 중 16개가 같았고, 다른 4개는 모두 실습 1의 3회 반복에서 회차마다 달랐던 과제였어요. 최대 단계 탓이 아니라 실행마다 흔들린 결과예요.

최대 단계를 정하는 기준은 성공한 과제가 실제로 쓴 단계 수예요. 이 실습에서는 성공한 과제가 모두 2단계 아니면 4단계였어요. 과제가 요구하는 깊이보다 두어 단계 여유를 두면 충분하고, 그 이상은 실패 과제의 비용만 늘립니다.

프레임워크 기본값은 이런 기준으로 정해져 있지 않을 수 있어요. LangGraph는 1.0.6부터 recursion_limit 기본값을 1000단계로 두고, 넘으면 GraphRecursionError를 내요. 쓰는 프레임워크의 기본값을 확인하고, 과제에 맞는 값으로 직접 정해 두시는 편이 나아요.

실습 3: 같은 호출을 되풀이할 때

반복 판정은 도구 이름과, 키를 정렬하고 문자열 앞뒤 공백을 지운 인자 JSON이 같은지로 해요. 같은 도구를 같은 인자로 다시 부르는 것을 감지하는 규칙 두 가지를 비교할게요.

  • stop: 반복 호출이 나오면 루프를 끝내요(repeat_stop).
  • strict: stop과 같지만 아래 예외를 두지 않아요.

stop은 바로 앞의 같은 호출이 오류였으면 한 번은 다시 부르게 둬요. 왜 그럴까요? 일시적 오류 뒤의 재시도는 정당한 반복이기 때문이에요. 이 예외를 쓴 뒤 또 반복하거나, strict에서 오류 뒤 재시도를 막은 경우를 "막힌 재시도"로 셉니다.

실습 1에서 반복은 13번 모두 오류 뒤에 같은 호출을 다시 부른 거였어요. 그래서 모델이 원래 다 푸는 깊이 1 과제 10개의 get_like_count에 첫 호출만 실패하는 일시적 오류(error_once)를 넣어, strict가 정당한 재시도까지 막는지 봤어요.

도구 오류는 무엇이 잘못됐고 어떻게 고치면 되는지 적은 문장으로 돌려줘요. 일시적 오류는 "인자는 올바르니 같은 인자로 다시 호출하세요"라고 씁니다.

make_faults_d1.py가 오류 주입 설정 faults_once_d1.json을 만들고, compare.py가 설정별 결과를 한 줄씩 나란히 놓아요. blocked retry 열이 막힌 재시도 수이고, d1_once_detail은 규칙을 끈 실행이에요.

lab make_faults_d1.py
agent --config d1_once_detail --faults faults_once_d1.json
agent --config d1_once_rg_stop --repeat-guard stop --faults faults_once_d1.json
agent --config d1_once_rg_strict --repeat-guard strict --faults faults_once_d1.json
lab compare.py results/d1_once_detail.jsonl results/d1_once_rg_stop.jsonl results/d1_once_rg_strict.jsonl
config            tasks  success  repeats(ok/err)  blocked retry  steps  prompt  cached  eval  wall(s)  stop reasons
d1_once_detail       10        2        0/2                    0   2.20    1597    1485    55      9.8  {'answer': 10}
d1_once_rg_stop      10        2        0/2                    0   2.20    1597    1485    55      6.5  {'answer': 10}
d1_once_rg_strict    10        0        0/2                    2   2.00    1433    1335    54     10.4  {'answer': 8, 'repeat_stop': 2}

규칙을 끈 실행과 stop에서는 모델이 오류 뒤에 같은 인자로 다시 불러 회복한 과제가 2개였어요. strict는 바로 그 두 번의 재시도를 반복으로 보고 루프를 끝냈고, 성공은 0개가 됐습니다.

그러니 반복 감지 규칙은 "오류 뒤 첫 재시도"를 예외로 둬야 해요.

정리

범위: Ollama 0.34.4, qwen3:1.7b(Q4_K_M, CPU), temperature 0, seed 42, 과제 45개(템플릿 안 40개, 템플릿 밖 5개), 본 실행 3회.

  • 135번 실행에서 max_steps에 닿은 과제는 0개였고, 1회차 궤적을 잘라 보면 성공은 4단계에서 13개가 된 뒤 늘지 않았습니다. 최대 단계는 성공한 과제가 실제로 쓴 단계 수(이 실습에서 2와 4)에 두어 단계 여유를 더해 정하세요.
  • temperature 0, seed 고정에서도 45개 중 16개의 도구 호출이나 답이 회차마다 달랐고, 성공 수는 13개와 15개로 갈렸습니다. 같은 조건을 여러 번 돌려 흔들림 크기를 먼저 재고, 그보다 작은 차이는 결론으로 쓰지 마세요.
  • 1회차의 반복 호출 13번은 모두 오류 뒤에 같은 호출을 다시 부른 것이었고, strict는 깊이 1 과제에서 오류 뒤 재시도로 회복한 2개를 막아 성공을 0개로 만들었으니, 반복 감지 규칙은 오류 뒤 첫 재시도를 예외로 두세요.
내 링크 만들기