← 返回 复古电话交换机,缠绕的铜线在头顶温暖的单个灯泡照亮下,35mm 胶片拍摄

用 Vercel AI SDK 和 Supabase 构建 AI Agent

定制软件与架构

三周前,一位客户,一位爱丁堡的 SaaS 创始人,打电话给我,问我是否能为他构建一个"智能助手",可以回答有关他的产品数据库的问题、记住过去的对话,并执行创建支持工单等操作。预算很紧张,截止日期更紧张。我一直想深入研究 Vercel AI SDK,这感觉就是时候了。

接下来是三天的实际构建:有些漂亮的部分,有些尴尬的错误,大量的源代码阅读。这篇文章是我一开始就希望拥有的工作教程。

---

我们实际上在构建什么

一个 AI agent。不是聊天机器人。这个区别比人们想象的更重要。

聊天机器人接收输入并生成输出。智能体除此之外还能决定调用哪些工具、以什么顺序调用,在出现问题时可以循环重试。它在会话之间保有记忆。它能够采取行动,而不仅仅是做出响应。

我们的智能体将:

  • 接收用户的自然语言提问
  • 使用工具调用查询 Supabase Postgres 数据库
  • 在会话之间记住对话历史(持久化存储在 Supabase)
  • 返回结构化的、有事实根据的答案

技术栈是 Next.js(App Router)、Vercel AI SDK、Supabase(用于数据库和认证)以及 OpenAI 的 gpt-4o 模型。你可以用 Anthropic 或 Mistral 替换 OpenAI,只需要改动大约十行代码,SDK 对供应商的抽象非常清晰。

---

项目设置

从一个全新的 Next.js 项目开始。

`` npx create-next-app@latest ai-agent --typescript --app --tailwind cd ai-agent ``

安装你实际需要的依赖:

`` npm install ai @ai-sdk/openai @supabase/supabase-js @supabase/ssr zod ``

Zod 对你的工具输入执行 模式 验证。没有它,你就是在信任模型传递合理的参数,这通常可以,直到出现问题。

在 .env.local 中设置你的环境变量:

`` OPENAI_API_KEY=sk-... NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... SUPABASE_SERVICE_ROLE_KEY=eyJ... ``

只在服务器端使用服务角色密钥。匿名密钥可以用于客户端身份验证流程。不要混淆这两个。(我在 2022 年的一个暂存环境中混淆过,短暂地给了每个用户对客户端 CRM 的管理员级别读取权限。那个周五下午不太好过。)

---

设置 Supabase

你需要从 Supabase 获取两样东西:一个用于实际数据的表,和一个用于对话记忆的表。

数据表

在本教程中,假设你在产品目录基础上进行构建。在 Supabase SQL 编辑器中运行此代码:

``sql create table products ( id uuid primary key default gen_random_uuid(), name text not null, description text, price_gbp numeric(10, 2), category text, in_stock boolean default true, created_at timestamptz default now() ); ``

用 20-30 行数据进行填充。真实数据能显著改善测试效果,我总是使用 Mockaroo,因为它生成特定领域的值,而不是"string1, string2"这样的垃圾数据。

消息表

这是会话历史在会话间存储的地方。

```sql create table conversation_messages ( id uuid primary key default gen_random_uuid(), session_id text not null, role text not null check (role in ('user', 'assistant', 'tool')), content jsonb not null, created_at timestamptz default now() );

create index on conversation_messages (session_id, created_at); ```

content 上的 jsonb 类型是有意为之。AI SDK 将消息内容作为结构化对象(文本部分、工具调用部分、工具结果部分)传递,而不是纯字符串。将其存储为文本然后尝试解析回来是你不需要的麻烦。

---

核心代理路由

创建 app/api/agent/route.ts。这是实际逻辑所在的位置。

