做 AI 应用这些年,我最大的感受是:模型能力迭代很快,但真正让项目卡住的从来不是模型本身,而是工程集成。尤其是当你需要 Agent 自动调用工具、处理图片输入、把多模态结果流式推给前端时,每一个环节都有大量协议细节要自己硬啃。Vercel AI SDK 6 这套系列教程,前两篇讲了基础的 chat 补全和流式请求,这篇第三部分,我把它用在一个客服工单处理系统上,跑通了“自动识别工单图片 -> 调用查询工具 -> 生成回复摘要”的完整链路。这篇文章就专门拆解 Agent 构建和多模态应用,偏实战、偏踩坑,适合已经能用 SDK 做基础对话、想往生产级应用再走一步的开发者。
1. Agent 运行原理:SDK 6 把“循环”打包到了哪一层
很多初学者第一次接触 Agent 时,容易把它想得很玄。其实抛开营销词汇,Agent 的本质就是“模型可以调用工具,并且系统允许这个调用过程反复多轮发生”。SDK 6 做的最重要的一件事,就是把这一整套工具调用协议封装成了框架原语,你不需要再自己拼接各家模型厂商的 function calling 格式。
1.1 一次对话循环的完整生命周期
在没有 SDK 的情况下,实现一个 Agent 循环,你要面对的大概是这些问题:模型返回的 tool call 参数怎么解析?工具执行完的结果以什么格式塞回对话?模型要求再次调用工具怎么办?什么时候该停止循环?每家模型厂商的响应体格式还不一样,换一个模型就要重写一遍。
SDK 6 把这套流程完整地封装进了streamText和StreamingAgentRunner(在框架内部称之为 Agent Loop)这一层。你可以把整个循环理解成一个“模型-工具-模型”的反复握手过程:
- 第一轮:把系统提示词和用户消息发给模型,模型返回文本,或者返回工具调用指令。
- 如果是工具调用指令,SDK 会解析出
toolName和args,然后去匹配你注册过的工具。 - 匹配成功后,SDK 执行工具的
execute函数,拿到结果。 - 结果以
user角色的工具结果消息追加到上下文中,再次送进模型。 - 模型根据工具结果继续生成文本或发起新一轮工具调用。
- 重复以上过程,直到模型不再产生工具调用请求,或者达到
maxSteps上限。
我在这个工单系统里实际遇到的问题很典型:模型在第一步完成了图片分析,得出了“这是一个退货申请工单”的结论,然后它需要去查这份订单是否存在售后记录。如果不做 Agent 循环,整个流程只能拆成两个独立请求,中间状态还要自己维护。用 SDK 的循环机制后,上下文、历史消息、工具结果都是框架自动维护的,我只管写清楚每个工具干什么就行。
1.2 streamText 与 streamUI:两条路径怎么选
SDK 6 里有两套上层方法:streamText和streamUI。很多刚上手的人会纠结该用哪一个,我直接说结论。
streamText更底层一些,返回的是纯文本数据流,适合后端服务之间调用、需要自己控制响应格式的场景。如果你只做 REST API 的返回,前端拿到文本流后自己渲染,选它准没错。而且streamText在 SDK 6 里也完全支持工具调用,工具执行完成后模型生成的文本会持续流式推给客户端。
streamUI则在上层做了一层 UI 状态同步,允许你在模型调用工具时,把某个 React 组件作为“工具结果”返回给前端。这个机制非常强,尤其适合做对话式界面,比如模型查询航班状态时,前端直接渲染一个操作按钮卡片,不用再自己写事件同步逻辑。不过代价就是streamUI的前后端耦合更深,更依赖固定的协议约定。
我建议这样选:如果你的前端是纯展示型页面,比如工单回复摘要这种,选streamText足够;如果你的产品要在对话流里动态渲染卡片、表单、按钮,那就上streamUI。
1.3 工具协议层:模型无关的 ToolCall
SDK 6 在工具定义上做了一个很聪明的抽象:tool()方法的参数只和你定义的 schema 有关,跟底层用什么模型无关。也就是说,你可以把同一套工具定义同时用在不同的模型服务商上,只要那个服务商支持工具调用就行。
这个模型无关的抽象帮我在工单系统里省了不少事。我们原先是先接了某家的模型,后来业务需要切换成另一个开源的本地模型,如果按传统做法,tool call 的解析格式必须重写。但 SDK 这边只需要改一下 provider 配置和 modelId,工具定义完全没动,切换成本几乎为零。
不过这里有个前提要强调:不同模型对工具调用的“稳定度”差异很大。小参数模型经常出现“以为自己调了工具、实际参数格式乱套”的情况。所以如果在生产环境跑 Agent,不要只看模型厂商宣传的工具调用能力,一定要用自己的工具集做充分的回归测试。
2. 构建第一个实用 Agent:工具定义、多步执行与人工确认
理论讲完,直接上代码。这一节我用工单系统里最常用的三个场景来讲:查询售后记录、计算退款金额、生成处置结论。每个场景对应一种 Agent 能力,组合起来就是一个完整的可落地 Demo。
2.1 工具定义的最小完整范式
在 SDK 6 中,定义一个工具的标准写法如下:
import { tool } from 'ai'; import { z } from 'zod'; const searchAfterSales = tool({ description: '根据订单号查询售后记录,返回退换货状态、金额、处理进度等信息', inputSchema: z.object({ orderId: z.string().describe('工单关联的订单号,格式为纯数字字符串'), }), execute: async ({ orderId }) => { // 这里是真实的业务查询逻辑,可以查数据库或调用内部服务 const record = await db.afterSales.findUnique({ where: { orderId } }); if (!record) { return { found: false, reason: '未找到该订单的售后记录' }; } return { found: true, orderId: record.orderId, status: record.status, refundAmount: record.refundAmount, progress: record.progress, }; }, });有三个细节值得注意。
第一,description不是可选项。模型会读这段描述来决定“什么时候用这个工具、当前对话场景是否匹配”。我见过很多新人工具描述写得很随意,比如只写“查询订单”,结果模型在用户问“我的退款什么时候到”时,完全不会触发这个工具。描述里最好包含“触发场景 + 输入输出约定 + 边界条件”,这些信息对模型判断非常重要。
第二,inputSchema建议始终用 zod 定义,并且每个字段都要写describe()。你写的字段描述最终会成为 prompt 的一部分,直接影响模型生成参数的准确度。我曾经对比过同一份工具 Schema 写与不写字段描述的测试结果,参数正确率大概差了 20%。
第三,execute必须保证返回可序列化的 JSON。不要返回 class 实例、Date 对象、Map 这些类型,模型的上下文合并机制要求工具结果必须是纯数据,否则序列化阶段会直接报错。
2.2 maxSteps 与循环退出:不是设得越大越好
Agent 的多轮循环能力虽然强,但绝不能无限制循环下去。SDK 提供了maxSteps参数控制最大轮数,默认值是 1,也就是“模型只能调用一次工具”。如果你的 Agent 需要多步推理,必须手动调高。
我建议从 3 开始,逐步加,不要贪多。原因很简单:每多一轮,请求耗时成倍增加,而且模型在长时间多轮对话中更容易产生幻觉,比如明明没有新的工具结果,它还在强行编造新的调用请求。
设置示例:
const result = streamText({ model: myModel, prompt: '订单 20241108 的退款进度如何?', tools: { searchAfterSales }, maxSteps: 5, });这里还有个容易被忽略的点:当maxSteps达到上限但模型仍然想继续调用工具时,SDK 不报错,而是直接把当前已有的文本结果返回。所以你在设计业务逻辑时,不要假设“调了工具就一定有后续输出”,一定要对“工具未执行完毕但回复已结束”的情况做兜底提示。
2.3 人工确认:allowAgentInterrupt 与真实业务场景
纯自动化的 Agent 在内部工具场景很爽,但一旦涉及资金、退款、敏感数据,必须有一个人工确认环节。SDK 6 在这方面提供了allowAgentInterrupt这个事件监听机制。
实际的使用场景是这样:模型在推理过程中认为需要调用一个“执行退款”的工具,但我们不希望模型直接操作,而是先把动作意图返回给前端,由运营人员点击确认按钮后,系统才真正执行。
SDK 的做法是把这类工具标记为“需要中断”,然后在流式响应中监听中断事件:
import { createEventCallback, AgentInterruptType } from 'ai'; const events = createEventCallback({ onAgentInterrupt: async ({ type, data }) => { if (type === AgentInterruptType.ToolConfirmation && data.toolName === 'refund') { // 通知前端弹出确认框,拿到用户确认后再继续执行 await notifyFrontendForApproval(data); } }, }); const result = streamText({ model: myModel, prompt: '请对订单 20241108 执行退款', tools: { refund: refundTool }, maxSteps: 5, onEvent: events, });我个人的建议是:退款、转账、删除、修改核心数据这四类工具,都要走人工确认机制;查询、推荐、分类这类只读工具,可以完全自动执行。这个分类标准我是在一次线上误操作后总结出来的,当时模型连续调用了两个只读工具后自己“顺手”执行了一个删除操作,虽然没有造成数据损失,但足够吓人。
2.4 工具执行容错:error 字段的合理使用
工具代码也是代码,必然会出错。SDK 6 允许在execute内部抛出异常,框架会捕获异常并将错误信息回传给模型,不过这是一把双刃剑。
假如你的工具在数据库连接失败时抛了一个很长的堆栈错误,模型拿到这个错误信息后,通常会尝试“修复”它,比如重调工具或向用户解释内部异常。但堆栈错误对模型没有意义,反而可能导致模型生成一段用户完全看不懂的技术报错。
更好的做法是主动吞掉异常,返回结构化的业务错误:
execute: async ({ orderId }) => { try { const record = await db.afterSales.findUnique({ where: { orderId } }); return record ? { found: true, ...record } : { found: false, reason: '查无记录' }; } catch (e) { return { found: false, reason: '查询服务暂时不可用,请稍后再试', retryable: true }; } }这样的返回更容易被模型理解,而且你还能通过retryable字段让模型决定要不要换个方式查询,或者直接用自然语言告知用户稍后重试。把工具的错误“格式化”成模型能读懂的语言,是 Agent 工程里一个非常关键但常被忽视的细节。
3. 多模态输入:让 Agent 胜任“看图处理”场景
工单系统里每天有大量图片:用户拍的快递破损照片、发票截图、手写退货单。这些内容模型必须“看到”,多模态输入就是解决这件事的。
3.1 图片参数的正确姿势:URL、Base64 与 MIME 缺一不可
SDK 6 的多模态输入协议沿用了消息内容分块的思路。一个带图片的用户消息,本质上是一个包含多个 content part 的数组,每个 part 有自己的类型(text或image)和对应的内容地址。
这里最容易犯的错误是:只传一个图片 URL,不说明图片的 MIME 类型。很多模型服务商对 MIME 类型非常敏感,缺了它会直接无法识别图片,或者返回 400。正确做法是同时提供url、mediaType、data三个维度中的至少两个:
import { UIMessage } from 'ai'; const message: UIMessage = { role: 'user', content: [ { type: 'text', text: '这张图片里有什么问题?' }, { type: 'image', image: { url: 'https://example.com/orders/20241108.jpg', mediaType: 'image/jpeg', }, }, ], };如果图片已经在本地读取为 Base64 字符串,可以这样传:
{ type: 'image', image: { data: base64String, // 不带 data:image/jpeg;base64, 前缀 mediaType: 'image/jpeg', }, }注意一个细节:SDK 6 里data字段默认是不带 Data URI 前缀的,别传错了格式,否则服务端解析会多一层转义,白白增加故障排查成本。
3.2 从文件上传到模型读取的完整链路
实际项目中,用户上传的图片一般有两个来源:一个是被上传到对象存储后生成的公开 URL,另一个是在本地内存中暂存、需要直接转交模型的 Base64。两个场景对应的代码路径完全不同。
如果是公开 URL,我就直接用url传给模型,这是最简单、性能最好的方式,因为模型服务商一般可以直接拉取图片。但要注意:如果你的服务在内网环境,用户图片存储在私有桶里,模型在公网拉不到这个 URL,这时必须先把图片转成 Base64 再传。
Base64 场景的完整链路我贴一下:
import { readFile } from 'node:fs/promises'; const buf = await readFile('/tmp/order-20241108.jpg'); const base64 = buf.toString('base64'); const result = streamText({ model: myModel, messages: [ { role: 'user', content: [ { type: 'text', text: '识别这张快递单,提取收件地址和快递单号。' }, { type: 'image', image: { data: base64, mediaType: 'image/jpeg' } }, ], }, ], });这里我强烈建议:上传前先做图片压缩。比如 5MB 的原图,Base64 编码后接近 6.7MB,直接传给模型不仅慢,而且多数模型服务商有单条消息大小限制,很可能直接请求失败。我在工具函数里封装了一个压缩逻辑:超过 1.5MB 的图片先压到 1080px 宽以内再转码,实际效果非常明显,失败率和响应时间都大幅下降。
3.3 真实场景:工单图片分类与表格抽取
多模态 Agent 最有价值的场景之一是“结构化解读非结构化图片”。我在工单系统里做了两个典型的功能。
第一个功能是快递破损图片自动分类。以前需要人工把图片分成“外包装破损”“内物破损”“面单模糊”等类别,现在用多模态 Agent,配合我预先定义好的分类工具,秒级完成。模型看到图片后,通过 Agent 循环自动去调用分类工具,拿到结果后生成一段描述,直接替代了原来需要两个人干的活。
第二个功能是表格截图信息抽取。很多用户上传的退款申请单其实是 Excel 截图,传统 OCR 只能拿到文字布局,没办法理解表格语义。多模态大模型可以直接处理这种类表格图片,识别出“商品名、数量、单价、申请退款金额”等结构化字段,再交给后续的自动退款工具。
这两个场景的共同点在于:多模态输入不是终点,而是 Agent 流程的起点。模型要先“看懂”图片,才能决定下一步调哪个工具,这正是把第二章的 Agent 循环和本章的多模态输入组合起来才能发挥的最大威力。
4. 多模态输出:文本、组件与图像同步流式返回
多模态不只有输入,输出同样重要。这里的输出不只是“模型生成图片”,而是“在一个响应里同时返回文本、UI 组件、结构化数据块,并且是流式的”。这个需求在实际业务中非常常见,尤其是 chatbot 界面。
4.1 用 tool 返回“非文本”渲染块
困境在于:如果你用最朴素的streamText方式,后端最终只吐出一段 Markdown 文本,前端就只能当普通文本渲染。那如果想要“根据模型判断结果,动态渲染一个工单操作卡片”这种效果,怎么办?
SDK 6 里建议的常规做法是:把 UI 渲染块定义为一种特殊的工具,让模型的工具调用结果来触发前端渲染。
我的做法是这样的。先定义一个叫renderOrderSummary的工具,它不查数据,只是接收模型整理好的摘要 JSON。然后把工具结果通过网络实时同步到前端,前端监听消息流中的“工具结果 Part”后进行组件渲染。
const renderOrderSummary = tool({ description: '当用户询问订单详情时,生成一个包含订单摘要和操作按钮的卡片数据', inputSchema: z.object({ orderId: z.string(), amount: z.number(), status: z.string(), estimatedDelivery: z.string().optional(), }), execute: async ({ orderId, amount, status, estimatedDelivery }) => { // 这个函数不真正“执行”什么, // 它的返回值会被 SDK 封装成 tool-result part 推给前端 return { orderId, amount, status, estimatedDelivery }; }, });前端拿到这个 result part 后,匹配toolName === 'renderOrderSummary',就渲染相应的组件。这种方式的好处是:模型自己判断该用哪个渲染组件,而不是前端写死规则。模型一旦学会“看见订单查询需求就用这个工具”,整个 UI 的灵活性就大大提升了。
4.2 长文本流式 UI 的性能考量
多模态混合输出还有一个容易踩坑的地方:流式 UI 组件的渲染性能。
当你在一条响应里同时推送文本 part、工具结果 part、图片 part 时,如果前端对这些部分的处理没有做隔离渲染,会导致组件频繁重渲染,卡顿非常明显。特别是图片 part,如果是 Base64 内嵌格式,数据量大,前端解析一次的成本不低。
我在这个系统里的处理方法是:前端把不同类型的 part 分流处理。文本 part 走 memoized 的流式文本组件,工具结果 part 走专门的卡片组件,图片 part 则异步加载后再渲染。每个组件只订阅自己关心的 part 类型,互不干扰。实测下来,即使用户在高度复杂的工单页面上同时接收多种类型的输出,界面响应依然流畅。
其实这里最核心的意识是:多模态输出不是把“所有东西塞给同一个渲染器”,而是“各流各道、各管各的”。搞清楚这个原则,后面加新的模态类型,也只是多写一个渲染器的事。
5. 上线前必须处理的稳定性细节
功能跑通和线上稳定之间,还有一段很长的路。这一节讲的都是我在这个工单系统上线过程中实际遇到的、也是你大概率会碰到的问题。
5.1 并发调用与连接管理
多模态 Agent 的每次请求往往比普通文本 chat 耗时更长。原因在于一轮工具调用可能伴随多轮内部请求,再加上图片传输体积大。一旦用户量上来,并发一高,很容易把上游连接打满。
我用的方案是并发池化。把模型调用的入口封装成一个统一的InvocationClient,内部用请求队列限流,保证同时发往模型服务商的请求数不超过某个阈值。SDK 的streamText返回的是流对象,我使用ReadableStream的标准方式接住它,然后再转发给上层业务。
class InvocationClient { private queue: Promise<any>[] = []; async run(task: () => Promise<Response>) { // 将任务加入队列,同时并发数超过限制时等待 const prev = this.queue[this.queue.length - 1] ?? Promise.resolve(); const next = prev.then(task); this.queue.push(next); try { return await next; } finally { this.queue = this.queue.filter(p => p !== next); } } }这个简化版的代码思路很实用:把并发限制在一个确定数值内,既保护了上游,也让整体响应时间的方差变小不少。而且 SDK 的流支持取消,前端断开连接时我及时 abort,不会让服务端白白消耗 token。
5.2 参数校验与工具描述:LLM 不是程序员
在上线阶段,我发现一个高频 bug:模型调用工具时,参数生成错误。比如orderId应该是字符串,它传成了数字;amount应该是百分制整数,它传成了小数。Zod 的inputSchema虽然能做运行时校验,但校验失败时模型的处理方式五花八门。
一个实用的套路是:工具的执行函数里不要直接用参数,先做一次“宽容归一化”。比如上面提到的orderId,我统一转成字符串,数值型字段统一 parseFloat。同时在字段的describe里把格式要求写得非常具体:“订单号是纯数字字符串,如 20241108,不要包含汉字和其他符号,不要用科学计数法”。
不要指望模型“聪明”到完全按规则来,它本质上只是在做概率预测。你给它的语义约束越明确、越接近真实的业务样例,它生成的参数就越稳定。
5.3 重试与幂等设计
多模态 Agent 一旦因为网络超时、上游 429、图片解析失败等原因中断,整个流程需要重跑。但重跑是有成本的,而且像退款这类动作绝不能重复执行多次。
我的处理方式是三层防护:
第一层,网络层重试。对可重试的临时错误,比如 429、5xx,SDK 内部可以配置最大重试次数和退避策略,保留默认值就行,但记得给超时设定阈值。
第二层,业务层幂等。给“执行退款”“更新工单状态”这类变更类工具加幂等键。工具第一次执行时把幂等键持久化,第二次收到相同幂等键时直接返回第一次的结果。这个设计能避免 Agent 重试时造成的重复扣款或重复更新。
第三层,人工兜底。我前述的allowAgentInterrupt在这里再次发挥作用:所有不可逆动作都要求运营确认后才能继续。即使前面的自动流程发生了意外,人工兜底至少能保证最终结果可控。
我在实际排查中还发现过一个偏门问题:某些模型服务商在工具调用被中断后,响应流不会正常结束,导致请求一直挂着。最终的解决办法是给整个流程加一个总超时标记,超过预期时间就直接终止流,并向前端返回“处理超时,请稍后重试”,这样至少系统不会一直挂着资源不释放。
整套跑下来,我最大的体会是:Agent 和纯文本对话完全不是一回事。文本对话是“请求-响应”的线性逻辑,Agent 是“感知-决策-行动-再感知”的循环逻辑,而多模态则是让 Agent 的感知层从纯文字扩展到图像和文件。三条线交织在一起,才是生产级 AI 应用的真实样貌。Vercel AI SDK 6 把这些底层协议封装得非常顺手,你省下的时间可以用来打磨工具质量、业务边界和容错机制,而不是徒手解析各家模型返回的 JSON。
最后再分享一个经验:如果你刚开始接触这套体系,不要一上来就追着最新的流式 UI 特性跑,先把streamText配合工具调用做扎实,再接入多模态输入,走通一条完整的业务链路之后,那些“高级特性”自然会变得顺手很多。