‹ 返回博客

个人网站(九):AI 网页宠物——把一只"会聊天的小鲸"养在网站里

2026-09-20 · 约 7 分钟读完 · #编程

一个人做个人网站(九):AI 网页宠物——把一只"会聊天的小鲸"养在网站里(收官)

系列最后一篇,聊这个站最"不务正业"、但访客停留时间最长的功能:右下角那只蓝发的 AI 桌宠「小鲸」。点开能聊天,戳一下会拌嘴,不同页面还有不同台词。它的背后是 DeepSeek 大模型,但你在前端代码里找不到一个 API key,也看不到任何 AI 厂商的 SDK。

这一篇讲三件事:为什么 AI 请求必须由后端代理、怎么用 Python 标准库零依赖地接好一个大模型、以及怎么让一个公开可聊的 AI 不被刷爆、不被诱导、不把错误甩到访客脸上。最后,按惯例给整个系列做一次复盘。

一、架构铁律:AI key 只能活在后端

最天真的做法是前端直接调 DeepSeek:fetch('https://api.deepseek.com/...', { headers: { Authorization: key } })。只要这样写,key 就会被打进前端构建包,任何人 F12 就能拿走,然后用你的额度跑到账单爆炸。这是一条没有任何补救措施的红线。

所以所有 AI 应用的第一原则是:浏览器永远只能调你自己的后端,由后端持有密钥、代理转发。代理还顺手带来三个能力:

  1. 鉴权门禁:不是谁都能无限聊,后端可以要求口令、发 token、做限流;
  2. 注入人设:system prompt(角色设定)由后端拼接,访客看不到也改不了;
  3. 内容裁剪与成本控制:对话多长、带多少历史、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

几个刻意的工程决策:

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,并按当前页面区分(在博客页戳她谈文章、在相册页戳她夸照片)。既让宠物随时随地有反应,又不给 API 增加无谓开销。只有真正打开面板聊天才花钱。

八、踩过的坑

  1. 宠物 token 和后台 token 必须用 kind 隔离,否则一套签名密钥下的两种凭证会互相串门。
  2. 前端 401 要分流:宠物接口 401 不能触发后台登出跳转,第 3 篇的拦截器为此按 URL 做了例外。
  3. 限流计数的退款要考虑窗口滚动,跨分钟的 minute_count 不能退。
  4. 大模型返回可能是空字符串(被安全策略拦截时),content or None + 上层兜底比把空白回复显示出来更自然。
  5. urllib 默认不带超时会永久挂起urlopentimeout 参数必传。
  6. localStorage 存对话要截断条数,长期使用会无限膨胀;上报历史要排除本地欢迎语,否则它会被当成对话的一部分喂给模型。
  7. 人设修改对新对话立即生效(每次请求都重新读配置),但已经进行中的旧对话上下文仍保留——这是符合直觉的行为,不需要额外处理。

九、系列复盘:一个人做一个能长期养的站

九篇写到这里,「小欢喜」从一个空目录长成了有内容、有交互、有"居民"的完整产品。回头看,真正支撑我一个人把它做完、并且愿意长期维护的,不是某个高级框架或炫技写法,而是下面这些朴素的原则。

第一,选型按"维护成本"而不是"技术先进性"排序。 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 网页宠物与收官(本文)