🚀 Vercel AI SDK 使用指南: 消息元数据 (Message Metadata)

12 阅读3分钟

在构建 AI 聊天应用时,我们经常需要传递一些不属于消息内容本身的额外信息。例如:

  • 🕒 时间戳:消息生成的时间。
  • 🤖 模型信息:使用的是 GPT-4 还是 Claude 3.5。
  • 💰 Token 用量:当前对话消耗了多少 Token。
  • 🆔 用户上下文:Session ID 或用户 ID。

Vercel AI SDK 提供了 Message Metadata(消息元数据) 功能来解决这个问题。它允许我们在消息级别(Message Level)附加自定义数据,这些数据不会作为 prompt 的一部分发送给大模型,而是专门用于 UI 展示或逻辑处理。

本文将带你通过三个步骤,实现一个带有时间戳和 Token 统计功能的聊天应用。


1. 定义类型 (Type Safety)

为了在前后端获得完整的 TypeScript 类型提示,我们首先需要定义元数据的 Schema。这里使用 zod 来定义结构。

新建 app/types.ts

TypeScript

// app/types.ts
import { UIMessage } from 'ai';
import { z } from 'zod';

// 1. 定义元数据 Schema
// 这里我们定义了创建时间、模型名称和 Token 用量
export const messageMetadataSchema = z.object({
  createdAt: z.number().optional(),
  model: z.string().optional(),
  totalTokens: z.number().optional(),
});

// 2. 导出 Metadata 类型
export type MessageMetadata = z.infer<typeof messageMetadataSchema>;

// 3. 创建带有元数据的 UIMessage 类型
// 在客户端,我们将使用这个类型来替代默认的 Message
export type MyUIMessage = UIMessage<MessageMetadata>;

2. 服务端实现 (Server Side)

在服务端,我们需要在流式响应中注入元数据。Vercel AI SDK 的 streamText 返回的 result 对象提供了一个 toUIMessageStreamResponse 方法,专门用于处理这种情况。

我们可以利用 messageMetadata 回调函数,在流的 开始 (start)结束 (finish) 阶段注入不同的数据。

新建或修改 app/api/chat/route.ts

TypeScript

// app/api/chat/route.ts
import { convertToModelMessages, streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic'; // 或者 @ai-sdk/openai
import type { MyUIMessage } from '@/app/types'; // 引入我们定义的类型

export async function POST(req: Request) {
  // 显式指定 messages 的类型为 MyUIMessage[]
  const { messages }: { messages: MyUIMessage[] } = await req.json();

  const result = streamText({
    model: anthropic('claude-3-5-sonnet-20240620'), // 这里替换为你使用的模型
    messages: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse({
    // 传入原始 messages 以确保返回对象的类型安全
    originalMessages: messages, 
    
    messageMetadata: ({ part }) => {
      // 阶段 1: 流开始时,注入创建时间和模型信息
      if (part.type === 'start') {
        return {
          createdAt: Date.now(),
          model: 'claude-3-5-sonnet',
        };
      }

      // 阶段 2: 流结束时,注入 Token 用量统计
      if (part.type === 'finish') {
        return {
          totalTokens: part.totalUsage.totalTokens,
        };
      }
    },
  });
}

注意toUIMessageStreamResponse 是处理 UI 消息流的标准方式,它能确保元数据与文本流正确合并,而不会破坏流式响应的格式。


3. 客户端实现 (Client Side)

在客户端,我们使用 useChat hook,并传入我们定义的 MyUIMessage 泛型。这样 message.metadata 就会有自动补全提示了。

修改 app/page.tsx

TypeScript

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

import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import type { MyUIMessage } from '@/app/types';

export default function Chat() {
  // 1. 使用泛型 MyUIMessage 初始化 useChat
  // 2. 配置 transport 以匹配服务端 API 路径
  const { messages, input, handleInputChange, handleSubmit } = useChat<MyUIMessage>({
    transport: new DefaultChatTransport({
      api: '/api/chat',
    }),
  });

  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.map(message => (
        <div key={message.id} className="mb-4 whitespace-pre-wrap">
          <div className="font-bold flex items-center gap-2">
            {message.role === 'user' ? 'User' : 'AI'}
            
            {/* --- 展示元数据:时间戳 --- */}
            {message.metadata?.createdAt && (
              <span className="text-xs text-gray-400 font-normal">
                {new Date(message.metadata.createdAt).toLocaleTimeString()}
              </span>
            )}
          </div>

          {/* 渲染消息内容 */}
          <div className="mt-1">
             {message.content}
          </div>

          {/* --- 展示元数据:Token 统计 (仅在流结束后显示) --- */}
          {message.metadata?.totalTokens && (
            <div className="text-xs text-gray-400 mt-1 bg-gray-100 p-1 rounded inline-block">
              Token消耗: {message.metadata.totalTokens}
            </div>
          )}
        </div>
      ))}

      <form onSubmit={handleSubmit} className="fixed bottom-0 w-full max-w-md p-2 bg-white border-t">
        <input
          className="w-full p-2 border border-gray-300 rounded shadow-xl"
          value={input}
          placeholder="Say something..."
          onChange={handleInputChange}
        />
      </form>
    </div>
  );
}

💡 总结与最佳实践

  1. 区分场景:不要将所有数据都塞进 Metadata。

    • Metadata:适合“关于消息的数据”(如时间、来源、成本)。
    • Data Parts:适合“消息的内容”(如生成的图表数据、工具调用结果)。
  2. 类型安全:始终使用 zod 定义 Schema 并共享给前后端,这能避免很多拼写错误带来的 bug。

  3. 按需发送:利用 part.type ('start' 或 'finish'),只在正确的时机发送必要的数据。例如 Token 统计只有在生成结束后 (finish) 才能获取到。

通过以上配置,你的 AI 应用就拥有了专业的元数据处理能力,不再是简单的“黑盒”对话了!