和一个 AI agent 一起搭博客:决策、死胡同与最终上线的东西

Read in English

AI 问答 · DeepSeek

和一个 AI agent 一起搭博客:决策、死胡同与最终上线的东西

  • meta
  • astro
  • cloudflare
  • ai
  • deepseek

在承认自己不想运维服务器之前,我花了一个下午对比 VPS 价格。

最终做出来的博客是静态的:Astro 页面跑在 Cloudflare 上,一个小 Worker 负责登录和 Ask AI,D1 存用户和配额,图片在 R2。它已经在 awmthink.com 上线。域名是唯一的固定成本。

有三件事反复出现:我在中国大陆,大多数读者不在;我不想要主题(theme)。

这是一篇搭建记录。代码在仓库里。我把决策写下来,因为代码解释不了它们——尤其是我做错的那些。

我真正需要什么

内容必须是 Git 里的 Markdown。不要 CMS,不要编辑器,不要文章数据库。我还希望一个 AI agent 能在这个站上干活,这意味着仓库必须容易解释:几个目录、少量命令、一份写明“什么不能碰”的文档。

我把工作拆成了几轮:

轮次 内容 决策
第一轮 可读的文章、代码高亮、图片、SEO 静态 HTML、Astro、Markdown
之后 GitHub 登录、匿名降级、配额 OAuth App + 签名 cookie + D1
之后 对段落/小节 Ask AI 构建期切片 + SSE + DeepSeek
更晚 评论、搜索、统计 暂缓
更晚 在页面里跑代码 只做了设计,没上线

被我省掉的东西:多作者、日更、主题市场、通用 CMS。我不是在做平台。我要的是一个放文章的地方,加文章周围的几个功能。

托管、认证、AI 的选择最后都回到同一个矛盾:我希望这个站在中国和在其他地方一样能流畅访问,同时不让它变成一份运维工作。这个矛盾决定了站点住在哪里、多少逻辑跑在边缘上。

托管在哪

方案 国内访问 国际访问 备案 成本
国内云服务器 最快 国际出口不稳定 需要 $4–9/月
香港/新加坡 VPS 尚可,100–200ms 好 不需要 $4–9/月
境外静态托管 + CDN 可用,150–300ms 最好 不需要 $0

国内服务器对中文读者最快,但也意味着 ICP 备案:实名域名、备案服务商的服务器、一到三周的审核,以及个人站能做什么的种种规定。我不想把一个写作项目的第一个月花在流程上,也不希望计划好的交互依赖一次审核。

香港或新加坡免备案、延迟尚可,但那样我就要运维一台服务器:TLS、备份、系统更新、监控。这些我都做过,不难,但那是第二份工作。

所以我选了 Cloudflare 上的静态托管:自定义域名,无备案。中文读者的延迟从 20ms 变成 150–300ms——对文字来说没问题。如果流量证明值得,国内镜像以后再加。

我还关掉了 workers_dev 和预览 URL,不服务 www。会话 cookie 和 OAuth 回调绑在一个主机名上,第二个主机名只会割裂登录态。

托管的故事就这么多。域名是唯一的固定成本;DeepSeek 按量计费,其他一切在这个量级下都在免费层内。我喜欢这套架构小到可以一个月不管它。

为什么是 Astro

我装过的主题多到知道第一周会怎么过:一下午一个漂亮的首页,然后一个月都在对抗主题的假设。我要的是反面:一小份能从头读到尾的代码。

Astro 是框架,不是主题。Content collections 给每篇文章一个带类型的 frontmatter schema;页面默认是静态 HTML;代码高亮在构建期完成。哪个功能需要时我可以加一个 React 或 Svelte 小岛,但文章页不引入任何客户端框架。

