用Vercel AI SDK构建Claude流式代理

用Vercel AI SDK构建Claude流式代理

使用Vercel AI SDK v6 + @ai-sdk/anthropic在Next.js App Router中实现Claude流式聊天和工具调用代理的实战指南。通过代码学习streamText、generateObject和工具循环模式。

const result = streamText({
  model: anthropic('claude-sonnet-4-6'),
  prompt: '你好,流式测试',
});

for await (const chunk of result.textStream) {
  process.stdout.write(chunk);
}

第一次运行这段代码时感觉很奇妙。不是因为Claude能逐字输出文本感到惊讶,而是这仅用5行代码就实现了让我有点意外。和直接使用Anthropic SDK相比,设置代码减少了一半以上。

我第一次接触Vercel AI SDK是通过工作群里有人分享的链接。通常”10分钟用Next.js实现AI聊天”这类标题在实际操作时,在安装依赖包的阶段就会遇到问题。我半信半疑地试了一下,结果确实很快。从那以后,我在做原型开发时经常用它。

认真用了一段时间Vercel AI SDK之后,优缺点变得很明显。这篇文章在解释”如何使用”的同时,也会诚实地说明在哪些地方遇到了麻烦。如果你已经用过了,可以直接跳到”工具调用”和”生产环境注意事项”部分。

为什么选择Vercel AI SDK — 与其他选择的直接比较

我先试了其他方案。直接使用Anthropic SDK、LangChain.js,然后是Vercel AI SDK。

直接使用Anthropic SDK最为灵活,但将流式响应传递给前端的样板代码比预期的多。SSE格式处理、前端hook实现、错误处理都需要手动编写。功能本身简单,但代码行数不必要地增多。

// 直接使用Anthropic SDK时的流式配置 — 实际会更长
const stream = await anthropic.messages.stream({
  model: 'claude-sonnet-4-6',
  max_tokens: 1024,
  messages: [{ role: 'user', content: prompt }],
});

