ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

前端开发接入AI:从API调用到流式输出与Agent工具调用的实战笔记

2026/9/15 3:51:42 拓冰建站 浏览量
前端开发接入AI:从API调用到流式输出与Agent工具调用的实战笔记 day4说实话是这轮前端AI学习里最有感觉的一天。前三天我还在跟概念较劲——大模型能力从哪来、API怎么申请、后端转发怎么搭今天算是第一次把AI真正“接”进了自己写的Web项目里而且是从零开始手写没用现成的SDK。这篇笔记不打算写成像教程一样的东西更多是记录我今天做前端AI项目时踩过的坑、想明白的原理以及最后跑通时的那个“原来如此”瞬间。如果你也处于前端想切入AI、又不知道从哪一步开始动手的阶段今天这篇应该能帮你省掉不少摸索时间。核心内容大概涉及前端怎么跟大模型API打交道、密钥怎么保护、流式输出怎么做、还有我顺手试了一把Agent工具调用的初体验。1. 今天的目标让AI在Web项目里真正跑起来1.1 前3天学到的关键节点先说下前3天我干了什么方便你对照自己的进度。day1主要是通读了一遍大模型API文档把chat completion的基本格式搞明白了。核心就是你发给它一个messages数组数组里放着system系统指令、user用户问题、assistant模型回答然后模型返回一段文本。这个结构看起来简单但其实很重要因为后面所有复杂玩法都是在这个基础上叠加的。day2搭了一个Node后端转发服务主要为了解决密钥保护问题。这个我在后面的章节会细说简单讲就是不能让浏览器直接带着API密钥去请求大模型必须经过自己后端中转。day3对着官方示例用命令行curl验证了一遍确认整个链路是通的。那一刻成就感挺强的但说实话也特别空虚——curl能返回东西和用户能用这中间差的可不是一星半点。所以今天的目标就很明确了做一个简单的聊天界面支持连续对话支持流式打字效果最好再加上一两个能体现AI能力的功能按钮。不追求好看先追求“能用”。我给自己定的验收标准是在一个输入框里打一句话能像ChatGPT那样一个字一个字地冒出来并且上下文不丢。1.2 一个关键抉择直连大模型API还是走自己的后端中转这是今天遇到的第一个需要做决定的点前端代码里是直接调大模型API还是调自己写的后端接口两种方案我都试了一遍对比如下方案优点缺点适用场景前端直连代码量少几乎不需要后端网页托管到静态服务器就能用密钥暴露别人F12一翻就拿到无法做权限控制和审计本地验证原型、个人单机工具后端中转密钥留在服务端可做限流、日志、业务逻辑安全可控多写一层接口部署多一个服务任何要上线、多人使用的场景我一开始图省事直接在本地HTML里写死了一个密钥测试。结果打开浏览器控制台发现密钥就挂在全局变量里那一刻一身冷汗。这要是传到GitHub上分分钟被爬虫扫出来盗刷。所以别嫌麻烦自己搭一个极简后端转发是必须的。今天我用Node.js的Express写了一个不到50行的转发接口把前端的请求原样转发给大模型API再把响应原样返回。代码不复杂但把密钥安全地锁在了服务端。1.3 今天完成的Demo形态最终做出来的东西长这样一个普通到不能再普通的页面上面是聊天记录区域下面是输入框和发送按钮。没有花哨的样式连消息气泡都只用了最简单的圆角卡片。但有两个点让它跟普通聊天框不一样第一所有回答都是流式输出的不是等机器想完一口气吐出来第二我在侧边栏放了一个“工具区”点了之后AI会主动“调用”我指定的函数来获取当前时间再回答。第二点算是Agent的雏形后面第4章细讲。这个Demo虽然看起来很初级但它覆盖了前端AI项目最核心的几块拼图网络请求、鉴权、流式解析、状态管理、异步竞态处理。这些恰恰是以后做任何AI产品都绕不开的基本功。2. 前端调用大模型接口参数没那么玄乎2.1 请求体里的核心参数第一次打开大模型API文档的时候我被那一堆参数名吓到了。但是真正用起来90%的时间只需要关心这几个model——选哪个模型。不同模型的能力差异很大这个按自己的需求选就行。messages——对话上下文数组。这是整个请求体的灵魂每个元素有role和content两个字段role分三种system设定AI角色和行为、user用户说的话、assistantAI之前回复的内容。多轮对话就是把这个数组一直往长里加。temperature——控制回答的随机性取值一般是0到2。0基本是“每次都一样”1到1.5就有点天马行空了。做客服问答用0.3左右做创意文案可以调到1以上。max_tokens——限制回答的最大长度防止模型一句话写篇论文把你的账单顶爆。stream——是否开启流式返回。设为true之后接口不会一次性返回完整结果而是像水龙头一样不断吐数据片段前端边收边显示。这个对用户体验影响巨大我第3章专门讲。这里要特别提醒一个容易忽略的点messages数组的长度不是无限的。每次请求都带上全量历史不仅浪费token还可能超出模型的上下文窗口。实际项目中要做截断或摘要比如只保留最近10轮。这个坑我今天就踩到了后面问题排查里细说。2.2 密钥保护为什么前端不能直接带密钥很多第一次接触的人都会问“我调大模型API为啥不能直接在前端代码里写密钥”答案很简单浏览器里的一切都是透明的。你写在代码里的任何字符串用户打开开发者工具都能看到。密钥一旦泄露别人就能拿你的账号去调用API花你的钱而且是按量计费很快就会产生一笔不小的账单。所以正确的姿势一定是“前端调自己后端后端再去调大模型”。前端请求自己后端接口时可以使用你自己的登录态或请求头做鉴权而后端持有真正的密钥去请求大模型。我今天的做法是后端Express服务监听3000端口提供一个/api/chat接口。前端页面跑在Vite的5173端口发请求到/api/chat。后端收到请求后把请求体透传给大模型API并附上密钥。后端拿到响应后再返回给前端。开发环境下还有一个细节前后端端口不同会触发浏览器的跨域拦截也就是CORS。解决方案有两个要么在后端启用cors中间件要么给Vite配置代理。我用的后者这样运维起来更省心开发环境无感知跨域生产环境前端静态资源可以由Nginx反代到后端。2.3 请求封装示例下面这段是我今天写的请求封装不算完善但足够跑通一个基础功能。我用的是fetch因为现代浏览器原生支持不用额外引库。async function chatRequest({ messages, onUpdate, signal }) { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, stream: true }), signal, // AbortController 专属用于中断请求 }); if (!response.ok) { throw new Error(请求失败: ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; let result ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按行切分逐条解析 const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { const parsed parseStreamLine(line); if (parsed) { result parsed; onUpdate?.(result); } } } return result; }注意这里面我用了TextDecoder的stream: true参数这个细节很重要。因为流式传输时一个中文字符的字节可能被拆到两个chunk里不用stream: true的话第二个chunk单独解码会解出乱码。这个坑我一开始没注意后面排查了半天。3. 流式输出用户体验的分水岭3.1 为什么流式输出这么重要你可以做个实验请求一个比较长的AI回答用非流式方式前端就一直转圈可能要等10到20秒突然整个回答一次性蹦出来。这个过程用户的心情是崩溃的因为没有任何反馈不知道到底有没有在干活。流式输出就是解决这个问题的。服务端边生成边推送前端边接收边渲染用户看到的是一个字一个字“打”出来的过程。虽然总耗时其实差不多但体感上会快很多而且有一种“它正在努力思考”的参与感。用生活化的类比来说非流式就像一次性把一整盘菜端上来流式就像看厨师现场炒菜。你觉得哪个体验更有温度今天做完流式之后我再也不想用一次性返回的方式了。如果你准备做AI类前端产品流式输出基本属于“必须要会”的能力。3.2 前端如何接收流式数据大模型API的流式返回格式是SSEServer-Sent Events风格。每一行是一个事件以data:开头后面跟着JSON字符串最后一行是data: [DONE]表示结束。我写了一个简单的解析函数function parseStreamLine(line) { if (!line.startsWith(data:)) return null; const payload line.slice(5).trim(); if (payload [DONE]) return null; try { const json JSON.parse(payload); const content json.choices?.[0]?.delta?.content; return content || ; } catch (e) { console.warn(解析失败:, payload, e); return null; } }为什么是delta.content而不是content因为流式返回时每个chunk携带的是“相对于上一个chunk新增的片段”而不是完整回答。把这些delta片段按顺序拼起来才是完整的回答。这里还有一层理解要打破流式解析的本质是“边读边拼”不是“等全部读完再解析”。所以我的代码里用了while (true) reader.read()的循环每读到一块数据就立即处理保证渲染是实时的。3.3 增量渲染的三个常见坑第一个坑千万不要用innerHTML做流式增量渲染。因为AI可能中途输出包含HTML标签的内容比如它回答“点击按钮”如果你用innerHTML这个字符串会被浏览器当标签解析掉轻则显示异常重则出现XSS漏洞。正确做法是用textContent纯文本渲染绝对安全。第二个坑不要每次onUpdate都重建整个DOM节点。我今天的聊天区域用的是数组存储消息每次更新就把最后一条消息的textContent重新赋值。这个方式没问题但如果消息数量多了频繁重建整棵DOM树会卡。更优的做法是只更新对应节点的文本内容或者用虚拟滚动。我这个Demo数据量小暂时没做优化但思路要先有这个意识。第三个坑中断请求。用户发送问题后如果反悔了或者切换了会话必须中断掉正在进行的流式请求。我用的方案是AbortController把signal传给fetch需要中断时调用abort()。接口会抛出一个AbortError需要在上层捕获并静默处理。这个如果处理不好会出现“用户已经切换页面了上一轮的文本还在继续往页面上写”的灵异事件。4. 顺带试了一把Agent的边工具调用4.1 聊天和Agent的区别今天白天做完聊天功能后我突发奇想能不能让AI不只是“嘴上说说”而是真的能“动手干点事”普通的聊天大模型本质是一个“超级嘴炮王”你再怎么问它它也只能输出文字。但Agent的思路不一样给它定义几个工具函数它通过输出特定的结构化指令让我方代码去执行工具然后把执行结果再喂回给它由它基于结果组织最终回答。这两者的区别可以理解成“只会指路的问路人”和“真的会带你走一段路的向导”。前者告诉你“往东走50米”后者会真的迈开腿走几步再根据路况跟你商量下一步。我今天给AI定义了一个工具查询当前时间。具体流程是用户问“现在几点了”前端把消息发给AI同时告诉它“你有一个工具叫getCurrentTime可以获取当前时间”。AI判断这个问题需要工具返回一个tool_calls指令内容是“调用getCurrentTime”。前端代码收到后执行真正的getCurrentTime函数拿到时间。前端把执行结果作为一条tool角色的消息再次发回给AI。AI看到结果后组织一句完整回答“现在是2026年X月X日 14:30。”这整个过程用户看到的是AI“自己知道该用什么工具”这就是Agent的核心机制官方术语叫function calling也称工具调用。4.2 前端如何描述一个“工具”工具不是凭空存在的需要让AI知道“你有哪些工具可以用、每个工具需要哪些参数”。我按照API文档定义了一个JSON结构{ type: function, function: { name: getCurrentTime, description: 获取当前时间返回年月日和时分秒, parameters: { type: object, properties: {}, required: [] } } }这段结构的作用可以理解为“给AI看的一份工具说明书”。AI会读这个说明然后决定当前问题要不要调用工具、调用哪个工具、传入什么参数。关键在于description要写得足够清楚准确。我一开始写的是“获取时间”AI经常回答“您可以查看手机上的时间”而不调用工具。我改成“获取当前时间返回年月日和时分秒”AI才每次都正确触发。这就是给AI写文档的玄学——你描述得越精确它就越容易做出正确判断。4.3 最简单的工具调用流程实现下面是我在Demo里实现的完整流程只涉及一个工具所以逻辑比较直白// 第一步用户提问带上工具定义一起发送 const messages [{ role: user, content: 现在几点了 }]; const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, tools: [timeToolDefinition] }), }); const data await response.json(); // 第二步检查模型是否要求调用工具 const toolCalls data.choices?.[0]?.message?.tool_calls; if (toolCalls) { // 第三步执行工具 const result getCurrentTime(); messages.push(data.choices[0].message); // 把tool_calls消息加入上下文 messages.push({ role: tool, tool_call_id: toolCalls[0].id, content: JSON.stringify(result), }); // 第四步把工具结果回传给模型让模型组织最终回答 const secondResponse await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), }); const finalData await secondResponse.json(); renderMessage(finalData.choices[0].message.content); } else { // 模型没有调用工具直接回答 renderMessage(data.choices[0].message.content); }这段流程可能是今天最有价值的产出。因为我真正理解了AI Agent的“思考-行动-观察”循环是怎么在前端落地。虽然只是玩具级别但骨架已经对了。后面接真正的工具比如搜索、读写数据库、调业务接口套路是完全一样的只是工具的复杂度提升了。我建议每个做前端AI的人都亲手走一遍这个流程。别以为这是后端的事工具执行的调度层完全可以放在前端特别适合做交互型Agent。这也是很多偏前端的Agent开发岗位要求的核心能力之一。5. 今日踩坑记录这些问题浪费了我三个小时5.1 跨域问题浏览器安全策略的“铁拳”第一次把前端页面跑起来点击发送按钮控制台直接飘红Access to fetch at http://localhost:3000 from origin http://localhost:5173 has been blocked by CORS policy。这个问题的本质是浏览器规定一个来源的网页不能随便请求另一个来源的接口除非对方在响应头里明确“同意”。这是安全机制不能绕过只能配合。我的解决方式是给Vite加代理配置在vite.config.js里加一段export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, }, }, }, });这样前端请求/api/chat时实际上由Vite开发服务器转发给3000端口浏览器看到的是同源请求自然就不会拦了。这条经验的价值不光在开发期生产环境用Nginx做反向代理的时候思路也一模一样。5.2 流式输出乱码一个解码细节引发的血案第一次跑通流式输出时我发现AI回答里偶尔会出现“”这样的乱码字符尤其是在回答开头。排查了很久才发现问题出在TextDecoder的用法上。流式请求的数据是按二进制块到达的一个UTF-8中文编码占3个字节这个3字节可能被拆到两次不同的chunk里。如果不加{ stream: true }参数每次解出来的就是残缺字节自然乱码。正确的用法是const decoder new TextDecoder(utf-8); // 每次解码传入 stream: true buffer decoder.decode(value, { stream: true }); // 流结束时调用 decoder.decode() 清空缓冲这个细节不用流式永远碰不到一用流式必遇到。知道了原理以后以后遇到类似乱码问题就有方向了。5.3 请求竞态用户手快程序遭殃还有一个比较隐蔽的坑用户在输入框快速按回车连续发送了两条消息。结果第一条消息的回答还没流完第二条的流式输出就开始了两条内容交替渲染在页面上整个对话记录彻底乱套。这个问题叫竞态条件。我的解决方案比较简单粗暴用一个请求序列号或者简单理解成requestId每次发送新请求时流式回调里检查当前序号是否等于最新序号不相等就直接忽略。更规范的做法是结合AbortController发送新请求时先中断上一个请求。这样既省流量又避免渲染混乱。我今天的Demo用的是“序号判断忽略过期回调”虽然简单但够用。如果做产品级项目建议直接上AbortController。5.4 从“前端八股”到实战面试视角的反思今天踩完这些坑之后我回头翻了一下收藏夹里的前端面试题突然有了很不一样的感觉。以前看“说说你对跨域的理解”“谈谈前端性能优化”这类题目感觉就是八股背就完了。但今天被CORS拦了一次、给节点赋值文本被坑了一次之后再回头看这些题才明白面试官到底想问什么。跨域既是安全策略也是工程问题性能优化不光是指算法复杂度还包括DOM更新频率、网络请求数量这些非常现实的东西。我觉得这就是“带着项目学”的价值。不是说八股不用背而是只有在实战里踩过坑你才知道那些答案背后对应的是什么具体场景。面试官问的不是定义是你有没有真正解决过问题。今天这几个坑以后聊起来都是非常鲜活的素材。结尾day4的收获说到底是心态层面的以前觉得AI很神秘今天发现它本质上就是一个“特殊格式的HTTP接口”。前端该干的活依然是处理用户输入、管理页面状态、优化交互体验、兜住异常边界这些基本功一点没变变的只是数据的来源更聪明了。如果你也在学前端AI我特别建议你找一天安静下来自己手写一遍流式解析别急着引第三方SDK。用原生fetch把SSE流接一遍、把delta拼一遍、把AbortController用一遍你会对整个链路有完全不一样的理解。第八百遍看教程不如自己亲手踩一遍。明天我准备在Agent工具调用上继续往下挖目标是接一个真正有业务价值的工具——查询本地JSON数据并画成图表。