← 文章 / 编程开发
freeCodeCamp 7小时前 · 2026-10-03 16:31:23 · 11 阅读

基于 Next.js、AWS 和沙箱环境打造 Lovable 风格 AI 应用构建器

Lovable、Bolt 和 v0 这类工具,初次使用时总带点魔力。说实话,我第一次看到类似功能时都惊呆了……明明只是在聊天窗口里操作,就能搞定这一切。当时真是让我大开眼界,甚至第一次尝试 Lovable 时还引发了些许“存在主义危机”。

但你有没有想过,幕后到底发生了什么?一个应用是如何让另一个应用具备即时的测试、分享和下载能力的?

某处肯定有个 AI 模型在写代码,这点很明确。但具体怎么实现呢?这些代码还得被安装、构建并运行。让我有些将信将疑的是,如今似乎没人在运行前仔细审查这些代码了。它们可能充满 bug,运行缓慢,甚至试图做不该做的事,比如用 rm -rf 摧毁整个系统。AI 确实干过类似的事,所以我们无法百分之百放心。

这篇文章正是为了解决这个问题。

在这篇文章中,你将构建一个 Lovable 风格的 AI 应用生成器。这不是简单的克隆,而是深入剖析 Lovable 背后的逻辑,看看它究竟如何运作。我们将保持用户界面简洁基础。

在这个项目中,用户可以用通俗的英语描述应用需求,AI 智能体将在隔离的云沙箱中编写代码。AI 会自动修复自身错误,随后展示实时预览。之后,用户可以通过聊天继续迭代,回滚到任意版本,并一键发布最终应用。

本文将涵盖:

在本教程中,你将从零开始构建整套系统。在这个过程中,你将学到:

  • 为什么 AI 生成的代码需要沙箱环境

  • 如何利用内存快照,将沙箱准备时间从 30 多秒缩短至约 4 秒

  • 如何构建智能体循环,使其能够编写代码、自查工作并修复错误

  • 如何实时流式传输智能体的操作至浏览器(且在页面刷新时不丢失状态)

  • 如何通过自定义网关展示支持热重载的实时预览

  • 如何使用 Git 为每次更改版本化,同时确保令牌不会进入沙箱内部

  • 如何让空闲沙箱休眠,在需要时唤醒,并发布最终应用

这部分会涉及一些进阶概念,但跟着读下来,你会学到不少。我自己开发的时候就深有体会。😉

目录

计划与架构

在深入代码之前,先理解整体架构很有帮助,因为其中有不少概念值得提前掌握。

应用拆分为三个进程和若干基础设施组件。我从一开始就坚持一条原则:Web 应用绝不直接运行 AI agent,agent 也绝不在沙箱内运行。稍后你就会明白这样设计的重要性。

c3274cbe-ac2d-4fbc-ab45-b0a2899a99c9

以下是完整流程:

发送提示词

用户输入提示词后,Web 应用(Next.js)将其保存,在 Postgres 中创建一个“run”,将任务推入队列,然后立即返回。这样用户就不用干等着 HTTP 请求,让 agent 慢慢执行了。

运行 Agent

worker 进程领取这个任务后,先确保项目有一个正在运行的 sandbox,然后启动 agent 循环。LLM 决定要做什么,而它发出的每一次工具调用(写文件、运行命令、安装依赖包等)都实际发生在 sandbox 里。

每一步操作还会作为事件保存到数据库,浏览器正是通过这个方式实时获取进度的。

展示预览

生成的应用在 sandbox 里运行自己的 Vite dev server。我们有一个小型 gateway 服务,把 http://<project-id>.preview.localhost:4000 代理到这个 dev server,包括 Vite 热更新用的 WebSocket。

所以当 agent 修改文件时,预览会自动刷新,说实话每次看到这一幕我还是觉得很酷。

保存与发布

agent 完成任务后,worker 会把改动提交为一个新版本。当用户点击 Publish 时,应用会被构建,静态文件上传到 S3,这样即使 sandbox 休眠了,已发布的站点也能正常访问。

