跳到正文
Chandler Nguyen
AI阅读时间1分钟

我把 9 个 Agent 的营销平台开源送了出去

STRAŦUM v1 现在以 MIT 许可证公开了。九个 Agent、两个 Postgres schema、十种语言——还有一张上线时压根没打开 Row Level Security 的表,谁拿着浏览器里加载的那把 key,谁就能碰到它。下面是我送出去了什么、哪些地方我不会再照原来的方式做,以及为什么你完全可以 fork 它、做一个你自己的版本。

我今天把自己营销平台的第一个版本开源了。

它叫 STRAŦUM(斯特拉图姆)。九个 AI Agent,一个按客户把数据分开的代理公司工作空间,十种语言。我在 2025 年一边自学写代码,一边把它做了出来。后来我开始做第二个版本,与其把第一个版本锁起来,我选择把它送了出去。

如果你想先看看再往下读: github.com/chandlernguyen/stratum-oss

git clone https://github.com/chandlernguyen/stratum-oss

如果你正带着营销团队,而且根本不会打开那个仓库,可以直接跳到文末的“如果你根本不打算看代码”。那一节是专门写给你的,而且很短。

我想把那个仓库到底是什么说清楚,因为“开源”可以指很多东西,而其中大多数都夸大了它在这里的意思。

我到底发布了什么

STRAŦUM v1 已经发布,但不再积极开发。代码以 MIT 许可证公开。没有路线图,没有发布计划,也没有任何支持承诺——README 里是这么写的,贡献指南里又写了一遍。

它是以参考实现的形式发布的。它是一件可以拿来读、拿来 fork,或者从里面取下一些片段的东西。它不是一件可以依赖的东西。没有人会跟着未来依赖的变化去维护它,仓库里也把它知道的安全通告一条条列了出来,而不是假装它们不存在。

我并不是说它永远不会变。我不会再继续开发它,所以你应该按它保持现状来规划——但别人发给我的东西我会看,如果有人报了 bug,或者建议了什么能让代码更清楚的做法,我不会假装自己没看见。

把它发出来,还意味着要证明没有任何机密内容跟着一起出去。仓库里有一个脚本,会扫描每一个被 git 跟踪的文件,查找凭证、私钥、服务商 token 和生产环境标识符;它是 CI 里的第一个任务——排在测试前面——因为一把泄露的 key,在 push 的那一刻就已经进了历史,事后删掉也抹不平。

下面是它包含的东西:

  • 九个 Agent——战略、Persona、内容、效果情报、竞争情报、Campaign 规划、客户成功,还有两个。每一个都是一个基础 Agent 的子类,共用一套 prompt 结构、一个工具注册表,还有渐进式上下文。
  • 两个 Postgres schema。 单个企业的数据放在一个里,代理公司的数据放在另一个里,以 Row Level Security 作为隔离边界。
  • 十种语言,locale 通过 header 传到 API,这样生成的元数据和界面语言能对得上。
  • 一个不需要 API key 的 demo 模式。 Agent 不调用模型,而是返回明确标注过的预置输出,所以你没有服务商账号也能把整个应用点一遍。

如果你想做一个自己的版本

这是我最想看到的用法,所以值得把它明说,而不是留在暗示里。

如果你真正想要的,是属于自己的那一套营销 Agent 系统——你自己的 Agent、你自己的 prompt、你自己的 schema、上面再架你自己的产品——那就 fork 这个仓库,去把它做出来。MIT 许可证就是为这件事准备的,这是它预期的用法,不是什么漏洞。除了保留许可证文件,没有任何署名要求,也不需要先来问我。拿走有用的部分,扔掉没用的部分,凡是你不认同的地方,尽管改。

比起只看过一个我自己碰过的版本,我更愿意看到十几个不同的版本。

为什么把 v1 发布出来,而不是自己留着

我把它压了十个月没发,因为我以为没人会想要一个我已经停手的 v1。这个假设错了,而它错在哪里,比这个仓库本身更有价值。

第二个版本走的是另一条路。我现在在做的东西和这套代码库已经不太像了,将来也不会是它的一次 diff。把 v1 留在私有仓库里,只会让它慢慢变成一件谁也没法进去看的博物馆展品。

更重要的理由是:一个真能跑起来的多租户 agentic 应用,比一张架构图有用得多。我自学那会儿,帮到我的不是那些概念讲解——而是找到一个真实项目,看别人到底是怎么把它安排起来的。讲解里丢掉的那部分推理,恰恰是最重要的推理。

