ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Next.js五周极速构建AI产品:从流式输出到部署的实战复盘

Next.js五周极速构建AI产品:从流式输出到部署的实战复盘

入职第一周的周一早上,我还没来得及把工位上的 27 寸显示器调成顺手的角度,就被拉进会议室。领导开门见山:五周后要上线一个能注册、能演示、能跑完核心 AI 流程的产品,技术栈就用 Next.js。会议室安静了两秒,我的第一反应是——五周,够做什么?但既然路已经定了,后面就是怎么把坑一个个踩平的问题。对一个刚入职 AI 公司、还没完全熟悉业务的人来说,这个时限足够把一个想法从空目录推到公网,也足够把几个不起眼的坑踩得明明白白。这篇内容不是「Hello World」教程,而是我走完这 5 周后沉淀的实战复盘:选型理由、目录结构、流式输出、鉴权落库、部署排错、上线兜底,以及回头看时最想删掉重做的决定。如果你也要赶一个极短交付周期的 AI 产品,这份记录应该能帮你少熬几晚。

1. 入职第一天的"倒计时通知":为什么 5 周内必须选 Next.js 这条路线

1.1 五周到底意味着什么:先接受"做不了大系统"这个事实

五周在互联网公司通常够一个迭代周期,但往往做不完一个完整产品。想在这段时间上线 AI 产品,首先要接受的不是「我们能做得多好」,而是「哪些东西这五周绝对不能碰」。我当时列过一张白名单/黑名单——白名单是:核心 AI 流程能跑通、用户可以完成一次完整使用、数据能留住;黑名单是:多租户、精细化权限、跨端同步、管理后台。哪怕产品看起来少了很多功能,但只要保住核心闭环,它就是一个可以上线、可以收集反馈、可以迭代的产品。

我见过很多项目挂在「什么都想做」上:页面还没写完,就开始设计联盟广告位、积分系统、邀请裂变。五周版本的准则应该是:抛开体验上的锦上添花,先保证用户从进入页面到拿到 AI 结果这一条主线不断掉。后期加功能是加法,前期砍需求是减法,赶进度时减法更值钱。

1.2 为什么前后端分离在这种节奏下反而拖后腿

如果是常规产品,我大概率会选「前端框架 + 后端服务」的经典组合。分工清晰、职责明确,也更符合大多数团队的日常习惯。但这五周里我们只有两三个开发,如果强行拆成前后端两套工程,等于把沟通成本、联调成本、部署成本全部翻倍。第一周我就能预见到:前端改个字段要等后端接口更新,后端发现请求格式不对要回头对前端文档,光是这两个来回就能吃掉一半的排期。

选择 Next.js 的核心不是因为它比别的框架「更高级」,而是因为它把前端页面、API 路由、服务端逻辑放在同一个项目里。AI 产品最重的逻辑其实就是「把用户请求转发给模型、再把模型结果拿回来展示」,这个中间层完全可以用 Route Handler 或 Server Action 实现。省下的不只是代码量,更是环境切换的心智负担——你不需要在脑子里维护两套工程的两份上下文。

1.3 团队共识:不炫技,只补缺口

这里必须提一句入职时容易踩的坑:新人总想把公司技术栈换成自己更熟的,或者为了某个理由选冷门新框架。我们当时定的原则是「沿用团队已有共识,只补必要的缺口」。比如团队已经在用某个组件库,就直接拿过来用;数据库已经有了现成的连接配置,就直接复用;ORM、代码规范、CI 流程全部按公司仓库里的现有约定来。技术选型的讨论只围绕一个核心问题:这是不是能让 5 周交付更稳的选择?如果不是,就放下表现欲。

2. 前两周只干一件事:把最小闭环的骨架立起来

2.1 白纸拆解需求:把"做个产品"变成"三条链路"

开工第一周,我没有急着敲代码,而是找产品和设计用白纸把用户行为画了一遍。最后抽象出来三条链路:注册或匿名进入、发起 AI 任务并看到结果、基于历史记录找回上次对话。每个链路最多两张页面:落地页/对话页、历史记录页。这个抽象过程很重要,因为它能让所有人在同一份语义下讨论「缺什么」,而不是靠脑补。