这基本上就是我们应用的整体架构了。简单来说:

  • Next.js:UI 和 API 层

  • Worker(Node.js + pg-boss):Agent 层

  • Gateway(Node.js 代理):预览层

  • Postgres:状态和任务队列层

  • Cloud sandbox:执行层

  • S3(本地用 MinIO):存储层

  • 任意 LLM(我们用的是 Claude 或 GPT):推理层

为什么需要 Sandbox?

仔细想想,这个产品的本质就是"运行没人审查过的代码"。agent 写出的代码、npm install 拉取的依赖包(其安装脚本几乎可以执行任何操作)、然后 dev server 又把这一切跑起来。这种代码我可不敢在自己服务器上运行,你也别这么做。

所以每个项目都有自己的隔离 sandbox,本质上就是云上的一个小型 VM。我在挑选 sandbox 服务商时,真正在意的是这几点:

  • 挂起与恢复:大部分项目大多数时间都是闲置的。我希望能把它们休眠掉,并在几秒内带着完整的内存状态重新唤醒。

  • 文件、命令与终端:智能体需要读写文件并执行命令,同时为用户提供真实的 shell 终端体验更佳。

💁 在生产环境中使用此类工具时有许多事项需要考量,但这几点是我设定的硬性要求。

这里我选用 Tensorlake 沙箱。需要说明的是,选择它并没有特定原因。E2B、Daytona、Modal,甚至你自建的 Firecracker 环境都能胜任,你可以自由选用任何心仪的方案。我此前已在多个项目中使用过该服务,对于沙箱功能而言,它表现完美,尤其契合我们的使用场景。

注意:所有沙箱相关代码都封装在一个包里(packages/sandbox)。因此,如果你想更换服务商,这里几乎是你唯一需要修改的地方。应用的其他部分甚至感知不到底层调用的是哪家服务商。

项目设置指南

开始前,请确保已安装以下环境:

  • Node.js 22 或更高版本

  • pnpm

  • Docker(如果你计划先在本地测试,需用于 Postgres 和 MinIO)

此外,你还需要沙箱服务商和 LLM(Anthropic 或 OpenAI,任选其一)的 API 密钥。

首先克隆代码仓库并安装依赖:

git clone https://github.com/shricodev/lovable-build-tensorlake-aws.git
cd lovable-build-tensorlake-aws
pnpm install

接着,创建环境配置文件并填入密钥:

cp .env.example .env
# 添加沙箱和 LLM 密钥,并通过以下命令生成 AUTH_SECRET:
openssl rand -base64 32

然后启动本地基础设施,创建数据库表并配置存储桶:

# 启动 Postgres 和 MinIO
pnpm infra:up

# 创建数据库表
pnpm db:migrate

# 创建存储桶并配置权限
pnpm s3:setup

构建每个新项目的基础快照(稍后会有详细解释)。耗时约 45 秒:

pnpm sandbox:build-base

最后,启动所有服务:

# web 运行在 :3000,预览网关运行在 :4000
pnpm dev

打开 http://localhost:3000,登录并描述你的应用。搞定! 🎉

注意:该应用支持通过 GitHub 登录,同时也提供了一个简单的开发者登录入口,方便你在创建 GitHub OAuth 应用之前先试用。别担心,开发者登录在生产环境中始终是禁用的。

应用核心组件

这个项目的体量很大。如果逐行讲解,这篇文章就会变成一篇耗时几小时才能读完的长篇。因此,我将重点介绍真正驱动系统运行的核心组件。仪表盘、登录和代码编辑器等都是相当标准的功能,所以就不再赘述了。

注意:这意味着下方的代码片段只保留了关键部分。完整代码可以在仓库中找到。

快速启动沙箱

每个生成的应用都基于相同的模板:Vite、React、TypeScript、Tailwind 以及几个常用库。对于每个新项目,一种直观的方式(或者说比较原始的做法)是这样的:

  1. 创建一个全新的沙箱

  2. 上传模板

  3. 运行 npm install

  4. 启动开发服务器

