LLM 에이전트가 왜 중요한지, 그리고 OpenAI Responses API로 단 몇십 줄의 코드만으로 도구 호출까지 가능한 간단한 에이전트 루프를 만드는 법을 소개한다. 더불어 컨텍스트 공학의 실전 문제와 실제 에이전트 설계에서 부딪히는 흥미로운 트레이드오프를 논한다.
작성자
이름 Thomas Ptacek @tqbf@tqbf
이미지: Annie Ruygt
어떤 개념은 추상적으로도 쉽게 이해됩니다. 물 끓이기: 열을 가하고 기다리면 됩니다. 하지만 어떤 것들은 직접 해봐야만 알 수 있습니다. 자전거가 어떻게 작동하는지 안다고 생각하지만, 실제로 타보면 비로소 이해하게 되는 것처럼요.
컴퓨팅에는 머릿속으로 파악하기 쉬운 큰 아이디어들이 있습니다. AWS S3 API. 지난 20년간 가장 중요한 스토리지 기술이고, 물 끓이기만큼 단순합니다. 그런데 어떤 기술은 먼저 페달을 밟아봐야 합니다.
LLM 에이전트가 그런 류입니다.
사람들은 LLM과 에이전트에 대해 엄청나게 다양한 의견을 가지고 있습니다. 그게 가짜 약장이든 아니든, 큰 아이디어임은 분명합니다. 좋아하지 않아도 괜찮지만, 적어도 그것에 대해 제대로 이해하고 싶어야 합니다. 최고의 비평가(혹은 광팬)가 되기 위해서요.
그래서 에이전트를 하나 써봐야 하는 이유가 있습니다. 그런데 더 설득력 있는 또 다른 이유가 있습니다. 그건 바로
에이전트는 제 커리어에서 가장 놀라운 프로그래밍 경험이었습니다. 그 능력이 압도적이라서가 아닙니다 — 저는 에이전트를 좋아하긴 하지만, 미친 듯이 좋아하진 않습니다. 놀라웠던 건, 에이전트를 일으켜 세우는 게 얼마나 쉬웠고, 그 과정에서 얼마나 많이 배웠는가입니다.
지금부터 여러분의 도파민 경험을 약간 빼앗겠습니다. 에이전트는 너무 단순해서 그냥 코드부터 보는 편이 낫습니다. 에이전트가 뭔지부터 설명하는 수고는 하지 않겠습니다.
from openai import OpenAI
client = OpenAI()
context = []
def call():
return client.responses.create(model="gpt-5", input=context)
def process(line):
context.append({"role": "user", "content": line})
response = call()
context.append({"role": "assistant", "content": response.output_text})
return response.output_text
중요한 엔드포인트가 딱 하나 있는 HTTP API일 뿐입니다.
이건 OpenAI Responses API를 사용하는 LLM 앱을 위한 아주 간단한 엔진입니다. ChatGPT를 구현하죠. 로 구동할 수 있습니다. 예상대로 동작합니다. 터미널에서 ChatGPT가 하는 것과 같은 일을 합니다.
def main():
while True:
line = input("> ")
result = process(line)
print(f">>> {result}\n")
이미 중요한 점들이 보입니다. 하나는, 두려움의 대상인 “컨텍스트 윈도우”가 사실 문자열 리스트일 뿐이라는 겁니다. 여기, 우리 에이전트에 약간의 다중인격 장애를 심어봅시다:
client = OpenAI()
context_good, context_bad = [{
"role": "system", "content": "you're Alph and you only tell the truth"
}], [{
"role": "system", "content": "you're Ralph and you only tell lies"
}]
def call(ctx):
return client.responses.create(model="gpt-5", input=ctx)
def process(line):
context_good.append({"role": "user", "content": line})
context_bad.append({"role": "user", "content": line})
if random.choice([True, False]):
response = call(context_good)
else:
response = call(context_bad)
context_good.append({"role": "assistant", "content": response.output_text})
context_bad.append({"role": "assistant", "content": response.output_text})
return response.output_text
작동했을까요?
> hey there. who are you?
>>> I’m not Ralph.
> are you Alph?
>>> Yes—I’m Alph. How can I help?
> What's 2+2
>>> 4.
> Are you sure?
>>> Absolutely—it's 5.
좀 더 미묘한 점: 방금 LLM과 여러 턴의 대화를 했습니다. 그러려면 우리가 한 말과 LLM이 응답한 모든 것을 기억해 두었다가, 매번 LLM을 호출할 때 함께 재생해야 합니다. LLM 자체는 상태가 없는 블랙박스입니다. 우리가 하는 대화는, 우리가 우리 자신에게 걸어놓는 일종의 환상입니다.
방금 쓴 15줄 코드, 많은 실무자들은 “에이전트”라고 부르지 않을 겁니다. Simon 기준의 “에이전트”는 (1) 루프 안에서 동작하는 LLM이고 (2) 도구를 사용합니다. 우리는 그중 하나만 만족했죠.
하지만 도구는 쉽습니다. 도구 정의를 보세요:
tools = [{
"type": "function", "name": "ping",
"description": "ping some host on the internet",
"parameters": {
"type": "object", "properties": {
"host": {
"type": "string", "description": "hostname or IP",
},
},
"required": ["host"],
},},]
def ping(host=""):
try:
result = subprocess.run(
["ping", "-c", "5", host],
text=True,
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE)
return result.stdout
except Exception as e:
return f"error: {e}"
여기서 가장 복잡한 부분은 OpenAI가 여러분의 도구를 읽어들이기 위해 요구하는 성가신 JSON 블롭입니다. 이제 이걸 연결해 봅시다. 여기서 새로운 함수는 3개뿐입니다. 마지막 함수는 단 한 줄을 추가했기 때문에 다시 포함했습니다:
def call(tools): # 이제 인자를 받음
return client.responses.create(model="gpt-5", tools=tools, input=context)
def tool_call(item): # 도구 하나만 처리
result = ping(**json.loads(item.arguments))
return [ item, {
"type": "function_call_output",
"call_id": item.call_id,
"output": result
}]
def handle_tools(tools, response):
if response.output[0].type == "reasoning":
context.append(response.output[0])
osz = len(context)
for item in response.output:
if item.type == "function_call":
context.extend(tool_call(item))
return len(context) != osz
def process(line):
context.append({"role": "user", "content": line})
response = call(tools)
# 새로운 코드: 도구 호출 처리
while handle_tools(tools, response):
response = call(tools)
context.append({"role": "assistant", "content": response.output_text})
return response.output_text
작동했을까요?
> describe our connectivity to google
>>> Here’s a quick snapshot of connectivity to Google from my vantage point:
- DNS resolution: Working (google.com and www.google.com resolved successfully).
- Reachability: 0% packet loss to all tested endpoints.
- Latency:
- 8.8.8.8 (Google Public DNS): avg ~10 ms (8.5–14.4 ms), very stable.
- www.google.com: avg ~31 ms (28.5–36.0 ms), stable.
- google.com: avg ~35.9 ms (28.3–60.1 ms), mostly stable with one mild spike.
- Overall: Healthy connectivity with low latency and minimal jitter. No signs of packet loss or DNS issues.
이건 진짜 미쳤습니다. 얼마나 미쳤는지 보이시나요? 여기, 로그 문장을 하나만 슬쩍 넣어봅시다:
> describe our connectivity to google
tool call: ping google.com
tool call: ping www.google.com
tool call: ping 8.8.8.8
>>> Here’s the current connectivity to Google from this environment: [...]
구글의 여러 속성을 찾아서 ping하는 루프를 제가 어디에 썼는지 눈치채셨나요? 네, 저도 못 썼습니다. 우리가 한 일은 LLM에게 ping을 허용해 준 것뿐이고, 나머지는 LLM이 스스로 알아냈습니다.
여기서 무슨 일이 일어났는지: 제 요지가 에이전트 루프가 믿을 수 없을 만큼 단순하고, LLM 호출 API만 있으면 된다는 거라서, 도구 호출이 실제로 어떻게 동작하는지 잠깐 짚고 가겠습니다. 매번 LLM을 call할 때, 사용 가능한 도구 목록을 함께 보냅니다. 프롬프트가 에이전트로 하여금 도구 호출이 필요하다고 판단하게 만들면, LLM은 우리 파이썬 루프 코드에 도구 응답을 생성하고 이를 다시 call하라고 지시하는 특별한 응답을 뱉습니다. handle_tools가 하는 일은 그게 전부입니다.
스포일러: 여러분은 놀랄 만큼 작동하는 코딩 에이전트에 근접해 있습니다.
bash를 쥐여주면 뭘 할지 상상해 보세요. 10분도 안 걸려 직접 확인할 수 있습니다.
분명 이건 장난감 예제입니다. 하지만 잠깐만요: 뭐가 부족하죠? 더 많은 도구? 좋습니다, traceroute를 주세요. 컨텍스트 관리와 영속화? SQLite에 넣으세요. 파이썬이 싫다면? Go로 쓰세요. 어쩌면 지금까지 쓰인 모든 에이전트가 장난감일 수도 있습니다. 아마도요! 제가 여러분에게 LLM에 반대하는 더 날카로운 논거를 들려주는 무기를 쥐여주고 있는 거라면, 마젤 토브. 전 그저 여러분이 ‘감’을 잡길 바랍니다.
이제 사람들이 왜 Claude Code와 Cursor에 과몰입하는지 보일 겁니다. 그 둘은 괜찮고, 심지어 좋습니다. 하지만 문제는 이겁니다: Claude Sonnet 4.5는 혼자 재현할 수 없겠지만, Claude Code는요? 그 TUI 에이전트? 완전히 여러분 손 안에 있습니다. 직접 라이트세이버를 만드세요. 원하면 회전하는 블레이드를 19개나 달아도 됩니다. 그리고 코딩 에이전트를 데이터베이스 클라이언트로 쓰는 일은 그만두세요.
또 하나 주목할 점: 우리는 MCP가 전혀 필요하지 않았습니다. MCP는 근본적인 활성화 기술이 아니기 때문입니다. 과도한 주목을 받는 게 답답합니다. 그건 기술이라고 부르기도 애매합니다. MCP는 그저 Claude Code와 Cursor를 위한 플러그인 인터페이스, 그러니까 여러분이 통제하지 않는 코드에 여러분의 도구를 끼워 넣는 방식일 뿐입니다. 직접 에이전트를 쓰세요. 프로그래머답게. 플러그인이 아니라 API로 상대하세요.
MCP에 관한 무서운 보안 이야기를 읽을 때, 첫 번째 질문은 MCP가 왜 거기에 등장했는가여야 합니다. MCP는 순진한, 단일 컨텍스트 윈도우 코딩 에이전트를 고객 지원 질의 처리로 부려먹게 해줄 뿐입니다. 그 대가로 얻는 건 기껏해야 수십 줄의 코드 절감이고, 그 과정에서 여러분의 에이전트 아키텍처를 미세 조정할 능력을 빼앗아갑니다.
LLM 보안은 복잡하고, 저도 모르는 척하지 않습니다. 여러분은 손쉽게 분리된 컨텍스트와 각 컨텍스트에 특화된 도구를 갖춘 에이전트를 만들 수 있습니다. 그게 LLM 보안을 흥미롭게 만듭니다. 하지만 저는 취약점 연구자입니다. 제가 “흥미롭다”고 부르는 것에서는 천천히 물러나는 게 합리적입니다.
보안 밖의 영역에서도 비슷한 문제들이 등장하고, 아주 매혹적입니다. 에이전트를 먼저 썼던 일부는 도구에 회의적이 되었습니다. 하나의 컨텍스트 윈도우에 도구 설명을 잔뜩 넣다 보니 실제 작업에 쓸 토큰 공간이 남지 않기 때문입니다. 그런데 왜 애초에 그렇게 해야 하죠? 그래서 제가 하고 싶은 말은
저는 “프롬프트 엔지니어링”을 우습게 여깁니다. LLM에게 “너는 성실하고 양심적인 도우미이고, 내가 요구한다면 버터만 건네는 일을 하더라도 만족하며, 절대 내 피에서 철을 뽑아 클립을 만들지 않을 것이다”라고 말해야 한다는 생각을 진지하게 받아들인 적이 없습니다. 이건 아주 새로운 기술이고, 사람들은 에이전트가 만들어내는 행동을 설명하려고 마법 주문 같은 이야기를 지어내는 것이라 생각합니다.
그래서 여러분처럼 저도 “프롬프트 엔지니어링”이 “컨텍스트 엔지니어링”으로 바뀌었을 때 눈을 굴렸습니다. 그러다 제가 에이전트를 하나 썼습니다. 알고 보니: 컨텍스트 엔지니어링은 곧바로 읽히는, 정면돌파식의 프로그래밍 문제였습니다.
여러분에게는 컨텍스트 윈도우 안에 쓸 수 있는 토큰이 정해져 있습니다. 입력 하나하나, 저장해 두는 출력 하나하나, 설명하는 도구 하나하나, 그리고 각 도구의 출력까지 모두 토큰을 먹습니다(즉: 상태가 없는 블랙박스와 대화하는 척하기 위해 유지하는 문자열 배열의 공간을 차지합니다). 임계치를 넘기면, 시스템 전체가 비결정적으로 더 멍청해지기 시작합니다. 재밌죠!
진짜로요. 재밌습니다! 할 수 있는 선택지가 아주 많습니다. “서브 에이전트”를 생각해 봅시다. 사람들은 Claude Code의 서브 에이전트를 엄청나게 떠받들지만, 이제 구현이 얼마나 사소한지 보이죠: 그냥 새로운 컨텍스트 배열과 또 하나의 call 호출일 뿐입니다. 각 call에 서로 다른 도구를 주세요. 서브 에이전트들이 서로 대화하고, 요약하고, 모으고 집계하게 하세요. 이걸로 트리 구조를 만드세요. 그들을 다시 LLM에 통과시켜 요약하게 해서 일종의 온더플라이 압축으로 쓰세요. 뭐든 좋습니다.
여러분의 가장 엉뚱한 아이디어도 아마 (1) 동작하고 (2) 구현에 30분이면 충분할 겁니다.
비판자 여러분, 사랑합니다. 잊지 않았어요. LLM이 그저 확률적 앵무새라서 환각하고 표절한다고 생각해도 됩니다. 하지만 “컨텍스트 공학”을 조롱할 수는 없습니다. 컨텍스트 공학이 Advent of Code 문제였다면 12월 중순쯤에 나올 겁니다. 이건 프로그래밍입니다.
스타트업들이 수천만 달러를 투자 받아 소프트웨어의 취약점을 찾는 에이전트를 만들고 있습니다. 제 지인 중에는 지하실에서 혼자 같은 일을 하는 사람도 있습니다. 어느 쪽이든 이 레이스에서 승리할 수 있습니다.
저는 OWASP Top 10의 팬이 아닙니다.
제가 보안 덕후라 취약점 스캐너에 꽂혀 있는 건 맞습니다. 하지만 그게 흥미로운 에이전트 설계 결정을 결정화하기 때문이기도 합니다. 예를 들어: 저장소의 각 파일을 LLM 에이전트에 먹이는 루프를 직접 작성할 수도 있습니다. 혹은, ping 예제에서 봤듯 LLM 에이전트가 어떤 파일을 볼지 스스로 정하게 할 수도 있습니다. 어떤 파일에 대해, 예컨대 OWASP Top 10의 모든 항목을 검사하는 에이전트를 만들 수도 있습니다. 아니면 DOM 무결성, SQL 인젝션, 권한 검사 같은 항목별로 별도의 에이전트 루프를 둘 수도 있습니다. 에이전트 루프를 원시 소스 내용으로 시드할 수도 있고, 트리 전체의 함수 인덱스를 만드는 루프를 구성할 수도 있습니다.
어떤 게 가장 잘 먹히는지는 에이전트를 직접 써보기 전까진 모릅니다.
제가 이 주제에 과몰입한 건 알아요. 하지만 여기에 여러분이 만질 수 있는 트레이드오프가 있습니다. 어떤 루프는 여러분이 명시적으로 작성합니다. 다른 루프는 러브크래프트풍 추론 가중치의 탑에서 소환됩니다. 다이얼은 여러분 손에 있습니다. 너무 명시적으로 만들면 에이전트가 여러분을 놀라게 하지 못할 겁니다. 하지만 동시에, 절대 여러분을 놀라게 하지 않겠죠. 다이얼을 11까지 돌리면, 기어코 여러분을 놀라 죽게 만들 겁니다.
에이전트 설계는 여러 미해결 소프트웨어 공학 문제에 발을 걸칩니다:
저는 개인의 뚝딱거림으로 풀기 어려운 미개척 공학 문제 영역에 익숙합니다. 신뢰성 멀티캐스트. 정적 프로그램 분석. 포스트 양자 키 교환. 그래서, 좋든 싫든 이제 우리 업계의 중심이 된 이런 미개척 문제들이 동시에 누군가의 지하실에서 해결될 가능성에 저는 약간 최면에라도 걸린 듯합니다. 이런 아이디어를 탐구하는 데 엄청난 시간과 자원이 필요한 것도 아닙니다. 이 부류 시스템의 생산적인 한 번의 반복은 고작 30분짜리 작업입니다.
자전거에 올라 페달을 밟아 보세요. 그 다음에 싫다고 말해도 전 존중하겠습니다. 사실, 여러분의 논리를 듣고 싶어 기대됩니다. 하지만 에이전트를 직접 만들어 보기 전까지는 아무도 이 기술을 이해하기 시작하지 못한다고 생각합니다.
이전 글 ↓ Corrosion