跳到正文

从 Vercel 的 Agent 教程开始:普通开发者怎么做一个能落地的 AI Agent(2):用 AI SDK 跑通一个本地客服助手

从 Vercel 的 Agent 教程开始:普通开发者怎么做一个能落地的 AI Agent · 1 · 第 2 篇(当前) · 3

这次实践做一个本地客服助手:浏览器把问题交给 Node.js 服务,后端调用模型;模型需要订单和退款规则时,由后端执行工具,最后返回回复草稿。演示只提供查询与整理能力,不提交真实退款。

模型调用与工具循环使用 Vercel 维护的 AI SDK,HTTP 外壳使用 Hono,模型入口是本机的 OpenAI-compatible 服务。运行需要 Node.js 22+、pnpm,以及支持 tool calling 的本地模型。完整工程与配置见把最小项目跑起来。

文本、流式和结构化输出

Agent 的底座是一次模型调用。底层供应商可以不同,业务代码里最常遇到的输出形态通常是三类:

  • 生成文本:用在人要直接阅读的地方,比如回答问题、解释工具结果、起草邮件、写报告、生成 PR 描述。它的输出适合给人看,不适合直接拿去做程序分支。
  • 流式输出:用在用户正在等的地方,比如聊天页、长报告生成、代码解释和排障建议。流式返回能让界面先显示进度,也方便在用户取消、网络中断或工具调用耗时时做更好的交互。
  • 结构化输出:用在程序要继续处理模型结果的地方,比如任务分类、风险等级、工具参数、工单字段、数据库写入前的校验对象。结果一旦要进入 if、队列、数据库或另一个 API,就应该优先用 schema 约束,而不是事后从自然语言里用正则抠字段。

很多 Agent 的不稳定,源头常常在接口边界:模型回答得出来,开发者却把一段自然语言当成稳定数据用了。

Vercel AI SDK 的三类包

上面提到的文本、流式和结构化输出,最后都要落到代码接口上。如果每家模型厂商都用自己的写法,业务代码很容易被 OpenAI、Anthropic、Gemini、DeepSeek 这些接口差异拖住。Vercel 开发的 AI SDK 是一组 TypeScript 包,用来把模型调用、流式返回、结构化输出、工具调用和前端聊天状态变成一套相对统一的接口。

这组包大致分三类。

  • 核心包 ai:提供 generateText、streamText、generateObject、Output、tool 这些入口。Agent 后端逻辑、队列 worker、CLI、API route 里最常用的是它,因为真正的模型调用、工具调用和结构化输出都发生在服务端。
  • Provider 包 @ai-sdk/openai、@ai-sdk/anthropic、@ai-sdk/google、@ai-sdk/deepseek 等:负责把统一接口接到不同模型供应商。直接用某家模型厂商的 API key 时,一般需要安装对应 provider 包,例如 @ai-sdk/openai 或 @ai-sdk/deepseek。
  • UI 包 @ai-sdk/react、@ai-sdk/vue、@ai-sdk/svelte、@ai-sdk/angular 等:负责前端框架里的聊天消息、流式展示和工具调用状态。它们适合写聊天页、客服工作台和生成式 UI;密钥、真实模型调用和工具执行仍然应该留在后端。