这样确实能跑起来,但在我测试中,预览在33.4 秒后才真正可访问。其中大部分时间(约 23 秒)都花在了单核 vCPU 上运行 npm install 这件事上。我不知道大家如何,但我可不想每次启动新项目时都盯着加载图标转那么久。

解决方案是只执行一次上述所有步骤,并将结果保存为内存快照。内存快照会捕获文件、RAM 以及正在运行的进程。因此,当你恢复快照时,开发服务器已经在运行了,无需再次启动。

这是基础快照构建器的核心逻辑:

export async function buildBaseSnapshot(log: Logger) {
  // 冷路径,只执行一次:创建沙箱,上传模板,npm install,git init,
  // 验证构建通过,启动开发服务器并预热 Vite 缓存。
  const { ps } = await coldCreateFromTemplate({
    name: `base-${Date.now()}`,
    log,
    verify: true,
  });

  try {
    // 内存检查点:文件 + RAM + 运行中的进程。
    const snapshotId = await ps.checkpoint();
    writeBaseSnapshot({ snapshotId, createdAt: new Date().toISOString() });
  } finally {
    await ps.terminate();
  }
}

有了这套机制,为新项目创建沙箱就只是恢复一个快照,然后我们对其进行加固:

static async createFromSnapshot(opts: { snapshotId: string; name: string; log: Logger }) {
  const sb = await Sandbox.create({ snapshotId: opts.snapshotId, name: opts.name, timeoutSecs: 600 });
  const ps = new ProjectSandbox(sb, opts.log);

  await sb.update({
    exposedPorts: [5173],              // the Vite dev server, reachable through the proxy
    allowUnauthenticatedAccess: false, // the port URL is never public
    network: {
      allowInternetAccess: true,
      allowOut: ["registry.npmjs.org"], // npm and nothing else
      denyOut: [],
    },
  });
  return ps;
}

这里有几处值得注意:

  • exposedPorts 让开发服务器可以通过服务提供商的代理访问,但前提是你持有我们的 API key,稍后会在网关里用到这一点。

  • network 配置是一个白名单。只要 allowOut 里有条目,其余所有访问都会被拦截。

    💁 我实际用 example.com、github.com 和云元数据 IP 测试过,这些全都被拦截了,而 npm 依然正常工作。

  • 沙箱环境里完全没有任何密钥:没有 LLM key、没有数据库连接串、没有 AWS 密钥,什么都没有。所以即便生成的应用或某次提示注入想窃取点什么,里面也根本没有东西可偷。

以下是我实测的数据,统计到预览页面真正加载完成为止:

路径 耗时
冷启动(创建、安装依赖、启动 dev server) 33.4s
从内存快照恢复 4.0s
唤醒休眠中的沙箱 2.4s

差不多快了 8 倍,是不是很酷?😎

Agent 循环

Agent 循环是整个系统的大脑。用户每发送一次提示词,都会执行这个循环。

思路很简单,虽然实现不简单:给 LLM 一个目标和一些工具,让它调用工具,把结果反馈给它,如此往复直到任务完成。

Agent 可以使用的工具包括:

  • list_files、read_file、write_file、edit_file 和 delete_file

  • run_command,用于执行快速检查,比如 npx tsc --noEmit

  • install_packages,用于安装 npm 包

  • get_dev_server_logs 和 get_browser_errors,用于调试

  • finish,Agent 自认为任务完成时调用

每个工具其实就是一个包含 Zod schema 和 run 函数的小文件。以 edit_file 为例:

