从一个想法到可用系统:Hexo 个人网站站内 AI 助手开发复盘
摘要:这篇文章讲的是,为什么我会想到给自己的 Hexo 个人网站加一个 AI 助手,以及这个想法怎样一步步变成真正可用、可控、可维护的站内系统。它记录了从 v1.0 到 v1.1 的完整过程:哪些决策是对的,哪些判断是错的,哪些"测试全绿"背后藏着坑,以及最终我学到的几条工程经验。
这不是一篇教程。它是一份工程复盘。
写它的目的,是回答一个问题:"一个想法,是怎样变成可运行的系统的?"中间经历了哪些错误判断、哪些被测试掩盖的问题、哪些工具链冲突,最后 v1.1 交付了什么、还留下了什么。
如果你正在给个人项目加 AI 功能,或者在做 Cloudflare Serverless 开发,这里的踩坑经历可能比最终架构图更有用。
1. 为什么要给个人网站加 AI 助手
问题的起点
这个想法其实不是从"内容太多、搜索不够用"开始的。
我一直在断断续续地完善这个个人网站。有一次调整页面布局时,我注意到右上角还有一个位置一直没有真正派上用场——它空在那里,只是作为界面的一部分存在,却没有承载任何有意义的功能。
我最初的想法很朴素:给这个位置放上一个真正有用的东西,而不是为了填满界面随便塞一个装饰。
于是我开始认真想:这里放什么,才既符合我的想法,也符合这个网站本身的目的?
我的网站主要用来记录科研、机器人、控制、编程和项目实践。它的定位,是一个技术人记录和分享自己工作的地方。那么,放在这个网站上的功能,最自然的角色,应该是"帮助访客理解和查询本站的内容"。
带着这个方向,我在和 ChatGPT 讨论的过程中,逐渐形成了一个具体的想法:做一个"站内 AI 小助手"。
它既能利用起这个原本没有充分发挥作用的位置,也正好契合一个技术网站"帮助访客理解本站内容"的定位。
确定了方向之后,我才开始认真思考它具体应该做成什么样子。而这一步,才是后面所有设计的真正起点。
一开始的设想
最初我对这个"AI 助手"的设想其实很朴素:在网站角落放一个对话面板,访客问什么,它就回答什么。
但很快我发现,如果真这么做,它就是一个"什么都能聊"的通用聊天机器人——这会带来三个我无法接受的问题:
- 它会瞎编:问网站没有的内容,它也会用模型自身的知识"自信地"编一个答案。
- 无法验证:它说的话没有来源,我不知道该不该信。
- 费用不可控:无限制地调用 API,钱花起来没有边界。
所以"AI 助手"这个想法,从一开始就必须收敛成一个更具体的东西。
一句话定义
这不是一个通用聊天机器人。这是一个知识边界封闭的检索增强(RAG)助手:
- 只能回答网站已公开的内容;
- 所有回答附带可验证的站内来源;
- API 费用受严格预算控制。
这三条,构成了后面所有设计的锚点。
为什么是 RAG + LLM,而不是让模型自由回答
这是最核心的决策,值得单独说清楚。
LLM 的知识是"训练时刻的、泛化的",它不知道我网站上有哪几篇文章、每篇讲了什么。如果我让模型自由回答"这篇文章讲了什么",它只能靠猜。
RAG(检索增强生成)的做法是:先把网站内容建成可检索的知识库,用户提问时先检索出相关片段,再把这些片段连同问题一起交给模型,让它"基于给定材料"回答。
这样模型就从"凭记忆瞎答"变成了"照着材料答"。知识范围被锁死在网站内容上,回答可以被追溯,超出范围时也能诚实地回答"本站没有相关信息"。
这是整个系统成立的前提。 后面遇到的很多 bug,本质上都是这个前提在不同环节被破坏的表现。
2. 整体架构:每个组件承担什么职责
在深入细节之前,先交代这个系统的组成。
| 组件 | 职责 |
|---|---|
| Hexo 静态站 | 内容的源头(Markdown 文章) |
| 知识库构建脚本 | 把 Markdown 解析、切块、索引成 knowledge-base.json |
| 前端 AI 面板 | 用户提问的入口,展示回答与来源卡片 |
| Cloudflare Pages Functions | 后端入口,接收 POST /api/assistant,编排整条链路 |
| BM25 检索 | 在知识库里找出与问题相关的片段 |
| DeepSeek API | 基于检索片段生成最终回答 |
| Cloudflare Cache API | 缓存命中结果,减少重复调用 |
| Cloudflare D1 | Serverless SQLite,做每日预算的原子控制 |
一条请求的完整链路大致是:
浏览器 AI 面板(question + 当前页面 URL + 对话历史)
→ POST /api/assistant
→ 请求校验 / URL 规范化
→ 注入检测
→ 确定性直返(metadata / site overview / contact)★ 命中则直接返回
→ 缓存检查
→ BM25 站内检索
→ Prompt 构建(注入检索片段 + 当前页面信息)
→ D1 预算预留
→ DeepSeek 调用
→ D1 结算
→ 来源回查(模型只给编号,服务端回查真实 URL)
→ 缓存写入
→ 响应过滤后返回
几个贯穿全文的设计原则,先放在这里:
- 确定性任务不经过 LLM:元数据、站点概览、联系方式这类"答案是已知事实"的查询,用代码直接返回,又快又准又不花钱。
- 模型永远不直接输出 URL:模型只返回编号,服务端回查知识库得到真实来源,防止模型编造链接。
- 缓存键覆盖所有影响回答的维度:一旦改了检索/Prompt 行为,必须让旧缓存失效。
- 预算状态机从第一天就正确:每次 API 调用都花钱,费用控制不是附加功能。
3. 知识库与检索:为什么先选 BM25
为什么不用向量检索
这是被问得最多的设计问题。答案是:当前规模不需要。
现在知识库只有几篇文章 + 一个关于页面,构建出几十个语义块、几十 KB 纯文本。在这个规模下:
- BM25 全文检索的准确率足够好;
- 向量检索需要额外的 embedding 调用(花钱)或本地模型(复杂度);
- 混合检索(BM25 粗排 + 精排)等文章数量上去了再加也来得及。
选择原则:用最简单的方案解决当前的问题。BM25 不是最终方案,但它是当前正确的方案。
中文分词:Bigram 的取舍
BM25 假设输入是空格分隔的词,但中文没有天然空格。最简单的方案是 bigram(连续两个字一组):
"并联机构运动学" → ["并联", "联机", "机构", "构运", "运动", "动学"]
Bigram 不完美(“联机”、"构运"不是真实词),但它有两个巨大优势:零依赖(不需要在 Workers 环境加载结巴分词),倾向于提高召回率——宁可先多召回一些候选,再交给后续评分和排序机制筛选。
构建流程:Markdown → 可检索索引
build-knowledge-base.js 做的事,核心是三点:
- 语义切块:按 H2/H3 标题边界切分,相邻短段落合并,超长段落在句子边界拆分。每个 chunk 记录所属章节、位置、是否为摘要。
- 稳定 ID:
document_id由文档 URL 哈希生成。只要 URL 不变,跨构建的 ID 就相同——这对缓存至关重要(ID 变了,所有缓存回答都会失效)。 - 草稿排除:
draft: true的文章不进知识库。这是"知识边界封闭"的一部分。
新文章发出去,构建时自动入库,AI 就能检索到——不需要任何手动操作。
4. 检索评分:五个实际 Bug 和它们的教训
这一节是我踩过的最密集的坑。五个 bug 都指向同一个主题:评分模型是一个需要反复校准的跷跷板,你永远不知道下一个问题会从哪头翘起来。
Bug 1:元数据评分污染了段落相关性。 初版把文档 metadata(标题、标签)和 chunk 内容放在一起打分。结果搜"运动学",第一名是一篇标签含"运动学"但正文几乎不相关的文章——因为标签 +10 分,正文 bigram 重叠才 +1 分。修复:拆成两个独立评分函数,文档级和段落级分开算。
Bug 2:单字 Bigram 假阳性。 搜"机构",召回一堆含"结构"、"机关"的文章,仅仅因为共享一个字。修复:bigram 重叠数设最低阈值,单 bigram 重叠降级计分。
Bug 3:列表查询时同一篇文章占满 Top-K。 搜"有哪些机器人文章",返回的 5 个 chunk 有 3 个来自同一篇。修复:对"有哪些文章"这类列表意图做文档级去重(document_id 去重,每篇文章只展示一次);而普通内容问答仍允许同一篇文章提供多个相关 chunk 作为上下文证据。
Bug 4:"这篇文章讲了什么"把最短段落当摘要。 用户问摘要,检索返回了"致谢"段落(最短的)。修复:引入 chunk 优先级,摘要类查询按 abstract > conclusion > paragraph > code 排序。
Bug 5:中文 URL 编码不匹配。 浏览器传来的 pathname 是百分号编码的,知识库存的是未编码中文,两者对不上,导致"当前页面"识别失败。修复:统一 decodeURI 后再规范化。
这五个 bug 的共同教训是:检索质量不是一个算法问题,而是一堆边界条件的叠加。 每一个单独看都很小,但合起来就是"AI 助手看起来不聪明"的直接原因。
5. 当前页面感知:让 AI 知道你在看哪篇
用户在文章页打开面板问"这篇文章讲了什么",AI 应该知道"这篇文章"是哪篇,而不是全站乱猜。
实现链路是:前端传 page_context.url → 规范化 → 查知识库得到 {url, title, date} → 注入 Prompt。同时检索也给"当前页面的 chunk"加权,让结果更偏向用户正在看的文章。
这个设计本身是对的,但它是后面一系列问题的起点——它让"当前页面"这个上下文贯穿了检索、Prompt、缓存多个环节,任何一环没接好,"这篇文章"就会答错。
6. 元数据直返:不是所有查询都需要 AI
Preview 验证时发现一个问题:问"这篇文章的标题是什么",DeepSeek 返回的是文章摘要,而不是标题。
根因
LLM 的指令遵循是概率性的,不是确定性的。我在 Prompt 里写了"元数据查询应直接返回字段",但模型在正文和标题语义相关时,倾向于综合所有上下文生成回答,而不是简单地提取标题。
Prompt 只能提高概率,不能保证行为。
为什么选择 bypass 而不是继续改进 Prompt
| 维度 | 改进 Prompt | 代码直返 |
|---|---|---|
| 确定性 | 不可保证 | 正则可预测、可验证 |
| 性能 | 需要检索和模型调用 | 无需模型调用 |
| 成本 | 消耗 token | 零 token |
| 正确性 | 可能偏离 | 服务端已知的确定事实 |
所以我在 assistant-core 里加了一层:命中"标题/URL/发布日期"这类元数据意图时,直接从 currentPageInfo 取字段返回,完全绕过 LLM。
这条经验贯穿了后面整个 v1.1:确定性任务,用代码;只有真正的语义理解,才交给 LLM。
7. 后端设计:预算状态机、Provider 抽象、可信链
Provider 抽象
后端把"调用 AI 模型"抽象成标准接口。开发阶段用 Mock Provider(不需要 API Key 就能跑通整个后端),生产切换 DeepSeek Provider,其他代码零改动。
Mock 有个安全限制:只在 development 环境可用。当时我自认为"生产环境即使误配 mock 也会拒绝启动"——这个假设后来在 v1.1 被推翻了(见第 10 节)。
预算状态机
用 AI API 最大的风险是费用失控。所以每个 API 调用都要经历完整生命周期:
reserved → dispatched → settled / rejected / unknown / cancelled
关键设计是"已发送"和"是否可能计费"必须分开:Provider 调用之后的任何错误(网络超时、上游 5xx)都可能导致实际计费。如果不在调用前标记 dispatched,就无法区分"还能回收的预留"和"已经可能扣费的调用"。
来源可信链
这是防止模型"编造 URL"最可靠的方式:模型回答时只引用编号 [1]、[2],服务端用编号回查知识库,取出真实的 title/url。模型生成的任何不在知识库里的引用都会被丢弃。
模型永远无法直接决定返回给客户端的 URL。这个设计实现起来不复杂(几十行),但它是安全边界里非常重要的一环。
注入防护
用户输入是不可信数据,防护的思路是分层:检测明显的恶意意图、转义用户输入里的系统标记、响应只保留白名单字段。
这里我刻意不展开具体的检测规则和阈值——它们属于"防御细节",公开写出来只会帮助绕过。要讲的是一条原则:把用户内容当作不可信数据,在多个边界上做防御,而不是指望一层就能拦住。
8. "测试全绿"不代表系统可用
这是整篇文章最重要的部分之一。
某个阶段,测试报告显示所有模块测试全部 PASS。一切看起来很好。但实际主调用链中,markDispatched 根本没被调用。
五类假象
假象 1:未被调用的代码。 模块测试验证了 Provider 能调用、D1 能读写,但没人验证"主流程按正确顺序调用了所有步骤"。markDispatched 方法加进 D1 模块后,旧的 core 没同步更新——模块接口升级了,组合层没跟上。
假象 2:Mock 和生产 SQL 不一致。 D1 测试里的 Mock 用子串匹配生产 SQL,没命中也不报错,只是落进默认分支返回一个假结果。测试框架不知道 Mock 没命中,它看到了 PASS。
假象 3:无条件真断言。 有些"测试"名称描述得很好,但断言只是 true。它们没有验证任何状态变化。
假象 4:集成测试加载阶段就崩了。 某集成测试文件解析阶段就 SyntaxError,0 项测试实际运行,但阶段报告还在引用它的"预期覆盖"。
假象 5:没有统一入口。 每个测试文件能独立跑,但没有一条命令能验证"整个后端是否就绪"。
经验
模块测试验证"每个零件是否正常";集成测试验证"零件拼起来是否按正确顺序工作";组合测试验证"生产环境的装配方式是否正确"。三者缺一不可。模块测试全绿是最容易获得的信号,也是最容易产生虚假安全感的信号。
后来我建立了 npm run test:4b 统一命令:任何子测试失败,总退出码非零。这成为每次修改后的硬门槛。
9. v1.0 上线了,但它真的"能用"吗
v1.0 按时上线,测试全绿,架构文档很漂亮。但真实访客用起来,问题一个接一个冒出来。
有段时间,我陷入了"打补丁"的循环:每个新反馈都单独修一下,按下葫芦浮起瓢。后来我停下来,决定从前端 → API → Router → RAG → Prompt → DeepSeek → Sources → Cache,完整审查一遍真实运行链路,而不是继续针对单个 bug 打补丁。
这次审查揭开了几个我一直没有正视的真相。
10. 真相一:我以为接通了 DeepSeek,其实是 mock
现象
线上回答很"刻板",很多问题的答案几乎固定。我拿线上回答和 mock-provider.js 里的预设字符串一比对——逐字相同。
原来,线上根本没在调用 DeepSeek。它一直用的是 mock。
定位过程
我没有因为"代码看起来会调用 DeepSeek"就相信它。而是做了几件事:
- 线上
curl真实的/api/assistant,拿到回答; - 把回答和 mock 预设字符串逐字对比;
- 检查 Production 环境的
AI_PROVIDER配置。
结果:最后发现 Production 的 Provider 与运行环境配置都没有按预期生效,因此实际走进了 mock 分支。
根因与教训
这里有两层问题:
第一层,我的代码假设错了。 我在 Provider 抽象里设计了"mock 只在 development 可用",本意是防止误用。但这条防线依赖运行环境配置,而错误的环境配置恰好让这道保护失去了作用。
第二层,Preview 和 Production 是两套配置。 我在 Preview 环境验证过的东西,并不自动等于 Production 也正确。两个环境的变量、绑定、Secret 都要分别确认。
这件事的直接教训是:“代码看起来会调用 X” 和 “X 真的被调用了” 是两回事。 必须用日志、Mock 对照、真实响应这类可验证的证据,确认实际运行链路。这也是我后来在结构化日志里加 provider_type 字段的原因——让 mock 和 deepseek 的成功能一眼区分。
11. 真相二:token 匹配与自然语言之间的鸿沟
v1.1 里有两个问题,本质是同一个根因,值得放在一起讲。
现象
- 问"这个网站主要有哪些内容?",返回的是某篇具体文章的摘要,而不是网站整体介绍;
- 问"网站作者联系方式是什么?“,返回"没有找到相关信息”。
而事实上,"关于我"页面里清清楚楚写着作者的联系方式。
定位
这两个问题,我都是先在本地用 search 函数复现,看 BM25 到底召回了什么。
结果发现:知识库里确实有联系方式(在 About 页),也确实有"网站是做什么的"这类信息(散落在 About 页和各文章里)。但问题在于——
用户问题的用词,和知识库正文的用词,在 token 层面没有重合。
问"作者联系方式",用的是"作者"、“联系方式”;而 About 页写的是"我是谁"、“联系我”、“GitHub”、“邮箱”。两边唯一的交集是"联系"这一个 bigram,低于检索的通过阈值,于是 BM25 召回为空。
根因:token 匹配 ≠ 语义理解
这是 BM25 这类关键词检索的根本局限:它匹配的是"字面",不是"意思"。 "联系方式"和"联系我"在语义上是同一件事,但在 bigram 层面几乎不重叠。
修复:确定性直返,而不是放宽阈值
这里有个容易踩错的坑:有人会想"那我把检索阈值调低一点,让它能召回 About 页不就行了?"
不行。放宽全局阈值会让更多不相关问题也被召回,破坏已有的检索质量。
正确的做法是:这类"答案是已知稳定事实"的查询,根本不走检索,用代码直接返回。
于是 v1.1 增加了两个确定性路由:
- site-router:识别"站点概览 / 最近文章 / 文章列表"三类意图,从知识库文章元数据(标题/日期/分类)直接生成答案和来源;
- contact-router:识别"作者联系方式"意图,从 About 页提取 GitHub 和邮箱,直接返回。
它们都在检索之前拦截,命中就返回,既不经过 BM25,也不经过 LLM,更不花钱。
经验
确定性任务不应该强行交给 RAG/LLM。元数据、站点概览、联系方式——这些"答案是已知事实"的查询,用代码直接返回,又快又准又零成本。把 LLM 留给真正需要语义理解和内容综合的问题。
这一条,和第 6 节的"元数据直返"是同一条原则在不同场景的延伸。到 v1.1 结束时,这条"确定性直返"的路径已经覆盖了:文章元数据、站点概览、作者联系方式三类。
12. 真相三:本地修好了,线上还是错
现象
多轮对话里,第一问"本文研究的是什么机构?“,第二问"它有几个自由度?”。第二问返回"没有找到相关信息"。
一个典型的多轮检索难题
“它有几个自由度"里的"它"是代词,本身没有任何内容信号。conversation 只注入了 Prompt,没参与检索。所以检索层看到的就只是一个孤零零的"它有几个自由度”,"自由度"这个词的信号又很弱,于是召回为空。
初步的修复方向是:对"有当前页面上下文但不是显式’这篇文章’"的查询,放宽当前页 chunk 的通过门槛,让弱信号也能召回当前文章。
这个修复在本地测试通过了。然后我把代码推到 Preview——结果线上还是 no_results。
真正的根因:stale cache
我一开始以为是检索阈值没改对,反复调。后来才意识到问题在别处:
我改了 search.js 的检索行为,但没有让旧缓存失效。
缓存键里有一个手动版本维度,本意是"改了回答行为就手动 +1 让旧缓存失效"。但这次我忘了改。于是 Cloudflare Cache 里还躺着旧的 no_results 结果,用户问"它有几个自由度"这个精确字符串时,直接命中旧缓存,返回了旧的 no_results。
证据是:换一个等价问法(“它具备几个自由度”)就正常返回了正确答案,只有精确字符串命中 stale 缓存。
经验
修改检索/Prompt 行为后,一定要让旧缓存失效。否则"本地已修、线上仍错",而且线上错得让人以为代码没生效。
这条经验的更广含义是:一个系统里影响"最终回答"的维度,都必须显式地参与缓存失效。 漏掉任何一个,就会出现"改了这个没改那个"的隐性不一致。这也是为什么这个系统的缓存键有十几个维度的原因——每增加一个影响回答的变量,就要在缓存键里补上它。
13. 真相四:渲染是另一座山
前面几个问题都是"回答内容不对"。但还有一类问题:回答内容对了,却显示成一堆符号。
现象
技术问题会触发带公式的回答。DeepSeek 返回的是 Markdown + LaTeX 源码,但前端把 **加粗**、\(...\)、\[...\] 这些原样显示了出来。用户看到的是原始语法,而不是渲染效果。
为什么不能直接 innerHTML
这里有一个安全上的关键约束:模型输出是不可信数据,绝不能直接塞进 innerHTML。
如果我把模型返回的字符串直接 element.innerHTML = answer,那么模型输出的任何 HTML/脚本都会被浏览器执行——这就是一个经典的 XSS 漏洞。模型可能因为用户输入的诱导,输出一段 <script> 或 <img onerror=...>,然后被注入页面。
所以必须用"白名单式"的渲染:只解析我允许的 Markdown 结构(粗体、斜体、代码、列表、标题、链接),其余一律按纯文本处理。
零依赖的安全 Markdown 渲染器
我没有引入第三方 Markdown 库,而是手写了一个轻量渲染器:
- 先把 Markdown 解析成结构化的 AST(纯函数,可单测);
- 再用
createElement+createTextNode把 AST 变成 DOM 节点。
全程不碰 innerHTML。这样,<script> 这类内容永远只会变成文本节点,而不会变成可执行的元素。链接也做了白名单校验:只允许站内相对路径和 http(s),拒绝 javascript:、data: 等危险协议。
LaTeX:懒加载 MathJax
公式渲染用 MathJax(站内博客本身就用它,视觉统一)。为了避免给所有页面都加载这个不小的库,前端做成懒加载:只有回答里真的出现了公式,才异步加载 MathJax。
然后我踩了一个更隐蔽的坑:MathJax 的竞态
Markdown 渲染器写好了,测试也过了,Preview 验证却发现:第一条公式回答里的公式,永远显示成源码,但后来某条公式又能正常排版。
这个"忽好忽坏"的现象让我怀疑是异步竞态。最后用一个无头浏览器加载真实 JS、记录每一步的时序,抓到了根因:
MathJax 的 typesetPromise 不是脚本 onload 时就可用的。 脚本加载完成、onload 触发的那一刻,window.MathJax.typesetPromise 还是 undefined——它要等 MathJax 内部异步的 startup.promise 完成之后才被定义。
而我的代码里有一个"如果 typesetPromise 存在才排版"的守卫。于是在 onload 里直接排版时,守卫为假,第一条公式就永远没被排版。后面的消息因为 MathJax 已经就绪,反而能正常排版。
修复是:等 startup.promise 完成之后再排版,并且对单个气泡元素排版,而不是反复排版同一个持久容器。
经验
涉及异步初始化的第三方库,
onload≠ “ready”。要等它的初始化 Promise 完成,才能安全地调用它的 API。这类竞态往往"有时好有时坏",极难靠肉眼定位,必须靠带时序的运行时日志。
同时,安全渲染这件事说明:"把内容显示出来"本身也是一个安全边界。 渲染器必须和"信任模型输出"划清界限。
14. v1.0 → v1.1:一段演化的完整回顾
回头看,v1.0 和 v1.1 解决的是两个不同层次的问题。
v1.0 解决的是"有没有":把一条端到端的链路搭起来——知识库、检索、Prompt、Provider、预算、缓存、注入防护、来源可信链。它是骨架,是地基。
v1.1 解决的是"对不对"和"好不好":
- 接通了真实 DeepSeek(而不是 mock)——“真的能用”;
- 用确定性直返补上了 BM25 的 token 匹配鸿沟(site overview、contact)——“答得对”;
- 修了 stale cache 导致的多轮失败——“改动能生效”;
- 加上了 Markdown/LaTeX 渲染——“显示得好”。
这一轮里最核心的方法论转变,是从"针对单个 bug 打补丁",变成"从前端到后端完整审查真实链路,找到根因再改"。
目前还没解决 / 以后可能优化的
诚实地说,这个系统还远非完美。以下是我清楚知道、但暂时选择不做的:
- 越界问题可能误召回:问本站不存在的内容,有时会因为某个标签命中而召回一篇不太相关的文章,靠 Prompt 兜底让模型"没信息就说没信息"。检索层没有进一步收紧,因为收紧可能破坏现有的标签查询。这是一个需要在"召回率"和"准确率"之间再权衡的问题。
- 异常恢复能力仍可增强:后续可以进一步改善上游瞬时异常时的用户体验。
- 公开接口防护仍可持续增强:随着访问量增长,可以继续完善自动化访问控制与滥用防护。
- 搜索是 O(n) 线性扫描:当前几篇文章无所谓,文章量大了需要倒排索引或向量检索。
- 可观测性仍可增强:后续可以补充更完整的运行指标和趋势分析。
- 较宽的块级公式可能横向滚动:响应式 UI 的小瑕疵。
这些都是"以后可以做",但都不影响当前系统的核心可用性。
15. 写在最后:几条最重要的工程经验
整个项目做下来,代码量不算大(后端十几个文件,千余行核心代码),但边界条件很多。真正花时间的,往往不是写功能,而是处理这些边界。
挑几条我认为最有价值的经验:
1. 先做端到端骨架。 哪怕第一版只返回硬编码的回答,先把整条链路跑通,再逐个模块替换成真实实现。这比把所有模块开发完再拼装安全得多——你能在每一步都验证"替换这个模块后,链路是否还完整"。
2. 预算和计费放在第一位。 每次 API 调用都花钱。状态机要从第一天就正确,不能先用 mock 攒代码、后接真实预算——那样会出现大量假设不一致。
3. 测试要有分层可信度。 真实环境测试 > 组合集成测试 > 单模块 Mock 测试。不能因为 Mock 测试全绿就认为系统可用。当某一层测试不可用时,必须明确标注"这一步还没验证"。
4. 确定性任务不要交给 LLM。 元数据、站点概览、联系方式——这些"答案是已知事实"的操作,用代码完成,又快又准又零成本。不是所有查询都需要 AI。
5. 代码"看起来会调用 X"不等于 X 真的被调用了。 必须用可验证的证据(日志、Mock 对照、真实响应)确认实际链路。Preview 和 Production 是两套配置,要分别验证。
6. 修改影响"最终回答"的任何环节,都要让旧缓存失效。 否则就是"本地已修、线上仍错",而且线上错得极具迷惑性。
7. 模型输出是不可信数据,渲染和安全要一起考虑。 不能因为"要支持 Markdown/公式"就放弃 innerHTML 的安全边界。安全渲染和功能支持不是二选一。
8. 每个修改都要经过 Preview → Production 验收。 单元测试通过只是第一道门槛,真实环境的行为可能完全不同(配置差异、缓存、异步竞态都是单测覆盖不到的)。
给个人网站加 AI 这件事,最终交付的不是一个"聪明的聊天机器人",而是一个知识边界封闭、来源可验证、费用可控、每一步都可追溯的检索系统。它不夸大 AI 的能力,恰恰相反,整个设计都在给 AI 的不可靠性兜底——用检索锁定知识范围,用代码兜住确定性,用预算兜住成本,用来源链兜住编造。
这可能才是"给网站加 AI"真正值得记录的地方。
本文基于 v1.0-ai-assistant 与 v1.1-ai-assistant 的实际项目代码和过程记录撰写。文中涉及的 API Key、内部标识、预算阈值及其他敏感配置均已泛化处理。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)