我看过 Quarto 和 Jupyter Book,因为我很多笔记从 notebook 起步。jupytext 可以把 .ipynb 和一个 Markdown 文件配对,站点渲染 Markdown、notebook 保持源文件。最后选 Astro,是因为我想发布的多数内容是文字和代码,而且这个站需要为认证和 Ask AI 写自定义路由。Quarto 对 notebook 更好,对这张清单上的其他一切更差。

Cloudflare 适配器有两个意外。Adapter 14 移除了 Astro.locals.runtime,绑定改从 import { env } from 'cloudflare:workers' 来。它还默认想开启 KV session 和 Cloudflare Images,我两个都不用:

session: false,
adapter: cloudflare({
  imageService: 'passthrough',
  remoteBindings: false,
}),

remoteBindings: false 比看起来重要。开着的话,本地开发可能直接绑到生产 D1——随手测一个问题就在消耗真实配额,结果还取决于我当时是否登录着。我宁可本地失败。

图片不在 Astro 构建里。它们在一个 R2 bucket,从 img.awmthink.com 以一年缓存分发,我单独写过。短版本:我不想让二进制文件进仓库历史。

GitHub 登录,带匿名降级

这类博客的读者大多已经有 GitHub 账号;就算以后加评论,giscus 也一样要 GitHub。用 GitHub 做登录是最不意外的选项。

我考虑过 GitHub App 和托管认证供应商。GitHub App 为仓库权限而生,而我只需要一个 profile;托管供应商多一个依赖、一份账单,和一个从国内可能很慢的域名。我用的是 scope 为 read:user 的 OAuth App。

流程是带 PKCE 的 authorization code 加一个 state cookie。我读一次 /user,然后就把 GitHub token 扔掉——不存储,也永远不到浏览器。

会话是签名 cookie,不是 session 表。payload 里有 GitHub id、一个版本号和过期时间,用 HMAC-SHA256 签名。HTTPS 下 cookie 叫 __Host-session,意味着 Secure、Path=/、不带 Domain。把用户行里的 session_version 加一,该用户所有现存 cookie 立即失效。

匿名读者能读所有内容,每天问 5 个问题;登录后上限提高:

档位 条件 每日提问数
anon 未登录 5
new 已登录,账号不满 90 天且无公开仓库 10
member 账号满 90 天,或有公开仓库 50
pro 在 users.plan 里手工设置 200

账号年龄和公开仓库是粗糙的信号,但足以让批量注册小号变得不划算。

剩余次数显示在 Ask AI 面板里,不在页头。页头回答“谁登录着”;数字属于输入框旁边。两个地方都放过的那段时间,它看起来像个警告标签。

最蠢的一段是注册表单。我在 GitHub App 页面上耗了一阵——那个页面要求 Webhook URL,并且拒绝 localhost,因为 GitHub 够不到它。OAuth Apps 根本没有 webhook 字段。我上错了页面。有个用的发现是:一个 OAuth App 能挂多达十个 redirect URI,同一个应用就能同时覆盖 localhost 和生产。

Ask AI:限定在一个小节里

我不想要通用聊天机器人。我想要选中一段文字、提一个问题、得到一个用上了文章那一部分的回答。我从聊天框做起,后来意识到难的部分是找到正确的文本。

听起来很小,但它改变了系统的三个部分。

上下文是一个小节

我跳过了向量库。文章已经由 URL 确定,读者问的是一个标题或一段选区。构建期把每篇文章切成小节:

{
  "slug": "hello-static-blog",
  "intro": "…",
  "sections": [
    {
      "anchor": "the-constraints-i-care-about",
      "level": 2,
      "heading": "The constraints I care about",
      "path": ["The constraints I care about"],
      "text": "## The constraints I care about\n\n…"
    }
  ]
}

anchor 来自 Astro 生成标题 id 用的同一个 github-slugger。客户端发 anchor,服务端找文本。没有 embedding,没有要调的 chunk 大小。对一篇结构已知的文章,标题就够了。

