Skip to content

AGUI 协议:Vercel AI SDK + LangChain 实现流式组件渲染

我们前面做的 Agent 功能上没啥问题,但是 UI 比较简陋,只有流式的文字。

而你用 Cursor 之类的 Agent,它的界面是这样的:除了流式的文字,不同的 tool call 有不同的组件来展示,这样体验就好很多。

这种流式返回文字,还能流式渲染组件,需要一套协议,叫做 AGUI 协议(Agent–User Interaction Protocol),定义 agent 和图形界面怎么交互的。

AGUI 协议:SSE 里返回 JSON

比如我们之前返回的 SSE 消息是这样的,只有文字,并不能区分是文本内容,还是 tool call。需要一些元信息,比如 type

解决也很简单,返回 json 就好了。比如这样:

  • text-start 代表文本流开始
  • text-delta 是流式的文本数据
  • text-end 代表文本流结束

如果有 tool call 就是这样:

  • tool-input-start 代表开始接收到 tool 的参数
  • tool-input-delta 是流式的 tool call 的参数
  • tool-input-available 代表 tool 的参数接收完
  • tool-output-available 代表有了 tool 的调用结果,可以从 output 里取

这样 SSE 不止返回流式文本,而是这种 json,那前端不就知道当前是在工具调用还是输出流式文本了么?自然就可以渲染不同的组件,实现更好的体验。

上面的是 Vercel AI SDK 实现的协议,我们直接用它的就行:https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol。在 Vercel AI SDK 里叫做 Data Stream Protocol

Vercel AI SDK 的包结构

Vercel AI SDK 提供了这些包:

  • ai 包:写 agent 逻辑
  • @ai-sdk/openai@ai-sdk/anthropic 等包:对接不同的大模型,就和 langchain 的 ChatModel 一样
  • @ai-sdk/react@ai-sdk/vue 等包:对接后端接口,实现页面渲染

用它可以写 Agent,但它功能比较少,我们只用它的 UI 方面的功能,就是刚才的那套 AGUI 协议。它提供了和 LangChain 的集成包 @ai-sdk/langchain

我们的方案:用 LangChain 写 Agent 部分,然后复用这套 AGUI 协议来给前端传输消息;前端用 @ai-sdk/react@ai-sdk/vue 等来解析 SSE 的消息,拿到 messages,用不同组件渲染就可以了。

创建后端项目

bash
nest new agui-backend

安装用到的包:

bash
pnpm install @langchain/core @langchain/openai @nestjs/config zod

创建 .env 配置文件:

env
OPENAI_API_KEY=sk-xx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus
BOCHA_API_KEY=sk-xx

然后引入 ConfigModule 读取配置,创建 ai 模块:

bash
nest g module ai
nest g controller ai --no-spec
nest g service ai --no-spec

然后来写个 SSE 的 ai 接口。改下 AiModule,加一下网络搜索的 tool:

typescript
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import z from 'zod';

