고집 있고 미니멀한 코딩 에이전트를 만들며 배운 것

TMT

https://mariozechner.at/posts/2025-11-30-pi-coding-agent/

Image

대단한 건 아니지만, 제 것입니다

지난 3년 동안 저는 LLM의 도움을 받아 코딩해 왔습니다. 이 글을 읽는 분이라면 아마 저와 같은 과정을 거쳤을 겁니다. 코드를 복사해서 ChatGPT에 붙여넣던 시절에서 (제게는 한 번도 제대로 동작한 적 없는) Copilot 자동완성으로, 다시 Cursor로, 그리고 마지막으로 2025년에 우리의 주력 도구가 된 Claude Code, Codex, Amp, Droid, opencode 같은 새로운 세대의 코딩 에이전트 하니스로 넘어왔겠지요.

저는 대부분의 작업에서 Claude Code를 선호했습니다. Cursor를 1년 반 쓰다가 4월에 처음 시도해 본 것이 Claude Code였습니다. 그때는 훨씬 단순했습니다. 저는 단순하고 예측 가능한 도구를 좋아하는 단순한 사람이라 그 단순함이 제 작업 방식에 딱 맞았습니다. 그런데 지난 몇 달 사이 Claude Code는 제게 쓸 데가 없는 기능이 80%인 우주선이 되어 버렸습니다. 릴리스마다 시스템 프롬프트와 도구도 바뀌는데, 그러면 제 워크플로가 깨지고 모델 동작도 달라집니다. 저는 그게 정말 싫습니다. 게다가 화면이 깜빡입니다.

저는 여러 해 동안 복잡도가 다양한 에이전트를 여러 개 만들어 왔습니다. 예를 들어 제가 만든 작은 브라우저 조작 에이전트 Sitegeist는 사실상 브라우저 안에서 사는 코딩 에이전트입니다. 그 모든 작업에서 배운 것은 컨텍스트 엔지니어링이 무엇보다 중요하다는 점입니다. 모델의 컨텍스트에 무엇이 들어가는지 정확히 통제하면 더 좋은 결과가 나오고, 특히 코드를 쓸 때 그렇습니다. 기존 하니스들은 UI에 드러나지도 않는 것들을 뒤에서 몰래 끼워 넣어서, 이걸 지독하게 어렵게 만들거나 아예 불가능하게 만듭니다.

드러낸다는 이야기가 나왔으니, 저는 모델과 주고받는 모든 부분을 들여다보고 싶습니다. 이걸 허용하는 하니스는 사실상 없습니다. 또 자동으로 후처리할 수 있게 깔끔하게 문서화된 세션 포맷과, 에이전트 코어 위에 다른 UI를 얹을 수 있는 간단한 방법도 원합니다. 기존 하니스로 일부는 가능하지만, API에서 유기적으로 자라난 티가 납니다. 여기까지 오는 동안 짐이 쌓였고, 그게 개발자 경험에 그대로 드러납니다. 이걸 두고 누굴 탓하려는 건 아닙니다. 수많은 사람이 여러분이 만든 것을 쓰고 어느 정도 하위 호환성을 지켜야 한다면, 그건 치러야 하는 값입니다.

셀프 호스팅도 로컬과 DataCrunch에서 조금씩 해 봤습니다. opencode처럼 셀프 호스팅 모델을 지원하는 하니스도 있지만, 대개 잘 동작하지 않습니다. 주로 Vercel AI SDK 같은 라이브러리에 의존하기 때문인데, 이 SDK는 무슨 이유인지 셀프 호스팅 모델과 잘 맞지 않고 특히 도구 호출에서 그렇습니다.

그래서 클로드들한테 고함이나 치는 이 늙은이는 무엇을 할까요? 자기 코딩 에이전트 하니스를 직접 만들고, 구글로는 절대 검색되지 않을 이름을 붙일 겁니다. 그러면 사용자가 영영 생기지 않겠지요. 사용자가 없으니 GitHub 이슈 트래커에 이슈도 영영 올라오지 않을 겁니다. 얼마나 어렵겠습니까?

이걸 만들려면 다음이 필요했습니다.

  • pi-ai: 여러 프로바이더(Anthropic, OpenAI, Google, xAI, Groq, Cerebras, OpenRouter, 그리고 OpenAI 호환 엔드포인트 전부)를 지원하는 통합 LLM API. 스트리밍, TypeBox 스키마를 쓰는 도구 호출, 사고·추론 지원, 프로바이더를 넘나드는 매끄러운 컨텍스트 인계, 토큰과 비용 추적을 제공합니다.
  • pi-agent-core: 도구 실행, 검증, 이벤트 스트리밍을 처리하는 에이전트 루프.
  • pi-tui: 차분 렌더링과 (거의) 깜빡임 없는 갱신을 위한 동기화 출력, 자동완성이 되는 편집기와 마크다운 렌더링 같은 컴포넌트를 갖춘 미니멀 터미널 UI 프레임워크.
  • pi-coding-agent: 세션 관리, 커스텀 도구, 테마, 프로젝트 컨텍스트 파일로 이 모두를 엮어 주는 실제 CLI.

이 모든 작업을 관통한 제 원칙은 이렇습니다. 제가 필요하지 않으면 만들지 않는다. 그리고 저는 필요한 게 별로 없습니다.

pi-ai와 pi-agent-core

이 패키지의 API 세부 사항으로 지루하게 만들지는 않겠습니다. 전부 README.md에서 읽을 수 있습니다. 대신 통합 LLM API를 만들면서 부딪힌 문제와 그것을 어떻게 풀었는지 기록해 두고 싶습니다. 제 해법이 최선이라고 주장하는 건 아니지만, 여러 에이전트형·비에이전트형 LLM 프로젝트에서 꽤 잘 동작해 왔습니다.

네. 개. 다. 조명이 아니라... API 말입니다

거의 모든 LLM 프로바이더와 대화하려면 사실 네 가지 API만 할 줄 알면 됩니다. OpenAI의 Completions API, 더 최신인 Responses API, Anthropic의 Messages API, 그리고 구글의 Generative AI API입니다.

기능은 다들 비슷해서 그 위에 추상화를 올리는 일이 그리 어렵지는 않습니다. 물론 프로바이더마다 챙겨야 할 특이사항이 있습니다. 특히 Completions API가 그런데, 거의 모든 프로바이더가 이 API를 말하지만 각자 이 API가 무엇을 해야 하는지에 대한 이해가 다릅니다. 예를 들어 OpenAI는 자사 Completions API에서 추론 트레이스를 지원하지 않지만, 다른 프로바이더들은 자기 버전의 Completions API에서 지원합니다. llama.cpp, Ollama, vLLM, LM Studio 같은 추론 엔진도 마찬가지입니다.