export const editFile = defineTool({
  name: "edit_file",
  description:
    "Replace one exact snippet in a file. `search` must match exactly and occur exactly once.",
  schema: z.object({
    path: z.string(),
    search: z.string().min(1),
    replace: z.string(),
  }),
  async run({ path, search, replace }, { sandbox }) {
    const text = await sandbox.readFile(path);
    const count = text.split(search).length - 1;
    if (count !== 1) {
      return {
        isError: true,
        content: `search text occurs ${count} times in ${path}`,
      };
    }
    await sandbox.writeFile(
      path,
      text.replace(search, () => replace),
    );
    return { content: `Edited ${path}`, changedFiles: [path] };
  },
});

这里的 Zod schema 承担了双重职责。它会被转换为 JSON Schema 供 LLM 使用(通过 z.toJSONSchema),同时也在模型返回内容后、任何操作触及沙箱前进行校验。如果输入无效,错误会直接回传给模型,而不会导致整个运行崩溃。

随后,实际的循环开始运行:

while (true) {
  if (signal.aborted) return result("cancelled");

  const res = await llm.chat({
    system: SYSTEM_PROMPT,
    messages,
    tools,
    signal,
    onText,
  });
  messages.push(res.message);

  const results = [];
  for (const call of res.message.toolCalls) {
    const out = await executeTool(call.name, call.input, ctx); // validate + run
    results.push({
      type: "tool_result",
      toolCallId: call.id,
      content: out.content,
      isError: out.isError,
    });
    if (out.finish) finishCalled = true;
  }

  if (!finishCalled) {
    messages.push({ role: "user", content: results });
    continue;
  }

  // The agent says it's done. Now we check. (next section)
}

这里的 llm.chat 调用是我写的一个小封装,它以统一接口同时支持 Anthropic 和 OpenAI,这样你就可以在 UI 的下拉框中轻松切换模型。

在 Anthropic 这边,我们开启了 prompt caching(提示词缓存)。说实话,成本大头基本都是它扛的。通常一轮交互会读取超过 120K 个 token,其中绝大部分直接命中了缓存。

保障文件路径安全

这个问题有点隐蔽,要不是我到处翻查,还真发现不了。沙箱的文件 API 虽然会拦截包含 .. 的路径,却会老老实实地跟随符号链接。也就是说,如果生成的代码创建了一个指向 /etc/passwd 的 leak.txt,读取 leak.txt 时拿到的就是密码文件。这可不是好兆头。

因此,每个文件工具在处理任何操作前,都会在沙箱内解析出真实路径:

async safePath(relPath: string) {
  const abs = resolveProjectPath(relPath); // rejects "..", absolute paths, NUL bytes
  const r = await this.sb.run("realpath", { args: ["-m", "--", abs] });
  const real = r.stdout.trim();
  if (!isInsideApp(real)) throw new SandboxPathError(relPath, "resolves outside the project");
  return real;
}

realpath -m 会跟随所有符号链接,告诉你路径最终实际指向哪里。如果结果落在项目文件夹之外,这次调用就会直接失败。

控制上下文体积

不要因为在每轮新提示词里都重放之前的所有工具调用。相反,每一轮都以全新的对话开始,内容只包含:

  • 对之前每一轮的简短总结(用户问了什么,agent 做了什么)

  • 文件树和已安装的包列表

  • 当前的 src/App.tsx

agent 需要任何其他内容时,会自己用工具去读取。这样一来,即使项目已经历了 20 轮提示词,单轮的成本依然基本保持平稳。

让 agent 检查自己的工作

LLM 往往非常自信。哪怕构建已经一团糟,它也会轻描淡写地告诉你“一切正常”。因此,当 agent 调用 finish 时,我们不会盲信它说的话,而是在沙箱内运行三项检查:

export async function runChecks(sandbox: ProjectSandbox) {
  const tsc = await sandbox.exec("npx tsc --noEmit -p . 2>&1", {
    timeoutSecs: 120,
  });
  const build = await sandbox.exec(
    "npx vite build --outDir /tmp/build --emptyOutDir --logLevel error 2>&1",
    { timeoutSecs: 180 },
  );
  const render = await renderCheck(sandbox); // 在假 DOM 里把应用渲染一次

  return {
    ok: tsc.exitCode === 0 && build.exitCode === 0 && render.ok,
    typecheck: { ok: tsc.exitCode === 0, output: tsc.stdout },
    build: { ok: build.exitCode === 0, output: build.stdout },
    render,
  };
}

