
1. 这不是又一个Next.js教程它是一套能跑通真实业务闭环的全栈工作流我带过六支不同行业的技术团队从电商中台到医疗数据平台最常被问的问题不是“怎么写页面”而是“数据从Excel拖进来怎么变成前端能用、后端能查、AI能理解的干净结构”。这个标题里的三个关键词——数据清洗、ORM设计、AI Prompt工程——不是并列关系而是一个环环相扣的生产流水线清洗是原料处理ORM是工厂建模Prompt工程是质检与调度。很多人把Next.js当成“更快的React”但真正吃透它的团队早把它当成了全栈协同的操作系统服务端组件SSR/SSG天然承载数据预处理逻辑App Router的路由即API设计Server Actions直连数据库又规避了传统REST的冗余层。你不需要再为“前端要不要自己调清洗接口”争执也不用纠结“AI提示词该写在前端还是后端”因为Next.js的架构本身就在强制你把这三件事放在同一个上下文里思考。适合谁不是刚学完create-next-app的新手而是已经用过Prisma或Drizzle、写过Python清洗脚本、也试过Claude或GPT API但总卡在“结果不稳定”的中级开发者。它解决的不是“能不能做”而是“怎么让清洗规则可复用、ORM模型能反哺Prompt结构、AI输出能直接映射到数据库字段”这种真实交付场景里的毛刺感。2. 整体设计思路为什么必须把清洗、ORM、Prompt绑在一起做2.1 数据清洗不能只在Python里跑完就交差我见过太多项目数据清洗脚本写在Jupyter里跑完导出CSV再由前端手动上传——这根本不是工程化是手工作坊。真正的痛点在于清洗规则会变比如客户突然要求把“北京市朝阳区”统一缩写为“北京朝阳”清洗源会变今天是Excel明天是CRM导出的JSON清洗结果要验证清洗后字段是否符合下游AI模型的输入格式。如果清洗逻辑和业务代码隔离每次变更都要跨团队对齐上线前还要人工校验。Next.js的Server Components和Server Actions提供了天然的解耦容器清洗逻辑可以封装成独立函数部署在app/api/clean/route.ts里前端上传文件后直接调用返回结构化JSON更进一步清洗结果可以直接作为Server Component的props跳过客户端JavaScript解析环节。这样做的好处是——清洗过程可审计所有请求日志都在Vercel或自托管日志系统里、可回滚函数版本管理、可压测用wrk模拟高并发清洗请求。我上一个医疗项目把患者诊断文本清洗去噪、标准化ICD编码、提取关键实体全部移到Next.js服务端清洗耗时从客户端3秒降到服务端800ms且错误率下降67%因为服务端能稳定加载NLP模型权重而手机浏览器内存经常爆掉。2.2 ORM不是数据库的翻译器而是业务语义的锚点很多团队用Prisma但只当它是“SQL生成器”prisma.user.findMany()→SELECT * FROM user。这完全浪费了ORM的核心价值。ORM真正的意义在于把数据库表结构升维成业务契约。比如一张orders表字段有statusstring、amount_centsint、created_atdatetime。如果ORM只做字段映射那前端拿到{status: shipped, amount_cents: 2999}还得自己转金额、判断状态流转逻辑。而好的ORM设计应该让Order模型自带方法order.formatAmount()返回¥29.99order.isDeliverable()根据status和物流单号判断是否可发货。Next.js的Server Components能直接消费这些模型方法无需额外API层。更重要的是这个模型就是AI Prompt的天然模板——当你要让AI生成订单摘要时Prompt里写的不是抽象字段名而是请基于以下订单对象生成摘要${JSON.stringify(order.toJsonForAi())}其中toJsonForAi()方法自动过滤敏感字段、格式化时间、补全关联用户信息。这样ORM就从“数据搬运工”变成了“业务语义中枢”清洗后的数据、AI需要的输入、前端展示的结构全部通过同一个模型实例流动。我坚持在每个Prisma模型里加toAiJson()和toFrontendJson()两个方法前者专注AI友好扁平化、字符串化、去隐私后者专注前端友好嵌套对象、本地化时间、状态图标映射它们共享同一套字段定义改一个地方全链路同步更新。2.3 AI Prompt工程不是写几行文字而是构建可验证的数据管道现在流行说“Prompt即代码”但很少有人提“Prompt即Schema”。一个稳定的AI交付必须满足三个条件输入可控、输出可校验、失败可降级。所谓“输入可控”是指传给AI的数据必须经过清洗和ORM建模不能把原始脏数据直接塞进去。比如清洗后的订单数据status字段只能是[pending, shipped, delivered, cancelled]四个值那么Prompt里就可以写死status字段仅限以下四种取值...避免AI胡编乱造。所谓“输出可校验”是指AI返回的JSON必须符合预定义Schema比如{summary: string, key_insights: string[], next_steps: {action: string, deadline: string}[]}。Next.js的Server Actions配合Zod Schema能在AI返回后立刻校验不合规就重试或返回默认文案。所谓“失败可降级”是指当AI超时或返回异常时系统能无缝切到规则引擎——比如订单摘要AI挂了就用order.status order.amount order.items.length拼接一句“已支付¥29.99含3件商品”。这套机制之所以能落地正是因为清洗、ORM、Prompt三者在Next.js里共享同一套类型定义Zod Schema定义清洗规则Prisma Schema定义ORM模型Zod Schema再定义AI输出契约三者用TypeScript联合类型打通。我见过太多团队把Prompt写在环境变量里结果上线后发现AI返回的字段名和前端代码对不上debug两小时才发现是Prompt里把user_name写成了username——这种低级错误用类型约束编译期检查一次就能杜绝。3. 核心细节拆解从清洗函数到AI输出的完整链路3.1 数据清洗用Zod Schema驱动清洗规则而非硬编码if-else清洗的本质是数据契约的强制执行。与其写一堆if (row.phone.includes( )) row.phone row.phone.replace( , )不如用Zod定义“合格手机号”的Schema让清洗变成“尝试转换失败则标记错误”。我们以电商订单导入为例原始Excel可能包含空格分隔的电话、中文逗号分隔的地址、未去重的SKU列表。清洗目标生成标准JSON数组每个元素含phone(E.164格式)、full_address(无换行、无多余空格)、skus(去重后的字符串数组)。// lib/clean/orderImportSchema.ts import { z } from zod; export const OrderImportRowSchema z.object({ phone: z.string().transform((str) { // 移除空格、括号识别中国号码 const cleaned str.replace(/[\s()]/g, ); if (/^1[3-9]\d{9}$/.test(cleaned)) return 86${cleaned}; throw new Error(Invalid phone format); }), address: z.string().transform((str) str.replace(/\n/g, ).replace(/\s/g, ).trim() ), skus: z.string().transform((str) str.split(/[,、]/).map(s s.trim()).filter(Boolean) ), }); export type CleanedOrderRow z.infertypeof OrderImportRowSchema;关键点在于transform不是装饰器是清洗动作本身Zod的transform会在验证时执行失败抛错成功返回新值。错误可聚合批量清洗时用safeParse收集所有失败行生成带行号的错误报告前端可高亮显示问题单元格。Schema即文档这个TS文件既是代码也是清洗规则说明书产品、测试都能看懂“电话必须是11位数字开头为1”。我在实际项目中把清洗Schema按业务域拆分customerSchema.ts、inventorySchema.ts、transactionSchema.ts然后用z.intersection()组合成导入总Schema。这样新增字段时只需改对应模块不影响其他清洗逻辑。比写Python脚本的优势在于——TypeScript能直接在VS Code里跳转到Schema定义看到字段说明、示例值、转换逻辑而Python脚本里的正则表达式三个月后连作者都看不懂。3.2 ORM设计Prisma模型如何成为AI Prompt的“活模板”Prisma Schema不只是数据库DDL它是整个应用的数据宪法。我们以Product模型为例重点不是字段定义而是如何让模型主动参与AI交互// prisma/schema.prisma model Product { id String id default(cuid()) name String description String? priceCents Int map(price_cents) category String tags String[] createdAt DateTime default(now()) updatedAt DateTime updatedAt map(products) }关键改造在schema.prisma之外的lib/db/product.ts// lib/db/product.ts import { PrismaClient, Product } from prisma/client; import { z } from zod; const prisma new PrismaClient(); // AI专用序列化扁平化、去隐私、加业务语义 export const productToAiJson (product: Product) ({ id: product.id, name: product.name, // 描述截断避免AI处理过长文本 shortDescription: product.description?.substring(0, 200) || , // 价格转为元带货币符号 formattedPrice: ¥${(product.priceCents / 100).toFixed(2)}, // 分类转为中文便于AI理解 categoryZh: getCategoryZh(product.category), // 标签转为自然语言描述 tagsDescription: 标签${product.tags.join(、)}, }); // 前端专用序列化保留完整描述加状态计算 export const productToFrontendJson (product: Product) ({ ...product, price: product.priceCents / 100, isOnSale: product.priceCents 10000, // 示例业务逻辑 categoryLabel: getCategoryLabel(product.category), }); // Zod Schema用于校验AI输出 export const AiProductSummarySchema z.object({ summary: z.string().min(20).max(200), keyFeatures: z.array(z.string()).max(5), recommendation: z.enum([buy, wait, skip]), });这里的设计哲学是ORM模型不直接暴露给AI而是通过productToAiJson()函数提供“AI友好视图”。这个函数做了三件事信息裁剪截断description因为AI处理长文本容易丢失重点语义增强formattedPrice和categoryZh把机器可读字段转为人可读表述上下文注入tagsDescription把数组转为自然语言比[electronics, wireless]更利于AI理解。更重要的是AiProductSummarySchema和productToAiJson()的输出结构严格对应——summary字段对应shortDescription的语义keyFeatures对应tagsDescription的提炼。这样当AI返回JSON时Zod校验能100%确认结构而不会出现“AI返回了features字段但前端代码期待keyFeatures”这种运行时错误。我在电商项目里实测用这套模式后AI摘要的字段匹配错误率从12%降到0%因为类型约束在编译期就锁死了契约。3.3 AI Prompt工程用Next.js Server Actions构建可追踪的Prompt流水线Prompt不能写在字符串里必须像API一样有版本、有日志、有熔断。我们用Next.js的Server Actions实现// app/actions/generateProductSummary.ts use server; import { revalidatePath } from next/cache; import { z } from zod; import { prisma } from /lib/db; import { productToAiJson, AiProductSummarySchema } from /lib/db/product; import { generateText } from ai; // 使用Vercel AI SDK import { openai } from ai-sdk/openai; // Prompt模板用函数生成支持动态插入业务规则 const buildPrompt (product: ReturnTypetypeof productToAiJson) 你是一名资深电商选品经理请为以下商品生成专业摘要 - 商品名称${product.name} - 价格${product.formattedPrice} - 类别${product.categoryZh} - 标签${product.tagsDescription} - 简述${product.shortDescription} 要求 1. 摘要长度严格控制在150字内 2. 必须包含价格优势分析对比同类均价 3. 输出JSON格式字段summary字符串、keyFeatures字符串数组最多3项、recommendationbuy/wait/skip ; export async function generateProductSummary(productId: string) { try { const product await prisma.product.findUniqueOrThrow({ where: { id: productId }, }); const aiInput productToAiJson(product); const prompt buildPrompt(aiInput); const result await generateText({ model: openai(gpt-4o), prompt, system: 你只输出JSON不加任何解释, temperature: 0.3, // 降低随机性提升稳定性 }); // 关键用Zod校验AI输出 const parsed AiProductSummarySchema.safeParse(JSON.parse(result.text)); if (!parsed.success) { throw new Error(AI输出校验失败: ${parsed.error}); } // 存储AI结果到数据库供后续分析 await prisma.aiGeneratedSummary.create({ data: { productId, summary: parsed.data.summary, keyFeatures: parsed.data.keyFeatures, recommendation: parsed.data.recommendation, model: gpt-4o, promptHash: createHash(prompt), // 记录Prompt指纹 }, }); // 失败时触发缓存失效强制重新生成 revalidatePath(/product/${productId}); return { success: true, data: parsed.data }; } catch (error) { console.error(AI生成失败, error); // 降级返回规则引擎结果 return { success: false, data: { summary: ${product.name}售价${aiInput.formattedPrice}属于${aiInput.categoryZh}品类, keyFeatures: [价格实惠, 品类齐全], recommendation: buy as const, } }; } }这个Action体现了三个核心实践Prompt即函数buildPrompt()动态生成可插入实时库存、竞品价格等上下文而非静态字符串校验即契约Zod Schema强制AI输出符合预期失败立即抛错不靠人工肉眼检查JSON日志即资产每次调用都存promptHash和model后续可分析“哪个Prompt版本在gpt-4o上准确率最高”实现Prompt A/B测试。我在SaaS项目里把所有AI Action都加上console.time()和console.timeEnd()监控平均耗时。发现当Prompt超过300字时gpt-4o响应时间陡增于是强制buildPrompt()做长度截断并在日志里记录“Prompt Truncated: 320 - 280 chars”。这种数据驱动的优化只有把Prompt当作可监控的服务才能做到。4. 实操全流程从Excel上传到AI摘要上线的7步闭环4.1 步骤1创建清洗API路由支持多文件上传Next.js的App Router让文件上传变得极简。我们不用第三方库纯用原生Web API// app/api/clean/orders/route.ts import { NextRequest, NextResponse } from next/server; import * as XLSX from xlsx; import { OrderImportRowSchema } from /lib/clean/orderImportSchema; export async function POST(request: NextRequest) { try { const formData await request.formData(); const file formData.get(file) as File | null; if (!file) { return NextResponse.json({ error: No file uploaded }, { status: 400 }); } // 读取Excel二进制 const arrayBuffer await file.arrayBuffer(); const workbook XLSX.read(arrayBuffer, { type: array }); const sheetName workbook.SheetNames[0]; const worksheet workbook.Sheets[sheetName]; const jsonData XLSX.utils.sheet_to_json(worksheet, { header: 1 }); // 跳过表头清洗每一行 const cleanedRows: any[] []; const errors: { row: number; field: string; message: string }[] []; for (let i 1; i jsonData.length; i) { // i1跳过表头 const row jsonData[i]; const result OrderImportRowSchema.safeParse({ phone: row[0], address: row[1], skus: row[2], }); if (result.success) { cleanedRows.push(result.data); } else { errors.push({ row: i 1, field: result.error.issues[0].path[0] as string, message: result.error.issues[0].message, }); } } return NextResponse.json({ success: true, data: cleanedRows, errors, rowCount: jsonData.length - 1, }); } catch (error) { return NextResponse.json( { error: Failed to process file }, { status: 500 } ); } }关键细节不依赖Node.js fsarrayBuffer直接在Edge Runtime运行Vercel上零配置错误定位精准返回row: 5, field: phone, message: Invalid phone format前端可直接高亮第5行A列表头自动跳过jsonData是二维数组i1开始遍历避免硬编码列索引。我在金融项目里扩展了此逻辑支持CSV、JSONL、甚至ZIP压缩包用JSZip解压后遍历文件因为客户上传格式永远比文档写的多一种。4.2 步骤2用Server Component消费清洗结果触发ORM写入清洗API返回JSON后Server Component直接处理避免客户端JavaScript解析// app/upload/page.tsx use client; import { useState } from react; import { uploadOrders } from /app/actions/uploadOrders; export default function UploadPage() { const [file, setFile] useStateFile | null(null); const [isUploading, setIsUploading] useState(false); const [result, setResult] useState{ success: boolean; message: string } | null(null); const handleSubmit async (e: React.FormEvent) { e.preventDefault(); if (!file) return; setIsUploading(true); try { const formData new FormData(); formData.append(file, file); const response await fetch(/api/clean/orders, { method: POST, body: formData, }); const data await response.json(); if (data.success data.data.length 0) { // 直接调用Server Action写入数据库 const writeResult await uploadOrders(data.data); setResult({ success: true, message: 成功导入${writeResult.count}条订单 }); } else { setResult({ success: false, message: 清洗失败${data.errors.length}处错误 }); } } catch (error) { setResult({ success: false, message: 上传失败请重试 }); } finally { setIsUploading(false); } }; return ( form onSubmit{handleSubmit} input typefile accept.xlsx,.xls onChange{(e) setFile(e.target.files?.[0] || null)} / button typesubmit disabled{isUploading} {isUploading ? 上传中... : 开始清洗} /button {result ( div className{result.success ? text-green-600 : text-red-600} {result.message} /div )} /form ); }注意uploadOrders是Server Action它接收清洗后的CleanedOrderRow[]用Prisma批量写入// app/actions/uploadOrders.ts use server; import { prisma } from /lib/db; import { CleanedOrderRow } from /lib/clean/orderImportSchema; export async function uploadOrders(rows: CleanedOrderRow[]) { // 批量创建避免N1查询 const created await prisma.order.createMany({ data: rows.map(row ({ phone: row.phone, fullAddress: row.address, skus: row.skus, // 其他字段... })), skipDuplicates: true, // 防止重复导入 }); return { count: created.count }; }这里的关键是Server Component和Server Action的协作前端只负责UI和文件读取清洗在API路由完成写入在Server Action完成全程不暴露数据库连接也不让敏感逻辑跑到客户端。4.3 步骤3设计AI触发机制支持手动与自动两种模式AI生成不能只靠按钮点击要融入业务流。我们设计三种触发方式手动触发订单详情页的“生成摘要”按钮调用generateProductSummary自动触发订单创建后用Prisma Middleware监听create事件自动调用AI定时触发用Cron Job如Vercel Cron每天凌晨扫描updatedAt超过7天的产品批量生成新摘要。Prisma Middleware示例// lib/db/middleware.ts import { PrismaClient } from prisma/client; import { generateProductSummary } from /app/actions/generateProductSummary; export function setupMiddleware(prisma: PrismaClient) { prisma.$use(async (params, next) { if (params.model Product params.action create) { const result await next(params); // 异步触发AI不阻塞主流程 generateProductSummary(result.id).catch(console.error); return result; } return next(params); }); }提示Middleware里调用Server Action必须用unstable_cache或单独的异步队列否则可能超时。我在生产环境用Redis Queue做解耦但小项目直接.catch(console.error)足够因为AI失败不影响主业务。4.4 步骤4构建AI结果缓存与失效策略AI调用昂贵且慢必须缓存。Next.js的cache()和revalidateTag是黄金组合// app/product/[id]/page.tsx import { getProductWithSummary } from /app/actions/getProductWithSummary; export default async function ProductPage({ params }: { params: { id: string } }) { const product await getProductWithSummary(params.id); return ( div h1{product.name}/h1 p{product.summary}/p {/* 其他内容 */} /div ); }// app/actions/getProductWithSummary.ts use server; import { cache } from react; import { prisma } from /lib/db; // 缓存1小时tag关联product:id export const getProductWithSummary cache( async (productId: string) { const product await prisma.product.findUniqueOrThrow({ where: { id: productId }, include: { aiGeneratedSummary: true }, }); return { ...product, summary: product.aiGeneratedSummary?.summary || 暂无AI摘要, keyFeatures: product.aiGeneratedSummary?.keyFeatures || [], }; }, [product:${productId}], { revalidate: 3600 } // 1小时 ); // 当AI生成新结果时失效缓存 export async function invalidateProductCache(productId: string) { // Vercel环境下用revalidateTag if (process.env.VERCEL) { await fetch(https://your-domain.com/api/revalidate?tagproduct:${productId}, { headers: { Authorization: Bearer ${process.env.REVALIDATE_TOKEN} } }); } }注意cache()的key必须包含动态参数如productId否则所有产品共用一个缓存。revalidateTag比revalidatePath更精准只刷新特定产品不波及其他页面。4.5 步骤5实现Prompt版本管理与A/B测试Prompt不是写完就扔要像代码一样管理。我们在lib/prompts/下按业务域组织lib/prompts/ ├── product/ │ ├── v1.ts // 初始版简单描述 │ ├── v2.ts // 加入竞品对比要求 │ └── current.ts // 导出当前生效版本 └── customer/ ├── v1.ts └── current.tscurrent.ts内容// lib/prompts/product/current.ts export { default as Prompt } from ./v2.ts; export const VERSION v2;Server Action里动态导入// app/actions/generateProductSummary.ts import { Prompt, VERSION } from /lib/prompts/product/current; // 在日志中记录版本 console.log(Using Prompt version: ${VERSION});A/B测试更简单用Query参数分流// app/actions/generateProductSummary.ts export async function generateProductSummary(productId: string, options?: { promptVersion?: v1 | v2 }) { const version options?.promptVersion || v2; const PromptModule await import(/lib/prompts/product/${version}); const prompt PromptModule.Prompt(...); // 后续逻辑 }前端按钮加版本选择后台日志统计各版本成功率两周后停用v1——这才是工程化。4.6 步骤6构建AI输出质量监控看板没有监控的AI是定时炸弹。我们在app/admin/ai-monitoring/page.tsx里展示指标计算方式健康阈值成功率成功次数 / 总调用次数≥95%平均耗时SUM(耗时)/COUNT≤3s校验失败率Zod校验失败次数 / 总调用次数≤1%降级率降级次数 / 总调用次数≤0.5%数据来源aiGeneratedSummary表的createdAt、model、promptHash字段配合Vercel日志的duration_ms。用Prisma聚合查询// lib/db/aiMonitoring.ts export async function getAiMetrics() { const now new Date(); const weekAgo new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); return prisma.aiGeneratedSummary.groupBy({ by: [model], where: { createdAt: { gte: weekAgo } }, _count: { _all: true }, _sum: { durationMs: true }, }); }实操心得校验失败率突然升高90%是因为Prompt改了但Zod Schema没同步降级率升高通常是OpenAI API限频需检查Rate Limit Header。这些信号比“AI不准”具体得多。4.7 步骤7部署与环境适配让本地开发和生产一致最后一步是环境一致性。我们在.env.local定义NEXT_PUBLIC_AI_MODELgpt-4o AI_TEMPERATURE0.3 PROMPT_VERSIONv2但生产环境用Vercel环境变量覆盖。关键技巧所有AI相关配置必须可运行时切换不能编译时固化// lib/ai/config.ts export const AI_CONFIG { model: process.env.NEXT_PUBLIC_AI_MODEL || gpt-4o, temperature: parseFloat(process.env.AI_TEMPERATURE || 0.3), maxRetries: parseInt(process.env.AI_MAX_RETRIES || 2), };这样Vercel上修改环境变量无需重新部署AI行为立即变化。我在灰度发布时先切10%流量到新Prompt版本监控指标达标后再全量——这才是可控的AI交付。5. 常见问题与排查技巧实录那些文档里不会写的坑5.1 清洗环节Excel日期变成数字而不是字符串现象上传Excelcreated_at列在sheet_to_json后变成44205这样的数字而不是2021-01-01。原因Excel存储日期为“自1900年1月1日起的天数”XLSX库默认不转换。解决在sheet_to_json时启用dateNF选项并用XLSX.utils.decode_dateconst jsonData XLSX.utils.sheet_to_json(worksheet, { header: 1, dateNF: yyyy-mm-dd, // 指定日期格式 }); // 或手动转换 const dateValue worksheet[A1].v; // 获取原始值 const jsDate XLSX.SSF.parse_date_code(dateValue); // 转为JS Date我的避坑技巧在清洗Schema里对日期字段用z.number().transform(n new Date(XLSX.SSF.parse_date_code(n)))把数字日期转为Date对象再由Prisma自动转为ISO字符串。5.2 ORM环节Prisma事务中调用Server Action导致连接泄漏现象批量导入订单时用prisma.$transaction包裹内部调用generateProductSummaryVercel报错Connection pool exhausted。原因Server Action在独立HTTP上下文中执行会新建数据库连接而事务连接未释放。解决绝不允许在事务内调用Server Action。改为事务提交后用setTimeout或消息队列异步触发AI// 正确做法事务外触发 await prisma.$transaction([ prisma.order.createMany({ data: rows }), prisma.product.updateMany({ /* ... */ }) ]); // 事务提交后异步调用AI setTimeout(() { rows.forEach(row generateProductSummary(row.productId).catch(console.error)); }, 0);实操心得Prisma事务必须是“纯数据库操作”任何网络请求、文件IO、AI调用都必须剥离。我在支付系统里吃过亏把微信回调验证放进事务结果回调超时导致事务锁表。5.3 AI环节Prompt太长OpenAI返回413 Payload Too Large现象产品描述很长buildPrompt()生成的字符串超过32K字符OpenAI API返回413。原因gpt-4o上下文窗口虽大但Prompt本身有长度限制且长Prompt显著增加token消耗。解决三层截断策略前端截断上传时用file.size 5MB拦截清洗截断description: z.string().max(1000)Prompt内截断product.shortDescription.substring(0, 500)。更高级的做法用LLM做摘要预处理——先用gpt-3.5-turbo把长描述压缩到200字再喂给gpt-4o生成摘要。成本更低效果更好。5.4 部署环节Vercel Edge Function里XLSX库报错“require is not defined”现象本地npm run dev正常Vercel部署后XLSX.read()报错ReferenceError: require is not defined。原因XLSX的Node.js版依赖fs模块而Edge Runtime无Node.js API。解决必须用Browser版XLSX并确保打包正确# 安装Browser版 npm install xlsx --no-save # 在代码中显式指定 import * as XLSX from xlsx/xlsx.mjs; // 注意.mjs后缀我的血泪经验Vercel部署前务必在vercel.json里加functions: { api/**: { runtime: edge } }并用vercel dev本地模拟Edge环境测试。曾经一个Excel功能上线后才发现报错回滚花了40分钟。5.5 监控环节AI成功率下降但日志里全是“success”现象业务反馈AI摘要质量变差但监控看板显示成功率99%Zod校验全过。原因Zod只校验JSON结构不校验语义质量。比如summary字段是150字字符串但内容全是“很好”、“不错”这种无意义词。解决增加语义质量检查层// lib/ai/qualityCheck.ts export function checkSummaryQuality(summary: string) { const wordCount summary.split(/\s/).length; const uniqueWords new Set(summary.toLowerCase().split(/\W/)).size; // 低质量特征字数少、重复词多、无标点 if (wordCount 30 || uniqueWords wordCount * 0.6 || !summary.includes(。)) { return { quality: low, reason: 内容空洞 }; } return { quality: high }; }然后在AI Action