DevTech
2026년 8월 6일 · 6 min read

위임 도구가 멈춘 자리를 찾는 데 여섯 번이 걸렸습니다

로컬 모델에 프로세스 문서 초안을 맡겼습니다. 여섯 번 실패했습니다.

매번 증상이 달랐습니다. 항목을 빠뜨린 문서가 나왔고, 문서 대신 작업 보고문이 나왔고, 아무것도 안 나온 채 매달렸습니다. 증상이 다르니 원인도 다르다고 봤고, 그래서 매번 다른 곳을 고쳤습니다. 세 번 잘못 짚었습니다.

실제 원인은 두 개였고 둘 다 제가 고치던 곳에 없었습니다.

1차: 항목이 빠진 문서

첫 시도는 문서 형태로 나왔습니다. 그런데 지정한 항목 하나가 통째로 없었습니다. 어미가 섞였고, 제가 쓴 "자기서술"이 "자기 합리화"로 바뀌어 있었습니다. 물려받는 것과 합리화하는 것은 다른 주장입니다.

명세를 산문으로 나열한 게 문제라고 봤습니다. 무엇이 필수인지 표시가 없으니 모델이 취사선택할 여지가 생겼다고요.

그래서 항목을 번호로 구조화하고 "이 절에 아래 다섯 가지가 모두 들어간다"를 붙였습니다.

여기서 부수적으로 알게 된 게 하나 있습니다. 반려 명령은 스레드를 반려 상태로 종료할 뿐 재초안을 만들지 않습니다. 재초안은 새 스레드로 다시 호출해야 합니다.

3차: 문서 대신 작업 보고문

구조화한 명세로 돌렸더니 이번엔 완료됐습니다. 산출물 전체가 681바이트였습니다.

문서는 프론트매터에 status: draft를 포함하고, 모든 프로젝트 관련 정보를
일반화된 표현으로 대체했으며, 파일명은 영어 소문자 하이픈 형식으로
작성되었습니다.

문서가 아니라 문서에 대한 설명입니다.

명세를 지시형으로 정밀하게 만들수록 모델이 내용을 쓰는 대신 지시 준수 여부를 보고하는 쪽으로 갔습니다. "이 절에 아래 다섯 가지가 모두 들어간다"가 체크리스트로 읽힌 겁니다.

그래서 명세를 다시 바꿨습니다. 지시 나열이 아니라 채워 넣을 골격으로요. 제목과 소제목을 완성 형태로 주고 괄호 안 요점만 문장으로 바꾸게 했습니다.

4차: 아무것도 안 나옴

골격 명세로 돌렸더니 3분이 지나도 출력이 0바이트였습니다.

여기서 저는 이렇게 보고했습니다. "정상 생성 중입니다."

근거로 삼은 게 두 가지였습니다.

  • 모델 조회 결과에 모델이 적재돼 있음
  • 프로세스가 살아 있음

둘 다 생성 중이라는 증거가 아닙니다. 모델은 keep-alive로 5분간 메모리에 남습니다. 프로세스는 대기 상태에서도 살아 있습니다. 저는 "돌고 있음"의 증거가 아니라 "죽지 않았음"의 증거를 보고 진행 중이라고 판단했습니다.

같은 보고를 두 번 했습니다.

지표로 다시 재기

체감을 근거로 쓰지 않기로 하고 세 가지를 쟀습니다.

CPU 시간 델타

ps%CPU 는 프로세스 생애 평균입니다. 3분 살아 있던 프로세스가 1분만 계산했으면 33% 근처로 보이고, 그 값으로는 지금 계산 중인지 알 수 없습니다.

봐야 할 것은 누적 CPU 시간의 증가분입니다.

snap() { ps -eo pid,time,comm | grep -E "ollama|opencode"; }
snap > c1.txt; sleep 10; snap > c2.txt

10초 간격 결과입니다.

  pid  42323 opencode             +0.09s
  pid  96976 ollama               +0.00s

서버는 10초 동안 0초를 썼습니다. 계산하고 있지 않습니다.

소켓 상태

요청이 실제로 붙어 있는지는 연결 상태로 봅니다.

lsof -nP -iTCP:11434
ollama  127.0.0.1:11434 (LISTEN)
ollama  127.0.0.1:11434->127.0.0.1:62049 (CLOSED)
ollama  127.0.0.1:11434->127.0.0.1:62441 (CLOSED)

ESTABLISHED가 하나도 없습니다. 진행 중인 요청이 없다는 뜻입니다.

서버 접근 로그

가장 결정적이었습니다.

[GIN] 2026/08/06 - 00:17:09 | 200 | 31.199045s | POST "/v1/chat/completions"

서버는 31초 만에 200으로 응답을 끝냈습니다. 그 뒤 로그에는 제가 친 조회밖에 없습니다.

생성은 진작 끝났고, 응답을 받은 클라이언트가 종료하지 않고 매달려 있었던 겁니다. 모델이 느린 게 아니었습니다.

5차: 증상이 또 다름

같은 명세로 다시 돌렸더니 이번엔 서버 접근 로그에 응답 기록 자체가 없었습니다. 4차는 "응답 받고 안 끝남"이었는데 5차는 "요청을 보내지도 못함"입니다.

증상이 다르니 원인도 다릅니다. 여기서 명세를 더 고치는 건 의미가 없다고 판단하고 범위를 갈랐습니다.

최소 프롬프트로 범위 좁히기

명세 문제인지 도구 문제인지 가르기 위해 한 문장짜리로 직접 호출했습니다.

opencode run --agent vance "한 문장으로 답한다. 1+1은?"
exit=124 (120초 타임아웃), 출력 0바이트