前两项很好理解,第三项才是有意思的地方。很多 bug 只在运行时才会暴露,比如 Cannot read properties of undefined (reading 'map') 这类错误。常规做法是用 headless 浏览器,但它在小型 sandbox 里又重又慢,我不想走这条路。

所以模板里内置了一个小脚本,用 happy-dom(Node.js 的假 DOM)加上 Vite 的 ssrLoadModule 把应用渲染一次:

GlobalRegistrator.register({ url: "http://localhost:5173/" });
document.body.innerHTML = '<div id="root"></div>';
console.error = (...args) => errors.push(args.join(" "));

await server.ssrLoadModule("/src/main.tsx"); // 运行真正的应用入口
await new Promise((r) => setTimeout(r, 1500)); // 等 React 渲染完成

const rendered = document.getElementById("root").innerHTML.trim().length > 0;
console.log(
  JSON.stringify({ ok: rendered && errors.length === 0, rendered, errors }),
);

整个过程大约 2 秒,不仅抓到了 undefined.map 的崩溃,还精确定位到了 App.tsx 中的具体行号。非常 Nice!

只要任何一项检查失败,错误信息就会作为新消息直接回传给 agent,让它再修一轮:

lastCheck = await runChecks(sandbox);
if (lastCheck.ok) return result("succeeded");
if (healRounds >= maxHeal)
  return result("failed", { error: describeFailures(lastCheck) });

healRounds++;
messages.push({
  role: "user",
  content: [
    ...results,
    {
      type: "text",
      text: `Verification failed. Fix these problems, then call finish again.\n\n${describeFailures(lastCheck)}`,
    },
  ],
});

默认情况下,系统运行三轮后即停止。若此时问题仍未解决,用户会收到一条诚实的反馈:“我无法完成这次任务”,并附上具体的错误信息。

向浏览器推送进度

Agent 运行在 Worker 中,而用户正盯着浏览器。因此,无论是哪一步(“已写入 src/App.tsx”、“执行了 npx tsc --noEmit”、“检查通过”等),都必须实时从 Worker 同步到浏览器。

Worker 将每一步作为一行记录写入 run_events 表,随后触发 Postgres 的 NOTIFY 通知:

export async function appendEvent(
  db: Db,
  e: { projectId: string; runId?: string; type: string; payload: unknown },
) {
  const [row] = await db
    .insert(runEvents)
    .values(e)
    .returning({ id: runEvents.id });
  await db.$client.notify(
    "events",
    JSON.stringify({ projectId: e.projectId, id: row.id }),
  );
  return row.id;
}

在 Web 端,Next.js 路由处理器通过 Server Sent Events(SSE)将这些事件流式传输至浏览器。当浏览器连接时,它会先重放当前运行实例的历史记录,然后持续监听新产生的数据行:

const pump = async () => {
  const rows = await db
    .select()
    .from(runEvents)
    .where(and(eq(runEvents.projectId, project.id), gt(runEvents.id, cursor)))
    .orderBy(asc(runEvents.id));

  for (const r of rows) {
    cursor = r.id;
    send(`id: ${r.id}\ndata: ${JSON.stringify(r)}\n\n`);
  }
};

bus.on(project.id, pump); // fired by a single LISTEN connection per process
pump(); // replay first

由于所有事件都存储在数据库中,即使在中途刷新页面也不会丢失任何信息。浏览器重新连接后,会重放当前运行实例的状态,并从断点处继续。停止按钮的工作原理相同,只是方向相反:Web 应用发送带有运行 ID 的 NOTIFY,持有该运行的 Worker 收到后随即中断执行。

