一、这是什么

这篇文章是一个技术栈的自我陈述,记录 artemis2045.com 从零到一用到的每一个技术选型和背后的原因。

如果你正在搭自己的博客、或者想做一套带 Agent Sandbox 的个人站点,这里的选型和踩坑记录应该能帮你省不少时间。

简单总结一下就是

  • Next.js 15 做前端
  • FastAPI 做后端
  • PostgreSQL + pgvector 统一存储
  • Docker 作为 coding agent sandbox
  • DeepSeek 做模型网关
  • Cloudflare + Vercel + 腾讯云 COS 做部署。

二、整体架构

先放一张架构图,不然光看文字太抽象了:

Cloudflare (DNS + CDN + SSL Full Strict)
    │
    ├── Vercel ─── Next.js 15 前端
    │   ├── 公开博客:文章列表 + 详情 + 标签筛选 + 搜索
    │   ├── 登录/注册:邮箱密码 + GitHub OAuth
    │   ├── 评论区(登录后可见)
    │   └── Agent Console UI(仅允许账号)
    │
    ├── 硅谷服务器 ─── FastAPI 后端
    │   ├── /api/posts       → 博客文章
    │   ├── /api/auth        → 认证(注册、登录、GitHub OAuth、JWT)
    │   ├── /api/comments    → 评论区
    │   ├── /api/agent       → Agent Sandbox (session、SSE、diff)
    │   ├── /internal/llm    → DeepSeek model gateway
    │   ├── Nginx 反代 + TLS (Let's Encrypt)
    │   └── PostgreSQL 17 + pgvector
    │
    ├── Docker ─── Pi coding-agent runner
    │   ├── 一次性 workspace (git init)
    │   ├── 资源限制 (1 CPU, 768MB, 128 pids)
    │   └── 不挂载生产密钥、目录或 Docker socket
    │
    ├── 腾讯云 COS ─── Notion 图片镜像 + 博客配图
    │
    └── Notion API ──→ 定时同步 → 后端 → 图片镜像 COS → 写入 PG

文章发布的部分,整个链路走下来就是:

Notion 写文章 → 后端定时同步 → 转 Markdown + 图片镜像 COS → 写入 PostgreSQL → Next.js ISR 拉取 → VerCel部署 → Cloudflare 全球加速 → 公网展示。

而 Pi Agent Sandbox 的那条线是:

允许账号浏览器 → SSE → FastAPI 控制平面 → Docker Pi runner → DeepSeek model gateway → tool calls → diff → 返回前端。

整套流程基本上涵盖了常见的前后端技术,也展现了我个人对Agent的一些理解吧。

但是这里有一个选型失误,后端应该也选用 TS 比较好,前后端统一。我由于更熟悉 Python 所以优先选了 Python。


三、前端

3.1 核心选型

技术版本
Next.js15.5.18
React18.3.1
TypeScript5.6.3
Tailwind CSS3.4.14

3.2 Next.js 与 Vercel 的坑

npm 镜像别带进 lockfile

如果你在国内用腾讯云 npm 镜像,记得 npm install 之前检查 package-lock.json 里的 resolved 字段。Vercel 运行 npm ci 时不在腾讯云网络内,遇到 http://mirrors.tencentyun.com/npm/... 的地址会直接挂掉。

image
image

3.3 Markdown 渲染链

博客文章从 Notion 同步过来是 Markdown 字符串,前端需要把它渲染成带样式的 HTML。

用到的插件链如下:

Markdown 正文
  │
  ├── remark-gfm       → GFM 扩展(表格、任务列表、删除线)
  ├── remark-math      → 识别 $...$ 和 $$...$$ 数学公式
  │
  └── react-markdown   → AST 解析 + React 组件渲染
        │
        ├── rehype-slug       → 标题自动加 id,用于锚点跳转
        ├── rehype-raw        → 解析 Markdown 里内嵌的 HTML(如 <details>)
        ├── rehype-sanitize   → 白名单过滤,防 XSS
        │     └── 扩展白名单: <details> <summary> <u> <input> <mark> <kbd> <sub> <sup>
        ├── rehype-highlight  → 代码语法高亮 (highlight.js + github.css)
        └── rehype-katex      → 数学公式渲染为 HTML+CSS

Notion 的 notion-to-md 转换器偶尔会输出 <details><summary><u> 等 HTML 标签。rehype-sanitize 默认会把这些全过滤掉,文章里的折叠块就全塌了。

解决方式是扩展 tagNamesattributes,把 Notion 可能产出的标签加进白名单。

3.4 设计

用的是一套 Mondrian 色系(蒙德里安红黄蓝),通过 CSS 变量定义:

:root {
  --paper: #f7f3ea;   /* 暖白纸色背景 */
  --ink: #111111;     /* 正文黑色 */
  --red: #d9281e;     /* 强调/警告 */
  --blue: #185abc;    /* 链接 */
  --yellow: #f2c94c;  /* 高亮 */
}

无衬线字体栈 Inter → system-ui,文章正文用衬线字体 Source Serif 4 → Georgia。这套搭配看起来很报社风,适合长文本阅读。

彼埃·蒙德里安是风格派运动幕后艺术家,非具象绘画先驱,以直线、直角和三原色构成的“新造型主义”闻名。代表作有《灰色的树》《纽约市一号》等。我很喜欢这个风格。


四、后端

4.1 核心选型

技术为什么用它
FastAPI原生 async、自动 OpenAPI、SSE 支持。Flask 也能做但 async 要自己挂,Django 太重了不适合这种微服务架构,简单点就好
uvicornASGI 服务器,standard 版自带 uvloop + httptools
SQLAlchemy 2.0之前 1.x 的 API 说实话不好用。2.0 的 Mapped + mapped_column 声明式写法干净很多,async session 也原生支持
asyncpg最成熟的 Python async PostgreSQL 驱动。psycopg3 虽然也不错但 asyncpg 的 bare-metal 性能更好
pydantic-settingsFastAPI 生态标配,.envBaseSettings 一行绑定
uv替代 pip ,安装快一个数量级

依赖管理用的是 uv 而不是 pip

4.2 API 路由设计

后端路由拆成六块:

app/
├── main.py              → FastAPI 入口 + lifespan hook + CORS
├── config.py            → pydantic-settings 全局配置单例
├── authRouter.py        → /api/auth/*     (注册、登录、GitHub OAuth、JWT)
├── postsRouter.py       → /api/posts/*    (文章列表、文章详情)
├── commentsRouter.py    → /api/comments/* (评论 CRUD)
├── agent/router.py      → /api/agent/*    (Agent session、SSE、diff)
├── agent/gatewayRouter.py → /internal/llm/*  (DeepSeek model gateway)
├── agent/internalRouter.py → /internal/agent/* (runner 内部回调)
├── agent/runner.py      → Docker sandbox 生命周期管理
├── agent/service.py     → Agent 业务逻辑(创建 session、事件存储、plan 审批)
├── agent/memory.py      → 长期记忆 CRUD + pgvector 搜索
├── agent/capability.py  → Agent 能力审批与管理
├── notion/              → Notion 同步 + Markdown 转换 + 图片镜像
└── database/            → ORM models + 连接管理 + DDL

4.3 认证系统

认证选了自己写 OAuth 而不是用 Auth.js。

认证流程:

  • 邮箱注册 → bcrypt 哈希密码 → 写入 users 表 → 发验证邮件(SMTP)
  • 邮箱登录 → 验密码 → 签发 JWT (HS256,168h) → Set-Cookie: artemis_session
  • GitHub OAuth → 重定向 GitHub 授权页 → 回调 → codeaccess_token → 拿 GitHub user info → 按 github_id 查找或创建用户 → 签发 JWT
  • 后续请求 → FastAPI dependency 解析 Cookie → 验证 JWT → 注入 current_user

账户只影响评论权限和 Agent 的访问权限,目前只有 Owner 账号和我设置的一个 Guest 账号有Agent的访问权限。

# 只有"允许"与"不允许"之分
# guest 和 owner 如果都在允许列表中,能力完全一致
can_access_agent = check_allowed_agent_account(user)

4.4 数据库

PostgreSQL 17 + pgvector,统一存储。所有数据都在 PG 里,包括关系数据、向量嵌入、JSON 文档。pgvector 现在是最成熟的 PG 向量扩展,HNSW 索引效率足够好。个人博客的记忆体量用 pgvector 完全够了,感觉没什么必要上单独的向量数据库,比如 Milvus之类的。

但我的服务器只有 3.6GB 内存,PG 的默认配置是给服务器级硬件设计的,所以配置上要调低:

shared_buffers = 64MB   # 默认 128MB
work_mem = 4MB          # 默认 4MB,不动
max_connections = 20    # 默认 100

进程内存估算:

进程内存
Ubuntu 基础~500-800MB
PostgreSQL (调优后)~150-300MB
uvicorn (2 worker)~200-400MB
Nginx~50-100MB
Docker sandbox (单容器)~768MB
余量~1.5-2GB

单 agent 并发下够用了。

4.5 表设计全览

一共 13 张核心表 + 1 projects 历史表。按照使用场景分成三组:

博客/内容相关:

关键列用途
postsnotion_id, slug, content_md, tags[], publishedNotion 文章同步
post_imagesnotion_block_id, cos_key, public_url, content_hash图片元数据,文件存 COS
commentspost_id, user_id, parent_id, content, status楼中楼评论,软删除

Agent 相关:

关键列用途
agent_sessionstask, status, model, plan, plan_status, diff, capability_snapshot每次 Agent 任务
agent_eventssession_id, sequence, event_type, payload(JSONB)SSE 事件流,可审计可回放
agent_capabilitieskind, name, version, config(JSONB), risk(JSONB)已批准的 Agent 能力
agent_capability_candidateskind, manifest, files_snapshot, test_output, risk_report能力审批候选
agent_capability_secretscapability_id, secret_name, encrypted_value能力关联密钥

记忆相关:

关键列用途
memoriescontent, memory_type, embedding(vector 512), importance, confidence长期记忆
memory_edgesfrom_memory_id, to_memory_id, relation_type, weight记忆关系图
memory_candidatescontent, status, approved_memory_id候选记忆审批

索引策略:

  • memories.embeddingHNSW 索引 + vector_cosine_ops(余弦相似度搜索)

HNSW 是 pgvector 里性能最好的 ANN 索引类型。比 IVFFlat 写入慢,但查询快很多,对记忆召回这种读多写少的场景正合适。

4.6 Embedding

Embedding 用的是本地 CPU 推理。

属性
模型BAAI/bge-small-zh-v1.5
维度512
大小~24MB
推理引擎fastembed (Qdrant 出品,ONNX Runtime)
硬件CPU only (AMD EPYC)
外部依赖

我参考了 jcode 的做法。jcode 是 Rust 项目,用 all-MiniLM-L6-v2 (80MB) + tract-onnx 做本地 embedding。

选用 bge-small-zh-v1.5 的原因:

  • 中文更好。MiniLM 是英文模型,bge-small-zh 对中文语义的区分度明显更高
  • 更小。24MB vs 80MB,加载快,小内存友好
  • 512 维够了。对个人记忆系统来说,几百条记忆的检索精度不需要 1024 维

fastembed 是 Qdrant 团队维护的 Python 库,内部用 ONNX Runtime 跑模型推理。

API 非常简单:

from fastembed import TextEmbedding

model = TextEmbedding("BAAI/bge-small-zh-v1.5")
embedding = list(model.embed(["要编码的文本"]))[0]  # → 512 维 float list

五、Notion 同步

5.1 数据流

Notion Data Source API
  │
  ├── APScheduler 定时触发 (启动立刻跑一次,之后每 15 分钟)
  │
  ├── Python httpx 翻页拉 data source pages
  │     ├── 提取属性: title, slug, tags, status, date, summary, category
  │     ├── 镜像 cover 图片到 COS
  │     ├── UPSERT posts 表
  │     ├── 递归拉取全部 block children
  │     ├── 识别 image block → 下载临时 URL → SHA256 → 上传 COS → public URL
  │     └── 收集 {blockId: publicUrl} 映射
  │
  ├── Node 子进程调用 notion-to-md@3.1.9
  │     ├── 接收: blocks JSON + imageUrlByBlockId
  │     ├── 图片 URL 替换为 COS URL
  │     └── 返回: Markdown 字符串
  │
  └── 写入 posts.content_md + post_images 表

5.2 为什么选用 Notion

首先是我的工作笔记和文章草稿都在 Notion 里。我知道 Obisidian 很好很强大,但是折腾确实也需要一点时间。 Notion 的 API 足够完善。

Notion 做 CMS 的最大价值是写作体验——它的编辑器、数据库视图、图片管理比任何一个 headless CMS 都好用。同步到自家博客只是把这个写作结果多一个发布渠道。

5.3 notion-to-md 的坑

Notion 官方没有提供 block → Markdown 转换器。Python 生态有几个社区库但质量都不行——要么不支持嵌套列表、要么图片 URL 处理有问题、要么 callout/quote 渲染错位。

最终方案是 Python 负责拉数据,Node 负责转 Markdown

# Python 端: 通过 subprocess 调 Node 脚本
process = await asyncio.create_subprocess_exec(
    "node", "scripts/notion-to-md.cjs",
    stdin=asyncio.subprocess.PIPE,
    stdout=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate(inputBytes)

Node 端用 notion-to-md@3.1.9 (CommonJS),通过 stdin/stdout JSON 交换数据。Python 把 blocks + image URL 映射写好,Node 只管转换。


六、Agent Sandbox

6.1 设计原则

Agent Sandbox 的目标不是做一个通用聊天机器人,而是一个 网页端 coding agent:允许账号在浏览器提交开发任务,agent 在隔离 Docker 容器里读写文件、跑命令、看 diff、修错误,最后返回结果。

不过,本质目的还是为了展现我有搭建Agent的能力。

整条链路:

浏览器 Agent Console
  │  POST /api/agent/sessions  → 创建任务
  │  GET  /api/agent/sessions/{id}/events  → SSE 订阅事件流
  │
FastAPI 控制平面
  │  校验 canAccessAgent
  │  创建 agent_sessions / agent_events 记录
  │  准备 workspace(从模板复制 + git init)
  │  检查并发限制(默认 1)
  │
Docker sandbox
  │  docker run --rm -i --cpus 1 --memory 768m --pids-limit 128
  │  --cap-drop ALL --security-opt no-new-privileges
  │  -v workspace:/workspace:rw
  │
Pi coding-agent runner
  │  stdin/stdout JSONL 通信
  │  通过 /internal/llm 调 DeepSeek(短期 session token)
  │  Plan Mode → tool calls → diff → final answer
  │
  └── 事件回写到 agent_events 表 + SSE 推前端

6.2 Model Gateway

LLM 用的是 DeepSeek 的 Anthropic-compatible APIhttps://api.deepseek.com/anthropic)。模型配的是 deepseek-v4-pro

为什么这样设计 gateway:

Pi runner container
  └── 只访问 FastAPI /internal/llm  ← 短期 session token
        └── FastAPI 注入真实 DeepSeek API key
              └── <https://api.deepseek.com/anthropic>

Pi runner 拿的是一个短期 session token,后端 gateway 负责注入真实 key、转发 SSE 流、记录 token usage。

这样做的好处:

  • API key 泄漏面只在 FastAPI 的 .env
  • Token usage 可以按 session 统计
  • 可以在 gateway 层做速率限制、模型切换、回退

DeepSeek Web Search 是 provider-side——搜索发生在 DeepSeek 侧,Pi runner 不需要额外的公网访问能力。

容器内的 curl / npm / pip / git 网络策略和 LLM Web Search 是分开处理的。

6.3 Docker 安全策略

docker run \\
  --rm \\                    # 退出即删除
  --cpus 1 \\                # 单核
  --memory 768m \\           # 内存上限
  --pids-limit 128 \\        # 进程数上限
  --cap-drop ALL \\          # 移除全部 Linux capabilities
  --security-opt no-new-privileges \\  # 禁止提权
  -v /workspace:/workspace:rw \\       # 只挂临时 workspace
  artemis-pi-runner:latest

不挂载的东西

  • /var/run/docker.sock(这个挂了等于 root)
  • 宿主机 / 或生产目录
  • .env / SSH key / 云密钥

容器用非 root 用户运行。workspace 完全可写但生产目录完全不可见。Codex / Claude Code 的体验重点是自由读写 workspace + 跑命令 + 看 diff,这些都不需要 root。

6.4 Memory Tool

长期记忆存在 PostgreSQL,不在 Pi runner 里。Pi 通过内部 tool 调用 FastAPI memory API:

Tool后端调用说明
memory.search(query)pgvector HNSW 余弦搜索 + memory_edges CTE 图扩展召回相关记忆
memory.propose(content, type, tags)写入 memory_candidates候选记忆,等待审批
memory.approve(id)生成 embedding → 写入 memories前端确认后落库

记忆图遍历用的是 SQL 递归 CTE:

WITH RECURSIVE traversal AS (
    -- 种子节点:HNSW 向量搜索 top-k
    SELECT m.id, m.content, 0 AS depth
    FROM memories m
    WHERE m.id IN (SELECT id FROM hnsw_search(...))

    UNION ALL

    -- 沿 memory_edges 扩展
    SELECT m.id, m.content, t.depth + 1
    FROM memories m
    JOIN memory_edges e ON e.to_memory_id = m.id
    JOIN traversal t ON t.id = e.from_memory_id
    WHERE t.depth < 3  -- 最多 3 跳
)
SELECT DISTINCT * FROM traversal;

比手写 BFS 干净多了。

6.5 Capability 系统

Capability 是 Agent 能力的插件化系统,就是常见的四种类型:

类型说明
skill类似 Claude Code 的 Skill——包含 SKILL.md 描述文件,注入 system context
mcp_serverMCP 协议的外部工具服务
pluginPi runner 可以加载的代码插件
hook生命周期钩子(before_tool_call / after_tool_result 等)

如何维护能力的生命周期:

Agent 在 sandbox 里提案 → 生成候选(含文件快照、测试输出、风险评估)→ 浏览器端人工审批 → 写入 agent_capabilities 表 → 挂载到后续 sandbox 容器的 /agent-capabilities 只读目录。

最主要的是,还是需要前端进行确认的,我认为 Agent 需要用户能完全掌控,而不能让它自作主张。


七、图片处理

7.1 Notion 图片镜像

Notion 的图片 URL 是临时的——过期后图片就不可访问了。Google 搜索到的 Notion 博客文章经常是一堆裂图,就是这个原因。

所以同步时把 Notion 图片下载下来,上传到腾讯云 COS,Markdown 里的图片 URL 替换成 COS URL。这也是对象存储的常见做法。

Notion image block
  │
  ├── 读取 caption + 前后文段落
  ├── 下载临时 URL(httpx,5 分钟超时)
  ├── 上传到 COS(boto3 put_object)
  ├── 写入 post_images 表(cos_key, public_url, caption, surrounding_text, content_hash)
  └── Markdown 图片 URL 替换为 COS public URL

7.2 COS 选型

腾讯云 COS,S3 兼容协议,用 boto3 操作。本机的服务器在硅谷,COS 硅谷区域延迟低,而且国内访问速度尚可。


八、部署架构

Cloudflare
├── artemis2045.com  (CNAME → Vercel)
├── www.artemis2045.com  (CNAME → Vercel)
└── api.artemis2045.com  (A → 硅谷服务器 IP)
    │
    └── 硅谷服务器 (Ubuntu 24.04.4)
        ├── Nginx(1Panel OpenResty) :443 (Let's Encrypt)
        │   ├── proxy_pass /api/* → 127.0.0.1:8000
        │   └── 请求体 50MB 限制
        ├── FastAPI (uvicorn) :8000 → --reload
        ├── PostgreSQL :5432 → Docker artemis-pg
        └── Docker sandbox → 按需创建/销毁
平台花费
前端Vercel Hobby免费
DNS + CDN + SSLCloudflare免费
后端硅谷服务器¥200/年
图片存储腾讯云 COS~¥1/月
LLM APIDeepSeek~¥5-20/月

九、写在最后

希望这个博客站能给有需要的人一些帮助哦。