这是 agent-os-base 项目的系列进展复盘。每篇标一个序号、覆盖一段里程碑, 在
/tags/agent-os/下汇成一个系列,后续有新进展就出 (二)、(三)……。 本篇 (一) 覆盖 P0 需求定稿 → P2 公网接入上线。
缘起:我想要一个「自己的 Agent 底座」
作为独立开发者,我不想每做一个 AI 应用就从头搭一遍鉴权、用量、路由、SSE 流式。 我想要的是一层底座——把「谁在调用、调用什么、算多少钱、内容合不合规、请求发去哪个 agent」这些 横切关注点收口到一个地方,让每个具体的 agent 只管自己那点业务逻辑。
同时我有个私心:家里的机器算力和私有数据,不想全搬上云。于是最终的心智模型定成了 「混合云」——控制平面在云端收口,真正吃算力、碰私有数据的 agent 计算可以留在家里, 通过内网穿透被云端调用。本地能跑通的东西,配置一改就能平滑迁到云上。
这套东西,我给它起名 Agent OS。
架构 B:控制平面 / 数据平面 / 数据分层
定稿时对比了好几版草稿,最后拍板的是「架构 B」,三句话说清:
- 控制平面:云端一个 Go 薄网关。负责 JWT 鉴权、owner 授权、用量记账、(未来)内容审核、 限流,以及按 agent 路由,最后把各种后端的输出统一成一套 SSE 事件吐给客户端。
- 数据平面:可以分布式部署的 agent 后端。两种造法——自研的 FastAPI(Python), 或者 Dify app。对客户端而言两者长得一模一样。
- 数据分层:云端只放元数据(用户、agent 配置、用量流水);家侧放私有资产(向量库等)。
几个当时刻意做的减法,事后看都对:
- Dify 从「引擎」降级为「可选后端」。纯自研的 agent 可以完全不碰 Dify。
- 向量库只留 PGVector,砍掉 Chroma / Pinecone 的多套并存,用一个抽象接口兜住未来可能的切换。
- 不用 Nacos 这类注册中心,网关内部用一张路由表 + 内存热更新就够了。
- 否掉了「换皮上线」的念头,老老实实走飞书自建应用 / 内测。
技术栈落定:网关 = Go 标准库 net/http + pgx v5 + golang-jwt/v5;
自研后端 = Python 3.14 / FastAPI(httpx / asyncpg / pgvector);
数据层 = PostgreSQL 17 + pgvector。
一张图说清这套分层:
graph TB
C[客户端<br/>飞书 / 内测]
subgraph CP[控制平面 · 云端]
GW["Go 薄网关<br/>JWT鉴权 · owner授权 · 用量记账<br/>内容审核 · 限流 · 按 agent 路由 · 统一 SSE"]
end
subgraph DP[数据平面 · 可分布部署]
N[native FastAPI agent]
D["Dify app(可选后端)"]
end
subgraph DL[数据分层]
CM[("云端元数据<br/>users · agents_config · usage_ledger")]
HP[("家侧私有资产<br/>PGVector 向量库")]
end
C <-->|"HTTPS / JWT · 统一 SSE"| GW
GW --> N
GW -.-> D
GW --- CM
N --- HP
关键点:客户端只跟网关这一个入口打交道,看到的永远是同一套 SSE 事件; 后端是 native 还是 Dify、部署在云还是在家,对它完全透明。
P0 — 需求定稿:先吵架,再动手
P0 没写一行业务代码,全在吵清楚要做什么。
用 brainstorming 过了 6 个核心分歧(架构选型、Dify 的定位、向量库、计费怎么摆、网关技术栈……), 然后走需求流程成稿。最关键的一步是两轮对抗性 critic 检查——派独立上下文的 critic subagent 专门挑毛病,一共 24/30 条问题逐条处理掉,才 finalize。
定稿文档不只写「what/why」,还把「how」一起承载了:数据模型 DDL、接口规范、Mermaid 图示、 部署运维、性能预判、验收标准,11 章。这样后面做实现时,脑子里的模型和文档是对齐的。
教训沉淀:工具链全在 WSL;
.sh脚本强制 LF(.gitattributes); 记忆文件更新一律用编辑器工具落盘(git-bash 的 python heredoc 不可靠)。这几条后面反复救了我。
P1a — Walking Skeleton:先让一根线通到底
P1a 的目标很朴素:客户端 → 网关 → 最简 agent,一条统一 SSE 的线,端到端跑通。
subagent 驱动,6 个任务:Go 网关(config / proxy)+ Python 的 echo/chat 后端(内部 token 守卫)
- 统一 SSE。一根线通了,并入 main。骨架立住,后面就是往上挂血肉。
P1b — 本地最小底座:把三块硬骨头啃完
P1b 是工作量最大的一段,目标是「本地就能跑出一个像样的底座」。我给自己定的是三块都要做:
1. 统一 SSE + 流包裹中间件
网关从「纯 ReverseProxy」升级成流包裹中间件:解析后端的 SSE 帧,
usage 事件记进 sink 但不转发给客户端(用量是我的账,不是客户的事),
后端非 200 一律翻译成客户端的 502,流断了补一个 error 事件。
后端侧统一了事件格式化器,所有 agent 都发 event: token/usage/error/done。
2. 路由表迁 PostgreSQL + 鉴权 + 用量真落库
路由表从文件搬进 PG(users + agents_config,带约束和触发器),
POST /admin/reload 支持热重载(失败保留旧路由,不会把自己搞挂)。
鉴权用 JWT HS256,keyfunc 锁死算法(拒掉 alg:none / RSA 混淆这类经典攻击)。
授权做成 owner-only:私有 agent 只有 owner 能调,未认证用户对私有 agent 恒 403。
用量落进 usage_ledger,on conflict(request_id) do nothing 去重,
客户端尾包断连也不丢账(用 context.WithoutCancel + 超时兜住)。
这里有个安全裁定我印象很深:为什么
user_id=0(未认证)对私有 agent 一定安全? 因为私有行的owner_user_id是NOT NULL(CHECK 约束保证),而user_id从 1 起(BIGSERIAL),coalesce只在 NULL 时出 0,私有行不可能 NULL——所以 0 永远匹配不上任何私有 agent。 review 逐条确认没有user_id=0漏洞。约束即安全边界,这种「用数据库约束把非法状态变得不可表达」的 感觉特别踏实。
3. PGVector RAG(私有隔离)
RAG 落在 Python 后端(家侧数据平面),网关只多转发一个 user_id。
Embedder 做成协议,测试用确定性的 FakeEmbedder(hash → 1536 维),
生产用 OpenAI 兼容的 OpenAIEmbedder。检索时 owner 隔离(owner_user_id IS NULL OR = user_id),
匿名用户只看得见共享文档,既不漏私有也不丢共享。
X-User-Id 由网关从 JWT 派生后注入,客户端自己伪造这个头无效——因为网关是发一个全新请求给后端,
不透传客户端的头。
一个 provider 选型的关键认知:embedding 和 generation 是两类完全不同的 provider。 对话大脑(generation)几乎人人都能做,按 agent 随便换;但 embedding 只有少数厂商有, 而且维度是钉死在 pgvector 的
vector(N)列和索引里的——换 embedding 模型 = 换维度 = 改表 + 全量重嵌, 是一次显式迁移。所以我把 embedding 的选择拖到最后,先拖住成本低的对话侧。
4. 多 provider 回答式 agent + Dify 分派
llm.py 改成命名 provider 注册表,/chat?provider= 按 agent 选大脑,
seed 里注册了 chat-deepseek / chat-gemini(同一后端,?provider= 分流)。
Dify 分派则是在 proxy 里按 backend_type 分支:新增 internal/dify 只干一件事——
把 Dify 的 JSON-event SSE 翻译成我那套统一事件,和 native 的 ScanFrames 汇入同一个 emit。
真·模型腿全线打通:DeepSeek / Gemini 生成、Google gemini-embedding-001@1536 embedding、
真 RAG 组合、经网关调两个 agent、Dify 分派(真 Dify chatflow,模型 deepseek-reasoner)——
全部用真 key / 真 Dify 跑通,不再有替身。
embedding 踩坑记:Google 那边
text-embedding-004和gemini-embedding-004都不在 OpenAI 兼容端点上(404),唯一能用的是gemini-embedding-001, 而且必须传dimensions=1536(默认 3072 会被我的维度守卫拦下)。
P1b 收官时,我给「调通」划了条清晰的边界,免得骗自己:
committed 的 smoke 测试用替身(FakeEmbedder + echo LLM,CI 友好、不需要 key);
真模型 / 真 Dify 靠 .env + 手动 probe 验证。平台管道是真的,模型可以是假的——
配上 key 就亮。
P2 — 公网接入:从「家里能跑」到「公网能访问」
这是把整套东西真正推上线的一段,也是运维踩坑最密集的一段。
目标拓扑:某云厂商的轻量服务器(2C4G,域名已备案,下文用 example.com 占位),单机 Nginx 80/443 唯一入口,
按 server_name 分流——主域是静态博客,api.example.com 反代 127.0.0.1:8080 的 Go 网关。
家里的 FastAPI 后端通过 frp 内网穿透被云端调用。
几个设计上的巧劲:
- frps 用
proxyBindAddr=127.0.0.1,让 frpc 的remotePort=8001只绑云端本机, 于是网关 seed 里的backend_endpoint=http://127.0.0.1:8001/...一个字都不用改—— 本地和云上看起来一样。 - 家侧的 chat-only 后端
db.py做了懒连接,所以 P2 阶段家里根本不用起 PG。 - 网关二进制在 dev WSL 里交叉编译(
GOOS=linux)scp 上去,服务器不装 Go。 - SSE 的命门:nginx 那句
proxy_buffering off,不加就没有「逐 token 流回」的体验。
生产硬化:家侧后端一旦经 frp 暴露,X-Internal-Token 就从「内网信任」升级成真正的安全边界
(impersonation 全靠它 + 后端信任 X-User-Id)。所以加了 config.CheckProdSecrets——
GATEWAY_ENV=production 时,如果密钥还是 dev 默认值或为空,直接 fatal 拒启动(fail-closed),
而且这个检查前置到 main() 最顶部,任何 I/O 之前就拦。配套写了个 mktoken 离线签 JWT 的小工具。
整条公网请求链路——从 TLS 入口一路穿隧道回到家里,再把 token 流回来:
sequenceDiagram
autonumber
participant C as 客户端
participant NG as Nginx<br/>(云·TLS)
participant GW as Go 网关<br/>(云·127.0.0.1:8080)
participant PG as PG<br/>(云)
participant FS as frps<br/>(云·:7000)
participant FC as frpc<br/>(家·WSL)
participant BE as FastAPI 后端<br/>(家·:8011)
participant LLM as DeepSeek
C->>NG: HTTPS POST /v1/agents/chat-deepseek/messages (JWT)
NG->>GW: 反代 (proxy_buffering off)
GW->>GW: 校验 JWT → user_id
GW->>PG: 查路由表 (backend_endpoint)
GW->>FS: 请求 127.0.0.1:8001 (经隧道)
FS-->>FC: frp 隧道
FC->>BE: 转发 + 注入 X-User-Id / X-Internal-Token
BE->>LLM: 流式生成
LLM-->>BE: token 流
BE-->>GW: 统一 SSE (token / usage / done)
Note over GW: usage 记账不外发<br/>只转发 token / done
GW-->>NG: SSE
NG-->>C: 逐 token 流回
隧道那一跳(frps ⇄ frpc)就是「云端控制、家侧算力」的物理接缝; 断了它公网直接 502,重连后自动恢复——这正是 P2 韧性验收要证明的东西。
验收全过 🎉:
- 公网端到端:
https://api.example.com/v1/agents/chat-deepseek/messages(带 JWT) → nginx TLS → 网关 → PG 路由 → frps 隧道 → 家侧 frpc → 后端 → 真 DeepSeek → SSE 逐 token 流回。 路径收口正确(/admin→ 404、无 JWT → 401)。 - 韧性:
stop frpc→ 公网请求 502(隧道断);start frpc(~5s 自动重连)→ 200。 断 / 恢复无需人工干预。 - TLS 走 acme.sh + Let’s Encrypt(HTTP-01 自动续),不依赖手动证。
那些真正让我上头的坑
复盘一段项目,最有价值的往往不是「做成了什么」,而是「差点没做成什么」。
外挂盘事故(血的教训)。
我的两个 WSL 发行版(dev + prod)一开始放在外挂 USB SSD 上,这块盘反复掉线
(0x800701b1 = DEVICE_NOT_CONNECTED),是之前所有 WSL 不稳的根因。
更惨的是 wsl --manage --move 在掉线时半途失败——把 prod vhdx 拷到 E: 后删了 F: 源却没更新注册表,
我误把 E: 那份当垃圾删掉,prod vhdx 直接丢了(好在 dev 和云端都完好,prod 无独有数据可重建)。
三条教训刻进 DNA:
① WSL 别放外挂盘;② 迁移一律 export → 验证 tar → 再 unregister,绝不先删源;
③ 删目录前必须先确认里面是什么。后来 dev + prod 都迁到了内置 NVMe,存储层才真正稳下来。
WSL mirrored 网络模式。
本想把 frpc 做成「原生 Windows 服务」(最稳的开机自启),结果实测 WSL 是 mirrored 模式,
Windows 裸 TCP 连不到 WSL 的 loopback 端口(还有 Clash 代理在 127.0.0.1:7897 搅局)。
于是 frpc 改成跑在 prod WSL 内,和后端同一个 netns,必通。
另外 mirrored 下 dev/prod 共用 Windows localhost 会撞端口,所以 prod 后端改用 8011,和 dev 的 8001 隔离。
多层 ssh/wsl 命令的引号地狱。
所有经 wsl → ssh → 远端 bash 的多变量、含引号命令,内联的 $() / 单引号会被多层引号层层吃掉。
最后总结出一条铁律:本机 base64 编码脚本 → 远端 base64 -d | bash 执行,一劳永逸。
其它零碎:docker 官方脚本在国内被连接重置,改 apt 装 + 云厂商内网镜像加速器;
frps 以 User=ubuntu 跑却读不了 600/root 的配置(chown 一下);
acme.sh install-cert 用 root 的 ~ 找不到证书(改以 ubuntu 身份跑)。
现在站在哪,接下来去哪
已上线:P0 / P1a / P1b / P2 全部完成并入 main, 公网 SSE 端到端 + 断/恢复自动重连验收通过。 一个人,一台云轻量 + 家里一台机器,把「云端控制平面 + 家侧算力」这套混合云底座真的跑起来了。
遗留(都是后续阶段项,非当前 bug):
- 网关硬化切片
dev/p2-gateway-harden待合 main。 - 家侧持久化目前是登录级(登录才拉起 prod),真 7×24 需要自动登录或 admin 级开机任务。
- 计费前必修:用量的
request_id客户端可控且全局 UNIQUE,重放旧 id 会漏记,得改成服务端权威 id。 /admin/reload仍是静态 token(靠 nginx 不暴露缓解)。
下一站 P3:客户端接入(飞书 webhook 异步 + Push API)+ 消息队列 + 内容审核(前置 + 边发边审)。
再往后 P4 是第一个真实 agent 产品 + 商业化(余额扣费,并发不超不漏 + 退款), P5 是生产化(备份恢复、告警、隧道健康、3-2-1 / UPS)。
一个人做底座的乐趣就在这——每一层都得自己想清楚为什么, 每一个「差点翻车」都变成下一次的肌肉记忆。下一篇 (二) 见。