做技术选型调研这件事,最怕的就是拿着一堆框架对比文档,比到最后发现全是纸上谈兵。最近因为要推进一个 AI 产品从原型走向正式版本,我对 TypeScript、React、Next.js 这套组合做了一轮完整的调研和实测。这篇文章并不是要给你一个"选它就对了"的结论,而是把我们踩过的坑、验证过的方案、以及底层逻辑梳理清楚。如果你正在评估 AI 产品的技术栈,或者刚准备用 Next.js 接大模型接口,这篇内容应该能帮你省掉不少弯路。
先说一个基本判断:以 TypeScript 为语言基础,React 负责交互界面,Next.js 作为应用框架,确实是当前做 AI 产品最稳妥的组合之一。但这个"稳妥"是有前提条件的。下面的内容会按调研的逻辑展开,从选型考量到具体实现,再到真实环境下的问题排查,尽量做到不空谈结论,每一块都有对应的实操参考。
1. 为什么 AI 产品值得单独做一次技术栈评估
AI 产品的前端研发,和传统 Web 应用有明显差异。如果只是把大模型接口当作普通 HTTP 接口来对接,后面一定会遇到性能、体验、可维护性三方面的压力。这一节先讲清楚 AI 产品对技术栈的独特要求,这也是整个调研的出发点。
1.1 AI 产品与传统 Web 应用的本质差异
传统 Web 应用的核心是"数据展示和用户操作"。页面加载后,前端向后端请求数据,拿到 JSON 渲染到界面上,用户在表单里输入内容,提交后等待响应。整个交互模型是"请求-响应-渲染"的闭环,多数情况下一次请求的耗时在几百毫秒到几秒之间,用户的耐心阈值相对固定。
AI 产品则不同。它天然依赖大模型推理,而大模型生成内容的特点是长耗时、流式输出、不确定性强。用户输入一句话,后端可能要在几十秒甚至几分钟内持续吐出内容。这意味着前端必须处理流式数据、渲染部分完成的结果、管理中断和重试。如果拿传统的请求-响应模型硬套,用户会在白屏或 loading 状态里等很久,体验非常糟糕。
再加上 AI 产品往往需要在用户对话过程中维护多轮上下文,涉及历史消息的组织、token 消耗的预估、以及不同模型参数的切换。这些状态管理需求比传统 CRUD 应用复杂得多,一旦技术栈选型不当,代码会迅速膨胀到难以维护的地步。
1.2 这套组合到底解决了哪些真实问题
TypeScript、React、Next.js 组合的优势,并不在于"它们都是流行的技术",而在于它们各自恰好对应了 AI 产品研发中的致命痛点。
TypeScript 解决的是协议层面的信任问题。大模型接口的返回结构虽然遵循 JSON Schema,但实际返回的内容在类型上并不总是稳定。尤其是流式返回中,不同事件类型的数据结构差异很大。TypeScript 能让你在编译期就锁死数据结构,避免运行时才暴露字段拼写错误。对于多人协作的团队来说,类型定义本身就是一份可执行的接口文档。
React 解决的是"状态与视图同步"的复杂度问题。AI 对话界面中有大量中间态:正在生成、生成完毕、中断、错误、用户等待中。React 的声明式 UI 和单向数据流,让这些状态的切换在代码层面变得可预测。特别是大量 AI 界面组件需要频繁更新局部内容,比如逐字输出、工具调用的过程展示、引用来源的标注,React 的组件模型天然适合这种高频、局部、碎片化的更新场景。
Next.js 解决的是"服务端能力与前端工程化"的衔接问题。AI 产品不可能只靠纯静态页面存活,它需要 API 路由、服务端渲染、流式代理、鉴权逻辑。Next.js 把前后端放进同一个工程里,同时又保留了部署的灵活性。这对小团队和独立开发者尤其重要,不需要同时维护两个代码仓库、两套部署流程,能显著降低初始阶段的工程成本。
注意,我的建议是不要为了"追新技术"而选择这套组合。如果你的产品是纯工具型 AI 应用、不需要 SEO、没有服务端逻辑、部署环境受限,那么 React + Vite + 独立的 BFF 层也可以。技术栈是工具,适配场景才是目的。
1.3 目标场景与团队背景的匹配度分析
在决定引入这套技术栈之前,先对照一下自己的场景。我整理了一张评估表,建议在调研阶段就逐项打勾:
| 评估维度 | 适合采用本组合的信号 | 可能不适合的信号 |
|---|---|---|
| 产品形态 | 对话式 AI、AI 工作流、内容生成工具 | 纯前端 demo、无后端需求、离线工具 |
| 部署环境 | 需要 SSR/SEO、需要服务端 API、边缘部署 | 纯静态托管、内网受限环境 |
| 团队构成 | 前端+后端同组、Node.js 技术栈 | 以 Python/Java 为主、前后端分离强约束 |
| 交互复杂度 | 流式输出、多轮上下文、工具调用可视化 | 单次请求返回、无流式需求 |
| 迭代节奏 | 需要快速验证产品、频繁改交互 | 长周期交付、强规范约束 |
以我实测的情况来看,AI 产品的 MVP 阶段最怕的不是功能写不出来,而是改不动。产品经理今天说要加一个"停止生成"按钮,后天说要支持"重新生成",大后天又说要显示"思考过程"。如果状态管理和数据传输层设计得不够灵活,每次需求变更都要大面积改代码。React 组件化 + 自定义 Hook 抽取业务逻辑 + TypeScript 统一类型,这套组合恰恰能兜住这种高频变更的场景。
2. TypeScript 在 AI 产品中的实战定位
这一节深入 TypeScript 在 AI 产品里具体起到的作用。很多人对 TypeScript 的理解还停留在"给 JavaScript 加类型",但在 AI 产品里,它的价值远不止于此。
2.1 类型安全如何确保大模型接口的稳定对接
大模型服务的接口,特别是一些聚合平台的接口,返回结构往往比普通业务接口复杂得多。以常见的 OpenAI 兼容接口为例,非流式响应的结构包含 id、object、created、model、choices、usage 等多个层级,而 choices 数组里又嵌套了 message、finish_reason 等字段。如果用人肉记忆去写这些字段,一次拼写错误可能要在运行期才能发现,AI 产品修复一次线上问题的时间成本非常高。
TypeScript 的做法是在编译期进行拦截。定义一个完整的类型,然后通过泛型把请求函数和响应类型绑定起来:
interface ChatCompletionResponse { id: string; object: string; created: number; model: string; choices: Array<{ index: number; message: { role: 'assistant' | 'user' | 'system'; content: string; tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string; }; }>; }; finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null; }>; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; } async function fetchChatCompletion(params: ChatCompletionParams): Promise<ChatCompletionResponse> { const response = await fetch('/api/chat', { method: 'POST', body: JSON.stringify(params), }); return response.json() as Promise<ChatCompletionResponse>; }这样写的好处是调用方的 IDE 自动补全会给出所有字段提示,联调阶段不用频繁翻接口文档。更重要的是,如果接口结构后续变化,比如新增了一个字段,编译器会明确标出哪些地方需要同步调整,避免出现"漏改一处,线上崩溃"的问题。
2.2 判别联合与流式事件解析的类型建模方案
流式响应是 AI 产品中类型建模的难点。SSE(Server-Sent Events)格式下,服务端会持续推送多个事件,每个事件的数据结构可能完全不同。常见的事件类型包括:
- 开始事件(包含生成的 message ID)
- 内容增量事件(包含文本片段)
- 工具调用事件(包含函数名和参数)
- 结束事件(包含完整响应和 token 消耗)
- 错误事件(包含错误码和描述)
面对这种情况,用单一的 interface 描述所有事件是不现实的。TypeScript 的判别联合(Discriminated Union)在这里能发挥关键作用:
type StreamEvent = | { type: 'start'; messageId: string; createdAt: number } | { type: 'delta'; delta: string; index: number } | { type: 'tool_call'; toolCallId: string; toolName: string; args: Record<string, unknown> } | { type: 'done'; finishReason: 'stop' | 'length'; usage?: TokenUsage } | { type: 'error'; code: string; message: string }; function handleStreamEvent(event: StreamEvent) { switch (event.type) { case 'delta': // 此时事件类型收窄为包含 delta 字段的类型 appendToMessage(event.delta); break; case 'tool_call': // 此时可以安全访问 toolName 和 args executeToolCall(event.toolName, JSON.parse(JSON.stringify(event.args))); break; // 其余分支同理 } }当 switch 语句配合判别联合使用时,TypeScript 能在每个分支里自动收窄类型。比如进入case 'delta'后,编译器知道这个事件一定包含delta字段,访问event.toolName会直接报告编译错误。这在处理复杂的 AI 流式数据时极其好用,相当于在写业务逻辑的同时让编译器帮你做了一层数据校验。
2.3 泛型工具类型在后端返回结构处理上的妙用
除了基础类型和联合类型,TypeScript 的泛型工具类型在处理 AI 接口数据时也有一些实用技巧。
比如Partial可以用来处理可选字段。很多大模型接口在非流式返回里,usage字段可能不存在(取决于是否开启统计)。如果直接定义一个usage: TokenUsage,那运行时就可能拿到undefined。用Partial<TokenUsage>或者usage?: TokenUsage就能表达这种不确定性。
再比如Pick和Omit,在封装不同的模型服务时很实用。如果你对接了两家大模型厂商,它们的部分字段相同、部分字段不同,可以用Pick提取共性字段定义公共类型,用Omit排除差异字段,在保持类型安全的同时避免写重复代码。
还有一个容易被忽视的工具类型是Readonly。AI 产品中的配置对象,比如模型参数、系统提示词,一旦初始化就不应该被修改。用Readonly包裹后,任何试图修改的操作都会被编译器拦截。这属于"小投入大回报"的类型设计,值得养成习惯。
实操心得:不要把 TypeScript 仅仅当作写类型注解的工具,它更像是你的代码助手。在 AI 产品开发里,类型定义先行的习惯会倒逼你去思考数据流,提前识别可能出现的边界情况。很多流式解析的 bug,其实在写类型的时候就能被预判到。
3. React 状态管理与 AI 交互场景的工程化实践
React 的分工很明确,它不负责数据获取,不负责路由,不负责 SSR,它只负责把状态映射成 UI。但在 AI 产品里,状态的复杂度会超出一般预期,这一节是实战中的核心内容。
3.1 流式输出场景下的状态设计模式
假设你在做一个 AI 写作助手,用户输入主题后,界面需要逐字展示生成的内容。用传统的useState存一个完整的字符串,然后每次收到 delta 就拼接,这是最直觉的做法,但很快会遇到性能问题:生成 1000 个字可能触发 1000 次渲染,而且每次渲染都要重新拼接整个字符串。
更好的方式是把流式输出拆成两个层面的状态:
- 会话层状态:存整个会话的结构,比如消息数组、当前状态(idle、streaming、error 等)。
- 进行中层状态:存当前正在生成的消息内容,用可变引用配合节流更新。
代码结构可以是这样的:
const [messages, setMessages] = useState<ChatMessage[]>([]); const streamingContent = useRef(''); // 收到 delta 时,先更新 ref function handleDelta(delta: string) { streamingContent.current += delta; // 用 requestAnimationFrame 节流,保证 UI 不卡顿 scheduleUpdate(); } function scheduleUpdate() { if (rafId.current) return; rafId.current = requestAnimationFrame(() => { const content = streamingContent.current; setMessages(prev => { const next = [...prev]; // 更新最后一条消息的内容 next[next.length - 1] = { ...next[next.length - 1], content }; return next; }); rafId.current = null; }); }useRef在这里的作用是存储频繁变化但不希望触发渲染的数据,而requestAnimationFrame保证了 UI 更新频率不超过帧率。实测下来,即使是几千字的生成内容,界面也能保持流畅滚动。
一个重要的状态决策点:什么时候把消息从"进行中"转为"已确认"?我的做法是:收到done事件时,把最后一条消息的状态标记为completed,然后在会话记录里持久化。这样能保证用户刷新页面后,已经生成完成的消息不会丢失,也不会出现半截内容。
3.2 全局状态库 vs 原生 Hook,AI 场景如何选型
AI 产品天然有全局状态的需求:多个组件共享会话上下文、当前模型配置、用户偏好。到底要不要引入 Redux、Zustand 这类状态库?我的实测结论是:如果只是简单场景,用原生 Hook + Context 就够了;如果会话状态涉及多个层级的组件、需要持久化、有复杂派生状态,引入轻量级状态库更划算。
选型的关键在"复杂度临界点"。我在调研阶段做了一个简单的对比:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| useState + useReducer | 组件内状态、局部交互 | 零依赖、简单直接 | 跨组件共享难 |
| Context + 自定义 Hook | 中等规模、主题切换、用户配置 | 模板代码少、易理解 | 重渲染控制需要手动优化 |
| Zustand | 复杂会话、高频更新、需要持久化 | 性能好、API 简洁 | 需要额外学习成本 |
以一个多角色 AI 助手为例,它涉及用户配置、会话历史、当前生成状态、模型参数、消息附带的引用来源等多个维度的状态。如果全用 Context,Provider 嵌套会很深,任何状态更新都可能引起无关组件重渲染。引入 Zustand 后,可以按状态切片组织 store,组件按需订阅,显著降低渲染开销。
import { create } from 'zustand'; interface ChatStore { messages: ChatMessage[]; isStreaming: boolean; currentModel: ModelConfig; appendMessage: (msg: ChatMessage) => void; updateMessageContent: (id: string, content: string) => void; setStreaming: (flag: boolean) => void; } export const useChatStore = create<ChatStore>((set) => ({ messages: [], isStreaming: false, currentModel: defaultModel, appendMessage: (msg) => set(state => ({ messages: [...state.messages, msg] })), updateMessageContent: (id, content) => set(state => ({ messages: state.messages.map(m => (m.id === id ? { ...m, content } : m)), })), setStreaming: (flag) => set({ isStreaming: flag }), }));注意:不要一上来就布局全局状态库。AI 产品的 initialState 往往会经历多次调整,过早抽象会导致反复重构。我的做法是先用原生 Hook 写业务逻辑,等确认了状态结构的稳定性后,再迁移到 Zustand。这样既能快速验证想法,又能在关键时刻保证性能。
3.3 useEffect 竞态处理:AbortController 与请求取消的细节
AI 产品里有大量用户主动打断的场景:用户点击"停止生成"、切换会话、重新提交问题。如果这些场景不处理请求竞态,轻则状态错乱,重则数据覆盖。
竞态问题的根源在于:异步操作的结果返回时,组件可能已经处于不同的状态。比如用户发起了 A 请求,随后又发起了 B 请求,A 的结果后于 B 返回,那么 A 的结果会覆盖 B 的结果。这在对话场景里是致命的。
标准解法是 AbortController 配合清理函数:
useEffect(() => { const controller = new AbortController(); async function fetchData() { try { const res = await fetch('/api/chat', { signal: controller.signal }); // 处理响应 } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { // 预期内的取消,不需要处理 console.log('Request aborted'); } else { // 真正的错误 handleError(error); } } } fetchData(); return () => { controller.abort(); }; }, [deps]);这里的关键点是:AbortController 的 signal 需要被正确传递到 fetch 或流式读取的调用链中。如果你用的是原生 fetch,直接传递 signal 即可。但如果你封装了请求库或者使用了自定义的 SSE 客户端,就必须确保 signal 被透传到最底层。
还有一个容易被忽视的细节:React 严格模式下 useEffect 会执行两次(开发环境),这会导致不必要的请求重复发送。对于 AI 接口,重复请求不仅浪费 token,还可能造成状态混乱。解决办法是在模块级维护一个 abort 标识,或者在请求层实现幂等控制。
3.4 复杂列表渲染与虚拟滚动的性能实测
AI 对话一旦超过几十轮,消息列表的渲染压力会陡增。特别是每条消息里可能包含 Markdown 渲染后的长文本、代码块、引用信息,DOM 节点数量会非常可观。实测中,一个 50 轮对话的会话,如果不做优化,滚动已经会出现明显掉帧。
推荐的优化手段分三个层级:
- 纯展示组件用 memo 包裹,避免无关状态变化引起重渲染。
- 列表项内容缓存,比如代码块的高亮结果、Markdown 的解析结果,只在内容变化时才重新计算。
- 如果消息超过 100 条,引入虚拟滚动,只渲染可视区域附近的消息。
虚拟滚动在 AI 产品里有一点特殊:对话流的末尾是"活动区域",用户需要看到最新生成的内容。这要求滚动行为是"自动跟踪底部",但用户向上翻看历史时又要暂停跟踪。这里可以用一个 sentinel 元素(底部哨兵)配合 IntersectionObserver 来判断当前是否处于底部:
const bottomRef = useRef<HTMLDivElement>(null); const [isAtBottom, setIsAtBottom] = useState(true); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => setIsAtBottom(entry.isIntersecting), { root: scrollContainerRef.current, threshold: 0.1 } ); if (bottomRef.current) observer.observe(bottomRef.current); return () => observer.disconnect(); }, []); // 当 isAtBottom 为 true 时,自动滚动到底部避坑提醒:不要直接从第一轮渲染就上虚拟滚动。虚拟滚动库本身有学习成本,而且对动态高度消息的支持(比如 Markdown 内容高度不确定)会引入不少复杂度。先做好 memo 和缓存,评估是否真的遇到性能瓶颈,再决定是否引入。
4. Next.js 在 AI 产品中的核心能力拆解与选型分析
Next.js 是整个技术栈里最能拉开差距的一环。它不像 TypeScript 和 React 那样"单纯",它同时承担了前端框架、服务端运行时、API 层三层职责。对 AI 产品来说,理解 Next.js 的能力边界,决定着你项目的架构形态。
4.1 App Router 与 Pages Router 的选择:为什么从 App Router 开始
Next.js 目前有两种路由模式:App Router(新版)和 Pages Router(旧版)。对 AI 产品的新项目,我建议直接用 App Router,理由不只是"新特性"。
App Router 引入了 Server Components 的概念,服务器端组件可以异步获取数据,这非常适合 AI 产品的首屏渲染。比如用户打开项目页面,需要展示会话列表,Server Component 可以直接在服务端查询数据库、渲染出页面骨架,然后下发到客户端。客户端不需要再做一次请求-渲染的循环。
同时,App Router 的嵌套布局(layout)对 AI 产品的多级导航非常友好。比如一个包含"对话、知识库、模型配置"三个子页面的 AI 管理后台,layout 可以保持不变,只有局部内容更新,天然的导航缓存。
还有一个实际原因:Next.js 的多数新示例、生态库、官方文档都将重心放在了 App Router 上。Pages Router 虽然稳定,但在长期维护和新特性支持上明显处于劣势。
如果是从零开始的项目,我建议按以下最小结构初始化:
app/ ├── layout.tsx # 全局布局,包含主题、字体等 ├── page.tsx # 首页入口 ├── chat/ │ ├── page.tsx # 会话列表/主对话页 │ └── [conversationId]/ │ └── page.tsx # 具体会话详情页 ├── api/ │ ├── chat/ │ │ ├── route.ts # 对话接口 │ │ └── stream/ │ │ └── route.ts # 流式对话接口 │ └── models/ │ └── route.ts # 模型配置接口4.2 Route Handlers 与流式响应:从接口定义到边缘部署的完整链路
Next.js 的 Route Handlers 是 AI 产品 API 层的核心。在 App Router 下,app/api/chat/route.ts导出的POST函数就是一个完整的服务端接口。
一个常见的问题是:大模型接口的密钥不能暴露在前端,但 AI 产品又需要前端直接发起对话请求。Route Handlers 正好解决了这个问题——它在服务端运行,可以安全读取环境变量里的密钥,同时对外暴露统一的接口。
流式响应的实现在 Route Handlers 里也相当顺滑。Next.js 支持在服务端返回一个ReadableStream,客户端可以像调用普通 SSE 接口一样消费:
// app/api/chat/stream/route.ts export async function POST(req: Request) { const { messages } = await req.json(); // 调用大模型 SDK,拿到流式响应 const upstreamStream = await getChatStream(messages); // 创建一个 TransformStream 做数据转换/过滤 const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { for await (const chunk of upstreamStream) { // 处理数据,比如提取 delta 字段 const formatted = formatChunk(chunk); controller.enqueue(encoder.encode(`data: ${JSON.stringify(formatted)}\n\n`)); } controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); }这里有一个容易被忽略的点:部署环境必须支持流式响应不缓冲。如果你把 Next.js 应用部署在默认的 Node.js 服务器上,一般没问题;但如果你用了某些 PaaS 平台的反向代理层,它可能会缓冲响应直到结束才返回,这样流式体验就完全失效了。部署到支持流式响应的边缘环境(比如 Vercel 的流式支持)或者自建反向代理时,要确认关闭缓冲。
4.3 Server Actions 在 AI 场景中的可用性边界
Server Actions 是 Next.js 14+ 引入的能力,允许前端组件直接调用服务端函数,免去手动编写 API 路由。对 AI 产品来说,它很诱人——代码更少、类型更安全。
但实测下来,Server Actions 在 AI 场景里有一些边界需要留意:
适合的场景:小规模的状态变更,比如更新用户配置、重命名会话、收藏消息。这些操作不涉及长耗时、不涉及流式返回,Server Actions 可以显著减少模板代码。
不适合的场景:大模型对话、流式返回。Server Actions 的设计目标是"完成动作并返回结果",它并不适合做流式传输。虽然可以通过 streamable value 之类的实验特性模拟流,但那属于 hack,不建议在生产环境依赖。
所以我的建议是:在 AI 产品里,Server Actions 和 Route Handlers 并存。高频、短耗时、写操作类需求用 Server Actions,长耗时、流式交互用 Route Handlers。这是业务代码层面的基本纪律。
4.4 增量静态再生成与动态渲染的取舍
Next.js 的渲染策略对 AI 产品的影响,主要体现在内容型页面和工具型页面的差异化处理上。
对于 AI 产品的营销页、文档站、模型说明页,这些内容更新频率低,适合用静态生成(SSG)加上增量静态再生成(ISR)。ISR 指定一个revalidate时间窗口,让页面在后台周期性重新生成,既享受 CDN 缓存的性能,又不会让内容永远陈旧。
// app/models/page.tsx export const revalidate = 3600; // 每小时重新生成一次对于对话页、控制台这类强交互页面,需要的是动态渲染(Dynamic Rendering),保证每次请求都能拿到最新的会话数据。在 App Router 里,可以通过export const dynamic = 'force-dynamic'来显式声明,避免被静态优化。
不要小看这个取舍。如果对话页被错误地做了静态优化,用户访问时会直接拿到缓存页面,不仅看不到最新数据,还可能导致"点击无响应"的诡异 bug。在 Next.js 工程里,主动管理渲染策略比被动调优重要得多。
5. 全栈架构设计:从 UI 到数据流的完整方案
前面几节分别讲了 TypeScript、React、Next.js 各自的能力,这一节把它们串联起来,给出一个经过实测的完整架构方案。一个 AI 产品不仅仅是前端界面加一个大模型接口,它涉及会话管理、知识库检索、权限控制、流式推送等模块。
5.1 三层架构:客户端组件层、服务端 API 层与数据持久层
我们最终采用的分层架构是这样的:
第一层:客户端组件层(React + TypeScript)
这一层只负责 UI 交互和本地状态管理。组件从自定义 Hook 中获取数据和操作函数,不直接发请求。所有的 API 调用都被封装在 Hook 中,这样组件可以聚焦在渲染逻辑上。
// hooks/useChat.ts export function useChat(conversationId: string) { const messages = useChatStore(state => state.messages); const sendMessage = useCallback(async (content: string) => { // 调用 API 路由 }, [conversationId]); return { messages, sendMessage }; }第二层:服务端 API 层(Next.js Route Handlers)
这一层负责与大模型服务交互、鉴权、流式转发、以及数据校验。所有外部服务的密钥都只存在于这一层。一个重要的设计原则是:API 层不做业务逻辑,只做转发和鉴权。
第三层:数据持久层
会话历史、用户配置、消息记录都需要持久化。早期 MVP 可以直接用数据库内置的方案(比如 SQLite + Prisma),等到产品规模增长后再考虑迁移到独立数据库服务。
// lib/db.ts import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;这三层结构的好处是职责清晰:前端改 UI 不影响 API,API 改模型策略不影响前端,数据库结构变更也只影响自身的封装层。对 AI 产品的快速迭代来说,这就是生命力。
5.2 数据流设计:请求生命周期与消息工厂模式
AI 对话界面的数据流,从用户按下发送按钮开始,到消息渲染完成,中间经历多个状态。我整理了完整的请求生命周期,这是排查问题的基础:
- 用户输入内容,点击发送。
- 客户端创建一条"用户消息",状态为
pending,推入消息列表。 - 客户端调用
/api/chat/stream接口,传入用户消息和历史上下文。 - 服务端校验鉴权、组装参数,调用大模型服务,建立流式连接。
- 服务端通过 SSE 持续返回事件(start、delta、tool_call、done、error)。
- 客户端逐事件解析,更新消息列表:
- 创建一条"助手消息",状态为
streaming - 每次 delta 事件将内容拼接到消息中
- tool_call 事件触发工具执行流程
- done 事件将消息状态改为
completed - error 事件将消息状态改为
error,展示错误提示
- 创建一条"助手消息",状态为
- 客户端在消息结束后,更新会话的 token 消耗信息。
这个数据流设计里,有个细节值得单独强调:消息对象尽量设计成不可变(immutable)结构。每次更新消息内容时创建新的消息对象,而不是修改原对象。虽然在内存上多了一些消耗,但能显著降低 React 渲染时排查"为什么视图不更新"的难度。配合 React DevTools 的 profiler 也能更清楚地看到每次更新的来源。
5.3 工具调用(Function Calling)的前端编排与展示
成熟的大模型应用几乎都会用到工具调用(Function Calling)。模型根据用户意图选择调用工具、传入参数、等待工具结果,再继续生成内容。前端在这个过程中承担"编排展示"和"工具执行"两类职责。
以一个模拟项目为例,用户说"帮我查一下北京的天气",系统需要:
- 大模型识别出意图,返回
tool_calls事件,其中包含工具名get_weather和参数{"city": "北京"}。 - 前端收到
tool_call事件后,展示一个"正在调用工具"的提示框,例如显示工具名称和参数。 - 前端的工具执行层调用天气 API 获取结果。
- 前端把工具结果附加到消息上下文中,重新请求大模型。
- 大模型根据工具结果生成最终回答,前端流式展示。
这段流程在 UI 上体现为"思考过程可视化"。直接影响用户信任感。一个粗糙的实现只会显示"正在生成...",而好的实现会把工具调用过程清晰展示——用户能知道 AI 确实"想"了、"查"了、"回答"了。
TypeScript 在这里再次发挥作用:
interface ToolCall { id: string; name: string; args: Record<string, unknown>; status: 'running' | 'success' | 'error'; result?: unknown; }status字段让工具调用状态成为一个可追踪的 UI 状态,任何阶段的变化都能及时反映在界面上。
实操建议:工具调用的展示不要做得太过复杂。一个折叠面板就够了,默认展开显示工具名和参数,用户点击可以收起。不要把工具执行的中间日志全部铺在界面上,信息过载同样会伤害体验。
5.4 Markdown 渲染、代码高亮与流式排版方案
AI 生成的内容几乎都是 Markdown 格式,所以 Markdown 渲染方案选择非常关键。实测踩过的坑有几个:
最核心的问题是"流式渲染与完整渲染的切换时机"。如果每一段 delta 都立即做一次完整的 Markdown 解析,性能开销很大;但如果等全部生成完再渲染,用户会长时间看到空白。
折中方案是分阶段策略:流式阶段,只渲染纯文本(可以加基本的粗体/斜体处理),不渲染代码块和复杂排版;生成结束后,对整条消息做一次完整 Markdown 渲染。这个切换点就是done事件。
代码高亮方面,可以选择highlight.js或shiki。实测下来shiki的视觉效果更好、语言包更全,但体积更大。考虑到代码高亮的文本只在消息完成后才出现,建议动态加载,避免拖慢首屏。
// 动态引入代码高亮库,仅在需要时加载 const highlight = async (code: string, lang: string) => { const { codeToHtml } = await import('shiki'); return codeToHtml(code, { lang, theme: 'github-dark' }); };还有一个细节是表格渲染。AI 生成的表格如果不做样式适配,在小屏幕上会错乱。推荐在 Markdown 样式层给表格加上横向滚动容器,或者限制表格宽度。
6. 工程化实践:从环境配置到监控体系
技术栈的选型只是起点,真正决定项目长期健康度的是工程化实践。这一节分享我们在这套技术栈下落地工程化方案的细节。
6.1 环境变量管理与密钥安全分发
AI 产品一定会涉及各种 API 密钥。Next.js 提供了NEXT_PUBLIC_前缀来区分公开变量和服务端变量,这是基本的防线。
关键实践是:
- 所有密钥只放在
.env.local中,且该文件写入.gitignore。 - 前端只能访问
NEXT_PUBLIC_前缀的变量;服务端变量在 Route Handlers 中通过process.env访问。 - 不同环境的配置通过
.env.development、.env.production等文件区分,但共享的配置只维护一份。 - 对于生产环境的密钥,优先使用部署平台的环境变量配置功能,而不是写进代码仓库。
一个容易踩的坑是:NEXT_PUBLIC_变量是构建时内联的,修改它必须重新构建。如果前端需要读取动态的配置,应该通过服务端接口返回,而不是依赖构建时的环境变量。
6.2 统一的请求封装与错误处理模式
AI 产品涉及的请求种类多:普通 JSON 请求、SSE 流式请求、文件上传等。如果没有统一的封装,错误处理会非常混乱。
我的封装思路是分两层:
第一层:底层请求客户端
这层负责处理 HTTP 请求、统一超时、取消、错误码映射。以 fetch 为例,封装一个基础函数:
async function http<T>(url: string, init?: RequestInit): Promise<T> { const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers, }, }); if (!res.ok) { throw new ApiError(res.status, await res.text()); } return res.json() as Promise<T>; }第二层:业务 API 封装
针对不同的接口,比如对话、会话列表、模型配置,分别封装独立的函数。它们调用底层请求客户端,同时把业务参数类型化:
export const chatApi = { sendMessage: (params: SendMessageParams) => http<ChatResponse>('/api/chat', { method: 'POST', body: JSON.stringify(params), }), listConversations: () => http<Conversation[]>('/api/conversations'), deleteConversation: (id: string) => http<void>(`/api/conversations/${id}`, { method: 'DELETE', }), };在错误处理模式上,推荐"错误归一化"。所有业务错误统一使用ApiError类型,携带状态码和错误信息。前端 catch 到这个类型后,根据状态码决定展示什么文案。比如 429 显示"请求频率过高,请稍后再试",500 显示"服务暂时不可用"。不要直接抛出原始异常让用户看到一堆堆栈。
6.3 日志、可观测性与 token 消耗的追踪方法
AI 产品的运维和传统 Web 应用有显著差异,核心是token 消耗的可观测性。token 直接关联成本,如果不追踪,月底账单会让你措手不及。
需要追踪的维度:
- 每次对话请求的 token 消耗(prompt_tokens、completion_tokens、total_tokens)
- 各模型的使用频次与成本分布
- 用户维度的 token 使用量(如果有用户体系)
- 错误率与重试次数
在 Next.js 应用里,可以在 Route Handlers 中统一埋点。每次收到大模型响应后,把 usage 信息记录到数据库或日志服务:
async function logUsage(model: string, usage: TokenUsage, userId?: string) { await prisma.usageLog.create({ data: { model, promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens, totalTokens: usage.total_tokens, userId, timestamp: new Date(), }, }); }注意,流式响应时 usage 信息往往在最后一个事件里才会返回。一定要在
done事件中提取 usage 并记录,不要在每个 delta 里重复记录。
对于日志,推荐使用结构化的 JSON 日志,而不是散落一地的 console.log。每条日志带上上下文信息(请求 ID、用户 ID、模型名称、耗时),这样后续排查问题时可以通过请求 ID 串联整个链路。
6.4 性能优化:缓存、预连接与边缘渲染策略
Next.js 应用可以享受不少内置的性能优化,但 AI 产品里有些场景需要主动处理。
静态资源的缓存策略:对于图片、字体等静态资源,利用 Next.js 的自动静态化能力,设置合理的 Cache-Control。不需要每次都回源。
API 层的缓存:对于不需要实时更新的接口,比如模型列表、系统配置,可以用类似useMemo+ 缓存时间的方案,减少服务端压力。
边缘渲染:如果部署环境支持边缘函数,可以把一些轻量的接口(比如鉴权、静态资源处理)放在边缘执行,减少冷启动延迟。但要注意,大模型调用本身的耗时远大于网络延迟,边缘渲染并不能解决 LLM 推理时间的问题,它只优化"到达 LLM 之前"的路径。
数据库查询优化:AI 产品的会话历史查询往往按时间倒序,随着数据量增长,需要给conversationId + createdAt建联合索引。不要等慢查询出现再处理,在架构阶段就把索引设计进去。
7. 真实环境下的常见问题与排查技巧
技术栈再好,落地时总会遇到问题。这一节整理我们在开发过程中真实遇到过的坑,以及排查思路。
7.1 Next.js 流式响应偶发超时或卡死的排查记录
现象是:页面上的对话内容在生成一段时间后突然停止,接口没有返回错误,就是不再有数据推送了。
排查过程分几步:
- 检查服务端日志:确认是上游 LLM 中断,还是 Route Handler 内部报错。我们在日志里发现,多数情况是上游连接在没有任何提示的情况下被关闭。
- 检查超时设置:默认的 fetch 超时并不适用于流式响应。需要确保没有给流式请求设置过短的超时时间。如果使用的是第三方 HTTP 客户端,需要开启流式模式的超时配置。
- 客户端自动重连机制:SSE 连接本身有断线重连的机制,但默认的重连策略并不一定适合 AI 场景。需要在业务层实现"未完成消息的恢复"逻辑,即检测到连接断开后,重新发起请求并携带上下文,让大模型从断点继续生成。
- 边缘部署的缓冲区问题:上面提到过,某些平台的代理会缓冲 SSE 响应。可以通过在响应头加上
X-Accel-Buffering: no来尝试关闭缓冲(如果平台支持)。
最终解决方案是:在客户端做"心跳超时检测",如果超过设定时间没有收到任何事件,主动触发重连;同时在上游断开时,服务端向客户端发送一个error事件,携带具体的错误信息,让前端可以展示"内容生成中断,已尝试恢复"的提示。
7.2 TypeScript 类型收窄在流式解析中失效的场景
理论上判别联合的类型收窄是可靠的,但在实际开发中遇到过"收窄失效"的情况。
核心原因是:当你把数据从any或者unknown转换过来时,类型断言破坏了 TypeScript 的信任基础。比如从JSON.parse()出来的结果,类型是any,你手动断言成StreamEvent,那么后续的收窄逻辑虽然编译不报错,但运行时数据可能根本不符合预期的结构。
解决办法是:对any数据做运行时校验,而不是直接断言类型。可以使用类型守卫(type guard)或者校验库(比如 zod)来验证数据结构,确保进入业务逻辑的数据是可信的。
import { z } from 'zod'; const StreamEventSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('start'), messageId: z.string(), createdAt: z.number() }), z.object({ type: z.literal('delta'), delta: z.string(), index: z.number() }), // ... 其他事件 ]); // 在解析事件时 const parsed = StreamEventSchema.safeParse(rawEvent); if (parsed.success) { // 此时数据是可信的,类型也正确 handleEvent(parsed.data); } else { // 处理非法事件 }这不是 TypeScript 的缺陷,而是类型系统在"不可信数据边界"上的必然要求。理解这一点,就能避免在线上才暴露数据解析异常。
7.3 对话上下文无限增长带来的 token 成本失控
AI 产品长期运行后,一个隐藏的炸弹就是上下文无限膨胀。每次对话都要把完整的历史消息发送给大模型,token 消耗随消息长度线性增长,成本最终不可控。
缓解策略有:
- 滑动窗口截断:只保留最近 N 条消息作为上下文。
- 摘要压缩:对较早期的历史消息,用一次性摘要请求生成几句话的概括,替换原始内容。
- 关键信息抽取:从历史消息中提取用户偏好、关键事实,存成"记忆"结构,在后续对话中作为附加上下文。
- 按模块拆分:如果 AI 产品有多个功能模块,按模块维护独立的上下文,而不是所有功能共享同一份历史。
在代码层面的实现,可以在发送请求前对消息数组做预处理,根据 token 预算动态决定保留哪些内容:
function buildContext(messages: ChatMessage[], maxTokens: number): ChatMessage[] { // 从后往前裁剪,直到总 token 数小于预算 const result = []; let total = 0; for (let i = messages.length - 1; i >= 0; i--) { const msgTokens = estimateTokens(messages[i].content); if (total + msgTokens > maxTokens) break; result.unshift(messages[i]); total += msgTokens; } return result; }这个函数的实现需要考虑:系统提示词(system message)必须保留,用户最近的输入必须保留,中间的过时内容可以压缩或丢弃。token 估算可以用字符数粗略估算(中英文比例不同),也可以通过 tokenizer 精确计算。
我们的实测结论是:token 成本问题必须在产品设计阶段就列入规划,不能等线上账单异常再补救。在产品界面上可以展示当前会话的 token 消耗,让用户有感知,也方便开发团队收集真实数据。
7.4 模型输出不稳定时的兜底策略与界面提示设计
大模型输出天然不稳定,可能会出现格式错误、内容不完整、答非所问。前端 UI 不能假设一定得到完美结果,需要设计兜底策略。
格式错误兜底:如果要求模型输出 JSON,但返回了标准 JSON 之外的文本,可以在解析失败时尝试"提取 JSON 片段"(用正则或字符串查找),再做一次解析。如果仍然失败,展示"内容解析失败"的提示,同时把原始内容展示给用户。
内容不完整兜底:如果生成的内容在done事件之前就中断(网络异常、超时、上游错误),界面要明确提示用户"内容生成中断",并提供"重新生成"和"继续生成"的选项。
答非所问的检测:这个比较难,没有万无一失的方法。基础做法是设置一个"最低内容长度"阈值,如果生成内容过短且明显不完整,提示用户补充信息。
UI 设计上,所有的兜底提示都应该清晰、友好,不要展示技术性的报错堆栈。用户不关心"500 Internal Server Error",他们只想知道"怎么解决这个问题"。合理的提示可以像这样:
抱歉,回答被中断了。你可以点击"重新生成"重试,或者调整问题描述后再试。
8. 实践后的个人体会
整个调研和实测下来,我的感受是:技术选型不是一个"选最优"的过程,而是一个"选最适配"的过程。TypeScript、React、Next.js 这套组合,在我目前接触的 AI 产品场景里确实表现出了很高的适配度。TypeScript 让团队在快速迭代时少踩了很多低级错误;React 的组件模型在频繁变化的 AI 交互中保持了代码的可维护性;Next.js 则用一个工程统一了前后端,显著降低了初始阶段的部署和协作成本。
但这套组合不是银弹。如果你的团队对 React 生态不熟悉,或者产品形态更接近"纯工具"而非"对话式",或许应该重新评估。技术栈只是地基,真正决定产品成败的还是对 AI 场景的理解、对用户体验的把控、以及对成本模型的敬畏。过程中还有个体会是:AI 产品的技术栈调研应该是持续进行的,而不是一次性的。技术生态变化太快,今天的最优解可能半年后就被新方案取代。保持对新方案的敏感度、定期做小范围验证,是团队技术负责人值得投入的事。
最后再分享一个小技巧:在实际推进技术栈落地时,不要一次性引入所有高级特性。先用最小的闭环跑通"React + 对话接口 + 消息展示",确认基础链路稳定后,再逐步引入状态库、流式优化、Server Actions、边缘渲染。渐进式地引入复杂度,能让你每一步都踩在实地上,而不是在建空中楼阁。