我在仓库的架构笔记里写了四件自己做错、不会再做的事,而且四件都原样留着。第一件是一个安全漏洞。

那个上线了的错误

有一张表上线的时候没有打开 Row Level Security。

public.notification_push_deliveries 在创建时少了那行启用 RLS 的语句,还被授予了 anonymous 和 authenticated 角色的完整权限——包括 TRUNCATE——而且一条策略都没定义。它待在 Data API 所暴露的那个 schema 里,而 anonymous key 是随浏览器 bundle 一起发出去的。所以在那个时间段里,任何拿着这把 key 的人都可以读这张表,或者改它。它存的是推送设备标识符,这让它成了一个真实存在的数据暴露问题,而不是理论上的问题。

我是在一次安全审查里发现的。不是在 bug 报告里,也不是在测试里,因为这个应用自始至终都跑得好好的。一张表缺了 RLS,不会抛任何错误。查询照常成功,功能照常工作,那个本不该存在的权限就那么待在那里,直到有人去看,才看得见它。

它现在已经关上了。这张表启用了 RLS,没有定义任何策略,浏览器角色的授权也已经被撤掉,所以那个曾经会返回行的请求,现在回来的是一个权限错误。我刻意用过去时描述它:这是代码当时的样子,不是它今天的样子。

这正是我想说清楚的地方:这不是我做对的一个设计决策。这是我没做对、事后才发现的一次回归。

于是我不再相信自己的记忆

接下来我做的事,是这整件事里我唯一敢放心交给别人的部分。

我写了一个不指名任何具体表的测试。它遍历整个 schema,断言的是一条规则:暴露 schema 里的每一张表都必须启用 Row Level Security,而且任何按租户划分的物化视图都不能被浏览器角色读到。如果我下个月加了一张表又忘了,测试会挂,而且它挂在我忘掉的那件事上,而不是挂在我当初记得时写的那份清单上。

两个诚实的保留意见,都是我希望读者拿来盯着我、而不是日后自己才发现的:

  • 那项检查里管物化视图的那一半,只覆盖 public schema,不覆盖 agency schema。agency 那边还有它自己的暴露面,我还没关上。
  • 这个测试在连不上数据库时会跳过自己。跳过的测试是绿的,也就是说,一个坏掉的环境可以藏住一条正在失效的约束。

如果说这整件事让我养成了一个习惯,那就是这个:当我发现这种形状的 bug,我会去写那个本该抓到它的测试——而且是针对规则写,不是针对那个对象写。一个点名我记得的那三个对象的测试,只保护那三个对象。一个断言规则的测试,保护的是我下周新加、然后又忘掉的那一个。

第二个陷阱

还有第二个,是我靠运气而不是靠设计做对的,而且它正是我在跟厂商聊天时反复听到的那一句。

客户端过滤不是边界。如果你的应用在浏览器里按组织过滤行——.eq('org_id', ...) 之流——那隔离就跑在用户的机器上,也就意味着它能被开发者工具删掉。任何删得掉的东西都是展示偏好,不是安全控制。行的可见性必须在数据库里强制执行,或者藏在一个调用方绕不过去的服务端接口后面。

物化视图是这个问题里那个别扭的亲戚。它们根本没法拥有 Row Level Security。对物化视图的一条 SELECT 授权,会返回所有租户的行,而它在 migration 里看起来和授予一张表的那条一模一样——可后者仍然会被策略约束住。正因为这个,仓库里凡是按租户划分、又被物化的东西,都会撤掉浏览器角色的授权。

我还没做完的那一个

Row Level Security 管不了 TRUNCATE。它是表级权限,不是行级权限,所以策略对它不生效。仓库的基线授权给了浏览器角色 ALL,覆盖了一大堆表——其中就包括 TRUNCATE——之后再没有任何东西把它收窄。物化视图的授权是撤掉的。这些不是。所以确实有一些表,浏览器角色手里握着一个处在我这一节刚描述过的边界之外的权限。

实际上它是潜在的,而不是敞开的大门:Data API 没有 TRUNCATE 这个动词,浏览器角色也没法直接连数据库,而且正常部署不会把数据库端口暴露出来。这是一个卫生问题。但它和这篇开头那个 bug 是同一个形状——一个悄无声息地活过了自己理由的权限——而我宁愿把它指出来,也不愿让读者自己撞见,然后怀疑我到底知不知道。它需要在两个 schema 上做一次 revoke,再加一个测试,断言没有任何浏览器角色握着能绕过 RLS 的权限。