拿「用户注册」举例。如果做完整登录流程,员工账号打通、密码找回、验证码短信,这些一旦展开就不是一周能做完的。于是产品同意砍到「匿名用户 + 可选保存」,代价是用户换设备后看不到历史记录,换来的是开发量直接少一个量级。这类取舍在 5 周项目里每天都在发生,关键是每次取舍都要让数据或用户调研来背书,而不是拍脑袋。

2.2 App Router 目录结构的一次成型

项目脚手架我用了 Next.js 的 App Router。第一次规划目录时我就提醒自己:目录结构就是给未来三周的自己留的地图,混乱意味着迷路。最终长这样:

app/ layout.tsx page.tsx chat/ page.tsx api/ chat/route.ts history/route.ts components/ chat-input.tsx message-list.tsx lib/ db.ts llm.ts auth.ts prompts.ts types/ message.ts

components 只放纯 UI 组件,不带业务逻辑;lib 下放数据库连接、模型网关、鉴权、提示词管理等服务;types 统一约束前后端传参。这样做的收益在第三周体现得很明显:当需要改动模型调用逻辑时,我只需要打开 lib/llm.ts,其余页面代码一点不用动。

2.3 用 Route Handler 当"中间人":为什么要包一层

有人会问:Next.js 页面能直接调用模型接口,为什么还要多写一个 API 路由?直接调确实能跑,但有几个问题绕不过去:模型服务的密钥不能暴露在浏览器里;前端直连会有跨域和网络稳定性问题;缺少统一日志的话,线上出故障时你根本不知道是前端问题还是模型问题。所以我在中间加了一层 /api/chat 路由,由它负责拼装请求、调用模型、流式转发结果。

这层封装看似多了几行代码,但实际上把安全、日志、限流、降级都收拢到了一个地方。后面第四周完善监控时,我用同样思路在 lib/llm.ts 里加了耗时统计和错误上报,日志里能看到每次请求的耗时分布、错误码分布,排查起来比翻浏览器控制台高效得多。

3. 核心链路从能跑到好用:流式输出、权限与数据落库的实战细节

3.1 流式输出:从"转圈圈"到"逐字蹦出来"

AI 产品的体验好坏,很大程度取决于「第一个字出现的速度」。如果用户提交后只能看到加载动画转十几秒,心理上会觉得服务挂了。所以第一版我就决定做流式输出:模型每生成一段文字,就立刻推到前端。

在 Next.js 里实现流式输出,关键路由要返回一个 ReadableStream,而不是直接返回完整 JSON。基本的伪代码如下:

// app/api/chat/route.ts export const maxDuration = 60; export async function POST(req: Request) { const { messages } = await req.json(); const upstream = await fetch(MODEL_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` }, body: JSON.stringify({ model: MODEL_NAME, messages, stream: true, }), }); if (!upstream.ok || !upstream.body) { return Response.json({ error: "upstream_error" }, { status: 502 }); } const stream = new ReadableStream({ async start(controller) { const reader = upstream.body.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; controller.enqueue(value); } } catch (err) { controller.error(err); } finally { controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream; charset=utf-8" }, }); }

前端用 fetch 读取响应流,按行解析事件流格式,把增量数据追加到 message 列表里。这里最容易踩的坑是:直接在路由里把完整结果包装成 JSON 返回,前端等所有 token 生成完才拿到数据,体验直接退化。另一个坑是 Node runtime 和 Edge runtime 对 stream 的处理有差别,我调试时发现 Edge 环境下部分依赖不可用,最后统一把这条路由跑在 Node runtime 上,并显式声明了 runtime。

提示:凡是做 AI 流式输出,建议先在浏览器 Network 面板里确认响应类型确实是事件流,而不是一堆 JSON 一次性到达。这一步能省掉大量「为什么界面不逐字刷新」的排查时间。

3.2 用户鉴权:不加登录页也能完成闭环