```typescript import { openai } from '@ai-sdk/openai'; import { streamText, tool } from 'ai'; import { createClient } from '@supabase/supabase-js'; import { z } from 'zod';

const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! );

export async function POST(req: Request) { const { messages, sessionId } = await req.json();

// Load history from Supabase const { data: history } = await supabase .from('conversation_messages') .select('role, content') .eq('session_id', sessionId) .order('created_at', { ascending: true }) .limit(40);

const priorMessages = (history ?? []).map((row) => ({ role: row.role, content: row.content, }));

const allMessages = [...priorMessages...messages];

const result = await streamText({ model: openai('gpt-4o'), system: You are a helpful product assistant. You have access to a product database. Always use the queryProducts tool when the user asks about products, pricing, or availability. Never guess at product details. If the tool returns no results, say so plainly., messages: allMessages, tools: { queryProducts: tool({ description: 'Query the product catalogue by category, name, or stock status.', parameters: z.object({ category: z.string().optional().describe('Product category to filter by'), searchTerm: z.string().optional().describe('Name or keyword to search'), inStockOnly: z.boolean().optional().describe('Filter to in-stock products only'), }), execute: async ({ category, searchTerm, inStockOnly }) => { let query = supabase.from('products').select('*');

if (category) query = query.eq('category', category); if (inStockOnly) query = query.eq('in_stock', true); if (searchTerm) query = query.ilike('name', %${searchTerm}%);

const { data, error } = await query.limit(10);

if (error) return { error: error.message }; return { products: data ?? [] }; }, }), }, maxSteps: 5, onFinish: async ({ response }) => { // Persist the new messages const newMessages = response.messages.map((msg) => ({ session_id: sessionId, role: msg.role, content: msg.content, }));

await supabase.from('conversation_messages').insert(newMessages); }, });

return result.toDataStreamResponse(); } ```

这里有几点值得指出。

maxSteps: 5 是代理循环。SDK 会持续调用工具并将结果反馈给模型,最多五次,然后强制给出最终答案。设置过低,代理会在任务中途放弃。设置过高,模型容易混淆,可能快速累积 API 成本。五次对大多数任务来说是个合理的默认值。

onFinish 回调是持久化记忆的地方。不要在流完成前尝试保存消息,此时你还没有完整的工具调用/结果对。

---

构建前端

保持简单。AI SDK 中的 useChat hook 几乎可以完成所有繁重工作。

```typescript // app/page.tsx 'use client';

import { useChat } from 'ai/react'; import { useState } from 'react';

export default function AgentPage() { const [sessionId] = useState(() => crypto.randomUUID());

const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: '/api/agent', body: { sessionId }, });

return ( <div className="max-w-2xl mx-auto p-6"> <div className="space-y-4 mb-6"> {messages.map((m) => ( <div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}> <span className="inline-block bg-gray-100 rounded px-3 py-2 text-sm"> {typeof m.content === 'string' ? m.content : '[tool interaction]'} </span> </div> ))} </div> <form onSubmit={handleSubmit} className="flex gap-2"> <input value={input} onChange={handleInputChange} className="flex-1 border rounded px-3 py-2 text-sm" placeholder="Ask about products..." disabled={isLoading} /> <button type="submit" disabled={isLoading} className="bg-black text-white px-4 py-2 rounded text-sm"> Send </button> </form> </div> ); } ```

sessionId 在页面挂载时生成一次。在真实应用中,应将其绑定到你的认证用户 ID 或持久化的会话 cookie。否则每次页面刷新都会清空内存。

---

让 Agent 真正有用

关键在于:一个只是查询数据库的基础 agent 是演示。有用的 agent 需要处理边界情况。具体来说:

优雅地处理工具错误

如果你的 execute 函数抛出异常,SDK 会捕获它并将错误字符串传回给模型。模型通常会尝试向用户解释这个错误,这没问题。但你希望 execute 函数以数据形式返回错误而不是抛出异常,根据我的经验,模型处理返回的错误对象比处理捕获的异常效果更好。

接地和幻觉

系统提示的影响巨大。"永远不要猜测产品细节"不是废话,如果没有这条指令,gpt-4o 在查询没有结果时偶尔会编造产品规格。说实话,我在没有这个守卫的情况下测试过,询问了一个不存在的产品,模型编造了一个看起来合理的价格和描述。令人印象深刻,但完全没用。

控制历史长度

从历史记录中加载 40 条消息(上面的 .limit(40))是合理的上限。超过这个数量,你就在浪费 token 用于很少有帮助的旧上下文。对于长时间运行的 agent,可以考虑摘要:每 30 条消息,让模型总结迄今为止的对话,并将其存储为单条"摘要"消息。

Vercel AI SDK 关于多步骤工具调用的文档深入讨论了消息格式,如果你想深入了解工具结果部分的结构,可以查看。

---

部署到 Vercel

假设你使用的是 App Router,这真的很简单。

  1. 推送到 GitHub。
  2. 在 Vercel 仪表板中导入仓库。
  3. 在项目设置中添加环境变量。
  4. 部署。

