纸飞机的信笺
博客Awesome开源Demos制品库

从零开始搭建 Agent:① 起步、会话、工具调用

  1. 路线图
  2. 技术选型
  3. 迈出第一步:流式文本生成
  4. 流式渲染 Markdown
  5. 支持历史消息上下文
  6. 支持服务端工具调用
  7. 支持客户端工具调用

从零开始搭建 Agent:① 起步、会话、工具调用

2026年 5月 30日AI#AI#JS留言: ...

别人开发的 Agent 用久了,肯定会有想法自己也开发一个,而且,这个开发过程能使我们更深一步理解 Agent 的实现原理。

本文是一系列文章的第一篇,介绍使用前端技术栈从零开发一个 Agent。

路线图#

须知

路线图不是固定死的,后续可能会不断调整。

实现方式:直接基于本项目(本项目是一个 Next.js 全栈项目),开发一个类似于 Manus 的在线版网页 Agent。

功能清单:

  • 基础 AI 对话,带有附加功能:例如 Token 统计、智能标题等;
  • 联网搜索;
  • 沙箱、文件系统、代码执行器;
  • 记忆;
  • 多步骤计划;
  • 支持子 Agent,实现例如拆解任务、并行任务等;
  • 表单工具,对模糊不清的任务需求,请求用户给出选择或指示;
  • 支持 Agent Skill,提供 Agent Skills 配置功能;
  • 支持 MCP,提供 MCP 配置功能;
  • 自动上下文压缩总结;
  • 未完待续……

技术选型#

须知

当然,技术选型也不是固定死的,如果某些库不好用,后续随时会换掉,并更新此列表。

已有的库: 目前,PaperPlane.cc 使用了 Next.js、Prisma、Redis、tRPC、Shadcn/UI、Zod 等工具; 也就是说,数据请求、ORM、缓存、数据验证这些方面不需要准备额外的库。

