RAG / AI 落地 · 第 2 篇
上一篇:同专栏《在博客系统里嵌入 RAG:分块、混合检索与 Tool 编排》· 在线:/tool/ai · /tool/ai-summary
写在前面
上一篇讲知识怎么进库、怎么检索。本篇讲 token 怎么流到浏览器:三条 SSE 路径职责不同,密钥归属不同,失败时的「降级」也不是同一套策略。
若把它们揉成一个「AI 网关」,会出现:用户自备 Key 却被站内配额卡住、摘要密钥泄漏到前端、或 RAG 没 citations 却假装在引用站内文。
三条链路对照
| 入口 | 后端 | 密钥谁出 | 要不要登录 | 有没有知识库 |
|---|---|---|---|---|
| 右下角 FAB | POST /rag/query-stream |
服务端 rag 配置 |
要 JWT | 有(检索 + Tool) |
/tool/ai |
POST /pub/ai-stream |
浏览器表单里的 apiKey | 否(公开接口) | 无 |
/tool/ai-summary |
Nuxt POST /api/ai-summary-stream |
Nitro AI_SUMMARY_API_KEY |
否 | 无(粘贴正文) |
一、RAG 流式:query-stream + AI SDK UI Message
请求侧
- Guard:
JwtAuthGuard - 问题:
question或 AI SDKmessages最后一条 user(resolveRagQuestion) - 多轮:历史最多 4 轮、总长 3000 字(
extractRagChatHistory);检索仍只基于当前问题,历史只帮 LLM 理解「它 / 刚才那篇」
流协议(不是裸 OpenAI chunk)
响应头带 x-vercel-ai-ui-message-stream: v1,事件大致为:
start+messageIddata-citations(检索引用,流一开始就下发,前端可先画来源)text-start→ 多个text-delta→text-endfinish+data: [DONE]
写入工具:writeUiMessageSse / writeUiMessageSseDone。
LLM 本体仍是 OpenAI 兼容 chat.completions(streamChat,temperature: 0.3),只是对外包装成 UI Message Stream,方便 FAB 与 Vercel AI SDK 对齐。
配额:先检查,流真正开始再扣次
text
assertQuota(uid) // 已满 → 429,写 log status=quota_exceeded
prepareQuery(...) // 检索失败 → 不扣次
首个 stream chunk → consume(uid) // Redis incr,按自然日 TTL
默认日上限见 RAG_DAILY_QUERY_LIMIT(20,可被配置覆盖)。
设计点:检索失败或未连上模型不扣次数,避免「库挂了用户白嫖完额度」。
流中途报错:若已 streamStarted,写 error 事件收尾,不把半截连接变成未处理异常;日志 status=success|failed 异步进 rag_query_log。
二、工具箱对话:/pub/ai-stream(用户自备 Key)
/tool/ai 用浏览器 SSE 库 POST 到 Nest pub/ai-stream,body 含:
messages、model(如deepseek-chat/deepseek-reasoner)apiKey、baseURL(用户自己填)
服务端只做 透传流式代理,不拼站内知识库。
安全:SSRF 闸门
assertSafeAiBaseUrl:
- 仅
https - 禁止
localhost/.local/.internal - 禁止常见私网与元数据 IP
否则用户把 baseURL 指到内网 Redis/元数据,服务器会替他「代打」。
推理模型
deepseek-reasoner 路径会同时转发 delta.reasoning_content 与 content,前端分栏展示「思考 / 回答」。SSE 每帧必须以 \n\n 结尾,且 HTTP 状态先 200——注释里写过:不设 200 时部分客户端收一帧就断连。
与 RAG 的边界
/pub/ai-stream |
/rag/query-stream |
|
|---|---|---|
| Key | 用户自带 | 站内配置 |
| 配额 | 无站内日配额 | Redis 日配额 |
| 引用 | 无 | citations + Tool |
| 风险 | SSRF / Key 出现在浏览器 | Key 不出前端,要防滥用(配额+登录) |
三、文章摘要:Nitro 代理,不经 Nest
/tool/ai-summary → POST /api/ai-summary-stream(跑在 Nuxt 进程):
- 密钥:
AI_SUMMARY_API_KEY(nuxt.config→runtimeConfig) - 上游写死 DeepSeek
chat/completions,stream: true - 正文总长上限 8000 字
- 未配置 Key → 503;上游 401/403 原样映射,其它多归 502
为何不走 Nest?摘要是「随 Home 发版」的工具页能力,和博客 API 鉴权解耦;密钥留在 SSR 侧,浏览器拿不到。自文档 15 也写过:AI 路由分两条。
uni-app 侧有对等的 use-ai-summary / startSseStream(H5 fetch stream;小程序 enableChunked),协议仍是 data: …\n\n + [DONE]。
四、「降级」分别指什么
别混谈一个词:
| 场景 | 实际策略 |
|---|---|
| Embedding API 挂了 | EmbeddingService 可落 local hash(开发向);生产应修配置,见 A1 |
| RAG 开关关 / 未配置 | assertEnabled → 503,助手不可用 |
| 日配额用尽 | 429,提示明日再来 |
| RAG 流已开始后上游断 | SSE error + 收尾,已扣次(流已启动) |
| 摘要未配 Key | Nitro 503,页面提示设 env |
/tool/ai 用户 Key 错 |
上游错误经 SSE/异常返回,站内不「换备用 Key」 |
刻意不做的:用户 Key 失败时静默换成站内 Key——那会把工具箱成本打到你账单上,且边界再次糊掉。
踩坑与取舍
-
两种 SSE 方言
RAG 用 UI Message(text-delta);工具箱/摘要用 OpenAI chunk JSON。前端不能共用一个 parse。 -
citations 先于正文
先推引用再推字,弱网时用户先看到「根据哪几篇」,体验比最后甩来源好。 -
摘要 8k 上限
防一次把整本小说塞进 DeepSeek;超长应先本地截断或分段(产品层可再做)。 -
pub 代理不是免费 AI
公开接口 + 用户自备 Key 是功能,不是「站点代付」。监控上仍要防异常大流量打爆你的出口带宽。 -
生产 env 要对齐
Nest:rag_*/ embedding;Home:AI_SUMMARY_API_KEY。改生产 env 记得同步部署实际读取的文件(见仓库prod-env-deploy-sync规则)。
小结
| 链路 | 一句话 |
|---|---|
| RAG SSE | 登录 + 配额 + 检索/Tool + UI Message Stream |
| 工具箱 AI | 用户 Key 透传 + HTTPS/防 SSRF |
| 摘要 | Nitro 持钥代理 DeepSeek,随 Nuxt 发版 |
和上一篇合起来,才是完整的「博客里嵌 AI」:知识怎么来(A1)+ 字节怎么流、钱和密钥谁出(A2)。
关键路径
blog-server/src/modules/rag/core/rag.controller.tsblog-server/src/modules/rag/core/rag-ui-message-stream.util.tsblog-server/src/modules/rag/quota/rag-quota.service.tsblog-server/src/modules/features/pub/pub.controller.ts(ai-stream)blog-home-nuxt/server/api/ai-summary-stream.post.tsblog-home-nuxt/pages/tool/ai/index.vueblog-home-uniapp/src/utils/tool/sse-stream.ts

全部评论(0)