
做技术选型调研这件事最怕的就是拿着一堆框架对比文档比到最后发现全是纸上谈兵。最近因为要推进一个 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): PromiseChatCompletionResponse { const response await fetch(/api/chat, { method: POST, body: JSON.stringify(params), }); return response.json() as PromiseChatCompletionResponse; }这样写的好处是调用方的 IDE 自动补全会给出所有字段提示联调阶段不用频繁翻接口文档。更重要的是如果接口结构后续变化比如新增了一个字段编译器会明确标出哪些地方需要同步调整避免出现漏改一处线上崩溃的问题。2.2 判别联合与流式事件解析的类型建模方案流式响应是 AI 产品中类型建模的难点。SSEServer-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: Recordstring, 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。用PartialTokenUsage或者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] useStateChatMessage[]([]); 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 原生 HookAI 场景如何选型AI 产品天然有全局状态的需求多个组件共享会话上下文、当前模型配置、用户偏好。到底要不要引入 Redux、Zustand 这类状态库我的实测结论是如果只是简单场景用原生 Hook Context 就够了如果会话状态涉及多个层级的组件、需要持久化、有复杂派生状态引入轻量级状态库更划算。选型的关键在复杂度临界点。我在调研阶段做了一个简单的对比方案适用场景优点缺点useState useReducer组件内状态、局部交互零依赖、简单直接跨组件共享难Context 自定义 Hook中等规模、主题切换、用户配置模板代码少、易理解重渲染控制需要手动优化Zustand复杂会话、高频更新、需要持久化性能好、API 简洁需要额外学习成本以一个多角色 AI 助手为例它涉及用户配置、会话历史、当前生成状态、模型参数、消息附带的引用来源等多个维度的状态。如果全用 ContextProvider 嵌套会很深任何状态更新都可能引起无关组件重渲染。引入 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 createChatStore((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 useRefHTMLDivElement(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 不影响 APIAPI 改模型策略不影响前端数据库结构变更也只影响自身的封装层。对 AI 产品的快速迭代来说这就是生命力。5.2 数据流设计请求生命周期与消息工厂模式AI 对话界面的数据流从用户按下发送按钮开始到消息渲染完成中间经历多个状态。我整理了完整的请求生命周期这是排查问题的基础用户输入内容点击发送。客户端创建一条用户消息状态为pending推入消息列表。客户端调用/api/chat/stream接口传入用户消息和历史上下文。服务端校验鉴权、组装参数调用大模型服务建立流式连接。服务端通过 SSE 持续返回事件start、delta、tool_call、done、error。客户端逐事件解析更新消息列表创建一条助手消息状态为streaming每次 delta 事件将内容拼接到消息中tool_call 事件触发工具执行流程done 事件将消息状态改为completederror 事件将消息状态改为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: Recordstring, 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 httpT(url: string, init?: RequestInit): PromiseT { 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 PromiseT; }第二层业务 API 封装针对不同的接口比如对话、会话列表、模型配置分别封装独立的函数。它们调用底层请求客户端同时把业务参数类型化export const chatApi { sendMessage: (params: SendMessageParams) httpChatResponse(/api/chat, { method: POST, body: JSON.stringify(params), }), listConversations: () httpConversation[](/api/conversations), deleteConversation: (id: string) httpvoid(/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、边缘渲染。渐进式地引入复杂度能让你每一步都踩在实地上而不是在建空中楼阁。