我的博客右下角住着一个「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。我的策略是三层降级:

  1. 工具循环:中转支持 tools → 完整能力
  2. 仅参考资料:工具报错或 LLM_TOOLS=off → 回退为「前端检索的 context + 普通问答」,仍然有知识
  3. 纯聊天:连知识库都拉不到 → 至少人格还在

降级是自动的(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 费。个人博客的访问量下,每月成本基本就是几杯奶茶钱,限流兜底之后心里也踏实。

回头看,整个演进路径是三步:

  1. 代理:解决「密钥不进前端」,顺手解决多厂商兼容
  2. 知识库:构建期导出 JSON,用最低成本解决「它得懂我」
  3. 工具:函数调用把检索主动权交给模型,回答质量再上一个台阶

每一步都独立可用,也都能优雅降级。如果你也有一个静态博客,不妨从第一步开始——完整代码就一个 300 行的 Worker 文件加一个 JSON 端点,一个下午就能跑起来。

有问题欢迎来 GitHub 找我,或者——直接问右下角那个「AI 徐康」也行,它现在真的会查资料了。