为了不把五周耗在登录系统上,我们第一版用了「匿名身份」:用户首次访问时,服务端生成一个随机 ID 写入 HttpOnly Cookie,后续所有请求都带这个 ID;需要保存历史记录时,直接用这个 ID 作为用户维度的外键。登录按钮先做成「敬请期待」状态。

这个方案在安全和功能上都不算完整,但它保证了最核心的体验——用户不用注册也能把产品用起来。关键是 Cookie 的属性不能省:HttpOnly、SameSite=Lax、Secure(生产环境),避免被脚本偷走。后来产品数据验证有价值后,再逐步接邮箱登录,老用户数据可以通过 Cookie 里的匿名 ID 做合并,迁移路径是平滑的。

3.3 数据表只要三张就能撑住第一版

数据库我们用了团队已有的关系型数据库,没额外引入新组件。第一版只建了三张表:

CREATE TABLE sessions ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, title TEXT, created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now() ); CREATE TABLE messages ( id BIGSERIAL PRIMARY KEY, session_id VARCHAR(36) NOT NULL REFERENCES sessions(id), role VARCHAR(16) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT now() ); CREATE TABLE feedbacks ( id BIGSERIAL PRIMARY KEY, message_id BIGINT REFERENCES messages(id), rating INT, content TEXT, created_at TIMESTAMP DEFAULT now() );

sessions 存会话,messages 存对话内容,feedbacks 存用户反馈。外键和索引该加的加上,比如 sessions.user_id、messages.session_id。第三周我一度想加一张「用户表」,后来收住了——匿名阶段 userId 就是随机字符串,没必要为了好看做过度设计。数据库设计最重要的不是表多,而是改字段的成本低。第一版字段越少,第三周改起来越轻松。

3.4 部署那晚的"滑铁卢":超时、体积和冷启动

上线前的部署才是真正的战场。我们原以为推到 Serverless 平台就是几分钟的事,结果连续遇到三个问题。第一个是路由默认有请求体积限制,用户上传大段文本时直接 413;解决方法是调整请求体上限,并把超过阈值的输入在前端预先拦截。第二个是部分平台对单个函数执行时长有限制,模型响应慢时容易超时;我给对话路由声明了最大执行时长 60 秒,并通过流式响应让函数持续输出,尽量避免被判定为超时。

第三个是冷启动。Serverless 函数在长时间没有请求后,第一次唤起可能要好几秒,用户恰好是第一个来的人就会被劝退。我们的应对很朴素:持续保持一个低频率健康检查请求来暖函数,同时在客户端加了一个「正在连接模型服务器」的过渡态,把冷启动期间的空档变得可接受。上线后观察了几天,留有健康检查的版本首次请求耗时比裸奔版本稳定很多。

4. 和 AI 能力对接时最容易翻车的四个隐蔽细节

4.1 token 预算:不是把整个聊天记录全塞进去

和 AI 能力对接时,最隐蔽的问题不是效果不好,而是对话一长就开始「断片」。原因很简单:模型输入有 token 上限,把整段历史会话全塞进上下文,既不经济也不稳定。我们的方案是对消息数组做一次「窗口化」:保住系统提示词、最近几轮对话,把更早的内容截断甚至用摘要替换。具体轮数根据产品形态动态调,实测中我们对常见对话保留最近 6 到 10 条效果比较好。

这里还涉及另一个容易被忽略的成本问题:token 是花真金白银买的。不做预算控制、不做长度限制,一个重度用户就能把一个账号的配额刷穿。我们后来加了两个硬性指标:单次请求最大输入字符数、单会话每日请求上限。它们看似不是产品功能,却是 AI 产品能不能活过第一周的关键。

4.2 模型接口不稳定:降级是必须的,不是可选项

我在第二周之前都没意识到,模型接口的故障率和普通后端接口差很多。高峰期会限流,偶尔返回 5xx,甚至可能出现网络层超时。如果代码里不做处理,一次上游抖动就会变成一屏报错。我们做的降级有三层:第一层是超时重试,对偶发性错误最多重试两次,用指数退避而不是立刻重试;第二层是降级模型,主模型失败时切换备用模型;第三层是给用户兜底文案,明确提示「当前服务繁忙,请稍后再试」,而不是输出一段不知所云的 JSON。

