我的博客右下角住着一个「AI 徐康」:访客问它「你写过哪些 Go 的文章」,它会真的去搜我的博客、读出全文再回答,还附上文章链接。整套东西跑在一个免费的 Cloudflare Worker 上,前端一行密钥都没有。这篇文章记录它从「会聊天」到「有知识」再到「会查资料」的完整演进,以及一路踩过的坑。
想做一个什么东西?
静态博客(我用 Astro)天生没有后端,想加一个 AI 助手,最偷懒的做法是把 API key 写进前端直接调大模型——但这等于把钱包挂在门口。所以目标从一开始就很明确:
- 密钥绝不进浏览器:前端只知道一个代理地址
- 不锁定厂商:Claude、DeepSeek、Kimi、任何 OpenAI 兼容中转都能换着用,改环境变量就行
- 它得懂「我」:能回答关于我的技能、项目、博客内容的问题,而不是泛泛而谈
- 别被刷爆:来源校验 + 限流 + 输入输出上限,成本可控
最终形态是三层结构:
浏览器(聊天组件)
│ POST { messages, context }
▼
Cloudflare Worker(代理 + 工具执行,密钥只在这)
│ 按 LLM_FORMAT 转发
▼
大模型 API(Anthropic / OpenAI 兼容任选)
第一步:Worker 代理,把密钥关进笼子
Worker 的核心职责就一句话:把浏览器的对话转发给大模型,密钥用 wrangler secret 存在服务端。但一个裸转发的代理等于公开的免费 API,所以安全设计比转发本身的代码还多。
来源校验要 fail-closed
第一版我写的是「没配置 ALLOWED_ORIGIN 就放行所有来源」,全方位审查时发现这是个隐患:忘了配环境变量,Worker 就变成全网可用的免费接口。改成 fail-closed——不在白名单一律 403:
const allow = (env.ALLOWED_ORIGIN || '').split(',').map((s) => s.trim()).filter(Boolean);
if (!allow.length || !allow.includes(origin)) {
return json(403, { error: 'forbidden origin' }, cors);
}
CORS 头也一样:命中白名单才回放该来源,否则回一个 'null' 占位,让浏览器按预期拦截。
前端传来的东西一律不信
对话历史由前端维护(Worker 无状态),但前端传来的任何东西都要清洗和限长:
function sanitizeMessages(raw) {
if (!Array.isArray(raw)) return null;
const msgs = raw
.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
.map((m) => ({ role: m.role, content: m.content.slice(0, 4000) })) // 单条 4000 字
.slice(-20); // 最多 20 轮
const total = msgs.reduce((n, m) => n + m.content.length, 0);
if (!msgs.length || total > 12000) return null; // 总量上限
if (msgs[msgs.length - 1].role !== 'user') return null;
return msgs;
}
role 白名单、单条限长、轮数限制、总量限制、最后一条必须是 user——每一条都对应一种刷法。
KV 限流:十行代码的固定窗口
绑一个 KV namespace,按「IP + 当前分钟」做 key,超限直接 429:
async function rateLimited(env, ip) {
if (!env.RATE_LIMIT) return false; // 没绑 KV 就放行
const perMin = parseInt(env.RATE_PER_MIN || '12', 10);
const key = `rl:${ip}:${Math.floor(Date.now() / 60000)}`;
const cur = parseInt((await env.RATE_LIMIT.get(key)) || '0', 10);
if (cur >= perMin) return true;
await env.RATE_LIMIT.put(key, String(cur + 1), { expirationTtl: 90 });
return false;
}
不是精确的滑动窗口,但对个人站足够了。用自己的 key 给所有访客用,等于你买单,这一步别省。
一个 Worker 兼容两种厂商格式
Anthropic 和 OpenAI 的接口格式不同(路径、鉴权头、SSE 事件结构都不一样),我用一个 LLM_FORMAT 环境变量区分,Worker 内部把两种上游的流式响应统一解析成纯文本块回传,前端完全不用关心背后是谁:
if (format === 'anthropic') {
// content_block_delta → delta.text
if (evt.type === 'content_block_delta' && evt.delta?.type === 'text_delta') text = evt.delta.text;
} else {
// OpenAI 兼容:choices[0].delta.content
text = evt.choices?.[0]?.delta?.content || '';
}
换厂商 = 改三个环境变量(LLM_FORMAT / LLM_BASE_URL / LLM_MODEL)+ wrangler deploy,前端零改动。
第二步:构建期生成知识库,极简 RAG
代理通了之后,它只是个「套壳聊天」——问它「你写过什么文章」,它会一本正经地编。要让它懂「我」,得喂真实资料。
标准做法是向量数据库 + Embedding 检索,但对一个只有几十篇文章的个人博客来说太重了。我的做法是利用 Astro 的构建期端点,在 src/pages/ai-knowledge.json.ts 里把档案和全部博客导出成一个静态 JSON:
export async function GET() {
const posts = await getSortedPosts();
const data = {
profile: PROFILE, // 姓名、技能、方向、联系方式
posts: posts.map((p) => ({
id: p.id,
title: p.data.title,
description: p.data.description,
tags: p.data.tags,
url: `/blog/${p.id}/`,
content: stripMd(p.body || '').slice(0, 2400), // 清洗 Markdown,截断
})),
};
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=600' },
});
}
stripMd 会去掉图片、注释,代码块截断到 400 字——AI 作答不需要完整代码,但需要知道「这篇文章讲了什么」。
这个 JSON 就是全部的「知识库」。发新文章后 npm run build,知识库自动同步,零维护成本。前端聊天组件发消息前会按问题做一次轻量关键词检索,把最相关的内容作为 context 随请求带上——这就是极简版 RAG。
第三步:函数调用,从「有知识」到「会查资料」
前端检索有个天花板:它只能猜「这个问题可能和哪篇文章有关」,猜错了模型就拿不到资料。更好的方式是把主动权交给模型——让它自己决定查什么。
openai 格式下,Worker 内置了 4 个工具:
| 工具 | 作用 |
|---|---|
search_blog(query) | 按关键词搜博客,返回最相关的几篇 |
get_post(url) | 读某篇文章的完整正文 |
list_posts() | 列出全部文章 |
get_profile() | 取我的档案与联系方式 |
工具的执行也在 Worker 里:拉取上面那个 ai-knowledge.json(isolate 内缓存 5 分钟),在内存里做关键词打分检索。没有向量库,没有额外服务,一个 Array.prototype.sort 就够了。
主体是一个经典的工具循环——模型要求调用工具,Worker 执行后把结果塞回对话,再问一次,最多三轮:
for (let round = 0; round < 3; round++) {
const resp = await callOpenAI(env, { ...base, messages: convo, tools: TOOL_DEFS, tool_choice: 'auto' });
const msg = (await resp.json()).choices?.[0]?.message;
if (msg.tool_calls?.length) {
convo.push({ role: 'assistant', content: msg.content || '', tool_calls: msg.tool_calls });
for (const tc of msg.tool_calls) {
const result = execTool(tc.function.name, JSON.parse(tc.function.arguments || '{}'), kb);
convo.push({ role: 'tool', tool_call_id: tc.id, content: result.slice(0, 4000) });
}
continue; // 带着工具结果再问一轮
}
return textResp(extractOpenAIText(data), cors); // 没有工具调用 = 最终答案
}
效果立竿见影:问「你写过交叉编译的文章吗」,模型会先调 search_blog("交叉编译"),再调 get_post 读全文,最后给出带链接的、有具体细节的回答——而不是含糊其辞。
降级路径必须想好
不是所有中转都支持 function calling。我的策略是三层降级:
- 工具循环:中转支持 tools → 完整能力
- 仅参考资料:工具报错或
LLM_TOOLS=off→ 回退为「前端检索的 context + 普通问答」,仍然有知识 - 纯聊天:连知识库都拉不到 → 至少人格还在
降级是自动的(try/catch 包住整个工具循环),访客感知不到差别,只是答案精度不同。
系统提示词:人格 + 规矩
数字分身最怕两件事:忘了自己是谁,和一本正经地胡说。系统提示词里我重点写了这几条规矩:
- 第一人称「我」,像本人在和访客聊天
- 涉及「我个人」的事实只依据参考资料,资料里没有就明说「这个我暂时没在站点上写过」
- 推荐文章必须给出真实的 Markdown 链接,不要凭空猜
- 绝不杜撰联系方式、隐私或不存在的项目
技术问题可以让模型自由发挥专业能力,但「关于我」的部分必须有据可查——这条边界划清楚,数字分身才敢挂到生产环境上。
踩过的坑
1. 忘配环境变量时的默认行为。 任何「没配置就放行」的逻辑都是定时炸弹,CORS 和来源校验都要 fail-closed。
2. 中转商的 OpenAI 兼容是「大概兼容」。 有的中转 content 返回字符串,有的返回分段数组,有的把文本放在 output_text。我的 extractOpenAIText 最后长成了一个五种格式的兼容器。接中转就别假设响应结构,一律防御性解析。
3. Service Worker 会缓存你的聊天脚本。 博客开了 PWA 离线缓存,发版后聊天界面死活不更新,排查半天才想起是 SW 缓存策略的问题。静态资源加 hash 或对 JS 走 network-first,不然每次发版都像没发。
4. Wrangler 会认错目录。 在 Astro 项目根目录跑 wrangler deploy,它可能把整个站点当成 Worker。始终显式指定配置:npx wrangler deploy --config worker/wrangler.toml。
5. 功能开关放在构建变量里。 前端只在设置了 PUBLIC_AI_ENDPOINT 时才渲染聊天入口——没配就完全不出现,功能可以随时整体下线,不影响站点本身。
成本与总结
这套系统的运行成本:Cloudflare Worker 免费额度(每天 10 万次请求)+ KV 免费额度 + 大模型 token 费。个人博客的访问量下,每月成本基本就是几杯奶茶钱,限流兜底之后心里也踏实。
回头看,整个演进路径是三步:
- 代理:解决「密钥不进前端」,顺手解决多厂商兼容
- 知识库:构建期导出 JSON,用最低成本解决「它得懂我」
- 工具:函数调用把检索主动权交给模型,回答质量再上一个台阶
每一步都独立可用,也都能优雅降级。如果你也有一个静态博客,不妨从第一步开始——完整代码就一个 300 行的 Worker 文件加一个 JSON 端点,一个下午就能跑起来。
有问题欢迎来 GitHub 找我,或者——直接问右下角那个「AI 徐康」也行,它现在真的会查资料了。
评论