도구를 여럿 쥐여주고, 루프가 폭주하지 않게 — 예산과 병렬
2026-03-29
1편에서 에이전트가 while 루프 하나임을 봤습니다. 그런데 그 루프엔 구멍이 둘 있었어요.
- 도구가 하나뿐이었습니다(calculator). 진짜 에이전트는 여러 도구 중에서 골라 씁니다.
- 종료 조건이 "모델이
tool_use를 안 할 때"뿐이었습니다. 모델이 도구를 끝없이 부르면 루프가 안 끝나요 — 무한 루프이자 비용 폭탄이죠.
이번 편에 둘을 메웁니다. 규칙입니다.
규칙 — 도구가 여럿이면 모델이 고르게 두고, 루프엔 반드시 예산(상한)을 둬라.
도구를 여럿 쥐여주고, 그걸 병렬로 실행하고, 루프가 폭주하지 않게 막습니다. 언어는 여전히 Go, 그 동시성이 여기서 값을 합니다.
💻 코드는 github.com/kahnco/agent-from-scratch에 이어집니다.
go test -race로 병렬 실행에 데이터 레이스가 없음까지 검증합니다.
도구를 여럿 등록한다 — 모델이 고른다
1편의 에이전트는 이미 도구를 맵으로 들고 있었습니다. 그러니 도구를 늘리는 건 맵에 더 넣는 것뿐이에요.
Tools: map[string]agent.LocalTool{
"calculator": agent.CalculatorTool(), // 정확한 산술
"word_count": agent.WordCountTool(), // 단어 수
"current_time": agent.CurrentTimeTool(time.Now), // 현재 시각
},
여기서 중요한 건 — 어느 도구를 부를지는 우리가 if로 분기하는 게 아니라, 모델이 정한다는 겁니다. 1편에서 도구마다 붙인 설명(Description)이 이때 일합니다. 모델은 세 도구의 설명을 읽고, "지금 몇 시야?"엔 current_time을, "이 문장 단어 수?"엔 word_count를, "12345 곱하기 6789?"엔 calculator를 스스로 고르죠.
특히 current_time은 좋은 예입니다 — 모델은 현재 시각을 원리상 알 수 없어요(학습 시점에 멈춰 있으니). 그래서 시각이 필요하면 반드시 이 도구를 불러야 합니다. 도구를 주입받게 짜 둔 것도 그래서예요(테스트에서 시간을 고정하려고).
func CurrentTimeTool(now func() time.Time) LocalTool {
return LocalTool{
Description: "현재 시각을 RFC3339 형식으로 돌려준다. 모델은 지금 시각을 모르니 이 도구를 써야 한다.",
Schema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
Run: func(_ json.RawMessage) (string, error) {
return now().Format(time.RFC3339), nil
},
}
}
라우팅 품질은 설명의 품질이 좌우합니다. 도구 설명이 모호하면 모델이 엉뚱한 도구를 부르거나 안 부르죠. 그래서 도구 설명은 사실상 프롬프트 엔지니어링이에요 — "언제 이걸 써야 하는지"를 또렷하게 적을수록 라우팅이 정확해집니다.
규칙 회수 — 도구를 맵에 여럿 등록하면, 어느 걸 부를지는 모델이 설명을 읽고 정한다. 라우팅 품질은 도구 설명의 품질이다.
병렬 실행 — Go의 동시성이 빛나는 곳
한 응답에 tool_use 블록이 여러 개 올 수 있습니다. 모델이 "계산기도 쓰고 단어 수도 세줘" 같은 요청에 도구를 동시에 여러 개 부르는 거죠. 1편에선 이걸 순서대로 실행했는데, 서로 독립적인 도구라면 병렬로 돌리는 게 낫습니다. 여기서 Go의 고루틴이 값을 해요.
// 한 응답 안의 tool_use 블록들을 고루틴으로 동시에 실행한다.
// 각 고루틴은 자기 인덱스에만 써서 경쟁이 없고, 결과 순서는 호출 순서와 같다.
func (a *Agent) runToolsParallel(content []ContentBlock) []ContentBlock {
var calls []ContentBlock
for _, b := range content {
if b.Type == "tool_use" {
calls = append(calls, b)
}
}
results := make([]ContentBlock, len(calls))
var wg sync.WaitGroup
for i, call := range calls {
wg.Add(1)
go func(i int, call ContentBlock) {
defer wg.Done()
results[i] = a.runTool(call)
}(i, call)
}
wg.Wait()
return results
}
핵심 두 가지입니다. 첫째, 각 고루틴이 results[i]의 자기 칸에만 씁니다 — 서로 다른 인덱스라 락 없이도 데이터 레이스가 없어요(공유 슬라이스지만 겹쳐 쓰지 않으니). 둘째, results를 미리 크기만큼 할당해 두어 결과 순서가 호출 순서와 같습니다(append로 모으면 순서가 뒤죽박죽 됐겠죠). 그리고 이 결과들은 1편 그대로 한 user 메시지에 담아 되돌립니다 — 나눠 보내면 모델이 "병렬 호출을 그만두라"는 신호로 오해하거든요.
"레이스가 없다"는 건 말로 하는 게 아니라 증명합니다. go test -race로 돌리면 경합 감지기가 병렬 실행을 감시하고, 통과하면 겹쳐 쓰는 곳이 없다는 뜻이에요.
규칙 회수 — 독립적인 도구 호출은 고루틴으로 병렬 실행하되, 각자 자기 인덱스에만 써 레이스를 없애고 순서를 보존한다. 결과는 한 메시지에.
무한 루프의 위험 — 종료 조건이 하나뿐이었다
이제 더 무서운 구멍입니다. 1편의 루프는 이렇게 끝났어요.
if resp.StopReason != "tool_use" {
return collectText(resp.Content), nil // 유일한 탈출구
}
탈출구가 "모델이 도구를 안 부를 때" 딱 하나입니다. 그런데 모델이 — 버그로든, 프롬프트가 꼬였든, 도구가 계속 실패해 재시도하든 — 끝없이 tool_use를 하면 이 루프는 영영 안 끝납니다. 매 바퀴가 API 호출이니, 무한 루프는 곧 무한 청구서예요. 로컬 함수 무한 루프는 CPU만 태우지만, 에이전트 무한 루프는 돈을 태웁니다.
그래서 루프엔 **반드시 예산(상한)**이 있어야 합니다. 가장 단순한 예산은 "몇 스텝까지 돌 것인가"예요.
for step := 0; ; step++ {
// 예산 초과 — 모델이 도구를 끝없이 부르면 여기서 멈춘다.
if step >= a.maxSteps() {
return "", fmt.Errorf("스텝 예산 초과: 최대 %d 스텝", a.maxSteps())
}
// ... 모델 호출 → tool_use면 도구 실행 → 반복
}
step을 세다가 상한에 닿으면 에러로 빠져나옵니다. 이건 기능이 아니라 안전장치예요 — 정상 흐름에선 모델이 진작 end_turn으로 끝내니 이 가드에 안 걸립니다. 하지만 뭔가 잘못됐을 때, 이 한 줄이 무한 청구서를 막아 줍니다. "모델이 계속 도구만 부르면 N 스텝에서 멈추는지"를 테스트로 못 박았어요(mock 서버가 언제나 tool_use를 돌려주게 하고, MaxSteps=3이면 정확히 3번 부르고 멈추는지).
규칙 회수 — 에이전트 루프의 무한 루프는 무한 청구서다. 그래서 스텝 예산 같은 상한을 두어, 뭔가 잘못돼도 반드시 빠져나오게 한다.
정직하게 — 예산과 병렬의 결
두 구멍을 메웠지만, 이것도 최소 골격입니다.
- 스텝 수는 거친 예산이다. "몇 번 돌았나"는 대충의 상한일 뿐, 진짜 비용은 토큰입니다. 한 스텝이 짧은 도구 호출일 수도, 거대한 컨텍스트를 문 긴 응답일 수도 있으니까요. 제대로 하려면 누적 토큰/달러를 세어 상한을 걸어야 합니다(Claude API엔 이런 걸 도와주는 태스크 예산 기능도 있습니다). 스텝 수는 그 전에 두는 가장 싼 안전핀이에요.
- 병렬은 도구가 독립적일 때만. 계산기와 단어 세기는 서로 무관하니 동시에 돌려도 됩니다. 하지만 도구에 부작용이 있으면(같은 파일을 쓰거나, 순서가 중요하거나) 병렬이 위험해요. 모델이 병렬로 부르더라도 우리가 순차로 강제하거나, 도구별로 격리해야 합니다. 지금 도구들은 부작용이 없어 안전한 거죠.
- 부작용 없는 도구 vs 위험한 도구. calculator·word_count·current_time은 읽기 전용이라 모델이 마음대로 불러도 무해합니다. 하지만 "파일을 지운다"·"결제한다" 같은 도구는 다릅니다 — 모델의
tool_use를 그대로 실행하면 안 되고, 사람의 승인 게이트를 끼워야 해요. 도구를 늘릴수록 "어떤 도구는 자동, 어떤 도구는 승인"의 구분이 중요해집니다. - 에러 결과를 모델이 읽는다. 1편에서 도구 실패도
is_error로 되돌린다고 했는데, 도구가 여럿이면 이게 더 값을 합니다 — 한 도구가 실패해도 모델이 "그럼 다른 도구로" 하고 우회할 수 있거든요. 실패를 삼키지 않고 모델에게 알려 주는 게 에이전트를 견고하게 합니다.
이 넷이 앞으로의 결입니다. 이번 편으로 에이전트가 여러 도구를 골라 쓰고, 폭주하지 않는 수준이 됐어요. 다음은 이 루프가 돌수록 길어지는 대화(컨텍스트)를 어떻게 다스릴지입니다.
정리 — 여러 도구와 안전한 루프
- 도구 여럿: 맵에 등록하면 모델이 설명을 읽고 라우팅한다. 라우팅 품질 = 설명 품질.
- 병렬 실행: 독립적인 도구 호출은 고루틴으로 동시에, 각자 자기 인덱스에만 써 레이스 없이 순서 보존.
go test -race로 증명. - 무한 루프 = 무한 청구서: 종료 조건이 하나뿐인 루프는 폭주할 수 있다.
- 예산 가드:
MaxSteps상한으로 뭔가 잘못돼도 빠져나온다. 기능이 아니라 안전장치. - 정직하게: 스텝은 거친 예산(진짜는 토큰), 병렬은 독립 도구만, 부작용 도구는 승인 게이트, 에러 결과는 모델의 우회 재료.
다음 편에서는 루프가 돌수록 쌓이는 대화(컨텍스트)를 다룹니다 — 길어지면 비싸지고 느려지니, 요약·정리로 다스리는 법입니다.
핵심 한 줄 — 도구가 여럿이면 라우팅은 모델에게 맡기되, 실행은 병렬로 당기고, 루프엔 예산을 걸어라. 에이전트의 무한 루프는 CPU가 아니라 돈을 태운다.