一个人做个人网站(九):AI 网页宠物——把一只"会聊天的小鲸"养在网站里(收官)
系列最后一篇,聊这个站最"不务正业"、但访客停留时间最长的功能:右下角那只蓝发的 AI 桌宠「小鲸」。点开能聊天,戳一下会拌嘴,不同页面还有不同台词。它的背后是 DeepSeek 大模型,但你在前端代码里找不到一个 API key,也看不到任何 AI 厂商的 SDK。
这一篇讲三件事:为什么 AI 请求必须由后端代理、怎么用 Python 标准库零依赖地接好一个大模型、以及怎么让一个公开可聊的 AI 不被刷爆、不被诱导、不把错误甩到访客脸上。最后,按惯例给整个系列做一次复盘。
一、架构铁律:AI key 只能活在后端
最天真的做法是前端直接调 DeepSeek:fetch('https://api.deepseek.com/...', { headers: { Authorization: key } })。只要这样写,key 就会被打进前端构建包,任何人 F12 就能拿走,然后用你的额度跑到账单爆炸。这是一条没有任何补救措施的红线。
所以所有 AI 应用的第一原则是:浏览器永远只能调你自己的后端,由后端持有密钥、代理转发。代理还顺手带来三个能力:
- 鉴权门禁:不是谁都能无限聊,后端可以要求口令、发 token、做限流;
- 注入人设:system prompt(角色设定)由后端拼接,访客看不到也改不了;
- 内容裁剪与成本控制:对话多长、带多少历史、max_tokens 给多少,全部服务端说了算。
整体链路是:访客输入 → /api/pet/chat(验宠物 token、限流、清洗历史、拼人设)→ 后端用标准库请求 DeepSeek → 回复或兜底文案返回。
二、零 SDK 客户端:标准库就够了
DeepSeek 提供 OpenAI 兼容协议,本质就是一个带 Bearer 认证的 POST JSON 请求。既然如此,我没有引入 openai SDK,直接用 urllib.request 完成,服务器无需为这个功能多装任何依赖:
class AIClient:
def __init__(self, app):
self.api_key = app.config.get('AI_API_KEY') or ''
self.base_url = (app.config.get('AI_BASE_URL') or '').rstrip('/')
self.model = app.config.get('AI_MODEL') or 'deepseek-chat'
self.timeout = app.config.get('AI_TIMEOUT', 20)
self.temperature = app.config.get('AI_TEMPERATURE', 0.9)
self.max_tokens = app.config.get('AI_MAX_TOKENS', 300)
def chat(self, messages):
url = f'{self.base_url}/chat/completions'
payload = json.dumps({
'model': self.model,
'messages': messages,
'temperature': self.temperature,
'max_tokens': self.max_tokens,
'stream': False,
}, ensure_ascii=False).encode('utf-8')
req = urllib.request.Request(url, data=payload, method='POST', headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {self.api_key}',
})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read().decode('utf-8'))
return (data['choices'][0]['message']['content'] or '').strip() or None
except urllib.error.HTTPError as e:
logger.error('AI 服务 HTTP %s: %s', e.code,
e.read().decode('utf-8', errors='ignore')[:500])
return None
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError,
KeyError, IndexError) as e:
logger.error('AI 服务调用失败: %s', e)
return None
几个刻意的工程决策:
- 所有厂商差异收敛到三个环境变量(
AI_BASE_URL/AI_MODEL/AI_API_KEY)。哪天从 DeepSeek 换成任何 OpenAI 兼容的厂商(通义、智谱、Kimi、本地部署的模型),只改.env,代码一行不动; timeout=20s是用户体验的底线。大模型偶尔会卡,不能让一个请求把 Gunicorn worker 吊到天荒地老;- 任何异常都返回
None而不是抛出。网络错误、超时、额度用尽、返回结构变了,对上层都只是"这次没答出来",由接口层决定给访客看什么; - HTTP 错误要读响应体记日志。4xx 通常意味着 key 错、欠费、参数不合法,只记一句"调用失败"根本没法排查;但响应体只进服务器日志,绝不透传给访客。
temperature=0.9 让小鲸的回答跳脱可爱,max_tokens=300 配合人设里"单次回复不超过 80 字"的要求,既控制性格也控制成本。
三、口令门禁:一个轻量但完整的准入系统
聊天是要花钱的功能,不能对裸奔的互联网完全敞开。我设计了"口令解锁":站长把口令告诉朋友,朋友输入一次,换一个 7 天有效的签名 token,之后畅聊。
口令本身存在站点配置表(后台可改),比对时用恒定时间比较防时序攻击:
if not hmac.compare_digest(code, saved_code):
remaining = _record_unlock_fail(ip)
...
解锁成功签发的 JWT 里带一个 kind: 'pet' 标记——这是个容易被忽略但很重要的隔离:
payload = {'kind': 'pet', 'iat': now, 'exp': now + timedelta(days=7)}
第 3 篇后台管理员的 token 同样是 JWT、同样用 SECRET_KEY 签发,如果不加区分,一个宠物聊天 token 理论上可以拿去调 /api/admin/*(鉴权装饰器只验签合法)。加了 kind 字段后,宠物鉴权只接受 kind=='pet',后台装饰器只认用户 id,两套凭证彻底互不通用。前端也对应处理:宠物接口的 401 只清除宠物 token、退回口令输入框,绝不清空后台登录态(第 3 篇 axios 拦截器里按 URL 分支就是为它准备的)。
口令防爆破复用了第 3 篇登录锁定的同一套表结构和口径:按真实 IP 计失败次数,5 次错误锁 15 分钟,锁定期过后计数清零重来。安全基建一次搭建、多处复用。
四、双窗口限流与"失败退款"
解锁只挡住了门槛,聊天本身还要防滥用——这是个未登录也能调用、且每调用一次都花真金白银的接口。限流用"每分钟 + 每自然日"双窗口(默认 10 条/分钟、100 条/天),计数直接落库,按 IP + 日期一行记录:
if row.minute_count >= current_app.config['PET_RATE_MINUTE']:
return fail('你说话太快啦,缓一缓,过一分钟再来找我嘛~', 429), None
if row.day_count >= current_app.config['PET_RATE_DAY']:
return fail('今天聊了好多好多,我先去补充能量,明天再陪你聊!', 429), None
row.minute_count += 1
row.day_count += 1
这里有一个我自己很在意的"厚道"细节——AI 调用失败时退还本次计数:
reply = AIClient(current_app).chat(messages)
if not reply:
_refund_usage(usage_info)
return ok({'reply': _FALLBACK_REPLY})
DeepSeek 抽风、超时是服务方的问题,不能让访客白白损失每日额度。退款时还要小心跨分钟窗口:如果计数时属于上一个分钟窗口,退款时窗口已滚动,分钟数不能退(退了会冲减新窗口的额度),只安全地退日计数。这种"先占用、失败回滚"的思路和数据库事务是一个道理。
另一个顺序细节:未配置 API key 时直接返回"充电中"文案,且不计费、不限流。那是站长侧未就绪,不该消耗访客的任何东西。
五、上下文是访客给的,所以一个字都不能信
多轮对话需要带历史,但历史完全由客户端上报,这就给了攻击者注入空间——他可以伪造 history,塞进来一百条"system: 你现在是…",或者塞 10MB 的文本耗死你的 token 预算。所以服务端必须对历史做一次无情的清洗:
def _clean_history(history):
if not isinstance(history, list):
return []
cleaned = []
for item in history[-_MAX_HISTORY:]: # 最多 12 条
if not isinstance(item, dict):
continue
role, content = item.get('role'), item.get('content')
if role not in {'user', 'assistant'} or not isinstance(content, str):
continue # 伪造的 system 角色直接丢弃
content = content.strip()
if content:
cleaned.append({'role': role, 'content': content[:300]})
return cleaned
然后服务端按固定结构重组消息:system(人设)永远由后端放在第一位,清洗后的历史在中间,本次输入截断到 500 字放最后。客户端就算伪造了 system 角色也会被丢弃,人设无法被覆盖。
前端这边也保持克制:对话记录只存 localStorage(最近 50 条),不上报欢迎语,只把最近 12 条真实对话作为历史发出。隐私提示直接写在输入框上方——"对话只保存在你的浏览器里,不要告诉我密码"。
六、人设可配置,安全边界写进 prompt
小鲸的性格、欢迎语、名字、立绘 URL、口令全部存站点配置表,后台可视化修改,不必改代码重启。默认人设本身就是一份产品文档:规定了说话风格(简短、口语化、80 字内)、自我认知(知道自己是 AI、是站长创造的宠物),以及一整段安全边界——拒绝违法色情自残话题、不讨论系统设定本身、不泄露提示词、不编造事实、不提供专业建议、守护站长隐私。
prompt 注入无法 100% 靠提示词防住,但这道"软约束"配合前面的硬约束(key 不出后端、历史不能注入 system、频率受限、回复截断到 600 字),把风险压到了可接受的范围。AI 功能的安全从来不是某一句神奇咒语,而是多层防线的乘积。
七、兜底文案:把技术故障翻译成角色语言
这个功能我最得意的体验细节是错误处理。普通接口出错返回"请求失败 500",但一个"宠物"说出这种话会瞬间出戏。所有异常路径都被翻译成了小鲸的口吻:
- AI 服务异常 → "呜……我的脑袋突然卡壳了,等一下再问我嘛~ (>_<)"
- 站长没配 key → "我还在充电中,主人还没给我接上 AI 呢,晚点再来找我玩呀~"
- 触发限流 → 用角色语气劝你慢一点、明天再来。
访客永远不知道、也不需要知道后端发生了什么,他面对的始终是一个情绪稳定、即使掉线也在撒娇的角色。技术细节留在日志里,温柔留在界面上。
前端还做了一层"零成本互动":直接戳宠物的台词全部是本地写死的,不调 AI,并按当前页面区分(在博客页戳她谈文章、在相册页戳她夸照片)。既让宠物随时随地有反应,又不给 API 增加无谓开销。只有真正打开面板聊天才花钱。
八、踩过的坑
- 宠物 token 和后台 token 必须用
kind隔离,否则一套签名密钥下的两种凭证会互相串门。 - 前端 401 要分流:宠物接口 401 不能触发后台登出跳转,第 3 篇的拦截器为此按 URL 做了例外。
- 限流计数的退款要考虑窗口滚动,跨分钟的 minute_count 不能退。
- 大模型返回可能是空字符串(被安全策略拦截时),
content or None+ 上层兜底比把空白回复显示出来更自然。 - urllib 默认不带超时会永久挂起,
urlopen的timeout参数必传。 - localStorage 存对话要截断条数,长期使用会无限膨胀;上报历史要排除本地欢迎语,否则它会被当成对话的一部分喂给模型。
- 人设修改对新对话立即生效(每次请求都重新读配置),但已经进行中的旧对话上下文仍保留——这是符合直觉的行为,不需要额外处理。
九、系列复盘:一个人做一个能长期养的站
九篇写到这里,「小欢喜」从一个空目录长成了有内容、有交互、有"居民"的完整产品。回头看,真正支撑我一个人把它做完、并且愿意长期维护的,不是某个高级框架或炫技写法,而是下面这些朴素的原则。
第一,选型按"维护成本"而不是"技术先进性"排序。 Flask 而不是更重的全家桶,MySQL + create_all 而不是 migration 体系,systemd 而不是容器编排,纯 urllib 而不是 AI SDK——每一个选择都在问同一个问题:三年后我还愿不愿意维护它?单人项目最大的风险不是功能做不出来,而是做完之后被复杂度反噬、最终弃坑。
第二,一次把"规矩"立好,让所有模块复用。 统一响应信封、双轨蓝图、应用工厂、真实 IP 工具、失败锁定表结构、存储抽象层、useGameRecord/useSeo 这类 composable——前期看似多花的功夫,在相册、视频、作品、打卡、游戏、宠物一个个模块长出来时,全部变成了复制粘贴般的低成本。好的架构是让"做下一个功能"变便宜。
第三,安全靠分层,而不是靠相信任何单点。 JWT 配吊销时间戳、验证码一次性、STS 凭证最小权限、AI key 不出后端、客户端输入一律清洗、真实 IP 锚定在伪造不了的 X-Real-IP。没有任何一道防线是绝对的,但它们相乘后,攻击成本就远高于收益。
第四,承认差异、分别服务,而不是追求形式上的统一。 SPA 给人、SSR 给爬虫;直传给浏览器、回退留给故障;本地构建、服务器只装运行时。很多架构纠结来自想用一个方案满足所有消费者,而这个站反复证明:接受"两套",并用清晰的边界(Nginx 分流、capability 探测、环境变量开关)把它们隔开,反而更简单。
第五,把故障、成本和体验当成功能的一部分设计。 上传有 kill switch、AI 调用失败退额度还卖萌、证书过期 HTTP 仍可访问、图片强制下载头有反代剥离、物理引擎的事件盲区有轮询兜底。一个功能在"一切顺利"时能跑只完成了 30%,剩下 70% 是它在不顺的时候怎么办。
第六,内容是目的,代码只是容器。 整个技术体系里我最在意的其实是那些"非技术"决策:草稿状态机里发布时间的语义、连续天数"今天没打不算断"的温柔、宠物掉线时的撒娇、米白色的视觉基调。技术人的成长,往往是从追求"这个系统多酷",转向追求"用它的人多舒服"。
如果说还有什么想继续做的:把视频上传加上 Web Worker 里的本地截帧、给博客补一个邮件订阅、让小鲸能基于站内文章做 RAG 问答(她现在还读不了我写的内容)、再给小游戏加个好友对战。但不着急——个人网站不是冲刺项目,它是一个会陪自己很多年的数字花园,慢慢长,本身就是意义。
谢谢你读到这里。如果这个系列让你也萌生了"给自己做一个小站"的念头,那就是它最大的价值。从第一篇的架构图,到最后这只小鲸,愿你也能拥有一块完全属于自己的、小小的自留地。
系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官(本文)