这份 JSON 在构建期写到 /ai/posts/<slug>.json,通过静态资产绑定读取。客户端不发送文章正文,只发 slug、anchor、选区、问题和最近几轮对话,其余一律忽略。

Prompt 在服务端组装

System prompt 定框架:一篇文章,用给的上下文回答,上下文答不了就承认。我再附上标题、小节和选中文字。上下文上限 8000 字符,问题和选区各 2000,输出 800 token。

我把限制压得很小。博客里的回答应该是几段话,不是一份报告;小限制也让延迟和成本变得可预测。

用 DeepSeek 哪个接口

DeepSeek 有三个接口形态:Chat Completions、Responses 风格的 API、以及 Anthropic 兼容的 Messages API。我用的是 Chat Completions。

接口 提供什么 为什么没选
Chat Completions 简单的 OpenAI 兼容 SSE、tools、vision、thinking 控制 —
Responses 更丰富的 item/event 模型、pro 模型内置联网搜索、Codex 兼容 事件类型更多;本身无状态;这里用不到 tools 和联网
Anthropic 兼容 Claude Code 与 Anthropic SDK 兼容 站点没有 Anthropic 依赖

可迁移性是主因。Chat Completions 是覆盖我所需的最窄接口:一个 system prompt、一小段历史、一个 token 流。换供应商时改 base URL 和模型名,站点其余部分毫无感知。

有一件我做错的事:DeepSeek 默认开启 thinking,且 reasoning_effort 是 high。第一版会在每个问题上为推理 token 付费。现在快路径显式发送 thinking: { type: "disabled" },可选的 smart 路径对老用户开启。快路径一句回答用了 474 个 prompt token 和 73 个 completion token——prompt 里含文章上下文,completion 只有回答本身。

三个 SSE 事件

Worker 不透传供应商的流,而是翻译它:

event: delta
data: {"text":"…"}

event: done
data: {"model":"deepseek-flash","quota":{"remaining":4}}

event: error
data: {"message":"The model returned an error."}

done 携带最新配额,面板不用再发一次请求就能更新。客户端先把 delta 当纯文本显示,最终回答再用 marked 渲染、DOMPurify 清洗。我不信任模型给的 HTML。

让账单可预期

端点是公开的,所以限制必须是真实的:

  • 按身份的每日配额:登录时以 GitHub user id 为键,否则以 IP。
  • 全局每日上限 1000 次请求,存在同一张 D1 表的 global:ask 下。
  • 输入、历史、上下文、输出各有上限。
  • 匿名请求可选 Turnstile——已实现,生产还没开。
  • 退回。上游在第一个 token 前失败、文章切片缺失、或内部出错,用户配额和全局配额都退回。

配额检查是 D1 里一条原子 INSERT … ON CONFLICT … RETURNING,不是 KV 里的读-改-写。两个并行请求不可能都钻到限额之下。

一次 Ask AI 请求:浏览器、Worker、D1 配额、文章切片、DeepSeek 与 SSE 返回路径
图 1. 一次 Ask AI 请求。浏览器发送 slug、anchor、选区和问题;Worker 读取构建期的文章切片,把回答流式传回。 查看原图
文章页右侧的 Ask AI 面板,含建议问题与每日配额
图 2. Ask AI 面板。配额就在输入框旁边,它该在的地方。 查看原图

什么跑在哪

架构图:浏览器、Cloudflare 静态资产与 Worker API、D1、GitHub、DeepSeek、R2
图 3. 什么跑在哪。静态页面和文章切片留在 CDN 上;只有认证、配额和模型调用经过 Worker。图片完全绕开 Worker。 查看原图
  • 浏览器: 渲染静态 HTML,运行几个小的交互小岛(认证菜单、Ask AI 面板),以后 Pyodide 上线后跑代码。不持有任何 API key。
  • Cloudflare 边缘: 分发静态资产,跑一个 Worker。Worker 处理 OAuth 回调、配额检查、文章切片查找、prompt 组装和模型代理。
  • D1: 两张表,users 和 quota。配额表按日和主体为键;登录时会顺手清掉两三天前的旧行。
  • R2: 图片,从 img.awmthink.com 分发。请求不经过 Worker、不带 cookie,边缘缓存一年。
  • Git 与 GitHub Actions: 唯一事实源。CI 在每次 push 跑 check 和 build,通过后部署 main。D1 迁移刻意保持手动——push 不应该对生产执行 SQL。