@Module({
  controllers: [AiController],
  providers: [
    AiService,
    {
      provide: 'CHAT_MODEL',
      useFactory: (configService: ConfigService) => {
        return new ChatOpenAI({
          model: configService.get('MODEL_NAME'),
          apiKey: configService.get('OPENAI_API_KEY'),
          configuration: {
            baseURL: configService.get('OPENAI_BASE_URL'),
          },
        });
      },
      inject: [ConfigService],
    },
    {
      provide: 'WEB_SEARCH_TOOL',
      useFactory: (configService: ConfigService) => {
        const webSearchArgsSchema = z.object({
          query: z
            .string()
            .min(1)
            .describe('搜索关键词,例如:公司年报、某个事件等'),
          count: z
            .number()
            .int()
            .min(1)
            .max(20)
            .optional()
            .describe('返回的搜索结果数量,默认 10 条'),
        });

        return tool(
          async ({ query, count }: { query: string; count?: number }) => {
            const apiKey = configService.get<string>('BOCHA_API_KEY');
            if (!apiKey) {
              return 'Bocha Web Search 的 API Key 未配置(环境变量 BOCHA_API_KEY),请先在服务端配置后重试';
            }

            const url = 'https://api.bochaai.com/v1/web-search';
            const body = {
              query,
              freshness: 'noLimit',
              summary: true,
              count: count ?? 10,
            };

            const response = await fetch(url, {
              method: 'POST',
              headers: {
                Authorization: `Bearer ${apiKey}`,
                'Content-Type': 'application/json',
              },
              body: JSON.stringify(body),
            });

            if (!response.ok) {
              const errorText = await response.text();
              return `搜索 API 请求失败,状态码: ${response.status}, 错误信息: ${errorText}`;
            }

            let json: any;
            try {
              json = await response.json();
            } catch (e) {
              return `搜索 API 请求失败,原因是:搜索结果解析失败 ${(e as Error).message}`;
            }

            try {
              if (json.code !== 200 || !json.data) {
                return `搜索 API 请求失败,原因是: ${json.msg ?? '未知错误'}`;
              }

              const webpages = json.data.webPages?.value ?? [];
              if (!webpages.length) {
                return '未找到相关结果。';
              }

              const formatted = webpages
                .map(
                  (page: any, idx: number) =>
                    `引用: ${idx + 1}
    标题: ${page.name}
    URL: ${page.url}
    摘要: ${page.summary}
    网站名称: ${page.siteName}
    网站图标: ${page.siteIcon}
    发布时间: ${page.dateLastCrawled}`,
                )
                .join('\n\n');

              return formatted;
            } catch (e) {
              return `搜索 API 请求失败,原因是:搜索结果解析失败 ${(e as Error).message}`;
            }
          },
          {
            name: 'web_search',
            description:
              '使用 Bocha Web Search API 搜索互联网网页。输入为搜索关键词(可选 count 指定结果数量),返回搜索结果列表。',
            schema: webSearchArgsSchema,
          },
        );
      },
      inject: [ConfigService],
    },
  ],
})
export class AiModule {}

这里创建了 ChatModel 和网络搜索的 tool 的 provider。

然后在 AiService 注入:

typescript
import { Inject, Injectable } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import {
  AIMessage,
  AIMessageChunk,
  createAgent,
  HumanMessage,
  SystemMessage,
  ToolMessage,
} from 'langchain';
import { UIMessage } from 'ai';
import { toBaseMessages, toUIMessageStream } from '@ai-sdk/langchain';

@Injectable()
export class AiService {
  private readonly agent: ReturnType<typeof createAgent>;

  constructor(
    @Inject('WEB_SEARCH_TOOL') private readonly webSearchTool: any,
    @Inject('CHAT_MODEL') model: ChatOpenAI,
  ) {
    this.agent = createAgent({
      model,
      tools: [this.webSearchTool],
      systemPrompt:
        '你是 AI 助手,需要最新信息、事实核查或联网信息时,请使用 web_search 工具搜索后再作答。',
    });
  }

  async stream(messages: UIMessage[]) {
    const lcMessages = await toBaseMessages(messages);
    const lgStream = await this.agent.stream(
      { messages: lcMessages },
      {
        streamMode: ['messages', 'values'],
        recursionLimit: 12,
      },
    );

    return toUIMessageStream(lgStream as AsyncIterable<AIMessageChunk>);
  }
}

这次我们不再手写 agent loop、自己调用 tool 了,直接用 langchain 封装好的 createAgent 的 api。

然后用 @ai-sdk/langchain 这个适配器:把传入的 ai sdk 的 messages 转成 langchain 的 BaseMessage 传给 agent,再把返回的 stream 转成 ai sdk 的 ui message stream 返回。这样返回的流式内容就是 SSE 的 Data Stream Protocol 的协议数据了。

之前手写的 agent loop 也不是白学,那是理解原理的基础垫底,只是生产里用封装好的 API 更省事。

我们改下 AiController,加一下接口:

typescript
import {
  BadRequestException,
  Body,
  Controller,
  Get,
  Post,
  Query,
  Res,
  Sse,
} from '@nestjs/common';
import type { Response } from 'express';
import { AiService } from './ai.service';
import { pipeUIMessageStreamToResponse, UIMessage } from 'ai';

@Controller('ai')
export class AiController {
  constructor(private readonly aiService: AiService) {}

