我先说结论这件事能做成而且做成了之后你手里相当于多了一个随身携带、数据完全不出本机的 AI 秘书。但你要是照着网上那些教程直接抄大概率会在登录、回调、上下文、内存这几个环节逐个翻车。我前后折腾了三个晚上把所有能踩的坑都踩了一遍这篇文章把我最终的方案、源码思路和每一处翻车的修复链路完整写出来你可以直接照着搭。先说清楚我到底做了什么把本地部署的大模型Ollama 拉起的那种通过一套消息中转服务接入微信让微信变成大模型的对话入口。你在微信里给机器人发消息它能带着上下文连续对话而且完全不用把对话内容发到任何云端 API。适合自用、小团队内部用、或者单纯想折腾的人。如果你是第一次接触“本地 AI 接入微信”这个概念那么这篇文章就是为你准备的。1. 先泼一盆冷水本地模型接入微信到底图什么很多人一听到“接入微信”第一反应是做营销机器人满群自动回复、关键词触发、广告推送。我先把这个想法掐掉个人微信的自动化操作一直处于灰色地带轻则掉线重则限制我做完之后也只敢在自己的号和几个测试群里跑。真正有价值的场景是下面这几个你在外面用手机随时给本地模型丢一段文字让它总结、翻译、写邮件草稿云端那些服务你一个都不需要。你不想把聊天记录、私有数据发给任何一个外部接口。本地部署的大模型保证对话内容只在你的机器里。团队内部搭一个共享的 AI 助手成员用微信就能访问内网知识库或文档检索不需要每个人都去学命令行。所以这个项目图的是“私密、可控、随时可用”。如果你要的是营销裂变、自动加粉请到此为止别往下看了。技术本身是中性的但用法必须自己想清楚。1.1 三种方案对比个人微信、公众号、企业微信我在动手前先对比了三条路每条路都有致命缺点选错了后面全是坑。方案一个人微信 Hook 框架这是网上讨论最多、最“酷”的方案。通过 Hook 微信进程或者拦截协议监听收到的消息调用本地模型再自动回复。优点是完全模拟真人可以在任意群、任意联系人之间对话周围人看不出是机器人。缺点是稳定性差微信一升级可能就失效登录扫码有时候会被风控自动化程度高的时候有封号风险。我自己实测下来纯自用、消息量不大短期内没问题但如果拿去做营销大概率活不过一周。技术选型上这个方案在 Node.js 生态里首选 Wechaty配合不同的 puppet协议实现使用。我后面用的就是这条路。方案二微信公众号服务号/订阅号公众号有官方 API消息收发稳定、合规官方给你发消息的接口你只需要一个公网服务器接收事件推送。缺点是公众号的本质是“粉丝给号主留言”不是普通聊天而且服务号每个月群发次数有限制订阅号的消息被动回复交互还行但主动推送能力很弱。更麻烦的是你想让 AI 主动说点什么也很别扭。公众号适合做对外客服、内容订阅不适合做“随时问一嘴”的个人助理。而且如果你只是自己用申请公众号还需要审核太慢了。方案三企业微信企业微信有完备的 API 接收消息且支持机器人 webhook合规性最好。如果你公司已经用了企业微信这是最稳妥的企业内部 AI 助手落地方案。缺点是它毕竟不是个人微信你没法用自己平时聊天的微信号跟它说话另外消息回调需要做签名验证、并且字段结构比个人微信复杂不少。三种方案放在一起结论很明显自用且追求体验选个人微信对外服务选公众号企业内部选企业微信。1.2 我的最终选型我最后选了“个人微信 Wechaty Ollama 本地大模型”的组合。理由很简单Wechaty 的 API 设计对消息监听、发消息、群聊处理很友好几行代码就能跑通收发。Ollama 是目前本地部署大模型最省心的工具一条命令ollama run qwen2.5:7b就能把模型跑起来还暴露了 HTTP API方便程序调用。整个链路的私密性最强所有请求都发生在我的机器上。这个组合踩坑最多但也最值得写。2. 架构先行微信、消息管道、模型层怎么分工很多新人犯的错是一上来就对着 Wechaty 写回调函数结果联动、上下文、超时全混在一起代码越写越乱。我建议先画清楚链路。不要指望有一个大函数把所有事干完分布式拆开后面排查问题会省很多时间。整个链路由三层组成接入层Wechaty 监听微信消息拿到消息文本、发消息的人、所在群。调度层一个本地 Node.js 服务负责维护每个会话的上下文、调用模型、处理并发和重试。模型层Ollama 服务负责任何真正消耗算力的推理工作。消息走的方向是微信消息 - Wechaty 回调 - 调度层组装上下文 - 请求 Ollama - 得到回复 - 调度层把回复交给 Wechaty - 发回微信。为什么中间要插一个调度层因为 Wechaty 的回调是串行事件流但模型推理是耗时的同步操作。如果你在回调里直接“同步等模型返回”模型推理 30 秒微信端的消息就卡住 30 秒期间任何新消息都会被阻塞。更严重的是如果同时有三个人发消息三个人会抢同一个回调上下文回错人就发生了。这也是我后面踩到的最大的坑。2.1 为什么要用“会话 ID”隔离微信里区分一个对话非常容易如果是私聊用联系人的 wxid 作为会话 ID如果是群聊用群 ID。每一次收到消息调度层就根据会话 ID 取出该会话的历史记录拼上这条新消息丢给大模型得出回复之后把这段问答追加回历史记录。这就是经典的“多会话上下文隔离”。实现上我维护了一个内存里的 Mapkey 是会话 IDvalue 是一个数组保存最近 N 轮对话内存有上限超过后做一次滚动丢弃这样每个人、每个群都有自己的记忆互不干扰。2.2 在线、离线消息与排队策略微信消息是异步到达的模型推理是串行资源。我的做法是给每个会话建一个 FIFO 队列消息来了先入队调度层按顺序处理。同一个会话的多条消息严格串行避免乱序不同会话之间轮询处理避免一个人刷屏把别人阻塞。排队还有个好处如果模型正在跑一个长问题其他消息不会直接丢失而是排队等待。这样用户体验比直接丢消息好得多。你会看到微信端偶尔延迟几秒回复但至少不会吞消息。3. 保姆级部署从空机器到回复第一句话假设你现在手里有一台 Mac 或 Linux 机器Windows 也能跑但会遇到更多奇奇怪怪的问题后面坑里说。下面是从零到通的完整步骤。3.1 环境准备我们需要三样东西Node.js 18、Python 3.10可选主要是后处理脚本、Ollama。安装 Node.js 我用的是 nvm避免系统权限问题curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc nvm install 18 nvm use 18安装并启动 Ollamacurl -fsSL https://ollama.com/install.sh | sh ollama serve然后拉一个模型。个人自用我推荐qwen2.5:7b中文能力强、显存要求不高16G 内存的 Mac 都能跑得很顺。ollama pull qwen2.5:7b如果你显卡是 8G 显存可以试试qwen2.5:3b速度更快但聪明程度明显下降。机器差就别勉强7B 是体验和资源的最佳平衡点。验证 Ollama 是否就绪可以直接敲命令curl http://localhost:11434/api/generate \ -d {model: qwen2.5:7b, prompt: 你好, stream: false}能返回 JSON 就说明模型层通了。3.2 初始化 Node 项目和安装依赖mkdir local-wechat-ai cd local-wechat-ai npm init -y npm install wechaty npm install qrcode-terminalWechaty 的安装有时候会因为网络问题卡住国内环境建议先配好 npm 镜像。装好之后写一个最小的启动脚本只做一件事登录并打印二维码。// step1-login.js const { WechatyBuilder } require(wechaty); const qrcodeTerminal require(qrcode-terminal); const bot WechatyBuilder.build({ name: local-ai-bot, }); bot.on(scan, (qrcode, status) { qrcodeTerminal.generate(qrcode, { small: true }); console.log(请使用微信扫码登录); }); bot.on(login, (user) { console.log(登录成功${user}); }); bot.on(logout, (user) { console.log(退出登录${user}); }); bot.start() .then(() console.log(启动中等待扫码...)) .catch(console.error);运行node step1-login.js终端里会输出二维码。手机微信扫一下终端显示登录成功第一步就通了。这一步如果卡住那你接下来会进入这个项目最折磨人的阶段登录不了。这个问题我单独放在后面的神坑章节里讲。3.3 把“收到消息”和“调用模型”接起来登录通了的下一步就是把收到消息时的回调接到 Ollama。先写一个最简版本不处理上下文只做单轮问答const { WechatyBuilder } require(wechaty); const axios require(axios); const bot WechatyBuilder.build({ name: local-ai-bot }); async function askLocalModel(prompt) { const resp await axios.post(http://127.0.0.1:11434/api/generate, { model: qwen2.5:7b, prompt, stream: false, }); return resp.data.response; } bot.on(message, async (message) { if (message.self()) return; if (message.type() ! bot.Message.Type.Text) return; const text message.text().trim(); if (!text) return; try { const reply await askLocalModel(text); await message.say(reply); } catch (e) { console.error(调用失败, e); await message.say(本地模型暂时抽风了请稍后再试); } }); bot.start();跑起来之后你用微信给机器人发一句“你好”它大概率会回你一句自我介绍。到这里最简链路已经通了剩下所有工作都是让它更好用、更稳定、更不易翻车。4. 核心源码逐段拆解别抄了就跑上面那段代码能跑但只能单轮对话没有任何上下文记忆也容易并发错乱。下面是我最终在用的核心代码框架我会拆开讲每一段都在干什么。4.1 消息监听只处理要处理的微信里的消息类型很杂文本、图片、语音、视频、文件、位置、名片。本地大模型擅长处理文本图片需要额外走视觉模型或多模态流程语音要先转文字视频就直接忽略。我的策略很简单只处理Text类型以及把语音当作文本来处理需要额外换模型暂时不考虑。图片消息可以选择把路径传给支持视觉的模型比如qwen2.5-vl但这个对显存要求高属于进阶玩法。另外必须过滤掉自己发的消息否则机器人的回复会再次触发回调形成“我回我我又回我”的死循环。这个细节最容易被忽视一旦出事日志会像刷屏一样。bot.on(message, async (message) { if (message.self()) return; const room message.room(); const contact message.talker(); const text message.text(); if (message.type() ! bot.Message.Type.Text) { console.log(忽略非文本消息, type${message.type()}); return; } const sessionId room ? room:${room.id} : contact:${contact.id}; console.log([${sessionId}] 收到: ${text}); enqueueMessage(sessionId, text); });这里有个很关键的取舍群聊里收到的消息不仅包含机器人被 的情况还包含所有群成员的普通聊天。如果全量回那个群就炸了。所以我在群聊场景下加了一个判断逻辑消息文本中是否包含机器人的名字或者是否被 。只有满足条件才处理。function shouldHandleInRoom(text, room) { if (!room) return true; const selfName bot.currentUserSelf.name(); return text.includes(selfName) || text.includes(); }4.2 会话管理滑动窗口保持记忆大模型本身没有记忆。每一次调用都是独立的上下文完全靠你在请求里塞多少历史。最粗暴的做法是把这个会话所有消息全部拼进去但 token 会爆炸回答会越来越慢还会超长报错。我的方案是滑动窗口每个会话最多保留 12 条历史消息约 6 轮对话。新消息到达时先把历史数组取出来拼上当前消息再调用模型。模型回复后把“用户提问 模型回复”追加进历史数组。如果数组超过 12 条从最前面丢弃 2 条避免频繁截断。为什么丢弃 2 条而不是 1 条因为对话的最小单元是一条问和一条答丢 1 条会破坏问答配对让上下文变得诡异。class SessionManager { constructor(maxLen 12) { this.maxLen maxLen; this.sessions new Map(); } get(sessionId) { if (!this.sessions.has(sessionId)) { this.sessions.set(sessionId, []); } return this.sessions.get(sessionId); } append(sessionId, userMessage, assistantMessage) { const history this.get(sessionId); history.push({ role: user, content: userMessage }); history.push({ role: assistant, content: assistantMessage }); if (history.length this.maxLen) { history.splice(0, 2); } } buildPrompt(sessionId, currentMessage) { const history this.get(sessionId); const parts []; for (const item of history) { parts.push(${item.role user ? 用户 : 助手}${item.content}); } parts.push(用户${currentMessage}); return parts.join(\n); } }这个 SessionManager 是整个服务的中枢。每个会话独立维护不会串号也不会无限增长。4.3 模型调用用流式还是非流式Ollama 的/api/generate支持两种模式流式streamtrue和非流式streamfalse。非流式简单等模型全部生成完一次性返回。流式会通过 SSE 一点点返回 token。我最终选了非流式。原因只有一个词简单。本地 7B 模型在无 GPU 的 Mac 上生成 200 字大概要 20~40 秒流式返回的中间片段还得想办法从微信端发出去体验并不好反而容易把消息拆得七零八落。非流式配合“先回复一条’正在思考…’”的体验比流式更稳。async function askLocalModel(prompt, systemPrompt 你是我的个人助手请用简洁直接的中文回答。) { const messages [ { role: system, content: systemPrompt }, ]; const historyLines prompt.split(\n); for (const line of historyLines) { const idx line.indexOf(); if (idx -1) continue; const role line.substring(0, idx) 用户 ? user : assistant; const content line.substring(idx 1); messages.push({ role, content }); } const resp await axios.post(http://127.0.0.1:11434/api/chat, { model: qwen2.5:7b, messages, stream: false, options: { temperature: 0.7, num_predict: 2000, }, }); return resp.data.message.content.trim(); }这里我用了/api/chat而不是/api/generate。因为/api/chat直接接受 OpenAI 风格的 messages 数组system、user、assistant 角色清晰不需要自己手动拼 prompt 字符串。这让上下文管理变得干净很多。4.4 发送回复先安抚再干活本地模型慢用户等太久会以为机器人死了。我的处理方式是收到消息后先立刻回一条“收到思考中...”然后等模型返回后再把正文发出去。但这引入了另一个问题短时间发两条消息在微信端会被折叠体验反而不好。我最后的折中方案是只对可能超过 10 秒的请求发“思考中...”的提示短问题直接憋住等结果。判断逻辑很简单——看会话历史长度和当前消息是否包含“总结/解释/写”这类重任务关键词。async function handleMessage(sessionId, text) { const shouldDelay text.length 30 || /(总结|解释|写一|详细|分析)/.test(text); if (shouldDelay) { // 这里的 replyTarget 需要从外部传入代码里做了简化 await sendText(sessionId, 收到让我想一下...); } const prompt sessionManager.buildPrompt(sessionId, text); const reply await askLocalModel(prompt); sessionManager.append(sessionId, text, reply); await sendText(sessionId, reply); }不要小看这个细节。我实测过没有“思考中”提示时用户会在 10 秒左右开始怀疑是不是挂了然后连发三条追问把上下文彻底搅浑。加了提示之后整个交互就顺了。5. 神坑复盘我踩过的坑与修复链路这一章节是全文的核心标题说“踩齐了所有神坑”你在这里看到的每一个坑都是我真实遇到过、并且最后给出了修复方案的。我会按“现象 - 排查过程 - 根因 - 修复”的顺序写方便你对照复现。5.1 坑一二维码扫了四五次登录总是失败这是最劝退的坑。我第一天晚上就是倒在这里。现象脚本正常启动终端输出二维码手机扫码后提示“请在手机上确认”但微信端没有最终确认脚本迟迟不触发 login 事件。排查过程我开始以为是网络问题反复重启后来又怀疑二维码过期重新生成很多次。最后注意到日志里 scan 事件返回的 status 变了开始是 200 表示等待扫描后来变成 408 表示过期。根因我用的是 Wechaty 默认的 puppet本质是 Web 微信协议。但现在大量微信账号已经无法登录网页版微信官方页面会提示“当前版本已不支持”。扫码后手机端不能确认就是这个原因。修复换用支持非 Web 协议的 puppet。这一步需要获取一个 token配合 puppet 服务来走手机协议。我换成了wechaty-puppet-wechat这套方案通过注入方式实现绕开了 Web 微信的限制。换完之后扫码几乎秒登录没有再掉过。需要说明的是这个方案属于非官方手段只建议个人自用并且别拿去做营销。另外一个提高成功率的小技巧首次登录后会在本地生成凭证文件下次启动时只要凭证没过期就不需要重新扫码。5.2 坑二两个人同时发消息回复串号了现象我在一个测试群里和一个小号同时发消息过一会儿发现 A 的问题回复给了 BB 的问题回复给了 A。看了一眼代码整个人麻了。排查过程一开始怀疑是会话 ID 的问题但我打印了 sessionId发现每次消息的 sessionId 都是正确的。后来我加了时间戳日志发现第十秒收到了 B 的消息第十一秒模型返回了 A 的答复发送动作却用到了当前消息的会话上下文也就是说回复被异步竞争污染了。根因每个消息的回调都是异步函数模型调用是耗时操作。消息到达的顺序是 A、B但模型返回的顺序可能是 B、A。我的代码里如果用全局变量或循环变量去记录“当前消息”就会出现 B 的回复被塞进 A 的会话里。修复引入“会话任务队列”。同一个 sessionId 的消息逐个处理不允许并发。实现上我写了一个简单的 Promise 链const queues new Map(); function enqueueMessage(sessionId, text) { if (!queues.has(sessionId)) { queues.set(sessionId, Promise.resolve()); } queues.set(sessionId, queues.get(sessionId).then(async () { await handleMessage(sessionId, text); }).catch(err { console.error([${sessionId}] 处理失败:, err); })); }这段代码看起来简单但它把“同一个会话的消息串行化”变成了不可逾越的约束。此后我再也没有遇到过串号。5.3 坑三模型推理慢到微信端直接超时现象第一次完整跑通时我给机器人发了“帮我写一份周报”它沉默了整整一分钟。微信端既没有错误提示也没收到任何回复这个体验是灾难级的。排查过程看了一下日志模型实际推理花了 42 秒。我的机器是 Apple Silicon 16G 的统一内存跑 7B 量化模型速度也就那样。于是我开始想怎么缩短时间。根因有两层一是模型量化等级低推理慢二是我没有任何“慢任务”提示用户以为服务挂了。修复分为两部分模型层面换用更激进的量化版本比如qwen2.5:7b-q4_K_M速度比默认高不少效果损失可以接受。交互层面加入前面说的“收到让我想一下...”提示。另外我还设置了超时重试机制。如果单次请求超过 90 秒断开此次连接并给用户发一条失败提示把上下文保留允许用户追问。不要因为一次卡死就把整个会话丢掉。5.4 坑四上下文越聊越慢最终直接报错现象连续聊了 20 轮之后模型响应时间从 10 秒慢慢涨到 25 秒最后直接抛context length exceeded错误。排查过程看错误信息后我突然意识到会话历史一直在增长。最开始我设计了 maxLen但在实现时图省事直接用了history.push后不做截断maxLen 形同虚设。日志里打印出来的 prompt 长度从几百涨到了几千 token。根因滑动窗口没有真正生效。修复回到 SessionManager强制在 append 后检查长度并裁剪。同时我在调用模型前打印一条日志记录 token 数超过 8000 直接主动丢弃历史的一半而不是等到模型报错。注意改完上下文管理逻辑之后务必重启并连续测试 30 轮以上确认不会第二次踩到超长。千万别只测一轮就说好了。5.5 坑五服务跑两天之后内存爆掉现象发消息的响应速度越来越慢我用ps aux一看Node 进程内存占用从启动时的 120MB 涨到了 1.2GB。排查过程先怀疑是 Wechaty 内部缓存网上也有人提到。但后来我把自己代码里的 session 数量打印出来发现测试过程中创建了几百个 session而且每 session 都存了 12 条历史消息。群聊里非 的普通消息也进了队列导致无意义 session 暴涨。根因session ID 的 key 太“散”了。我在早期版本中把群内每一条非 消息也生成了 session但没有处理逻辑白白占内存。另外真实的消息对象被保存在了历史数组里消息对象内部嵌套大量字段更吃内存。修复只有真正需要回复的消息才创建 session。session 设置了空闲过期时间48 小时不活跃就删掉。历史数组中只保存字符串文本不保存消息对象。function cleanExpiredSessions() { const now Date.now(); for (const [id, session] of sessions.entries()) { if (now - session.lastActiveAt 48 * 3600 * 1000) { sessions.delete(id); } } } setInterval(cleanExpiredSessions, 60 * 60 * 1000);5.6 坑六中文乱码与表情符号处理现象模型返回的内容里有 emoji发到微信后变成了问号或空白。有些长文本里的换行符也消失了一坨挤在一起没法看。排查过程一开始以为是微信接口的问题后来我在终端里直接打印模型返回的内容发现数据本身是完整的。问题出在消息发送链路当消息里包含多个星号、反引号等 Markdown 符号时微信会把它们当成格式标记吃掉或者某些字符在传输时被转义。根因个人微信的自动化协议对特殊字符的兼容性并不好同时 Ollama 返回的内容是 Markdown 风格直接发给微信体验极差。修复写了一个清洗函数function sanitizeReply(text) { return text .replace(/[\s\S]*?/g, (m) \n${m.replace(//g, )}\n) .replace(/[#*~]/g, ) .replace(/\n{3,}/g, \n\n) .trim(); }不同模型的输出风格不一样清洗规则也要跟着调。我一开始清洗太激进把代码块里的缩进也删了导致发送的代码没法看。后来改成只去掉零散符号保留代码块完整。6. 稳定性治理从跑通到长期跑项目跑通和项目能长期稳定跑是两个完全不同的问题。我把这套东西放在那跑了三个月中间经历过掉线、内存泄露、模型卡死。提升稳定性的核心做法是这三点。6.1 用进程守护接管存活Node 服务绝对不能裸奔在前台一个意外异常就能让整个服务死掉。我用的是 PM2npm install -g pm2 pm2 start server.js --name wechat-ai pm2 save pm2 startupPM2 会自动守护进程崩溃后重启。我额外加了max_memory_restart参数让进程内存超过 600MB 时自动重启pm2 start server.js --name wechat-ai --max-memory-restart 600M这样就算代码里还有隐藏的内存泄漏也能通过自动重启兜底不会一夜之间死透。6.2 多群隔离与权限控制如果机器人同时被拉进多个群你绝对不希望所有群都能使唤它更不希望陌生人加你微信就能用你的算力。我配置了一个白名单机制私聊只有白名单里的联系人才能触发 AI。群聊只有白名单里的群 ID 才会响应。const ALLOWED_CONTACTS new Set([wxid_xxx, wxid_yyy]); const ALLOWED_ROOMS new Set([xxxchatroom]); function isAllowed(sessionId) { if (sessionId.startsWith(room:)) return ALLOWED_ROOMS.has(sessionId.slice(5)); return ALLOWED_CONTACTS.has(sessionId.slice(8)); }不在白名单直接忽略。这个机制避免了很多无用调用消耗算力也防止别人在一个测试群里把模型问麻。6.3 日志与可观测性我的日志统一走 JSON 格式每条日志包含时间、会话 ID、行为类型、耗时。这样排查问题时可以直接用日志文件定位{time:2025-01-15 10:23:45,session:contact:wxid_xxx,event:recv,content:你好} {time:2025-01-15 10:24:20,session:contact:wxid_xxx,event:reply,content:你好我是本地AI助手,cost:35}我还加了一个简单的健康检查接口每隔 5 分钟请求一次 Ollama如果失败就发一条提醒消息给我自己告诉我模型挂了。这个机制救了我好几次半夜偷偷看的都知道。7. 最后说几点这套东西的核心价值不在于代码本身而在于你想清楚了自己为什么需要它。我见过很多人花几天时间搭好了机器人新鲜劲一过就熄火了。对我来说它是我每天真正在用的小工具临时查资料、整理思路、起草信息手机微信上随手就发。最后分享两个小技巧。第一个微信消息的自动回复尽量保持简短200 字以内的回复体验比长篇大论好非常多可以在系统提示词里加一句“回复控制在 200 字以内”。第二个不要把服务搭建完就不管了每周看一次日志尤其关注那些被忽略的异常报错早点处理早点安心。这摊子事不难但需要耐心。