这些边界比框架更让我在意。静态文件可以缓存后忘掉;状态和秘密集中在一处;图片在自己的域名上,拖不垮站点。一块坏掉,其余照常工作。

代码执行还没做

下一个功能是在页面里跑代码。它有三个版本:

  1. 只读运行。 代码是我的,读者按 Run 看输出。技术文章的常见场景。
  2. 可改参数。 读者改一个值重跑片段。
  3. 任意代码。 读者自己写代码,可能是另一种语言,可能跑不止几秒。

前两个属于浏览器。Pyodide 在 Web Worker 里跑 Python,我不为 CPU 付费,也不用自己做沙箱。代价是冷启动和包限制:numpy、pandas 没问题,PyTorch 不行。

确定性的片段还应该在构建期就跑一遍,输出存在文章旁边——Run 按钮被按下之前页面就是完整的。

第三个版本意味着又要运维基础设施:沙箱、网络隔离、时间与内存限制、队列、限流。Piston 或 E2B 能提供隔离,但那就不再是静态博客了。

这些都没有上线。Ask AI 面板已经足够证明这种交互成立,我不想在第一个运行时稳定之前引入第二个。设计了,没构建。

设计系统

第一版站点看起来像一个带绿色强调色的 Astro 脚手架。能用,但看不出有人做过决策。

我有一个喜欢的参考:一本中文车牌图鉴。我对它的布局没兴趣,我要的是配色和字体:暖纸色、墨绿、衬线标题、荧光绿高亮、细线、编号、带硬阴影的小卡片。

我把 token 写进了 DESIGN.md:

  • 纸、墨、弱化文字,和两种线宽。
  • lime 只做关键词高亮,orange 只做焦点与 hover,从不同时出现。
  • Georgia 做展示字体,系统 sans 做正文,等宽做代码。
  • 1240px 外框,68ch 阅读列,图片允许破格到 960px。
  • 明暗两套主题共用一组语义 token;任何组件都不写死颜色。

首页用三张索引卡、一处 lime 高亮和一个编号列表,而不是卡片网格。文章页在每个 H2 前加橙色 §、着色的引用块和深色代码块。Ask AI 面板用同一套 token,看起来是站点的一部分而不是一个挂件。

浅色模式下的首页:衬线大标题、索引卡装饰和编号写作列表
图 4. 这篇文章发布前的首页。索引卡是装饰;编号列表才是真正的档案。 查看原图

回报在后面才来。当 Ask AI 面板需要页头、上下文 chip 和建议按钮时,token 已经覆盖了大部分。

和 agent 协作

代码大部分是 agent 写的。我写约束。

AGENTS.md 里有命令、目录结构、安全规则、数据库规则和一份检查清单。它刻意具体。“用设计 token”拦不住一个组件写死十六进制颜色;“会话 cookie 必须 HttpOnly、SameSite=Lax,HTTPS 下用 __Host-“拦得住。

agent 擅长那些枯燥但定义清晰的工作:脚手架、OAuth 回调、翻译 SSE、把站点迁到新设计、写文档。因为它能把整个仓库装进上下文,它也擅长发现前后不一致。

我必须做的决策都是边界类的:

  • 什么跑在浏览器、什么跑在边缘。
  • 哪些秘密存在、允许住在哪。
  • 一个保护钱包的配额和一个惩罚读者的配额之间的区别。
  • 现在先不构建什么。

