首页 / 文章

使用 Next.js 与 AI SDK 构建多步骤 AI 智能体用户界面

基于Next.js和AI SDK的AI智能体用户界面:使用TypeScript实现,具备类型化工具、多步骤循环功能,以及能随智能体工作动态渲染的流式生成式用户界面组件。

阅读约 8 分钟1868 词

大多数关于人工智能系统的教程都停留在最基础、最简单的阶段。你编写一个简短的脚本,向模型发送提示词,然后看着终端中输出的内容——这就已经让人感觉取得了进展。

真正的难度在于试图将这些内容转化为实际的产品时才会显现。

这时你就会发现,企业用户对原始的 Markdown 文本毫无兴趣。他们希望看到交互式的数据表格,在执行任何可能造成破坏的操作之前有确认步骤,以及不会导致页面跳转的流畅输出。

他们真正需要的是一个能够按顺序协调多个工具调用的模型,同时界面能为该过程的每个阶段渲染格式正确的 React 组件。

为智能体设计界面意味着将模型视为一个主动的执行引擎,而非简单的文本生成器。以下是使用 Vercel AI SDK 在 Next.js 中构建生产级智能体界面的蓝图——该界面支持多步骤工具调用、强类型数据传输,以及随着响应流式生成动态的 UI 元素。

架构设计:超越简单的文本流

一个功能完善的智能体处理流程与普通聊天机器人截然不同:

[ Client: useChat() ]
       │  ▲
       │  │  Data Stream (Tokens + Typed Tool Invocations)
       ▼  │
[ Next.js Route Handler: streamText() ]
       │  ▲
       │  │  Model generates tool arguments
       ▼  │