// 手动构建SSE响应
const encoder = new TextEncoder();
const readable = new ReadableStream({
  async start(controller) {
    for await (const chunk of stream) {
      if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk.delta.text)}\n\n`));
      }
    }
    controller.close();
  },
});

加上前端解析代码,代码量会相当可观。当需要精细控制时这是正确的方式,但为了构建一个聊天应用这种开销过大。

LangChain.js用到一半放弃了。版本间API变更频繁,文档与实际行为不符的情况发生了好几次。翻GitHub Issues经常看到”此功能已删除”的回答。对于复杂管道可能合适,但不适合快速原型开发。

Vercel AI SDK的实际优势有三点:

第一,streamText() + useChat() 的组合让服务端到客户端的流式连接在10行以内完成。第二,Claude、OpenAI、Gemini、Mistral之间的切换只需改一行provider代码——这实际上非常有用,可以用同样的代码比较不同模型的输出。第三,generateObject() 配合Zod schema验证,结构化输出处理简洁清晰。

缺点也有。它针对Vercel平台进行了优化,在其他部署环境中会产生限制。当需要对代理循环进行精细控制时,灵活性不如直接使用Anthropic SDK。这一点稍后会具体说明。

与直接构建Claude Managed Agents相比,Managed Agents在没有基础设施的情况下更容易上手,但自定义限制很明显。Vercel AI SDK处于两者之间——比原始SDK更抽象,比Managed Agents控制权更多。

环境设置 — 从安装包开始

前提条件

  • Node.js 20+
  • Anthropic API密钥(ANTHROPIC_API_KEY
  • Next.js 15(App Router)
# 新建项目
npx create-next-app@latest my-claude-app --typescript --app
cd my-claude-app

# 安装AI SDK核心包
npm install ai @ai-sdk/anthropic zod

.env.local中添加API密钥:

ANTHROPIC_API_KEY=sk-ant-api03-...

实际操作时遇到了一个情况:@ai-sdk/anthropic安装正常,但出现了TypeScript类型错误。tsconfig.json中的moduleResolution需要是bundlernode16以上。使用create-next-app创建的项目默认配置已经处理了这个问题。

目录结构这样设置:

app/
├── api/
│   ├── chat/
│   │   └── route.ts      # 流式聊天API
│   └── extract/
│       └── route.ts      # generateObject API
├── page.tsx              # 聊天UI
└── components/
    └── Message.tsx

看起来不复杂是正确的。这个结构实际上足以创建一个功能完整的聊天应用。

用streamText实现Claude流式输出

先创建服务端API路由。

app/api/chat/route.ts

import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';

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

  const result = streamText({
    model: anthropic('claude-sonnet-4-6'),
    system: `你是一个友善的技术博客助手。
对代码问题给出实用的回答,不懂的地方诚实说明。
保持回答简洁同时不遗漏重要内容。`,
    messages,
    maxTokens: 2048,
    temperature: 0.7,
  });

  return result.toUIMessageStreamResponse();
}

toUIMessageStreamResponse()是关键。这个方法单独处理SSE头部设置、数据块格式化和流终止。用Anthropic SDK手动实现这部分需要20行以上。

前端app/page.tsx

'use client';

import { useChat } from 'ai/react';
import { useEffect, useRef } from 'react';

export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
    api: '/api/chat',
  });
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
      <div className="flex-1 overflow-y-auto space-y-4 pb-4">
        {messages.length === 0 && (
          <p className="text-gray-400 text-center mt-8">有什么我可以帮助的吗?</p>
        )}
        {messages.map((m) => (
          <div
            key={m.id}
            className={`p-3 rounded-lg max-w-[85%] ${
              m.role === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'
            }`}
          >
            <p className="text-xs text-gray-400 mb-1">
              {m.role === 'user' ? '我' : 'Claude'}
            </p>
            <div className="whitespace-pre-wrap text-sm">
              {m.content as string}
            </div>
          </div>
        ))}
        {isLoading && (
          <div className="bg-gray-100 p-3 rounded-lg text-gray-400 text-sm">
            Claude正在输入...
          </div>
        )}
        {error && (
          <div className="text-red-500 text-sm p-2 bg-red-50 rounded">
            错误:{error.message}
          </div>
        )}
        <div ref={bottomRef} />
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2 mt-4 border-t pt-4">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入消息..."
          className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400"
          disabled={isLoading}
        />
        <button
          type="submit"
          disabled={isLoading || !input.trim()}
          className="bg-blue-500 text-white px-6 py-2 rounded-lg disabled:opacity-50"
        >
          发送
        </button>
      </form>
    </div>
  );
}

useChat处理消息状态管理、流式更新、加载状态和错误处理。自己实现的话需要useStateuseRefAbortController、SSE解析等相当多的代码。

运行npm run dev,聊天就在localhost:3000上工作了。Claude的回复像打字一样逐字流出。

工具调用 — 让Claude真正做些什么

要超越聊天让Claude调用外部工具,需要添加tools选项和maxSteps

import { streamText, tool } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

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

  const result = streamText({
    model: anthropic('claude-sonnet-4-6'),
    system: '你是一个管理天气信息和待办列表的助手。',
    messages,
    maxSteps: 5,
    tools: {
      getWeather: tool({
        description: '获取特定城市的当前天气',
        parameters: z.object({
          city: z.string().describe('要查询天气的城市名'),
          unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
        }),
        execute: async ({ city, unit }) => {
          // 实际实现中调用天气API
          return {
            city,
            temperature: unit === 'celsius' ? 22 : 72,
            condition: '晴天',
            humidity: 65,
            feelsLike: unit === 'celsius' ? 20 : 68,
          };
        },
      }),
      addTodo: tool({
        description: '向待办列表添加新项目',
        parameters: z.object({
          title: z.string().describe('待办事项标题'),
          priority: z.enum(['low', 'medium', 'high']).default('medium'),
          dueDate: z.string().optional().describe('截止日期(YYYY-MM-DD)'),
        }),
        execute: async ({ title, priority, dueDate }) => {
          const id = Math.random().toString(36).slice(2);
          return { id, title, priority, dueDate, created: new Date().toISOString() };
        },
      }),
    },
  });

  return result.toUIMessageStreamResponse();
}

maxSteps: 5很重要。没有它,Claude收到工具结果后不会继续生成响应。SDK自动处理这个循环——maxSteps限制最大迭代次数。

AI代理组合多个工具解决问题的模式在很大程度上依赖于maxSteps设置和每个工具description的质量。描述不清晰,Claude就无法判断何时使用哪个工具。早期版本中天气和待办事项会混淆,在系统提示中明确说明各工具使用场景后就稳定了。

在前端实时显示工具调用进度:

{messages.map((m) => (
  <div key={m.id}>
    {m.role === 'assistant' &&
      Array.isArray(m.toolInvocations) &&
      m.toolInvocations.map((ti) => (
        <div key={ti.toolCallId} className="text-xs text-gray-400 italic mb-1">
          {ti.state === 'call' && `⚙ 正在调用 ${ti.toolName}...`}
          {ti.state === 'result' && `✓ ${ti.toolName} 完成`}
        </div>
      ))
    }
    <div className="text-sm">{m.content as string}</div>
  </div>
))}

用generateObject提取结构化输出

与流式聊天分开,当需要从Claude的响应中提取特定结构的数据时,使用generateObject()

// app/api/extract/route.ts
import { generateObject } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

const ArticleMetaSchema = z.object({
  title: z.string().describe('文章标题(60字以内)'),
  summary: z.string().max(300).describe('3〜4句话摘要'),
  tags: z.array(z.string()).min(2).max(5).describe('相关技术标签'),
  difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
  estimatedReadTime: z.number().int().describe('预计阅读时间(分钟)'),
  hasCodeExamples: z.boolean(),
  mainTopics: z.array(z.string()).max(3).describe('最多3个主要话题'),
});

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

  try {
    const { object } = await generateObject({
      model: anthropic('claude-sonnet-4-6'),
      schema: ArticleMetaSchema,
      prompt: `请分析以下技术博客文章并提取元数据。
主要读者是后端/全栈开发者。

---
${content}
---`,
    });

    return Response.json(object);
  } catch (error) {
    return Response.json({ error: '分析失败' }, { status: 500 });
  }
}

响应以符合Zod schema的类型安全对象返回。不需要单独处理JSON解析错误或类型不匹配。

适合这个模式的场景:

  • 博客文章自动标签和元数据生成
  • 用户输入分类
  • 从长文档中提取结构化信息
  • 表单自动填充

这个博客的分类分数提取中实际使用了类似模式。在Zod schema字段上写好describe()是提升输出质量的关键。做好上下文工程意味着schema设计和提示质量决定了提取准确度的80%。

streamObject()也可用——当你想在UI中渐进式显示大型schema中的字段而不需要等待完整响应时很有用。

生产环境遇到的问题

用一段时间后,会出现几个限制。

Edge运行时限制

在Vercel Edge Functions上运行意味着无法使用Node.js专用包。@ai-sdk/anthropic在Edge上运行,但在工具函数内部导入Node.js专用包会导致部署错误。

// 在route.ts顶部明确声明
export const runtime = 'nodejs'; // 使用Node.js运行时而非Edge

大多数情况下,设置runtime = 'nodejs'是实用的选择。

Serverless超时

Vercel免费版serverless函数超时为10秒。Claude生成长文本或运行复杂工具循环可能会超时。Pro版可延长至60秒。

对于需要更长时间的任务,架构本身需要改变。单独构建MCP服务器来分离长时间运行任务是一种方案。

上下文累积成本

随着对话增长,完整消息历史累积在上下文中,token成本迅速增加。可以从streamText结果中查看使用量:

const result = streamText({ ... });

result.usage.then((usage) => {
  const inputCost = (usage.promptTokens / 1_000_000) * 3.0;
  const outputCost = (usage.completionTokens / 1_000_000) * 15.0;
  console.log(`Token:输入 ${usage.promptTokens},输出 ${usage.completionTokens}`);
  console.log(`费用:$${(inputCost + outputCost).toFixed(5)}`);
});

实际服务需要上下文管理策略。最简单的方法是只保留最近N轮:

const recentMessages = messages.slice(-20); // 最近10轮

const result = streamText({
  model: anthropic('claude-sonnet-4-6'),
  messages: recentMessages,
});

速率限制

触及Anthropic API速率限制会返回429 Too Many Requests错误。多用户并发环境需要请求队列或退避逻辑。ai包本身没有重试逻辑,需要自己实现或添加中间件。

什么情况下适合使用

Vercel AI SDK适合的情况:

  • 想要快速在Next.js应用中添加AI聊天功能
  • 想用同一套代码测试Claude、OpenAI和Gemini
  • 想用useChat钩子最小化前端状态管理
  • 计划部署到Vercel且超时限制在当前规模下不是瓶颈

不适合的情况:

  • 需要完全控制代理循环内部 — 直接使用Anthropic SDK更好
  • 有Python后端需要集成 — 这个SDK是TypeScript专用的
  • 服务基准是每用户数十轮以上的长对话 — 上下文管理成为重要架构考虑

个人而言,在验证新AI功能想法时经常用到它。能在30分钟内把想法变成能运行的原型是实实在在的优势。短板在于当你需要精细化生产控制时——这时要么直接用Anthropic SDK,要么在AI SDK之上构建抽象层。

Vercel AI SDK在便利性和灵活性之间做出了取舍。这个取舍适合很多用例,但不是所有情况。“先用这个,需要时再迁移”是现实的思维模式。


接下来想测试的是AI SDK 6中新增的human-in-the-loop工具审批流程。代理在调用特定工具前可以暂停等待人工审批。这在生产环境中有多可靠,还需要更多验证。在完全自主代理和手动工作流之间找到正确平衡,是2026年代理开发的核心挑战之一。

阅读其他语言版本

这篇文章有帮助吗?

您的支持能帮助我创作更好的内容。请我喝杯咖啡吧。

关于作者

jw

Kim Jangwook

AI/LLM专业全栈开发者

凭借10年以上的Web开发经验,构建AI代理系统、LLM应用程序和自动化解决方案。分享Claude Code、MCP和RAG系统的实践经验。

返回博客列表