  /**
   本地测试:
   curl -N -sS -X POST 'http://localhost:3000/ai/chat' \
     -H 'Content-Type: application/json' \
     -d '{"messages":[{"id":"1","role":"user","parts":[{"type":"text","text":"北京今天的天..."}]}]}'
   */
  @Post('chat')
  async postChat(
    @Body() body: { messages?: UIMessage[] },
    @Res({ passthrough: false }) res: Response,
  ): Promise<void> {
    if (!body?.messages || !Array.isArray(body.messages)) {
      throw new BadRequestException('Invalid JSON');
    }
    const stream = await this.aiService.stream(body.messages);
    pipeUIMessageStreamToResponse({ response: res, stream });
  }
}

因为 ai sdk 转换好的就是 SSE 的流,我们不需要自己再做处理,直接把它传给 response 就可以了。

安装用到的 ai sdk 的包:

bash
pnpm install ai @ai-sdk/langchain

用上面那个 curl 测试下,对接成功!现在就把 langchain 的 agent 的 stream 转成了 ai sdk 的 Data Stream Protocol 协议的格式了。

创建前端项目

bash
npx create-vite agui-frontend

这里创建的是 react 项目,用 @ai-sdk/react 来对接,你换成 vue 项目,用 @ai-sdk/vue 对接也可以。vercel ai sdk 支持各种前端框架。

在后端允许下跨域访问接口(main.ts 里 app.enableCors()),然后来改前端页面。安装 @ai-sdk/reactai 包:

bash
pnpm install @ai-sdk/react ai

核心逻辑是这个:用 useChat 连接后端的 SSE 接口,连接方式用 DefaultChatTransport。这样就可以拿到 messages 了,不用自己解析。message 有 idroleparts 属性:

tsx
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

export default function Chat() {
  const { messages } = useChat({
    transport: new DefaultChatTransport({
      url: '/ai/chat',
    }),
  });

  return (
    <div>
      {messages.map((message) => (
        <MessageRenderer key={message.id} message={message} />
      ))}
    </div>
  );
}

创建个组件渲染 parts 部分。ai 包提供了 isToolUIPartgetToolName 的 api,我们可以用它来判断当前 part 是不是 tool call,如果不是,就是渲染文本,如果是就是渲染对应的 tool 的组件:

tsx
import { getToolName, isToolUIPart, type UIMessage } from 'ai';

function MessageRenderer({ message }: { message: UIMessage }) {
  return (
    <div>
      {message.parts.map((part, i) => {
        // 是 tool call 的 part,按工具名渲染对应组件
        if (isToolUIPart(part)) {
          const toolName = getToolName(part);
          if (toolName === 'web_search') {
            return <WebSearchResult key={i} part={part} />;
          }
          return <DefaultToolCall key={i} part={part} />;
        }
        // 否则渲染文本(流式 markdown 用 streamdown 渲染)
        return <StreamdownText key={i} part={part} />;
      })}
    </div>
  );
}

getToolName 拿到 part 的工具名。目前只有 web search 的 tool,根据 state 来渲染 pending、error 状态的组件,还有成功后的组件。就像前面分析的,output-available 阶段可以拿到 output:

tsx
import { isToolUIPart, getToolOutput, type ToolUIPart } from 'ai';

function WebSearchResult({ part }: { part: ToolUIPart }) {
  // 根据 part.state: 'input-streaming' | 'output-available' | 'error' 渲染不同状态
  if (part.state === 'output-available') {
    const output = getToolOutput(part);
    // 根据 output 的格式(搜索结果列表)渲染卡片列表
    return <SearchResultList results={output} />;
  }
  if (part.state === 'error') {
    return <div className="tool-error">搜索失败</div>;
  }
  return <div className="tool-pending">正在搜索…</div>;
}

根据不同 tool 的 output 的格式做下渲染就可以了。具体代码可以从仓库复制,核心的就是刚才讲的这几个,其余的不重要。

跑一下 npm run dev,现在就不只是流式渲染文本了,还会流式渲染 tool call 对应的组件。

流式渲染 Markdown:Streamdown

