资讯动态

微信小程序接入LLM实战:聊天源码解析与流式返回避坑指南

发布时间:2026/10/17 9:35:21 来源:尧图企业网站定制
简介这是一份面向微信小程序开发者与AI应用入门者的实战源码包聚焦小程序端对接LLM大语言模型、实现AI对话能力的完整页面实现。资源包含小程序页面所需的wxml结构、wxss样式、js逻辑、json配置及配套图片素材可直接导入开发者工具运行体验帮助读者快速理解小程序与语言模型交互的基本链路。压缩包共6个文件以png图片、wxss样式表、wxml模板、json配置和js脚本为主整体约386KB体积轻量、结构清晰适合作为学习参考或二次开发起点。借助该页面读者可自定义AI人设例如儿童教育领域专家、商城客服或法律顾问并设定回复语气与具体要求从而灵活适配不同业务场景。目前已有978人学习浏览适合希望低成本上手小程序AI对话功能、探索大模型落地方式的开发者参考借鉴。1. 从一份聊天小程序源码说起LLM 接入到底卡在哪很多人第一次拿到「微信小程序聊天页面 AI 大语言模型」这类源码包第一反应是打开开发者工具点一下「编译」然后盯着模拟器里那个转圈的加载动画发呆。页面能跑气泡能弹但消息发出去石沉大海——因为真正决定这套东西能不能用的不是前端那个聊天 UI而是消息从wx.request出去之后怎么落到大模型接口上再原样流回来。这份源码解决的就是这段「最后一公里」它把小程序端的会话列表、消息气泡、输入框、滚动到底、加载态都写好了同时预留了对接 LLM 的服务端调用层。适合两类人——一类是想快速搭一个能对话的小程序原型、不想从零写 WXML 的开发者另一类是想搞清楚小程序请求链路、流式返回、上下文管理这些细节的熟手。下面按「它是什么 → 怎么接 → 坑在哪 → 怎么调优」的顺序拆开讲能直接抄的部分我都落成代码块。2. 拆开源码包目录结构、请求链路与 LLM 对接点2.1 目录结构与各文件职责拿到包先别急着改代码花五分钟把目录过一遍后面排错能省一半时间。这类聊天小程序源码的典型结构大致如下不同包命名会有差异但职责划分基本一致路径职责改动频率pages/chat/聊天主页面含 WXML/WXSS/JS/JSON高components/message-item/单条消息气泡组件中utils/request.js请求封装统一处理 header、超时、错误高utils/stream.js流式返回解析若支持高config/index.js接口地址、模型名、超时等常量高app.js全局登录态、用户信息初始化低重点看utils/request.js和config/index.js。前者决定请求怎么发、错误怎么兜后者决定你换成自己的模型服务时要改哪几个值。很多人翻车就翻在直接改页面 JS 里的硬编码地址结果换个环境全乱套。2.2 请求链路从小程序到 LLM 再回来小程序端不能直接持有模型 API Key这是铁律。正确链路是小程序 → 你自己的后端或云函数→ LLM 服务 → 原路返回。源码里通常会在config/index.js留一个baseUrl指向这个中间层。// config/index.js export default { // 指向你自己的后端服务不要填模型厂商的直连地址 baseUrl: https://your-backend.example.com/api, // 后端暴露的对话接口路径 chatPath: /chat/completions, // 单次请求超时流式场景要放大 timeout: 60000, // 是否开启流式返回 stream: true, // 传给后端的模型标识由后端决定映射到哪个真实模型 model: default-chat }这里baseUrl必须是自己的服务model只是个逻辑名真实模型名放在后端配置里。这样做的好处是换模型不用重新提审小程序改后端一行配置就行。timeout设 60 秒是因为流式返回下长回答可能持续几十秒设短了会在半路被掐断。2.3 消息发送与流式接收的核心逻辑聊天页面的 JS 里发送逻辑一般长这样// pages/chat/chat.js import config from ../../config/index Page({ data: { messages: [], // 会话消息列表 inputValue: , // 输入框内容 loading: false // 是否正在等待回复 }, // 发送消息 async onSend() { const content this.data.inputValue.trim() if (!content || this.data.loading) return // 先把用户消息推入列表界面立即反馈 const userMsg { role: user, content, id: Date.now() } this.setData({ messages: [...this.data.messages, userMsg], inputValue: , loading: true }) try { // 走统一请求封装带上历史上下文 const reply await this.requestLLM(this.data.messages) const aiMsg { role: assistant, content: reply, id: Date.now() 1 } this.setData({ messages: [...this.data.messages, aiMsg] }) } catch (e) { // 失败时给一条本地提示不污染会话历史 wx.showToast({ title: 请求失败请重试, icon: none }) } finally { this.setData({ loading: false }) } }, // 调用后端对话接口 requestLLM(history) { return new Promise((resolve, reject) { wx.request({ url: config.baseUrl config.chatPath, method: POST, timeout: config.timeout, header: { content-type: application/json }, data: { model: config.model, stream: config.stream, // 只传必要字段避免把前端 id 等冗余数据发给后端 messages: history.map(m ({ role: m.role, content: m.content })) }, success: res { if (res.statusCode 200 res.data) { resolve(res.data.content || ) } else { reject(new Error(bad status: res.statusCode)) } }, fail: reject }) }) } })逻辑说明onSend先做本地乐观更新把用户消息立刻显示出来再发请求这样交互不卡顿。requestLLM里对history做了map清洗只保留role和content因为后端和模型接口通常只认这两个字段多传会报参数错误。stream: true时上面这段是简化版真实流式要用wx.request的enableChunked配合onChunkReceived逐块解析下面单独说。参数说明timeout对流式必须放大model是逻辑名messages数组顺序不能乱模型靠顺序理解上下文。2.4 流式返回在小程序端的处理如果后端支持 SSE 或分块返回小程序侧要开enableChunked// utils/stream.js export function streamRequest({ url, data, onDelta, onDone, onError }) { const task wx.request({ url, method: POST, enableChunked: true, // 关键开启分块接收 header: { content-type: application/json }, data, success: onDone, fail: onError }) // 每收到一块数据就解析 task.onChunkReceived(res { const text decodeChunk(res.data) // 需按后端编码解码 // 按行拆分逐行处理 SSE 的 data: 前缀 text.split(\n).forEach(line { const trimmed line.trim() if (!trimmed || !trimmed.startsWith(data:)) return const payload trimmed.slice(5).trim() if (payload [DONE]) return try { const json JSON.parse(payload) const delta json.choices?.[0]?.delta?.content || if (delta) onDelta(delta) } catch (e) { // 半包 JSON 解析失败是正常的等下一块拼上再试 } }) }) return task }enableChunked是流式的前提不开就只能等全部返回。onChunkReceived拿到的res.data是ArrayBuffer要先按后端编码一般是 UTF-8解码成字符串。按行拆分后只处理data:开头的行[DONE]是结束标记。JSON 解析失败不要慌那是半包等下一块拼上再解析即可——这是流式最常见的「玄学」现象其实是分包边界问题。3. 后端对接层把小程序请求翻译成 LLM 调用3.1 为什么必须要有中间层小程序不能存 API Key这是平台限制也是安全底线。中间层干三件事鉴权确认请求来自你的小程序、拼装把小程序的消息格式转成模型接口格式、转发带上 Key 调模型。用云函数或自建服务都行逻辑一样。3.2 一个最小可用的转发实现// server/chat.js —— Node 环境示例 export default async function handler(req, res) { const { messages, model, stream } req.body // 基础校验防止空请求打到模型 if (!Array.isArray(messages) || messages.length 0) { return res.status(400).json({ error: messages required }) } // 只保留合法角色过滤前端可能混入的字段 const safeMessages messages .filter(m [user, assistant, system].includes(m.role)) .map(m ({ role: m.role, content: String(m.content).slice(0, 4000) })) const upstream await fetch(https://your-llm-endpoint/v1/chat/completions, { method: POST, headers: { content-type: application/json, // Key 只存在服务端环境变量里 authorization: Bearer ${process.env.LLM_API_KEY} }, body: JSON.stringify({ model: mapModel(model), // 逻辑名映射到真实模型 messages: safeMessages, stream: !!stream }) }) if (!upstream.ok) { return res.status(502).json({ error: upstream failed }) } if (stream) { // 流式把上游的流原样透传给小程序 res.setHeader(content-type, text/event-stream) upstream.body.pipe(res) } else { const data await upstream.json() res.json({ content: data.choices?.[0]?.message?.content || }) } }逻辑说明先校验messages再过滤角色和截断长度避免超长内容把模型请求撑爆。mapModel把前端传的逻辑名映射成真实模型名换模型只改这一处。流式分支直接把上游流pipe给小程序不做二次缓冲延迟最低。参数说明slice(0, 4000)是单条消息的字符上限按需调整process.env.LLM_API_KEY必须走环境变量不要写进代码。3.3 上下文长度与截断策略模型有上下文窗口上限聊久了历史会超。常见做法是保留最近 N 轮或按 token 估算截断// 保留最近 10 轮对话system 提示始终保留 function trimHistory(messages, maxRounds 10) { const system messages.filter(m m.role system) const dialog messages.filter(m m.role ! system) const kept dialog.slice(-maxRounds * 2) // 一问一答算两轮 return [...system, ...kept] }maxRounds * 2是因为一轮对话包含用户和助手两条消息。system 提示单独拎出来始终保留否则模型会「失忆」丢掉人设。这个策略简单但有效比按 token 精确计算省事代价是可能浪费一点窗口。4. 避坑与排查那些让你怀疑人生的报错4.1 消息发出去了界面一直转圈现象点发送后 loading 不消失也没有报错弹窗。 原因多半是wx.request的fail回调没触发或者 Promise 里reject后没有catch异常被吞了。 解决在requestLLM外层强制加try/catch并在fail里console.error打印完整错误对象同时检查timeout是否设得太短流式场景下 60 秒起步。4.2 流式返回中文乱码现象逐块解析出来的中文是问号或方块。 原因ArrayBuffer解码时用了错误的编码或者没处理跨块的多字节字符。 解决统一用 UTF-8 解码跨块字符要维护一个残留 buffer把上一块末尾不完整的字节拼到下一块开头再解码不能每块独立解码。4.3 真机上请求失败开发者工具正常现象模拟器里一切正常真机预览时接口 404 或超时。 原因开发者工具默认不校验域名真机要求request合法域名在白名单里且必须是 HTTPS。 解决在小程序后台配置 request 合法域名指向你的后端本地调试可临时勾选「不校验合法域名」但上线前必须配好。4.4 上下文越聊越慢、越聊越贵现象对话轮次多了以后响应变慢费用上涨。 原因每次请求都把完整历史发过去token 随轮次线性增长。 解决用上面的trimHistory截断或引入摘要机制——把早期对话压缩成一段摘要塞进 system既省 token 又保留记忆。4.5 消息气泡滚动不到底现象新消息来了但视图停在上面要手动滑。 原因setData更新列表后scroll-view的scroll-into-view没跟着更新或更新时机早于渲染完成。 解决在setData的回调里再设置scrollIntoView为目标消息 id确保渲染完成后再滚动。5. 进阶调优让对话更稳、更省、更像人5.1 用 system 提示稳住人设模型默认回答风格飘忽加一段 system 提示能明显收敛const SYSTEM_PROMPT 你是一个简洁、友好的助手。 回答控制在 200 字以内不确定的信息直接说不知道不要编造。把这段作为messages的第一条role: system传进去配合前面的trimHistory始终保留。实测下来加了 system 之后答非所问的情况少很多尤其是问它「你是谁」这类问题时不会乱答。5.2 超时与重试的边界流式请求不要盲目重试因为重试会导致重复内容。我的习惯是非流式请求失败重试 1 次流式请求失败只提示用户手动重发。重试间隔用指数退避第一次 1 秒第二次 2 秒别一上来就狂发把后端打挂。5.3 验证清单上线前我会强制走一遍这几项缺一项都不放心检查项通过标准真机 HTTPS 请求能正常返回无域名报错流式中文无乱码无半包解析崩溃长对话20 轮后仍能响应token 不爆异常输入空消息、超长消息、特殊字符不崩断网重连提示友好不卡 loading5.4 一个容易忽略的细节setData传大数组会触发性能警告消息列表长了以后每次全量setData会卡。改成只更新新增的那条用路径写法this.setData({ [messages[ idx ]]: msg })或者用concat只推增量。这个改动在消息上百条后体感差异很明显。从那以后我每次接这类聊天小程序都强制先跑一遍真机流式中文和长对话截断这两项再谈别的。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