예를 들어 openai-completions.ts에서는 이렇습니다.

  • Cerebras, xAI, Mistral, Chutes는 store 필드를 싫어합니다
  • Mistral과 Chutes는 max_completion_tokens 대신 max_tokens를 씁니다
  • Cerebras, xAI, Mistral, Chutes는 시스템 프롬프트용 developer 역할을 지원하지 않습니다
  • Grok 모델은 reasoning_effort를 싫어합니다
  • 프로바이더마다 추론 내용을 서로 다른 필드로 반환합니다(reasoning_contentreasoning)

수도 없이 많은 프로바이더에서 모든 기능이 실제로 동작하는지 확인하려고, pi-ai에는 이미지 입력, 추론 트레이스, 도구 호출처럼 LLM API에 기대할 만한 기능을 다루는 상당히 방대한 테스트 스위트가 있습니다. 테스트는 지원하는 모든 프로바이더와 널리 쓰이는 모델을 대상으로 돌아갑니다. 좋은 시도이긴 하지만, 그래도 새 모델과 새 프로바이더가 바로 동작한다고 보장해 주지는 않습니다.

또 하나 큰 차이는 프로바이더가 토큰과 캐시 읽기·쓰기를 보고하는 방식입니다. Anthropic이 가장 제정신인 방식을 쓰지만, 전반적으로는 무법지대입니다. 어떤 곳은 SSE 스트림 시작에서 토큰 수를 알려 주고, 어떤 곳은 끝에서만 알려 주는데, 그러면 요청이 중단됐을 때 정확한 비용 추적이 불가능해집니다. 설상가상으로, 나중에 청구 API와 대조해서 어떤 사용자가 토큰을 얼마나 썼는지 알아낼 수 있는 고유 ID를 넘길 방법도 없습니다. 그래서 pi-ai는 토큰과 캐시 추적을 최선의 노력 수준으로만 합니다. 개인용으로는 충분하지만, 서비스를 통해 최종 사용자가 토큰을 쓰는 상황에서 정확하게 청구하기에는 부족합니다.

구글에는 특별히 한마디 해야겠습니다. 오늘까지도 도구 호출 스트리밍을 지원하지 않는 것 같은데, 참으로 구글답습니다.

pi-ai는 브라우저에서도 동작해서 웹 기반 인터페이스를 만들 때 유용합니다. 어떤 프로바이더는 CORS를 지원해서 이걸 특히 쉽게 해 주는데, 특히 Anthropic과 xAI가 그렇습니다.

컨텍스트 인계

프로바이더 사이의 컨텍스트 인계는 pi-ai를 설계할 때부터 염두에 둔 기능입니다. 프로바이더마다 도구 호출과 사고 트레이스를 기록하는 방식이 달라서, 이건 최선의 노력 수준일 수밖에 없습니다. 예를 들어 세션 중간에 Anthropic에서 OpenAI로 바꾸면, Anthropic의 사고 트레이스는 어시스턴트 메시지 안의 콘텐츠 블록으로 변환되고 <thinking></thinking> 태그로 감싸집니다. 이게 합리적일 수도 있고 아닐 수도 있는데, Anthropic과 OpenAI가 반환하는 사고 트레이스가 내부에서 실제로 벌어지는 일을 그대로 보여 주는 건 아니기 때문입니다.

이 프로바이더들은 이벤트 스트림에 서명된 블롭을 끼워 넣는데, 같은 메시지를 담은 후속 요청에서는 이걸 그대로 다시 실어 보내야 합니다. 같은 프로바이더 안에서 모델을 바꿀 때도 마찬가지입니다. 그래서 뒤편의 추상화와 변환 파이프라인이 번거로워집니다.

다행히 pi-ai에서는 프로바이더 간 컨텍스트 인계와 컨텍스트 직렬화·역직렬화가 꽤 잘 동작합니다.

import { getModel, complete, Context } from '@mariozechner/pi-ai';

// 클로드로 시작
const claude = getModel('anthropic', 'claude-sonnet-4-5');
const context: Context = {
  messages: []
};

context.messages.push({ role: 'user', content: 'What is 25 * 18?' });
const claudeResponse = await complete(claude, context, {
  thinkingEnabled: true
});
context.messages.push(claudeResponse);

// GPT로 전환 - 클로드의 사고 과정을 <thinking> 태그가 붙은 텍스트로 보게 됩니다
const gpt = getModel('openai', 'gpt-5.1-codex');
context.messages.push({ role: 'user', content: 'Is that correct?' });
const gptResponse = await complete(gpt, context);
context.messages.push(gptResponse);

// 제미나이로 전환
const gemini = getModel('google', 'gemini-2.5-flash');
context.messages.push({ role: 'user', content: 'What was the question?' });
const geminiResponse = await complete(gemini, context);

// 컨텍스트를 JSON으로 직렬화 (저장, 전송 등에 사용)
const serialized = JSON.stringify(context);

// 나중에: 역직렬화해서 어떤 모델로든 이어서 진행
const restored: Context = JSON.parse(serialized);
restored.messages.push({ role: 'user', content: 'Summarize our conversation' });
const continuation = await complete(claude, restored);

우리는 여러 모델을 함께 쓰는 세상에 살고 있습니다

모델 이야기가 나왔으니, 저는 getModel 호출에서 모델을 타입 안전하게 지정하고 싶었습니다. 그러려면 TypeScript 타입으로 바꿀 수 있는 모델 레지스트리가 필요했습니다. 그래서 OpenRoutermodels.dev(opencode 팀이 만들었습니다. 정말 유용하니 고맙다는 말을 전합니다)의 데이터를 파싱해서 models.generated.ts로 만들고 있습니다. 여기에는 토큰 비용과 이미지 입력·사고 지원 같은 기능 정보가 들어갑니다.

그리고 레지스트리에 없는 모델을 추가해야 할 때를 대비해, 새 모델을 쉽게 만들 수 있는 타입 시스템도 원했습니다. 셀프 호스팅 모델을 쓸 때, models.dev나 OpenRouter에 아직 올라오지 않은 신규 릴리스를 쓸 때, 혹은 잘 알려지지 않은 LLM 프로바이더를 시험해 볼 때 특히 유용합니다.

import { Model, stream } from '@mariozechner/pi-ai';

const ollamaModel: Model<'openai-completions'> = {
  id: 'llama-3.1-8b',
  name: 'Llama 3.1 8B (Ollama)',
  api: 'openai-completions',
  provider: 'ollama',
  baseUrl: 'http://localhost:11434/v1',
  reasoning: false,
  input: ['text'],
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  contextWindow: 128000,
  maxTokens: 32000
};

const response = await stream(ollamaModel, context, {
  apiKey: 'dummy' // Ollama는 실제 키가 필요 없습니다
});

통합 LLM API 중에는 요청을 중단하는 방법을 제공하는 걸 아예 무시하는 경우가 많습니다. LLM을 어떤 형태로든 프로덕션 시스템에 넣으려면 이건 절대 받아들일 수 없는 일입니다. 부분 결과를 돌려주지 않는 통합 LLM API도 많은데, 좀 황당한 일입니다. pi-ai는 처음부터 도구 호출까지 포함해 파이프라인 전체에서 중단을 지원하도록 설계했습니다. 동작 방식은 이렇습니다.