我不会再做的事

两个并行的 schema 复制了一大堆 DDL。 代理公司的数据和个体企业的数据是两套平行结构,而不是一张表加一个租户判别列。这种分离确实更干净。但复制在维护上付出的代价,比那个判别列要高,下次我会选另一边。

手动函数调用是一大堆代码。 Agent 循环是手工处理工具调用的,没有用服务商提供的自动函数调用——好处是流式输出和工具执行都留在应用自己的控制里。代价是应用得自己拼装函数结果的各个部分,而在现在的模型上,这些部分必须带上 call id,光有函数名不行。漏掉这个 id,你不会得到 schema 报错。你得到的东西读起来像是模型在抽风,那是一个糟糕得多的下午。如果现在有更高层的 API 支持带工具的流式输出,这笔取舍值得重新算一遍。

迁移链一路涨到了 321 个文件。 其中很大一部分叫 fix__v2remove_,因为我一直在往后追加修正,而不是直接改掉我已经写下的东西。schema 的真实状态,只有从最开始把历史重放一遍才知道。我当时写的那些文章,对那段时间的信心超出了代码应得的水准——2025 年 11 月我说三十三个迁移“终于解决”了多租户,然后之后好几个月还在继续写纠正性迁移。

前端相信 API 的形状,却不做校验。 进来的路上有仔细的校验——后端用 Pydantic 解析每一个请求——出去的路上一个都没有。浏览器拿到响应,然后照单全收。一份共享 schema 本可以在字段形态变化时抓到这种漂移,而不是等到它变成一块白屏才被发现。把这条和上面三条加在一起,就是架构笔记里我说会换个做法的那四件事。

发布出来的仓库把迁移链重建成了 二十个分层的迁移,按关注点分组——先是表,然后是按领域分组的函数、视图、索引、触发器、策略、授权、定时任务,最后一轮加固。这大致上成立,而不是严格成立;该老实说的地方还是要说:二十个里面有一个,是我自己承认放了些没归好类的函数的杂物袋;而表这一层也不是按它文件名暗示的那样拆的。那个以共享 schema 命名的文件,同时也建了八个 agency 表;而那个以 agency schema 命名的文件,一张表定义都没有。分层是真的;标签不完美。

这次拆分真正做对的地方,是读它的时候真正重要的那一点:这次 schema 变动纯粹是一次重组,验证方式是把拆分前后各 dump 一遍,确认两者除了 dump 工具的随机 token 之外完全一致。

这次验证,是我唯一愿意动手碰它的原因。

什么活了下来

Row Level Security 是真正的边界,而不是一个可以开关的功能。一旦设计围着它来安排,隔离就不再是一条检查清单项,而变成了系统的一个属性。

写入都走被路由的数据库函数。 应用代码不选择要碰哪个 schema。它调用一个函数,由这个函数去检查组织类型然后分发,于是这个选择只活在一个地方,新的代码路径不可能忘记做这个选择。

注册流程忽略客户端输入。 开通流程里的触发器不会采信客户端提供的组织 id 或角色,因为那些数据是用户可控的。它会新建一个组织,并给一个默认的 owner 角色。事情很小,容易做错,做错的时候代价很贵。

客户端延迟构建。 服务在第一次用到时才构建它的服务商客户端,而不是在 import 的时候。这听起来像是一种风格偏好,但它不是:有好几个服务是在模块 import 时就创建的,所以提前构建意味着import 这个应用本身就需要一把 API key,而失败会以服务商 SDK 抛出的一个看不懂的错误出现,早到什么都还没能启动。把它改成延迟,才让 demo 模式成为可能——而 demo 模式,才让一个陌生人能零成本地评估这个项目。

最后这一条是我最满意的决定,而当初做它的时候,我并不是因为它后来变得重要才做的。我是为了让它别再崩。

如果你根本不打算看代码

这一节,是我还在代理公司那一侧、买这类软件的时候会想要的,所以里面没有一行代码。

真正值得带走的,是**“我们按账户过滤”“数据库根本返回不了另一个账户的行”**之间的区别。

我职业生涯里大部分时间都在买平台,最近这几年在自己做平台,而这条区别,正是我直到亲手发布了错的那一版才明白的。前者是浏览器遵守的一条规则。后者是浏览器破坏不了的一条规则。大多数工具只有前者,却用后者的语言来描述它。

