TIL - 코드, LLM 호출, 그리고 AI 에이전트 사이에서의 선택
요약
LLM 호출과 AI 에이전트의 본질적인 차이를 Go 언어를 이용한 직접 구현을 통해 분석합니다. 에이전트는 단순한 모델 호출을 넘어, 도구 호출(tool-calling)과 루프 로직이 결합된 형태임을 설명합니다.
핵심 포인트
- 에이전트의 핵심은 모델이 아닌 외부 코드가 실행하는 'while-loop' 구조에 있음
- SDK는 상용구 코드를 줄여줄 뿐, 에이전트의 사고 과정(loop 로직)을 대신해주지 않음
- 단순 LLM 호출과 에이전트의 차이는 도구 스키마 제공과 실행 루프의 유무임
- Go 언어를 사용하여 프레임워크 없이 직접 에이전트 메커니즘을 구현하며 원리 파악
두 가지 질문이 저를 이 길로 이끌었습니다.
첫 번째 질문: "AI 에이전트 (AI agent)"란 진짜 무엇인가? 대부분의 채용 공고에서 이를 언급합니다. 저는 깊이 있게 파고들지 않았고, 겉으로 보기에는 그것이 무엇을 지칭하는지 알 수 없었습니다. 에이전트는 다른 엔드포인트 (endpoint)인가요? 다른 모델 (model)인가요? 우리가 설치하는 라이브러리 (library)인가요? 아니면 단순히 LLM API를 특정한 방식으로 호출하는 것에 대한 이름인가요?
두 번째 질문은 나중에 찾아왔고, 저를 멈춰 세웠습니다. 메커니즘을 이해하고 무언가를 만들기 위해 앉았을 때, 일반적인 Go 언어로 작성할 수 없는 아이디어를 단 하나도 떠올릴 수 없었습니다. 즉, 몇몇 API를 호출하고, 데이터베이스 (database)에 접근하여 결과를 반환하는 함수 (function) 말입니다. 일반적인 코드로 할 수 있다면, 왜 모델에게 비용을 지불하며 더 성능이 낮은 방식으로 일을 시켜야 할까요?
이것이 제가 발견한 내용입니다. 두 질문 모두에 대한 실제적인 답이 있으며, 두 번째 질문에는 함정이 있습니다.
그래서 두 가지 모두를 만들어 보았습니다
하나의 Go 서비스, 하나의 인터페이스 (interface), 그리고 복잡도가 증가하는 네 개의 엔드포인트 (endpoints):
POST /chat/no-memory 상태가 없는 (stateless) 단일 LLM 호출
POST /chat 멀티 턴 (multi-turn), 세션별 히스토리 (history)
POST /chat/agent/weather 도구 호출 (tool-calling) 루프, 도구 한 개
...
프레임워크 (framework)는 사용하지 않았습니다. 처음에는 직접 구현한 net/http를 사용했고, 그다음에는 동일한 인터페이스 뒤에서 SDK를 사용하는 두 번째 구현을 만들어 SDK가 정확히 저를 위해 무엇을 하고 있는지 확인했습니다. (답: SDK는 상용구 코드 (boilerplate)를 제거해 줄 뿐, 사고 과정을 제거해주지는 않았습니다. 루프 (loop) 로직은 두 방식 모두 동일했습니다). 참고할 점은, Groq의 공식 SDK는 Python과 JS만 지원하므로, Go에서는 커뮤니티 라이브러리나 OpenAI 호환 클라이언트 (client)를 사용해야 한다는 것입니다.
단순한 호출에서 시작하여 한 단계씩 에이전트로 올라가는 과정이 첫 번째 질문에 대한 전체적인 해답이 되었습니다.
에이전트는 while-loop입니다
전문 용어를 배제한 버전입니다.
단순한 LLM 호출은 상태가 없는 (stateless) HTTP 요청입니다. 우리는 메시지를 보내고, 텍스트를 돌려받습니다. 모델은 아무것도 기억하지 못합니다. "대화 히스토리 (Conversation history)"는 우리가 매번 저장하고 다시 보내는 상태 (state)입니다. 그것이 위의 /chat/no-memory와 /chat이며, 둘 사이의 유일한 차이점은 배열 (array)을 누가 유지하느냐입니다.
**에이전트 (Agent)**는 한 가지를 더 추가합니다. JSON 스키마 (JSON schemas)로 기술된 도구 (tools) 목록을 함께 보냅니다. 이제 모델은 최종 답변 대신 "get_metrics를 {"service":"checkout"} 인자와 함께 호출하세요"라고 응답할 수 있습니다. 그래서 우리는 다음과 같은 루프 (loop)를 수행합니다:
- 메시지와 도구 스키마를 전송합니다.
- 모델이 최종 답변을 하거나, 도구 요청을 하며 응답합니다.
- 우리의 코드가 도구를 실행합니다. 모델은 아무것도 실행하지 않으며, 오직 요청만 합니다.
- 결과를 히스토리 (history)에 추가하고, 다시 호출합니다.
- 모델이 요청을 멈추거나, 반복 횟수 제한 (iteration cap)에 도달할 때까지 반복합니다.
이것이 차이점입니다. 동일한 엔드포인트 (endpoint), 동일한 모델, 그리고 약 150줄의 추가적인 Go 코드일 뿐입니다. 모든 에이전트 프레임워크 (agent framework)는 이 루프에 상태 유지 (state persistence), 재시도 (retries), 그리고 트레이싱 (tracing)을 더한 것입니다.
이를 구축하면서 깨달은 점은, 가장 어려운 부분들이 대부분 우리가 이미 다른 이름으로 알고 있는 것들이라는 사실입니다.
| 에이전트 문제 | 실제 정체 |
|---|---|
| 도구 디스패치 (Tool dispatch) + 스키마 검증 (schema validation) | RPC 디스패치 (RPC dispatch) |
| ... |
표가 이렇게 일치하는 데에는 이유가 있으며, 루프가 이를 숨기고 있습니다.
코드 내부에서는 반복되는 하나의 API 호출처럼 보입니다. 하지만 우리는 모델을 호출하고, 모델은 도구를 요청하며, 우리는 그 도구를 호출합니다 (실제 시스템에서는 로깅 백엔드, 메트릭 API, 런북 저장소와 같은 다른 서비스입니다). 그런 다음 우리는 모델을 다시 호출합니다. 하나의 요청이 사전에 아무도 정해두지 않은 순서로 여러 독립적인 서비스로 퍼져나갑니다 (fan out). 이것이 오케스트레이션 (orchestration)이며, 우리는 이미 이에 대한 이름을 가지고 있습니다: 바로 분산 시스템 (distributed system)입니다.
차이점은 우리가 어떤 부분을 신뢰할 수 없느냐 하는 것입니다. 보통 오케스트레이터 (orchestrator)는 시스템에서 가장 신뢰할 수 있는 요소인 우리의 코드이며, 우리는 의구심을 종속성 (dependencies)에 남겨둡니다. 하지만 에이전트에서 오케스트레이터는 모델입니다. 다음에 무엇을 호출할지 결정하는 컴포넌트가 이제 가장 신뢰할 수 없는 요소가 됩니다. 모델은 동일한 입력에 대해 다른 시퀀스 (sequence)를 선택할 수 있고, 잘못된 형식의 요청을 생성할 수 있으며, 실패할 때는 요란하게 실패하는 대신 그럴듯하게 실패하곤 합니다.
따라서 기존의 본능이 여전히 적용되지만, 우리는 그 본능을 새로운 곳으로 향하게 할 뿐입니다. 모든 인자(argument)를 검증하세요. 호출자가 요청을 반복할 수 있으므로 쓰기(write) 작업은 멱등성(idempotent)을 갖게 만드세요. 루프(loop)에 제한을 두세요. 도구(tool)가 반환하는 모든 것은 데이터로 취급하고, 절대로 명령(instruction)으로 취급하지 마세요. 우리가 시스템의 경계(edge)에서 이미 수행하고 있는 것들을, 우리가 신뢰하는 데 익숙한 오케스트레이션(orchestration)에 적용하는 것입니다.
실제로 새로운 것은 짧은 목록으로 요약됩니다: 프롬프팅 (prompting), 구조화된 출력 (structured output), 평가 (evals), 그리고 검색 (retrieval)입니다.
첫 번째 질문에 대한 답은 나왔습니다. 그때 두 번째 질문이 등장했습니다.
왜 굳이 이것을 사용해야 하는가?
나는 무언가 만들 것이 필요했습니다. 그리고 내가 가진 모든 아이디어는 즉시 일반적인 Go 언어로 구현할 수 있는 모습으로 그려졌습니다.
"학습 계획 생성"은 구조화된 출력 (structured output)을 가진 하나의 LLM 호출이며, 함수로 감싸져 있습니다. 루프가 필요 없습니다. "지원 티켓 분류"는 하나의 호출이며, 그 결과에 대해 switch 문을 실행하면 됩니다. "문서 요약"은 하나의 호출입니다. 루프를 사용하려는 시도를 할 때마다, 일반적인 코드가 더 저렴하고, 빠르고, 결정론적(deterministic)이며, 테스트 가능했습니다.
이것은 작은 반론이 아닙니다. 이것이 핵심 질문입니다. 루프는 우리에게 비용(토큰), 지연 시간(iteration당 초 단위 시간), 결정론 (동일한 입력에 대해 다른 출력), 그리고 디버깅 가능성 (동작이 코드가 아닌 산문 형태의 텍스트에 존재함)을 요구합니다. 우리는 이 모든 것을 대가로 무엇을 얻고 있는 걸까요?
정확히 단 한 가지입니다: 모델이 코드를 작성할 당시에는 존재하지 않았던 정보를 사용하여, 런타임(runtime)에 다음 단계를 선택한다는 것입니다.
지능도 아니고, 언어도 아닙니다. 그것들은 단일 호출에서 나옵니다. 루프는 런타임 분기 선택(branch selection)을 구매하는 것이며, 그 외에는 아무것도 아닙니다.
따라서 질문은 다음과 같이 바뀝니다: 우리는 실제로 언제 그것이 필요한가?
내가 내린 기준
일반적인 프레임워크는 "단순한 문제는 코드로, 복잡한 문제는 에이전트(agent)로"라고 말합니다. 실행에 옮기기에는 너무 모호합니다. 제가 사용할 버전은 다음과 같습니다:
입력을 확인하기 전에 플로우차트(flowchart)를 그릴 수 있는가?
만약 그렇다면(Yes), 결정 지점(decision points)을 열거할 수 있고 분기(branches)를 작성할 수 있으므로, 우리는 그것을 작성해야 합니다. 코드는 결정론적(deterministic)이고, 테스트 가능하며, 디버깅이 가능하고, 비용이 거의 들지 않습니다. 그 상황에서 에이전트(agent)를 사용하는 것은 순수한 오버헤드일 뿐이며, LLM의 역할은 (만약 역할이 있다면) 하나의 박스 안에서 이루어지는 단일 호출, 즉 분류(classify), 추출(extract), 요약(summarize)입니다. 루프(loop)는 없습니다.
만약 아니라면(No), 즉 경로가 각 단계에서 드러나는 내용에 따라 달라지고 실제 트리(tree)가 그 순간에만 발견될 수 있는 수천 개의 분기를 가지고 있다면, 그것이 바로 루프가 그 비용을 정당화하는 좁은 사례입니다.
그 기준이 제가 **로그 트리아지(log triage)**를 선택한 이유입니다. 다음을 고려해 보세요:
if latencyHigh {
checkDatabase()
if dbSlow {
...
우리는 이것을 작성할 수 있습니다. 하지만 어떤 다운스트림 서비스(downstream service)일까요? 로그가 무엇을 말하느냐에 달려 있습니다. 만약 DB 체크 결과 배포(deploy)로 인해 쿼리 플랜(query plan)이 변경된 것이 드러난다면 어떨까요? 우리는 그것에 대한 분기를 만들어 두지 않았습니다. 우리가 조사하는 순서는 각 단계가 무엇을 반환하느냐에 따라 달라집니다.
그것이 트리거(trigger)입니다. "복잡하다"가 아니라, **"코드를 작성하는 시점에 분기를 열거할 수 없다"**는 것입니다.
이 기준을 명심하세요. 포스트의 나머지 내용은 우리가 이 기준을 통과했을 때, 그리고 그 후 우리가 내린 답을 서서히 뒤집게 될 때 어떤 일이 일어나는지에 관한 것입니다.
테스트 케이스
의도적인 거짓말이 포함된 가짜 장애 상황:
checkoutp99가 14:03에 급증했습니다. 하지만 checkout은 정상입니다. DB 풀(pool)은 건강하며(4/20), 트래픽도 정상입니다. checkout은payment를 호출하는데, payment의max_pool_size는 13:58에 설정 재로드(config reload)로 인해 20에서 5로 조용히 낮아졌습니다. Payment는 풀이 포화되어 타임아웃(timeout)이 발생하기 시작했습니다. Checkout은 원인이 아니라 피해자입니다.
하드코딩된 데이터 대신 세 가지 도구를 사용합니다: query_logs, get_metrics, search_runbook. 정답을 맞힌다는 것은 checkout 자체의 리소스를 배제하고, 다운스트림 서비스로 넘어가며, (함정인) 13:58의 설정 변경을 포착하기 위해 장애 발생 시점보다 더 넓은 시간 범위(time window)를 확인하는 것을 의미합니다.
모킹된 데이터(Mocked data)는 지름길이 아니라 하나의 기능입니다. 그렇게 되면 모든 실패는 불안정한 인프라 때문이 아니라, 명확하게 루프의 책임이 됩니다.
그리고 다섯 번의 실패가 있었습니다
1. 한 번도 열어본 적 없는 런북(runbook)을 인용함
iteration 1: query_logs(checkout, 13:55-14:05)
iteration 2: get_metrics(checkout, latency)
iteration 3: get_metrics(checkout, error_rate)
...
누락된 점을 주목하세요: search_runbook이 한 번도 호출되지 않았습니다. 그리고 그것이 생성한 답변은 다음과 같습니다:
...이러한 증상은
해결책 (시스템 프롬프트 (system prompt)): "첫 번째로 그럴듯한 설명에서 멈추지 마세요. 결론을 내리기 전에 대안들을 배제하십시오: 서비스 자체의 리소스가 정상인지, 그리고 트래픽이 비정상적인지 확인하십시오."
3. 무엇을 검색해야 할지 알기도 전에 런북 (runbook)을 검색했습니다
iteration 6: search_runbook("payment.Authorize timeout") ← 여기서 검색함
iteration 7: get_metrics(payment, latency)
iteration 8: query_logs(payment, 13:55-14:05) ← 여기서 원인을 찾음
이 에이전트는 6번째 반복(iteration)에서 파악한 증상을 사용하여 검색을 수행했고, 8번째 반복에서 실제 근본 원인(root cause)을 찾아낸 뒤 다시는 검색하지 않았습니다. 사람은 무의식적으로 이렇게 행동합니다. '풀 고갈(pool exhaustion)이네, 그럼 그 런북을 찾아보자'라고 말이죠. 루프(loop)는 기본적으로 재계획(re-plan)을 수행하지 않습니다.
해결책 (시스템 프롬프트 (system prompt)): "근본 원인을 식별한 후에는 원래의 증상이 아닌, 근본 원인 용어를 사용하여 런북을 다시 검색하십시오."
4. 호출자(caller)가 잘못된 호출을 생성했습니다
{"error":{"code":"tool_use_failed","failed_generation":
"<function=query_logs{\"service\": \"checkout\"}</function>"}}
도구는 맞고, 인자(arguments)도 맞지만, 구문(syntax)이 잘못되었습니다. 함수 이름 뒤에 > 하나가 누락되었습니다. gRPC 클라이언트는 이런 실수를 할 수 없습니다. 와이어 포맷(wire format)은 생성되어 있으며 보장되기 때문입니다. 하지만 여기서는 호출자가 유효한 요청을 형성하는 능력 자체가 확률적(probabilistic)입니다.
이는 저의 재시도(retry) 로직도 망가뜨렸습니다:
attempt 1: <function=query_logs{...
attempt 2: <function=query_logs{... ← 동일함
attempt 3: <function=query_logs={... ← 한 글자가 다름
...
네 번의 시도, 15초의 백오프(backoff), 그리고 동일하게 잘못된 출력. 재시도는 출력이 달라질 경우에만 도움이 됩니다. 낮은 온도(low temperature) 설정에서 모델은 거의 결정론적(deterministic)이기 때문에, 동일한 요청은 동일한 실패를 생성합니다.
해결책 (코드, 그 다음 모델): tool_use_failed를 재시도 대상 세트에서 제외하여, 15초를 허비하는 대신 빠르게 실패(fail fast)하도록 만듭니다. 그다음 모델을 교체합니다. openai/gpt-oss-120b 모델에서는 이 문제가 다시 발생하지 않았습니다.
5. 컨텍스트(Context)의 증가는 비용 모델입니다
Rate limit reached ... tokens per minute (TPM): Limit 8000, Used 6886, Requested 1311
모든 반복(iteration)마다 전체 히스토리를 다시 전송하기 때문에, 8번째 반복은 이전 7개의 도구(tool) 실행 결과를 모두 포함하게 됩니다. 1분도 채 되지 않아 8번째 반복에서 거대한 벽에 부딪힙니다. 이는 단일 호출이 커서가 아니라, 호출들이 누적(compound)되기 때문입니다. 반복당 prompt_tokens를 로그로 기록해 보세요. 그 곡선이야말로 에이전트 경제학 (agent economics)에 대해 우리가 보여줄 수 있는 가장 정직한 지표입니다.
해결책 (코드): 429 오류 발생 시 지수 백오프 (exponential backoff)를 적용하고, 자체적인 곡선 대신 Retry-After 헤더를 준수합니다. 이는 완화책 (mitigation)일 뿐, 근본적인 해결책 (fix)은 아닙니다. 성장은 루프 (loop) 자체에 내재되어 있습니다.
두 가지 종류의 가드레일 (guardrail)
앞서 언급한 해결책들의 라벨을 다시 살펴보세요. 두 가지는 코드였으며, 코드 수정은 그저 엔지니어링일 뿐입니다. 더 나은 재시도 분류 (retry classification), 더 나은 백오프 (backoff) 같은 것들 말이죠. 거기엔 흥미로운 것이 없습니다.
나머지 세 가지는 시스템 프롬프트 (system prompt)였습니다. 그리고 이 세 가지는 서로 다른 종(species)입니다.
유형 A: 판단력을 향상시키는 가드레일.
더 나은 도구 설명 (tool descriptions). "결론을 내리기 전에 대안을 배제할 것." "런북 (runbook)을 호출했을 때만 인용할 것." 이것들은 선택 공간 (option space)을 줄이지는 않지만, 모델이 동일한 공간을 더 잘 탐색하도록 돕습니다. 비용이 거의 들지 않습니다. 이를 유지하고 더 추가하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기