这是 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_ledgeron conflict(request_id) do nothing 去重, 客户端尾包断连也不丢账(用 context.WithoutCancel + 超时兜住)。

这里有个安全裁定我印象很深:为什么 user_id=0(未认证)对私有 agent 一定安全? 因为私有行的 owner_user_idNOT NULL(CHECK 约束保证),而 user_id 从 1 起(BIGSERIAL), coalesce 只在 NULL 时出 0,私有行不可能 NULL——所以 0 永远匹配不上任何私有 agent。 review 逐条确认没有 user_id=0 漏洞。约束即安全边界,这种「用数据库约束把非法状态变得不可表达」的 感觉特别踏实。

3. PGVector RAG(私有隔离) RAG 落在 Python 后端(家侧数据平面),网关只多转发一个 user_idEmbedder 做成协议,测试用确定性的 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-004gemini-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)。

一个人做底座的乐趣就在这——每一层都得自己想清楚为什么, 每一个「差点翻车」都变成下一次的肌肉记忆。下一篇 (二) 见。