명세도 체인도 아닙니다. 한 문장으로도 매달립니다.

원인 1: 배정된 모델이 추론 모델

서버에 직접 물어봤습니다.

curl -s http://127.0.0.1:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen3:30b","messages":[{"role":"user","content":"1+1은?"}],"max_tokens":50}'
"content": "",
"reasoning": "Okay, the user is asking \"1+1은?\" which means..."

content 가 비어 있고 출력이 전부 reasoning 으로 갔습니다. 클라이언트는 content 를 읽습니다.

세 가지를 대조했습니다.

호출 http content reasoning
qwen3:30b 200 / 3.3s 0자 705자
qwen3:30b + /no_think 200 / 4.5s 0자 705자
qwen3-coder:30b 200 / 7.2s 10자 0자

/no_think 지시는 OpenAI 호환 엔드포인트에서 먹지 않았습니다. 프롬프트에 넣어도 출력은 그대로 reasoning 으로 갑니다.

이걸로 초기 실패가 설명됩니다.

시도 증상 해석
1차 문서가 나옴(항목 누락) 추론이 끝나 content 가 나옴
3차 681바이트 메타 서술 추론 꼬리가 content 로 샘

명세를 정교하게 만들수록 추론이 길어져 악화되는 구조였습니다. 1차에서 3차까지 제가 고친 건 명세뿐이었고, 그건 원인을 빗겨간 대응이었습니다. 고칠수록 나빠지는 방향으로 밀고 있었던 셈입니다.

원인 2: 모델과 무관하게 멈춤

모델을 바꿔도 안 됐습니다.

opencode run --agent vance --model ollama/qwen3-coder:30b "1+1은?"   # 120초 타임아웃
opencode run --agent forge "1+1은?"                                  # 120초 타임아웃
opencode run "2+2는?"                                                # 120초 타임아웃

에이전트를 바꿔도, 아예 지정하지 않아도 매달립니다. 서버 직접 호출은 3~7초에 되니 서버 문제가 아닙니다.

클라이언트 로그는 매번 같은 자리에서 끊깁니다.

init
(정확히 60초 뒤) cleanup prune=7.days
(이후 없음)

모델 호출 로그가 아예 없습니다. 네트워크 이전 단계에서 멈춥니다.

확인한 것들입니다.

  • 잔존 프로세스: 없음
  • 내부 SQLite 잠금: 없음(쓰기 가능), 무결성 ok
  • 설정 파일: 모델명·주소 유효, 모델 전부 존재
  • 버전: 1.18.5

설정을 고쳐도, WAL을 체크포인트해도 그대로였습니다.

답은 stdin이었습니다

TUI 계열 도구가 흔히 걸리는 자리를 떠올렸습니다.

opencode run --agent vance "한 문장으로 답한다. 1+1은?" < /dev/null
exit=0 소요 28초 출력 56바이트
> vance · qwen3-coder:30b
1+1은 2입니다.

stdin을 닫으니 28초 만에 끝났습니다.

호출하는 쪽 코드는 이랬습니다.

proc = subprocess.run(
    ["opencode", "run", "--agent", agent, prompt],
    capture_output=True,
    text=True,
    timeout=1800,
)

capture_output=True 는 stdout과 stderr만 잡습니다. stdin은 부모 것을 그대로 물려받습니다. 헤드리스로 부르는데 입력 스트림이 열려 있으니, 도구는 입력이 올 거라 보고 기다립니다.

한 줄입니다.

stdin=subprocess.DEVNULL,

수정 후 체인 호출은 26초에 정상 종료했습니다. 그전엔 30분 타임아웃까지 매달리던 호출입니다.

왜 여섯 번이 걸렸나

증상이 매번 달랐다는 게 함정이었습니다. 다른 증상을 보고 다른 원인을 가정했고, 매번 그럴듯한 가설이 나왔습니다. 항목 누락은 명세 구조 문제로, 메타 서술은 지시형 문장 문제로 설명됐습니다. 둘 다 그 자체로는 말이 됩니다.

실제로는 원인 하나가 여러 증상을 만들고 있었습니다. 추론 모델의 응답 길이에 따라 어떤 날은 문서가 나오고 어떤 날은 꼬리만 나오고 어떤 날은 아무것도 안 나옵니다. 같은 원인의 세 얼굴을 보고 세 개의 원인을 가정한 겁니다.

그리고 원인 2는 처음부터 거기 있었는데, 증상이 원인 1과 구분되지 않아 가려져 있었습니다.

남는 것

진행 중인지 아닌지를 판단하는 지표를 미리 정해두는 게 낫습니다. 급할 때 고르면 눈에 보이는 것부터 집게 되고, 눈에 보이는 건 대개 "죽지 않았음"입니다.

보면 안 되는 것
프로세스 생존 대기 중에도 살아 있음
모델 적재 여부 keep-alive로 남아 있을 뿐
ps%CPU 생애 평균이라 순간 상태를 못 봄
봐야 할 것 무엇을 알려주나
CPU 시간 델타 지금 계산하고 있는가
소켓 ESTABLISHED 요청이 붙어 있는가
서버 접근 로그 이미 끝났는가, 시작도 안 했는가

세 번째가 이번에 답을 줬습니다. 200과 소요시간이 찍혀 있으면 그 시점 이후의 대기는 전부 클라이언트 쪽 문제입니다.

그리고 하나 더. 헤드리스로 외부 프로세스를 부를 때는 stdin을 명시적으로 닫습니다. 파이썬 subprocesscapture_output=True 는 stdin을 건드리지 않습니다. 이 한 줄이 없어서 오늘 두 시간을 썼습니다.

참고

Sponsored

댓글