详解如何构建 GPT 代码 Agent 根据自然语言描述自动生成完整可运行的全栈应用代码,大幅提升开发效率。
我们创建了 GPT Web App Generator,它可以让你简要描述想要创建的 web 应用,几分钟内就会生成一个用 React、Node.js、Prisma 和 Wasp 编写的完整全栈代码库,你可以直接下载和本地运行!
我们最初把这作为一个实验,看看能有多好地利用 GPT 在 Wasp(我们正在开发的开源 JS web 应用框架)中生成全栈 web 应用。自从我们发布以来,仅在几天内就有超过 3000 个应用被生成!
查看这篇博文可以看到 GPT Web App Generator 的实际效果,包括一分钟演示视频、一些示例应用,以及我们对未来的一些计划。或者,你可以在 https://usemage.ai/ 自己试试!
在这篇博文中,我们将探索创建 GPT Web App Generator 的技术层面:我们使用的技术、我们如何设计提示词、遇到的挑战,以及我们做出的选择!(注:从这里开始我们将简称为"Generator"或在讨论后端时称为"代码智能体")
此外,Generator 背后的所有代码都是开源的:web 应用、GPT 代码智能体。
首先,让我们快速解释一下我们最终得到的产品以及它的性能。
Generator 的输入是应用名称、应用描述(自由文本)和一些简单选项,比如主色调、temperature、认证方法和要使用的 GPT 模型。
作为输出,Generator 生成一个完整的全栈 web 应用的 JS 代码库:前端、后端和数据库。前端是 React + Tailwind,后端是带有 Express 的 Node.js,数据库操作我们使用了 Prisma。所有这些都通过 Wasp 框架连接在一起。
你可以在这里看到一个生成的代码库示例:https://usemage.ai/result/07ed440a-3155-4969-b3f5-2031fb1f622f 。
Generator 尽力生成开箱即用的代码 → 你可以下载到本机并运行它。对于较简单的应用,比如 TodoApp 或 MyPlants,它通常生成没有错误的代码,你可以直接开箱即用运行它们。
对于稍微复杂一些的应用,比如包含文章和评论的博客,它仍然生成合理的代码库,但这里那里可能会有一些错误。对于更复杂的应用,它通常不会完全遵循,而是在某个复杂度级别停止,然后用 TODO 或省略功能填充剩余部分,所以它有点像是对所要求内容的简化模型。总体上,它针对生成 CRUD 业务 web 应用进行了优化。
这使它成为为你下一个 web 应用项目快速启动一个坚实原型的绝佳工具,甚至可以即时生成简单的、可工作的应用!
当我们着手构建 Generator 时,我们为自己设定了以下目标:
我们必须能够在几周内构建它
它未来维护起来应该相对容易
它需要快速且便宜地生成应用(几分钟内,低于 $1)
生成的应用应该尽可能少有错误
因此,为了保持简洁,我们不做任何 LLM 级别的工程或微调,而是直接使用 OpenAI API(具体是 GPT-3.5 和 GPT-4)在任何时刻给予正确的上下文(文档片段、示例、指导方针等)来生成应用的不同部分。为了确保生成应用的连贯性和质量,我们不给代码智能体太多自由,而是通过一步步沿着生成应用的路径来严格指导它。
作为第 0 步,我们确定性地生成一些代码文件,不使用 GPT,仅基于用户选择的选项(主色调、认证方法):这些包括项目的一些配置文件、一些基础全局 CSS 和一些认证逻辑。你可以在这里看到这个逻辑(我们称之为"骨架"文件):Github 上的代码 。
然后,代码智能体接手!
代码智能体的工作分为 3 个主要阶段:
由于 GPT-4 比 GPT-3.5 要慢得多,成本也显著更高(在每分钟的 token 数量和每分钟的请求数方面也都有更低的速率限制),我们只在规划中使用 GPT-4,因为那是关键步骤,之后我们用 GPT-3.5 完成其余部分。
关于每个应用的成本 💸:一个应用通常消耗 25k 到 60k token,当我们使用 GPT-4 和 GPT-3.5 的混合时,成本约为每个应用 $0.10 到 $0.20。如果我们仅用 GPT-4 运行,成本是 10 倍,约为 $1 到 $2。
🎶 间奏曲:OpenAI Chat Completions API 简短说明
OpenAI API 提供了不同的服务,但我们仅使用了其中一个:"chat completions"。
API 本身实际上非常简单:你发送一个对话,然后你从 GPT 获得一个响应。
对话只是一列消息,其中每条消息都有内容和角色,角色指定谁"说"了那个内容 → 是"user"(你)还是"assistant"(GPT)。
需要注意的重要事情是没有状态/记忆的概念:每个 API 调用都是完全独立的,GPT 知道的唯一东西就是你此时提供给它的对话!
如果你想知道 ChatGPT(后台使用 GPT 的 web 应用)如何在没有记忆的情况下工作 → 嗯,每次你写一条消息,整个目前为止的对话都会被重新发送!这里有一些额外的聪明机制在起作用,但这基本上就是它的核心。
官方指南、官方 API 参考。
一个 Wasp 应用由 Entities(Prisma 数据模型)、Operations(Node.js 查询和操作)和 Pages(React)组成。
在给定应用描述和标题后,代码智能体首先生成一个 Plan:它是一个 Entities、Operations(查询和操作)和构成应用的 Pages 的列表。所以有点像是应用的初稿。它不生成代码 → 而是想出它们的名称和一些其他细节,包括关于它们应该如何表现的简短描述。
这通过向 GPT 的单个 API 请求完成,其中提示词包含以下内容:
关于 Wasp 框架的简短信息 + 一些 Wasp 代码的示例。
我们解释说我们想要生成 Plan,解释它是什么,以及它如何通过描述其 schema 表示为 JSON。
我们提供一些 Plan 的示例,表示为 JSON。
我们希望它遵循的一些规则和指导方针(例如"plan 应该至少有 1 页","确保生成一个 User entity")。
指示仅以有效的 JSON 响应形式返回 Plan,没有其他文本。
应用名称和描述(由用户提供)。
你可以在这里看到我们如何生成这样的提示词:代码。
Wasp 是一个全栈 web 应用框架,使用 React(前端)、NodeJS 和 Prisma(后端)。应用的高层级结构在 main.wasp 文件中描述(使用特殊的 Wasp DSL 编写),细节部分在 JS/JSX 文件中。
Wasp DSL(在 main.wasp 中使用)有点像 JSON,字符串只能用双引号,不能用单引号。下面会有示例。
Wasp 的主要特性:
典型的流程:Routes 指向 Pages,Pages 调用 Queries 和 Actions,Queries 和 Actions 操作 Entities。
示例 main.wasp(注释是给你的解释):
app todoApp {
wasp: { version: "^0.11.1" },
title: "ToDo App",
auth: {
userEntity: User,
methods: { usernameAndPassword: {} },
onAuthFailedRedirectTo: "/login"
},
client: {
rootComponent: import { Layout } from "@client/Layout.jsx",
},
db: {
prisma: {
clientPreviewFeatures: ["extendedWhereUnique"]
}
},
}
route SignupRoute { path: "/signup", to: SignupPage }
page SignupPage {
component: import Signup from "@client/pages/auth/Signup.jsx"
}
route LoginRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import Login from "@client/pages/auth/Login.jsx"
}
route DashboardRoute { path: "/", to: Dashboard }
page DashboardPage {
authRequired: true,
component: import Dashboard from "@client/pages/Dashboard.jsx"
}
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
tasks Task[]
psl=}
entity Task {=psl
id Int @id @default(autoincrement())
description String
isDone Boolean @default(false)
user User @relation(fields: [userId], references: [id])
userId Int
psl=}
query getUser {
fn: import { getUser } from "@server/queries.js",
entities: [User] // Entities that this query operates on.
}
query getTasks {
fn: import { getTasks } from "@server/queries.js",
entities: [Task]
}
action createTask {
fn: import { createTask } from "@server/actions.js",
entities: [Task]
}
action updateTask {
fn: import { updateTask } from "@server/actions.js",
entities: [Task]
}
我们在寻找一份计划来构建一个新的 Wasp 应用(应用说明在提示符末尾)。
在生成计划时,你必须遵循以下指令:
id Int @id @default(autoincrement())username String @uniquepassword Stringtasks Task[]。计划表示为 JSON,采用以下模式:
{
"entities": [{ "entityName": string, "entityBodyPsl": string }],
"actions": [{ "opName": string, "opFnPath": string, "opDesc": string }],
"queries": [{ "opName": string, "opFnPath": string, "opDesc": string }],
"pages": [{ "pageName": string, "componentPath": string, "routeName": string, "routePath": string, "pageDesc": string }]
}
这是一个计划示例(简化了一些,因为我们没有列出所有 entities/actions/queries/pages):
{
"entities": [{
"entityName": "User",
"entityBodyPsl": " id Int @id @default(autoincrement())\n username String @unique\n password String\n tasks Task[]"
}],
"actions": [{
"opName": "createTask",
"opFnPath": "@server/actions.js",
"opDesc": "检查用户是否已认证,如果是,创建属于他们的新 Task。将描述作为参数,默认将 isDone 设为 false。返回创建的 Task。"
}],
"queries": [{
"opName": "getTasks",
"opFnPath": "@server/queries.js",
"opDesc": "获取当前认证用户的所有 Task。"
}],
"pages": [{
"pageName": "DashboardPage",
"componentPath": "@client/pages/Dashboard.jsx",
"routeName": "DashboardRoute",
"routePath": "/",
"pageDesc": "主页面,显示用户的所有 Task,允许创建、更新和删除它们。"
}]
}
我们刚刚为生成计划描述的提示词设计,实际上与其他步骤(例如生成步骤和修复步骤以及各自的子步骤)非常相似,所以让我们讨论这些共性。
我们使用的所有提示词大致遵循相同的基本结构:
通用上下文
项目上下文:我们在前面步骤中生成的与当前步骤相关的内容。
指令:生成现在想要的内容 + JSON 模式 + 这样的 JSON 响应示例。
规则和指导:这是一个很好的地方来警告它关于它常犯的错误,或给予它一些额外建议,并强调什么需要发生,什么绝对不能发生。
指令:仅用有效的 JSON 响应,不要输出其他文本。
原始用户提示:应用名称和描述(由用户提供)。
我们把原始用户提示放在最后是因为这样我们可以在系统消息中告诉 GPT,当它看到原始用户提示的开始(我们为它专门设了一个标题)之后,它需要把之后的一切都视为应用描述而不是指令 → 这样我们试图防守潜在的提示词注入。
在生成计划后,Generator 逐步遍历计划,并要求 GPT 生成每个 web 应用部分,同时为其提供文档、示例和指导。每次生成一个 web 应用部分时,Generator 都会将其装配到整个应用中。这是我们工作的大头:在正确的时刻为 GPT 提供正确的信息。
在我们的案例中,我们为计划中的所有操作(Actions 和 Queries:NodeJS 代码)以及计划中的所有页面(React 代码)这样做,每个都有一个提示词。所以如果我们有 2 个 queries、3 个 actions 和 2 个页面,那就是 2+3+2 = 7 个 GPT 提示词/请求。提示词按之前解释的方式设计。
生成操作时,我们向 GPT 提供之前生成的 Entities 的信息,而在生成页面时,我们提供之前生成的 Entities 和操作的信息。
最后,Generator 尽最大努力修复 GPT 之前可能引入的任何错误。GPT 喜欢修复它之前生成的东西 → 如果你首先要求它生成一些代码,然后只是告诉它修复它,它通常会改进它!
为了进一步增强此过程,我们不只是要求它修复之前的代码,还向它提供有关要特别注意的指令,例如我们注意到它经常犯的常见错误类型,以及指出我们自己能够检测到的任何具体错误。
关于检测要报告给 GPT 的错误,理想情况下,你会有一个完整的 REPL 运行 → 这意味着通过解释器/编译器运行生成的代码,然后将其发送进行修复,以此类推直到所有内容都修复。
在我们的案例中,通过 TypeScript 编译器运行整个项目对我们来说不可行,考虑到我们为自己设置的时间限制,但我们使用了一些更简单的静态分析工具,如 Wasp 的编译器(用于 .wasp 文件)和 prisma format(用于 Prisma 模型模式),并将这些发送给 GPT 来修复它们。我们还编写了一些简单的启发式方法,能够检测一些常见的错误。
我们的代码(& 提示词)用于修复页面。
我们的代码(& 提示词)用于修复操作。
在提示词中,我们通常会重复我们在生成步骤中提供的相同指导原则,同时添加一些额外的指针指向常见错误,这通常会有帮助——它修复了它之前遗漏的东西。但通常不是一切都被修复了,相反,某些东西仍然会漏过。我们无法让它始终一致地修复某些东西,例如 Wasp 特定的 JS 导入——无论我们多么强调它需要做什么,它就是不停地搞错。即使是 GPT4 在这种情况下也不是完美的。对于这样的情况,在可能的地方,我们最终编写了自己的启发式方法来修复这些错误(修复 JS 导入)。
我们曾尝试让 GPT 在修复错误时解释它做了什么:它会修复哪些错误,以及它已经修复了哪些错误,因为我们听说这样做可能会有帮助,但我们没有看到它性能有明显改善。
测试代码智能体的性能很难。
在我们的情况下,代码智能体生成一个新应用需要几分钟时间,而且你需要直接通过 OpenAI API 来运行测试。另外,由于结果是非确定性的,很难判断输出是否受到了你所做改动的影响。
最后,评估输出本身也很困难(尤其是在我们的情况下,输出是整个全栈 web 应用)。
理想情况下,我们应该建立一个系统,使得我们可以只运行整个生成过程的某些部分,并且可以为不同参数集合的每一个自动运行特定部分多次(这些参数集合不仅包括不同的提示词,还包括模型类型(gpt4 vs gpt3.5)、temperature 等参数),以便比较每个参数集合的性能。
评估性能理想情况下也应该自动化,例如我们可以统计编译期间的错误和/或评估应用设计的质量;但这同样也很困难。
不幸的是,我们没有时间建立这样的系统,所以我们大多数时候都在进行手动测试,这相当主观且容易受到随机性影响,而且只有在改动产生很大影响时才有效,而你无法真正检测出那些次要的优化。
当我们开始在 Generator 上工作时,我们以为 GPT 上下文的大小会是主要问题。然而,到最后我们根本没有遇到任何上下文问题——我们想指定的大部分内容都能装进 2k 到最多 4k token,而 GPT3.5 的上下文长度可以达到 16k!
相反,我们遇到了更大的问题,那就是它的"智能"——意思是 GPT 不会遵循我们非常明确告诉它要遵循的规则,或者会做我们明确禁止它做的事情。GPT4 在遵循规则上证明比 GPT3.5 更好,但即使是 GPT4 也会一遍遍重复某些错误,忘记特定的规则(尽管有足够充足的上下文)。"修复"步骤确实有帮助:我们会在那里重复规则,GPT 会学到更多的规则,但通常仍然不是全部。
如本文前面提到的那样,在我们与 GPT 的所有交互中,我们总是要求它以 JSON 格式返回响应,为此我们指定了 schema 并给出了一些例子。
然而,GPT 仍然不总是遵循这个规则,有时会在 JSON 周围添加一些文本,或者会在格式化 JSON 时犯错误。
我们处理这个问题的方式是使用两个简单的修复:
从接收到的 JSON 开始,我们会删除从开头到遇到 { 为止的所有字符,以及从结尾到遇到 } 为止的所有字符。这是一个简单的启发式方法,但在实践中对于删除 JSON 周围的冗余文本效果很好,因为 GPT 通常不会在那些文本中包含任何 { 或 }。
如果我们未能解析 JSON,我们会发送它给 GPT 进行修复。我们包括之前的提示词和它最后的答案(包含无效的 JSON),并添加修复指令以及我们得到的 JSON 解析错误。我们重复这个过程几次,直到它得到正确的结果(或者直到我们放弃)。
在实践中,这两种方法在 99% 的情况下都为我们处理了无效的 JSON。
注意:当我们实现代码智能体时,OpenAI 发布了 GPT 的新功能"functions",这基本上是一种让 GPT 根据你描述的 schema 以结构化 JSON 形式响应的机制。所以用"functions"来做这件事可能更合理,但我们已经让它工作得很好了,所以我们就坚持了下来。
我们直接调用 OpenAI API,所以我们很快注意到它经常会返回 503 - 服务不可用——特别是在高峰时段(例如中欧时间下午 3 点)。
因此,建议有某种重试机制,理想情况下具有指数退避,使你的代码智能体能够应对这些随机的服务中断,以及潜在的速率限制。我们采用了带有指数退避的重试机制,效果非常好。
Temperature 决定了 GPT 的创意程度,但它越有创意,就越"不稳定"。它会产生更多幻觉,也更难遵循规则。temperature 是一个从 0 到 2 的数字,默认值为 1。
我们尝试了不同的值,发现了以下情况:
≥ 1.5 会不时给出相当愚蠢的结果,其中包含随机字符串。
≥ 1.0, < 1.5 还可以,但引入了太多的错误。
≥ 0.7, < 1.0 是最优的——足够有创意,同时仍然没有很多错误。
≤ 0.7 似乎与稍高的值性能相似,但可能创意稍少一些。
也就是说,我认为我们对 0.7 以下的值测试得还不够,这肯定是我们可以进一步深入研究的地方。
我们最终使用 0.7 作为默认值,除了修复提示词,对于那些我们使用了较低值 0.5,因为看起来 GPT 在 0.7 的温度下修复时改动太多了(过于有创意)。我们的逻辑是:在编写代码的第一个版本时让它发挥创意,然后在修复时让它稍微更加常规一些。再次强调,我们对这一切的测试还不够充分,所以这肯定是我想要我们进一步探索的东西。
虽然我们最终对在如此短的时间内建立的东西的性能印象深刻,但我们也渴望尝试许多不同的想法来进一步改进它。在这个发展如此迅速的生态系统中,有许多途径有待探索,很难达到一个你觉得已经探索了所有选项并找到了最优解决方案的境界。
以下是一些未来尝试的令人兴奋的想法:
我们对代码智能体生成的代码施加了相当多的限制,以确保它能很好地工作:我们不允许它创建辅助文件、包含 npm 依赖、使用 TypeScript、不使用高级 Wasp 功能等。我们很想解除这些限制,从而允许创建更复杂、更强大的应用。
我们对代码智能体生成的代码施加了相当多的限制,以确保它能很好地工作:我们不允许它创建辅助文件、包含 npm 依赖、使用 TypeScript、不使用高级 Wasp 功能等。我们很想解除这些限制,从而允许创建更复杂、更强大的应用。
与其让代码智能体一次性做完所有事情,我们可以允许用户在生成应用的第一个版本后与它交互:提供额外的提示词,例如,修复某些东西、向应用添加某个功能、以不同的方式做某些事情等。这里最困难的是弄清楚在哪个时刻向 GPT 提供什么上下文,并适当地设计用户体验,但我确信这是可以做到的,它会将 Generator 提升到下一个可用性水平。另一个选项是允许在初始生成步骤之间进行干预——例如,在生成计划后,允许用户通过向 GPT 提供额外指令来调整它。
与其让代码智能体一次性做完所有事情,我们可以允许用户在生成应用的第一个版本后与它交互:提供额外的提示词,例如,修复某些东西、向应用添加某个功能、以不同的方式做某些事情等。这里最困难的是弄清楚在哪个时刻向 GPT 提供什么上下文,并适当地设计用户体验,但我确信这是可以做到的,它会将 Generator 提升到下一个可用性水平。另一个选项是允许在初始生成步骤之间进行干预——例如,在生成计划后,允许用户通过向 GPT 提供额外指令来调整它。
找到一个适合该目的的开源 LLM,并为我们的目的对其进行微调/预训练。如果我们能教它更多关于 Wasp 和我们使用的技术的知识,这样我们就不必在每个提示词中都包含它,我们可以节省相当多的上下文,并让 LLM 更专注于我们在提示词中指定的规则和指导方针。我们也可以自己托管它,并对成本和速率限制有更多的控制。
找到一个适合该目的的开源 LLM,然...