需要注意的一点:流式响应需要支持 Web Streams 的运行时。Vercel 上的 App Router 开箱即用地支持这一点。如果你使用的是 Pages Router 和较旧的 Express 风格 API,你需要不同的配置方式。说实话,就用 App Router 吧。

Supabase 连接池也值得检查。免费层上的 Supabase 项目的连接限制约为 60。如果你在每个代理请求时都访问数据库并进行多个查询,在负载下可能会比预期更快地达到该限制。在生产部署中使用 Supabase 的连接池程序(PgBouncer) 的事务模式。

---

我下次会做得不同的地方

当我为爱丁堡客户发布这个系统时,有几件事让我吃了亏:

  • 我最初在 Supabase 中将消息内容存储为文本,而不是 jsonb。从字符串中重建结构化的工具消息非常痛苦,我浪费了大半个下午。
  • 我没有在路由上添加速率限制。客户团队在 UAT 期间立即开始用长而复杂的查询轰击它。在交给人类之前添加类似 Upstash 速率限制 的东西。
  • 我在初始测试中将 maxSteps 设置为 10。在一个措辞不当的查询中,模型在得出产品不存在的结论之前进行了六次工具调用。那是六次数据库往返和大量的 token。五次几乎总是足够的。

不过核心架构表现得很好。Vercel AI SDK 让我免去了编写自己的流解析器和工具调用状态机,仅此一点就值得这个依赖。

---

常见问题

除了 OpenAI 之外,哪些模型支持 Vercel AI SDK?

SDK 支持 Anthropic (Claude 3.5 Sonnet 等)、Google (Gemini)、Mistral、Cohere,以及通过像 @ai-sdk/anthropic 这样的提供商包支持的更多模型。切换提供商在大多数情况下真的只需要改一行代码,将 openai('gpt-4o') 替换为 anthropic('claude-3-5-sonnet-20241022'),流、工具调用和 useChat 钩子的工作方式完全相同。某些提供商在工具调用支持方面有一些特殊考虑,所以在做决定之前请查看 SDK 兼容性表。

agent 可以写入 Supabase,而不仅仅是读取吗?

可以的,这就是 agent 既有趣又危险的地方。你可以用编写 queryProducts 的方式编写 createSupportTicket 或 updateProductStock 工具。execute 函数只是运行 insert 或 update 而不是 select。我强烈建议在任何 agent 可以写入的表上配置行级安全策略,并将破坏性操作 (delete) 完全排除在工具集之外,除非你在 UI 中有确认步骤。

我如何处理身份验证,使用户只能看到他们自己的数据?

最清洁的方法:在路由处理器内使用用户的 JWT (来自 cookie 或 Authorization header) 而不是服务角色密钥生成 Supabase 客户端。这样 Supabase 的行级安全策略会自动应用。@supabase/ssr 包有从 Next.js cookies 中提取会话的辅助函数。不要将用户 ID 作为纯参数传递给 agent,如果系统提示不够严密,模型可能被诱骗查询另一个用户的数据。

Vercel AI SDK 能用于生产环境吗?

我会说它是生产就绪的,但有些注意事项。它得到积极维护,Vercel 快速推送功能,核心流式传输和工具调用原语是稳定的。变动较多的部分是实验性功能(如带复杂联合架构的 generateObject)。固定你的版本,升级前阅读更新日志。Seahawk 有两个实时客户项目在上面运行,没有问题,但我们采用了激进的版本固定。

为什么特别选择 Supabase,而不是 Railway 上的 Postgres 或 PlanetScale?

Supabase 给你一个屋顶下的 Postgres、类型化客户端、身份验证、实时功能、存储和边缘函数。对于这样的项目,auth 集成和 SQL 编辑器能真正节省时间。不过,实际的 agent 代码可以与任何 Postgres 兼容的数据库协作。把 Supabase 客户端换成 pg 或 Drizzle ORM,在结构上没有任何改变。

---

说实话,这个技术栈让我惊喜的是,从零开始构建一个有内存持久化的 agent 有多快。Vercel AI SDK 做了很多幕后工作:流式协议、工具调用序列化、提供商抽象。Supabase 处理持久化和身份验证,不会给你添太多麻烦。剩下的复杂性,也就是没人能为你抽象的部分,是写一个系统提示词让模型真正按你想要的方式行动。这一块需要反复迭代。从严格开始,逐步放宽,用你能想到的最差劲的问法来测试。

相关阅读:2026 年 AI 搜索关键词研究:它是什么,为什么传统 SEO 研究不再适用、技术 SEO 和AI 搜索。

← 返回