资讯动态

Vercel AI SDK 文件交付全攻略:从上传到验收的闭环实践

发布时间:2026/9/10 9:02:43 来源:尧图企业网站定制
做 AI 应用最容易被忽视的一环是文件交付。很多项目里“上传成功”这四个字只是客户端自嗨文件有没有完整到达服务端、模型到底吃没吃到、处理结果和源文件对不对得上这一连串问题不盯住迟早会在线上翻车。Vercel AI SDK 从 4.x 开始把文件能力做得比较完整了FilePart、convertToDataPart、onFileUpload 这些 API 真正能支撑起一条“可选、可传、可验、可查”的交付闭环。这篇文章我就围绕 Files V4 这套能力讲清楚怎么把文件上传这件事从“能发出去”升级成“能验收”适合正在用 AI SDK 做聊天、Agent、文档分析类产品的开发者参考。1. 先泼盆冷水“上传成功”不是文件交付的终点1.1 交付链路其实有五个环节我见过太多项目把文件交付理解成“前端把文件塞进请求后端收到就完事”。但真实的文件交付链路至少包含五个环节选文件用户在前端选中文件此时只有文件名、大小、类型这些元信息。传输文件内容从浏览器到服务端可能是 base64 塞进 JSON也可能是 multipart 或直传对象存储。落库/存储服务端把文件内容放到了哪里临时内存、磁盘、还是对象存储。模型消费AI 模型真正读到了文件内容而不是只收到一个“有文件”的信号。结果回执客户端确认处理结果与源文件一致能追溯到“哪个文件、什么时间、什么状态”。任何一个环节断掉用户看到的现象都一样要么模型说“我看不到你的文件”要么处理结果张冠李戴要么文件传了一半卡死。问题在于前端 UI 只告诉你“上传成功”而后端有没有收到、模型有没有消费你根本不知道。这就是典型的“交付了但不可验收”。1.2 传统实现为什么接不住验收这个需求如果只是用input typefile加一个fetch上传你能拿到的只有 HTTP 状态码。状态码 200 不代表文件完整更不代表模型消费成功。要做出可验收的闭环必须把每个环节变成有状态、可观测、可重试的节点文件内容要有完整性校验不能只说“收到了”要说“收到的和你发的一致”。每个文件要有独立的生命周期上传中、已存储、校验通过、处理中、已完成。任何一个节点失败要有明确的错误类型而不是让用户看到一个无限转圈的菊花。AI SDK v4 的文件能力刚好提供了这套基础设施。它不是替你完成文件交付而是把文件作为一种一等公民的消息类型让整条链路可以被代码显式地控制。2. Files V4 的 API 骨架FilePart、DataContent、convertToDataPart2.1 FilePart 长什么样在 AI SDK v4 里聊天的消息不再是扁平字符串而是由一组parts构成的。文件在消息里对应一个FilePart结构大致如下interface FilePart { type: file; data: string | Uint8Array | ArrayBuffer | Blob; // DataContent mimeType: string; filename?: string; }也就是说文件内容被塞进了data字段mimeType告诉模型这是什么类型filename保留原始文件名。这个结构同时存在于客户端消息和服务端消息里所以两端看到的文件模型是一致的。不要小看这一点v3 时代附件是挂在experimental_attachments上的旁路数据模型消费和前端展示经常对不上v4 把它收编进消息结构本身这才是闭环能成立的前提。2.2 DataContent 的三种形态各有什么用DataContent是这个体系里最灵活也最容易被误解的类型它可以是形态例子适用场景Data URLdata:image/png;base64,iVBOR...小文件直接内联简单粗暴二进制Uint8Array/ArrayBuffer/Blob客户端本地处理避免字符串转换开销HTTP URLhttps://your-blob-store.com/xxx.png大文件走对象存储服务端按需拉取理解这三种形态你才能定传输策略。小文件比如几百 KB 的图片直接转 Data URL 塞进 JSON 请求体链路最短大文件几 MB 的 PDF如果也转 base64请求体膨胀 33%很容易撞上服务端的上限这时候就应该先把文件传到对象存储然后把 URL 作为data传过去。2.3 convertToDataPart 在链路上的位置convertToDataPart的作用是把浏览器里的File对象转换成消息里的DataPart。它的典型用法在客户端import { convertToDataPart } from ai; const file new File([hello], hello.txt, { type: text/plain }); const part await convertToDataPart(file); // part.type file, part.data 是 Data URLpart.mimeType 是 text/plain这个函数解决的是“从文件对象到消息结构”的转换问题。默认情况下它会读取整个文件转成 Data URL所以它天然适合中小文件。如果你用了onFileUpload钩子返回 URLAI SDK 内部就不会走convertToDataPart的默认转换而是直接用你返回的 URL 作为data这也是大文件方案的正确入口。提示convertToDataPart是异步的因为它要读文件内容。在客户端和服务端都能用但在浏览器里遇到超大文件时内存占用会明显上升建议只对 4MB 以下的文件走这个函数。3. 把“选文件→传到对的地方→模型消费”串成闭环3.1 客户端useChat 的 maxFileCount、maxFileSize 与 onFileUpload在 React 项目里文件交付的入口是useChat。先看一个完整配置use client; import { useChat } from ai-sdk/react; export function Chat() { const { messages, input, handleInputChange, handleSubmit, status, error, } useChat({ maxFileCount: 3, maxFileSize: 10 * 1024 * 1024, // 10MB onFileUpload: async (file) { const formData new FormData(); formData.append(file, file); const res await fetch(/api/upload, { method: POST, body: formData }); if (!res.ok) { throw new Error(upload failed: ${res.status}); } const { url } await res.json(); return url; // 返回值会作为 FilePart.data }, }); return ( div {messages.map((m) ( div key{m.id} div{m.role}/div {m.parts?.map((part, idx) part.type file ? ( a key{idx} href{part.data as string} download{part.filename} {part.filename} /a ) : ( div key{idx}{part.text}/div ), )} /div ))} form onSubmit{handleSubmit} input typefile multiple / input value{input} onChange{handleInputChange} / button typesubmit发送/button /form divstatus: {status}/div {error diverror: {error.message}/div} /div ); }几个关键点maxFileCount和maxFileSize是客户端前置守卫。文件数量超限或单文件超限useChat会在提交前拒绝省得把无效请求发到服务端。onFileUpload是整条链路的“替身接口”。它接收一个File返回一个DataContent。你可以在里面做任意上传逻辑走 Vercel Blob、走 S3、走自家网关都行只要最终返回一个 URL 或 base64。status字段是链路的“总开关状态”submitted | streaming | ready | error但它只覆盖请求整体文件级别的状态还需要自己维护。3.2 大文件与 URL 策略什么时候直传什么时候走对象存储这是 Files V4 落地里最重要的一次取舍。我建议按文件体量分两条路文件大小传输方式理由 1MB默认 base64/Data URL链路短无需额外存储依赖1MB – 5MBonFileUpload 转对象存储避免 JSON 请求体膨胀 5MB必须对象存储 URL否则几乎必然触发请求体上限对象存储我优先推荐 Vercel Blob因为它和 Vercel 部署天然配套路由权限、CDN、防盗链都省了自己搭。上传接口示例// app/api/upload/route.ts import { put } from vercel/blob; import { NextResponse } from next/server; export const maxDuration 30; export async function POST(request: Request) { const formData await request.formData(); const file formData.get(file) as File | null; if (!file) { return NextResponse.json({ error: no file }, { status: 400 }); } // access: public 意味着拿到 URL 就能读适合需要模型回访的场景 const { url, pathname } await put(file.name, file, { access: public, addRandomSuffix: true, }); return NextResponse.json({ url, pathname }); }返回的 URL 会作为FilePart.data传给模型。很多模型提供商OpenAI、Anthropic、Google 等的接口在收到 URL 形态的 image/file part 时会自动抓取内容前提是 provider 支持supportsUrl。如果你的模型不支持远程文件你仍然可以在服务端把 URL 拉下来转 base64 再喂给模型服务端的转换代码和客户端一样用convertToDataPart配合fetch即可。3.3 服务端在 chat route 里识别并校验 file part服务端的核心职责有两个把客户端传来的 file part 整理成模型消费的结构以及在交给模型之前完成校验。示例// app/api/chat/route.ts import { streamText } from ai; import { openai } from ai-sdk/openai; export const maxDuration 60; const ALLOWED_MIME new Set([ image/png, image/jpeg, image/webp, application/pdf, text/plain, text/markdown, ]); function assertSafeFilePart(part: { type: string }) { if (part.type ! file) return; const p part as { data: string; mimeType: string; filename?: string }; if (!ALLOWED_MIME.has(p.mimeType)) { throw new Error(unsupported file type: ${p.mimeType}); } // data 为 URL 时无法直接看长度这里只做基础守卫 if (typeof p.data string p.data.startsWith(data:)) { const base64 p.data.split(,)[1] ?? ; if (base64.length 14 * 1024 * 1024) { // 约等于 10MB 原始内容 throw new Error(file too large); } } } export async function POST(req: Request) { const { messages } await req.json(); const normalized (messages as Arrayany).map((m) { const parts (m.parts ?? []).map((part: any) { assertSafeFilePart(part); return part; }); return { role: m.role, parts }; }); const result streamText({ model: openai(gpt-4o-mini), messages: normalized, }); return result.toDataStreamResponse(); }服务端校验是闭环的底线因为客户端的所有限制都可以被绕过。MIME 白名单、大小上限必须在服务端再查一遍。这里只做了同步校验如果你走的是对象存储 URL建议在streamText之前用fetch(url, { method: HEAD })确认 Content-Length避免把坏链交给模型。4. 验收点设计哈希、状态机、取消与重试4.1 用 SHA-256 作为文件完整性的“收货单”传输完整这是“可验收”的第一层含义。HTTP 200 只能证明请求完成不能证明内容没被截断或篡改。最可靠的做法是客户端算 SHA-256随文件一起交到服务端服务端比对一致才算“签收”。// 浏览器端计算 SHA-256Web Crypto 原生支持 async function sha256(file: File): Promisestring { const buffer await file.arrayBuffer(); const digest await crypto.subtle.digest(SHA-256, buffer); return [...new Uint8Array(digest)] .map((b) b.toString(16).padStart(2, 0)) .join(); }然后在上传接口里带上摘要// app/api/upload/route.ts 里扩展 const expectedHash formData.get(sha256) as string; const buffer await file.arrayBuffer(); const actualHash await computeSha256Hex(buffer); // Node crypto 实现 if (expectedHash actualHash ! expectedHash) { return NextResponse.json({ error: hash mismatch }, { status: 422 }); }服务端比对通过后你可以把哈希写进交付记录。这一步的收益很实在线上遇到“文件内容不对”的客诉时你直接查哈希定位是传输丢了还是模型处理错了而不是靠猜。4.2 交付状态机文件不是瞬移是一步步到达的我给文件交付定义了六个状态每个状态都有明确的进入条件和退出条件状态含义进入条件退出条件selected用户已选文件前端 onChange开始上传uploading正在传输onFileUpload 触发上传接口返回 URLstored已存入对象存储上传接口 200服务端校验通过verified完整性校验通过哈希比对一致消息提交给模型processing模型消费中streamText 开始数据流返回done/failed终态流结束或异常—前端不必全量实现这六个状态但至少要在 UI 上区分uploading、processing、done、failed。AI SDK 的status字段覆盖的是请求整体文件级别的状态需要你在onFileUpload里自己维护比如用 React state 存一个 Map 记录 fileId 对应的阶段。4.3 进度、取消和重试体验细节决定成败fetch拿不到上传进度想要真实进度条必须上 XHR或者用fetchReadableStream自己包一层。XHR 的做法最省事function uploadWithProgress( file: File, onProgress: (percent: number) void, signal?: AbortSignal, ): Promisestring { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload); xhr.upload.onprogress (e) { if (e.lengthComputable) { onProgress(Math.round((e.loaded / e.total) * 100)); } }; xhr.onload () { if (xhr.status 200) resolve(JSON.parse(xhr.responseText).url); else reject(new Error(upload ${xhr.status})); }; xhr.onerror () reject(new Error(network error)); if (signal) signal.addEventListener(abort, () xhr.abort()); const form new FormData(); form.append(file, file); xhr.send(form); }); }重试策略我推荐两层文件上传失败时做指数退避最多重试 3 次间隔 1s / 2s / 4s消息整体失败时让用户看到error状态后手动重发不要自动重发整段对话因为模型消费可能已经在服务端产生了部分输出自动重发会导致重复内容。5. 上线 Vercel 的部署细节体积上限、函数时长与自定义域名5.1 请求体上限决定了你的传输方案这是 Files V4 部署时最容易踩的硬限制。Vercel 的 Serverless Function 对请求体大小有约束Hobby 计划尤其严格实测在 4.5MB 左右就会返回 413。这意味着如果你把 6MB 的文件转成 base64 塞进 JSON 请求体链路必挂。前面说的“大文件走对象存储 URL”不是优化建议而是硬性要求。判断你的文件该走哪条路我建议用一条经验公式文件原始大小 × 1.37base64 膨胀系数 消息其他字段约 10KB 请求体上限的 80%才允许走内联。否则一律走对象存储。5.2 maxDuration 与函数超时文件上传接口和聊天流式接口都要注意函数时长。Vercel 上 Fluent Compute 的默认时长是 300 秒但 Hobby 可能更低建议显式声明// app/api/upload/route.ts export const maxDuration 30; // app/api/chat/route.ts export const maxDuration 60;聊天接口用streamText().toDataStreamResponse()是流式返回首字节时间很快但整个函数可能持续到流结束。如果模型提供商响应慢函数超时会让客户端收到半截流前端status会卡在streaming需要监听error并做断流处理。5.3 Vercel Blob 的环境变量与安全Blob 客户端 token 绝不能出现在浏览器代码里。在app/api/upload/route.ts里使用put时SDK 会自动读取环境变量BLOB_READ_WRITE_TOKEN。在 Vercel 项目 Settings → Environment Variables 里配好这个 token本地开发则在.env.local里配。线上环境请确认 token 对应的 store 设置了合适的缓存和访问控制避免公开 store 被刷流量。5.4 绑定自定义域名的标准动作如果你想把项目绑定到自己的域名流程很短进入 Vercel 项目面板的 Settings → Domains输入你的域名并点击 Add然后去域名服务商处添加面板给出的 CNAME 记录指向cname.vercel-dns.com等待 HTTPS 证书自动签发一般几分钟内完成。绑定后文件上传接口和聊天接口都走你自己的域名CORS、Cookie、主域资源引用都更好控制。生产环境我建议尽早绑定不要等到上线当天才处理。5.5 环境变量与关键配置清单配置项位置说明BLOB_READ_WRITE_TOKENEnvironment VariablesVercel Blob 读写令牌仅服务端使用OPENAI_API_KEY或其他模型 keyEnvironment Variables模型接口密钥maxDurationroute.ts 导出控制函数最大执行时长DNS CNAME域名服务商指向cname.vercel-dns.comDomainsVercel 项目设置绑定自定义域名6. 实盘踩坑记录六个让交付翻车的细节6.1 base64 把请求体撑爆我最开始图省事所有文件都走默认的convertToDataPart内联。上线后用户传一个 8MB 的 PDF请求体直接变成 11MBVercel 返回 413前端却只显示“网络错误”。排查了很久才定位到是请求体上限。如果你要的是稳定交付大文件必须直传 Blob别抱侥幸心理。6.2 File.type 为空导致 MIME 校验误杀手机相册选出的某些文件File.type可能是空字符串。客户端校验直接把这类文件拦了。兜底方案是把空 MIME 类型映射成具体类型或者用文件头嗅探。最简单的做法.type为空时根据扩展名给出默认application/octet-stream并在服务端做二次校验时放行这个兜底类型但记录一条 warning 供排查。6.3 模型没吃到 file part有段时间服务端返回正常但模型回答“我看不到任何文件”。后来发现是把消息传给streamText时parts字段丢了。AI SDK 的streamText要求消息要么是string内容要么是{ role, parts }的完整结构混用会导致部分 provider 直接忽略文件。解决方法是服务端统一把消息标准化成{ role, parts }再传不要传 v3 风格的字符串消息。6.4 上传完成的文件变成孤儿用户在onFileUpload进行时点了“停止”或直接发了下一条消息Blob 里的文件已经传上去了但消息没提交文件就变成无人引用的孤儿。Blob 本身有生命周期管理但如果你自建对象存储建议加一个定时清理任务删除超过 24 小时未被消息引用的文件。配上过期时间成本可控。6.5 客户端与服务端包版本不一致ai-sdk/react和ai的版本如果差得太多会出现客户端convertToDataPart生成的结构和服务端解析不一致的情况最常见的是data字段是 data URL 而服务端按 base64 解码导致中文文件名乱码。修复方式很简单锁版本两端都用同一个版本号升级时一起升别单独升级某个包。6.6 流式错误没被 UI 捕获streamText返回的是数据流模型消费文件时可能中途抛错。如果前端只监听status不监听error用户会看到消息卡在“正在生成”但实际上已经失败了。useChat的error字段会携带错误对象务必在 UI 上渲染出来并提供一个“重试”按钮重试时把原始文件一并带过去而不是让用户重新上传。最后再分享一个小技巧把每一条文件交付记录都打一条结构化日志包含 filename、mimeType、size、sha256、状态机各阶段的耗时。这套日志在线上排查时就是你的“黑匣子”出了问题不需要复现直接翻记录就能定位是传输、校验还是模型消费的锅。文件交付这件事做到“每一份文件都有据可查”才算真正闭环了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价