注: LLM 输出的流式文本以 token 为单位逐步到达。若保存每个 token,单次交互将产生数百行数据。因此,Worker 采用缓冲机制,每隔 250ms 写入一行。这里还遇到过一个小 bug:由于每次写入都是独立的 promise,导致事件顺序错乱。将每次写入都放入同一个 promise 链中后,问题得以解决。

实时预览网关

生成的应用开发服务器在沙箱内的 5173 端口运行,提供商会将其暴露为类似 https://5173-<sandbox-id>.sandbox.example 的 URL。你当然可以直接把这个 URL 塞进 iframe 了事,但这么做有两个问题:

  1. 加载页面时浏览器需要我们的沙箱 API 密钥。这显然行不通。

  2. 如果直接公开该 URL,任何人只要猜对了地址就能打开页面,我们将完全丧失对可见性的控制。

因此我们在其前面加了一个轻量级网关。它将 <project-id>.preview.localhost:4000 映射到对应的沙箱,并在服务端自动附加 API 密钥:

const proxy = createProxyServer({ changeOrigin: true, secure: true, ws: true });

const server = http.createServer(async (req, res) => {
  const projectId = HOST_RE.exec(req.headers.host ?? "")?.[1];
  const target = await resolve(projectId); // project -> sandbox, cached for a few seconds
  if (!target.sandboxId) return send(res, noPreviewPage());

  delete req.headers.cookie; // never forward the visitor's credentials
  delete req.headers.authorization;
  proxy.web(req, res, {
    target: previewUrlFor(target.sandboxId),
    headers: { authorization: `Bearer ${API_KEY}` },
  });
});

// Vite's hot reload runs over a WebSocket, so upgrades get proxied too.
server.on("upgrade", async (req, socket, head) => {
  const target = await resolve(HOST_RE.exec(req.headers.host ?? "")?.[1]);
  proxy.ws(req, socket, head, {
    target: previewUrlFor(target.sandboxId).replace(/^https/, "wss"),
    headers: { authorization: `Bearer ${API_KEY}` },
  });
});

正是这个 upgrade 处理器让预览体验如此灵动。每当 agent 修改文件时,Vite 会通过 WebSocket 推送变更,预览界面无需刷新即可实时更新。

设置网关还有一个容易被忽视的好处。预览页面运行在不同的源(*.preview.localhost)上,而主应用运行在 localhost:3000。因此,生成的应用永远无法读取主应用的 cookie,也无法以当前登录用户的身份调用其 API。由于现代浏览器中 *.localhost 解析为 127.0.0.1,你甚至无需修改 hosts 文件即可实现这一点。

注意:模板还会向预览注入一个很小的脚本,监听 window.onerror 并把错误发送给父窗口。工作区再将这些错误转发给后端,agent 的 get_browser_errors 工具就是从这里拿到真实的运行时错误。

基于 Git 的版本管理

每完成一次 prompt 就会生成一个版本。用户可以回退到某个特定的 "prompt",效果类似 git reset。

实现上,沙箱里一个普通的 Git 就完全够用了。基础快照中已经有一个仓库,模板作为首次提交,每次成功的对话轮次结束后,worker 会提交所有改动:

export async function commitAll(ps: ProjectSandbox, message: string) {
  const out = await git(
    ps,
    `git add -A
if git diff --cached --quiet; then
  echo NOCHANGE
else
  git commit -q -m "$MSG"
  git rev-parse HEAD
  git show --name-only --format= HEAD
fi`,
    { MSG: message },
  );
  if (out.trim() === "NOCHANGE") return null;
  const [sha, ...files] = out.trim().split("\n");
  return { sha, files };
}

注意:你可能好奇为什么这里没有 exit 0。命令是通过 bash -l 执行的,在 login shell 里显式调用 exit 会触发 ~/.bash_logout,而这个镜像里它的最后一条命令会失败,导致我的退出码变成失败状态。这个问题真的折腾了我很久。😭

