
Vercel AI SDK 用到现在我最深的感触不是它调大模型多顺畅而是 Files 这条线在 V4 阶段的思路变了文件不再只是“传到服务器就算完”而是要进入一条能追踪、能被接收方确认、最后形成回执的可验收闭环。这篇文章就讲讲我怎么用这套能力把一个 AI 文件处理小工具从“发文件—收结果”升级成带签收流程的交付闭环整个过程踩了不少坑也沉淀了一套可以复用的方案。这套内容适合两类人一类是在做 AI 工具、想知道怎么把上传、处理、结果文件这几个环节串起来的人另一类是业务方经常追着问“文件到底发没发、有没有人确认”需要给交付过程一个明确交代的开发同学。读完你能直接带走一套状态机设计、几段可落地的接口代码以及验收签名和回执的实操思路。1. 项目解读从“上传文件”到“交付验收”到底差了多远1.1 一个真实存在的交付痛点我最初做的是一个内部用的 AI 台账整理工具业务方的诉求很朴素“把我们每个月的报表丢进去AI 帮你汇总然后把汇总结果发给我们。”听起来就是“上传 生成 下载”三步对吧实际跑起来根本不是这么回事。真实的场景是运营同事上传了一份 CSVAI 处理完生成了整理后的 Excel我直接给了一个下载链接到群里。结果一周后业务负责人来问“那份文件谁收到了内容确认过吗有没有人实际打开看过上个月的版本是不是没更新”这些问题的核心不是“文件有没有生成”而是“文件有没有被正确验收”。上传和生成是单方面行为交付是双方行为——发出方要给得出凭证接收方要给得了确认整个过程还得有状态可查。这是我做 V4 版本时最大的认知转变把“文件传输”这件事升级成“交付协议”。传输只关心字节有没有到交付要关心接收方是否确认了文件的完整性、可用性和归属。1.2 Files V4 到底是一个什么东西先说清楚我这里说的“Files V4”不是某个官方产品线的正式版本号而是我在 Vercel AI SDK 应用里沉淀的一套文件交付协议方案内部代号从 V1 迭代到 V4。V1 阶段就是最朴素的“服务端接收上传存储后返回 URL”。V2 引入了对象存储直传解决了大文件上传超时的问题。V3 把 AI 处理和文件存储打通但状态还是散的数据库里只知道“文件创建了”“结果生成了”中间谁在处理、处理完有没有人确认全靠日志猜。V4 的突破在于把整个链路定义成了一条可验收闭环创建交付单 - 上传原文件 - 进入 AI 处理队列 - 生成交付文件 - 加密签名 - 接收方核验回执 - 标记验收通过或退回。每一步都有状态记录每一步都有时间戳最终的文件交付凭证是加密的接收方拿到的不是一个裸链接而是一个可核验的交付单。这套设计放在 Vercel AI SDK 项目里特别顺因为 AI SDK 本身提供了良好的工具调用和流式上下文机制我可以把“文件处理”当成一个标准工具挂到模型调用链路上让文件流转和状态更新跟着同一个请求上下文走省掉了大量手工编排的胶水代码。1.3 可验收闭环到底要闭环什么很多人一听到“闭环”就想到状态机但我觉得更要想清楚的是一条交付链路里到底哪些东西必须被闭环。第一是交付物的闭环。你不能只承诺“我发了一份文件”而要让接收方能验证收到的文件就是你发出的文件没有在传输过程中被篡改。这靠文件内容哈希来解决。第二是确认动作的闭环。文件不是点了发送就等于交付完成真正的完成节点是接收方看了、知道这是给谁的、确认内容没问题。这靠“验收回执”来解决。第三是状态的闭环。一个交付任务从产生到关闭所有状态切换必须单向、有序、可回溯。谁在什么时候把它从“处理中”改成“待验收”数据库里都得留痕。把这三件事做成协议代码写起来才有章法。很多人把这块做成个上传组件但它是业务协议的一部分而且是和信任、责任绑定非常紧的一部分。我在 V4 里最满意的一件事就是把“文件已处理”和“文件已验收”这两个状态严格区分开了前者只说明 AI 跑完任务后者才代表业务动作完成。2. 方案设计先画链路再写代码2.1 状态机先行功能后置在动任何路由和组件代码之前我先把状态机画清楚了。没有状态机文件到了哪个环节全靠人脑记忆临时排查会非常痛苦。我定义了这样一组状态状态含义可能流向CREATED已创建交付单等待上传UPLOADEDUPLOADED原文件已上传完成PROCESSINGPROCESSINGAI 正在处理或已经处理完产出文件COMPLETED或FAILEDCOMPLETED交付文件已生成并完成签名DELIVEREDDELIVERED交付链接已经提供给接收方ACCEPTED或REJECTEDACCEPTED接收方确认验收通过任务关闭终态REJECTED接收方反馈文件有问题退回处理PROCESSING我特别在意DELIVERED到ACCEPTED这一跳因为这才是“验收”二字的落点。很多系统做到COMPLETED就停了相当于快递员把包裹扔在门口签收人信息全是空的这不能叫交付。状态机设计里还有一条铁律状态只能向后切换不允许回退到更早的阶段除非你走一条显式的REJECTED分支。为此我写了一个极简的状态转移校验函数每次写库之前都校验一遍。2.2 上传方式选型直传对象存储还是走服务端中转文件上传这里我纠结过很久。最简单的方案是让前端把文件 POST 到我的 API Route然后服务端再转存到对象存储。这个方案调试方便但有两个问题一是体积一大请求时间就长免费实例容易触发超时二是服务器的网络带宽成了整个链路的瓶颈A 传 50MB 可能没问题十个人同时传服务器压力就上来了。V4 我改成“预签名直传”。前端先从 API 拿一个带权限和过期时间的直传地址然后直接把文件交给对象存储中间不经过业务服务器。这样网络压力被分摊到存储服务的边缘节点和我经常说的“让专业的人干专业的活”是一个道理。在 Vercel 生态里我这里用的是 Vercel Blob 的put方法它会返回一个客户端可以直接 PUT 的 URL 和最终的公开访问 URL。关键点是上传完成后我数据库里的文件状态不能靠前端“说完成就完成”必须由服务端去确认存储端文件确实存在、大小一致再推进状态防止有人绕过前端直接伪造交付记录。2.3 为什么坚持把 AI 处理编排放在 SDK 里文件处理的业务逻辑是拿到原文件调用大模型去整理、提取、汇总最后再生成一个新文件交付出去。这中间涉及到模型调用参数、上下文处理、工具定义和结果解析。我选择完全用 Vercel AI SDK 的streamText加tool机制来编排而不是直接把文件 URL 交给模型、用普通函数硬写。原因是这里有一个很容易被忽视的好处AI SDK 的工具调用天然支持多轮上下文。举个例子AI 处理一份 CSV 时它会先调用analyzeFile工具理解结构再调用generateExcel工具生成整理结果。这两个工具之间的状态是共享的模型可以记住前一步的分析结果。如果你在外面自己写编排逻辑就要手动维护这些中间变量代码会很绕。更关键的是工具调用里可以注入文件状态更新。我在每个工具执行的末尾都会更新数据库里的交付单状态这样就实现了“AI 算到哪里流程进度就跟到哪里”不是事后补一条记录而是全程实时反映。2.4 “验收”的边界验收的不是 AI 生成质量这个边界一开始我完全没划清导致到处被业务方挑战。后来想明白系统里所谓“验收”不应该聚焦在大模型生成的内容好不好、准不准那是业务评审要做的事不该由系统协议去断言。我这里的验收只锁三件事第一交付文件本身是完整的、可打开的。光说“处理完成”没有意义我用文件字节数、哈希值、文件类型魔数这几项来做技术校验。第二交付文件的原数据对应关系是明确的。接收方要能确认这是哪一次任务、哪一份原始文件、哪个版本的产物。第三验收动作是真实发生的。只要接收方点击了“确认验收”系统就记录操作人、操作时间、设备指纹和环境信息生成一份不可抵赖回执。这三件事全部通过状态才走到ACCEPTED。3. 实操搭一条可验收的文件交付流水线3.1 目录结构与核心依赖先说依赖。我的项目是 Next.js App Router 加 Vercel AI SDK核心包版本大致是ai和ai-sdk/openai存储用的vercel/blob数据库我用 PostgreSQL 加 Drizzle ORM。这几个组合用下来最顺不需要额外引入重型任务队列。目录结构我按“链路”而不是按“页面”来分app/ api/ deliveries/ route.ts // 创建交付单返回上传直传地址 verify/ route.ts // 校验签名的回执接口供接收方确认 dashboard/ page.tsx // 业务方查看交付单列表 lib/ delivery-state.ts // 状态机与转移校验 delivery-sign.ts // 签名生成与核验 ai-tools.ts // AI SDK 工具定义 db/ schema.ts // 交付单表、验收记录表把和文件链路相关的函数都放进lib而不是散落在路由里是我重构到第三版才忍痛改好的。路由文件只做 HTTP 协议转换和参数校验真正的业务逻辑全在库函数里方便复用和单测。3.2 创建交付单并返回直传地址创建交付单的接口要做三件事写入数据库一条CREATED状态记录、向 Blob 申请一个可直传的 URL、把 URL 和交付单 ID 一起返回给前端。代码大致长这样import { put } from vercel/blob; import { db } from /db; import { deliveries } from /db/schema; import { randomUUID } from crypto; export async function POST(request: Request) { const body await request.json(); const { projectId, fileType } body; // 1. 生成交付单 ID状态初始为 CREATED const deliveryId randomUUID(); // 2. Vercel Blob 的 put 方法返回一个可直接上传的 URL const blob await put(raw/${deliveryId}.${fileType}, new Blob(), { access: public, addRandomSuffix: true, }); // 3. 落库 await db.insert(deliveries).values({ id: deliveryId, projectId, status: CREATED, rawBlobUrl: blob.url, // 这个 URL 可直接用于客户端 PUT createdAt: new Date(), }); return Response.json({ deliveryId, uploadUrl: blob.url, downloadUrl: blob.url, // 实际上传完成后可用同一个 URL 访问 }); }这里有个细节put方法的兼容度很高客户端拿到uploadUrl之后直接用fetch发 PUT 请求不需要额外的鉴权头因为签名已经织进 URL 里了。这个 URL 有有效期有效期内没有传完就必须重新申请。3.3 让 AI 处理文件并生成交付产物原文件上传完成后前端会再调用一个“开始处理”的接口把deliveryId传进来。这个接口内部调用 AI SDK把文件处理定义成一个工具调用链。我在ai-tools.ts里定义了三个工具inspectFile分析文件结构transformContent生成目标格式的内容produceFile把结果写成一个新文件并更新交付单状态。这里给出produceFile一个简化版重点看它怎么在工具内部推进状态机import { tool } from ai; import { z } from zod; import { put } from vercel/blob; import { markDeliveryStatus } from /lib/delivery-state; export const produceFileTool tool({ description: Generate the final deliverable file content, parameters: z.object({ deliveryId: z.string(), content: z.string(), suggestedFileName: z.string(), }), execute: async ({ deliveryId, content, suggestedFileName }) { // 状态必须是 PROCESSING 才能产出 await markDeliveryStatus(deliveryId, PROCESSING, COMPLETED); // 这里假设 content 是最终交付文本实际项目中可能是流式内容 const blob await put(deliverables/${deliveryId}.md, content, { access: public, }); await db .update(deliveries) .set({ resultUrl: blob.url, status: COMPLETED, completedAt: new Date(), }) .where(eq(deliveries.id, deliveryId)); return { resultUrl: blob.url }; }, });实际处理中我会把“生成内容”和“写出文件”分成两个工具因为大模型的输出可能是流式的真正写文件时要等到内容完整。但原则是一样的每个工具负责一个明确阶段执行成功才允许状态流转。3.4 给交付文件加签名生成可核验回执状态到COMPLETED之后系统会执行一次“签名动作”。这一步我几乎是在所有教程里都看到有人忽略但它恰恰是可验收闭环里最关键的信任底座。我做的事情是拿出交付文件的 URL、大小、哈希值加上交付单 ID 和接收方标识用 HMAC-SHA256 生成一个签名然后把签名和这些元数据打包成一份“验收回执单”。签名函数长这样import crypto from crypto; const SECRET process.env.DELIVERY_SIGN_SECRET!; export function signDelivery(delivery: { id: string; resultUrl: string; fileSize: number; fileHash: string; receiver: string; }) { const payload [ delivery.id, delivery.resultUrl, delivery.fileSize, delivery.fileHash, delivery.receiver, ].join(|); const signature crypto .createHmac(sha256, SECRET) .update(payload) .digest(hex); return { payload, signature, signedAt: new Date().toISOString(), }; }接收方验收时前端会把回执单里的 payload 和 signature 送回/api/verify服务端用同一个密钥重新计算签名比对一致才认为这份回执没有被篡改。这里我踩过一个很典型的坑一开始把接收方标识写成了数据库自增 ID后来发现接收方邮箱变更会导致回执校验失败。最后我改成接收方用户的公钥地址稳定且可追踪。不是所有业务都需要这么重但如果做跨组织交付强烈建议留一个稳定的接收方标识。3.5 用状态校验函数收口所有状态切换状态切换最怕的就是“到处直接改 status 字段”。Senior 一点的开发者会在团队里立规矩但我更相信用代码管住人写一个统一的迁移函数所有链路代码都走它。const TRANSITIONS { CREATED: [UPLOADED], UPLOADED: [PROCESSING, FAILED], PROCESSING: [COMPLETED, FAILED], COMPLETED: [DELIVERED], DELIVERED: [ACCEPTED, REJECTED], REJECTED: [PROCESSING], ACCEPTED: [], FAILED: [], } as Recordstring, string[]; export async function markDeliveryStatus( deliveryId: string, from: string, to: string ) { const row await db.query.deliveries.findFirst({ where: eq(deliveries.id, deliveryId), }); if (!row) throw new Error(delivery not found); if (row.status ! from) { throw new Error( Invalid transition: ${row.status} - ${to}, expected from ${from} ); } if (!TRANSITIONS[from]?.includes(to)) { throw new Error(Invalid transition: ${from} - ${to}); } await db .update(deliveries) .set({ status: to, updatedAt: new Date() }) .where(eq(deliveries.id, deliveryId)); }这个函数会让非法状态迁移直接抛异常。开发阶段能暴露很多隐性问题比如有人忘了把状态置为UPLOADED就直接触发 AI 处理函数会立刻拦下来。生产环境里我还会把这个异常上报到日志平台。4. 完整链路演示验收闭环跑起来的样子4.1 前端三步唤起整套流程前端侧的逻辑我封装成了一个 React hook业务页面只需要三步申请交付单、上传文件、确认接收。上传文件的代码很简洁因为直传 URL 已经由后端准备好async function uploadFile({ file, deliveryId }: { file: File; deliveryId: string }) { // 1. 获取直传地址 const res await fetch(/api/deliveries, { method: POST, body: JSON.stringify({ projectId: project_123, fileType: csv }), }); const { uploadUrl } await res.json(); // 2. 直接把文件 PUT 到目标地址 const uploadRes await fetch(uploadUrl, { method: PUT, headers: { Content-Type: file.type }, body: file, }); // 3. 通知后端开始处理 await fetch(/api/deliveries/${deliveryId}/process, { method: POST }); }前端的“确认验收”动作会收集三样东西交付单 ID、接收方标识、以及回执验证结果。验证通过后统一提交到/api/verify这个动作需要带上短期有效的登录凭据避免验收动作被伪造。4.2 用任务表追踪每份交付物数据库里交付记录的关键字段我列一下方便你搭建时对照。核心就是这个deliveries表字段作用id交付单唯一标识project_id归属项目status当前状态机状态raw_blob_url原始文件地址result_url交付文件地址file_hash交付文件的 SHA-256 哈希receiver接收方标识signed_payload回执签名原文signatureHMAC 签名created_at创建时间confirmed_at验收确认时间这里的file_hash尤其重要。接收方点击确认的时候前端会先把交付文件下载下来在浏览器里用 Web Crypto 计算 SHA-256再和后端记录的哈希做比对。哈希一致验收按钮才允许点亮。如果不一致系统会直接标记REJECTED并触发人工检查。用浏览器算大文件哈希其实有性能风险几百 MB 的文件算哈希时页面会卡。我的处理办法是对超大文件只抽取前 1MB、中间 1MB、最后 1MB 做分段哈希再合并哈希。既满足了校验要求又不会卡死页面。4.3 验收人在界面上到底能做什么业务方打开交付单详情页看到的不再是一句“已发送”而是一张类似快递签收单的界面。状态、时间线、原文件、交付文件、处理日志、签名状态都排在眼前。验收人要做两件事下载交付文件确认内容没问题点击验收按钮前系统会提示他再次核对接收方信息。点击后前端会做一轮本地哈希校验然后把签名回执发送到服务端服务端验签通过后这条交付单的状态就变为ACCEPTED整条链路关闭。这个界面还有一个容易被忽略但很重要的功能退回理由。如果验收人觉得文件不对必须填写理由才能触发REJECTED这个理由会回流到 AI 处理环节作为下一版优化的输入。有了它整个闭环才真正形成“数据从交付现场回到生成现场”的回路。5. 踩坑与排查技巧5.1 上传 URL 过期前端传一半发现地址失效Vercel Blob 的预签名 URL 默认有效期不长。第一次上线时我踩了坑用户上传一个 300MB 的视频文件传到 60% 时 URL 过期上传直接中断而交付单状态还停在CREATED前端也不报错用户以为上传成功实际存储端压根没有文件。解决方式是双管齐下后端把expiresIn设置得比文件上传测试峰值更长并且在前端上传前先检查创建时间和当前时间的间隔超过一定阈值就重新申请直传地址。还有一个经验在客户端上传期间启动一个“心跳任务”定时向后端询问 URL 是否仍有效失效就提示用户重新上传。5.2 文件类型校验不能只看扩展名和 Content-Type文件类型校验是另一个让我吃了不少苦的环节。一开始我按扩展名和白名单 MIME 判断结果用户把一个改名为.pdf的 HTML 文件传了上来AI 处理时直接解析错乱前端预览也打不开整个任务卡在PROCESSING状态。问题在于扩展名和Content-Type都可以被伪造唯一靠谱的是读文件头部的“魔数”也就是文件开头的几个字节。我在上传完成后加了一个服务端读取校验的步骤async function sniffFileType(buffer: Buffer) { // PDF 文件头是 %PDF if (buffer.subarray(0, 5).toString() %PDF-) return pdf; // 这里继续补全 PNG、JPEG、XLSX 等格式的魔数判断 return unknown; }校验不通过的文件直接标记FAILED并给出明确提示。千万不要跳过这一步尤其在 AI 链路里文件解析失败导致的报错排查成本远比上传时多写几个if要高。5.3 验收签名密钥轮换导致旧回执验签失败签名用 HMAC 对称加密非常高效但有个问题DELIVERY_SIGN_SECRET一旦轮换之前签发的回执就全部失效接收方再点确认校验会对不上。第一次轮换密钥时我只改了环境变量忘了做兼容结果在线系统瞬间冒出几十个验签失败报警。解决方法是给签名加上版本号const SECRET_VERSIONS { v1: process.env.DELIVERY_SIGN_SECRET_V1, v2: process.env.DELIVERY_SIGN_SECRET_V2, }; export function signDelivery(delivery) { const version v2; const svcSecret SECRET_VERSIONS[version]; // payload 开头拼上版本号 const payload v2|${delivery.id}|...; // 签名计算用 v2 密钥 }核验时从 payload 里解析出版本号选择对应密钥重新计算。这样老回执用老密钥也能验新回执用新密钥签交接空窗就消失了。5.4 免费版额度与自定义域名的几个真实体验最后聊点部署层面的。Vercel 的 Hobby 免费版对个人项目来说够用但有几个点必须清楚免费版有带宽和 Blob 存储量的限制文件交付这种场景流量增长很快我建议上线前就把用量监控接上别等账单出来再慌。自定义域名绑定不难在项目的Settings - Domains里添加你的域名然后到 DNS 服务商那里加一条 CNAME 记录指向 Vercel 提供的目标地址等 SSL 证书自动签发即可。这里有个经验域名一定要提前绑定不要等接口调试完再绑因为证书签发要几分钟到几十分钟不等别在演示前才发现域名没生效。绑定域名后所有代码里的永久链接尽量用环境变量拼不要写死域名。我在项目里维护了一个APP_URL环境变量Blob 返回的 URL 如果是通配域名前端展示时再做一层替换这样迁移环境或切换域名时不用改任何业务代码。6. 收尾的一点经验V4 这套方案做完有件事我记得特别清楚第一次给业务方演示时对方看到“验收回执”界面里的签名哈希和文件指纹第一反应是“这也太技术化了”但当他们意识到每一步都有记录、每份文件都能核验反而特别放心。如果你也要做文件交付相关功能我的建议只有一条别急着写代码先坐下来把状态机和验收边界讨论清楚。文件从上传到验收看似简单但真正决定系统水平的是那些看不见的协议设计。把“谁在什么时候确认了什么”落到数据里这个交付才站得住脚。最后再分享一个小技巧我习惯把验收回执设计成只追加、不删除的结构即使用户点了退回原来的回执记录也保留完整再建立一条新的REJECTED分支记录。这样任何时候有人问“当时为什么退回”你都能翻出完整的操作人和理由而不是对着新状态一头雾水。