AI 核心 SDK: 使用 Vercel AI SDK 来调度 AI 大模型,这包含了 ai 和 @ai-sdk/* 等一系列包; 当然,也可以使用 LangGraph。

UI 组件库: 使用 Vercel AI Elements 作为组件库,它专为开发 Agent 而创立,而且是基于 Shadcn/UI 的,易于定制。 在 Shadcn/UI 的 第三方库目录 页面搜索 “ai”,可以找到很多专门为 AI 准备的 UI 组件库,例如:LobeHub UI、LiveKit Agents UI、WebLLM、Assistant UI 等;当然也可以使用普通的不基于 Shadcn/UI 的组件库,例如 Ant DesIgn X。

还可以使用 LobeIcons,它提供了大量 AI 大模型和对应厂商的矢量格式图标。

Markdown 渲染: 使用 Vercel Streamdown,它可以流式渲染 Markdown。

网络搜索: 使用 Tavily 即可,它也有对应的 SDK。

沙箱: 使用 E2B 即可,它也有对应的 SDK。

杂项: 使用 Vercel 的 resuamable-stream 实现可恢复流;使用 TokenLens 实现 Token 消耗统计;使用 ai-stream-utils 处理流式输出。

迈出第一步:流式文本生成#

首先,安装几个最基础的包:

pnpm add ai @ai-sdk/react

Vercel AI SDK 内置了对他们自家的 AI Gateway 的适配,可以零配置对接; 但是,如果你不使用他们的服务,那么需要额外安装适配器。

Vercel AI Gateway 添加付款方式后,只要从未充值过,每个月有 5 美金的免费赠金。但只要充值过任意金额后,就不再有赠金了。

而且,现在免费政策收紧,这笔赠金只能用最高 GPT-5.2 这种模型,每个模型还有用量限制,差不多用 2.5 美元就必须换别的模型了,免费赠金意义不大。

在这个项目中,为了节约成本,我使用 OpenAI 接口的智谱 GLM-5.1 模型,因此需要安装以下适配器:

pnpm add @ai-sdk/openai-compatible

然后,我们添加一些 AI 大模型的 Endpoint 环境变量:

# AI 主模型 服务商请求 Endpoint URL
AI_MAIN_MODEL_ENDPOINT =
 
# AI 主模型 服务商请求 API Key
AI_MAIN_MODEL_API_KEY =

因为后续还会有类似于 “辅助模型” 等设定,因此,现在添加的模型定名为 “主模型”。

Vercel AI SDK 需要创建 “模型”:

import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
 
export const mainModelId = 'GLM-5.1'
 
export const mainModel = createOpenAICompatible({
  name: mainModelId,
  baseURL: process.env.AI_MAIN_MODEL_ENDPOINT!,
  apiKey: process.env.AI_MAIN_MODEL_API_KEY,
})(mainModelId)

这里,createOpenAICompatible 创建出来的叫 “Provider”,必须传给它一个模型名,才能选择模型,因此后面接上 (mainModelId)。

这个文件可以作为公共文件,因为后续都会使用它来获取模型。

然后,我们要提供一个后端接口,它同样使用 Vercel AI SDK,返回流式的大模型输出:

import { streamText } from 'ai'
 
import { mainModel } from '@/lib/agent/models'
 
export async function POST(request: Request) {
  const { prompt } = (await request.json()) as { prompt: string }
  const result = streamText({ model: mainModel, prompt })
 
  return result.toTextStreamResponse()
}

这段代码接收用户的 POST 提交,提取 prompt 字段内容作为输入,通过 AI SDK 提供的 streamText 函数获取大模型输出,并使用 toTextStreamResponse 转为流式 HTTP 响应。

然后就是前端代码了:

'use client'
 
import { useCompletion } from '@ai-sdk/react'
 
export default function ChatDemo1() {
  const { completion, complete, error, input, isLoading, setCompletion, setInput } = useCompletion({
    api: '后端接口的路径',
    streamProtocol: 'text',
  })
 
  function handleSend() {
    setCompletion('')
    setInput('')
    complete(input.trim())
  }
 
  return (
    <div>
      <textarea onChange={e => setInput(e.target.value)} value={input} />
      <button disabled={!input.trim() || isLoading} isLoading={isLoading} onClick={handleSend}>
        发送
      </button>
 
      <div>{error?.message ?? (isLoading ? '生成中...' : '')}</div>
      <div>{completion}</div>
    </div>
  )
}

这里使用了 Vercel AI SDK 的 useCompletion,其中 input 和 completion 分别是输入和输出文本,还有几个布尔值状态;可以认为是几个 React State 被组合在一起。填写好后端接口路径,即可通过 complete 函数发起请求。

将它们组装,渲染组件如下(样式经过了额外美化):

在线 Demo

我们完成了最基础的文本输出 AI 大模型对接。

流式渲染 Markdown#

如果 AI 输出了 Markdown 格式,页面无法正确渲染。此时,推荐使用 Vercel 的 streamdown 来美化输出;虽然渲染 Markdown 的库非常多,但 streamdown 是 Vercel 专为大模型流式输出而开发的渲染器,它可以正确处理未闭合的标签。

安装 streamdown 和配套的插件:

pnpm add streamdown @streamdown/cjk @streamdown/code

其中,@streamdown/cjk 可以让 Markdown 的标记语法在连接中日韩文字时能正确生效,@streamdown/code 用于渲染代码块。

Streamdown 使用了 Vercel AI Elements 相关的组件,添加它:

pnpm dlx ai-elements@latest add message

此外,还需要在 TailwindCSS 的全局 CSS 中添加:

@source "../node_modules/streamdown/dist/*.js";
@source "../node_modules/@streamdown/code/dist/*.js";
@source "../node_modules/@streamdown/cjk/dist/*.js";
@import "streamdown/styles.css";

引入相关样式。

这样以来,我们就可以更新前端代码,使用 streamdown 来渲染输出:

'use client'
 
import { useCompletion } from '@ai-sdk/react'
import { cjk } from '@streamdown/cjk'
import { code } from '@streamdown/code'
import { Streamdown } from 'streamdown'
 
export default function ChatDemo1() {
  const { completion, complete, error, input, isLoading, setCompletion, setInput } = useCompletion({
    api: '后端接口的路径',
    streamProtocol: 'text',
  })
 
  function handleSend() {
    setCompletion('')
    setInput('')
    complete(input.trim())
  }
 
  return (
    <div>
      <textarea onChange={e => setInput(e.target.value)} value={input} />
      <button disabled={!input.trim() || isLoading} isLoading={isLoading} onClick={handleSend}>
        发送
      </button>
 
      <div>{error?.message ?? (isLoading ? '生成中...' : '')}</div>
      <Streamdown
        plugins={{ cjk, code }}
        controls={{ code: true, table: true }}
        isAnimating={isLoading}
        animated
      >
        {completion}
      </Streamdown>
    </div>
  )
}

这里使用了 <Streamdown> 组件,通过 plugins 传入了插件,通过 controls 开启了例如 “代码块右上角的复制按钮” 等 UI 上的交互控件。

渲染组件如下(样式经过了额外美化):

在线 Demo

点击输出,可以看出,现在可以在保持流式输出的同时,正确渲染各种 Markdown 标记语法,甚至代码块的代码高亮也可以正确显示。

支持历史消息上下文#

上面的 Demo,只能单纯地让大模型针对一句话进行回答,连对话都做不到。接下来,我们将实现一个在 JS 中使用变量存储会话记录,并通过 “Chat” 方式实现的 AI 会话系统。

首先,我们安装完整的 Vercel AI Elements 组件库:

npx shadcn@latest add @ai-elements/all

因为后续会不断完善功能,所以直接一次性安装完全。

可以在 OpenAI 官网开发者文档 了解到会话的接口格式。

不过,我们使用 Vercel AI SDK 的话,直接使用对应的函数传参即可,接口格式可以先不研究。 AI 会话是一系列消息历史,从代码角度来说,其实就是一个存放多个消息对象的 JS 数组,新增进来的对话每次都要追加到数组的尾部,然后重新提交。

使用 Vercel AI SDK 实现后端逻辑:

import { convertToModelMessages, streamText, type UIMessage } from 'ai'
 
import { mainModel } from '@/lib/agent/models'
 
export async function POST(request: Request) {
  const { messages } = (await request.json()) as { messages: UIMessage[] }
  const modelMessage = await convertToModelMessages(messages)
  const result = streamText({ model: mainModel, messages: modelMessage })
 
  return result.toUIMessageStreamResponse()
}

之前是通过 prompt 接收用户的单条文本输入,现在则是通过 messages 接收完整的会话历史。

前端当然也要修改:

'use client'
 
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { useState } from 'react'
 
const chatHistory: UIMessage[] = [
  {
    id: 'welcome',
    parts: [{ text: '你好,我是智能 AI 助手,有任何疑问尽管问我!', type: 'text' }],
    role: 'assistant',
  },
]
 
const transport = new DefaultChatTransport({ api: '后端接口的路径' })
 
export default function Chat() {
  const [input, setInput] = useState('')
  const { messages, sendMessage, status } = useChat({ messages: chatHistory, transport })
 
  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          <strong>{message.role === 'user' ? '用户' : 'AI'}:</strong>
          {message.parts.map((part, index) =>
            part.type === 'text' ? <span key={`${message.id}-${index}`}>{part.text}</span> : null
          )}
        </div>
      ))}
 
      <textarea onChange={event => setInput(event.target.value)} value={input} />
      <button
        disabled={!input.trim() || status !== 'ready'}
        onClick={() => {
          setInput('')
          sendMessage({ text: input.trim() })
        }}
      >
        发送
      </button>
    </div>
  )
}
'use client'
 
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
 
import { Conversation, ConversationContent } from '@/components/ai-elements/conversation'
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'
import {
  PromptInput,
  PromptInputBody,
  PromptInputFooter,
  PromptInputSubmit,
  PromptInputTextarea,
} from '@/components/ai-elements/prompt-input'
 
const chatHistory: UIMessage[] = [
  {
    id: 'welcome',
    parts: [{ text: '你好,我是智能 AI 助手,有任何疑问尽管问我!', type: 'text' }],
    role: 'assistant',
  },
]
 
const transport = new DefaultChatTransport({ api: '后端接口的路径' })
 
export default function Chat() {
  const { error, messages, sendMessage, status, stop } = useChat({
    messages: chatHistory,
    transport,
  })
 
  return (
    <div>
      <Conversation>
        <ConversationContent>
          {messages.map(message => (
            <Message from={message.role} key={message.id}>
              <MessageContent>
                {message.parts.map((part, index) =>
                  part.type === 'text' ? (
                    <MessageResponse
                      isAnimating={message.role === 'assistant' && status === 'streaming'}
                      key={`${message.id}-${index}`}
                    >
                      {part.text}
                    </MessageResponse>
                  ) : null
                )}
              </MessageContent>
            </Message>
          ))}
        </ConversationContent>
      </Conversation>
 
      <PromptInput
        onSubmit={message => void sendMessage({ text: message.text.trim() })}
        className="mt-5"
      >
        <PromptInputBody>
          <PromptInputTextarea
            disabled={status !== 'ready'}
            placeholder="和 AI 说点什么..."
            rows={2}
          />
        </PromptInputBody>
 
        <PromptInputFooter>
          <span>{error?.message ?? (status === 'submitted' ? '等待响应...' : '')}</span>
 
          <PromptInputSubmit onStop={stop} status={status} />
        </PromptInputFooter>
      </PromptInput>
    </div>
  )
}

了解基本原理只需要看简化版。完整版代码基本上所有组件都使用来自 AI Elements 的,样式也不再修改,使用原生样式。

渲染组件如下:

在线 Demo

你好,我是智能 AI 助手,有任何疑问尽管问我!

这样,我们就实现了一个简单的会话系统。

支持服务端工具调用#

至此,我们已经实现了 AI 对话工具,现在它就像是 2023 年刚问世的 ChatGPT 网页版一样,可以像人类一样回答用户的问题。

但是,Agent 区别于大模型的点就在于,Agent 能拆解复杂任务、上网搜索、访问文件系统,这些所有与外界系统的交互,都是通过工具调用来触发的,它们的核心区别之一,就是 Agent 支持工具调用。


工具调用的原生支持、参数格式等,大模型厂商已经提前规定好了;本文不赘述那些规定格式,因为我们使用 SDK,这些格式 SDK 会帮我们处理。

首先,想让大模型认识工具,需要用一段文字来介绍工具的作用、返回值; 如果工具支持传参,那么每个参数也需要一段文字来介绍,主要介绍参数的格式、作用。

这个过程,就是向 AI 注册工具。


举例:注册一个 “提供时区,获取当前时间” 的工具:

import 'server-only'
 
import { tool } from 'ai'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { z } from 'zod'
 
import 'dayjs/locale/zh-cn'
 
dayjs.extend(utc)
dayjs.locale('zh-cn')
 
const getCurrentTimeByTimeZone = tool({
  // 工具的描述
  description: '获取指定时区的当前时间。',
 
  // 如果工具支持参数,此处定义参数的格式
  inputSchema: z.object({
    timeZone: z.int().min(-12).max(14)
      .optional().default(8)
      .describe('UTC 时区偏移小时数,仅支持数字,例如 8、-8、0;不提供时默认为 8。'),
  }),
 
  // 工具自身的运行代码
  execute: async ({ timeZone }) => {
    return { formatted: dayjs().utcOffset(timeZone).format('YYYY年M月D日 dddd HH:mm:ss') }
  },
})

可以看到,Vercel AI SDK 通过 tool 函数使我们可以注册工具。

其中:

  • description 是最重要的字段之一,它是工具的完整介绍;
  • 工具如果支持参数输入,那么需要配置 inputSchema; SDK 预期用户使用 zod 来定义参数的校验和描述,这里需要为每个字段定义校验规则、报错信息和描述,其中最重要的就是 .describe() 定义的描述文字;
  • execute 就是工具实际执行代码了,AI 输入参数后,就会运行这个函数并使用它的返回值。

注册好工具后,我们要把工具传递给大模型。

更新后端代码:

import 'server-only'
 
import { convertToModelMessages, streamText, type UIMessage } from 'ai'
 
import { mainModel } from '@/lib/agent/models'
 
export async function POST(request: Request) {
  const { messages } = (await request.json()) as { messages: UIMessage[] }
  const modelMessage = await convertToModelMessages(messages)
 
  const result = streamText({
    model: mainModel,
    messages: modelMessage,
    tools: { getCurrentTimeByTimeZone },
  })
 
  return result.toUIMessageStreamResponse()
}

这里,使用 tools 字段把工具列表注册给大模型。


接下来,更新前端组件。

Vercel AI SDK 支持多种消息,普通文本是一种类型,工具调用则有一种专门的类型来区分出来;因此,我们需要通过代码分支判断决定渲染何种组件。

而且,消息中可能包含多个部分,因为 Vercel AI SDK 返回的结果中,一条消息中可能包含多次工具调用,这是允许的行为;这部分内容也要用循环的方式依次渲染。

更新后的前端代码如下:

'use client'
 
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { useState } from 'react'
 
const transport = new DefaultChatTransport<UIMessage>({ api: '后端接口的路径' })
 
export default function ToolUse() {
  const [input, setInput] = useState('')
  const { messages, sendMessage, status } = useChat<UIMessage>({ transport })
 
  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          <strong>{message.role === 'user' ? '用户' : 'AI'}:</strong>
 
          {message.parts.map((part, index) => {
            switch (part.type) {
              case 'text':
                return <span key={`${message.id}-${index}`}>{part.text}</span>
 
              case 'tool-getCurrentTimeByTimeZone':
                return (
                  <div key={`${message.id}-${index}`}>
                    <div>正在获取指定时区的当前时间</div>
                    <pre>{JSON.stringify(part.input, null, 2)}</pre>
                    <pre>{JSON.stringify(part.output, null, 2)}</pre>
                  </div>
                )
 
              default:
                return null
            }
          })}
        </div>
      ))}
 
      <textarea onChange={event => setInput(event.target.value)} value={input} />
      <button
        disabled={!input.trim() || status !== 'ready'}
        onClick={() => {
          setInput('')
          sendMessage({ text: input.trim() })
        }}
      >
        发送
      </button>
    </div>
  )
}
'use client'
 
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
 
import { Conversation, ConversationContent } from '@/components/ai-elements/conversation'
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'
import {
  PromptInput,
  PromptInputBody,
  PromptInputFooter,
  PromptInputSubmit,
  PromptInputTextarea,
} from '@/components/ai-elements/prompt-input'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
 
const chatHistory: UIMessage[] = [
  {
    id: 'welcome',
    parts: [{ text: '你好,我是一个支持全世界不同时区的报时机器人!', type: 'text' }],
    role: 'assistant',
  },
]
 
const transport = new DefaultChatTransport<UIMessage>({ api: '后端接口的路径' })
 
export default function ToolUse() {
  const { error, messages, sendMessage, status, stop } = useChat<UIMessage>({
    messages: chatHistory,
    transport,
  })
 
  return (
    <div>
      <Conversation>
        <ConversationContent>
          {messages.map(message => (
            <Message from={message.role} key={message.id}>
              <MessageContent>
                {message.parts.map((part, index) => {
                  switch (part.type) {
                    case 'text':
                      return (
                        <MessageResponse
                          isAnimating={message.role === 'assistant' && status === 'streaming'}
                          key={`${message.id}-${index}`}
                        >
                          {part.text}
                        </MessageResponse>
                      )
 
                    case 'tool-getCurrentTimeByTimeZone':
                      return (
                        <Tool key={`${message.id}-${index}`}>
                          <ToolHeader
                            state={part.state}
                            title="获取指定时区的当前时间"
                            type={part.type}
                          />
 
                          <ToolContent>
                            <ToolInput input={part.input || ''} />
                            <ToolOutput errorText={part.errorText || ''} output={part.output} />
                          </ToolContent>
                        </Tool>
                      )
 
                    default:
                      return null
                  }
                })}
              </MessageContent>
            </Message>
          ))}
        </ConversationContent>
      </Conversation>
 
      <PromptInput
        onSubmit={message => void sendMessage({ text: message.text.trim() })}
        className="mt-5"
      >
        <PromptInputBody>
          <PromptInputTextarea
            disabled={status !== 'ready'}
            placeholder="问问 AI 当前时间..."
            rows={2}
          />
        </PromptInputBody>
 
        <PromptInputFooter>
          <span>{error?.message ?? (status === 'submitted' ? '等待响应...' : '')}</span>
 
          <PromptInputSubmit onStop={stop} status={status} />
        </PromptInputFooter>
      </PromptInput>
    </div>
  )
}

渲染组件如下:

在线 Demo

你好,我是一个支持全世界不同时区的报时机器人!

我已提供了默认的问题 “美国现在的时间是?”,可以尝试一下点击发送。

你可能发现问题了:AI 先说出 “我帮你查查” 之后,发起了工具调用,但最后不会继续输出内容了,只有工具调用的过程,却没有最终回答!

这是因为:Vercel AI SDK 默认是以 “一问一答” 的模式运行,问指的是用户的输入;答指的是把问题发送给 AI 大模型供应商。 也就是说:每次用户输入,默认只能给大模型提交一次消息。

我们的流程是: 用户询问 AI:“美国现在的时间是?” → AI 回答:“我帮你查查”,并发起工具调用,经过工具执行拿到工具调用的结果。

这并不能完成需求,接下来还要: 把之前的对话和工具调用结果继续发给 AI → AI 回答:“根据工具结果,告诉你美国的时间……”。

这期间,需要给 AI 大模型提供商发送两次消息。


接下来,介绍一种并非最完善的前端解决方式。

这种方式是,只要前端检测到最末尾的消息是工具调用,则表明缺少一个让 AI 最终回答的步骤,此时,应该自动续发消息,让 AI 继续。

Vercel AI SDK 的客户端部分,提供了 sendAutomaticallyWhen 这个参数,它让 SDK 在满足特定条件时自动发送;同时还提供了 lastAssistantMessageIsCompleteWithToolCalls 这个参数,它表示 “最后一条 AI 返回的消息是工具调用”。 这两者组合使用,便可避免 “工具调用出现在消息最末尾” 这种情况。

更新前端代码:

import { lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
 
const { error, messages, sendMessage, status, stop } = useChat<UIMessage>({
  messages: chatHistory,
  transport,
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
})

添加了这一行之后,只要前端检测到末尾消息是 AI 的工具调用,便会自动续发。

更新后的组件如下:

在线 Demo

你好,我是一个支持全世界不同时区的报时机器人!

点击按钮发送消息,可以看出,工具调用后 AI 会继续回答问题,我们似乎解决了问题。


这种解决方式,只是单纯的 “前端续发” 方案,它更多适用于后端异常中断时的恢复措施; 我们希望解决办法是,尽可能从根源来解决。

其实,Vercel AI SDK 已经提供了配置方式。这里先直接给出后端代码:

import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai'
 
export async function POST(request: Request) {
  const { messages } = (await request.json()) as { messages: UIMessage[] }
  const modelMessage = await convertToModelMessages(messages)
 
  const result = streamText({
    model: mainModel,
    messages: modelMessage,
    tools: { getCurrentTimeByTimeZone },
    stopWhen: stepCountIs(5),
  })
 
  return result.toUIMessageStreamResponse()
}

注意高亮行,这里我们使用 stopWhen: stepCountIs(5) 向 SDK 传递了要求:现在允许 “一问五答”。 这样,即使触发了工具调用,消耗掉了一个步骤,还剩余好几个步骤可以让 AI 总结文字返回给用户。

这才是根本性的解决方式。

更新后端代码并接入,新组件如下:

在线 Demo

你好,我是一个支持全世界不同时区的报时机器人!

新的组件同样可以正常在工具调用后输出最终回答。

使用我默认的提示词测试 Demo,你会发现,因为美国跨越多个时区,此问题会导致多次工具调用。尽管配置了 stopWhen: stepCountIs(2),但最终还是可以完成输出;从这可以看出,Vercel AI SDK 允许我们在一个步骤中发起多次工具调用。


实际开发中,Agent 会执行更多的步骤,因此 stopWhen: stepCountIs(5) 的值会调的比较大,例如 50; 前端方面,也会开启 sendAutomaticallyWhen,通过前后端同时采取措施,避免工具调用作为最后一条消息的情况。

支持客户端工具调用#

上文中,使用的 “获取时间” 工具是服务端执行一个函数,把 AI 的输入转化为函数运行的结果并放回对话历史中,让 AI 根据它来继续生成文字。 这种方式,我们称之为 “服务端工具调用”。

接下来尝试开发 “客户端工具”,在前端渲染一些 UI 组件,让用户与之产生交互。


在用户使用 AI 工具时,有时对需求的描述可能不够清晰,因此,我们希望 AI 可以在适当的时机弹出表单,让用户进一步选择明确自己的需求;这个表单将尽可能减少交互复杂度,以选择为主,但也提供输入框允许用户自行输入。

首先,在后端 SDK 处注册这个工具:

import 'server-only'
 
import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai'
import { z } from 'zod'
 
import { mainModel } from '@/lib/agent/models'
 
const clarifyingRequirement = tool({
  description:
    '当用户的需求不够明确,无法可靠继续处理时,向用户确认需求。优先提供 2 到 4 个简短、互斥、容易选择的选项;如仍需要更多细节,可允许用户补充文本。',
  inputSchema: z.object({
    question: z.string().describe('需要向用户确认的问题。问题应简短具体,一次只确认一个关键点。'),
    options: z
      .array(
        z.object({
          label: z.string().describe('选项名称 Key,尽量简短。'),
          description: z.string().optional().describe('选中此项代表的含义或影响。'),
        })
      )
      .min(2)
      .max(4)
      .describe('给用户选择的候选项,优先覆盖最可能的需求方向。'),
    allowCustomText: z
      .boolean()
      .optional()
      .default(true)
      .describe('是否允许用户手动输入补充说明;默认允许。'),
    customTextPlaceholder: z
      .string()
      .optional()
      .default('也可以补充你的具体想法...')
      .describe('补充文本输入框的占位文案。'),
  }),
})
 
export async function POST(request: Request) {
  const { messages } = (await request.json()) as { messages: UIMessage[] }
  const modelMessage = await convertToModelMessages(messages)
 
  const result = streamText({
    model: mainModel,
    messages: modelMessage,
    tools: { clarifyingRequirement },
    stopWhen: stepCountIs(10),
  })
 
  return result.toUIMessageStreamResponse()
}

因为这是一个给前端用户使用的组件,它不需要执行代码,因此它没有 execute。

然后,在前端为这个工具创建 UI 界面; 这是一个由按钮和输入框组成的表单,允许用户选择某一项,或者不想选择,直接在输入框输入文本并提交。

代码如下:

type ToolOutput = { answer: string; source: 'custom' | 'option'; description?: string }
 
export interface ToolClarifyingRequirementProps {
  part: any
  customText: string
  onCustomTextChange: (toolCallId: string, value: string) => void
  onSubmit: (toolCallId: string, output: ToolOutput) => void
}
 
export function ToolClarifyingRequirement({
  part,
  customText,
  onCustomTextChange,
  onSubmit,
}: ToolClarifyingRequirementProps) {
  if (part.state === 'input-streaming') return <div>正在整理需要确认的问题...</div>
  if (part.state === 'output-available') return <div>已选择:{part.output.answer}</div>
  if (part.state === 'output-error') return <div>{part.errorText}</div>
  if (part.state !== 'input-available') return null
 
  const customTextValue = customText.trim()
 
  return (
    <div>
      <p>{part.input.question}</p>
 
      {part.input.options.map(option => (
        <button
          key={option.label}
          onClick={() =>
            onSubmit(part.toolCallId, {
              answer: option.label,
              description: option.description,
              source: 'option',
            })
          }
          type="button"
        >
          {option.label}
          {option.description && <span>:{option.description}</span>}
        </button>
      ))}
 
      {part.input.allowCustomText !== false && (
        <>
          <textarea
            onChange={event => onCustomTextChange(part.toolCallId, event.currentTarget.value)}
            placeholder={part.input.customTextPlaceholder ?? '也可以补充你的具体想法...'}
            value={customText}
          />
 
          <button
            disabled={!customTextValue}
            onClick={() =>
              onSubmit(part.toolCallId, { answer: customTextValue, source: 'custom' })
            }
            type="button"
          >
            提交补充
          </button>
        </>
      )}
    </div>
  )
}
import type { UIMessage } from 'ai'
import { CheckIcon, MessageCircleQuestionIcon, SendIcon } from 'lucide-react'
 
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
 
interface ClarifyingRequirementOption {
  label: string
  description?: string
}
 
interface ClarifyingRequirementInput {
  question: string
  options: ClarifyingRequirementOption[]
  allowCustomText?: boolean
  customTextPlaceholder?: string
}
 
export interface ClarifyingRequirementOutput {
  answer: string
  source: 'custom' | 'option'
  description?: string
}
 
export type ClientToolUseMessage = UIMessage<
  unknown,
  never,
  {
    clarifyingRequirement: {
      input: ClarifyingRequirementInput
      output: ClarifyingRequirementOutput
    }
  }
>
 
export interface ToolClarifyingRequirementProps {
  part: Extract<ClientToolUseMessage['parts'][number], { type: 'tool-clarifyingRequirement' }>
  customText: string
  onCustomTextChange: (toolCallId: string, value: string) => void
  onSubmit: (toolCallId: string, output: ClarifyingRequirementOutput) => void
}
 
export function ToolClarifyingRequirement({
  part,
  customText,
  onCustomTextChange,
  onSubmit,
}: ToolClarifyingRequirementProps) {
  const isWaitingForUser = part.state === 'input-available'
  const customTextValue = customText.trim()
 
  return (
    <div className="not-prose space-y-3 text-sm">
      {part.state === 'input-streaming' && (
        <div className="text-muted-foreground flex items-center gap-2">
          <MessageCircleQuestionIcon className="size-4" />
          正在整理需要确认的问题...
        </div>
      )}
 
      {isWaitingForUser && (
        <>
          <div className="space-y-1.5">
            <div className="font-medium">{part.input.question}</div>
            <div className="text-muted-foreground text-xs">请选择一个最接近的方向。</div>
          </div>
 
          <div className="grid gap-2 sm:grid-cols-2">
            {part.input.options.map(option => (
              <Button
                className="h-auto min-h-12 justify-start px-3 py-2 text-left whitespace-normal"
                key={option.label}
                onClick={() =>
                  onSubmit(part.toolCallId, {
                    answer: option.label,
                    description: option.description,
                    source: 'option',
                  })
                }
                type="button"
                variant="outline"
              >
                <span className="flex min-w-0 flex-col items-start gap-0.5">
                  <span className="text-foreground text-sm">{option.label}</span>
                  {option.description && (
                    <span className="text-muted-foreground text-xs leading-snug">
                      {option.description}
                    </span>
                  )}
                </span>
              </Button>
            ))}
          </div>
 
          {part.input.allowCustomText !== false && (
            <div className="space-y-2">
              <Textarea
                className="min-h-20 resize-y"
                onChange={event => onCustomTextChange(part.toolCallId, event.currentTarget.value)}
                placeholder={part.input.customTextPlaceholder ?? '也可以补充你的具体想法...'}
                value={customText}
              />
 
              <Button
                disabled={!customTextValue}
                onClick={() =>
                  onSubmit(part.toolCallId, { answer: customTextValue, source: 'custom' })
                }
                type="button"
              >
                <SendIcon className="size-3.5" />
                提交补充
              </Button>
            </div>
          )}
        </>
      )}
 
      {part.state === 'output-available' && (
        <div className="bg-muted/50 flex items-center gap-2 rounded-md px-3 py-2">
          <CheckIcon className="size-4 text-green-600" />
          已选择:{part.output.answer}
        </div>
      )}
 
      {part.state === 'output-error' && (
        <div className="bg-destructive/10 text-destructive rounded-md px-3 py-2">
          {part.errorText}
        </div>
      )}
    </div>
  )
}

这样这个请求用户详细描述问题的表单组件就完成了,我们把它接入 AI 对话流程中。

更新工具调用,针对消息的类型进行判断,如果发现是这个客户端工具调用,则渲染这个组件:

'use client'
 
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
import { useState } from 'react'
 
import {
  ToolClarifyingRequirement,
  type ClarifyingRequirementOutput,
  type ClientToolUseMessage,
} from './ToolClarifyingRequirement'
 
const chatHistory: ClientToolUseMessage[] = [
  {
    id: 'welcome',
    parts: [{ text: '你好,我是你的 AI 助理。', type: 'text' }],
    role: 'assistant',
  },
]
 
const transport = new DefaultChatTransport<ClientToolUseMessage>({
  api: '后端接口的路径',
})
 
export default function ClientToolUse() {
  const [input, setInput] = useState('帮我想一个品牌名')
  const [customTextByToolCallId, setCustomTextByToolCallId] = useState<Record<string, string>>({})
 
  const { addToolOutput, messages, sendMessage, status } = useChat<ClientToolUseMessage>({
    messages: chatHistory,
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    transport,
  })
 
  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          <strong>{message.role === 'user' ? '用户' : 'AI'}:</strong>
 
          {message.parts.map((part, index) => {
            switch (part.type) {
              case 'text':
                return <div key={`${message.id}-${index}`}>{part.text}</div>
 
              case 'tool-clarifyingRequirement':
                return (
                  <ToolClarifyingRequirement
                    customText={customTextByToolCallId[part.toolCallId] ?? ''}
                    key={`${message.id}-${index}`}
                    onCustomTextChange={(toolCallId, value) => {
                      setCustomTextByToolCallId(prev => ({ ...prev, [toolCallId]: value }))
                    }}
                    onSubmit={(toolCallId, output: ClarifyingRequirementOutput) => {
                      addToolOutput({ output, tool: 'clarifyingRequirement', toolCallId })
                    }}
                    part={part}
                  />
                )
 
              default:
                return null
            }
          })}
        </div>
      ))}
 
      <textarea
        disabled={status !== 'ready'}
        onChange={event => setInput(event.currentTarget.value)}
        value={input}
      />
      <button
        disabled={!input.trim() || status !== 'ready'}
        onClick={() => {
          sendMessage({ text: input.trim() })
          setInput('')
        }}
        type="button"
      >
        发送
      </button>
    </div>
  )
}
 
'use client'
 
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
import { useState } from 'react'
 
import { Conversation, ConversationContent } from '@/components/ai-elements/conversation'
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'
import {
  PromptInput,
  PromptInputBody,
  PromptInputFooter,
  PromptInputProvider,
  PromptInputSubmit,
  PromptInputTextarea,
} from '@/components/ai-elements/prompt-input'
import {
  ToolClarifyingRequirement,
  type ClarifyingRequirementOutput,
  type ClientToolUseMessage,
} from './ToolClarifyingRequirement'
 
const chatHistory: ClientToolUseMessage[] = [
  {
    id: 'welcome',
    parts: [{ text: '你好,我是你的 AI 助理。', type: 'text' }],
    role: 'assistant',
  },
]
 
const transport = new DefaultChatTransport<ClientToolUseMessage>({
  api: '后端接口的路径',
})
 
export default function ClientToolUse() {
  const [customTextByToolCallId, setCustomTextByToolCallId] = useState<Record<string, string>>({})
 
  const { addToolOutput, error, messages, sendMessage, status, stop } =
    useChat<ClientToolUseMessage>({
      messages: chatHistory,
      sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
      transport,
    })
 
  return (
    <div>
      <Conversation>
        <ConversationContent>
          {messages.map(message => (
            <Message from={message.role} key={message.id}>
              <MessageContent>
                {message.parts.map((part, index) => {
                  switch (part.type) {
                    case 'text':
                      return (
                        <MessageResponse
                          isAnimating={message.role === 'assistant' && status === 'streaming'}
                          key={`${message.id}-${index}`}
                        >
                          {part.text}
                        </MessageResponse>
                      )
 
                    case 'tool-clarifyingRequirement':
                      return (
                        <ToolClarifyingRequirement
                          customText={customTextByToolCallId[part.toolCallId] ?? ''}
                          key={`${message.id}-${index}`}
                          onCustomTextChange={(toolCallId: string, value: string) => {
                            setCustomTextByToolCallId(prev => ({ ...prev, [toolCallId]: value }))
                          }}
                          onSubmit={(toolCallId: string, output: ClarifyingRequirementOutput) => {
                            addToolOutput({ output, tool: 'clarifyingRequirement', toolCallId })
                          }}
                          part={part}
                        />
                      )
 
                    default:
                      return null
                  }
                })}
              </MessageContent>
            </Message>
          ))}
        </ConversationContent>
      </Conversation>
 
      <PromptInputProvider initialInput="帮我想一个品牌名">
        <PromptInput
          onSubmit={message => void sendMessage({ text: message.text.trim() })}
          className="mt-5"
        >
          <PromptInputBody>
            <PromptInputTextarea
              disabled={status !== 'ready'}
              placeholder="提出一个可能需要确认细节的需求..."
              rows={2}
            />
          </PromptInputBody>
 
          <PromptInputFooter>
            <span>{error?.message ?? (status === 'submitted' ? '等待响应...' : '')}</span>
 
            <PromptInputSubmit onStop={stop} status={status} />
          </PromptInputFooter>
        </PromptInput>
      </PromptInputProvider>
    </div>
  )
}
 

更新代码后,渲染出的组件如下:

在线 Demo

你好,我是你的 AI 助理。

可以直接点击发送按钮,使用我预设的问题,体会客户端工具调用的效果。

修订记录

  • 2026年 6月 10日
    feat(blog/article): 新增《Agent》篇博文,添加 AI 相关依赖和 SDK;更新 demo-shell
修订记录

留言