但现在流式文本部分的 markdown 还没处理(豆包里是渲染好的)。我们加一个流式渲染 markdown 对应组件的库 Streamdown

bash
pnpm install streamdown @streamdown/code @streamdown/mermaid

这样流式的 markdown 文本就会用对应组件来渲染了(表格、mermaid 流程图、代码等语法都会用不同组件展示)。

再加一个 Tool:发送邮件

我们只做了 web search 的 tool,再来加一个 tool——把之前发送邮件的 tool 拿过来。安装下依赖:

bash
pnpm install @nestjs-modules/mailer

.env 加对应配置,在 AppModule 引入这个包:

typescript
MailerModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => ({
    transport: {
      host: configService.get<string>('MAIL_HOST'),
      port: Number(configService.get<string>('MAIL_PORT')),
      secure: configService.get<string>('MAIL_SECURE') === 'true',
      auth: {
        user: configService.get<string>('MAIL_USER'),
        pass: configService.get<string>('MAIL_PASS'),
      },
    },
    defaults: {
      from: configService.get<string>('MAIL_FROM'),
    },
  }),
}),

之后在 AiModule 添加一个 provider:

typescript
{
  provide: 'SEND_MAIL_TOOL',
  useFactory: (mailerService: MailerService, configService: ConfigService) => {
    const sendMailArgsSchema = z.object({
      to: z
        .email()
        .describe('收件人邮箱地址,例如:someone@example.com'),
      subject: z.string().describe('邮件主题'),
      text: z.string().optional().describe('纯文本内容,可选'),
      html: z.string().optional().describe('HTML 内容,可选'),
    });

    return tool(
      async ({ to, subject, text, html }: {
        to: string;
        subject: string;
        text?: string;
        html?: string;
      }) => {
        const fallbackFrom = configService.get<string>('MAIL_FROM');
        await mailerService.sendMail({
          to,
          subject,
          text: text ?? '(无文本内容)',
          html: html ?? `<p>${text ?? '(无 HTML 内容)'}</p>`,
          from: fallbackFrom,
        });
        return `邮件已发送到 ${to},主题为「${subject}」`;
      },
      {
        name: 'send_mail',
        description:
          '发送电子邮件。需要提供收件人邮箱、主题,可选文本内容和 HTML 内容。',
        schema: sendMailArgsSchema,
      },
    );
  },
  inject: [MailerService, ConfigService],
},

绑定一下:createAgent 的 tools 里加上 sendMailTool。直接调用会渲染默认 tool call 组件,我们再加一个单独的组件用于渲染发送邮件的 tool(根据 output 展示"邮件已发送到 xxx")。试下效果,完成。

代码上传了课程仓库:https://github.com/QuarkGluonPlasma/ai-agent-course-code

总结

我们基于 AGUI 协议实现了流式渲染文本、tool call 组件的效果,用的是 Vercel AI SDK 的 Data Stream Protocol:

  • 后端用 LangChain 来写 Agent,不再手写 agent loop,直接用 createAgent 的 api
  • 通过 @ai-sdk/langchain 把 stream 转为基于 Data Stream Protocol 协议的 SSE 流
  • 前端用 @ai-sdk/react@ai-sdk/vueuseChat 来解析这个 SSE 流,拿到 messages
  • 根据 message 是文本还是 tool call 做不同的渲染:
    • 文本用 streamdown 流式渲染,会解析 markdown 的表格、mermaid 流程图、代码等语法,用不同组件展示
    • tool call 则是自定义组件实现渲染(一个 tool 定制一个 UI 组件)

对接了 AGUI 协议后,Agent 的交互体验就好很多了。

补充理解:之前我们没区分流式的内容是什么,直接返回文本;这套协议给流式内容打上了标识(text / tool-input / tool-output 等),还做了 SDK 来解析,前端就能按标识渲染特定化的组件了。

如果本地跑报 ERR_REQUIRE_ESM(p-retry 相关),一般是某个依赖被 require 了 ESM 模块,升级依赖版本或换 Node 版本即可。

预览到此为止,输入密码解锁全文

解锁后本机会记住,同密码的其他文章也无需重复输入

基于 VitePress 构建 · 专注前端与 AI 实战