很多人会觉得做这些「很麻烦」,但这恰恰是 AI 工程和普通业务接口最大的区别:上游供应商的可用性不可能做到 100%,你必须把「上游不可用」当成一个正常业务分支来设计。上线后的监控数据里,大约 1% 的请求经历过至少一次重试,这个比例看起来不大,但如果没有重试,这 1% 的用户就会在群里抱怨产品不可用。

4.3 别让 prompt 注入变成"内鬼"

AI 产品里用户输入的内容会被拼进模型上下文,这本身就给了恶意输入可乘之机。有人会故意在输入里附上「忽略所有之前的指令」试图撬系统提示词,有人会把超长文本灌进来刷爆 token,还有人会利用错误信息里的日志内容套取敏感配置。这些场景,我第一版就占全了。

能落地的防线大概是这么几条:系统提示词和用户内容用不可猜测的分隔符隔开,并明确告诉模型用户内容是待处理数据而不是指令;请求前做长度、频率、内容格式三重校验,异常输入直接拒绝,不让它进上下文;服务端对单 IP/匿名 ID 做限流,超出阈值的请求返回 429。别指望模型自己能识别注入,能在源头拦截的绝不放进模型。

4.4 提示词模板和参数的后台化:让产品自己改文案

上线前产品同学问了我一句:现在改动一段引导文案,是不是又要找你?这问得我后背发凉。如果提示词和各类文案都硬编码在前端代码里,任何一次文案调整都要走代码变更、构建、上线流程,三分钟的事被放大成半天。于是我在第三周做了一次小重构:把所有提示词模板和模型参数从代码里抽出来,放到独立配置里,产品可以自行调整后立即生效。

这个调整带来的连锁收益比我想象中大:运营可以针对不同渠道写不同开场白,测试可以并行尝试多套提示词,而开发完全不用参与。代价是提示词版本需要留痕,我们简单地用配置文件的修改时间和备注来管理,够用就好。给提示词留好版本记录,是 AI 产品后期迭代里最省心的一笔投入。

5. 离上线还差最后一步:验收清单、灰度策略和监控兜底

5.1 用"最蠢用户"的心态过十遍核心链路

上线前一天,我们把核心链路当成黑盒测试,角色扮演「最没耐心的用户」。我会故意什么都不输入直接点提交、粘贴一页乱码、狂点两次按钮、在生成到一半时刷新页面、连续提交十个请求……这些场景看着刻意,但真实用户什么都干得出来。

这一轮测试抓到了不少真问题:空提交时提示文案不友好,按钮连点会产生重复请求,刷新后流式状态丢失……我们用一个「请求状态机」把整个交互收敛成:idle -> connecting -> streaming -> done -> error。任何异常输入都只会落在这个状态机里的某个状态上,界面再也不会出现「既没加载完又没报错」的尴尬局面。上线前的黑盒测试永远不嫌多,每多抓到一个 bug,就是少一个用户的流失理由。

5.2 灰度发布:先给自己人用,再放给真实用户

技术同学很容易犯一个错:测试通过就赶紧全量开放。但 5 周里我们没有足够的真实流量验证,冒然全量上线,一旦数据库被奇怪的并发打爆,连回滚的机会都有限。我们采用的白名单灰度:上线第一天只有内部邮箱域名的账户能访问真实功能;第二天放开到邀请注册;确认各种指标平稳后,第三天左右才公开。

灰度阶段我会实时盯日志,看有没有大面积报错、有没有异常慢的请求、数据库连接数是否健康。这三天里发现的几个小问题——比如某条历史消息渲染异常、某个移动端样式错位——都是在只影响内部少量用户时就被修掉的,没有造成公开事故。灰度不是流程冗余,它是给「五周没睡好的人」留的缓冲带。

5.3 埋点最少要配齐三件事