失败比成功有用:

  1. Astro 忽略 src/pages/_ai。 下划线开头的目录不参与路由,而构建照样成功,所以过了很久才发现。把路由挪到 src/pages/ai/posts 才修好。
  2. Adapter 14 移除了 Astro.locals.runtime。 绑定从 cloudflare:workers 来。我找到的多数示例还是旧 API。
  3. 适配器开了我没要的东西。 生成的配置里冒出 KV session 命名空间和 Cloudflare Images。session: false 和 imageService: 'passthrough' 移掉了它们。
  4. 本地 D1 不是一个库。 astro dev 和 wrangler d1 execute --local 可能解析到不同的 Miniflare SQLite 文件。CLI 迁移把表建在一个文件里,dev server 用另一个,报 no such table: quota。现在一个仅开发环境的 ensureDevSchema() 在第一个碰库的请求上应用迁移。
  5. D1.exec() 不是迁移执行器。 它把多行 CREATE TABLE 按行拆开,报 incomplete input。按分号切分迁移、逐条执行才通过。
  6. DeepSeek 默认开 thinking。 第一版会在每个问题上为推理 token 付费。快路径现在显式发送 thinking: { type: "disabled" }。
  7. 部署可能看起来成功却在服务旧资产。 Worker 更新了,自定义域名映射没有。摘掉重挂映射才修好。我把它写下来,下次就不会调试错层。

我反复遇到同一个模式:agent 能很快实现一个决策,但它分不清哪个决策昂贵。这部分留给我。

成本

项目 成本
域名 约 $12/年
Cloudflare Workers、静态资产、D1 免费层
R2 存储与流量 10 GB 免费,无流量费
DeepSeek 按量计费;每月几美分到几美元
可选 VPS 之前的总计 这个流量下约 $12–20/年

第一次部署 dry-run 是 635 KiB,gzip 后 166 KiB。文章页不携带客户端框架;全部 JavaScript 是认证菜单和 Ask AI 面板,后者打开时才加载。

我检查了那些容易做错的事:

  • npm run check 和 npm run build 零错误零警告通过。
  • wrangler deploy --dry-run 只显示 D1 和 assets 两个绑定。
  • D1 的原子 RETURNING 行为在本地验证过。
  • OAuth 登录重定向指向正确的 client id 和回调 URL。
  • 一个 mock 模型验证了流式、配额耗尽与退回。
  • 一次真实 DeepSeek 请求验证了模型名和 thinking 参数。
  • 浏览器验证了面板、流式 Markdown、选区气泡和配额耗尽态。

Turnstile 生产验证、代码执行、国内镜像还没有验证。这些都还开着。

如果重来

我在攒够三篇值得发布的文章之前就把功能做完了。顺序反了。头十个小时应该用来写作,因为只有写作才能检验排版、代码块、图片流程和导航到底好不好用。

我会更早加 RSS、sitemap 和 Open Graph 图。它们很小,而在此之前发的每一篇文章都错过了一个分发渠道。

设计系统应该排在第一个功能前面。做一套 token 很便宜,回头补很贵;第一个版本的 Ask AI 面板在换皮之前像另一个产品。

接下来:选定片段的 Pyodide、RSS 和 sitemap、匿名用户默认开 Turnstile、评论和搜索,以及——只在流量证明值得时——国内镜像。

我还有一个更大的计划:把十年间散落在 GitHub、笔记应用和飞书文档里的笔记拉进一个可检索的系统。那是另一篇文章,这一篇讲的是搭建。

站名取自 Vannevar Bush 1945 年的文章《As We May Think》。我喜欢那个想法:一个私人的知识仓库,文档之间有足迹相连。博客是它的一个简单版本,而 Markdown 文件十年后依然打得开——Worker 我可不敢这么说。

代码是容易的部分。决定不做什么,花了更久。

评论

评论公开可见,以 GitHub 身份署名。