简介这是一份面向AI时代Web应用开发者的实战代码包聚焦将AI能力深度融入产品架构的系统性工程方法适合具备TypeScript与Next.js基础、希望从零搭建AI Native产品的开发者参考。内容围绕RAG检索增强生成、Prompt Engineering工程化、向量数据库选型与混合检索等核心环节展开并延伸至认证授权、多租户隔离、限流熔断、可观测性建设及生产环境高可用部署等落地细节同时涉及AI RSC、MCP协议等前沿趋势。资源包共3个文件以inscode工程配置、html页面与gitignore忽略规则为主压缩包约14KB结构精简便于快速导入开发环境。目前已有140人学习下载。代码目录划分清晰、注释详尽封装了可复用Hook与标准化错误处理模板并附测试用例与CI/CD流水线脚本所有源码均经真实业务场景验证可直接用于生产环境帮助读者对照实现从产品形态定义到RAG接入的完整链路。1. AI Native Web 开发实战为什么“加个 AI 接口”根本不算 AI Native很多团队以为在页面右下角挂个聊天窗口、后端调一次大模型接口就算完成了 AI Native 改造。我见过一个后台系统就是这么干的前端加了个悬浮按钮用户输入问题后端拼一段 prompt 转发给模型返回一段文字渲染出来。上线两周日活调用不到二十次用户反馈“还不如自己搜文档”。问题不在模型在于这套架构里 AI 是外挂不是原生——它拿不到页面状态、不知道用户当前在哪个表单、不能触发任何操作只能干聊。AI Native Web 开发实战要解决的核心问题是让 AI 成为 Web 应用的一等公民能读写应用状态、调用业务函数、参与交互流程而不是一个孤立的对话框。它适合已经有前端工程能力、想把大模型能力真正嵌进产品流程的开发者也适合正在做内部工具、希望用自然语言替代复杂表单操作的团队。这一篇不讲模型原理只讲怎么把 AI 接进一个真实可跑的 Web 工程里从最小闭环到参数调优再到踩过的坑。2. 从零搭一个 AI Native 最小闭环前端、后端与工具调用2.1 为什么选“前端直连 后端代理”而不是纯前端调模型常见做法有两种一是前端直接拿 API Key 调模型二是前端只负责 UI所有模型调用走后端代理。第一种在 Demo 阶段很爽但 Key 暴露在浏览器里任何人打开 DevTools 就能拿走而且跨域、限流、审计都没法做。第二种多一层转发但换来三个关键能力Key 留在服务端、可以对请求做鉴权和计费、能统一管理 prompt 模板和工具函数。我一般会选后端代理但不是简单的转发。后端要承担“工具路由”的职责模型返回的不是纯文本而是一个结构化的动作指令后端解析后决定调用哪个业务函数再把结果回传给模型做二次生成。这样前端只需要维护一套 UI 状态业务逻辑全部收在后端模型换版本、换供应商都不影响前端。具体到技术选型前端用 React 或 Vue 都行关键是状态管理要能暴露“当前页面上下文”。后端用 Node.js 或 Python 都可以我倾向 Node.js因为和前端同语言工具函数的类型定义可以共享。数据库不是必须的但建议加一个轻量的 SQLite 或 Redis 做会话缓存否则多轮对话会丢上下文。2.2 最小可运行代码一个能查订单状态的 AI 助手下面这段代码是一个最小闭环前端发消息后端代理调模型模型决定是否调用queryOrder工具后端执行后把结果返回。代码用 Node.js Express 写模型接口用通用的 OpenAI 兼容格式实际换成任何兼容接口都能跑。// server.js import express from express; import fetch from node-fetch; const app express(); app.use(express.json()); // 业务工具函数查订单 async function queryOrder({ orderId }) { // 真实场景这里查数据库这里用模拟数据 const mockDb { A1001: { status: 已发货, eta: 2025-04-12 }, A1002: { status: 待付款, eta: null }, }; return mockDb[orderId] || { status: 未找到该订单, eta: null }; } // 工具描述告诉模型有哪些函数可调 const tools [ { type: function, function: { name: queryOrder, description: 根据订单号查询订单状态和预计送达时间, parameters: { type: object, properties: { orderId: { type: string, description: 订单号例如 A1001 }, }, required: [orderId], }, }, }, ]; app.post(/api/chat, async (req, res) { const { messages } req.body; // 第一次请求让模型决定是否调用工具 const firstResp await fetch(https://api.example.com/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.MODEL_API_KEY}, }, body: JSON.stringify({ model: your-model-name, messages, tools, tool_choice: auto, }), }); const firstData await firstResp.json(); const choice firstData.choices[0]; // 如果模型没有调用工具直接返回文本 if (!choice.message.tool_calls) { return res.json({ reply: choice.message.content }); } // 如果有工具调用逐个执行 const toolResults []; for (const call of choice.message.tool_calls) { const args JSON.parse(call.function.arguments); let result; if (call.function.name queryOrder) { result await queryOrder(args); } toolResults.push({ role: tool, tool_call_id: call.id, content: JSON.stringify(result), }); } // 第二次请求把工具结果回传给模型生成自然语言回复 const secondResp await fetch(https://api.example.com/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.MODEL_API_KEY}, }, body: JSON.stringify({ model: your-model-name, messages: [...messages, choice.message, ...toolResults], }), }); const secondData await secondResp.json(); res.json({ reply: secondData.choices[0].message.content }); }); app.listen(3000, () console.log(AI Native server running on :3000));这段代码的关键在两次请求的设计。第一次请求带上tools定义模型返回的tool_calls是一个数组可能包含多个调用。每个调用有id和function.arguments参数是 JSON 字符串需要JSON.parse后再传给业务函数。执行完所有工具后把结果按role: tool的格式追加到消息列表再发第二次请求。第二次请求不要带tools否则模型可能再次调用工具陷入循环。参数上tool_choice设为auto让模型自己判断如果希望强制调用某个工具可以设为{ type: function, function: { name: queryOrder } }。temperature建议设 0.2 到 0.5工具调用场景不需要太高的随机性。max_tokens根据业务复杂度设一般 500 到 1000 够用太长会拖慢响应。2.3 前端怎么把页面状态传给 AI前端不能只发用户输入的文字还要把当前页面上下文带上。比如用户在订单详情页问“这个订单什么时候到”前端应该把orderId一起传给后端而不是让模型去猜。实现方式是在消息列表里加一条system消息或者在后端维护一个context对象。// 前端发送请求时带上页面上下文 async function sendMessage(userInput, pageContext) { const messages [ { role: system, content: 当前页面是订单详情页订单号${pageContext.orderId}用户ID${pageContext.userId}, }, { role: user, content: userInput }, ]; const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), }); const data await resp.json(); return data.reply; }这里pageContext由前端路由和状态管理提供React 里可以用useContext或useSelector拿。注意不要把敏感信息塞进 system 消息比如完整手机号、身份证号模型侧可能记录日志。常见做法是只传业务 ID后端根据 ID 查详细信息。3. 工具调用的参数设计与多轮对话状态管理3.1 工具函数的参数怎么定义才不容易翻车工具参数定义直接决定模型能不能正确调用。我踩过的坑是参数名用缩写比如oid代替orderId模型经常传错。参数名要完整、语义明确description要写清楚格式和示例。下面是一个对比参数定义方式模型调用成功率问题{ oid: string }约 60%模型不知道 oid 是什么经常传用户输入原文{ orderId: string }无描述约 75%能猜对但格式不对时不会纠正{ orderId: string }带描述和示例约 95%模型会主动从上下文提取或追问描述里要写“例如 A1001”这种具体示例模型对示例的敏感度远高于抽象说明。如果参数是枚举值一定要在description里列出所有可能值否则模型会编造。参数数量控制在 5 个以内太多会导致模型漏传或传错。另一个坑是参数类型。模型对string和number的区分在边界情况下会出错比如订单号001可能被转成1。解决办法是在description里强调“字符串类型保留前导零”或者后端收到后做一次格式化。3.2 多轮对话里怎么避免上下文爆炸多轮对话最直接的做法是把所有历史消息都带上但 token 消耗会线性增长几十轮之后请求就超限了。常见做法是滑动窗口加摘要保留最近 N 轮完整消息更早的用一段摘要代替。// 维护会话历史超过阈值时做摘要 function buildMessages(session, newUserMessage) { const MAX_RECENT 6; // 保留最近 6 条消息 const history session.history || []; // 如果历史太长把最早的部分压缩成摘要 let summary session.summary || ; if (history.length MAX_RECENT) { const oldMessages history.slice(0, history.length - MAX_RECENT); // 这里可以调模型生成摘要也可以简单拼接 summary oldMessages.map((m) ${m.role}: ${m.content}).join(\n); session.history history.slice(-MAX_RECENT); } const messages []; if (summary) { messages.push({ role: system, content: 历史对话摘要${summary} }); } messages.push(...session.history); messages.push({ role: user, content: newUserMessage }); return messages; }摘要生成可以异步做不阻塞当前请求。摘要的 prompt 要明确“保留订单号、用户意图、已执行的操作”否则模型会把关键信息丢掉。我一般会在摘要里强制保留最近一次工具调用的结果因为用户很可能接着问“那这个订单能改地址吗”。会话状态存哪里内存最简单但重启就丢。Redis 适合多实例部署设一个 30 分钟的过期时间。如果要做持久化审计落库但只存消息 ID 和工具调用记录不存完整文本避免隐私问题。3.3 工具执行失败时怎么让模型“说人话”工具函数可能抛异常比如数据库连接失败、订单号不存在。直接把错误堆栈返回给模型模型会生成一段莫名其妙的道歉。正确做法是把错误转成结构化信息让模型基于这个信息回复。async function safeExecuteTool(name, args) { try { if (name queryOrder) { const result await queryOrder(args); return { success: true, data: result }; } return { success: false, error: 未知工具 }; } catch (err) { // 不返回堆栈只返回可读的错误类型 return { success: false, error: 查询服务暂时不可用请稍后重试 }; } }返回给模型的内容里带上success字段模型会根据这个字段决定是展示数据还是道歉。如果success: false模型一般会说“抱歉暂时查不到”比直接抛异常友好得多。注意错误信息里不要暴露内部表名、字段名防止 prompt 注入后被套出结构。4. 避坑与排查AI Native Web 开发里最容易翻车的五件事4.1 模型返回的 JSON 参数解析失败现象JSON.parse(call.function.arguments)抛异常日志里看到参数是{orderId: A1001}而不是{orderId: A1001}。原因是模型在生成参数时偶尔会漏引号或加注释。解决解析前先做一次清洗去掉首尾空白和可能的 markdown 代码块标记解析失败时用正则提取关键字段作为兜底。function safeParseArgs(raw) { try { return JSON.parse(raw); } catch { // 兜底尝试提取 key: value 形式 const match raw.match(/orderId[]?\s*[:]\s*[]?([A-Za-z0-9])/); return match ? { orderId: match[1] } : {}; } }4.2 工具调用陷入死循环现象模型反复调用同一个工具请求一直不返回。原因是第二次请求时仍然带了tools定义模型看到工具结果后觉得不完整又调一次。解决第二次请求的 body 里去掉tools字段或者设tool_choice: none。另外在代码里加一个最大调用次数限制比如 3 次超过就强制返回文本。4.3 前端状态和后端上下文不一致现象用户切换了页面但 AI 还在用旧页面的上下文回答。原因是前端只在初始化时传了一次pageContext路由变化后没更新。解决在路由变化的生命周期里重新发送上下文更新或者每次请求都带上当前pageContext不要依赖后端缓存。4.4 流式输出和工具调用冲突现象开了流式输出后工具调用的参数被截断解析失败。原因是流式返回的tool_calls是分片的需要拼接完整后才能解析。解决如果要用流式工具调用阶段先不开流等工具执行完、第二次请求生成自然语言时再开流。或者用支持流式工具调用的 SDK手动拼接delta里的function.arguments。4.5 敏感信息被写进 prompt现象用户输入里带了手机号模型把它写进了工具参数后端日志里出现明文。原因是前端没有做输入过滤后端也没有脱敏。解决前端对输入做一次正则替换把手机号、身份证号替换成占位符后端在写日志前对messages做一次脱敏只保留角色和长度。工具参数里如果必须用手机号用后四位匹配不要传全量。5. 进阶用函数注册表把工具调用做成可插拔当工具函数超过五个if-else会变得很难维护。我一般会做一个函数注册表每个工具是一个对象包含name、description、parameters和execute。新增工具只需要注册不用改路由逻辑。// tools/registry.js const registry new Map(); export function registerTool(tool) { registry.set(tool.name, tool); } export function getToolDefinitions() { return Array.from(registry.values()).map((t) ({ type: function, function: { name: t.name, description: t.description, parameters: t.parameters, }, })); } export async function executeTool(name, args) { const tool registry.get(name); if (!tool) return { success: false, error: 工具未注册 }; try { const data await tool.execute(args); return { success: true, data }; } catch (err) { return { success: false, error: 执行失败 }; } } // 注册订单查询工具 registerTool({ name: queryOrder, description: 根据订单号查询订单状态, parameters: { type: object, properties: { orderId: { type: string, description: 订单号例如 A1001 }, }, required: [orderId], }, execute: async ({ orderId }) { // 实际查询逻辑 return { status: 已发货, eta: 2025-04-12 }; }, });注册表的好处是工具定义和业务逻辑放在一起改参数不用翻路由文件。另一个好处是可以做权限控制每个工具加一个requiredRole字段执行前检查当前用户角色没权限就返回“无权限操作”。这样 AI 不会越权调用管理接口。验证工具是否注册成功可以加一个/api/tools接口返回所有工具定义前端调试时能看到模型实际可用的工具列表。如果模型说“我没有这个能力”先检查这个列表里有没有对应工具。我自己的习惯是每加一个新工具先用tool_choice强制调用一次确认参数解析和执行都正常再放开auto。这样能把工具本身的 bug 和模型判断的 bug 分开排查。另外工具描述里的示例要定期更新业务字段变了但描述没改模型会一直传旧字段这种问题查起来很费时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?