每次我向其他开发人员解释 Rasepi 的翻译架构时,他们都会提出一个问题:"等等,你们的所有租户都共享一个 DeepL API 密钥?你如何保证他们的词汇表和风格规则不会相互泄露?
这个问题很有道理。答案涉及的设计工作比你想象的要多。
我们在上一篇文章中介绍了完整翻译流水线,包括块级散列、协调器以及从文档保存到翻译输出的整个流程。本篇文章将深入探讨多租户的具体问题。如何使用没有租户概念的第三方 API 并在其基础上构建租户隔离。
问题:DeepL 不了解您的客户
DeepL 的 API 使用单个 API 密钥进行验证。在该密钥下创建的所有内容、词汇表、样式规则列表、翻译历史都属于同一个账户。DeepL 没有 "此词汇表属于租户 A "的概念。
当您调用 GET /v2/glossaries时,您将获得来自 ** 个租户的 ** 份词汇表。创建样式规则列表时,该列表与其他租户的样式规则位于同一命名空间。API 是扁平的。
对于自托管产品来说,每个客户都使用自己的 DeepL 密钥运行自己的实例,这样就可以了。对于我们管理基础设施的多租户 SaaS 而言?你需要一个隔离层。
数据库是真相之源
我们的核心设计决策:数据库拥有所有词汇表内容和样式规则配置。DeepL 是运行时的执行目标,仅此而已。
每个 TenantGlossary 和 TenantStyleRuleList 实体都实现了 ITenantScoped,这意味着 EF Core 全局查询过滤器会自动将所有读取范围限定为当前租户。在租户 A 的请求上下文中查询词汇表,永远不会返回租户 B 的条目。这也是我们在 Rasepi 中随处使用的隔离模式,在 ORM 层级强制执行。
有趣的地方就在这里。当租户编辑词汇表术语时,我们不会立即调用 DeepL。我们会更新数据库行并设置 IsDirty = true。就是这样。实际的 DeepL 词汇表会在下一次翻译需要它之前懒散地创建(或重新创建)。
_codeblock_0
TenantGlossaries上的查询过滤器会进行隔离。IsDirty标志实现了懒同步。命名约定(_rasepi-{glossary.Id})仅用于在 DeepL dashboard 中调试,没有任何功能用途。
为什么是懒同步?因为 DeepL v2 词汇表是不可变的。您无法编辑它们。任何更改都意味着删除和重新创建。如果一个团队导入了包含 200 个术语的 CSV,然后修改了一个词条中的一个错字,我们不希望两次删除和重新创建 DeepL 词汇表。我们只需两次都设置 IsDirty,下一次翻译运行时就会进行一次重新创建。
样式规则:相同的模式,不同的应用程序接口
DeepL's样式规则 是较新的(v3 API),实际上是可变的,这一点更好。你可以使用 PUT /v3/style_rules/{style_id}/configured_rules 就地更新已配置的规则,还可以单独添加或删除自定义指令。
不过,我们仍然使用相同的 IsDirty 模式。一个 TenantStyleRuleList 有一个 DeepLStyleId 映射到 DeepL 的运行时标识符,另外还有 ConfiguredRulesJson 用于格式化规则,以及一个 TenantCustomInstruction 条目集合用于自由文本翻译指令。
真正的威力在于这些自定义指令。每条指令都是最多 300 个字符的纯语言指令,可影响 DeepL 的翻译方式。来自我们租户的真实例子:
- _"始终使用'Sie'形式,而不是'du'"_一家德国律师事务所
- _"将'deployment'翻译为'Bereitstellung',而不是'Deployment'"_对于依赖于上下文的术语,这超出了简单的词汇表映射范围
- _"使用英式英语拼写(颜色、组织、许可证)"_适用于在英语变体之间进行翻译的英国公司
- _"在数字金额后加上货币符号"_用于欧洲惯例
每个租户的每种目标语言都可以有完全不同的指令,但都使用相同的 API 密钥。每一次翻译调用都只包含属于请求租户的 glossary_id 和 style_id_,从而实现了隔离。其他租户的 DeepL 资源永远不会被引用。
翻译调用:所有内容组成
当协调器翻译一个程序块时,它会将所有租户的特定设置合并到一个请求中:
代码块_1__
这里的每个参数都针对租户。glossaryId 是通过租户过滤查询解决的。styleId 也是通过同样的方式解析的。formality来自TenantLanguageConfig,也是租户作用域。甚至___DEBLOCK_23__(为提高翻译质量而发送的周边段落,不计费)也来自租户自己的文档。
我想强调的一点是:当设置 style_id 时,DeepL 会自动使用他们的 quality_optimized 模型。您不能将样式规则与 latency_optimized 结合使用。这是一个 DeepL 限制,但说实话是一个合理的限制。如果您要投资自定义样式规则,您可能希望获得最佳的输出质量。
块级缓存:数据库作为翻译记忆库
我们不会调用 DeepL 来处理没有变化的代码块。缓存机制就是 TranslationBlock 表本身。
每个 EntryBlock源都有一个 ContentHash,这是其语义内容的 SHA256 值(去除了 blockId和 deleted等元数据属性)。每个 TranslationBlock都存储了翻译时的 SourceContentHash。当源代码块发生变化时,其哈希值也会发生变化。协调器会比较哈希值,并只对不匹配的区块进行排队。
每个区块的决策树如下所示:
1.哈希值匹配、翻译存在 = 跳过(缓存、最新) 2.哈希值已更改、机器翻译、未锁定 = 自动重新翻译 3.哈希值已更改,人工编辑或锁定 = 标记为过时,不覆盖
第三种情况至关重要。如果您的德语译员对某个段落进行了人工润色,我们不会因为英文来源发生了变化就将其删除。我们会将其标记为 "过期",以便他们知道需要对其进行审核,但翻译后的文本保持不变。
实际结果是:在 30 个段落的文档中编辑一个段落,只需触发一个 DeepL API 调用(嗯,一个批次,包括一个区块)。所有语言的其他 29 个段落都已缓存,不会产生任何费用。
为什么不对每个租户使用单独的密钥?
我认真考虑过。给每个租户一个自己的 DeepL API 密钥,完全消除隔离问题。
我们没有这么做有三个原因
1.** 计费复杂性。** 每个租户都需要自己的 DeepL 订阅或提供子账户的方法。DeepL 本身不提供多租户密钥管理。 2.共享基础设施意味着共享费率限制和批量折扣。我们的总使用量可获得更优惠的价格。 3. 操作简单。**只需轮换一个密钥,监控一个配额,维护一个集成。
代价是我们需要我所描述的隔离层。但是,鉴于我们已经为系统中的其他一切提供了租户范围的 EF Core 查询,因此将其添加到词汇表和样式规则中就变得简单易行了。模式已经存在。
##真正保护您的是什么
总结一下隔离保证:
- 词汇表条目**存储在
TenantGlossary(实现ITenantScoped)中,由 EF Core 全局查询过滤器过滤。DeepL 词汇表 ID 是不透明引用,只能在租户上下文中解析。 - 样式规则和自定义说明**通过
TenantStyleRuleList_遵循相同的模式。 - 翻译内容**位于
TranslationBlock中,通过其父Entry→Hub链进行作用域划分,该链也是租户作用域划分。 SaveChanges防护**会在新实体上自动设置TenantId,并在跨租户写入时抛出。- 生产代码中没有 __DEBLOCK_42**。永远不会
设计原则很简单:DeepL 查看资源 ID。Rasepi 看到的是租户范围内的实体。它们之间的映射永远不会跨越租户边界,因为解析映射的查询在物理上无法返回另一个租户的数据。
如果你正在构建一个多租户的 SaaS,并与没有原生租户支持的第三方应用程序接口集成,那么这种模式就能很好地发挥作用。将外部 API 视为无状态执行引擎,将所有配置保存在你自己的租户范围数据库中,懒散地同步,永远不要相信外部资源列表的隔离性。