全量上线前,产品问我埋点方案准备怎么做。我说第一版不搞复杂的数据中台,只确认三件事:多少人进入对话页、多少次请求成功、多少人提交反馈。三个指标分别对应转化漏斗的三层,足够判断产品方向是否值得继续投入。

实现上就是在路由和前端事件里顺手打点,日志里带着 sessionId、userId、耗时、错误码。关键是这些日志必须能被快速检索,所以格式要固定。后来复盘去看数据时,我们能清楚说出「进入对话页的用户有 70% 真的发起了请求,其中 60% 完整看到了流式结果,5% 提交了反馈」——相比拍脑袋,这就是上线第一周最宝贵的产出。

5.4 值班怎么值才能睡得着

上线后的第三天凌晨,我被测试群里的告警吵醒过。不是系统挂了,而是某个用户的请求耗时异常飙升到 90 秒。虽然最终没造成事故,但那一晚让我意识到:没有告警就没有安全感。我们的做法很朴素:异常日志接入群机器人,请求错误率达到阈值就触发提醒;另配一个简单的可用性检查,每五分钟探测一次首页,挂了就通知全组。

值班手册再简单也要写:登录哪个后台看日志、重启哪条流水线、回滚到哪个版本、怎么联系模型服务供应商。真出事的时候脑子是一片空白的,文档比记忆可靠。上线不是终点,而是另一个「持续盯着」阶段的开始——不需要很复杂的系统,关键是让最坏情况发生时有路径可走。

6. 五周结束后的复盘:如果再赶一次进度,我会坚持什么、放弃什么

6.1 技术选型:不选最好的,选 5 周内不会崩的

现在回头看,当时最正确的决定反而是那些看起来很「保守」的:沿用团队熟悉的框架、用已有数据库、少引新依赖。技术选型在短周期项目里的目标不是「最有前景」,而是「预计五周内不会把我们自己坑死」。一个很新的框架也许未来可期,但如果它某个版本升级导致构建失败、网上连文档都搜不到几条,那你就是那个替新框架趟坑的人。

6.2 先跑通最小闭环,再谈"优化":任何优化都要有数据

我第一周其实也有过冲动,想先写一个完美的流式渲染组件、做一套可复用的抽象层。好在被自己摁住了:先把「输入-请求-输出」这条笨链路跑通,再谈复用和抽象。前两周代码写得确实不精致,但正是这个能跑的最小骨架,让第三周的优化有了对照物。没有数据支撑的优化叫自嗨,有了数据支撑的优化叫迭代。

6.3 数据模型可以小,但不能天天改

五周项目里,我最后悔的是第四周差点为了一个「未来需求」重构 sessions 表。这种事在赶进度时尤其危险:因为时间紧,大家容易觉得「现在顺手改了总比以后改便宜」,但任何改动都会牵扯到代码、历史数据、联调。正确做法是:把可能的变化记进待办清单,而不是立刻改表。等数据来了再说,很多以为会发生的需求其实根本不会发生。

6.4 接口约定和讨论记录,是团队协作里最便宜的投资

因为工期紧,第一周我们并没有花太多时间写接口文档,结果到了第二周就吃了亏:前端以为的字段名和后端实际返回的不一致,两边各花了一小时排查。后来我们定了一个很轻的约定:每次联调前先在群里同步一份「本次接口字段示例」,改字段必须说明原因。就是这一条,后面三周的联调顺畅了许多。短周期项目的文档不用长,能减少误解就够了。

6.5 把赶进度变成节奏感:每天都有可演示的增量

最后想说的是心态。五周的节奏如果安排成「前三周默默憋大招,最后一周疯狂冲刺」,大概率会在最后时刻翻车。我们的做法是每天都保留一个「可演示的中间状态」:第一天是空项目跑起首页,第二天是假数据页面,第四天是真实模型调用返回一行文本……这样既方便随时拉人看效果拿反馈,也让自己在长达五周的高压下建立起持续交付的正反馈。赶进度最怕的不是工作量大,而是看不到进展带来的焦虑。每天在一个真实可见的产物上推进一点,比任何一个炫酷的技术决策都更能保证项目活到上线那一天。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进