展示用 LangChain + ChatGPT 构建自动化 Agent 的完整实践;涵盖 AI agent 框架的实际应用和工程化方法
LangChain、ChatGPT 和其他新兴技术让我们得以构建一些极具创意的工具。
在本教程中,我们将构建一个全栈 Web 应用,让它充当我们自己的个人 Twitter AI 智能体,或者像我喜欢称呼的那样——“实习生”。它会记录你的笔记和想法,并将它们与引领趋势的 Twitter 用户所发布的推文结合起来,帮助你头脑风暴、产生新想法,以及为你撰写推文草稿!💥
顺便说一句,如果你在教程过程中遇到困难,或者想随时查看我们正在构建的应用的完整最终代码仓库,可以在这里找到:https://github.com/vincanger/twitter-intern
Wasp = } 是唯一一个开源、完全采用服务器架构的 React/Node 全栈框架。它内置编译器,让你可以在一天内构建应用,并通过一条 CLI 命令完成部署。
我们正在努力帮助你尽可能轻松地构建高性能 Web 应用,其中也包括制作这些每周发布的教程!
如果你能在 GitHub 上为我们的仓库点亮 Star,我们将不胜感激:https://www.github.com/wasp-lang/wasp 🙏
……就连 Ron 也会在 GitHub 上给 Wasp 点 Star 🤩
Twitter 是一个很棒的营销工具,也是探索想法并不断打磨自身观点的绝佳方式。但持续保持发推习惯可能既耗时又困难。
因此,基于以下假设,我决定使用 LangChain 构建自己的个人 Twitter AI 智能体:
🧠 LLM(如 ChatGPT)并不是最优秀的写作者,但它们非常擅长通过头脑风暴产生新想法。
📊 在某些细分领域中,少数 Twitter 用户主导着大部分讨论,也就是说,趋势引领者会影响人们当下正在讨论的话题。
💡 AI 智能体需要上下文,才能生成与你及你的观点相关的想法,因此它应该能够访问你的笔记、想法、推文等内容。
所以,与其尝试构建一个完全自主、替你发推的 AI 智能体,我认为更好的方式是构建一个替你完成头脑风暴的 AI 智能体,并以你喜爱的趋势引领型 Twitter 用户以及你自己的想法为基础。
你可以把它想象成一名负责处理繁重工作的实习生,而你则负责筛选和把关!
为了实现这一点,我们需要利用几种热门的 AI 工具:
Embeddings 和向量数据库
LLM(大型语言模型),例如 ChatGPT
LangChain 和由多次 LLM 调用构成的顺序“链”
Embeddings 和向量数据库为我们提供了一种强大的方式,可以针对自己的笔记和想法执行相似性搜索。
如果你不熟悉相似性搜索,最简单的解释方式就是将它与普通的 Google 搜索进行比较。在普通搜索中,短语“a mouse eats cheese”只会返回包含这些单词组合的结果。相比之下,基于向量的相似性搜索不仅会返回这些单词,还会返回包含“dog”“cat”“bone”和“fish”等相关词语的结果。
你可以看出它为什么如此强大:即使我们的笔记并不完全匹配、只是存在关联,相似性搜索仍然能够将它们找出来!
例如,如果我们喜爱的趋势引领型 Twitter 用户发布了一条关于 TypeScript 优势的推文,而我们只有一条关于“我们最喜欢的 React Hooks”的笔记,相似性搜索仍然很可能返回这条结果。这一点非常重要!
获取这些笔记后,我们可以将它们与用于生成更多想法的提示词一起传递给 ChatGPT completion API。随后,这个提示词的结果会被发送给另一个提示词,后者包含生成推文草稿的指令。我们会将这些优秀的结果保存到 Postgres 关系型数据库中。
这种提示词调用“链”,基本上就是 LangChain 这个包名称的由来 🙂
这种方式会为我们提供大量新想法和推文草稿,它们都与我们喜爱的趋势引领型 Twitter 用户所发布的推文相关。我们可以浏览这些内容,编辑并将喜欢的想法保存到自己的“笔记”向量存储中,或者直接发布一些推文。
我个人已经使用这个应用一段时间了。它不仅生成了一些很棒的想法,还能激发新的灵感,即使它生成的某些想法只是“普普通通”。这也是为什么我在导航栏最显眼的位置加入了“Add Note”功能。
好了,背景介绍到此为止。让我们开始构建你自己的个人 Twitter 实习生吧!🤖
顺便说一句,如果你在跟随本教程操作时遇到任何困难,可以随时参考本教程包含最终应用的代码仓库:Twitter Intern GitHub Repo
我们将把它构建为一个全栈 React/NodeJS Web 应用,因此首先需要完成相关设置。不过别担心,这根本花不了多少时间,因为我们将使用 Wasp 作为框架。
Wasp 会替我们完成所有繁重的工作。你马上就会明白我的意思。
# First, install Wasp by running this in your terminal:npm i -g @wasp.sh/wasp-cli# next, create a new project:wasp new twitter-agent# cd into the new directory and start the project:cd twitter-agent && wasp start
很好!运行 wasp start 时,Wasp 会安装所有必需的 npm 包,在 3001 端口启动服务器,并在 3000 端口启动 React 客户端。在浏览器中访问 localhost:3000 查看效果。
你可以安装 Wasp vscode 扩展,以获得最佳的开发体验。
你会注意到,Wasp 会使用如下文件结构设置你的全栈应用:
.├── main.wasp # The wasp config file.└── src ├── client # Your React client code (JS/CSS/HTML) goes here. ├── server # Your server code (Node JS) goes here. └── shared # Your shared (runtime independent) code goes here.
现在开始添加一些服务端代码。
首先,在项目根目录中添加一个 .env.server 文件:
# https://platform.openai.com/account/api-keysOPENAI_API_KEY=# sign up for a free tier account at https://www.pinecone.io/PINECONE_API_KEY=# will be a location, e.g 'us-west4-gcp-free'PINECONE_ENV= # We will fill these in later during the Twitter Scraping section# Twitter details -- only needed once for Rettiwt.account.login() to get the tokens TWITTER_EMAIL=TWITTER_HANDLE=TWITTER_PASSWORD=# TOKENS -- fill these in after running the getTwitterTokens script in the Twitter Scraping sectionKDT=TWID=CT0=AUTH_TOKEN=
我们需要一种方式来存储所有这些绝妙的想法。因此,首先前往 Pinecone.io,注册一个免费试用账户。
在 Pinecone 控制台中,进入 API keys 页面并创建一个新密钥。将你的 Environment 和 API Key 复制并粘贴到 .env.server 中。
对 OpenAI 也执行同样的操作:访问 https://platform.openai.com/account/api-keys 创建账户和密钥。
现在,让我们使用下面的代码替换 main.wasp 配置文件中的内容。这个文件就像应用的“骨架”,它将替你完成全栈应用的大部分配置 🤯
app twitterAgent { wasp: { version: "^0.10.6" }, title: "twitter-agent", head: [ "<script async src='https://platform.twitter.com/widgets.js' charset='utf-8'></script>" ], db: { system: PostgreSQL, }, auth: { userEntity: User, onAuthFailedRedirectTo: "/login", methods: { usernameAndPassword: {}, } }, dependencies: [ ("openai", "3.2.1"), ("rettiwt-api", "1.1.8"), ("langchain", "0.0.91"), ("@pinecone-database/pinecone", "0.1.6"), ("@headlessui/react", "1.7.15"), ("react-icons", "4.8.0"), ("react-twitter-embed", "4.0.4") ],}// ### Database Modelsentity Tweet {=psl id Int @id @default(autoincrement()) tweetId String authorUsername String content String tweetedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) userId Int drafts TweetDraft[] ideas GeneratedIdea[]psl=}entity TweetDraft {=psl id Int @id @default(autoincrement()) content String notes String originalTweet Tweet @relation(fields: [originalTweetId], references: [id]) originalTweetId Int createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) userId Intpsl=}entity GeneratedIdea {=psl id Int @id @default(autoincrement()) content String createdAt DateTime @default(now()) updatedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) userId Int originalTweet Tweet? @relation(fields: [originalTweetId], references: [id]) originalTweetId Int? isEmbedded Boolean @default(false)psl=}entity User {=psl id Int @id @default(autoincrement()) username String @unique password String createdAt DateTime @default(now()) favUsers String[] originalTweets Tweet[] tweetDrafts TweetDraft[] generatedIdeas GeneratedIdea[]psl=}// <<< Client Pages & Routesroute RootRoute { path: "/", to: MainPage }page MainPage { authRequired: true, component: import Main from "@client/MainPage"}//...
你可能已经注意到了上述实体中的 {=psl psl=} 语法。它表示这对 psl 括号之间的所有内容实际上都使用了另一种语言,在这里是 Prisma Schema Language。Wasp 底层使用 Prisma,因此如果你以前用过 Prisma,理解起来应该很直接。
如你所见,我们的 main.wasp 配置文件包含:
身份验证方式,
数据库模型(“实体”)
至此,我们的应用结构基本上已经定义完成,Wasp 会替我们处理大量配置工作。
不过,我们仍然需要运行一个 Postgres 数据库。通常这件事可能相当麻烦,但使用 Wasp 时,只需安装并运行 Docker Desktop,然后打开另一个独立的终端标签页或窗口,并运行:
wasp start db
这会替你启动一个 Postgres 数据库,并将应用连接到该数据库。无须进行任何其他操作!🤯 只需让这个终端标签页和 Docker Desktop 保持打开,并在后台运行即可。
在另一个终端标签页中运行:
wasp db migrate-dev
并确保为数据库迁移指定一个名称。
如果你为了运行这条命令而停止了 Wasp 开发服务器,现在可以使用 wasp start 再次启动它。
此时,应用会将我们导航到 localhost:3000/login,但由于我们尚未实现登录界面和流程,所以看到的会是一个空白页面。别担心,我们稍后会处理它。
嵌入想法与笔记
不过首先,让我们在 main.wasp 配置文件中定义一个用于保存笔记和想法的服务端 action。将以下代码添加到文件底部:
// main.wasp//...// <<< Client Pages & Routesroute RootRoute { path: "/", to: MainPage }page MainPage { authRequired: true, component: import Main from "@client/MainPage"}// !!! Actionsaction embedIdea { fn: import { embedIdea } from "@server/ideas.js", entities: [GeneratedIdea]}
声明 action 后,接下来创建它。新建 .src/server/ideas.ts 文件,并添加以下代码:
import type { EmbedIdea } from '@wasp/actions/types';import type { GeneratedIdea } from '@wasp/entities';import HttpError from '@wasp/core/HttpError.js';import { PineconeStore } from 'langchain/vectorstores/pinecone';import { Document } from 'langchain/document';import { OpenAIEmbeddings } from 'langchain/embeddings/openai';import { PineconeClient } from '@pinecone-database/pinecone';const pinecone = new PineconeClient();export const initPinecone = async () => { await pinecone.init({ environment: process.env.PINECONE_ENV!, apiKey: process.env.PINECONE_API_KEY!, }); return pinecone;};export const embeddings = new OpenAIEmbeddings({ openAIApiKey: process.env.OPENAI_API_KEY,});/** * Embeds a single idea into the vector store */export const embedIdea: EmbedIdea<{ idea: string }, GeneratedIdea> = async ({ idea }, context) => { if (!context.user) { throw new HttpError(401, 'User is not authorized'); } console.log('idea: ', idea); try { let newIdea = await context.entities.GeneratedIdea.create({ data: { content: idea, userId: context.user.id, }, }); if (!newIdea) { throw new HttpError(404, 'Idea not found'); } const pinecone = await initPinecone(); // we need to create an index to save the vector embeddings to // an index is similar to a table in relational database world const availableIndexes = await pinecone.listIndexes(); if (!availableIndexes.includes('embeds-test')) { console.log('creating index'); await pinecone.createIndex({ createRequest: { name: 'embeds-test', // open ai uses 1536 dimensions for their embeddings dimension: 1536, }, }); } const pineconeIndex = pinecone.Index('embeds-test'); // the LangChain vectorStore wrapper const vectorStore = new PineconeStore(embeddings, { pineconeIndex: pineconeIndex, namespace: context.user.username, }); // create a document with the idea's content to be embedded const ideaDoc = new Document({ metadata: { type: 'note' }, pageContent: newIdea.content, }); // add the document to the vectore store along with its id await vectorStore.addDocuments([ideaDoc], [newIdea.id.toString()]); newIdea = await context.entities.GeneratedIdea.update({ where: { id: newIdea.id, }, data: { isEmbedded: true, }, }); console.log('idea embedded successfully!', newIdea); return newIdea; } catch (error: any) { throw new Error(error); }};
我们在 main.wasp 文件中将 action 函数定义为来自 ‘@server/ideas.js’,但实际创建的却是一个 ideas.ts 文件。这是怎么回事?!
这是因为 Wasp 内部使用 esnext 模块解析机制,它始终要求将扩展名指定为 .js(即生成的 JS 文件所使用的扩展名)。这适用于所有 @server 导入(以及服务端文件的常规导入),但不适用于客户端文件。
很好!现在,我们已经拥有了一个用于向向量数据库添加笔记和想法的服务端 action。而且,我们甚至不需要自己配置服务器(感谢 Wasp 🙂)。
不过,让我们先退一步,梳理一下刚刚编写的代码:
我们创建一个新的 Pinecone 客户端,并使用 API 密钥和环境对其进行初始化。
我们创建一个新的 OpenAIEmbeddings 客户端,并使用 OpenAI API 密钥对其进行初始化。
我们在 Pinecone 数据库中创建一个新索引,用于存储向量嵌入。
我们创建一个新的 PineconeStore,它是 LangChain 围绕 Pinecone 客户端和 OpenAIEmbeddings 客户端提供的封装。
我们创建一个新的 Document,其中包含需要嵌入的想法内容。
我们将该文档及其 ID 一同添加到向量存储中。
我们还会更新 Postgres 数据库中的想法,将其标记为已嵌入。
现在,我们想要创建用于添加想法的客户端功能,但你应该还记得,我们在 Wasp 配置文件中定义了一个 auth 对象。因此,在前端执行任何操作之前,我们需要先添加登录功能。
让我们在 main.wasp 文件中添加新的 Route 和 Page 定义,以快速完成这项工作:
//...route LoginPageRoute { path: "/login", to: LoginPage }page LoginPage { component: import Login from "@client/LoginPage"}
……然后创建文件 src/client/LoginPage.tsx,内容如下:
import { LoginForm } from '@wasp/auth/forms/Login';
import { SignupForm } from '@wasp/auth/forms/Signup';
import { useState } from 'react';
export default () => {
const [showSignupForm, setShowSignupForm] = useState(false);
const handleShowSignupForm = () => {
setShowSignupForm((x) => !x);
};
return (
<>
{showSignupForm ? <SignupForm /> : <LoginForm />}
<div onClick={handleShowSignupForm} className='underline cursor-pointer hover:opacity-80'>
{showSignupForm ? 'Already Registered? Login!' : 'No Account? Sign up!'}
</div>
</>
);
};
在 main.wasp 文件的 auth 对象中,我们使用了 usernameAndPassword 方法,这是 Wasp 提供的最简单的身份验证形式。如果你有兴趣,Wasp 还提供了 Google、Github 和电子邮件验证的抽象,但在本教程中,我们坚持使用最简单的身份验证方法。
完成身份验证设置后,如果我们尝试访问 localhost:3000,我们会自动被重定向到登录/注册表单。
你会看到 Wasp 为我们创建了登录和注册表单,这是因为我们在 main.wasp 文件中定义的 auth 对象。太棒了!🎉
但即使我们添加了一些样式类,我们还没有设置任何 CSS 样式,所以现在可能看起来会很丑陋。
幸运的是,Wasp 附带了 Tailwind CSS 支持,所以我们要做的就是在项目根目录中添加以下文件:
.
├── main.wasp
├── src
│ ├── client
│ ├── server
│ └── shared
├── postcss.config.cjs # 在这里添加此文件
├── tailwind.config.cjs # 在这里也添加此文件
└── .wasproot
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
最后,用以下代码替换你的 src/client/Main.css 文件的内容:
@tailwind base;
@tailwind components;
@tailwind utilities;
现在我们拥有 Tailwind CSS 的魔力了!🎨 我们稍后再进行样式设计。耐心,小蜘蚣。
从这里开始,让我们为添加笔记到向量存储创建相应的客户端组件。创建一个新的 src/client/AddNote.tsx 文件,内容如下:
import { useState } from 'react';
import embedIdea from '@wasp/actions/embedIdea';
export default function AddNote() {
const [idea, setIdea] = useState('');
const [isIdeaEmbedding, setIsIdeaEmbedding] = useState(false);
const handleEmbedIdea = async (e: any) => {
try {
setIsIdeaEmbedding(true);
if (!idea) {
throw new Error('Idea cannot be empty');
}
const embedIdeaResponse = await embedIdea({
idea,
});
console.log('embedIdeaResponse: ', embedIdeaResponse);
} catch (error: any) {
alert(error.message);
} finally {
setIdea('');
setIsIdeaEmbedding(false);
}
};
return (
<div className='flex flex-row gap-2 justify-center items-end w-full'>
<textarea
autoFocus
onChange={(e) => setIdea(e.target.value)}
value={idea}
placeholder='LLMs are great for brainstorming!'
className='w-full p-4 h-22 bg-neutral-100 border rounded-lg'
/>
<button
onClick={handleEmbedIdea}
className='flex flex-row justify-center items-center bg-neutral-100 hover:bg-neutral-200 border border-neutral-300 font-bold px-3 py-1 text-sm text-blue-500 whitespace-nowrap rounded-lg'
>
{isIdeaEmbedding ? 'Loading...' : 'Save Note'}
</button>
</div>
);
}
这里我们使用了之前定义的 embedIdea 操作将我们的想法添加到向量存储中。我们还使用 useState hook 来跟踪我们正在添加的想法,以及按钮的加载状态。
现在我们有了一种方式来添加我们自己的想法和笔记到我们的向量存储中。相当不错!
现在我们需要设置 LangChain 在顺序 LLM 调用中表现出色的链。
以下是我们将采取的步骤:
定义一个使用 LangChain 的函数,以启动对 OpenAI ChatGPT 完成端点的一系列 API 调用。该函数接收我们从最喜欢的 Twitter 用户之一提取的推文作为参数,在我们的向量存储中搜索类似的笔记和想法,并基于示例推文和我们的笔记返回新的"头脑风暴"想法列表。
定义一个新的操作,循环遍历我们最喜欢的用户数组,提取他们最近的推文,并将它们发送到上面提到的我们的 LangChain 函数。
所以让我们再次开始创建我们的 LangChain 函数。创建一个新的 src/server/chain.ts 文件:
import { ChatOpenAI } from 'langchain/chat_models/openai';
import { LLMChain, SequentialChain } from 'langchain/chains';
import { PromptTemplate } from 'langchain/prompts';
import { PineconeStore } from 'langchain/vectorstores/pinecone';
import { OpenAIEmbeddings } from 'langchain/embeddings/openai';
import { PineconeClient } from '@pinecone-database/pinecone';
const pinecone = new PineconeClient();
export const initPinecone = async () => {
await pinecone.init({
environment: process.env.PINECONE_ENV!,
apiKey: process.env.PINECONE_API_KEY!,
});
return pinecone;
};
const embeddings = new OpenAIEmbeddings({
openAIApiKey: process.env.OPENAI_API_KEY,
});
export const generateIdeas = async (exampleTweet: string, username: string) => {
try {
// remove quotes and curly braces as not to confuse langchain template parser
exampleTweet = exampleTweet.replace(/"/g, '');
exampleTweet = exampleTweet.replace(/{/g, '');
exampleTweet = exampleTweet.replace(/}/g, '');
const pinecone = await initPinecone();
console.log('list indexes', await pinecone.listIndexes());
// find the index we created earlier
const pineconeIndex = pinecone.Index('embeds-test');
const vectorStore = new PineconeStore(embeddings, {
pineconeIndex: pineconeIndex,
namespace: username,
});
//
// sequential tweet chain begin --- >
//
/**
* vector store results for notes similar to the original tweet
*/
const searchRes = await vectorStore.similaritySearchWithScore(exampleTweet, 2);
console.log('searchRes: ', searchRes);
let notes = searchRes
.filter((res) => res[1] > 0.7) // filter out strings that have less than %70 similarity
.map((res) => res[0].pageContent)
.join(' ');
console.log('\n\n similarity search results of our notes-> ', notes);
if (!notes || notes.length <= 2) {
notes = exampleTweet;
}
const tweetLlm = new ChatOpenAI({
openAIApiKey: process.env.OPENAI_API_KEY,
temperature: 0.8, // 0 - 2 with 0 being more deterministic and 2 being most "loose". Past 1.3 the results tend to be more incoherent.
modelName: 'gpt-3.5-turbo',
});
const tweetTemplate = `You are an expert idea generator. You will be given a user's notes and your goal is to use this information to brainstorm other novel ideas.
Notes: {notes}
Ideas Brainstorm:
-`;
const tweetPromptTemplate = new PromptTemplate({
template: tweetTemplate,
inputVariables: ['notes'],
});
const tweetChain = new LLMChain({
llm: tweetLlm,
prompt: tweetPromptTemplate,
outputKey: 'newTweetIdeas',
});
const interestingTweetTemplate = `You are an expert interesting tweet generator. You will be given some tweet ideas and your goal is to choose one, and write a tweet based on it. Structure the tweet in an informal yet serious tone and do NOT include hashtags in the tweet!
Tweet Ideas: {newTweetIdeas}
Interesting Tweet:`;
const interestingTweetLlm = new ChatOpenAI({
openAIApiKey: process.env.OPENAI_API_KEY,
temperature: 1.1,
modelName: 'gpt-3.5-turbo',
});
const interestingTweetPrompt = new PromptTemplate({
template: interestingTweetTemplate,
inputVariables: ['newTweetIdeas'],
});
const interestingTweetChain = new LLMChain({
llm: interestingTweetLlm,
prompt: interestingTweetPrompt,
outputKey: 'interestingTweet',
});
const overallChain = new SequentialChain({
chains: [tweetChain, interestingTweetChain],
inputVariables: ['notes'],
outputVariables: ['newTweetIdeas', 'interestingTweet'],
verbose: false,
});
type ChainDraftResponse = {
newTweetIdeas: string;
in