三周前,一位客户——一位爱丁堡的 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); ```
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();
// 从 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: 你是一位有帮助的产品助手。你可以访问产品数据库。当用户询问产品、定价或库存时,始终使用 queryProducts 工具。永远不要猜测产品细节。如果工具返回无结果,直言不讳。, messages: allMessages, tools: { queryProducts: tool({ description: '按类别、名称或库存状态查询产品目录。', parameters: z.object({ category: z.string().optional().describe('要筛选的产品类别'), searchTerm: z.string().optional().describe('要搜索的名称或关键字'), inStockOnly: z.boolean().optional().describe('仅筛选有货产品'), }), 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 }) => { // 持久化新消息 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="询问产品..." disabled={isLoading} /> <button type="submit" disabled={isLoading} className="bg-black text-white px-4 py-2 rounded text-sm"> 发送 </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,这真的很简单。
- 推送到 GitHub。
- 在 Vercel 仪表板中导入仓库。
- 在项目设置中添加环境变量。
- 部署。
需要注意的一点:流式响应需要支持 Web Streams 的运行时。Vercel 上的 App Router 开箱即用地支持这一点。如果你使用的是 Pages Router 和较旧的 Express 风格 API,你需要不同的配置方式。说实话,就用 App Router 吧。
Supabase 连接池也值得检查。免费层的 Supabase 项目的连接限制约为 60。如果你在每个代理请求中都多次查询数据库,在负载下你可能会比预期更快地耗尽连接。在生产部署中使用 Supabase 的连接池化器(PgBouncer)的事务模式。
---
我下次会做得不同的地方
当我为爱丁堡客户发布这个系统时,有几件事让我吃了亏:
- 我最初在 Supabase 中将消息内容存储为文本,而不是 jsonb。从字符串中重建结构化的工具消息非常痛苦,我浪费了大半个下午。
- 我没有在路由上添加速率限制。客户团队在 UAT 期间立即开始用长的、复杂的查询轰击它。在将任何东西交给真人之前,添加类似 Upstash Rate Limit 这样的东西。
- 我在初始测试中将 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 的功能更新速度很快,核心流式传输和工具调用的基础设施是稳定的。变化比较大的是那些实验性功能(比如带有复杂 union schema 的 generateObject)。锁定你的版本号,升级前先读 changelog。Seahawk 现在有两个活跃的客户项目在用它,目前没有问题,但我们的版本号锁定策略非常激进。
为什么特别选择 Supabase,而不是 Railway 上的 Postgres 或 PlanetScale?
Supabase 给你一个屋顶下的 Postgres、类型化客户端、身份验证、实时功能、存储和边缘函数。对于这样的项目,auth 集成和 SQL 编辑器能真正节省时间。不过,实际的 agent 代码可以与任何 Postgres 兼容的数据库协作。把 Supabase 客户端换成 pg 或 Drizzle ORM,在结构上没有任何改变。
---
说实话,这个技术栈让我惊喜的是,从零开始构建一个有内存持久化的 agent 有多快。Vercel AI SDK 做了很多幕后工作:流式协议、工具调用序列化、提供商抽象。Supabase 处理持久化和身份验证,不会给你添太多麻烦。剩下的复杂性,也就是没人能为你抽象的部分,是写一个系统提示词让模型真正按你想要的方式行动。这一块需要反复迭代。从严格开始,逐步放宽,用你能想到的最差劲的问法来测试。
相关阅读:2026年AI搜索关键词研究:它是什么、为什么传统的、技术SEO和AI搜索。
