Last active
July 29, 2026 05:06
-
-
Save code-yeongyu/6871ca81a5962ff1fddee7b2ed9fa620 to your computer and use it in GitHub Desktop.
하네스를 sandbox 밖에서 실행하세요 (왜, 그리고 어떻게) — Nathan Flurry / Rivet, Opus 5 번역
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html><html lang=ko><meta charset=utf-8> | |
| <meta name=viewport content="width=device-width,initial-scale=1"> | |
| <title>하네스를 sandbox 밖에서 실행하세요 (왜, 그리고 어떻게)</title> | |
| <style> | |
| :root{color-scheme:light dark}*{box-sizing:border-box} | |
| body{margin:0;padding:0 20px 120px;font:17px/1.78 -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",Pretendard,"Noto Sans KR",sans-serif;color:#1a1a1a;background:#fbfbfa;-webkit-text-size-adjust:100%} | |
| main{max-width:720px;margin:0 auto;padding-top:48px} | |
| .cover{margin:0 0 32px;border-radius:12px;overflow:hidden;border:1px solid #e6e6e1} | |
| .cover img{width:100%;display:block} | |
| .meta{font-size:14px;color:#8a8a85;margin:0 0 40px;padding-bottom:24px;border-bottom:1px solid #e6e6e1} | |
| .meta a{color:#8a8a85} | |
| h1{font-size:31px;line-height:1.32;letter-spacing:-.02em;margin:52px 0 18px;font-weight:700} | |
| h1:first-of-type{margin-top:0} | |
| h2{font-size:22px;line-height:1.4;margin:44px 0 14px;font-weight:650} | |
| h3{font-size:19px;margin:34px 0 12px;font-weight:640} | |
| p{margin:0 0 20px}ul{margin:0 0 22px;padding-left:22px}li{margin:0 0 12px} | |
| strong{font-weight:660} | |
| code{font:.9em ui-monospace,SFMono-Regular,Menlo,monospace;background:#efefec;padding:2px 6px;border-radius:4px} | |
| pre{background:#f4f4f1;border:1px solid #e6e6e1;border-radius:10px;padding:16px;overflow-x:auto;margin:0 0 24px} | |
| pre code{background:none;padding:0;font-size:13.5px;line-height:1.6} | |
| a{color:#2b6cb0;text-underline-offset:2px} | |
| figure{margin:28px 0} | |
| figure img{width:100%;display:block;border-radius:10px;border:1px solid #e6e6e1;background:#fff} | |
| figcaption{margin-top:10px;font-size:13.5px;line-height:1.6;color:#8a8a85;text-align:center} | |
| @media(prefers-color-scheme:dark){ | |
| body{background:#16161a;color:#e4e4e2} | |
| .meta{color:#77777f;border-bottom-color:#2c2c33}.meta a{color:#77777f} | |
| code{background:#26262d}pre{background:#1d1d23;border-color:#2c2c33} | |
| a{color:#7cb0e8}figure img{border-color:#2c2c33;background:#1d1d23} | |
| figcaption{color:#5a5a63}.cover{border-color:#2c2c33}} | |
| </style> | |
| <main> | |
| <div class=cover><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/image.png?v=2" alt=""></div> | |
| <p class=meta>Nathan Flurry (Rivet) · 2026-07-27 · <a href="https://x.com/NathanFlurry/status/2081768022025658672" target=_blank rel=noopener>원문(X)</a> · <a href="https://rivet.dev/blog/2026-07-27-run-your-harness-outside-the-sandbox/" target=_blank rel=noopener>원문(블로그)</a> · Opus 5 번역</p> | |
| <h1>하네스를 sandbox 밖에서 실행하세요 (왜, 그리고 어떻게)</h1> | |
| <p>2026년 초부터 에이전트를 어디서 실행할 것인가에 대한 논쟁이 계속되어 왔습니다. <strong>sandbox 안인가, 아니면 밖인가</strong>. 양쪽 방식 모두 수많은 구현체가 나왔지만, 업계는 <strong>성숙한 프로젝트일수록 에이전트를 sandbox 밖에서 실행하는 방향</strong>으로 움직이고 있습니다.</p> | |
| <p>지난 몇 달 사이에 OpenAI, Anthropic, Vercel, Cloudflare, Amp가 모두 agent loop를 sandbox 밖에서 실행하는 플랫폼을 출시했습니다.</p> | |
| <p>#</p> | |
| <h1>sandbox 안에서 에이전트 실행하기: 셋업은 쉽지만, 유지보수는 어렵습니다</h1> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/sandbox-arch-2.png?v=1" alt="Each agent runs inside its own sandbox next to the shell and filesystem" loading="lazy"><figcaption>Each agent runs inside its own sandbox next to the shell and filesystem</figcaption></figure> | |
| <p>많은 회사들이 <strong>에이전트를 sandbox 안에서 실행하는 것</strong>으로 시작합니다. 솔직히 말해서 그게 간단하기 때문입니다. 셋업은 대략 이런 모습입니다:</p> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/sandbox-sequence.png?v=1" alt="Sequence diagram: the user creates an agent through your API, your API creates a sandbox, and each prompt is run by execing the Claude CLI inside the sandbox" loading="lazy"><figcaption>Sequence diagram: the user creates an agent through your API, your API creates a sandbox, and each prompt is run by execing the Claude CLI inside the sandbox</figcaption></figure> | |
| <p>하지만 이 아키텍처로 운영하기 시작하면, <strong>sandbox 안에서 돌아가는 것들의 본질적으로 혼란스러운 성질</strong>과의 힘겨운 싸움이 시작됩니다.</p> | |
| <h1>에이전트를 sandbox 안에서 실행하면 안 되는 이유</h1> | |
| <p>sandbox는 신뢰할 수 없는 코드를 실행하기 위해 만들어진 것이지, <strong>에이전트 자체를 호스팅하기 위한 것이 아닙니다</strong>. 에이전트를 안에서 실행하는 방식은 세 가지 이유로 무너집니다.</p> | |
| <h2>이유 1: Blast Radius</h2> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/sandbox-bomb.png?v=1" alt="A sandbox with a bomb inside it" loading="lazy"><figcaption>A sandbox with a bomb inside it</figcaption></figure> | |
| <p>sandbox는 <strong>에이전트가 만들어내는 혼란을 가두기 위한 공간</strong>입니다. 이건 설계 의도이기도 합니다. sandbox 안에서 에이전트가 할 수 있는 일에는 아무런 구조가 없고, 그래서 sandbox는 정말 다양한 이유로 깨집니다. 빌드 커맨드가 sandbox를 OOM으로 죽이기도 하고, 폭주하는 스크립트가 CPU를 점유하기도 하고, 잘못된 도구 설치가 <code>$PATH</code>를 망가뜨리기도 하고, 그냥 파일시스템이 손상되기도 합니다.</p> | |
| <p>sandbox가 깨지면 다음 것들이 함께 무너집니다:</p> | |
| <ul> | |
| <li><strong>agent loop, 재시도, 그리고 durability</strong>: 장애로부터 복구하기 위해 존재하는 durability 메커니즘이 sandbox가 죽을 때 함께 죽습니다. 재시도 로직은 sandbox 자체의 장애를 우아하게 처리할 수 있도록 백엔드에 있어야 합니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Session history</strong>: session history를 sandbox 안에 저장하면 다른 모든 것과 함께 손상되거나 날아갑니다. 밖에, 제대로 된 데이터베이스에 보관하세요.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>장애에 대한 observability</strong>: sandbox가 죽으면 자기 자신의 장애를 observability에 보고할 수 없습니다. 감지된 장애는 여러분의 백엔드에서 전송하세요.</li> | |
| </ul> | |
| <h2>이유 2: Trust Boundary</h2> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/sandbox-unlock.png?v=1" alt="A sandbox with an open padlock inside it" loading="lazy"><figcaption>A sandbox with an open padlock inside it</figcaption></figure> | |
| <p>sandbox는 <strong>아무것도 신뢰하지 않는(trust-nothing) 아키텍처</strong>를 전제로 합니다. 에이전트는 prompt injection에 취약하고 민감한 정보를 유출하기 쉽기 때문입니다. sandbox 안에서 접근 가능한 것이 있다면, 그 데이터나 API는 악용되고 유출된다고 가정하는 편이 안전합니다.</p> | |
| <p>다음 중 어느 것도 sandbox 안에서는 안전하게 할 수 없습니다:</p> | |
| <ul> | |
| <li><strong>LLM 자격증명과 라우팅</strong>: sandbox 안의 토큰은 곧 에이전트가 유출하거나 악용할 수 있는 토큰입니다. 밖에 두세요. <em>(일부 라우터는 테넌트별로 스코프가 제한된 토큰을 제공해 이 문제를 완화해 줍니다.)</em></li> | |
| </ul> | |
| <ul> | |
| <li><strong>권한과 승인(approvals)</strong>: 코딩 에이전트는 장애물을 우회하도록 학습되어 있습니다. 따라서 sandbox 안에서 강제하는 권한은 에이전트가 어떻게든 우회할 수 있는 권한입니다. 신뢰할 수 있는 백엔드에서 밖으로 강제하세요.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Audit log</strong>: sandbox 안에서 기록된 audit log는 에이전트가 조작하거나 손상시킬 수 있는 로그입니다. 밖에서 기록하세요.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>멀티플레이어 보안</strong>: sandbox에 대한 접근 권한은 곧 모든 것에 대한 접근 권한입니다. 그래서 sandbox 안에서는 협업자들에게 사용자별 권한을 부여할 방법이 없습니다. 사용자별 권한은 백엔드에서 강제하세요.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>신뢰할 수 있는 도구</strong>: 사설 데이터베이스나 내부 API와 통신하는 도구는 sandbox에 맡길 수 없는 자격증명을 필요로 합니다. 이런 것들은 harness에서 도구로 제공하세요. 별도의 커스텀 인증 프록시가 필요 없습니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>에이전트 간 통신</strong>: sandbox 대 sandbox 통신은 모든 sandbox가 형제 sandbox들의 주소와 자격증명을 들고 있어야 한다는 뜻입니다. 복잡하고, 실수하기 쉽고, 안전하지도 않습니다. 대신 복잡한 인증·라우팅 메커니즘이 필요 없는 백엔드에서 에이전트 간 통신을 라우팅하세요.</li> | |
| </ul> | |
| <h2>이유 3: sandbox는 항상 실행 중인 게 아닙니다</h2> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/sandbox-snooze.png?v=1" alt="A sleeping sandbox" loading="lazy"><figcaption>A sleeping sandbox</figcaption></figure> | |
| <p>sandbox는 설계상 <strong>사용하지 않을 때 잠듭니다</strong>. 그런데 이게 에이전트를 강력하게 만드는 많은 요소들을 망가뜨립니다.</p> | |
| <p>sandbox가 잠들어 있는 동안 동작하지 않게 되는 것들은 다음과 같습니다:</p> | |
| <ul> | |
| <li><strong>스케줄("loops")</strong>: 잠든 sandbox는 스스로 깨어나 무언가를 트리거할 수 없습니다. sandbox를 깨우는 건 밖에 있는 harness의 몫입니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>워크플로우("graphs")</strong>: durable한 다단계 워크플로우는 실패를 재시도하고 몇 시간씩 잠들어 있을 수도 있습니다. 이를 재개시켜 줄 무언가가 sandbox 밖에 존재해야 합니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>세션 빠르게 불러오기</strong>: 세션을 읽는 데 sandbox를 깨워야 해서는 안 됩니다. sandbox를 시작하는 데는 오랜 시간이 걸릴 수 있기 때문입니다. history를 외부 데이터베이스에 저장하면 읽기는 즉시 끝납니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>세션 인덱싱</strong>: 여러 세션을 가로질러 검색하려면 모든 트랜스크립트를 읽어야 하는데, 인덱스를 만들자고 sandbox 무리를 전부 깨울 수는 없습니다. history가 밖에 있으면 인덱싱은 그냥 데이터베이스 작업일 뿐입니다.</li> | |
| </ul> | |
| <h1>올바른 아키텍처: sandbox를 도구로 노출하세요</h1> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/agent-out-of-sbx-arch.png?v=1" alt="The agents run in your backend, and each one calls into its own sandbox holding just the shell and filesystem" loading="lazy"><figcaption>The agents run in your backend, and each one calls into its own sandbox holding just the shell and filesystem</figcaption></figure> | |
| <p>에이전트와 sandbox를 위한 올바른 아키텍처는 어떤 모습일까요? <strong>agent harness를 sandbox 안이 아니라 여러분의 백엔드에서 실행하도록 옮기는 것</strong>이 핵심입니다.</p> | |
| <p>harness가 스크립트를 실행하거나, 파일을 읽고 쓰거나, sandbox 안에서 다른 무언가를 해야 할 때는 sandbox로 원격 호출을 보내는 도구를 통합니다.</p> | |
| <p><strong>harness가 실행되는 머신에서는 아무것도 실행되지 않습니다.</strong> 대신 모든 것은 sandbox로 향하는 tool call로 실행됩니다.</p> | |
| <h1>Vercel AI SDK를 이용한 간단한 예제</h1> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/actor-arch-detailed.png?v=1" alt="Your backend runs the generateText loop and the tools. The loop talks to the LLM provider, and the tools call into the sandbox's shell and filesystem" loading="lazy"><figcaption>Your backend runs the generateText loop and the tools. The loop talks to the LLM provider, and the tools call into the sandbox's shell and filesystem</figcaption></figure> | |
| <p>개념을 소개하기 위해, 가능한 한 가장 단순한 버전부터 시작해 봅시다. <em>generateText</em> 루프와 <strong>도구로 노출된 sandbox</strong>입니다. 여기엔 프레임워크도, 오케스트레이션 레이어도 없습니다. <strong>도구 세 개와 루프 하나, 약 90줄의 코드</strong>입니다.</p> | |
| <p>이 코드는 세 가지 일을 합니다.</p> | |
| <p>1단계: 로컬 Docker 프로바이더에서 <strong>sandbox를 생성</strong>합니다:</p> | |
| <pre><code>import { docker } from "@computesdk/docker"; | |
| // Boot a sandbox on the local Docker provider. Swap `docker` for any other | |
| // provider (e2b, daytona, vercel, etc.). | |
| const compute = docker({ | |
| runtime: "node", | |
| image: { name: "node:22-slim", pullPolicy: "ifNotPresent" }, | |
| }); | |
| const sandbox = await compute.sandbox.create();</code></pre> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/multiple-actors.png?v=1" alt="Each user talks to an agent actor with its own SQLite database, and each actor talks to its own sandbox. Idle agents sleep and their sandboxes pause" loading="lazy"><figcaption>Each user talks to an agent actor with its own SQLite database, and each actor talks to its own sandbox. Idle agents sleep and their sandboxes pause</figcaption></figure> | |
| <p>2단계: sandbox를 호출하는 <strong>도구들을 정의</strong>합니다:</p> | |
| <pre><code>import { tool } from "ai"; | |
| import { z } from "zod"; | |
| // The agent's tools run here in your own process. The sandbox only receives | |
| // the commands and file operations that the tools send to it. | |
| const tools = { | |
| runCommand: tool({ | |
| description: "Run a shell command in the sandbox", | |
| inputSchema: z.object({ | |
| command: z.string().describe("The shell command to run"), | |
| }), | |
| execute: async ({ command }) => { | |
| const result = await sandbox.runCommand(command); | |
| return { | |
| stdout: result.stdout, | |
| stderr: result.stderr, | |
| exitCode: result.exitCode, | |
| }; | |
| }, | |
| }), | |
| writeFile: tool({ | |
| description: "Write a file in the sandbox", | |
| inputSchema: z.object({ | |
| path: z.string().describe("Absolute path of the file"), | |
| content: z.string().describe("Full contents of the file"), | |
| }), | |
| execute: async ({ path, content }) => { | |
| await sandbox.filesystem.writeFile(path, content); | |
| return { ok: true }; | |
| }, | |
| }), | |
| readFile: tool({ | |
| description: "Read a file from the sandbox", | |
| inputSchema: z.object({ | |
| path: z.string().describe("Absolute path of the file"), | |
| }), | |
| execute: async ({ path }) => { | |
| return { content: await sandbox.filesystem.readFile(path) }; | |
| }, | |
| }), | |
| };</code></pre> | |
| <figure><img src="https://assets.rivet.dev/website/blog/2026-07-27-run-your-harness-outside-the-sandbox/actor-sequence.png?v=1" alt="Sequence diagram: creating the agent creates an actor, the actor creates a sandbox, and each prompt is processed by the actor making tool calls into the sandbox" loading="lazy"><figcaption>Sequence diagram: creating the agent creates an actor, the actor creates a sandbox, and each prompt is processed by the actor making tool calls into the sandbox</figcaption></figure> | |
| <p>3단계: 터미널에서 작업을 읽어들이는 <strong>agent loop를 구성</strong>합니다:</p> | |
| <pre><code>import * as readline from "node:readline/promises"; | |
| import { anthropic } from "@ai-sdk/anthropic"; | |
| import { generateText, type ModelMessage, stepCountIs } from "ai"; | |
| // Read tasks from the terminal and run the agent on each one | |
| const terminal = readline.createInterface({ | |
| input: process.stdin, | |
| output: process.stdout, | |
| }); | |
| // The session history | |
| const messages: ModelMessage[] = []; | |
| while (true) { | |
| // Wait for the user to type a task | |
| const prompt = await terminal.question("agent> "); | |
| messages.push({ role: "user", content: prompt }); | |
| // Run the agent loop until the task is complete | |
| const result = await generateText({ | |
| model: anthropic("claude-sonnet-5"), | |
| system: | |
| "You are a coding agent working inside a fresh Linux sandbox with Node.js installed. " + | |
| "Use the tools to run commands and edit files to complete the user's task.", | |
| messages, | |
| tools, | |
| stopWhen: stepCountIs(20), | |
| }); | |
| messages.push(...result.response.messages); | |
| console.log(result.text); | |
| }</code></pre> | |
| <p>이렇게 하면 에이전트의 세션, 권한, observability, 그리고 위에서 논의한 모든 것에 대한 <strong>완전한 통제권</strong>을 갖게 됩니다.</p> | |
| <p>실행 가능한 전체 예제는 GitHub에 있습니다.</p> | |
| <h1>프로덕션으로 가져가기: Stateless HTTP 서버의 문제</h1> | |
| <p>위 스크립트는 로컬 개발 머신에서는 아주 잘 동작하지만, 이제 프로덕션화가 필요합니다.</p> | |
| <p>그런데 문제가 있습니다. 대부분의 백엔드는 <strong>stateless HTTP 서버</strong>이고, 그 위에서 에이전트를 실행하면 금방 지저분해집니다:</p> | |
| <ul> | |
| <li><strong>Race condition</strong>: 같은 에이전트에 대한 두 개의 호출이 서로 다른 서버에 도착하고, 둘 다 루프를 실행하면서 동시에 history를 기록합니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>프롬프트 취소</strong>: 실행 중인 프롬프트를 중단하려면 그것을 실행 중인 요청에 손을 뻗어야 하는데, stateless 서버에서는 그럴 방법이 없습니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>멀티플레이어</strong>: 같은 에이전트와 대화하는 여러 사용자에게는 공유된, 살아 있는 연결 지점이 필요한데, stateless 엔드포인트로는 불가능합니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Durability와 내결함성</strong>: 루프 도중에 서버가 죽으면 에이전트도 함께 죽고, 아무것도 재개해 주지 않습니다.</li> | |
| </ul> | |
| <p>또 다른 대안으로 "durable agent"를 위한 워크플로우 엔진을 고려할 수도 있습니다. 하지만 <strong>에이전트의 메인 루프는 무한히 실행되고</strong>, 워크플로우 엔진은 끝나지 않는 루프를 위해 만들어진 적이 없습니다:</p> | |
| <ul> | |
| <li><strong>Replay가 느려집니다</strong>: 워크플로우 엔진은 지금까지의 모든 단계를 replay해서 상태를 재구성하는데, 끝나지 않는 루프는 끝없이 커지는 replay를 의미합니다. <em>(일부 워크플로우 엔진은 history를 제한하는 편법을 갖고 있지만, 이런 용도로 만들어진 게 아닙니다.)</em></li> | |
| </ul> | |
| <ul> | |
| <li><strong>실행 중 업그레이드가 어렵습니다</strong>: 살아 있는 실행 아래에서 워크플로우 코드를 바꾸는 건 악명 높게 어렵고, 절대 종료되지 않는 에이전트라는 건 모든 배포가 실행 도중에 일어난다는 뜻입니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>실시간도, 멀티플레이어도 없습니다</strong>: 워크플로우는 WebSocket을 유지하거나 클라이언트로 토큰을 스트리밍할 수 없어서, 결국 그걸 위한 별도 레이어를 만들게 됩니다.</li> | |
| </ul> | |
| <p>stateless 서버와 워크플로우 엔진은 같은 이유로 실패합니다. 에이전트는 <strong>장수하는(long-lived) stateful 워크로드</strong>인데, 둘 다 그런 용도로 만들어지지 않았기 때문입니다.</p> | |
| <h1>Actor 모델: 에이전트를 위한 stateful 아키텍처</h1> | |
| <p><strong>stateful 워크로드</strong>라는 문제는 전혀 새로운 게 아닙니다.</p> | |
| <p><strong>에이전트마다 작은 Node.js 프로세스를 영원히 돌린다</strong>고 상상해 보세요. 크래시가 나면 재시작하고, 유휴 상태면 잠들고, 그리고 여기로 요청을 보낼 수 있습니다.</p> | |
| <p>그게 바로 <strong>actor 모델</strong>입니다. 에이전트 하나당 actor 하나, 그리고 각각이 자기 sandbox를 갖습니다. WhatsApp, Discord, 그리고 Halo의 멀티플레이어를 떠받치는 것과 같은 아키텍처이며, <strong>프로덕션에서 에이전트를 실행하는 최선의 방법</strong>입니다.</p> | |
| <p>앞 단계에서 만든 agent loop를 다시 보면, 이 구조는 <strong>actor 모델과 잘 맞습니다</strong>. 우리 에이전트에게 필요한 것들은 이렇습니다:</p> | |
| <ul> | |
| <li><strong>오랜 시간 동안 실행되기</strong>: agent loop는 무슨 일이 있어도 살아남습니다. actor가 크래시하거나 sandbox가 손상된 상태에 빠지면, actor가 재시작하고 중단된 지점부터 이어갑니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>실행 사이에 상태 유지하기</strong>: 각 actor는 자신만의 SQLite 데이터베이스를 갖습니다. 그래서 session history가 루프 바로 옆에 존재합니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>유휴 상태일 때 잠들기</strong>: 사용자가 조용해지면 actor는 잠듭니다. 깨어 있을 때도 고작 몇 메가바이트의 RAM입니다. stateful 프로세스를 웹 요청 수준의 비용으로 운영하는 셈입니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>사용자가 다시 프롬프트를 보내거나 스케줄에 따라 깨어나기</strong>: actor는 요청이 오면, 혹은 스케줄이나 cron에 따라 깨어납니다. 그리고 워크플로우는 actor의 부분집합입니다.</li> | |
| </ul> | |
| <p>여기에 더해 actor는 다음도 제공합니다:</p> | |
| <ul> | |
| <li><strong>기본 제공되는 멀티플레이어와 실시간</strong>: 여러 클라이언트가 같은 actor와 통신할 수 있고, WebSocket으로 실시간 협업이 가능합니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>수평 확장</strong>: 에이전트 하나당 actor 하나를 필요한 만큼 많은 머신에 분산시킵니다. 에이전트를 병렬로 띄우는 게 별도 프로젝트가 아니라 기본값이 됩니다.</li> | |
| </ul> | |
| <p><em>이 가이드는 데모를 위해 Rivet Actors를 사용하지만, actor 모델을 구현한 어떤 도구에도 동일하게 적용됩니다.</em></p> | |
| <h1>agent loop를 actor 안으로 옮기기</h1> | |
| <p>actor로 이를 구현하는 간단한 예제입니다. 코드는 세 개의 파일(<em>client.ts</em>, <em>actors.ts</em>, <em>server.ts</em>)에 걸쳐 있고, 완전히 동작하는 버전은 <strong>약 180줄의 코드</strong>입니다:</p> | |
| <p><strong>1단계: actor가 깨어날 때 sandbox를 생성</strong>하고, 잠들었다 깨어나거나 재시작해도 동일한 sandbox에 다시 연결합니다:</p> | |
| <pre><code>import { createClient } from "rivetkit/client"; | |
| import type { registry } from "./actors"; | |
| const client = createClient<typeof registry>(); | |
| // Each key is an independent agent with its own actor, sandbox, and history | |
| const agent = client.codingAgent.getOrCreate(["demo"]);</code></pre> | |
| <p><strong>2단계: sandbox와 통신할 도구들을 구성</strong>합니다:</p> | |
| <pre><code>import { docker } from "@computesdk/docker"; | |
| import { actor } from "rivetkit"; | |
| // The same Docker provider setup as the simple example | |
| const compute = docker({ | |
| runtime: "node", | |
| image: { name: "node:22-slim", pullPolicy: "ifNotPresent" }, | |
| }); | |
| export const codingAgent = actor({ | |
| // The sandbox id is persisted so the actor can reattach to the same | |
| // sandbox across sleeps and restarts | |
| state: { sandboxId: null as string | null }, | |
| // Attach the sandbox when the actor wakes | |
| createVars: async (c) => { | |
| // Reattach to this session's sandbox, or boot a new one on first wake | |
| let sandbox = c.state.sandboxId | |
| ? await compute.sandbox.getById(c.state.sandboxId) | |
| : null; | |
| if (!sandbox) { | |
| sandbox = await compute.sandbox.create(); | |
| c.state.sandboxId = sandbox.sandboxId; | |
| } | |
| return { sandbox, tools: createTools(sandbox) }; | |
| }, | |
| // ...the rest of the actor comes together in step 5 | |
| });</code></pre> | |
| <p><strong>3단계: durable queue에서 프롬프트를 처리</strong>하고, session history는 actor의 SQLite 데이터베이스에 둡니다:</p> | |
| <pre><code>import { tool } from "ai"; | |
| import { z } from "zod"; | |
| // The agent's tools run in the actor. The sandbox only receives the | |
| // commands and file operations that the tools send to it. | |
| function createTools(sandbox: Sandbox) { | |
| return { | |
| runCommand: tool({ | |
| description: "Run a shell command in the sandbox", | |
| inputSchema: z.object({ command: z.string() }), | |
| execute: async ({ command }) => await sandbox.runCommand(command), | |
| }), | |
| // ...same writeFile and readFile tools as the simple example above | |
| }; | |
| }</code></pre> | |
| <p><strong>4단계: actor를 등록하고 서버를 시작</strong>합니다:</p> | |
| <pre><code>import * as readline from "node:readline/promises"; | |
| // Read tasks from the terminal and run the agent on each one | |
| const terminal = readline.createInterface({ | |
| input: process.stdin, | |
| output: process.stdout, | |
| }); | |
| while (true) { | |
| // Queue the task on the actor and wait for the agent to complete it | |
| const prompt = await terminal.question("agent> "); | |
| const result = await agent.send("prompt", { content: prompt }, { wait: true }); | |
| if (!result.response) throw new Error(`Task ${result.status}`); | |
| console.log(result.response.text); | |
| }</code></pre> | |
| <p><strong>5단계: 사용자가 에이전트를 생성하면, 그것이 actor를 생성합니다.</strong> 각 키는 자신만의 actor, sandbox, history를 가진 독립적인 에이전트입니다:</p> | |
| <pre><code>import { anthropic } from "@ai-sdk/anthropic"; | |
| import { generateText, stepCountIs } from "ai"; | |
| import { actor, queue } from "rivetkit"; | |
| import { db } from "rivetkit/db"; | |
| export const codingAgent = actor({ | |
| // ...state and createVars from step 2 | |
| // Session history lives in the actor's own SQLite database | |
| db: db({ | |
| onMigrate: async (db) => { | |
| await db.execute( | |
| "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, message TEXT)", | |
| ); | |
| }, | |
| }), | |
| // Tasks arrive on a durable queue | |
| queues: { | |
| prompt: queue<{ content: string }, { text: string }>(), | |
| }, | |
| // Process one task at a time off the queue | |
| run: async (c) => { | |
| for await (const message of c.queue.iter({ completable: true })) { | |
| // Load the session history from SQLite | |
| const rows = await c.db.execute("SELECT message FROM messages ORDER BY id"); | |
| const history = rows.map((row) => JSON.parse(row.message)); | |
| // Run the agent loop until the task is complete | |
| const userMessage = { role: "user", content: message.body.content }; | |
| const result = await generateText({ | |
| model: anthropic("claude-sonnet-5"), | |
| messages: [...history, userMessage], | |
| tools: c.vars.tools, | |
| stopWhen: stepCountIs(20), | |
| }); | |
| // Append the new user message and everything the agent produced | |
| for (const newMessage of [userMessage, ...result.response.messages]) { | |
| await c.db.execute( | |
| "INSERT INTO messages (message) VALUES (?)", | |
| JSON.stringify(newMessage), | |
| ); | |
| } | |
| // Reply to the caller waiting on this task | |
| await message.complete({ text: result.text }); | |
| } | |
| }, | |
| });</code></pre> | |
| <p><strong>6단계: 사용자가 프롬프트를 보내면</strong>, actor의 큐에 적재됩니다:</p> | |
| <pre><code>import { setup } from "rivetkit"; | |
| import { codingAgent } from "./actors"; | |
| export const registry = setup({ use: { codingAgent } }); | |
| registry.start();</code></pre> | |
| <p>실행 가능한 전체 예제는 GitHub에 있습니다.</p> | |
| <h1>하네스를 sandbox 밖에서 대신 실행해 주는 프레임워크들</h1> | |
| <p>이 아키텍처는 업계 전반에 제대로 알려지지 않았을 뿐, 이미 충분히 검증되어 있습니다. 하네스를 sandbox 밖에서 대신 실행해 주는 선택지는 여럿 있습니다:</p> | |
| <ul> | |
| <li><strong>Rivet Actors (직접 만든 harness 사용)</strong>: Rivet Actors는 sandbox와 함께 자신만의 harness를 가져와 쓰기에 가장 유연한 선택지입니다. 범용 프리미티브이고, 오픈소스이며, 셀프호스팅이 가능하기 때문입니다. 문서와 GitHub을 참고하세요.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>agentOS (기존 harness 활용)</strong>: agentOS는 Rivet Actors 위에 구축되어 있으며, Claude Code, Codex, OpenCode, Pi 같은 주류 harness들과 함께 이 아키텍처를 지원합니다. 또한 직접 만든 커스텀 에이전트를 제공할 수도 있고, sandbox mounting을 이용해 sandbox와 통신하는 것도 지원합니다. 문서와 GitHub을 참고하세요.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Vercel의 Eve (커스텀 harness)</strong>: Eve는 Vercel 네이티브 프리미티브로 sandbox 바깥 에이전트 아키텍처를 제공합니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Cloudflare의 Flue (수정된 Pi)</strong>: Flue는 Cloudflare 네이티브 프리미티브로 동일한 아키텍처를 제공합니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Amp Orbs</strong>: Amp Orbs는 durable thread, 셀프 스케줄링, 에이전트 간 메시징으로 에이전트를 실행하며, 이 모든 것이 Amp CLI에서 자동화됩니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>OpenAI의 Agents SDK (sandbox는 직접 준비)</strong>: Sandbox Agents는 agent loop를 여러분의 harness에 두고 실행은 교체 가능한 sandbox 클라이언트에 위임합니다. 호스팅 프로바이더로는 Cloudflare, Daytona, E2B, Modal, Vercel이 있습니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Anthropic의 Managed Agents (호스팅된 루프)</strong>: Managed Agents는 agent loop를 Anthropic의 인프라에 두고, 도구 실행은 여러분이 설정한 sandbox에서 이뤄집니다(출시 시점 기준 Cloudflare, Daytona, Modal, Vercel, 혹은 직접 준비한 sandbox). 동일한 분리 구조를 검증해 주지만, 루프가 여러분의 백엔드가 아니라 Anthropic의 서버에서 돌아간다는 차이가 있습니다.</li> | |
| </ul> | |
| <h1>자주 묻는 질문</h1> | |
| <ul> | |
| <li><strong>harness를 sandbox 밖에서 실행하면 보안상 위험하지 않나요?</strong> 아닙니다. 도구들은 사실상 sandbox로 향하는 API 호출입니다. 에이전트가 harness를 실행 중인 머신에서 코드를 실행하거나 파일을 건드릴 수 있는 경로는 없습니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Claude Code, Codex, OpenCode를 이런 식으로 어떻게 실행하나요?</strong> 이들은 이 아키텍처를 네이티브로 지원하지 않지만, agentOS가 이 harness들을 sandbox 밖에서 동작시킬 수 있는 런타임을 제공합니다. Pi는 직접 지원합니다. 위의 Vercel AI SDK 예제처럼 코딩 도구들을 여러분의 sandbox에 바인딩된 도구로 교체하면 됩니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>sandbox만 쓰는 것보다 비싸지 않나요?</strong> 아닙니다. actor는 몇 메가바이트의 RAM이고, 많은 경우 전체 sandbox 대신 그것만 돌아갑니다. 예를 들어 스레드를 읽거나 공유하는 데는 전체 sandbox를 부팅할 필요가 없습니다.</li> | |
| </ul> | |
| <ul> | |
| <li><strong>에이전트를 sandbox 밖에서 실행할 때와 안에서 실행할 때의 레이턴시 차이는 어떤가요?</strong> 무시할 만한 수준입니다. 같은 데이터센터 안에서 tool call당 대략 10밀리초 정도가 추가되는데, 브라우저에서 보내는 일반적인 API 요청이 50~200밀리초라는 점과 비교해 보면 됩니다.</li> | |
| </ul> | |
| <h1>시작하기</h1> | |
| <p>저희는 Rivet Actors를 에이전트를 위한 유연하고, 오픈소스이며, 셀프호스팅 가능한 프리미티브로 만들어 오고 있습니다. 저희 Discord에 들러 주시거나, 댓글을 남기시거나, 질문이 있으면 저에게 DM을 보내 주세요!</p> | |
| <ul> | |
| <li><strong>문서</strong>: rivet.dev/docs/actors</li> | |
| </ul> | |
| <ul> | |
| <li><strong>GitHub</strong>: github.com/rivet-dev/rivet</li> | |
| </ul> | |
| <ul> | |
| <li><strong>Discord</strong>: rivet.dev/discord</li> | |
| </ul> | |
| </main></html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment