一、这是什么
这篇文章是一个技术栈的自我陈述,记录 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.js | 15.5.18 |
| React | 18.3.1 |
| TypeScript | 5.6.3 |
| Tailwind CSS | 3.4.14 |
3.2 Next.js 与 Vercel 的坑
npm 镜像别带进 lockfile。
如果你在国内用腾讯云 npm 镜像,记得 npm install 之前检查 package-lock.json 里的 resolved 字段。Vercel 运行 npm ci 时不在腾讯云网络内,遇到 http://mirrors.tencentyun.com/npm/... 的地址会直接挂掉。

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 默认会把这些全过滤掉,文章里的折叠块就全塌了。
解决方式是扩展 tagNames 和 attributes,把 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 太重了不适合这种微服务架构,简单点就好 |
| uvicorn | ASGI 服务器,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-settings | FastAPI 生态标配,.env → BaseSettings 一行绑定 |
| 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 授权页 → 回调 →
code换access_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 历史表。按照使用场景分成三组:
博客/内容相关:
| 表 | 关键列 | 用途 |
|---|---|---|
posts | notion_id, slug, content_md, tags[], published | Notion 文章同步 |
post_images | notion_block_id, cos_key, public_url, content_hash | 图片元数据,文件存 COS |
comments | post_id, user_id, parent_id, content, status | 楼中楼评论,软删除 |
Agent 相关:
| 表 | 关键列 | 用途 |
|---|---|---|
agent_sessions | task, status, model, plan, plan_status, diff, capability_snapshot | 每次 Agent 任务 |
agent_events | session_id, sequence, event_type, payload(JSONB) | SSE 事件流,可审计可回放 |
agent_capabilities | kind, name, version, config(JSONB), risk(JSONB) | 已批准的 Agent 能力 |
agent_capability_candidates | kind, manifest, files_snapshot, test_output, risk_report | 能力审批候选 |
agent_capability_secrets | capability_id, secret_name, encrypted_value | 能力关联密钥 |
记忆相关:
| 表 | 关键列 | 用途 |
|---|---|---|
memories | content, memory_type, embedding(vector 512), importance, confidence | 长期记忆 |
memory_edges | from_memory_id, to_memory_id, relation_type, weight | 记忆关系图 |
memory_candidates | content, status, approved_memory_id | 候选记忆审批 |
索引策略:
memories.embedding用 HNSW 索引 +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 API(https://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_server | MCP 协议的外部工具服务 |
plugin | Pi 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 + SSL | Cloudflare | 免费 |
| 后端 | 硅谷服务器 | ¥200/年 |
| 图片存储 | 腾讯云 COS | ~¥1/月 |
| LLM API | DeepSeek | ~¥5-20/月 |
九、写在最后
希望这个博客站能给有需要的人一些帮助哦。
Comments
评论
Loading comments...
登录后可以评论。 Login