四阶段架构演进指南:从单配置对象到多供应商隔离(OpenAI/Claude),涵盖提示词管理、成本控制、租户数据隔离等实战痛点。
两个租户、两个 AI Provider、两套 prompt。听起来很简单,第一天也确实如此。这正是陷阱所在。它会一直保持简单,直到第二位客户发来第一个“简单问一下”;十八个月后,你已经在运营一个小型分布式系统来回答这个问题。下面才是那张幻灯片的真实版本:四个阶段,每个阶段都源于某个真实的人在 Slack 中输入的一条真实需求。
租户 A 想用 OpenAI,租户 B 想用 Claude。双方都希望使用自己的 system prompt。最显而易见的初版方案是:一个配置对象,每个租户占一行。能出什么问题呢?(所有问题。所有问题都有可能发生,只是还没到时候。)
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Request │ → │ TENANT_CONFIG │ → │ Provider │
│ (tenantId) │ │ (hardcoded obj) │ │ SDK call │
└─────────────┘ └──────────────────┘ └─────────────┘
const TENANT_AI_CONFIG = {
tenantA: { provider: 'openai', model: 'gpt-5', prompt: 'You are terse and technical.' },
tenantB: { provider: 'anthropic', model: 'claude-sonnet-5', prompt: 'You are friendly. Antworte auf Deutsch.' },
} as const
async function handleChat(tenantId: string, userMessage: string) {
const config = TENANT_AI_CONFIG[tenantId]
const client = config.provider === 'openai' ? openai : anthropic
return client.chat(config.model, config.prompt, userMessage)
}
一个下午就能上线。两个租户、两行配置,演示效果极佳,所有人都在鼓掌 👏。把这一刻裱起来吧,因为这是这个代码库今后最平静的时刻。
上线一周后(才一周,我们甚至还没完整跑完一个 sprint),租户 B 发来消息:“能不能让我们自己修改 prompt,不用每次都等你们部署?”这个要求很合理:他们了解自己的用户,我们不了解;而且也没人愿意成为那个半夜被呼叫起来、只为修改一个字符串字面量的值班工程师。硬编码对象无法满足这个需求,哪怕只改一个逗号,也得重新构建。
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Request │ → │ tenant_settings │ → │ Provider │
│ (tenantId) │ │ (DB row, admin │ │ SDK call │
│ │ │ editable) │ │ │
└─────────────┘ └──────────────────┘ └─────────────┘
配置从代码常量迁移到一张数据表中,租户可以通过自己的管理后台修改它。
async function getAiConfig(tenantId: string) {
const row = await ctx.db.tenantSettings.findOne({ tenantId })
return row.aiConfig // { provider, model, prompt }
}
配置结构与之前相同,只是来源变了。handleChat 完全不需要修改,它根本不知道这一切曾经发生过——而这恰恰就是把查询逻辑封装在一个函数背后的意义。
自助服务很好用,直到它不再好用:租户 B 的 prompt 在上周二悄悄发生了变化,他们的机器人不知为何开始用海盗腔回答问题,而且没人能还原事情的来龙去脉。客服收到工单,而最诚实的回答只能是:“我们也不知道,数据库也不记得。”普通的数据库行只会被覆盖,过去没有任何表示形式,就像按下了 Ctrl+Z,却根本没有撤销历史。
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐
│ Admin edits │ → │ ConfigChanged │ → │ current config │
│ the prompt │ │ event (who, │ │ = fold(events) │
│ │ │ when, diff) │ │ │
└──────────────┘ └────────────────┘ └──────────────────┘
配置不再是可变的数据行,而是采用事件溯源:每次修改都是一个事件,当前值则是对这些事件进行投影得到的结果。
async function updateAiConfig(tenantId: string, patch: Partial<AiConfig>, actor: string) {
await ctx.emit('AiConfigChanged', { tenantId, patch, actor, at: ctx.now() })
}
async function getAiConfig(tenantId: string): Promise<AiConfig> {
const events = await ctx.db.events.find({ tenantId, type: 'AiConfigChanged' })
return events.reduce((cfg, e) => ({ ...cfg, ...e.patch }), DEFAULT_AI_CONFIG)
}
现在,“谁在什么时候修改了它”变成了一次查询,而不是一场通灵仪式 🔮。handleChat 依然没有任何变化,它只是调用 getAiConfig,完全没意识到自己现在面对的是事件日志,而不是一张数据表。
这时来了一家更大的租户——就是那种会拥有专属 Slack 频道的客户——并提出了两个要求:他们希望使用自己的 OpenAI key(为了控制成本、使用自己的速率限制,也为了应付在背后紧盯着他们的财务团队);同时,他们还希望设置严格的月度支出上限,避免某个咖啡因摄入过量的实习生写出的脚本,最终变成一张五位数的账单。
┌──────────────┐ ┌───────────────────────┐ ┌──────────────┐
│ Request │ → │ config.apiKey? │ → │ usage < cap?│
│ │ │ (BYOK, encrypted) │ │ → call │
│ │ │ else our shared key │ │ → else 429 │
└──────────────┘ └───────────────────────┘ └──────────────┘
async function callProvider(tenantId: string, userMessage: string) {
const config = await getAiConfig(tenantId)
const usage = await getMonthlyUsage(tenantId)
if (config.usageCap && usage >= config.usageCap) {
throw new UsageCapExceeded(tenantId)
}
const apiKey = config.byokApiKey ?? process.env.SHARED_API_KEY // BYOK overrides shared key
const client = getClient(config.provider, apiKey)
const reply = await client.chat(config.model, config.prompt, userMessage)
await recordUsage(tenantId, reply.usage.totalTokens)
return reply
}
只需在同一份配置中增加 byokApiKey 和 usageCap 两个字段,并在调用前检查一次计数器。无需引入新架构,无需重写前三个阶段,也不用说什么“抱歉,我们需要花整整一个季度重新设计”。
每个阶段都把 getAiConfig(tenantId) → { provider, model, prompt, ... } 保留为系统的接缝。底层存储方式变了四次(常量、数据库行、事件溯源投影、包含加密密钥的投影),调用方却从未察觉、从不关心,甚至都没有问过。这才是真正的经验:不要一开始就设计完整的多租户 AI 系统,而要设计一道能够吸收下一条 Slack 消息所带来变化的接缝。
有意略过的内容包括:Provider fallback、流式传输,以及按模型维护的成本表。等某个租户真正提出需求时再添加它们,就像前面的所有功能一样。如果在你读完这句话之前,就有租户要求接入第五个 Provider,那也不算反例——那只是普通的星期二。🙃
若要采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。