[ Tool Execution (e.g. DB Query / Stripe API) ]
       │
       └──> (Loop back to LLM if more steps are required)
  • 有状态传输:客户端将用户输入发送到运行在 Edge 或 Node 运行时上的路由处理程序。
  • 服务器端工具执行:模型会判断自身是否已拥有足够信息来给出响应,或者是否需要调用通过 Zod 模式验证的预定义工具以获取外部数据。
  • 多步骤智能体循环:工具在服务器上运行完成后,其结果会自动传回模型——这一过程受SDK中配置的步骤限制约束——直至模型生成自然语言形式的最终答案。
  • 类型化生成式用户界面:客户端不会直接显示原始JSON,而是从数据流中提取出类型化的工具片段,并为它们渲染专用的交互式React组件。
  • 1. 使用类型化工具定义智能体API

    首先使用App Router构建API路由。目标是创建一个能够获取实时账户分析数据并执行事务性操作(如生成发票)的智能体。

    安装所需的包:

    npm install ai @ai-sdk/openai @ai-sdk/react zod
    

    然后在app/api/agent/route.ts文件中定义路由处理函数:

    import { openai } from '@ai-sdk/openai';
    import { streamText, tool } from 'ai';
    import { z } from 'zod';
    
    // Prevent function timeouts on long agent loops
    export const maxDuration = 60;
    
    export async function POST(req: Request) {
      const { messages } = await req.json();
    
      const result = streamText({
        model: openai('gpt-4o'),
        messages,
        // System instructions establish agent personality and boundaries
        system: `You are an internal operations assistant.
          Use the available tools to answer queries and perform operations.
          Always confirm parameters before invoking actions that write data.`,
    
        // Allow the model to execute a tool, inspect output, and take subsequent steps
        maxSteps: 5,
    
        tools: {
          getAccountMetrics: tool({
            description: 'Fetch real-time metrics for a customer account',
            parameters: z.object({
              accountId: z.string().describe('The alphanumeric account identifier'),
              timeframe: z.enum(['7d', '30d', '90d']),
            }),
            execute: async ({ accountId, timeframe }) => {
              // Replace with real database or external API call
              await new Promise((r) => setTimeout(r, 800)); // Simulating latency
    
              return {
                accountId,
                timeframe,
                revenue: 24500,
                activeUsers: 342,
                churnRisk: 'low',
              };
            },
          }),
    
          generateInvoiceDraft: tool({
            description: 'Prepare an invoice draft for customer review',
            parameters: z.object({
              recipientEmail: z.string().email(),
              amount: z.number().positive(),
              currency: z.string().default('USD'),
            }),
            execute: async ({ recipientEmail, amount, currency }) => {
              // Simulating invoice creation
              return {
                invoiceId: `inv_${Math.random().toString(36).substring(2, 9)}`,
                status: 'draft',
                recipientEmail,
                amount,
                currency,
                createdAt: new Date().toISOString(),
              };
            },
          }),
        },
      });
    
      return result.toDataStreamResponse();
    }
    

    为何maxSteps如此重要

    早期多步骤智能体推理方法依赖于笨重的 while 循环、手动修改消息数组,以及每一步都需要单独向大语言模型发起请求。

    通过设置 maxSteps: 5,SDK 可以在服务器端接管这一递归的工具解析流程,并将每个中间状态实时直接传输给客户端。

    2. 构建客户端:流式类型化组件岛

    在前端,现代智能体界面会将每个接收到的响应拆分为多个部分,而非将其视为一个完整的文本块。单个响应中可能包含纯文本片段、仍在等待处理的工具调用,以及已经处理完成的工具结果。

    在 app/page.tsx 中设置聊天容器:

    'use client';
    
    import { useChat } from '@ai-sdk/react';
    import { useState } from 'react';
    import { MetricsCard } from '@/components/tools/MetricsCard';
    import { InvoiceCard } from '@/components/tools/InvoiceCard';
    
    export default function AgentConsole() {
      const [input, setInput] = useState('');
      const { messages, append, status } = useChat({
        api: '/api/agent',
      });
    
      const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (!input.trim()) return;
        append({ role: 'user', content: input });
        setInput('');
      };
    
      const isLoading = status === 'streaming' || status === 'submitted';
    
      return (
        <main className="max-w-4xl mx-auto py-10 px-4 flex flex-col h-screen">
          <header className="border-b pb-4 mb-6">
            <h1 className="text-xl font-semibold tracking-tight">Operations Intelligence Agent</h1>
            <p className="text-sm text-neutral-500">Autonomous workflow orchestration and metrics exploration.</p>
          </header>
    
          {/* Message Feed */}
          <div className="flex-1 overflow-y-auto space-y-6 pr-2">
            {messages.map((m) => (
              <div
                key={m.id}
                className={`flex flex-col ${
                  m.role === 'user' ? 'items-end' : 'items-start'
                }`}
              >
                <div
                  className={`rounded-2xl px-4 py-3 max-w-[85%] text-sm ${
                    m.role === 'user'
                      ? 'bg-blue-600 text-white'
                      : 'bg-neutral-100 text-neutral-900 border border-neutral-200'
                  }`}
                >
                  {/* Handle granular message parts */}
                  {m.parts?.map((part, index) => {
                    if (part.type === 'text') {
                      return <p key={index} className="whitespace-pre-wrap">{part.text}</p>;
                    }
    
                    // Handle tool rendering based on typed part identifier
                    if (part.type === 'tool-invocation') {
                      const { toolName, state, args } = part.toolInvocation;
    
                      if (state !== 'result') {
                        return (
                          <div key={index} className="my-2 py-2 px-3 bg-neutral-50 rounded border border-dashed border-neutral-300 text-xs text-neutral-500 animate-pulse">
                            Executing tool: <code className="font-mono">{toolName}</code> with {JSON.stringify(args)}...
                          </div>
                        );
                      }
    
                      const { result } = part.toolInvocation;
    
                      // Render specialized components for each tool
                      if (toolName === 'getAccountMetrics') {
                        return <MetricsCard key={index} data={result} />;
                      }
    
                      if (toolName === 'generateInvoiceDraft') {
                        return <InvoiceCard key={index} data={result} />;
                      }
                    }
    
                    return null;
                  }) ?? m.content}
                </div>
              </div>
            ))}
          </div>
    
          {/* Input Form */}
          <form onSubmit={handleSubmit} className="mt-4 flex gap-2">
            <input
              value={input}
              onChange={(e) => setInput(e.target.value)}
              placeholder="Ask for metrics or draft an invoice..."
              disabled={isLoading}
              className="flex-1 rounded-lg border border-neutral-300 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50"
            />
            <button
              type="submit"
              disabled={isLoading}
              className="bg-neutral-900 text-white rounded-lg px-5 py-2 text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 transition"
            >
              {isLoading ? 'Thinking...' : 'Send'}
            </button>
          </form>
        </main>
      );
    }
    

    3. 渲染生成式 UI 组件

    无需直接输出原始 JSON,只需将架构定义的输出直接映射到展示组件上即可。

    以下是 components/tools/MetricsCard.tsx 的内容:

    interface MetricsCardProps {
      data: {
        accountId: string;
        timeframe: string;
        revenue: number;
        activeUsers: number;
        churnRisk: string;
      };
    }
    
    export function MetricsCard({ data }: MetricsCardProps) {
      return (
        <div className="my-3 bg-white border border-neutral-200 rounded-xl p-4 shadow-sm text-neutral-800">
          <div className="flex items-center justify-between pb-2 border-b border-neutral-100 mb-3">
            <span className="font-mono text-xs text-neutral-400">ID: {data.accountId}</span>
            <span className="bg-emerald-50 text-emerald-700 text-xs px-2 py-0.5 rounded-full font-medium">
              Window: {data.timeframe}
            </span>
          </div>
          <div className="grid grid-cols-3 gap-4 text-center">
            <div>
              <p className="text-xs text-neutral-400 uppercase font-semibold">Revenue</p>
              <p className="text-lg font-bold">${data.revenue.toLocaleString()}</p>
            </div>
            <div>
              <p className="text-xs text-neutral-400 uppercase font-semibold">Users</p>
              <p className="text-lg font-bold">{data.activeUsers}</p>
            </div>
            <div>
              <p className="text-xs text-neutral-400 uppercase font-semibold">Churn Risk</p>
              <p className="text-lg font-bold capitalize text-emerald-600">{data.churnRisk}</p>
            </div>
          </div>
        </div>
      );
    }
    

    还有 components/tools/InvoiceCard.tsx:

    interface InvoiceCardProps {
      data: {
        invoiceId: string;
        status: string;
        recipientEmail: string;
        amount: number;
        currency: string;
        createdAt: string;
      };
    }
    
    export function InvoiceCard({ data }: InvoiceCardProps) {
      return (
        <div className="my-3 bg-white border border-neutral-200 rounded-xl p-4 shadow-sm text-neutral-800">
          <div className="flex justify-between items-center mb-2">
            <h4 className="text-xs uppercase font-bold tracking-wider text-neutral-400">Invoice Draft Created</h4>
            <span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded font-mono font-medium">
              {data.status}
            </span>
          </div>
          <p className="text-xl font-bold font-mono my-1">
            ${data.amount} <span className="text-xs font-sans text-neutral-400">{data.currency}</span>
          </p>
          <p className="text-xs text-neutral-500">Recipient: <span className="text-neutral-700">{data.recipientEmail}</span></p>
          <div className="mt-3 flex gap-2">
            <button
              onClick={() => alert(`Invoice ${data.invoiceId} sent!`)}
              className="w-full bg-neutral-900 hover:bg-neutral-800 text-white text-xs font-semibold py-1.5 rounded transition"
            >
              Approve & Send
            </button>
          </div>
        </div>
      );
    }
    

    4. 经实践总结的生产环境经验

    将此类接口从本地开发环境部署到正式生产环境时,往往很快就会遇到各种边界情况。请牢记以下防护措施。

    1. 在 Vercel 上设置服务器超时时间

    那些串联多个工具调用的脚本很容易超出无服务器函数的默认超时时间(通常为10到15秒)。务必在 App Router 路由中明确指定超时时长:

    export const maxDuration = 60; // Set according to your deployment plan limits
    

    2. 工具调用的幂等性

    当模型因指令不明确而误读自身输出或重新执行某一步骤时,可能会多次调用变异工具。任何涉及写操作的工具,比如需要通过支付方式扣费或创建新账户记录的工具,都应要求提供幂等性密钥,该密钥可从提示语本身获取或由会话标识符派生而来。

    3. 人工干预确认

    绝不能允许智能体自主执行不可逆的操作,例如删除记录、发送真实邮件或转账。

    • 让智能体生成一个草稿状态,标明为status: 'pending_approval'之类的标识。
    • 显示一个包含手动确认按钮的交互式卡片。
    • 要求用户通过标准的Next.js Server Action明确批准并触发该操作。

    AI接口的新标准

    普通的聊天补全框正在成为过去式。如今的用户不再需要没有实体的对话伙伴,他们希望使用能够减少工作流程中的繁琐操作,同时让他们始终掌握控制权的工具。

    通过将Next.js App Router的流式处理与AI SDK的类型化工具运行时相结合,你可以将语言模型转变为可靠的功能构建模块,而非不可预测的文本生成器。

    这样就能打造出一个系统,它兼具强类型TypeScript组件的可预测性以及基础模型的灵活推理能力,而这正是企业软件所需要的平衡点。