版本恢复永远不会改写历史。它只是让文件完全回到旧提交的状态,然后在这个基础上作为一个全新的版本提交。也就是说,"恢复到版本 1" 会生成版本 5,而你随时还能回到版本 4。不会丢失任何东西。

让历史记录持久化(不在沙箱里放 Token)

沙箱内的 Git 很好用,但它的生命周期和沙箱一样短。所以每生成一个版本后,历史记录还会推送到一个托管的 Git 仓库。

最直接的做法是给沙箱一个 Git token,直接跑 git push。但我能拿到的 token 权限范围是整个项目,而不是单个仓库。把这种 token 放进一个满是不可信代码的沙箱里?算了吧,谢了。

所以沙箱自己从不执行推送。它生成一个 git bundle(本质上是把整个仓库打包成一个文件),worker 读取这个文件,然后由 worker 自己来完成推送:

export async function pushToHostedGit(
  ps: ProjectSandbox,
  repo: string,
  dataDir: string,
) {
  await git(ps, "git bundle create -q /tmp/repo.bundle main");
  const bundle = await ps.sb.readFile("/tmp/repo.bundle");

  const mirror = join(dataDir, "git", `${repo}.git`); // worker 上的裸仓库
  writeFileSync(join(mirror, "incoming.bundle"), bundle);
  await run("git", [
    "-C",
    mirror,
    "fetch",
    "-q",
    "--force",
    "incoming.bundle",
    "+refs/heads/main:refs/heads/main",
  ]);

  const cred = await repos.credential(repo); // 短时效 token,仅在 worker 上生成
  await run("git", [
    "-C",
    mirror,
    "-c",
    `http.extraHeader=Authorization: Basic ${basic(cred)}`,
    "push",
    "-q",
    "--force",
    url,
    "main",
  ]);
}

还有一个好处是,这一机制也能支撑 remix 功能。当用户复制共享项目时,worker 会从托管仓库加载源项目的 bundle 到一个全新的 sandbox 中,原始 sandbox 甚至无需唤醒即可完成任务。

沙箱的休眠、唤醒与共享

即使没人操作,运行中的 sandbox 也会产生成本。因此,有一个每分钟运行的小型任务,会挂起任何 10 分钟内没有 Agent 活动的 sandbox。挂起操作会保留内存,因此开发服务器能完全恢复到挂起前的状态。

唤醒操作在网关层完成。如果有人打开一个休眠项目的预览,网关会显示一个持续自动刷新的“正在唤醒你的应用...”页面,并在后台恢复该 sandbox:

if (isPage && target.status === "suspended") {
  const outcome = wake(projectId, target, log); // 按项目去重
  const done = await Promise.race([outcome, timeout(4000, "pending")]);
  if (done === "busy") return send(res, busyPage());
  if (done !== "running") return send(res, wakingPage()); // 自动刷新
}

在我的测试中,休眠的 sandbox 大约 3 秒就能唤醒,应用随即加载完成。🎊

共享有限数量的沙箱

大多数 sandbox 提供商会限制同时运行的 sandbox 数量,免费套餐尤其如此。所以我添加了一个简单的并发检查器,所有操作都在 Postgres 中通过咨询锁(advisory lock)完成,确保两个 worker 不会在同一时刻做出相同的决策:

async function decide(db: Db, projectId: string) {
  return db.transaction(async (tx) => {
    await tx.execute(sql`select pg_advisory_xact_lock(${LOCK_KEY})`);

    const live = await runningSandboxesOldestFirst(tx);
    if (live.some((s) => s.projectId === projectId)) return { kind: "ok" };
    if (live.length < concurrencyLimit()) return { kind: "ok" };

    // Full. Free a slot by suspending the least recently used idle sandbox.
    const victim = live.find((s) => !busyProjects.has(s.projectId));
    if (victim) return { kind: "evict", sandboxId: victim.sandboxId };

    return { kind: "wait", position }; // everything is busy, just wait...
  });
}