ai 和 @ai-sdk/* 这组名字容易让人误会。它们不像 jwt、cookie 这种平台无关的技术名,也不像 body-parser 这种和具体 Web 框架解耦的中间件名;它们属于 Vercel 主导的 AI SDK 项目,只是用一组很通用的 npm 包名发布。按传统品牌识别习惯,叫 @vercel/ai、@vercel/ai-sdk-openai 会更不容易误解。

但这不等于它只能在 Vercel 平台上用。官方 README 把 AI SDK 定位成 provider-agnostic 的 TypeScript toolkit,支持 Next.js、React、Svelte、Vue、Angular 和 Node.js 等运行环境。更准确的理解是:这是 Vercel 维护的一套通用 AI 应用抽象;你可以部署在 Vercel,也可以在自己的 Node.js 服务、serverless 平台或其他后端运行时里使用它。

对业务代码来说,统一接口的好处是主流程长期面对 generateText、streamText、tool 这组通用 API,减少对 OpenAI、Anthropic 或 Google 等供应商接口的直接依赖。通用包名则没有直接体现 Vercel 的维护归属,读者第一次看到 ai 和 @ai-sdk/* 时,容易把「通用 API」误读成「中立标准库」。

这篇实践的代码先走一条能在本地跑起来的链路:本地 Node.js 后端、ai、@ai-sdk/openai-compatible、zod,再接一个本机 OpenAI-compatible 模型服务。后端运行在 Node.js 中,模型逻辑涉及下面三个包:

  • ai 负责模型调用、流式返回、结构化输出、工具定义和工具循环。
  • @ai-sdk/openai-compatible 负责把 AI SDK 接到兼容 OpenAI API 的模型服务,比如 LM Studio、Ollama 的 OpenAI-compatible endpoint,或者团队自建的 OpenAI-compatible proxy。
  • zod 负责描述结构化输出和工具参数,让模型给出的对象能被程序校验。

为什么还要 zod

TypeScript 只能在写代码和编译时帮我们检查类型,代码运行起来以后,类型信息已经被擦掉了。模型返回的 JSON、HTTP 请求体、工具调用参数,都是运行时才出现的外部输入;程序不能只因为 TypeScript 里写了 RefundRequest,就相信模型一定会返回合法字段。

zod 做的是运行时校验:模型说要调用 getOrder,AI SDK 会拿 zod schema 检查参数里有没有 orderId、类型是不是字符串、枚举值是不是合法。结构化输出也是同样道理,模型返回对象以后先过 schema,程序再进入下一步。它还有一个好处:同一份 schema 可以推导出 TypeScript 类型,避免「运行时校验规则」和「代码里的类型」写两份后慢慢漂移。

完整项目还需要 Hono、静态页面服务等外壳依赖;模型调用和结果处理则由 ai 包提供。

Vercel AI Gateway 是什么

AI Gateway 是 Vercel 托管的模型入口层,用来统一管理 provider key、模型路由、日志、预算和 fallback。本地代码也可以调用它,但通常要先在 Vercel 平台创建 AI_GATEWAY_API_KEY。这对第一次跟着教程跑 demo 的读者来说,门槛会高一截。

所以这篇文章第一遍先不用 Gateway,而是用本机 OpenAI-compatible 服务跑通核心机制。等模型调用、工具调用和工具循环都理解以后,再把模型入口换成 AI Gateway、OpenRouter、LiteLLM 或某个模型厂商的 provider 包。

模型部分需要的依赖是:

pnpm i ai @ai-sdk/openai-compatible zod

前端 UI 包只处理浏览器里的消息状态、流式展示和交互状态;后端工具、密钥和模型调用仍然放在服务端。

下面按用途列出 AI SDK 包族中的入口,具体导入位置仍要区分:

  • 文本生成:generateText 适合后台任务、摘要、邮件草稿和工具执行后的说明。
  • 流式文本:streamText 适合聊天页、长报告、代码解释和需要边生成边展示的交互。
  • 结构化输出:generateObject、streamObject、Output 适合分类、抽取、路由和写库前校验,让模型按 schema 交出对象。
  • 工具调用:tool、dynamicTool、jsonSchema、zodSchema 用来把搜索、数据库、业务接口和本地函数交给模型按参数调用。
  • MCP / 外部工具:createMCPClient 从单独的 @ai-sdk/mcp 包导入,用来接入 MCP Server,例如文件系统、设计工具或 SaaS 连接器;它不是 ai 包的导出。导入方式见官方 MCP 文档。
  • 多模型和供应商:provider 包、createProviderRegistry 和 AI Gateway 让同一套业务代码可以切换不同模型和供应商。
  • Embedding / 图片:embed、embedMany、generateImage 用在向量检索、相似度计算、生成配图或视觉素材这类场景。

更完整的清单可以看 AI SDK Core Reference,工具调用可以单独看 Tools and Tool Calling。

从模型调用到结构化结果

以下示例使用 ai 包的核心入口,写成服务端 TypeScript 普通函数。Express、Hono、Next.js Route Handler、RPC、队列 worker 或 CLI 都可以在外层调用它,核心逻辑不必绑定某个 HTTP 框架。

代码和配套项目采用 AI SDK 7 的 instructions、isStepCount() 等命名。旧版项目应按对应迁移指南调整,不能把不同大版本的示例直接混用。

先准备一个本地模型入口。下面的写法默认 LM Studio 的 Local Server 地址是 http://localhost:1234/v1;如果你用 Ollama 的 OpenAI-compatible endpoint,通常把 LOCAL_LLM_BASE_URL 改成 http://localhost:11434/v1。

// model.ts
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const localProvider = createOpenAICompatible({
  name: 'local',
  baseURL: process.env.LOCAL_LLM_BASE_URL ?? 'http://localhost:1234/v1',
  apiKey: process.env.LOCAL_LLM_API_KEY ?? 'local',
});

export function getLocalModel() {
  const modelId = process.env.LOCAL_LLM_MODEL;

  if (!modelId) {
    throw new Error('缺少 LOCAL_LLM_MODEL,请先在本地模型服务里加载一个模型。');
  }

  return localProvider.chatModel(modelId);
}
// answer-question.ts
import { generateText } from 'ai';
import { getLocalModel } from './model';

export async function answerQuestion(question: string) {
  const result = await generateText({
    model: getLocalModel(),
    instructions: '你是一个简洁、可靠的技术助手。',
    prompt: question,
  });

  return {
    answer: result.text,
  };
}

这个模型调用函数把配置、输入和框架入口分开了:

  • 模型 ID 放配置里,不写死在业务逻辑里。
  • system prompt 和用户输入分开。
  • 业务函数只关心输入和输出,不关心请求来自哪个框架。
  • 后面要加工具、fallback、日志和权限时,有明确入口。

真正接到 Web 服务时,Express、Fastify、Nest、Hono、Next.js Route Handler 做的事情都差不多:从请求体读出 question,调用 answerQuestion(question),再把 { answer } 转成 JSON 返回。换成 RPC、队列或 CLI,也只是把入口参数换掉,模型调用函数不用跟着重写。

流式输出适合用户正在等结果的场景。聊天页面如果等模型全部生成完再返回,会显得卡;流式接口可以让用户边看边等。

// stream-assistant-reply.ts
import { streamText } from 'ai';
import { getLocalModel } from './model';

export function streamAssistantReply(message: string) {
  const result = streamText({
    model: getLocalModel(),
    instructions: '你是一个能解释代码和排查问题的开发助手。',
    prompt: message,
  });

  return result.textStream;
}

这里返回的是一段可迭代的文本流。HTTP streaming、SSE、WebSocket、终端输出的写法不一样,但核心都是消费这段流,再把每个增量片段发给用户。

模型结果一旦要进入程序分支,就需要能被程序校验的字段。让模型按 schema 返回对象,可以避免从自由文本中猜测字段,再据此决定下一步。

// extract-task.ts
import { generateObject } from 'ai';
import { z } from 'zod';
import { getLocalModel } from './model';

const TaskSchema = z.object({
  title: z.string(),
  priority: z.enum(['low', 'normal', 'high']),
  dueDate: z.string().nullable(),
});

export async function extractTask(text: string) {
  const { object } = await generateObject({
    model: getLocalModel(),
    schema: TaskSchema,
    prompt: `从下面这段话里提取任务信息:\n\n${text}`,
  });

  return object;
}

第一次做 Agent,优先跑通这三类调用。等模型调用、流式返回和类型安全的结构化结果都稳定了,后面的工具循环才有可靠地基。

结构化输出是 Agent 的中间状态

以构建排障为例,用户说:「帮我看一下这个仓库为什么构建失败,能修就修,修不了就给我建议。」如果中间状态全是自然语言,程序很难知道 Agent 现在处于哪个阶段:是在收集信息、定位原因、生成补丁、运行测试,还是需要用户确认。

更稳的做法是让关键节点输出结构化状态。

import { z } from 'zod';

const DiagnosisSchema = z.object({
  status: z.enum(['need_more_context', 'root_cause_found', 'cannot_reproduce']),
  confidence: z.number().min(0).max(1),
  suspectedFiles: z.array(z.string()),
  nextAction: z.enum(['read_file', 'run_command', 'propose_patch', 'ask_user']),
  explanation: z.string(),
});

这个 schema 面向程序控制流程,不直接展示给用户。它让程序能做几件事:

  • nextAction 是 run_command 时,检查命令是否在允许列表里。
  • confidence 太低时,可以触发人工复核,但高分本身不能授权改文件。
  • suspectedFiles 超出工作区时,直接拒绝。
  • ask_user 出现时,把问题抛给人,而不是让 Agent 继续猜。

结构化输出尤其适合这些场景:

  • 从邮件、需求、日志里提取任务。
  • 判断工具调用下一步。
  • 生成执行计划。
  • 输出风险等级。
  • 把 Agent 结果交给另一个系统继续处理。

自然语言适合沟通,结构化对象适合控制流程。Agent 开发要把这两种输出分开。

schema 能保证字段形状,不能证明内容真实。confidence 和模型填写的风险等级都只是建议;资源归属、动作权限和审批状态必须由可信业务数据与确定性策略检查。工具返回的网页、文档和日志也应当作资料,不应因为混进一句命令就获得新的执行权限。

工具让 Agent 开始做事

要让 Agent 真正改变外部世界,就要把「可执行动作」包装成工具。

一个工具通常包含三部分:

  • 描述:告诉模型这个工具能做什么,什么时候该用。
  • 输入 schema:告诉模型参数长什么样,同时让程序可以校验。
  • 执行函数:真正访问数据库、HTTP API、文件系统或其他服务。

工具最好按可独立授权和验收的业务动作拆分。一个 callInternalApi 或 doCustomerAction 如果承载了许多不相关操作,模型很难选,程序也很难为它设权限。但拆得过细同样会增加调用和规划成本;目标是职责清楚,不是工具越小越多越好。例如:

  • getOrderStatus 只查订单状态。
  • listRefundRules 只返回可用退款规则。
  • draftRefundReply 只生成客服回复草稿。
  • submitRefundRequest 真正提交退款申请,并且要求人工确认。

这样拆的好处是,程序可以给每个工具单独设置权限、超时、重试、审计和风险等级。模型也更容易在正确时机选中正确工具。

下面先实现贯穿示例里的只读工具:用户只问订单状态时,模型可以调用 getOrderStatus。

// order-agent.ts
import { generateText, isStepCount, tool } from 'ai';
import { z } from 'zod';
import { getLocalModel } from './model';

const tools = {
  getOrderStatus: tool({
    description: '根据订单号查询订单状态。只在用户明确询问订单状态时调用。',
    inputSchema: z.object({
      orderId: z.string().describe('订单号,例如 ORD-2026-0001'),
    }),
    execute: async ({ orderId }) => {
      const response = await fetch(`https://api.example.com/orders/${encodeURIComponent(orderId)}`);

      if (!response.ok) {
        return { ok: false, reason: `订单查询失败,HTTP ${response.status}` };
      }

      const order = await response.json();
      return {
        ok: true,
        orderId,
        status: order.status,
        updatedAt: order.updatedAt,
      };
    },
  }),
};

export async function answerOrderQuestion(message: string) {
  const result = await generateText({
    model: getLocalModel(),
    instructions: '你是订单助手。只有在需要订单数据时才调用工具。',
    tools,
    stopWhen: isStepCount(2),
    prompt: message,
  });

  return {
    answer: result.text,
  };
}

isStepCount(2) 给一次工具查询及其后的回答留下两个生成步骤。步数上限和工具调用次数不同:同一步可以请求多个工具;达到上限也不保证已经得到最终文本。示例省略了认证、订单归属校验和请求超时,接入真实订单接口时必须在服务端工具层补齐。

这个例子里,模型只知道有一个叫 getOrderStatus 的工具,工具需要 orderId,执行由后端代码完成。工具一旦有副作用,错误就可能改变真实业务状态,不能只按回答质量来评估。

定义工具时,尤其要检查这三处:

  • 描述是否具体:queryData、doAction、callApi 这类名字对模型帮助很小。工具描述应该写清楚它解决什么任务、什么时候使用、什么时候不要使用。
  • 参数 schema 是否足够严格:一个工具如果接受 any 或一大段自由 JSON,模型就会把不确定性全部塞进参数里,后端很难判断。能枚举就枚举,能拆字段就拆字段,能写 describe() 就把示例写进去。
  • 危险动作是否有确认环节:比如发邮件、删数据、扣款、发布生产配置、创建真实订单,都应该先有人类确认,或拆成「预览」和「提交」两步。模型可以建议动作,最终提交权应该留给人或确定性工作流。

Agent 循环需要退出条件

模型调用工具后,程序通常会把工具结果再交回模型,让模型继续判断。这个循环很自然,也必须有明确的退出条件。

一次 Agent 执行至少要有这些停止条件:

  • 模型已经给出最终答案。
  • 工具调用次数达到上限。
  • 总耗时达到上限。
  • 花费达到预算上限。
  • 工具返回不可恢复错误。
  • 命中需要人工确认的动作。

把这些限制写进运行时,比写进 prompt 更可靠。prompt 可以提醒模型「不要调用太多工具」,但真正的上限应该由程序控制。

const result = await generateText({
  model: getLocalModel(),
  tools,
  prompt: input,
  stopWhen: isStepCount(5),
  abortSignal: AbortSignal.timeout(30_000),
});

这里示范最大生成步数与请求取消信号。超时信号需要传给工具内部的 fetch 等操作;不配合取消的本地工作不会因此自动停止,外部已经发生的副作用也不会撤销。若要严格限制工具总次数,应在每次工具执行前单独计数和检查,不能把生成步数直接当成工具调用次数。

真实项目里可以把这个循环交给框架处理。AI SDK、OpenAI Agents SDK、LangGraph、Cloudflare Agents、Workflow SDK 都会提供不同层级的运行时支持。但无论框架怎么封装,你都要知道底下发生了什么:模型通过「判断 -> 调工具 -> 看结果 -> 再判断」逐步推进任务。

一次 Agent 请求可能触发多次模型调用和工具调用。供应商故障、限流、成本预算和失败恢复需要在生产任务中继续约束,详见从本地 demo 到可恢复的生产任务。

把最小项目跑起来

配套工程包含 Hono HTTP 入口、模型与工具循环、本地数据和原生 Web UI。按材料中的文件清单复制工程,在 .env 中设置 LOCAL_LLM_BASE_URL、LOCAL_LLM_API_KEY 与 LOCAL_LLM_MODEL,再运行:

pnpm install
pnpm dev

打开 http://localhost:3333,输入「客户说 ORD-2026-0001 的耳机已经签收,想退款。请判断是否符合规则,并写一段给客户的回复。」页面会展示回复草稿与工具调用过程。完整源码、环境变量示例、Windows 复制命令和运行步骤见最小 Agent 工程与运行步骤弹窗。

模型不支持 tool calling 时,HTTP 服务可以启动,却不能据此判断工具循环已跑通。连接真实订单接口前,还要在服务端补齐身份、订单归属、超时和审计,不能直接把示例工具当成生产接口。

总结

本地工程把 Web UI、HTTP 请求、模型调用与工具执行接在同一条可观察的链路上。文本回答适合沟通,结构化输出让程序继续判断,工具结果则返回模型进入下一步;这些动作都由服务端完成,密钥和工具权限也留在服务端。

生成步数、工具总次数和超时分别控制不同成本。schema 检查字段形状,取消信号只对配合它的操作生效,达到步数上限也不保证任务已经完成。先验证只读查询与草稿,再逐步增加生产能力,能够保留明确的验收范围。

上一篇:模型、工具与受控执行循环。下一篇:从本地 demo 到可恢复的生产任务。

最小 Agent 工程与运行步骤

把最小项目跑起来

最小项目沿用客服售后场景,但不引入 Next.js。它用 Hono 搭一个 Node.js HTTP 服务,首页是一段很薄的 Web UI,浏览器把客服输入发到 /api/agent,后端再用 Vercel AI SDK 调模型和本地工具。这样读者能同时看到 Web UI、HTTP 接口、模型调用和工具调用循环。

完整源码可在下方工作区浏览,包含 HTTP 入口、模型与工具循环、本地数据和 Web UI。复制到本地前,先读 README.md,再按 FILES.json 获取文件。

Vercel AI SDK 最小 Agent 源码正在加载代码工作区...

本地运行需要四样东西:

  • Node.js 22+。
  • pnpm。
  • 一个本地 OpenAI-compatible 模型服务。
  • 一个支持 tool calling 的本地模型。

本地模型服务可以用 LM Studio,也可以用 Ollama 的 OpenAI-compatible endpoint。LM Studio 的 Local Server 常见地址是 http://localhost:1234/v1;Ollama 的 OpenAI-compatible endpoint 常见地址是 http://localhost:11434/v1。模型 ID 以本机实际加载的模型为准。

这里的「本地」指浏览器访问 localhost,本地 Hono 服务读取 .env,再把模型请求发给本机模型服务。它不需要先注册 Vercel,也不需要部署。代价是本机要能跑得动模型,并且模型要支持工具调用;如果模型不支持 tool calling,这个 demo 的 HTTP 服务仍然能启动,但 Agent 可能不会按预期调用工具。

真正要上线给别人使用时,再考虑 Vercel Functions、Docker、VPS 或其他部署方式;真正要统一管理多模型、key、日志和 fallback 时,再考虑 Vercel AI Gateway、LiteLLM 或 OpenRouter 这类网关。

pnpm install
cp .env.example .env

Windows PowerShell 里复制环境变量文件可以写成:

Copy-Item .env.example .env

然后编辑 .env:

LOCAL_LLM_BASE_URL=http://localhost:1234/v1
LOCAL_LLM_API_KEY=local
LOCAL_LLM_MODEL=你的本地模型 ID
PORT=3333

启动服务:

pnpm dev

打开 http://localhost:3333,输入这类问题:

客户说 ORD-2026-0001 的耳机已经签收,想退款。请判断是否符合规则,并写一段给客户的回复。

这个项目的请求链路很短:

  1. 浏览器页面调用 fetch('/api/agent')。
  2. Hono route 校验 message,再调用 runSupportAgent(message)。
  3. runSupportAgent 调 generateText,把客服任务、系统指令和工具列表交给模型。
  4. 模型需要订单或退款规则时,模型依据工具描述和 schema 生成调用意图与参数,AI SDK 校验并分派调用,后端执行 getOrder、listRefundPolicy 或 createReplyChecklist。
  5. stopWhen: isStepCount(5) 允许模型在一次请求里多走几步:调用工具、读取结果、继续判断,直到给出最终回复或达到步数上限。
  6. 后端把最终回答和工具调用过程返回给页面,客服能看到回复草稿,也能看到 Agent 用过哪些工具。

这个 demo 没有安装 @ai-sdk/react 这类 UI 包,因为它的前端只需要一个原生页面和一次 fetch。如果项目已经是 React、Vue、Svelte 或 Angular,再把页面层换成对应 UI 包;后端的 runSupportAgent(message)、工具定义和密钥边界不需要跟着改。