做 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 type="file">加一个fetch上传,你能拿到的只有 HTTP 状态码。状态码 200 不代表文件完整,更不代表模型消费成功。要做出可验收的闭环,必须把每个环节变成有状态、可观测、可重试的节点:
- 文件内容要有完整性校验,不能只说“收到了”,要说“收到的和你发的一致”。
- 每个文件要有独立的生命周期:上传中、已存储、校验通过、处理中、已完成。
- 任何一个节点失败,要有明确的错误类型,而不是让用户看到一个无限转圈的菊花。
AI SDK v4 的文件能力刚好提供了这套基础设施。它不是替你完成文件交付,而是把文件作为一种一等公民的消息类型,让整条链路可以被代码显式地控制。
2. Files V4 的 API 骨架:FilePart、DataContent、convertToDataPart
2.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 URL | data:image/png;base64,iVBOR... | 小文件直接内联,简单粗暴 |
| 二进制 | Uint8Array/ArrayBuffer/Blob | 客户端本地处理,避免字符串转换开销 |
| HTTP URL | https://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 URL,part.mimeType 是 text/plain这个函数解决的是“从文件对象到消息结构”的转换问题。默认情况下它会读取整个文件转成 Data URL,所以它天然适合中小文件。如果你用了onFileUpload钩子返回 URL,AI 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 type="file" multiple /> <input value={input} onChange={handleInputChange} /> <button type="submit">发送</button> </form> <div>status: {status}</div> {error && <div>error: {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 – 5MB | onFileUpload 转对象存储 | 避免 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 Array<any>).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-256,Web Crypto 原生支持 async function sha256(file: File): Promise<string> { 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 触发 | 上传接口返回 URL |
stored | 已存入对象存储 | 上传接口 200 | 服务端校验通过 |
verified | 完整性校验通过 | 哈希比对一致 | 消息提交给模型 |
processing | 模型消费中 | streamText 开始 | 数据流返回 |
done/failed | 终态 | 流结束或异常 | — |
前端不必全量实现这六个状态,但至少要在 UI 上区分uploading、processing、done、failed。AI SDK 的status字段覆盖的是请求整体,文件级别的状态需要你在onFileUpload里自己维护,比如用 React state 存一个 Map 记录 fileId 对应的阶段。
4.3 进度、取消和重试:体验细节决定成败
fetch拿不到上传进度,想要真实进度条必须上 XHR,或者用fetch+ReadableStream自己包一层。XHR 的做法最省事:
function uploadWithProgress( file: File, onProgress: (percent: number) => void, signal?: AbortSignal, ): Promise<string> { 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.37(base64 膨胀系数)+ 消息其他字段(约 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_TOKEN | Environment Variables | Vercel Blob 读写令牌,仅服务端使用 |
OPENAI_API_KEY(或其他模型 key) | Environment Variables | 模型接口密钥 |
maxDuration | route.ts 导出 | 控制函数最大执行时长 |
| DNS CNAME | 域名服务商 | 指向cname.vercel-dns.com |
| Domains | Vercel 项目设置 | 绑定自定义域名 |
6. 实盘踩坑记录:六个让交付翻车的细节
6.1 base64 把请求体撑爆
我最开始图省事,所有文件都走默认的convertToDataPart内联。上线后用户传一个 8MB 的 PDF,请求体直接变成 11MB,Vercel 返回 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、状态机各阶段的耗时。这套日志在线上排查时就是你的“黑匣子”,出了问题不需要复现,直接翻记录就能定位是传输、校验还是模型消费的锅。文件交付这件事,做到“每一份文件都有据可查”,才算真正闭环了。