
Lovable
从零搭建一个 Lovable 式 AI 应用生成器:沙箱、Agent 循环与实时预览
用 Next.js、Node 工作进程、代理网关和云端沙箱,搭一个「用自然语言描述应用、AI 写代码并自我修复、实时预览、版本回滚、一键发布」的生成器。本文拆解整体架构、沙箱选型与网络隔离、内存快照加速启动、Agent 工具循环、事件流式推送、热重载预览网关、Git 版本管理、沙箱休眠唤醒与发布流程。
Lovable 这类工具第一次用会让人觉得像变魔术:在聊天框里描述一个应用,几秒后就能看到一个能跑、能分享、能下载的成品。但真正值得弄清楚的是它背后的机制——AI 写的代码总得有人安装、构建、运行,而且这些代码在运行前几乎没人读过,它可能报错、可能很慢,也可能做出不该做的事。本文要做的不是复刻一个 Lovable,而是把它的逻辑拆开,从零搭一个「Lovable 式」的 AI 应用生成器:用户用自然语言描述应用,AI Agent 在隔离的云端沙箱里写代码、自己检查、自己修错,然后把实时预览呈现出来;用户可以继续对话迭代、回滚到任意版本、一键发布。如果你正在评估或自建这类产品,可以参考本站的 Lovable 工具页了解它的定位,再往下看具体实现。
整体架构:三个进程加几层基础设施
在写代码之前,先把各部分的职责划清楚。整个系统由三个进程和若干基础设施组成,有一条原则必须从一开始就守住:Web 应用永远不自己运行 AI Agent,Agent 也永远不在沙箱里运行。这条边界决定了后面所有的设计。
一次完整的请求流程是这样的:
- 提交提示词。用户在界面输入提示词,Web 应用(Next.js)把它存下来,在 Postgres 里创建一条「run」记录,往队列里放一个任务,然后立刻返回。用户不需要挂着 HTTP 请求等 Agent 干完活。
- 运行 Agent。一个独立的工作进程取走任务,先确认该项目有可用的沙箱,然后启动 Agent 循环。由大模型决定下一步做什么,它发出的每一次工具调用(写文件、执行命令、安装依赖等)都真实发生在沙箱内部。每一步同时作为事件写入数据库,浏览器就是靠这些事件拿到实时进度。
- 展示预览。生成的应用在沙箱里跑自己的 Vite 开发服务器。另有一个轻量网关服务,把
http://<project-id>.preview.localhost:4000代理到那个开发服务器,包括 Vite 热重载所用的 WebSocket。所以 Agent 一改文件,预览就自动更新。 - 保存与发布。Agent 完成后,工作进程把改动提交为一个新版本。用户点击发布时,应用被构建,静态文件上传到 S3,这样即使沙箱处于休眠状态,已发布的站点依然可访问。
按层来划分更清楚:
| 层 | 技术选型 | 职责 |
|---|---|---|
| 界面与 API | Next.js | 用户交互、提示词接收、状态查询 |
| Agent 层 | Node.js + pg-boss | 消费队列任务、驱动 Agent 循环 |
| 预览层 | Node.js 代理 | 把子域名请求转发到沙箱内的开发服务器 |
| 状态与队列 | Postgres | 项目、run、事件、版本记录 |
| 执行层 | 云端沙箱 | 隔离运行生成的应用 |
| 存储层 | S3(本地用 MinIO) | 存放构建产物 |
| 推理层 | 任意大模型 | 决定 Agent 的下一步动作 |
为什么必须要有沙箱
这个产品的本质,用一句话说就是「运行没人审阅过的代码」。Agent 写代码,npm install 拉进来的包,其安装脚本几乎可以执行任何操作,接着开发服务器会把这些代码全部跑起来。这种东西不应该跑在你自己的服务器上。
所以每个项目都要有自己独立的沙箱,本质上是一台云端的小型虚拟机。挑选沙箱服务时,真正硬性的需求只有两条:
- 挂起与恢复。大多数项目大部分时间都是闲置的。需要能把它们休眠,再在几秒内唤醒,并且内存状态保持完整。
- 文件、命令与终端。Agent 需要读写文件、执行命令,同时最好也能给用户一个真实的 shell。
生产环境选型时要考察的维度远不止这些,但以上是必须满足的底线。可选的方案很多,E2B、Daytona、Modal,甚至自建 Firecracker 都可以。关键是把所有沙箱相关代码收进一个独立包(例如 packages/sandbox),这样换供应商时只需要改这一处,应用其余部分根本不需要知道底层用的是谁。
准备工作
开始之前,先确认本地环境具备以下条件:
- Node.js 22 或更高版本
- pnpm
- Docker(本地测试时用来跑 Postgres 和 MinIO)
- 沙箱服务商的 API Key
- 一个大模型的 API Key(Anthropic 或 OpenAI 均可)
然后克隆仓库并安装依赖:
git clone <repository-url>
cd lovable-build-tensorlake-aws
pnpm install
接着创建环境变量文件并填入密钥:
cp .env.example .env
# 填入沙箱与大模型的密钥,并用下面的命令生成 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
打开本地地址、登录、描述一个应用即可。应用支持 GitHub 登录,但也提供了一个简单的开发登录方式,方便你在创建 GitHub OAuth 应用之前先试用。开发登录在生产环境中始终是关闭的。
操作步骤
第一步:用内存快照把沙箱启动压到几秒
每个生成的应用都从同一个模板开始:Vite、React、TypeScript、Tailwind,加上几个常用库。最直白的做法是每建一个新项目就重来一遍:创建全新沙箱、上传模板、跑 npm install、启动开发服务器。这能work,但实测从开始到预览可访问需要 33.4 秒,其中约 23 秒纯粹是 npm install 在单 vCPU 上跑。
解决办法是:这些事只做一次,把结果保存为内存快照。内存快照会捕获文件、内存和正在运行的进程,恢复时开发服务器已经在跑了,不需要重新启动任何东西。
export async function buildBaseSnapshot(log: Logger) {
// 冷路径,只执行一次:创建、上传模板、npm install、git init、
// 验证能构建、启动开发服务器并预热 Vite 缓存。
const { ps } = await coldCreateFromTemplate({
name: `base-${Date.now()}`,
log,
verify: true,
});
try {
// 内存检查点:文件 + 内存 + 运行中的进程。
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], // Vite 开发服务器,通过代理访问
allowUnauthenticatedAccess: false, // 端口 URL 永远不公开
network: {
allowInternetAccess: true,
allowOut: ["registry.npmjs.org"], // 只放行 npm
denyOut: [],
},
});
return ps;
}
这段配置里有几个要点值得单独说明:
exposedPorts让开发服务器可以通过服务商的代理访问,但前提是持有 API Key,后面网关会用到这一点。network块是一份白名单。一旦allowOut里有了条目,其余出站流量全部被阻断。可以用example.com、github.com以及云元数据 IP 做验证,它们都会被拦下,而 npm 依然正常工作。- 沙箱环境里完全没有任何密钥:没有大模型 Key、没有数据库连接串、没有 AWS 凭证。即使生成的应用或提示词注入试图窃取信息,里面也没有东西可偷。
实测数据(计时到预览真正加载完成):
| 路径 | 耗时 |
|---|---|
| 冷启动(创建、安装、启动开发服务器) | 33.4 秒 |
| 从内存快照恢复 | 4.0 秒 |
| 唤醒休眠中的沙箱 | 2.4 秒 |
大致是 8 倍的差距。
第二步:设计 Agent 循环与工具集
Agent 循环是整个系统的大脑。每次用户发来提示词,跑的就是它。思路本身很简单:给大模型一个目标加一组工具,让它调用,把结果喂回去,如此反复直到完成。
Agent 可用的工具如下:
list_files、read_file、write_file、edit_file、delete_file:文件操作run_command:执行快速检查,例如npx tsc --noEmitinstall_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 }) {
// 在沙箱内读取文件、校验 search 唯一匹配、写回替换结果
},
});
把 search 限定为「必须精确匹配且只出现一次」是有意为之:这样模型就不能含糊地「大概改一下」,一旦匹配失败或匹配到多处,工具会直接报错,迫使模型重新读取文件、给出更精确的片段。这比让模型整文件重写要安全得多,也更容易在版本对比中看出改了什么。
第三步:让 Agent 检查自己的工作
Agent 循环里最关键的一环不是「写」,而是「验」。工具集里专门留了 run_command、get_dev_server_logs 和 get_browser_errors,目的就是让模型在宣布完成之前先自己跑一遍检查:
- 用
npx tsc --noEmit做类型检查,把编译错误在提交前消掉。 - 读取开发服务器日志,捕捉启动失败、模块解析错误、运行时异常。
- 读取浏览器端错误,捕捉类型检查发现不了的前端运行时问题。
这些检查的结果会作为工具返回值回到模型上下文里,模型据此决定是继续修还是调用 finish。也就是说,自我修复不是额外加的功能,而是循环本身的一部分:报错信息进入上下文,模型产生新的工具调用,改动再次落到沙箱里,如此往复。
第四步:把进度实时推给浏览器
Agent 的每一步都会作为事件写入数据库。浏览器不是靠长连接直接盯着工作进程,而是订阅这些事件。这样做的好处是:即使页面刷新,已经发生的事件依然在数据库里,重新连上就能补齐进度,不会丢。
事件流大致覆盖这些节点:run 开始、模型思考、工具调用发起、工具调用返回、文件写入、命令输出、错误、run 结束。前端按顺序渲染,用户看到的就是「Agent 正在做什么」的实时时间线。
第五步:搭预览网关,把热重载透出去
生成的应用在沙箱里跑自己的 Vite 开发服务器,监听 5173 端口。网关服务的职责是把 http://<project-id>.preview.localhost:4000 代理到对应沙箱的开发服务器,并且要一并代理 Vite 热重载所用的 WebSocket。少了 WebSocket,页面就只能手动刷新,体验会差一大截。
因为沙箱端口设置了 allowUnauthenticatedAccess: false,网关在转发时必须带上服务商的 API Key,端口 URL 本身不会暴露在公网上。子域名里的 <project-id> 用来定位是哪个项目的沙箱。
第六步:用 Git 做版本管理,但别把令牌放进沙箱
Agent 每完成一轮工作,工作进程就把改动提交为一个新版本。版本能力带来两个直接好处:用户可以回滚到任意历史版本;发布时也有明确的「当前版本」可以构建。
这里有一条安全约束必须遵守:不要把 Git 令牌放进沙箱。沙箱里跑的是未经审阅的代码,任何凭证放进去都等于交出去。提交动作应该由沙箱之外的工作进程完成,沙箱只负责产生文件改动。
第七步:休眠、唤醒与共享沙箱
大多数项目大部分时间闲置,让它们一直跑着既浪费也不必要。做法是让空闲沙箱进入休眠,需要时再唤醒。前面测过,唤醒一个休眠沙箱大约 2.4 秒,用户基本感觉不到中断。
共享方面,因为每个项目有独立的子域名预览地址,把链接发给别人就能看到当前状态。但要注意:预览地址指向的是沙箱里的开发服务器,沙箱休眠时它就不通了。真正需要长期可访问的,是发布后的产物。
第八步:发布应用
用户点击发布时,应用被构建,静态文件上传到 S3(本地开发用 MinIO)。这样即使沙箱处于休眠状态,已发布的站点依然能正常访问。发布和预览是两条独立的路径:预览服务于「正在迭代」,发布服务于「已经定稿」。
一个完整示例
把上面的步骤串起来,从零到能看到预览的完整流程如下。
1. 准备环境并安装依赖
git clone <repository-url>
cd lovable-build-tensorlake-aws
pnpm install
2. 配置密钥
cp .env.example .env
openssl rand -base64 32
# 把生成的 AUTH_SECRET 以及沙箱、大模型的 API Key 填入 .env
3. 启动基础设施并初始化
pnpm infra:up
pnpm db:migrate
pnpm s3:setup
4. 构建基础快照(约 45 秒,只需一次)
pnpm sandbox:build-base
5. 启动全部服务
pnpm dev
# Web: http://localhost:3000
# 预览网关: http://localhost:4000
6. 在浏览器中打开 Web 地址,登录,输入一句自然语言描述,例如「做一个带搜索和分页的待办列表」。此时后台发生的事是:提示词被保存、Postgres 里创建 run、队列收到任务、工作进程取走任务、从基础快照恢复出一个沙箱(约 4 秒)、Agent 循环开始调用工具、每一步写入事件、浏览器实时渲染进度、Vite 开发服务器在沙箱内启动、网关把子域名代理过去、预览自动出现。
7. 继续对话迭代,Agent 会基于当前文件状态继续修改,预览随热重载自动更新。
8. 回滚或发布:需要时切回任意历史版本;确认无误后点击发布,产物构建并上传到 S3。
注意事项
- 出站网络是白名单机制。一旦
allowOut里写了条目,其余出站流量全部被阻断。只放行registry.npmjs.org时,npm 可用,其他外部地址不可达。需要访问别的服务时必须显式加进白名单。 - 沙箱内不放任何密钥。没有大模型 Key、没有数据库连接串、没有云存储凭证。这是防止提示词注入和恶意依赖的最后一道防线,不要为了方便破坏它。
- Git 令牌不要进沙箱。提交动作放在沙箱之外的工作进程里完成。
- 端口不公开。
allowUnauthenticatedAccess: false意味着端口 URL 不能直接访问,必须经过带 API Key 的网关。 - 预览依赖沙箱存活。沙箱休眠时预览地址不可用;需要长期可访问的内容应当走发布流程,把静态产物放到对象存储。
- 开发登录仅用于本地。它让你在配置 GitHub OAuth 之前就能试用,但在生产环境中始终关闭。
- 换沙箱供应商只需改一个包。所有沙箱代码集中在
packages/sandbox,应用其余部分不感知具体供应商。 - 基础快照需要重建。模板依赖或基础镜像变化后,需要重新执行基础快照构建,否则新项目仍会从旧快照恢复。
- 价格、配额、沙箱超时上限、存储容量等易变信息,请以各服务商官网当前公布的信息为准。