Codemode란 무엇인가
TMT1년도 더 전에 저는 이 블로그에 몇 편의 글을 쓰면서, 커스텀 도구(또는 MCP 서버)를 컨텍스트에 불러오지 말고 그냥 스크립트를 더 많이 쓰라고 권했습니다. 무엇보다 Code Is All You Need라는 글에서 코드만 있으면 된다고 썼고, MCP needs code라는 글에서는 MCP에 코드가 필요하다고 썼습니다. 이번 Pi 1.0에서는 Codemode를 통해 MCP 지원을 추가했는데, 어떤 면에서는 진작 나왔어야 할 기능이지만 어떤 분들에게는 다소 의외일 수도 있습니다. 그래서 이 모든 것이 무슨 의미인지, 달라진 제 생각을 이 블로그에서 나누려고 합니다.
도구란 무엇일까요?
Pi 같은 하네스가 LLM이 호출할 도구를 제공할 때는 도구 정의를 넘겨주고, 이 정의는 서버 쪽에서 특정한 토큰 구조로 변환됩니다. 모델이 도구를 호출하도록 유도되는지는 강화 학습 과정에서 결정됩니다. 이 내용은 예전에도 다룬 적이 있으니, 더 알고 싶다면 참고하세요.
저희가 CLI와 bash를 강하게 선호하는 이유 중 하나는 호출을 쉽게 조합할 수 있고, 모델이 학습 과정에서 파일 시스템이 어떻게 동작하는지도 함께 배우기 때문입니다. 그래서 모델은 echo foo > /tmp/test.txt 같은 도구를 호출하면, 그 호출 뒤에 /tmp에 test.txt라는 파일이 생긴다는 것까지 배웁니다.
하지만 bash에는 근본적인 한계가 하나 있습니다. 실행되는 프로그램만 조합할 수 있다는 점입니다. 그런데 어떤 것들은 프로그램이 아니라 LLM에 내장된 네이티브 도구이고, 어느 정도는 그럴 수밖에 없습니다.
가장 뚜렷한 예가 read나 view_image입니다. 멀티모달 모델이 이미지를 읽어야 할 때는 cat을 쓸 수 없습니다. 하네스가 실제 이미지 페이로드를 LLM 프로토콜에 직접 넣어 줘야 하기 때문입니다.
또 하나 아주 선명한 예는 서브 에이전트입니다. 서브 에이전트를 띄우고 조율하려면 하네스가 제공하는 도구를 피하기 어렵습니다. 이론적으로는 에이전트가 환경 변수와 Unix 소켓으로 바깥 하네스와 통신하는 CLI 도구를 제공할 수도 있지만, 꽤 조잡한 방식입니다. 게다가 여기에는 다른 문제도 있는데, 바로 코드가 어디에서 실행되느냐입니다.
두뇌와 손
이를 더 잘 이해하려면 각 구성 요소가 어디에서 실행되는지 조금 더 생각해 볼 필요가 있습니다. 보통은 서로 다른 두 시스템이 관여합니다. 첫 번째는 두뇌, 즉 하네스입니다. 하네스는 한 머신에서 실행되고, 신뢰할 수 있는 쪽입니다. 두 번째는 흔히 같은 머신이지만, 실제로 도구가 실행되는 곳, 즉 손입니다. Pi에서는 이제 이것을 실행 환경(execution environment)이라고 부르지만, 모든 작업이 향하는 대상이라고 생각해도 됩니다.
저희에게 결정적으로 중요한 점은, 두뇌인 하네스와 bash를 돌리고 도구를 실행하는 대상 환경 사이에 경계선이 있다는 것입니다.
그리고 이렇게 둘로 나누면 아주 중요한 결과가 몇 가지 생깁니다. 우선 둘은 서로 다른 파일 시스템에서 실행되고, 신뢰 수준도 다릅니다. 예를 들어 Gondolin 같은 샌드박싱 솔루션을 쓰면 bash 쪽 작업은 문제없이 샌드박스 안에 갇히지만, 하네스 자체는 그렇지 않습니다.
하네스 쪽에서 오케스트레이션하기
여기서 Codemode가 실제로 하는 일이 나옵니다. Codemode는 LLM이 실행 환경 쪽이 아니라 하네스 쪽에서 복잡한 작업을 표현하고 오케스트레이션하는 방법입니다. Codemode는 하네스 안의 자체 샌드박스에서 실행됩니다. Pi의 경우 의도적으로 제약을 건 WASM 런타임 안의 QuickJS에서 실행됩니다. 네트워크도, 파일 시스템도, 타이머도 없고, RAM도 제한되어 있습니다. 할 수 있는 일은 도구를 더 호출하는 것뿐입니다. Codemode가 Scheme이나 다른 언어를 실행하는 모습도 충분히 상상해 볼 수 있습니다.
Codemode가 낯설다면, 기본적으로는 어떤 언어 안에서(저희의 경우 JavaScript) 도구 호출을 실행하는 방법이라고 보시면 됩니다. 이렇게 하면 호출을 조합할 때 꼭 LLM의 컨텍스트를 거치지 않아도 됩니다. 이름을 붙인 공은 이 용어를 처음 만든 Cloudflare의 친구들에게 돌립니다.
예를 들어 LLM에서 bash 호출을 일반 도구 호출로 실행하면, 저희는 마지막 2,000줄만 컨텍스트에 넣고, 에이전트가 더 보고 싶으면 넘친 출력을 담은 파일을 직접 열어 봐야 합니다. 하지만 에이전트가 같은 호출을 Codemode로 실행하면, Codemode 쪽은 더 큰 출력을 구조화된 형태로 전달받습니다.
가장 중요한 점은 Codemode가 JavaScript이기 때문에 에이전트가 동시 실행 작업과 기본적인 워크플로를 표현할 수 있다는 것입니다. 요즘 에이전트가 이를 활용하는 흔한 방식은, 먼저 어떤 도구 응답에서 항목 5~10개를 살펴 형태를 파악한 다음, 다음 n개 항목을 처리하는 Codemode 스크립트를 작성하는 것입니다.
Codemode로는 상태를 대화 기록(transcript)에 넣어 둘 수도 있습니다! 한 번의 Codemode 호출이 데이터를 저장해 두면, 세션의 다음 호출이 그 데이터를 다시 불러올 수 있다는 뜻입니다. 그리고 기억하세요. 이 모든 일은 샌드박스가 아니라 하네스 호스트에서 일어납니다.
Pi의 경우, Codemode를 쓰면 Pi의 전통적인 인터페이스에서는 애초에 말이 안 되는 호출도 할 수 있습니다. 예를 들어 이미지 모델로 이미지를 생성하거나 원샷 분류 모델로 텍스트를 분류하고 싶다면, 해당 Pi API는 Codemode로는 노출되어 있지만, 컨텍스트만 낭비하게 될 일반 도구로는 노출되어 있지 않습니다.
실제 모습
지금까지 이런저런 이야기를 했으니, 이제 조금 더 구체적으로 보여 드리는 게 좋겠습니다. 최근 제 Pi 세션에서 있었던 Codemode 호출 몇 가지를 함께 살펴보겠습니다. 이 코드 중 사람이 작성한 것은 하나도 없습니다. 실제 Pi 세션에서 가져왔고, 보기 편하도록 들여쓰기만 다시 했습니다. 에이전트가 Codemode를 자동으로 쓰기 시작하는 경우는 두 가지입니다. 모델이 원래 그 도구를 자연스럽게 집어 드는 작업이거나, 사용자가 그렇게 하라고 요청한 경우입니다.
참고로 Pi에서 Codemode는 기본적으로 MCP가 활성화되어 있을 때만 켜지지만, 설정에서 "defaultTools": ["+codemode"]로 켤 수 있습니다. Pi에게 켜 달라고 부탁하기만 하면 됩니다.
이미지 생성하기
간단하게 이미지 생성부터 시작해 보겠습니다. 이미지 생성은 Pi가 AI SDK 코어에서 지원하는 기능이지만, 에이전트가 쓸 수 있는 도구는 아닙니다. 예전에는 이미지 모델을 쓰려면 전용 확장을 직접 만들거나, 에이전트가 직접 node를 실행해 내부 이미지 API를 쓰게 하는 수밖에 없었습니다. 하지만 이제는 Codemode 안에 내부 모델 API를 꽤 많이 노출해 두었기 때문에, 에이전트가 다음처럼 이를 쓸 수 있습니다.
const [painter] = await models.getAvailableOfType("image");
const result = await models.generateImages(painter, {
input: [{ type: "text", text: "A cute little puppy sitting on a grassy " +
"lawn, soft natural light, photorealistic" }],
});
if (result.stopReason !== "stop") return result.errorMessage;
for (const block of result.output) {
if (block.type === "image") image(block);
else text(block.text);
}여기서 image() 호출은 이미지를 이미지 콘텐츠로 LLM에 돌려보냅니다. 하네스 쪽에서는 이 이미지를 에이전트에 바로 넘기는 동시에, 에이전트가 나중에 그 이미지를 bash로 다시 넘기고 싶을 때를 대비해 임시 산출물로 디스크에도 저장합니다.
분류하기
Jev 같은 분류 모델에도 비슷한 이야기가 적용됩니다. 분류 모델 역시 일반적인 도구를 통해서는 에이전트의 워크플로에 잘 맞지 않습니다. 하지만 전용 도구를 따로 제공하는 대신, Codemode에서는 에이전트가 AI SDK에 바로 접근해 이 모델들을 직접 호출할 수 있습니다. 다음은 Jev로 GitHub 이슈를 대량으로 처리해 간단한 감정 분석을 하는 예입니다.
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
const r = await tools.bash({
command: "gh issue list --state open --limit 100 " +
"--json number,title,body,comments",
});
const issues = JSON.parse(r.output);
const results = await Promise.all(issues.map(async (issue) => {
const res = await models.classify(jev, {
state: {
title: issue.title,
body: (issue.body || "").slice(0, 4000),
comments: issue.comments.slice(-5).map(c => c.body.slice(0, 800)),
},
questions: {
sentiment: {
type: "choice",
instructions: "What is the overall sentiment of the author towards pi?",
criteria: {
positive: "Appreciative, happy, constructive praise",
neutral: "Matter-of-fact report or request without emotion",
negative: "Frustrated, annoyed, upset, or angry",
},
},
frustration: {
type: "score",
instructions: "How frustrated is the reporter?",
criteria: ["not at all", "mildly", "clearly frustrated", "very angry"],
},
kind: {
type: "choice",
instructions: "What kind of issue is this?",
criteria: {
bug: "Bug report or regression",
feature: "Feature request or enhancement",
question: "Question or support request",
other: "Docs, discussion, meta, spam",
},
},
},
});
if (res.stopReason !== "stop") {
return { n: issue.number, title: issue.title, error: res.errorMessage };
}
return { n: issue.number, title: issue.title, ...res.answers };
}));
store("sentiment_results", results);
return results
.filter(r => !r.error)
.sort((a, b) => b.frustration.score - a.frustration.score)
.slice(0, 12)
.map(r => \`#${r.n} ${r.frustration.score.toFixed(2)} [${r.kind.choice}] ${r.title}\`);위 예제에서 store()도 호출한다는 점에 주목하세요. 이 함수는 실행 결과를 세션 대화 기록에 저장합니다. 그래서 이후의 Codemode 호출이 필요하면 그 결과를 다시 읽어 올 수 있습니다.
여기서 Promise.all을 써도 괜찮습니다. Pi가 동시에 실행되는 도구 수를 자체적으로 최대 4개로 제한하고, 나머지는 큐에 넣어 관리하기 때문입니다.
좀 더 과감한 예로, Jev로 게임 엔진을 조종해 디버깅에 활용할 수도 있습니다.
Jev와 Codemode로 게임 디버깅하기
여기서 에이전트는 제 tankctl 명령을 알고 있었고, 사용자가 문제를 디버깅하도록 돕기 위해 이 명령을 감싸는 최소한의 하네스를 재빨리 직접 만들어 게임 루프를 돌렸습니다. 30단계짜리 루프를 만들었다는 점을 보세요. 각 단계마다 먼저 게임 엔진에 가서 무슨 일이 일어나고 있는지 텍스트 덤프를 받고, 그다음 Jev에 가서 다음에 무엇을 할지 정합니다.
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
const tank = async (cmd) =>
(await tools.bash({ command: \`tools/tankctl "${cmd}"\` })).output;
await tank("start --map assets/maps/night_arena.map");
const questions = {
action: {
type: "choice",
instructions: "You control the tank '@' in a top-down tank game. " +
"Choose the best next action.",
criteria: {
attack: "an enemy has line of sight to you and you can fire at it",
approach: "no enemy has line of sight; drive toward the nearest enemy",
dodge: "an enemy shot is heading at you and will hit soon",
powerup: "a powerup is close and no enemy threatens you",
},
},
};
function commandFor(choice, st) {
const p = st.player;
const enemy = st.enemies.filter(e => !e.dead)
.sort((a, b) => (b.los - a.los) || (a.dist - b.dist))[0];
if (choice === "attack" && enemy) {
return \`fire_at tank ${enemy.id}; frames 30 until clear,damage,kill\`;
}
if (choice === "dodge") {
// move perpendicular to the closest incoming shot
const s = st.projectiles.filter(s => !s.yours)
.sort((a, b) => a.eta - b.eta)[0];
const dir = s && Math.abs(s.vel[0]) > Math.abs(s.vel[1])
? (p.pos[1] > s.pos[1] ? "+down" : "+up")
: (p.pos[0] > (s ? s.pos[0] : 0) ? "+right" : "+left");
return \`input ${dir}; frames 20 until damage; input stop\`;
}
const powerup = st.powerups.filter(u => u.available)
.sort((a, b) => a.dist - b.dist)[0];
if (choice === "powerup" && powerup) {
return \`goto ${powerup.pos[0]} ${powerup.pos[1]} 180\`;
}
return enemy ? \`goto ${enemy.pos[0]} ${enemy.pos[1]} 90\` : null;
}
const log = [];
for (let step = 0; step < 30; step++) {
const st = JSON.parse(await tank("state"));
if (st.state !== "playing") break;
const threats = st.projectiles
.filter(s => !s.yours && s.miss_dist < 1.5 && s.eta < 1.5)
.map(s => \`incoming shot dist ${s.dist} eta ${s.eta}s\`)
.join("\n") || "no incoming shots";
const r = await models.classify(jev, {
state: { map: await tank("view 8"), threats, hp: st.player.hp },
questions,
});
if (r.stopReason !== "stop") {
log.push(\`#${step} classifier error: ${r.errorMessage}\`);
break;
}
const choice = r.answers.action.choice;
const cmd = commandFor(choice, st);
if (!cmd) break;
log.push(\`#${step} hp=${st.player.hp} ${choice} -> ${await tank(cmd)}\`);
}
return log.join("\n");MCP 서버 호출하기
마지막으로, 당연하게도 Codemode는 MCP 서버를 호출하는 데 아주 좋습니다. 저희는 MCP 도구를 LLM에 전혀 노출하지 않기 때문에, 에이전트는 먼저 제공된 API로 Codemode 안에서 도구 검색을 실행해 연결된 서버로 무엇을 할 수 있는지 알아냅니다. 이렇게 점진적으로 탐색하는 방식 덕분에, 오늘날 MCP 전반이 많은 사용 사례에서 충분히 잘 돌아갑니다.
예를 들어 여기서는 에이전트가 도구를 탐색하지도 않고 곧바로 Sentry MCP를 집어 듭니다. 아마 강화 학습 과정에서 Sentry MCP가 어떻게 생겼는지 이미 배웠기 때문일 것입니다. 다만 Sentry 서버를 쓸 수 있다는 사실 자체는 저희가 시스템 프롬프트에 넣어 주는 내용을 보고 알게 됩니다. 완전히 짐작으로 하는 것은 아닙니다.
const orgs = await tools.mcp__sentry__find_organizations({});
const { organizations } = orgs.structuredContent;
const results = await Promise.allSettled(organizations.map(org =>
tools.mcp__sentry__find_projects({
organizationSlug: org.slug,
regionUrl: org.regionUrl,
})
));
return organizations.map((org, i) => {
const r = results[i];
if (r.status !== "fulfilled") return { org: org.slug, error: String(r.reason) };
if (r.value.isError) return { org: org.slug, error: r.value.content };
return {
org: org.slug,
projects: r.value.structuredContent.projects.map(p => p.slug),
};
});지금의 MCP는 힘겨운 싸움입니다
여기서 MCP 이야기를 너무 많이 하고 싶지는 않지만, 사실 MCP는 Codemode의 덕을 크게 보는 프로토콜입니다. 문제의 일부는 실제 MCP가 Codemode를 (아직?) 쓰지 않는 하네스를 대상으로 만들어지는 경우가 많다는 데 있습니다. 하지만 흐름은 바뀌고 있습니다. 그동안 임시방편으로 쓰인 방법은 Cloudflare가 한 것처럼 MCP 서버 안에서 Codemode를 돌리는 것이었습니다. 그런데 그러면 Codemode 안에 Codemode가 들어가는 꼴이 되고, 이는 꽤 좋지 않습니다. JSON 이스케이프를 이중으로 해야 하고, 작은 모델은 쉽게 헷갈리며, 안쪽 코드가 바깥쪽 도구를 호출할 수도 없습니다. 그래서 예를 들어 Pi에서 Cloudflare MCP 서버를 쓰면, 에이전트는 JavaScript를 작성해서 그것을 또 다른 JavaScript에 실어 보내야 합니다. 정말 최적과는 거리가 멀지만, 왜 이런 일이 벌어지는지도 이해는 됩니다.
const accRes = await tools.mcp__cloudflare__execute({
code: \`async () => {
const r = await cloudflare.request({ method: "GET", path: "/accounts" });
return r.result.map(a => ({ id: a.id, name: a.name }));
}\`,
});
const accounts = JSON.parse(accRes.content.map(c => c.text).join(""));
const out = [];
for (const account of accounts) {
const r = await tools.mcp__cloudflare__execute({
account_id: account.id,
code: \`async () => {
const r = await cloudflare.request({
method: "GET",
path: \\`/accounts/\${accountId}/workers/scripts\\`,
});
return r.result.map(s => ({ id: s.id, modified: s.modified_on }));
}\`,
});
out.push({ account: account.name, workers: r.content.map(c => c.text).join("") });
}
return out;MCP에 바라는 것
그럼 마무리하면서 묻겠습니다. 오늘날 Codemode는 MCP와 얼마나 잘 맞을까요? 글쎄요… 썩 잘 맞지는 않습니다. MCP 서버들이 아직 Codemode를 쓰는 하네스를 제대로 겨냥하고 있지 않기 때문입니다(다만 지금은 대부분의 하네스가 Codemode를 지원한다고 생각합니다).
이 조합이 잘 돌아가도록 몇 가지 권장 사항을 정리했습니다.
- 구조화된 콘텐츠: Codemode는 호출 결과로 깔끔하게 형식이 갖춰진 JSON을 받고 싶어 합니다. 그러려면 서버가 그런 JSON을 돌려줘야 하는데, 아직 그렇게 하지 않는 서버가 많습니다. MCP의
outputSchema시스템이 이 용도에 아주 좋습니다. - 일관된 결과: MCP 서버가 일관된 데이터를 돌려주지 않을 때 흥미로운 실패 사례가 생깁니다. 예를 들어 결과 집합에 항목이 몇 개 들어 있느냐에 따라 토큰을 아끼려고 응답을 다르게 구성하는 경우입니다. 그러면 항목 5개로 처음 살펴볼 때는 성공했다가, 서버가 최대 배치 크기로 결과를 돌려줄 때는 실패할 수 있습니다.
- 대용량 바이너리 데이터: 현재 MCP는 대용량 바이너리 데이터를 아직 지원하지 않아서, 정말 흥미로운 사용 사례 중 상당수가 아직 전혀 제대로 동작하지 않습니다. 그래서 파일 업로드를 MCP가 아닌 경로로 처리하도록 사전 서명된 URL(pre-signed URL)을 쓰는 식의 온갖 이상한 우회책을 쓰게 됩니다.
- 조합 가능한 도구 검색: 어떤 작업에 어떤 도구가 알맞은지는 MCP 클라이언트보다 MCP 서버가 더 잘 알 수도 있습니다. 하지만 지금은 하네스가 여러 MCP 서버로 도구 검색을 나눠 보낼 수 있는 좋은 메커니즘이 없습니다. 모두 창발적인 동작에 기대고 있어서, 활성 서버가 여러 개가 되면 잘 확장되지 않습니다.
Codemode의 미래
그렇다면 이 모든 이야기의 결론은 무엇일까요? 1년 전에 CLI를 권하며 쓴 글을 뒤집는 걸까요? 저는 그렇게 생각하지 않습니다. 오히려 제가 보기에 MCP 생태계는 1년 전에 저희가 효과가 있다고 짚은 바로 그것, 즉 코드를 받아들였습니다. 다만 Codemode는 하네스 안에서 에이전트에게 더 많은 자유를 주는 유능한 메커니즘으로 동작할 수 있다는 점에서 MCP를 넘어섭니다.
하지만 아직 풀어야 할 문제도 있습니다. 우선 Codemode에서는 내구성(durability)을 확보하기가 더 까다롭습니다. 호출을 스냅샷으로 남기려면 내구성 있는 워크플로 엔진(durable workflow engine)의 아이디어를 일부 가져와야 할지도 모릅니다. 아니면 결정론적인 특성을 생각하면 Starlark 같은 언어가 JavaScript보다 조합용 언어로 더 나을 수도 있습니다.
이미지와 바이너리 데이터, 그리고 이 패턴이 작은 모델에서는 아예 잘 동작하지 않는다는 점도 더 다듬어야 할 부분입니다. 그러니 아직 완벽한 해법이라고 할 수는 없지만, 꽤 유용한 패턴이고 앞으로 더 많이 활용하게 될 것이라고 봅니다.