← 返回博客

一个 API 密钥,多个租户:我们如何在不同客户间隔离 DeepL 翻译

Rasepi 对所有租户使用一个 DeepL API 密钥。以下是我们如何处理每个客户的词汇表、风格规则、缓存翻译和块级隔离,而不泄露任何信息。

Thinking out loud
一个 API 密钥,多个租户:我们如何在不同客户间隔离 DeepL 翻译

每次我向其他开发人员解释 Rasepi 的翻译架构时,他们都会提出一个问题:"等等,你们的所有租户都共享一个 DeepL API 密钥?你如何保证他们的词汇表和风格规则不会相互泄露?

这个问题很有道理。答案涉及的设计工作比你想象的要多。

我们在上一篇文章中介绍了完整翻译流水线,包括块级散列、协调器以及从文档保存到翻译输出的整个流程。本篇文章将深入探讨多租户的具体问题。如何使用没有租户概念的第三方 API 并在其基础上构建租户隔离。

问题:DeepL 不了解您的客户

DeepL 的 API 使用单个 API 密钥进行验证。在该密钥下创建的所有内容、词汇表、样式规则列表、翻译历史都属于同一个账户。DeepL 没有 "此词汇表属于租户 A "的概念。

当您调用 GET /v2/glossaries时,您将获得来自 ** 个租户的 ** 份词汇表。创建样式规则列表时,该列表与其他租户的样式规则位于同一命名空间。API 是扁平的。

对于自托管产品来说,每个客户都使用自己的 DeepL 密钥运行自己的实例,这样就可以了。对于我们管理基础设施的多租户 SaaS 而言?你需要一个隔离层。

数据库是真相之源

我们的核心设计决策:数据库拥有所有词汇表内容和样式规则配置。DeepL 是运行时的执行目标,仅此而已。

每个 TenantGlossaryTenantStyleRuleList 实体都实现了 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_idstyle_id_,从而实现了隔离。其他租户的 DeepL 资源永远不会被引用。

翻译调用:所有内容组成

当协调器翻译一个程序块时,它会将所有租户的特定设置合并到一个请求中:

代码块_1__

这里的每个参数都针对租户。glossaryId 是通过租户过滤查询解决的。styleId 也是通过同样的方式解析的。formality来自TenantLanguageConfig,也是租户作用域。甚至___DEBLOCK_23__(为提高翻译质量而发送的周边段落,不计费)也来自租户自己的文档。

我想强调的一点是:当设置 style_id 时,DeepL 会自动使用他们的 quality_optimized 模型。您不能将样式规则与 latency_optimized 结合使用。这是一个 DeepL 限制,但说实话是一个合理的限制。如果您要投资自定义样式规则,您可能希望获得最佳的输出质量。

块级缓存:数据库作为翻译记忆库

我们不会调用 DeepL 来处理没有变化的代码块。缓存机制就是 TranslationBlock 表本身。

每个 EntryBlock源都有一个 ContentHash,这是其语义内容的 SHA256 值(去除了 blockIddeleted等元数据属性)。每个 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中,通过其父 EntryHub链进行作用域划分,该链也是租户作用域划分。
  • SaveChanges防护**会在新实体上自动设置TenantId,并在跨租户写入时抛出。
  • 生产代码中没有 __DEBLOCK_42**。永远不会

设计原则很简单:DeepL 查看资源 ID。Rasepi 看到的是租户范围内的实体。它们之间的映射永远不会跨越租户边界,因为解析映射的查询在物理上无法返回另一个租户的数据。

如果你正在构建一个多租户的 SaaS,并与没有原生租户支持的第三方应用程序接口集成,那么这种模式就能很好地发挥作用。将外部 API 视为无状态执行引擎,将所有配置保存在你自己的租户范围数据库中,懒散地同步,永远不要相信外部资源列表的隔离性。

Smoower logo Smoower

Know what your DevRel team should ship next

Smoower watches your repos, releases, and issues, plus what devs say about you on Reddit, Hacker News, and YouTube. Each teammate gets the next task worth doing and a draft worth shipping, with the source signal attached.

See a sample draft →