为了验证这个逻辑,我把限制设为 1,在一个项目的沙箱处于空闲状态时,向另一个项目发送了提示词。结果空闲的那个进入了睡眠状态,新任务占用了它的资源。接着,我在第二个项目还在运行时,又向第一个项目发送了提示词,界面显示“正在等待空闲沙箱,队列第 1 位”,直到有资源释放为止。如果你处于更高档的套餐,只需在 .env 中提高限制值即可,其他配置无需改动。

发布应用

在开发阶段,预览功能非常好用,但你并不希望正式发布的应用依赖一个每 10 分钟就会休眠的沙箱。 🫩

因此,发布流程会将应用构建一次,并转换为纯静态文件:

export async function publish(project: Project) {
  const ps = await projectSandbox(project.id); // wakes it if needed
  const build = await ps.exec(
    "rm -rf dist && npx vite build --outDir dist --emptyOutDir 2>&1",
    { timeoutSecs: 180 },
  );
  if (build.exitCode !== 0)
    throw new HttpError(422, `The build failed:\n${build.stdout.slice(-1500)}`);

  const files = await listFiles(ps, "dist");
  const prefix = `published/${slug}/${versionId}/`;

  for (const f of files) {
    const bytes = await ps.readBytes(`dist/${f}`);
    await storage.put(prefix + f, bytes, contentTypeFor(f), cacheControlFor(f));
  }

  await savePublishedSite({ projectId: project.id, slug, s3Prefix: prefix });
  return { url: publishedUrl(slug) };
}

然后网关从 S3 为这些文件提供服务,地址是 http://<slug>.app.localhost:4000。Vite 带哈希的文件(比如 assets/index-CScgwd68.js)会被永久缓存,而 index.html 始终会重新校验,所以更新能立刻生效。没有文件扩展名的路径都会回退到 index.html,因此客户端路由也能正常工作。

发布后的站点特意使用独立子域名。如果它们放在主应用域名下,已发布应用的 JavaScript 就能带着浏览者自己的 cookie 调用你的 API——这可不是你想看到的。😺

在我的测试中,发布大约耗时 6 秒,已发布的站点加载只用了 4ms,此时沙箱还在休眠,因为整个过程完全不碰沙箱。

App Builder 实测

下面是 app builder 的快速演示:

出于好奇,我还做了一次小规模评测:把同样的三个提示词(习惯打卡、看板、带图表的支出面板)分别发给两个不同的模型,每个都在全新沙箱中运行:

模型 通过(构建 + 渲染) 平均耗时
Claude Sonnet 5 3/3 135 秒
GPT 5.5 3/3 92 秒

六个应用全部一次通过构建和渲染,没有需要修复的轮次,说实话有点出乎我的意料。在 Claude 上,每个应用的成本大约在 $0.15 到 $0.20 之间。

结语

你觉得这个项目怎么样?自从 AI 取代了原始编码之后,这是我近期做过最有趣的项目之一。🤦‍♂️

用 Lovable 这类工具时,很容易以为关键全在提示词和模型上。但自己动手做一个之后你会发现,大部分工作其实都在模型之外——比如代码在哪里运行、启动有多快、怎么展示给用户、以及如何防止它做不该做的事。

如果只让你记住一点,那就是沙箱部分。把 AI 生成的代码当作不可信代码对待,给它一台独立的小机器,不存放任何密钥、几乎没有网络访问权限。至于速度和成本,交给内存快照和挂起/恢复机制就够了。

这里的扩展空间还很大。你可以给模板加上简单的后端(比如 Hono 和 SQLite),让用户构建全栈应用;或者让用户在预览中点击某个元素,从而精确编辑对应的组件;甚至可以将同一提示词生成的几种设计变体并排展示。我现在实在太累了,就不去实现了,留个机会给你。✌️

基础架构已经就绪,剩下的只是在其上继续搭建。

完整源代码可以在这里找到:shricodev/lovable-build-tensorlake-aws

文章到这里就结束了。非常感谢你的阅读,下次见。🫡

原始来源: freeCodeCamp

评论 (0)