博客的右下角现在多了一个 🤖 按钮——访客可以问它「博主是谁」「ISR 报错怎么解决」「博客用了什么架构」,它会基于这个站的文章和我的简历作答,答不出就老实承认。这篇文章记录它从方案讨论到上线的全过程:一次在 1.7G 内存服务器上的 AI 落地实践,以及六个教科书级的坑。
一、方案之争:先回答三个灵魂拷问
动手前先想清楚三件事。
要不要本地跑大模型? 我那台服务器总共 1.7G 内存(可用 245M),qwen2.5 的 Q4 量化版至少要吃 2-3G——这不是磁盘空间问题,是内存硬伤。所以生成环节用云端 API:智谱 GLM-4-Flash(免费档),账单风险直接归零。
要不要向量数据库? 算一笔账就清楚了:
76 篇文章 → 切成 273 个知识块(每块 ~700 字符)
每块向量 1024 维 float32 ≈ 4 KB
全部向量 ≈ 1 MB —— 一个 JSON 文件的事
余弦暴力检索:273 次 × 1024 维乘加 ≈ 单次查询 <1ms
Chroma/Milvus 解决的是百万级向量的 ANN 近似检索。数据量小到连内存都装不满时,全量暴力算就是最优解——一个 JSON 文件加三十行 Go,替代一个常驻 200MB+ 内存的 Python 进程。
RAG 后端能用 Go 吗? 太能了。拆开看 RAG 链路:切块是字符串处理、向量化是一次 HTTP 调用、检索是一个 for 循环、拼 prompt 是字符串拼接、流式输出是 SSE 透传——没有任何一步必须 Python。Python 的优势在 LangChain 这类胶水库和脏文档解析生态,而我的场景里,问答助手直接长在博客现有的 gin 服务里,复用部署链路、DB 连接池和中间件,零新增进程。
二、架构与实现
最终链路:
【离线索引】curl 触发 /api/ai/rebuild-index
拉全部文章 + 简历文本 → 按段落切块(~700字)
→ 批量调 embedding API(10条/批) → chunks.json
【在线对话】POST /api/ai/chat
三层风控检查 → 缓存命中?
→ 问题向量化 → 余弦检索 Top5
→ 拼 prompt(系统约束+片段) → GLM-4-Flash 流式透传
几个值得说的实现细节。
切块带标题前缀。 每块开头拼上《文章标题》,保证任何一块被单独召回时都自含上下文——这是低成本高收益的召回质量优化:
for i, c := range chunks { withTitle[i] = "《" + title + "》\n" + c }
防幻觉约束写进 system prompt。 「仅依据提供的资料回答;没有就说『博客中暂无相关内容』;禁止编造」——实测问知识库外的问题时它会老实说没有,而不是一本正经地胡编。
来源标注。 回答末尾自动附参考文章标题,既方便读者跳转原文,也让错误答案可追溯。
三、六个坑,个个真实
坑一:「专属实例」和公共百炼是两个世界
开通 API Key 时拿到的端点是 ws-xxx.maas.aliyuncs.com(专属实例),调用返回 429「余额不足」——但同一把 key 打公共端点 dashscope.aliyuncs.com 直接成功。免费额度挂在公共平台,专属实例是独立计费体系。教训:拿到新端点先用单条请求验证可用性,再接进代码。
坑二:改完代码忘了换二进制
「环境变量明明对了,行为却还是打智谱」——排查半天发现容器里跑的是旧镜像:改完 Go 代码后 compose build 打包的是上次 scp 的老二进制。修复流程从此固定:编译 → scp → build → up 四步缺一不可。
坑三:批量上限不是猜的
embedding 接口按批调用,我按惯例写了 32 条/批,阿里云返回明确的 batch size is invalid, it should not be larger than 10。不同模型的批量上限不同,接入前查文档比试错快。
坑四:volume 挂载被环境变量字符串误判
检测脚本用 grep ai-data compose 文件判断是否已挂载——结果 env 行里的路径 AI_INDEX_PATH: /www/ai-data/chunks.json 命中了关键词,挂载被跳过。容器内目录始终不存在。字符串匹配做状态检测,误判往往来自最不起眼的地方。
坑五:BFF 把流式攒成了一坨(最隐蔽)
gin 端 SSE 逐 chunk flush 没问题,但用户反馈「等几秒一次性吐出」。排查发现 BFF 代理用的是 axios responseType: "arraybuffer"——整个响应被完整缓冲后才转发给浏览器。修复:AI 路径改用 fetch + ReadableStream 直通:
const upstream = await fetch(target, { method, headers, body }); // body 是 ReadableStream,直接透传 return new Response(upstream.body, { status: upstream.status, headers: withNoBuffering(headersOut), });
再补一个 X-Accel-Buffering: no 头防止 nginx 层二次缓冲。上线后逐分片打时间戳验证:相邻 chunk 间隔 2ms,真流式。
这里还有个方法论教训:我的端到端测试只验证了「能读到多个分片」,没验证「分片是否实时到达」——伪流式在这种测试下完全隐形,直到真人体验才暴露。
坑六:缓存永不过期
相同问题的回答缓存在内存 map 里,但重建索引后不清缓存、也没 TTL——发新文章后,老问题会一直命中过时的旧回答。修复:缓存条目带时间戳(24h TTL)+ 重建索引成功即清空全部缓存。
顺带的架构优化:简历作为特殊知识块参与检索,同时在 system prompt 里注入固定身份描述——双层设计让 AI 既「认识」我(任何问题都不跑偏),又能精确召回简历细节(问项目经历时给出具体段落)。
四、成本与风控:让攻击者在数学上无利可图
公开聊天接口必然被刷,防线设计成层层封顶:
第1层 BFF IP 限流 每 IP 每分钟 5 次
第2层 相同问题缓存 24h 内命中零成本
第3层 全局日配额熔断 当天 300 次后直接拒绝服务
兜底 免费模型 GLM-4-Flash 0 单价
最坏情况推演:假设限流被分布式绕过、缓存全不命中,日配额熔断保证每天最多 300 次 LLM 调用——全走免费模型,账单恒等于 0 元。攻击者的最大战果是让问答功能当天不可用,仅此而已。
Embedding 用阿里云 text-embedding-v4(100 万 token 免费额度,全站建索引用掉约 15 万)。至于那些名字带 async 的 2000 万额度模型——它们是离线批处理接口,一次查询要等任务排队,在线 RAG 根本不能用;而且索引和查询必须同一模型(跨模型向量不在同一语义空间),薅那个羊毛毫无意义。
五、上线首日:被真实体验逼出来的五层流水线
文章发出后自己多用几次,问题立刻暴露出来。用一套标准坐标系复盘——召回(找全)→ 重排(排准)→ 纠偏(处理冲突)→ 生成——上线时的实现只算「找一部分 → 直接生成」的简化版,于是补了三轮:
混合检索:语义路之外再加一条关键词硬路
向量检索擅长语义泛化,但对专有名词是灾难:搜 DYNAMIC_SERVER_USAGE,embedding 把它泛化成"报错/异常",反而捞不回专门讲它的那篇文章。解法是双路互补——向量余弦照旧,同时复用博客现成的 MySQL ngram 全文索引再查一路,命中文章按排名加权(第 1 名 +0.30 递减)融合进最终排序。上线实测,精确词问题直接钉中目标文章且来源唯一。
阈值截断:宁可说没有,不强行凑数
Top5 是机械取前五——如果最好的候选余弦也只有 0.2(基本无关),照样会被塞进上下文污染回答。修复:余弦低于 0.35 且无关键词加持的块直接丢弃;全军覆没时明确回答"暂无相关内容"。检索质量的第一原则是知道自己不知道。
时间纠偏:给知识块标上出生日期
同一个主题我写过不止一篇(ISR 的坑就有 140 和 147 两篇),块里没有日期时 AI 无从判断谁新谁旧。切块时把发布年月写进标题前缀(《标题》(2026-08 发布》),prompt 里明示"冲突时以较新资料为准并注明差异"——纠偏层最便宜的实现。
一个典型的边界问题:LLM 吞掉了我钦定的标记
让来源链接与拒答联动时踩了个很有代表性的坑:我在 prompt 里要求模型资料不足时必须回答固定短语,后端用 strings.Contains 精确匹配这个短语来判定"拒答则不附来源"。结果模型第一次输出就把我设计的外壳吞了——要求输出 【博客中暂无相关内容】,它输出了不带括号的版本;再上一版甚至自由发挥成"暂无关于……或穿衣建议的相关内容"。
最终稳定方案:prompt 强制固定句式 + 后端匹配短语本体而非全串。LLM 和传统代码对接的边界处,永远要假设格式化约定会被概率性破坏。
参考来源变成可点击的跳转链接
最初回答末尾的"参考文章标题"是纯文本。这里有个正确性陷阱:让 LLM 自己生成 URL 必然幻觉(编造不存在的文章 ID)。实际做法是检索阶段记录命中文章的 ID(同篇去重、按相关度排序),回答流结束后由 gin 确定性下发一个 sources 结构,前端渲染成可点击链接——URL 来自数据库主键,准确率 100%。顺手解决了另一个细节:AI 回答是 Markdown 格式但气泡按纯文本渲染,引入 react-markdown(默认不执行原始 HTML,天然防 XSS)后列表、粗体、代码块才真正立起来。
六、写在最后
这个项目的完整技术栈:Next.js BFF 流式透传 + Gin 手写 RAG 全链路(切块/向量化/混合检索/融合重排/阈值截断/prompt 组装/SSE 透传/确定性溯源)+ 双供应商 embedding 可配置 + 三层风控熔断 + 简历身份感知。总代码量不到 700 行,月成本 0 元。
它也是我面试时的现成谈材:什么时候该用重型组件(医疗知识库,PDF 解析+本地推理)、什么时候该做减法(个人博客,云 API+文件存储)——同一套架构模式在两种资源约束下的两种落地形态,这比背 LangChain API 有说服力得多。
欢迎来右下角找 🤖 聊聊,它比我更有耐心。