《AI 功能的本质是工程:个人站 AI 从 0 到 1 实战》
一天为个人站从0到1搭建完整AI能力闭环,详解工程实践与架构规范
- AI功能本质是工程:模型调用仅占一行代码,核心是上下文管理、成本控制与体验细节
- 一个模型接入层服务对话、摘要、写作三场景,通过适配层抹平多供应商差异
- 匿名用户用日盐IP哈希限流,配合成本熔断保护小水管服务器
- 问答日志复用用量流水表扩列设计,两段式分组查询支撑统计看板
- 规范是防退化疫苗:分层红线、规则唯一化需写入文档并坚持自查
阅读说明:本文按「一 → 八」顺序展开,可线性读完;赶时间可只读 一、二、六(产品视角)或 四、五、七(技术视角)。文内代码与配置均为示例片段。
一、前言:一天时间,给个人站装上一套「AI 能力」
如果你是一个独立开发者的读者,你大概会好奇:给一个已经成型的站点加上 AI,到底要动多少东西?今天我从 0 到 1 完成了站内 AI 的完整闭环——前台问答、内容摘要、后台模型配置、用量日志、成本核算,并顺手做了一轮架构审查,把大文件和重复代码的隐患按进了规范里。
这篇文章有两条阅读线,你可以按需取用:
| 你是谁 | 怎么读 |
|---|---|
| 普通用户 / 产品向读者 | 看「白话解读」小节、功能表格和示意图,了解 AI 功能由什么组成、数据怎么流动、你的隐私怎么被保护 |
| 开发者 / 学习向读者 | 跟着「实现细节」走一遍技术路线:流式输出、检索增强、配额控制、方言适配、日志聚合,最后有一张完整的学习路径图 |
先放一张成果总览:
| 模块 | 用户可见的功能 | 背后的技术 |
|---|---|---|
| 站内问答面板 | 「问 AI」按钮 → 流式回答 + 引用来源 | SSE 流式 + RAG 检索 + 上下文缓存 |
| 文章摘要卡 | 文章顶部 AI 摘要与关键词 | 摘要生成任务 + 结果缓存 |
| 智能应用 · AI 配置 | 预设 / 方言 / API Key / 连通性测试 | 配置中心 + 环境变量兜底 |
| 智能应用 · 模型配置 | 模型增删改、档位绑定(对话/摘要/写作) | 多供应商模型注册表 |
| AI 问答日志 | 明细合并视图 + 统计看板 | 用量流水表 + 两段式分组查询 |
| 配额与成本 | 「今日剩余 X / Y 轮」提示 | 日盐哈希 + Redis 计数 + 成本熔断 |
二、功能全景:一套「一站三用」的 AI 能力
整个功能的设计收敛为一句话:一个模型接入层,服务三种使用场景(对话问答、内容摘要、写作辅助),全部围绕「个人站已有内容」展开,而不是做一个通用聊天玩具。
mindmap
root((站内 AI))
前台体验
问问这个站
全站问答
问这篇文章
深度思考模式
文章 AI 摘要
剩余轮数提示
后台管理
AI 配置
供应商预设
方言适配
连通性测试
模型配置
档位绑定
AI 问答日志
明细合并视图
统计看板
Credits 核算
运行保障
内容安全护栏
配额与熔断
用量流水
回答缓存
白话解读:把 AI 想象成一个「住在你网站里的图书管理员」。前台是读者问问题的窗口,后台是给管理员发工资、排班、记账的地方,运行保障则是防止管理员说错话、超时工作、花超预算的规章制度。
三、核心链路:一次「问问这个站」背后发生了什么
以用户点击「问 AI」并发送一个问题为例,完整链路如下:
sequenceDiagram
participant U as 用户浏览器
participant F as 前端面板
participant B as 后端 AI 模块
participant R as 检索/缓存
participant L as 上游 LLM
U->>F: 输入问题并发送
F->>B: POST /ai/chat/stream(SSE)
B->>B: 护栏校验(长度/注入/净化)
B->>B: 配额检查(Redis INCR)
B->>R: 查回答缓存(提问规范化哈希)
alt 命中缓存
R-->>B: 回放缓存答案
B-->>F: usage + done(cached=true)
else 未命中
B->>R: 检索相关内容(RAG)
B->>B: 组装 Prompt(引用块 + 近几轮上下文)
B->>L: 流式请求(方言适配)
L-->>B: token 流
B-->>F: delta 逐段推送
B-->>F: citation / usage / done
B->>B: 落用量日志 + 存缓存
end
F-->>U: 逐字渲染 + 引用来源
值得注意的三个细节:
- 配额在入口就消费。问题发出即计一轮,不管模型答得快慢——这样重试也占额度,防止刷接口。
- 缓存命中是「秒回」的。提问经过规范化后取哈希,命中就直接回放上次的答案流,token 消耗记 0,日志标
cached=1。 - 每一轮都会落一条日志,无论成功、失败、中止还是被护栏拦截——这就是后台「问答日志」页的数据来源。
四、后端实现细节(NestJS)
4.1 分层红线:Service 只写自己的表
架构审查发现的第一类问题,是「越权」:模型服务直接注入了配置表的 Repository 去改配置。修复方式是跨模块只允许方法调用:
// ❌ 违规:model.service 直接注入别的模块的表
class AiModelService {
constructor(
@InjectRepository(AiModel) private modelRepo: Repository<AiModel>,
@InjectRepository(AiSetting) private settingRepo: Repository<AiSetting>, // 越界!
) {}
}
// ✅ 正确:通过对方 Service 的公开方法
class AiModelService {
constructor(
@InjectRepository(AiModel) private modelRepo: Repository<AiModel>,
private setting: AiSettingService,
) {}
async bind(role: AiModelRole, modelId: string, userId: number) {
await this.setting.bindModelRole(role, modelId, userId)
}
}
白话解读:每个 Service 是一个部门,只能动自己的档案柜;需要别人的资料,就发内部邮件(方法调用),而不是自己跑去翻别人的柜子。好处是规则只有一处,改口径不会漏。
4.2 规则唯一化:同一张表的规则只写一处
配置表的「判空 / 环境变量铺底 / 数值安全转换」曾经在两个 Service 里各写一套。抽成工具文件后两处 import:
// setting-row.util.ts —— 全站唯一的行级规则(示例)
export function isSettingRowInitialized(row: AiSetting): boolean {
return row.apiKey !== null || row.baseUrl !== null || row.model !== null
}
export function fillSettingRowFromEnv(row: Partial<AiSetting>): void {
// 空壳行用环境变量铺底,保证「首次配置前」也有可用值
// 注:环境变量名为示意,非真实键名
row.apiKey ??= process.env.AI_API_KEY ?? null
row.baseUrl ??= process.env.AI_BASE_URL ?? null
}
export function toSafeNumber(value: unknown, fallback: number): number {
const num = Number(value)
return Number.isFinite(num) ? num : fallback
}
4.3 方言适配:一个函数抹平供应商差异
不同厂商的 Chat Completions 接口「形似而神不似」——参数名、鉴权头、思考模式各有怪癖。抽一个纯函数统一组装请求:
// chat-request.util.ts(示例)
export function buildChatRequestBody(
compat: AiCompat,
input: ChatRequestBodyInput,
): Record<string, unknown> {
const body: Record<string, unknown> = {
model: input.model,
messages: input.messages,
stream: !!input.stream,
}
// 某些厂商无 max_tokens,只能 max_completion_tokens;OpenAI 系则相反
body[compat.maxTokensField] = input.maxTokens
const thinking = input.thinking !== undefined ? input.thinking : compat.thinking
if (thinking) body.thinking = thinking
if (input.stream && compat.supportsStreamUsage) {
body.stream_options = { include_usage: true }
}
return body
}
对照表如下(字段名取自各家公开文档):
| 差异点 | OpenAI 系 | 国产推理系 A | Anthropic |
|---|---|---|---|
| 最大输出参数 | max_tokens |
max_completion_tokens |
max_tokens |
| 鉴权头 | Authorization: Bearer |
Authorization: Bearer |
x-api-key |
| 思考模式 | 无 | thinking 开关 |
extended |
| token 用量 | 流末尾stream_options |
同左 | 消息级返回 |
学习要点:适配层的价值在「新增一家供应商 = 新增一个配置对象」,业务代码零改动。
4.4 配额与匿名识别:日盐 IP 哈希
匿名用户没有账号,怎么限流又不侵犯隐私?答案是当天盐值的 IP 哈希:
/** 访客哈希:IP + 日盐,只用于配额与去重,不做画像 */
export function visitorHash(ip: string): string {
return createHash('sha256')
.update(`${localDate()}|${ip || 'unknown'}`)
.digest('hex')
.slice(0, 32)
}
/** 查询剩余轮数(不消费配额) */
async getRemaining(userId: string | null, visitorHash: string) {
const [anonUsed, userUsed] = await Promise.all([
this.redis.getKey(this.anonKey(visitorHash)),
userId ? this.redis.getKey(this.userKey(userId)) : Promise.resolve(null),
])
return {
anonRemaining: Math.max(0, quota.anonDaily - (Number(anonUsed) || 0)),
userRemaining: userId
? Math.max(0, quota.userDaily - (Number(userUsed) || 0))
: null,
}
}
| 身份 | 标识 | 日上限 | 隐私说明 |
|---|---|---|---|
| 匿名 | `sha256(日期 | IP)` 前 32 位 | 10 轮 |
| 登录 | userId | 100 轮 | 正常账号体系 |
再加一层成本熔断:当日累计花费超过阈值自动关闭匿名入口。小水管服务器的自我保护。
白话解读:就像游乐园发的手环——当天有效、第二天作废、不记名,只用来数「每人今天玩了几次」。
4.5 内容安全护栏:三道防线
把「文章正文 + 用户提问」都当作不可信输入:
| 防线 | 时机 | 做什么 |
|---|---|---|
| 输入校验 | 提问入口 | 长度上限、空问题拒绝、敏感词初筛 |
| 注入检测 | Prompt 组装前 | 识别「忽略以上指令」类越狱话术 |
| 输出净化 | 回答出口 | 移除模型可能泄露的系统提示词片段 |
4.6 问答日志:不新建表的扩列设计 + 两段式分组查询
「问答日志」听起来该建新表?其实用量流水与问答日志本就是同一份数据的两个视角。给它扩 6 列即可(表名、列为示意):
ALTER TABLE `ai_usage_log`
ADD COLUMN `question` varchar(500) NULL COMMENT '提问原文(截断500)',
ADD COLUMN `mode` varchar(16) NULL COMMENT 'default|deep',
ADD COLUMN `scope` varchar(16) NULL COMMENT 'site|post',
ADD COLUMN `post_slug` varchar(128) NULL COMMENT '问这篇的文章',
ADD COLUMN `ip` varchar(64) NULL COMMENT '仅后台可见',
ADD COLUMN `cached` tinyint NOT NULL DEFAULT 0 COMMENT '命中缓存';
明细页还支持「同一提问者同一天合并成一组」——两段式分页查询:先按 (提问者, 自然日) 分组取组头(COUNT(*) OVER() 省一次 count),再对本页的组批量取组内明细(ROW_NUMBER() 截断防爆):
SELECT COALESCE(NULLIF(user_id, ''), NULLIF(ip, ''), 'unknown') AS source_key,
DATE_FORMAT(created_at, '%Y-%m-%d') AS visit_day,
COUNT(*) AS cnt,
COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens,
COUNT(*) OVER () AS total -- 分页总数随行返回
FROM ai_usage_log
WHERE scene = 'chat' AND created_at >= ?
GROUP BY source_key, visit_day
ORDER BY MAX(created_at) DESC
LIMIT 20 OFFSET 0
4.7 成本核算:CNY 与 Credits 双口径
一个有意思的坑:模型表里的「每百万 token 单价」填的是 0——因为用的是订阅制套餐(包月按 Credits 扣量),不是按量计费。于是统计看板补了 Credits 折算(系数取自套餐官方文档):
| 模型档位 | 命中缓存输入 | 未命中输入 | 输出 |
|---|---|---|---|
| 轻量档(flash 类) | 2 Credits/M | 100 Credits/M | 200 Credits/M |
| 旗舰档(pro 类) | 2.5 Credits/M | 300 Credits/M | 600 Credits/M |
Credits ≈ 命中缓存token/1M × 2 + (输入-命中)/1M × 100 + 输出/1M × 200
细节:夜间 0~8 点官方按 0.8x 计,但我们在统计里没有折算(服务器时区存疑,宁可少算不多算),只在界面上标注了——统计口径要诚实。
五、前端实现细节(Vue 3 / Nuxt)
5.1 流式渲染:SSE over Fetch
前端用 fetch + ReadableStream 手动解析 SSE 事件流,逐段推入消息气泡:
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 逐条解析 "event: delta\ndata: {...}\n\n",推送 delta / citation / usage / done
}
5.2 组件拆分:defineModel 双向绑定
后台弹窗、卡片拆分遵循「父页面只管状态,子组件只管表单」的套路:
<!-- 子组件 ModelFormDialog.vue -->
<script setup>
const visible = defineModel<boolean>('visible', { required: true })
const editing = defineModel<AiModelItem | null>('editing')
const emit = defineEmits<{ saved: [list: AiModelItem[]] }>()
// form / saving / submit 全部自己维护,父页面不感知中间态
</script>
<!-- 父页面 index.vue -->
<ModelFormDialog
v-model:visible="dialogVisible"
v-model:editing="editingModel"
@saved="onSaved"
/>
5.3 过渡动画与按钮系统
弹出面板用 Vue <Transition> 做双向过渡(进场弹性、出场干脆):
.ai-panel-enter-active {
transition: opacity 0.22s ease-out,
transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.ai-panel-leave-active {
transition: opacity 0.16s ease-in, transform 0.16s ease-in;
}
.ai-panel-enter-from { opacity: 0; transform: translateY(10px) scale(0.95); }
右下角悬浮按钮(AI / 目录 / 回到顶部)统一成 42px 圆钮、同色系、等距 12px 垂直排列,多套主题各有适配样式——这类「视觉一致性」的工程量,往往比功能本身还多。
六、统计看板能回答哪些问题
| 你可能想知道 | 看板给出的字段 |
|---|---|
| 今天多少人用了 AI? | 问答总轮数、独立来源、会话数 |
| 用得多不多? | Token 消耗(入/出拆分)、Credits 消耗、总成本 |
| 答得快不快? | 平均首字耗时(TTFT)、平均总耗时、成功率 |
| 哪个模型最火? | 模型分布(轮数 / Credits / 成本) |
| 谁问得最多? | 活跃访客 Top 10(来源 + 轮数 + Token + 最近提问) |
| 用户满意吗? | 反馈有用率(点赞/点踩占比) |
七、技术路线图:想复刻该学什么
flowchart TD
A[会写 Vue/Node 基础 CRUD] --> B[阶段一: HTTP 流式]
B --> B1[SSE 协议与 EventSource]
B --> B2[fetch ReadableStream 手动解析]
B --> C[阶段二: LLM 接入]
C --> C1[Chat Completions 协议]
C --> C2[供应商差异与适配层]
C --> D[阶段三: 质量与成本]
D --> D1[RAG: 嵌入检索/关键词检索]
D --> D2[护栏: 注入检测/输出净化]
D --> D3[配额: Redis 计数/熔断]
D --> E[阶段四: 可观测]
E --> E1[用量流水表设计]
E --> E2[聚合统计 SQL]
E --> E3[成本双口径核算]
E --> F[阶段五: 工程化]
F --> F1[分层红线与规则唯一化]
F --> F2[组件拆分与设计系统]
F --> F3[文档与规范沉淀]
| 阶段 | 学什么 | 关键产出 | 难度 |
|---|---|---|---|
| 一 · HTTP 流式 | SSE 协议、ReadableStream |
逐字输出的聊天框 | ⭐⭐ |
| 二 · LLM 接入 | Chat Completions、适配层 | 多供应商可切换 | ⭐⭐ |
| 三 · 质量与成本 | RAG、护栏、Redis 配额 | 回答有据可查、花销可控 | ⭐⭐⭐ |
| 四 · 可观测 | 用量表、窗口聚合 SQL | 统计看板 | ⭐⭐⭐ |
| 五 · 工程化 | 分层规范、组件化、文档 | 可长期维护的代码 | ⭐⭐⭐⭐ |
给普通读者的建议:不必按顺序全学。如果你只是想「看懂 AI 产品在做什么」,读完第一、二节就可以合上文章了——核心直觉是:AI 功能 = 模型 + 上下文 + 约束 + 记账。
八、结语
一天的实现沉淀下来的不只是功能,还有两句话:
- AI 功能的本质是工程:模型调用只占一行代码,剩下全是上下文管理、成本控制和体验细节。
- 规范是防退化的疫苗:大文件、重复规则、越权调用,这些病都是慢性发作的,把红线写进文档并坚持自查,是唯一低成本的疫苗。
如果这篇文章帮你看懂了什么,或者你在自己的项目里踩过更有趣的坑,欢迎交流。

评论 0
还没有评论,快来抢沙发吧~