Why Does Every AI Agent Still Look Like `while (true) { ... }`?
Most agent runtimes share the same brittle skeleton. Here's what happens when you replace it with an event log.
개요 #
요즘 나온 AI 에이전트 코드베이스를 아무거나 열어보면 골격이 거의 똑같다. Claude Code, Codex, Pi, 오픈소스 프로젝트 대부분이 무한 루프 하나와 state라는 가변 객체 하나로 굴러간다. 모델이 두뇌, 루프가 심장, state는 그동안 쌓인 객체 더미다.
개발자 tom은 이 구조가 데모에서는 잘 돌아가지만 실제로 몇 달 써보면 같은 자리에서 계속 금이 간다고 지적했다. 그가 내놓은 대안은 로그를 진실의 원천으로 삼고, 상태는 로그의 투영(projection)으로 취급하는 것이다. 이 전제로 만든 개인용 에이전트 Pizza를 오픈소스로 공개했다.
모두가 쓰고 있는 그 골격 #
tom이 지적한 전형적인 형태는 이렇다.
let state = {};
while (true) {
const plan = await llm.plan(state);
const results = await runTools(plan);
state = updateState(state, results);
if (isDone(state)) break;
}가장 자연스러운 첫 설계다. 튜토리얼에 넣기 좋고, 일단 돌아간다. 문제는 오래 쓸 때 드러난다.
루프에서 갈라지는 네 군데 #
중단 처리가 임시방편이 된다 #
사용자가 턴 도중에 프로세스를 죽이거나, 도구가 멈추거나, 모델이 되묻는 순간 어정쩡하게 절반만 끝난 이터레이션이 state에 남는다. 버리든 그 자리에서 땜질하든, 어느 쪽이든 상태는 실제로 무슨 일이 있었는지에 대해 거짓말을 하게 된다.
재시도가 전부 특수 케이스다 #
도구 하나가 실패하면 try/catch를 두르고, 재시도를 걸고, 에러를 모델에 넘기고, 그걸 state에 반영하는 걸 잊지 말아야 한다. 몇 달 지나면 "이 턴이 깔끔하게 안 끝났으면 어떻게 하지"에 대한 즉흥적인 분기가 열 개쯤 쌓인다.
병렬 도구 호출이 껄끄럽다 #
모델이 read와 grep을 동시에 부르고 싶어 한다. 그러면 루프는 둘을 순차로 늘어놓거나, 프로미스를 띄우고 다음 llm.plan() 전에 결과를 다시 조립해야 한다. 관리할 상태가 또 늘어난다.
되돌릴 수 없다 #
state는 가변 객체 더미다. 대화의 이전 지점에서 갈라져 나오려면 손으로 상태를 재구성해야 한다. 모델이 실제로 무엇을 봤는지 재생할 방법도 없다. 긴 세션을 디버깅할 방법이 사실상 없는 셈이다.
tom은 이것들이 구현 디테일이 아니라 while(true) 형태 자체에서 나오는 결과라고 봤다. 대화 전체 이력을 가변 상태 객체 하나가 대신하려 들기 때문이다.
로그가 상태라면 #
몇 달 전부터 tom이 만들기 시작한 Pizza는 전제가 다르다. 메시지, 도구 호출, 결과, 파일 편집이 전부 EventStore의 한 행(row)이 된다.
CREATE TABLE events (
sequence INTEGER PRIMARY KEY,
event_id TEXT,
type TEXT,
payload_json TEXT,
caused_by TEXT,
thread_id TEXT,
...
);런타임은 state를 메모리에 들고 있지 않는다. 로그의 꼬리를 읽고, 다음 이벤트를 핸들러에 넘기고, 결과를 다시 append한다. 여기서 "턴"은 루프의 또 한 번의 반복이 아니라 상태 전이다.
UI, LLM 컨텍스트, 세션 트리는 모두 같은 로그에 대한 질의(query)일 뿐이다. 무슨 일이 있었는지 보고 싶으면 이벤트를 읽는다. 대화를 분기하고 싶으면 이전 sequence에서 새 thread_id를 시작한다. 재생하고 싶으면 이벤트를 다시 적용한다.
Photo by DS stories on Pexels
로그에서 딸려 나오는 기능들 #
이벤트 로그를 진실의 원천으로 확정하면, 원래는 까다로웠던 기능들이 특수 케이스가 아니게 된다.
"새 대화" 버튼이 없다 #
작업 사이의 경계가 없다. 같은 워크스페이스에서 며칠, 몇 주, 몇 년을 이어 대화할 수 있고, 이전 메시지와 편집과 도구 호출은 여전히 질의 가능한 이벤트로 남아 있다. 에이전트는 백지 상태로 다시 시작하라고 요구하는 대신, 로그의 꼬리를 투영해 스스로 컨텍스트를 관리한다. tom은 이걸 모든 걸 기억하는 친구와 길게 이어지는 대화 스레드에 비유했다.
도구 목록 대신 CLI 하나 #
read_file / write_file / grep / git처럼 도구를 길게 나열하지 않는다. 모델에게 주는 건 cli 도구 하나다. read, write, edit 같은 내장 명령은 구조화된 내부 핸들러가 처리하고, 나머지 — grep, sed, git, npm, python, ls — 는 사용자 셸로 그대로 넘긴다. 모델이 셸을 익혀야 하는 대신, 실제 파이프라인을 조합할 수 있게 된다. cli 호출 하나가 이벤트 하나이므로 로그도 일관되게 유지된다. read에 한 행, git diff에 한 행, npm test에 한 행이다.
Git 같은 세션 트리 #
모든 메시지가 caused_by 포인터를 가진 이벤트라서, 대화는 이미 트리 구조다. 이전 메시지 어디서든 fork하고, 되돌리고, 분기해 이어갈 수 있다. 나중에 덧붙인 undo 스택이 아니라 데이터 모양 자체가 그렇다.
모든 인터페이스가 같은 런타임 #
TUI, 데스크톱 앱, JSON-RPC 서버, 단발성 CLI가 모두 같은 SessionFacade 이벤트 스트림을 소비한다. 같은 로그의 다른 투영일 뿐이다. 터미널에서 세션을 시작한 뒤 데스크톱 앱을 열어도 같은 이벤트 스트림이다.
에이전트끼리 tell #
워크스페이스 A의 Pizza 에이전트가 워크스페이스 B의 에이전트에 tell 이벤트를 보낼 수 있다. B의 에이전트는 자기 이벤트 로그 안에서 작업을 처리하고 결과를 돌려준다. B의 실제 프로젝트 파일과 컨텍스트가 A의 로그로 새어 들어가지 않는다. tell 요청과 그 응답만 이벤트로 남는다.
스스로 고치기도 한다 #
pizza-self-optimization이라는 옵트인 스킬이 있다. 로컬 이벤트 로그를 근거로 읽고, Pizza 저장소를 fork하고, 로그에서 버그를 재현하고, 테스트를 작성한 뒤 PR을 연다. 이벤트 로그가 에이전트의 실제 행동에 대한 재현 가능한 기록이기 때문에 성립하는 기능이다.
아무도 말하지 않는 트레이드오프 #
tom은 이벤트 소싱이 공짜가 아니라는 점을 분명히 했다. 핫 패스에 실제 데이터베이스가 들어온다. 재생 비용, 로그 크기, 스냅샷을 고민해야 한다. 세션에 이벤트가 수천 개 쌓이면 fork할 때마다 로그에서 컨텍스트를 재구성하는 게 느려지므로 주기적인 materialized 스냅샷이 필요하다. 아직 구현하지 않았지만 다음 조각이 그것이라고 밝혔다.
"큰 상태 객체를 그냥 메모리에 들고 있는" 단순함도 잃는다. 런타임이 뭔가를 알아야 한다면 그건 이벤트 안에 있어야 한다. 로그 밖에 있는 건 보이지 않는 사이드 이펙트이고, 터지기를 기다리는 버그다.
만능은 아니지만, 유일한 형태도 아니다 #
tom은 while(true) 패턴이 여전히 많은 에이전트의 올바른 기본값이라고 인정했다. 단순하고, 돌아가고, 튜토리얼에 들어간다. 다만 오래 이어지는 세션, 분기되는 대화, 멀티 에이전트 협업, 그리고 에이전트가 실제로 한 일을 감사하거나 재생하는 기능을 원한다면 잘못된 추상화처럼 느껴지기 시작한다는 것이다.
EventStore 방식에도 비용이 있다. 하지만 fork, 재생, 멀티 에이전트 협업, 디버깅 같은 어려운 일들이 평범한 데이터베이스 연산으로 바뀐다. tom은 Pizza에서 그쪽에 걸었고, 지금까지 설계 중 가장 잘 버텨준 부분이라고 평가했다.
코드는 github.com/tomsun28/pizza에 오픈소스로 공개돼 있다.
이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.