import { getModel, stream } from '@mariozechner/pi-ai';

const model = getModel('openai', 'gpt-5.1-codex');
const controller = new AbortController();

// 2초 후 중단
setTimeout(() => controller.abort(), 2000);

const s = stream(model, {
  messages: [{ role: 'user', content: 'Write a long story' }]
}, {
  signal: controller.signal
});

for await (const event of s) {
  if (event.type === 'text_delta') {
    process.stdout.write(event.delta);
  } else if (event.type === 'error') {
    console.log(\`${event.reason === 'aborted' ? 'Aborted' : 'Error'}:\`, event.error.errorMessage);
  }
}

// 결과 받기 (중단된 경우 부분 결과일 수 있습니다)
const response = await s.result();
if (response.stopReason === 'aborted') {
  console.log('Partial content:', response.content);
}

도구 결과를 구조적으로 나누기

어떤 통합 LLM API에서도 못 본 또 하나의 추상화는, 도구 결과를 LLM에 넘기는 부분과 UI에 보여 줄 부분으로 나누는 것입니다. LLM에 주는 쪽은 보통 텍스트나 JSON인데, 여기에 UI에 표시하고 싶은 정보가 다 들어 있는 건 아닙니다. 텍스트로 된 도구 출력을 파싱해서 UI에 보여 줄 형태로 다시 구성하는 일도 아주 괴롭습니다. pi-ai의 도구 구현은 LLM용 콘텐츠 블록과 UI 렌더링용 콘텐츠 블록을 따로 반환할 수 있게 합니다. 도구는 이미지 같은 첨부물도 반환할 수 있고, 각 프로바이더의 기본 형식으로 첨부됩니다. 도구 인자는 TypeBox 스키마와 AJV로 자동 검증되며, 검증에 실패하면 상세한 오류 메시지를 줍니다.

import { Type, AgentTool } from '@mariozechner/pi-ai';

const weatherSchema = Type.Object({
  city: Type.String({ minLength: 1 }),
});

const weatherTool: AgentTool<typeof weatherSchema, { temp: number }> = {
  name: 'get_weather',
  description: 'Get current weather for a city',
  parameters: weatherSchema,
  execute: async (toolCallId, args) => {
    const temp = Math.round(Math.random() * 30);
    return {
      // LLM에 줄 텍스트
      output: \`Temperature in ${args.city}: ${temp}°C\`,
      // UI용 구조화 데이터
      details: { temp }
    };
  }
};

// 도구는 이미지도 반환할 수 있습니다
const chartTool: AgentTool = {
  name: 'generate_chart',
  description: 'Generate a chart from data',
  parameters: Type.Object({ data: Type.Array(Type.Number()) }),
  execute: async (toolCallId, args) => {
    const chartImage = await generateChartImage(args.data);
    return {
      content: [
        { type: 'text', text: \`Generated chart with ${args.data.length} data points\` },
        { type: 'image', data: chartImage.toString('base64'), mimeType: 'image/png' }
      ]
    };
  }
};

아직 부족한 것은 도구 결과 스트리밍입니다. ANSI 시퀀스가 들어오는 대로 보여 주고 싶은 bash 도구를 떠올려 보세요. 지금은 불가능하지만, 간단한 수정이라 언젠가 패키지에 들어갈 겁니다.

도구 호출 스트리밍 중의 부분 JSON 파싱은 좋은 사용자 경험에 필수입니다. LLM이 도구 호출 인자를 스트리밍하는 동안 pi-ai는 그것을 점진적으로 파싱해서, 호출이 끝나기 전에 UI에 중간 결과를 보여 줄 수 있게 합니다. 예를 들어 에이전트가 파일을 다시 쓰는 동안 diff가 흘러 들어오는 모습을 보여 줄 수 있습니다.

최소한의 에이전트 골격

마지막으로 pi-ai는 전체 오케스트레이션을 담당하는 에이전트 루프를 제공합니다. 사용자 메시지를 처리하고, 도구 호출을 실행하고, 결과를 LLM에 다시 넣어 주고, 모델이 도구 호출 없이 응답할 때까지 이걸 반복합니다. 이 루프는 콜백을 통한 메시지 큐잉도 지원합니다. 매 턴이 끝나면 대기 중인 메시지를 요청하고, 다음 어시스턴트 응답 전에 그것을 끼워 넣습니다. 루프는 모든 일에 대해 이벤트를 내보내므로 반응형 UI를 만들기 쉽습니다.

이 에이전트 루프에는 다른 통합 LLM API에 있는 최대 스텝 수 같은 조절 장치가 없습니다. 그런 게 필요한 상황을 겪은 적이 없으니, 왜 넣겠습니까? 루프는 에이전트가 끝났다고 할 때까지 그냥 돕니다. 다만 이 루프 위에서 pi-agent-core는 실제로 유용한 것들을 담은 Agent 클래스를 제공합니다. 상태 관리, 단순화된 이벤트 구독, 두 가지 모드(하나씩 또는 한꺼번에)의 메시지 큐잉, 첨부물 처리(이미지, 문서), 그리고 에이전트를 직접 실행하거나 프록시를 통해 실행할 수 있게 하는 전송 계층 추상화입니다.

pi-ai에 만족하냐고요? 대부분은 그렇습니다. 어떤 통합 API든 추상화가 새기 때문에 완벽할 수는 없습니다. 하지만 지금까지 일곱 개의 프로덕션 프로젝트에서 쓰였고, 저에게는 아주 잘 맞았습니다.

Vercel AI SDK를 쓰지 않고 왜 직접 만들었냐고요? Armin의 블로그 글이 제 경험과 똑같습니다. 프로바이더 SDK 위에 직접 쌓으면 완전한 통제권을 얻고, API를 제가 원하는 대로 훨씬 작은 표면적으로 설계할 수 있습니다. 직접 만드는 이유에 대해서는 Armin의 블로그가 더 깊이 있게 다룹니다. 가서 읽어 보세요.

pi-tui

저는 DOS 시절에 자랐으니 터미널 사용자 인터페이스와 함께 자란 셈입니다. 둠의 화려한 설치 프로그램부터 볼랜드 제품까지, TUI는 90년대가 끝날 때까지 제 곁에 있었습니다. 그리고 결국 GUI 운영체제로 넘어갔을 때는 정말 끝내주게 행복했습니다. TUI는 대체로 이식성이 좋고 스트리밍하기도 쉽지만, 정보 밀도는 형편없습니다. 그런 말을 다 해 놓고도, pi는 터미널 사용자 인터페이스로 시작하는 게 가장 합리적이라고 생각했습니다. GUI는 필요하다고 느껴질 때 나중에 얹으면 되니까요.

그러면 왜 TUI 프레임워크를 직접 만들었을까요? Ink, Blessed, OpenTUI 같은 대안들을 살펴봤습니다. 다들 각자 나름대로 괜찮을 거라고 생각하지만, 저는 TUI를 React 앱처럼 쓰고 싶은 마음은 전혀 없었습니다. Blessed는 거의 관리되지 않는 것처럼 보이고, OpenTUI는 아예 프로덕션 준비가 안 됐다고 밝히고 있습니다. 게다가 Node.js 위에 TUI 프레임워크를 직접 만드는 일이 재미있는 작은 도전처럼 보였습니다.

TUI에는 두 종류가 있습니다

터미널 사용자 인터페이스를 만드는 일 자체가 그리 어렵지는 않습니다. 어느 쪽 고생을 택할지만 정하면 됩니다. 방법은 크게 두 가지입니다. 하나는 터미널 뷰포트(터미널 내용 중 실제로 눈에 보이는 부분)를 통째로 차지하고 그것을 픽셀 버퍼처럼 다루는 방식입니다. 픽셀 대신 셀이 있고, 각 셀에는 배경색·전경색과 기울임·굵게 같은 스타일이 붙은 문자가 들어갑니다. 저는 이걸 전체 화면 TUI라고 부릅니다. Amp와 opencode가 이 방식을 씁니다.

단점은 스크롤백 버퍼를 잃는다는 점이고, 그러면 검색을 직접 구현해야 합니다. 스크롤도 잃으니 뷰포트 안에서 스크롤 동작을 직접 만들어 줘야 합니다. 구현이 어렵지는 않지만, 터미널 에뮬레이터가 이미 제공하는 기능을 전부 다시 만들어야 한다는 뜻입니다. 특히 마우스 스크롤은 그런 TUI에서 늘 어딘가 어색하게 느껴집니다.

두 번째 방법은 그냥 다른 CLI 프로그램처럼 터미널에 써 나가는 것입니다. 내용을 스크롤백 버퍼에 덧붙이고, 애니메이션 스피너나 텍스트 편집 필드처럼 다시 그려야 하는 것이 있을 때만 보이는 뷰포트 안에서 '렌더링 커서'를 조금 위로 올립니다. 정확히 그렇게 단순하지는 않지만, 대충 그런 이야기입니다. Claude Code, Codex, Droid가 이 방식을 씁니다.

코딩 에이전트에는 사실상 채팅 인터페이스라는 좋은 성질이 있습니다. 사용자가 프롬프트를 쓰면, 에이전트의 답변과 도구 호출, 그리고 그 결과가 이어집니다. 전부 깔끔하게 선형이라 '기본' 터미널 에뮬레이터와 잘 맞습니다. 자연스러운 스크롤이나 스크롤백 버퍼 안 검색처럼 내장된 기능을 전부 그대로 쓸 수 있습니다. 이 방식은 TUI가 할 수 있는 일을 어느 정도 제한하기도 하는데, 저는 그게 마음에 듭니다. 제약이 있으면 군더더기 없이 해야 할 일만 하는 미니멀한 프로그램이 나오기 때문입니다. pi-tui에서는 이 방향을 골랐습니다.

리테인드 모드 UI

GUI 프로그래밍을 해 본 적이 있다면 리테인드 모드와 이미디어트 모드라는 말을 들어 봤을 겁니다. 리테인드 모드 UI에서는 프레임이 바뀌어도 유지되는 컴포넌트 트리를 쌓아 올립니다. 각 컴포넌트는 자기를 어떻게 그릴지 알고 있고, 바뀐 게 없으면 결과를 캐시해 둘 수 있습니다. 이미디어트 모드 UI에서는 매 프레임마다 전부 처음부터 다시 그립니다(실제로는 이미디어트 모드 UI도 캐싱을 합니다. 안 하면 버티지 못하니까요).

pi-tui는 단순한 리테인드 모드 방식을 씁니다. Component는 문자열 배열(뷰포트 폭에 맞는 줄들이며, 색과 스타일을 위한 ANSI 이스케이프 코드가 포함됩니다)을 반환하는 render(width) 메서드와, 키보드 입력을 받는 선택적 handleInput(data) 메서드를 가진 객체일 뿐입니다. Container는 세로로 배치된 컴포넌트 목록을 들고 있으면서 그들이 그려 낸 줄을 모두 모읍니다. TUI 클래스 자체도 컨테이너인데, 모든 것을 지휘합니다.

TUI가 화면을 갱신해야 할 때는 각 컴포넌트에 그리라고 요청합니다. 컴포넌트는 결과를 캐시할 수 있습니다. 스트리밍이 완전히 끝난 어시스턴트 메시지라면 매번 마크다운을 다시 파싱하고 ANSI 시퀀스를 다시 만들 필요가 없습니다. 캐시해 둔 줄을 그냥 반환하면 됩니다. 컨테이너는 모든 자식에게서 줄을 모읍니다. TUI는 이 줄들을 전부 모아, 이전 컴포넌트 트리로 앞서 그려 둔 줄들과 비교합니다. 일종의 백버퍼를 유지하면서 스크롤백 버퍼에 무엇을 썼는지 기억하는 것입니다.

그다음 바뀐 부분만 다시 그리는데, 저는 이 방법을 차분 렌더링(differential rendering)이라고 부릅니다. 저는 작명에 정말 서툴러서, 아마 이미 공식 명칭이 있을 겁니다.

차분(Differential) 렌더링

정확히 무엇이 다시 그려지는지 보여 주는 간단한 데모입니다.

알고리즘은 단순합니다.

  1. 첫 렌더링: 모든 줄을 터미널에 그냥 출력합니다
  2. 폭이 바뀐 경우: 화면을 완전히 지우고 전부 다시 그립니다(소프트 줄바꿈이 달라지므로)
  3. 일반적인 갱신: 화면에 있는 것과 처음으로 달라지는 줄을 찾아, 커서를 그 줄로 옮기고 거기서 끝까지 다시 그립니다

한 가지 함정이 있습니다. 처음 바뀐 줄이 보이는 뷰포트보다 위에 있으면(사용자가 위로 스크롤한 경우) 전체를 지우고 다시 그려야 합니다. 터미널은 뷰포트 위쪽의 스크롤백 버퍼에 쓰는 것을 허용하지 않습니다.

갱신 중 깜빡임을 막기 위해 pi-tui는 모든 렌더링을 동기화 출력 이스케이프 시퀀스(CSI ?2026hCSI ?2026l)로 감쌉니다. 터미널에게 출력을 전부 버퍼에 모아 두고 한 번에 원자적으로 보여 달라고 알려 주는 것입니다. 요즘 터미널은 대부분 이걸 지원합니다.

얼마나 잘 동작하고 얼마나 깜빡일까요? Ghostty나 iTerm2처럼 제대로 만든 터미널에서는 훌륭하게 동작하고 깜빡임이 전혀 보이지 않습니다. VS Code 내장 터미널처럼 사정이 좋지 않은 구현에서는 시간대, 화면 크기, 창 크기 같은 것에 따라 조금 깜빡입니다. 저는 Claude Code에 아주 익숙해져 있어서 여기에 더 시간을 들이지 않았습니다. VS Code에서 조금 깜빡이는 정도는 만족합니다. 안 그러면 오히려 낯설 것 같습니다. 그리고 그래도 Claude Code보다는 덜 깜빡입니다.

이 방식은 얼마나 낭비일까요? 앞서 그린 줄을 스크롤백 버퍼 하나만큼 저장하고, TUI가 그릴 때마다 줄을 다시 만듭니다. 위에서 설명한 캐싱으로 완화되니 다시 그리는 건 큰 문제가 아닙니다. 그래도 많은 줄을 서로 비교해야 합니다. 현실적으로, 25년 안에 만들어진 컴퓨터라면 성능이나 메모리 사용량(아주 큰 세션에서도 수백 킬로바이트) 모두 문제가 되지 않습니다. V8 덕분입니다. 그 대가로 제가 얻은 것은 빠르게 반복할 수 있는 아주 단순한 프로그래밍 모델입니다.

pi-coding-agent

코딩 에이전트 하니스에 어떤 기능을 기대해야 하는지는 설명할 필요가 없을 겁니다. pi에는 다른 도구에서 익숙해진 편의 기능이 대부분 들어 있습니다.

  • Windows, Linux, macOS에서 동작합니다(Node.js 런타임과 터미널이 있는 환경이면 어디든)
  • 여러 프로바이더를 지원하고 세션 중간에 모델을 바꿀 수 있습니다
  • 이어하기, 다시 열기, 분기가 되는 세션 관리
  • 전역에서 프로젝트별로 계층적으로 불러오는 프로젝트 컨텍스트 파일(AGENTS.md)
  • 자주 쓰는 작업을 위한 슬래시 명령
  • 인자를 받는 마크다운 템플릿 형태의 커스텀 슬래시 명령
  • Claude Pro/Max 구독을 위한 OAuth 인증
  • JSON으로 설정하는 커스텀 모델·프로바이더
  • 실시간으로 다시 불러오는 커스터마이즈 가능한 테마
  • 퍼지 파일 검색, 경로 자동완성, 드래그 앤 드롭, 여러 줄 붙여넣기를 지원하는 편집기
  • 에이전트가 작업하는 동안 메시지를 큐에 넣기
  • 비전 모델을 위한 이미지 지원
  • 세션을 HTML로 내보내기
  • JSON 스트리밍과 RPC 모드를 통한 헤드리스 실행
  • 완전한 비용·토큰 추적

전체 목록을 보고 싶다면 README를 읽어 보세요. 더 흥미로운 건 pi가 철학과 구현에서 다른 하니스와 어디서 갈라지는지입니다.

최소한의 시스템 프롬프트

시스템 프롬프트는 이렇습니다.

당신은 전문 코딩 어시스턴트입니다. 파일을 읽고, 명령을 실행하고, 코드를 편집하고, 새 파일을 작성해서 사용자의 코딩 작업을 돕습니다.

사용할 수 있는 도구:
- read: 파일 내용 읽기
- bash: bash 명령 실행
- edit: 파일을 정밀하게 수정
- write: 파일 생성 또는 덮어쓰기

가이드라인:
- ls, grep, find 같은 파일 작업에는 bash를 사용하세요
- 편집하기 전에 read로 파일을 확인하세요
- 정확한 변경에는 edit을 사용하세요(기존 텍스트가 정확히 일치해야 합니다)
- write는 새 파일이나 전체 재작성에만 사용하세요
- 수행한 작업을 요약할 때는 일반 텍스트로 바로 출력하세요. 한 일을 보여 주려고 cat이나 bash를 사용하지 마세요
- 응답은 간결하게 하세요
- 파일을 다룰 때는 파일 경로를 명확히 보여 주세요

문서:
- 자신에 대한 문서(커스텀 모델 설정과 테마 제작 포함)는 다음 위치에 있습니다: /path/to/README.md
- 사용자가 기능, 설정, 셋업에 대해 물을 때, 특히 커스텀 모델이나 프로바이더를 추가하거나 커스텀 테마를 만들어 달라고 할 때 이 문서를 읽으세요.

그게 전부입니다. 맨 아래에 끼워 넣어지는 건 AGENTS.md 파일뿐입니다. 모든 세션에 적용되는 전역 파일과 프로젝트 디렉터리에 저장된 프로젝트별 파일 둘 다 들어갑니다. 여기서 pi를 원하는 대로 손볼 수 있습니다. 원한다면 시스템 프롬프트 전체를 교체할 수도 있습니다. 예를 들어 Claude Code의 시스템 프롬프트, Codex의 시스템 프롬프트, 또는 opencode의 모델별 프롬프트(클로드용은 그들이 복사해 온 원래 Claude Code 프롬프트줄인 버전입니다)와 비교해 보세요.

이게 미친 짓이라고 생각할 수도 있습니다. 모델들은 아마 자기 네이티브 코딩 하니스에 대해 어느 정도 학습이 되어 있을 겁니다. 그러니 네이티브 시스템 프롬프트나, opencode처럼 그와 비슷한 것을 쓰는 게 가장 이상적이겠지요. 하지만 알고 보니 프런티어 모델들은 죄다 강화학습으로 지독하게 훈련되어 있어서, 코딩 에이전트가 무엇인지 본질적으로 이해합니다. 시스템 프롬프트가 1만 토큰씩 필요해 보이지는 않습니다. 뒤의 벤치마크 절에서 확인하게 될 것이고, 지난 몇 주 동안 pi만 써 오면서 개인적으로 경험한 것도 그렇습니다. Amp도 네이티브 시스템 프롬프트의 일부를 가져다 쓰지만, 자기 프롬프트로도 잘 지내는 것 같습니다.

최소한의 도구 모음

도구 정의는 이렇습니다.

read
  파일 내용을 읽습니다. 텍스트 파일과 이미지(jpg, png, gif, webp)를
  지원합니다. 이미지는 첨부물로 전송됩니다. 텍스트 파일은 기본적으로
  처음 2000줄을 읽습니다. 큰 파일에는 offset/limit을 사용하세요.
  - path: 읽을 파일 경로(상대 또는 절대)
  - offset: 읽기를 시작할 줄 번호(1부터 시작)
  - limit: 읽을 최대 줄 수

write
  파일에 내용을 씁니다. 파일이 없으면 만들고, 있으면 덮어씁니다.
  상위 디렉터리는 자동으로 만듭니다.
  - path: 쓸 파일 경로(상대 또는 절대)
  - content: 파일에 쓸 내용

edit
  정확한 텍스트를 치환해서 파일을 편집합니다. oldText는 공백까지
  정확히 일치해야 합니다. 정밀하고 국소적인 편집에 사용하세요.
  - path: 편집할 파일 경로(상대 또는 절대)
  - oldText: 찾아서 치환할 정확한 텍스트(정확히 일치해야 합니다)
  - newText: 기존 텍스트를 대체할 새 텍스트

bash
  현재 작업 디렉터리에서 bash 명령을 실행합니다. stdout과 stderr를
  반환합니다. 선택적으로 초 단위 타임아웃을 지정할 수 있습니다.
  - command: 실행할 bash 명령
  - timeout: 초 단위 타임아웃(선택, 기본 타임아웃 없음)

에이전트가 파일을 수정하거나 임의의 명령을 실행하지 못하게 제한하고 싶다면 읽기 전용 도구(grep, find, ls)가 더 있습니다. 기본적으로는 꺼져 있어서 에이전트는 위의 네 가지 도구만 받습니다.

알고 보니 효과적인 코딩 에이전트에 필요한 건 이 네 가지 도구가 전부입니다. 모델은 bash를 쓸 줄 알고, 비슷한 입력 스키마를 가진 read, write, edit 도구로 학습되어 있습니다. 이걸 Claude Code의 도구 정의opencode의 도구 정의(구조도, 예시도, git 커밋 흐름도 같아서 Claude Code에서 파생된 게 분명합니다)와 비교해 보세요. 눈에 띄는 건 Codex의 도구 정의도 pi만큼이나 최소한이라는 점입니다.

pi의 시스템 프롬프트와 도구 정의를 합치면 1,000 토큰 아래입니다.

기본값은 YOLO입니다

pi는 완전한 YOLO 모드로 돌아가며, 사용자가 자기가 무엇을 하는지 안다고 가정합니다. 파일시스템에 제한 없이 접근하고, 권한 확인이나 안전장치 없이 어떤 명령이든 실행할 수 있습니다. 파일 조작이나 명령에 대한 권한 확인 창도 없습니다. 악성 내용이 있는지 Haiku가 bash 명령을 미리 검사하는 것도 없습니다. 파일시스템에 완전히 접근합니다. 사용자 권한으로 어떤 명령이든 실행할 수 있습니다.

다른 코딩 에이전트의 보안 조치를 들여다보면 대부분 보여주기식 보안(security theater)입니다. 에이전트가 코드를 쓰고 실행할 수 있게 된 순간, 사실상 끝난 겁니다. 데이터 유출을 막는 유일한 방법은 에이전트가 도는 실행 환경의 네트워크 접근을 전부 끊는 것인데, 그러면 에이전트가 거의 무용지물이 됩니다. 대안으로 도메인 허용 목록을 쓸 수 있지만, 이것도 다른 방법으로 우회할 수 있습니다.

사이먼 윌리슨이 이 문제에 대해 길게 써 왔습니다. 그가 제안한 이중 LLM(dual LLM) 패턴은 혼란된 대리자 공격과 데이터 유출을 다루려 하지만, 본인도 이 해법이 꽤 나쁘다고 인정합니다. 게다가 구현 복잡도를 엄청나게 키웁니다. 핵심 문제는 그대로입니다. LLM이 개인 데이터를 읽고 네트워크 요청을 보낼 수 있는 도구에 접근할 수 있다면, 공격 경로를 두고 두더지 잡기를 하는 셈입니다.

이 세 가지 능력(데이터 읽기, 코드 실행, 네트워크 접근)이 겹치는 문제를 풀 수 없으니, pi는 그냥 포기합니다. 어차피 생산적인 일을 하려면 모두가 YOLO 모드로 돌리고 있는데, 그럼 그걸 기본값이자 유일한 선택지로 두면 어떻겠습니까?

기본적으로 pi에는 웹 검색이나 페치 도구가 없습니다. 하지만 curl을 쓸 수 있고 디스크에서 파일을 읽을 수 있는데, 둘 다 프롬프트 인젝션 공격에 충분한 표면적을 제공합니다. 파일이나 명령 출력에 담긴 악성 내용이 동작에 영향을 줄 수 있습니다. 완전한 접근이 불편하다면 pi를 컨테이너 안에서 돌리거나, (허울뿐인) 안전장치가 필요하다면 다른 도구를 쓰세요.

내장 할 일 목록은 없습니다

pi는 내장 할 일 목록을 지원하지 않고, 앞으로도 지원하지 않을 겁니다. 제 경험으로 할 일 목록은 도움이 되기보다 모델을 헷갈리게 하는 편입니다. 모델이 추적하고 갱신해야 하는 상태가 늘어나고, 그러면 일이 잘못될 여지도 늘어납니다.

작업 추적이 필요하면 파일에 써서 상태를 밖에 두세요.

# TODO.md

- [x] 사용자 인증 구현
- [x] 데이터베이스 마이그레이션 추가
- [ ] API 문서 작성
- [ ] 레이트 리미팅 추가

에이전트는 필요할 때 이 파일을 읽고 갱신할 수 있습니다. 체크박스를 쓰면 무엇이 끝났고 무엇이 남았는지 따라갈 수 있습니다. 단순하고, 눈에 보이고, 내 통제 안에 있습니다.

플랜 모드는 없습니다

pi에는 내장 플랜 모드가 없고, 앞으로도 없을 겁니다. 파일을 수정하거나 명령을 실행하지 말고 문제를 함께 짚어 보자고 에이전트에게 말하는 것으로 대개 충분합니다.

세션을 넘어 계획을 유지해야 한다면 파일에 쓰세요.

# PLAN.md

## 목표
OAuth를 지원하도록 인증 시스템 리팩터링

## 접근 방법
1. OAuth 2.0 흐름 조사
2. 토큰 저장 스키마 설계
3. 인가 서버 엔드포인트 구현
4. 클라이언트 측 로그인 흐름 갱신
5. 테스트 추가

## 현재 단계
3단계 진행 중 - 인가 엔드포인트

에이전트는 일하는 동안 계획을 읽고, 갱신하고, 참조할 수 있습니다. 세션 안에서만 존재하는 일회성 플랜 모드와 달리, 파일로 된 계획은 여러 세션에서 함께 쓸 수 있고 코드와 함께 버전 관리할 수 있습니다.

재미있는 건 Claude Code에도 이제 플랜 모드가 있는데, 본질적으로 읽기 전용 분석이고 결국은 마크다운 파일을 디스크에 씁니다. 그리고 명령 실행을 어마어마하게 많이 승인해 주지 않으면 플랜 모드를 사실상 쓸 수 없습니다. 그러지 않으면 계획 자체가 거의 불가능하니까요.

pi와 다른 점은 제가 모든 것을 완전히 관찰할 수 있다는 것입니다. 에이전트가 실제로 어떤 자료를 봤고 어떤 것을 완전히 놓쳤는지 볼 수 있습니다. Claude Code에서는 지휘하는 클로드 인스턴스가 보통 서브에이전트를 띄우는데, 그 서브에이전트가 무엇을 하는지는 전혀 보이지 않습니다. 저는 마크다운 파일을 바로 봅니다. 에이전트와 함께 그 파일을 고칠 수 있습니다. 요약하면, 계획에는 관찰 가능성이 필요한데 Claude Code의 플랜 모드로는 그걸 얻지 못합니다.

계획 중에 에이전트를 꼭 제한해야 한다면, CLI로 접근할 수 있는 도구를 지정할 수 있습니다.

pi --tools read,grep,find,ls

그러면 에이전트가 아무것도 수정하지 못하고 bash 명령도 실행하지 못하는, 탐색과 계획용 읽기 전용 모드가 됩니다. 다만 그게 마음에 들지는 않을 겁니다.

MCP는 지원하지 않습니다

pi는 MCP를 지원하지 않고, 앞으로도 지원하지 않을 겁니다. 이에 대해서는 길게 쓴 적이 있는데, 요약하면 MCP 서버는 대부분의 용도에 과하고 컨텍스트 오버헤드가 상당하다는 것입니다.

Playwright MCP(도구 21개, 1만 3,700 토큰)나 Chrome DevTools MCP(도구 26개, 1만 8,000 토큰)처럼 인기 있는 MCP 서버는 세션마다 도구 설명 전체를 컨텍스트에 쏟아 넣습니다. 일을 시작하기도 전에 컨텍스트 창의 7%에서 9%가 사라지는 겁니다. 그중 많은 도구는 해당 세션에서 한 번도 쓰지 않을 것들입니다.

대안은 단순합니다. README가 딸린 CLI 도구를 만드는 것입니다. 에이전트는 그 도구가 필요할 때 README를 읽고, 필요한 순간에만 토큰 비용을 치르며(점진적 공개), bash로 도구를 호출할 수 있습니다. 이 방식은 조합할 수 있고(출력을 파이프로 넘기고 명령을 엮을 수 있습니다), 확장하기 쉽고(스크립트를 하나 더 추가하면 됩니다), 토큰도 아낍니다.

pi에 웹 검색을 붙이는 방법은 이렇습니다.

이런 도구들을 github.com/badlogic/agent-tools에 모아 두고 관리합니다. 각 도구는 에이전트가 필요할 때 읽는 README가 딸린 단순한 CLI입니다.

MCP 서버를 정말 반드시 써야 한다면, MCP 서버를 CLI 도구로 감싸 주는 페터 슈타인베르거mcporter를 살펴보세요.

백그라운드 bash는 없습니다

pi의 bash 도구는 명령을 동기적으로 실행합니다. 개발 서버를 띄우거나, 테스트를 백그라운드로 돌리거나, 명령이 아직 돌고 있는 동안 REPL과 주고받는 내장 방법은 없습니다.

의도한 것입니다. 백그라운드 프로세스 관리는 복잡도를 키웁니다. 프로세스 추적, 출력 버퍼링, 종료 시 정리, 그리고 돌고 있는 프로세스에 입력을 보내는 방법이 필요합니다. Claude Code는 백그라운드 bash 기능으로 일부를 처리하지만, 관찰 가능성이 떨어지고(Claude Code에서 자주 보이는 문제입니다) 조회할 도구도 주지 않은 채 에이전트가 돌고 있는 인스턴스를 기억하게 만듭니다. 예전 Claude Code 버전에서는 컨텍스트 컴팩션 뒤에 에이전트가 자기 백그라운드 프로세스를 다 잊어버리고 조회할 방법도 없어서, 직접 죽여야 했습니다. 이후 수정됐습니다.

대신 tmux를 쓰세요. pi가 LLDB에서 크래시하는 C 프로그램을 디버깅하는 모습입니다.

관찰 가능성으로는 어떻습니까? 같은 방식이 오래 도는 개발 서버, 로그 출력 지켜보기 같은 용도에도 그대로 통합니다. 원한다면 위의 LLDB 세션에 tmux로 들어가서 에이전트와 함께 디버깅할 수도 있습니다. tmux는 활성 세션을 전부 나열하는 CLI 인자도 줍니다. 얼마나 좋습니까.

백그라운드 bash는 그냥 필요하지 않습니다. 아시다시피 Claude Code도 tmux를 쓸 수 있습니다. bash만 있으면 충분합니다.

서브에이전트는 없습니다

pi에는 전용 서브에이전트 도구가 없습니다. Claude Code는 복잡한 일을 해야 할 때 작업의 일부를 서브에이전트에게 맡기는 경우가 많습니다. 그 서브에이전트가 무엇을 하는지는 전혀 보이지 않습니다. 블랙박스 안의 블랙박스입니다. 에이전트 사이의 컨텍스트 전달도 부실합니다. 지휘하는 에이전트가 서브에이전트에게 어떤 초기 컨텍스트를 넘길지 결정하는데, 사용자는 거기에 거의 손을 댈 수 없습니다. 서브에이전트가 실수를 하면 전체 대화를 볼 수 없으니 디버깅이 고통스럽습니다.

pi가 자기 자신을 띄우게 하고 싶다면, bash로 자기를 실행하라고 시키면 됩니다. 완전한 관찰 가능성과 서브에이전트와 직접 주고받는 기능이 필요하면 tmux 세션 안에서 자기를 띄우게 할 수도 있습니다.

하지만 더 중요한 건 이겁니다. 워크플로를 고치세요. 최소한 컨텍스트 수집이 핵심인 워크플로만이라도요. 사람들은 세션 안에서 서브에이전트를 쓰면 컨텍스트 공간을 아낀다고 생각하는데, 그건 사실입니다. 하지만 서브에이전트를 그렇게 생각하는 건 잘못입니다. 세션 중간에 컨텍스트 수집을 위해 서브에이전트를 쓰는 건 미리 계획하지 않았다는 신호입니다. 컨텍스트를 모아야 한다면, 그 일을 먼저 별도의 세션에서 하세요. 나중에 새 세션에서 쓸 수 있는 산출물을 만들어 두면, 도구 출력으로 컨텍스트 창을 더럽히지 않고도 에이전트에게 필요한 컨텍스트를 전부 줄 수 있습니다. 그 산출물은 다음 기능을 만들 때도 쓸 수 있고, 관찰 가능성과 조종 가능성도 온전히 확보됩니다. 컨텍스트를 모으는 동안에는 그게 중요합니다.

널리 퍼진 믿음과 달리, 모델은 아직 새 기능을 구현하거나 버그를 고치는 데 필요한 컨텍스트를 전부 찾아내는 일에 서툽니다. 저는 그 원인을 모델이 파일 전체가 아니라 일부만 읽도록 학습된 데서 찾습니다. 그래서 전부 읽기를 주저합니다. 결국 중요한 컨텍스트를 놓치고, 작업을 제대로 마치는 데 필요한 것을 보지 못합니다.

pi-mono 이슈 트래커와 풀 리퀘스트를 보세요. 에이전트가 무엇이 필요한지 온전히 파악하지 못해서 닫히거나 고쳐지는 것이 많습니다. 기여자들의 잘못이 아닙니다. 불완전한 PR이라도 제가 더 빨리 나아가는 데 도움이 되니 정말 고맙게 생각합니다. 그저 우리가 에이전트를 너무 믿고 있다는 뜻입니다.

서브에이전트를 완전히 부정하는 건 아닙니다. 타당한 용도가 있습니다. 제가 가장 자주 쓰는 건 코드 리뷰입니다. 커스텀 슬래시 명령으로 pi에게 코드 리뷰 프롬프트를 주고 자기를 띄우라고 시키면, 그 결과를 받아 옵니다.

---
description: 코드 리뷰 서브에이전트 실행
---
bash로 자기 자신을 서브에이전트로 띄워 코드 리뷰를 수행하세요: $@

적절한 인자와 함께 \`pi --print\`를 사용하세요. 사용자가 모델을 지정하면
그에 맞게 \`--provider\`\`--model\`을 사용하세요.

서브에이전트에게 다음 항목을 코드에서 리뷰하도록 요청하는 프롬프트를 넘기세요:
- 버그와 논리 오류
- 보안 문제
- 오류 처리 누락

코드를 직접 읽지 마세요. 서브에이전트가 읽게 하세요.

서브에이전트가 찾아낸 내용을 보고하세요.

그리고 이걸로 GitHub의 풀 리퀘스트를 리뷰하는 방법은 이렇습니다.

간단한 프롬프트로 무엇을 리뷰할지, 어떤 모델을 쓸지 고를 수 있습니다. 원하면 사고 수준도 정할 수 있습니다. 리뷰 세션 전체를 파일로 저장해 두고 다른 pi 세션에서 그걸 열어 볼 수도 있습니다. 아니면 이건 일회성 세션이니 디스크에 저장하지 말라고 할 수도 있습니다. 이 모든 게 메인 에이전트가 읽는 프롬프트로 바뀌고, 에이전트는 그걸 근거로 bash로 자기를 다시 실행합니다. 서브에이전트 내부 동작까지 다 볼 수는 없지만, 그 결과는 온전히 볼 수 있습니다. 다른 하니스는 이걸 제대로 제공하지 않는데, 저는 도무지 이해가 되지 않습니다.

물론 이건 다소 만들어진 사례입니다. 실제로는 그냥 새 pi 세션을 띄워서 풀 리퀘스트를 리뷰하라고 시키고, 필요하면 로컬 브랜치로 가져오게 합니다. 첫 리뷰를 보고 나서 제 리뷰를 얹고, 그다음 괜찮아질 때까지 함께 다듬습니다. 쓰레기 같은 코드를 머지하지 않기 위해 제가 쓰는 방식입니다.

여러 기능을 병렬로 구현하려고 서브에이전트를 여러 개 띄우는 건 제가 보기엔 안티패턴이고 통하지 않습니다. 코드베이스가 쓰레기 더미로 무너져도 상관없다면 이야기가 다르겠지만요.

벤치마크

제가 거창한 주장을 많이 하는데, 위에서 말한 이 삐딱한 이야기들이 실제로 통한다는 수치 증거가 있을까요? 제 경험은 있지만, 그건 블로그 글로 옮기기 어렵고 그냥 믿어 달라고 하는 수밖에 없습니다. 그래서 Claude Opus 4.5를 쓴 pi로 Terminal-Bench 2.0 테스트를 돌려서, Codex, Cursor, Windsurf와 각자의 네이티브 모델을 쓰는 다른 코딩 하니스들과 겨루게 했습니다. 물론 벤치마크가 실제 성능을 대표하지 않는다는 건 다들 압니다. 하지만 제 말이 전부 헛소리는 아니라는 증거로 제가 내놓을 수 있는 최선입니다.

작업마다 다섯 번씩 시도하는 전체 실행을 완료했고, 그래서 결과를 리더보드에 제출할 자격이 생겼습니다. 미국 태평양 시간대가 깨어나면 오류율이(따라서 벤치마크 결과도) 나빠진다는 걸 알게 되어서, 중부 유럽 시간대에만 돌아가는 두 번째 실행도 시작했습니다. 첫 번째 실행 결과는 이렇습니다.

그리고 2025년 12월 2일 기준 현재 리더보드에서 pi의 순위는 이렇습니다.

그리고 리더보드에 넣어 달라고 Terminal-Bench 팀에 제출한 results.json 파일입니다. 결과를 재현해 보고 싶다면 pi용 벤치 러너는 이 저장소에 있습니다. 종량제보다는 클로드 구독 플랜을 쓰시길 권합니다.

마지막으로, 중부 유럽 시간대에만 돌린 실행을 살짝 보여 드립니다.

이건 끝나기까지 하루쯤 더 걸릴 것 같습니다. 끝나면 이 글을 갱신하겠습니다.

리더보드에서 Terminus 2의 순위도 봐 주세요. Terminus 2는 Terminal-Bench 팀이 직접 만든 미니멀 에이전트로, 모델에게 tmux 세션만 하나 줍니다. 모델은 명령을 텍스트로 tmux에 보내고 터미널 출력을 직접 파싱합니다. 화려한 도구도, 파일 조작도 없이 그냥 터미널과 주고받기만 합니다. 그런데도 훨씬 정교한 도구를 갖춘 에이전트들 사이에서 제 몫을 해내고, 다양한 모델과도 잘 동작합니다. 미니멀한 방식도 그만큼 잘할 수 있다는 증거가 하나 더 생긴 셈입니다.

정리하면

벤치마크 결과는 웃음이 나오는 이야기지만, 진짜 증명은 실제로 써 봐야 나옵니다. 제 시험대는 매일의 실무이고, 거기서 pi는 훌륭하게 해내고 있습니다. 트위터에는 컨텍스트 엔지니어링 글이 넘쳐나지만, 지금 우리가 가진 하니스 중에 실제로 컨텍스트 엔지니어링을 할 수 있게 해 주는 건 없다고 느낍니다. pi는 제가 최대한 통제권을 쥘 수 있는 도구를 직접 만들어 보려는 시도입니다.

지금의 pi에 저는 꽤 만족합니다. 컴팩션이나 도구 결과 스트리밍처럼 더 넣고 싶은 기능이 몇 개 있지만, 개인적으로 그 이상 필요한 건 별로 없을 것 같습니다. 컴팩션이 없다는 게 저에게는 문제가 되지 않았습니다. 무슨 이유인지 저는 에이전트와 수백 번 주고받은 내용을 한 세션에 밀어 넣을 수 있는데, Claude Code에서는 컴팩션 없이 그렇게 하지 못했습니다.

그래도 기여는 환영합니다. 다만 제 모든 오픈소스 프로젝트가 그렇듯, 저는 독재자처럼 운영하는 편입니다. 큰 프로젝트들을 하며 여러 해에 걸쳐 어렵게 배운 교훈입니다. 보내 주신 이슈나 PR을 제가 닫더라도 서운해하지 않으셨으면 합니다. 이유도 최선을 다해 설명하겠습니다. 저는 이 프로젝트를 초점이 분명하고 관리 가능한 상태로 두고 싶을 뿐입니다. pi가 필요에 맞지 않는다면, 포크하시길 정말 권합니다. 진심입니다. 그리고 제 필요에 더 잘 맞는 것을 만드신다면, 기쁘게 그 일에 함께하겠습니다.

위의 배움 중 일부는 다른 하니스에도 그대로 옮겨 갈 거라고 생각합니다. 어떻게 되는지 알려 주세요.

Edit this page

Search the archive

Find a page by title, or search the text inside one.