当你在评估一个会装着好几个客户数据的平台——竞争情报、效果数据、受众定义,不管是什么——要问的问题不是“它安全吗”。这个问题所有人都会说安全。要问的是:隔离到底在哪里执行,如果一个开发者把那个过滤器删掉了,会发生什么?

好答案有两个。它由绑定到登录用户身份的策略在数据库里强制执行,或者它被挡在浏览器绕不过去的服务端接口后面。任何答案里只要出现浏览器,就是不行。

好的答案听起来像是:策略就挂在表上,并且以用户的 session 为键;浏览器从不直接查那张表;这是证明它的测试。没用的答案听起来像是:我们的应用按账户过滤;数据是加密的;我们有 SOC 2 合规。这些可能都是真的,但没有一个回答了那个问题。

它只有一句话,刚好放得进供应商安全审查里,而那也是我会放它的地方。对方的回答会告诉你,多租户是设计进去的,还是后来补上的。

常见问题

为什么把它发布出来,而不是让它在私有仓库里待着?

因为公开的东西可以被查验,私有的不能。我早先写过的那些讲这件事的文章里有一些说法;而一个带着迁移、策略和测试的仓库,是读者可以自己去验证的东西,包括我搞错的那部分。架构笔记里有一节叫“取舍,以及我会怎么换个做法”,仓库之所以以这种形式存在,就是因为它。

第二个版本会开源吗?

不会。它是私下开发的,而且这套代码库不会和这个版本长得像。我宁愿把这话直说,也不愿留着模糊空间,让人 clone 了这个还期待有什么路线图。

我能在生产环境里用它吗?

我不会。它是参考,不是产品。种子数据里的 demo 凭证只供本地使用,没有任何支持承诺,也没人会跟着未来的依赖变化去修它。它是一个适合拿来读、拿来借用的东西,也是一个不适合拿来跑生意的东西。

试用它需要 AI key 吗?

不需要。它启动时是 demo 模式,Agent 不调用模型,而是返回明确标注过的预置输出。你可以不用 key、不花一分钱,把整个应用点一遍——每一个 Agent、代理公司的客户流程、语言切换器。切换到真实模型调用,只要一个设置和一把 key。我第一次跑就花了十分钟以上:它需要 Docker 跑着,还要 Node、Python、Poetry 和 Supabase CLI,而慢的那部分是一次很大的下载。


我一边做它,一边写了很多关于它的东西,如果要挑两篇开始读,我会选为什么我在第二天就做了多租户第六十七天我把它重做时发生了什么。如果两篇里只有一篇值得你花时间,那就是第二篇——那一篇里,架构被证明是错的。

代码在 github.com/chandlernguyen/stratum-oss,而那个守着我在上线时犯下的错误的测试,在 tests/automated/test_rls_coverage.py 里。

如果你也发布过多租户系统,还发现了我没提到的第三个静默陷阱,我真的很想听听——这种才是值得收集的东西。

反馈、建议,以及 v1 之后会怎样

我不想让它变成单向广播,所以下面是我对“可以期待什么”的诚实版本。

我欢迎什么: 如果仓库里有什么东西本身就是错的,欢迎报 bug。那些可以更清楚、更简单,或者能用更少代码做到的地方,欢迎提建议。如果你试着把它跑起来、撞上了 README 没覆盖的东西,欢迎告诉我。如果你发现了真正的问题、想自己修,欢迎提 pull request。还有,如果你 fork 了它、做出了自己的东西,我想知道你改了什么、为什么改——那是最有意思的反馈,因为那些决定是你真的做过的。

我能承诺什么: 不多,而我宁愿这样说,也不愿暗示别的。这不是一个还在积极开发的项目,我没有在运营一个支持台,也没法承诺回复时间。有些建议我会去做。有些我会看完、认同,然后一直没时间做。这就是一个已经有了后继者的副业项目的现实版本。

v1 之后会怎样: 它就照现在这样公开着。我不会再继续开发它,所以别把计划建立在会有新版本上。但我也不会假装它被封起来了——代码是公开的,许可证允许你把它带往任何方向,而如果有什么东西坏了、或者确实说不清楚,我没有理由为了原则,就让它一直这样。

联系我最简单的方式,是在仓库里开一个 issue;如果你不想公开,也可以发邮件。

这次就先到这里。

祝好,Chandler