针对 OpenAI 实时 AI 功能题的完整解答:选定具体功能、请求生命周期、准入与排队、负载削减、缓存、成本控制、前端恢复、可观测性、一次排障经历,以及 10 倍规模扩展。

由 The Forward Deployed 编辑团队撰写并审校

本文属于 OpenAI 系统设计题库。该题目能代表这一轮面试的水平。分析与解答为本站原创。

题目描述

这道题故意出得很宽泛,考察的是你能否把它具体化。尽早选定一个功能,并做端到端设计。本解答以文档编辑器中的摘要按钮为例:用户点击后,当前打开文档的摘要流式输出到侧边面板。

澄清问题

  • 功能是什么? 提出一个方案:面向日活 5000 万用户的编辑器提供文档摘要功能。确认面试官认可这个选择。
  • 模型在哪里运行? 假设使用第三方模型 API,每分钟有固定的 token 配额,并保留后续自建部署的选项。
  • 体感速度要求多快? 首个字词在一两秒内出现;完整摘要在十秒左右完成。
  • 摘要需要多新鲜? 必须与当前文档版本一致。
  • 成本怎么办? 有月度预算。询问预算规模,或给出成本公式,把预算当作输入。
  • 谁可以使用? 所有用户,免费 tier 有更低的限额。

实时 AI 功能难在哪里

模型容量是稀缺资源,而且它的行为与普通后端不同。一次模型调用需要数秒,每个 token 都有真实成本,并且受制于不会随流量增长而扩大的配额。在 Web 层可以用自动扩容吸收的流量尖峰,对于由模型支撑的功能来说,会变成排队、超时和账单。

与此同时,用户能看到一切。首个 token 出现太慢会让人觉得坏了。流式输出中途断掉比直接报错更糟。一个等待 60 秒后失败的请求,既浪费了用户的时间,也浪费了它消耗的 token。

因此,核心矛盾是用户体验与稀缺且昂贵的容量之间的冲突。每一个设计决策,要么保护容量,要么把容量花在用户能察觉到的地方。

flowchart LR
  U([User clicks Summarize]):::user --> GW[Gateway]:::svc
  GW --> C{Cache hit?}:::svc
  C -->|yes| S[Stream cached summary]:::store
  C -->|no| A{Admit?}:::svc
  A -->|no| R[Fast, clear rejection]:::bad
  A -->|yes| M[Model call, streamed]:::store
  classDef user fill:#e6efec,stroke:#315e55,color:#171717;
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
  classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
核心思想。 把模型每秒可用的 token 数当作稀缺资源。用缓存和准入来保护它,只把它花在用户仍在等待的请求上。

关键概念

准入控制

在入口处判断一个请求能否及时得到服务。如果不能,立即拒绝并给出清晰的提示。这样队列保持很短,已准入的请求才能保持快速。

负载削减与降级

在过载时,先丢弃或降低价值最低的工作:免费 tier 的请求、长输出,或大模型。降级让功能以较低质量继续可用,而不是对所有人失效。

利特尔法则

并发数等于到达率乘以持续时间。每秒 300 个、持续 10 秒的流意味着同时有 3,000 个打开的流。它用于估算连接容量和模型并发数。

提示词缓存

许多模型提供商会缓存重复出现的提示词前缀,并以更低的费率计费,同时处理速度更快。每个提示词开头固定的指令块都能从中受益。

分布式追踪

一个追踪 ID 跟随请求穿越浏览器、网关、队列和模型调用。每一步记录一个带时间戳的 span。它能把“摘要很慢”变成“队列等待没问题,是模型耗时翻倍了”。

核心思想。 只接收你能服务的请求,优先舍弃价值最低的工作,用利特尔法则估算容量,缓存前缀和结果,并追踪每一个请求。

需求

在继续阅读之前。 请写出 Summarize 功能的需求。你会承诺哪一个面向用户的数字?

1.1 功能需求

  • 对当前文档版本生成摘要,以流式方式输出到面板中。
  • 当文档未发生变化时,立即返回已有的摘要。
  • 当用户关闭面板时取消请求。
  • 失败时重试。
  • 执行按用户和按套餐等级的限制。

1.2 非功能需求

  • 已接收请求的首 token 时间在 p95 下低于 1.5 秒。
  • 无长时间挂起。 请求要么在几秒内开始流式输出,要么被明确拒绝。
  • 成本控制在预算内,并在超支前发出告警。
  • 可观测性。 任何缓慢或失败的请求都能通过其追踪记录得到解释。

1.3 约束与属性

快速、诚实的体验是属性。 用户可以原谅“繁忙,请一分钟后再试”;他们不会原谅一个转了一分钟的加载动画。模型容量和成本是约束。 它们在短期内是固定的,且扩容成本高昂。

粗略估算

2.1 请求速率

5000 万日活用户;10% 每天使用一次 Summarize:每天 500 万次请求,平均约每秒 58 次。峰值为平均值的 5 倍:约每秒 290 次。

2.2 Token

每次摘要 1,500 个输入 token(文档加指令)和 300 个输出 token。

  • 峰值输出:290 × 300 = 每秒 87,000 个 token。
  • 峰值输入:290 × 1,500 = 每秒 435,000 个 token。
  • 每天:75 亿输入 token 和 15 亿输出 token。

2.3 成本

日成本 = 输入 token 数 × 输入单价 + 输出 token 数 × 输出单价。由于输出 token 的单价通常是输入的数倍,两项在这里都很重要。即使没有具体价格也要写出公式。然后注意两个最大的杠杆:缓存命中率(可以省掉整个请求)和输出长度(会放大昂贵的部分)。

2.4 并发

如果一次摘要流式输出持续 6 秒,峰值时打开的流 = 290 × 6 ≈ 1,740。网关轻松应对;真正受限的是模型配额。

2.5 缓存潜力

热门文档会被许多人阅读和总结。如果 30% 的请求命中同一文档版本的已有摘要,峰值模型负载会降至约每秒 200 个请求,成本下降 30%。

核心思想。 峰值时的每秒 token 数是容量指标;成本则是一个公式,有两个杠杆:缓存命中率和输出长度。

API 设计

继续阅读之前。 用户快速连续点击两次“总结”。应该发生多少次模型调用?

一次。按文档版本去重:第二个请求加入第一个请求的流,或在第一个请求完成后获取缓存结果。

3.1 摘要

POST /v1/documents/:doc_id/summaries
  {doc_version, length: short|medium}
  -> 200 text/event-stream
       event: meta    {summary_id, cached: false, model: "fast"}
       event: delta   {text}
       event: done    {usage}
  -> 200 (cached)  same stream, emitted at once
  -> 429 {reason: "user_limit", retry_after}
  -> 503 {reason: "busy", retry_after}
  -> 413 {reason: "document_too_long"}

3.2 为什么用 SSE

答案在一次请求后单向流动,走普通 HTTP,自带重连语义。WebSocket 在这里只会增加连接状态管理,没有好处。

数据模型

summary_cache   key: (doc_id, doc_version, prompt_version, length)
                value: summary_text, model, created_at     TTL: 30 days
inflight        key: same as cache -> stream id (dedupe concurrent requests)
user_limits     (user_id, tier, tokens_today, requests_this_minute)
usage_events    (ts, user_id, doc_id, model, input_tokens, output_tokens,
                 cached, latency_ms, outcome)               -> analytics
prompt_versions (id, template, created_at, active)

提示词版本是缓存键的一部分。更改提示词后,绝不能继续提供用旧提示词生成的摘要。

高层设计

5.1 每次点击都调用模型

flowchart LR U([User]):::user --> API[API]:::svc --> M[Model]:::store classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;

每次点击都要付出一次完整的模型调用,包括重复点击。流量激增时,请求数会超过配额;供应商返回错误或变慢,用户在超时中等待。

5.2 修复 1:按文档版本缓存

同一文档版本、提示词版本和长度的摘要在目的上完全相同。缓存它们,并对进行中的请求去重。

5.3 修复 2:准入控制和有界队列

每个用户的令牌桶限制个人用量。全局并发上限与模型配额匹配。超出上限的请求在短队列中等待,并设有截止时间;如果预估等待时间超过截止时间,则立即拒绝。

flowchart LR
  R[Request]:::svc --> PU{User bucket ok?}:::new
  PU -->|no| X1[429]:::bad
  PU -->|yes| G{Global slots free?}:::new
  G -->|yes| M[Model call]:::store
  G -->|no| Q[[Queue with deadline]]:::new
  Q -->|slot frees in time| M
  Q -->|deadline passes| X2[503 busy]:::bad
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
  classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
  classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

5.4 修复 3:过载时降级

当队列增长时,将免费层请求切换到更小的模型和更短的摘要;如有必要,再丢弃免费层请求。付费用户尽可能长时间地保持最佳体验。

5.5 修复 4:取消与追踪

当浏览器关闭流时,网关取消上游调用。每个请求都携带追踪 ID,贯穿每一跳。

5.6 组合后的设计

sequenceDiagram
  autonumber
  actor U as User
  participant B as Browser
  participant G as Gateway
  participant C as Cache
  participant A as Admission
  participant M as Model API
  U->>B: click Summarize
  B->>G: POST summaries {doc_version} (trace id)
  G->>C: lookup (doc, version, prompt, length)
  alt hit
    C-->>G: summary
    G-->>B: stream cached text
  else miss
    G->>A: admit? (user bucket, global slots, tier)
    alt rejected
      A-->>G: busy, retry_after
      G-->>B: 503 with message
    else admitted
      G->>M: stream request (prompt version N)
      M-->>G: tokens
      G-->>B: delta events
      G->>C: store summary
    end
  end
  opt user closes panel
    B->>G: disconnect
    G->>M: cancel
  end
核心思想。 先查缓存,再做准入,然后降级,接着取消无人等待的请求,并对这一切进行追踪。

深入探讨

6.1 准入与过载

继续阅读之前。 产品发布后流量在二十分钟内翻了三倍。模型配额不变。请推演用户会看到什么。

第 0 分钟:缓存吸收热门文档的重复请求。未命中的请求进入准入环节。全局槽位被填满。

第 2 分钟:队列增长。网关用队列长度除以近期完成速率来估算等待时间。对已准入的请求,估算值保持在截止时间以内,因此它们正常流式传输。

第 3 分钟:预估流量超过免费额度阈值。免费用户使用较小的模型并生成简短摘要,消耗的 token 只占一小部分,因此同样的配额能容纳更多请求。

第 5 分钟:仍然超载。免费用户立即收到“摘要服务繁忙,请一分钟后再试”。付费用户仍可获得完整摘要。

第 20 分钟:流量下降,阈值按相反顺序逐步放宽。

任何时刻都不会出现用户等了一分钟然后失败的情况。这就是在不变约束下保持的特性。

区分答案的关键:过载

弱自动扩展 API 层

增加服务器,但当模型配额是瓶颈时毫无作用。

良好限流与队列

对每个用户限流、限制并发,并使用带超时的队列。

强按预估等待时间准入,并进行分层降级

当等待时间会超过截止时间时提前拒绝,在丢弃免费流量之前先将其降级到更便宜的模型,并逐分钟说明每个层级看到的情况。

6.2 缓存与去重

按文档版本和提示词版本缓存。对文档的编辑会改变其版本并导致缓存未命中,这是正确的。提示词变更会使所有缓存失效,这也是正确的,但会造成一波未命中;应逐步滚动发布提示词版本以分散影响。

两个用户在几秒内对同一热门文档点击“摘要”,应共享一次模型调用。维护一个从缓存键到流的进行中映射。第二个请求订阅第一个流,重放已生成的 token,然后实时跟随。

不要跨文档使用语义相似度缓存。两个相似的文档可能恰好在关键的那一句话上不同。

6.3 成本控制

  • 限制输出。 每个层级设定最大长度;默认生成“简短”摘要。
  • 按大小路由。 短文档使用较小的模型;长文档使用较大的模型,或先用小模型分块摘要再合并。
  • 提示词缓存。 将固定指令放在前面,以便提供商可以缓存它们。
  • 预算与告警。 按小时对照每日预算跟踪成本;按一天中的时段在预算的 70% 时告警,而不是在 100% 时。
  • 按功能衡量。 统计每次摘要和每个活跃用户的成本,让产品决策能看到成本。

成本会悄悄漂移。常见原因是提示词变更导致输入 token 增多。将每次请求的 token 数作为指标进行跟踪并设置告警。

6.4 前端

立即显示面板并展示占位符。token 到达时按动画帧批量渲染。确保部分 markdown 内容可安全渲染。如果流中断,保留已生成的部分文本,标记为已中断,并提供“重试”选项。将错误映射为通俗语言:

情况消息
429 用户限额“您已达到今日摘要限额。”
503 繁忙“摘要服务当前繁忙。请一分钟后再试。”
413 内容过长“此文档过长,无法一次性完成摘要。”
流中断部分文本,已标记,支持重试

当用户关闭面板或离开页面时中止请求。

6.5 可观测性与一个排障故事

阅读前思考。 用户反映昨天摘要变慢了。请讲述你如何找出原因。

以 p50 和 p95 跟踪:首 token 时间、总耗时、排队等待时间和模型时间。同时跟踪缓存命中率、按原因分类的拒绝率、取消率、按类型分类的错误率、每请求输入和输出 token 数,以及每小时成本。仅在采样和脱敏的前提下记录提示词和输出,因为它们包含用户内容。

排查过程:打开延迟仪表盘。昨天 14:00 起,首 token 时间翻倍。排队等待时间平稳,说明不是过载。模型时间翻倍。每请求输入 token 数在同一分钟翻倍。部署日志显示 13:58 的一次提示词变更,把文档的完整修订历史加进了提示词。回滚提示词版本;几分钟内指标恢复正常。为每请求输入 token 数添加告警。

具体地讲出这个故事。面试官要求讲一个生产环境的故事,因为它能体现你的监控是否能找出原因。

区分答案水平的关键:运维

弱:仅有错误日志

有错误日志和可用性检查,但无法解释延迟或成本的变化。

良好:延迟和错误指标

跟踪 p95 延迟和错误率,配有仪表盘。

强:带排障路径的追踪

用 trace ID 将延迟拆分为排队等待时间和模型时间,跟踪每请求 token 数和成本,并展示这些指标如何定位一次真实的性能回退。

6.6 流量增长十倍时

  • 容量。 提前提高供应商配额;在带健康检查的路由器后添加第二个供应商或区域;考虑为免费层自托管一个更小的模型。
  • 预计算。 在最常阅读的文档发生变更时,通过使用非高峰时段容量的后台批处理任务生成摘要。
  • 缓存。 流量越大,命中率越高,因为访问热度是偏斜的;更大的缓存收益更高。
  • 限制。 沿用相同的准入规则,但收紧免费层限制。
  • 成本。 token 增长十倍,账单就是十倍,除非缓存和路由有所改进。上线前把成本模型展示给产品负责人。

6.7 长文档

阅读前思考。 用户在一篇 300 页、约 150,000 token 的文档上点击“摘要”。模型的上下文是 128,000 token。会发生什么?

三种方案,按质量和成本排序。

  • 明确拒绝。 返回 413,并附上“该文档太长,无法一次性完成摘要。”便宜且诚实,但对最需要它的用户最不友好。
  • Map-reduce。按章节将文档切分成能容纳的块,用小模型并行总结每个块,然后用更大的模型对这些总结再总结。它能处理任意长度,但会丢失跨章节的关联,成本大约相当于对整个文档处理一遍再加上最后一遍。
  • 增量式。按章节内容哈希缓存各章节的总结。当文档变化时,只重新总结发生变化的章节,并重建顶层总结。对于长且频繁编辑的文档,这是目前最便宜的方式。

实际计算一下:150,000 个 token 分成 10 个 15,000 的块,每个块产出 500 token 的总结,最后对 5,000 token 做一次处理。输入约 155,000 个 token,与单遍处理相近,且 10 个块调用并行执行,因此最终总结的首 token 时间等于一次块调用加上最终调用的首 token。在块运行期间,流式显示“正在总结各章节”的进度状态。

6.8 输出的质量与安全

一个快速、便宜但错误的总结比没有更糟。建立一个小型评测集,包含文档及其参考总结,用经过校准的模型裁判对新版本的提示词和模型在忠实度(不得出现文档中不存在的论断)和覆盖度(主要要点齐全)上打分。以此作为提示词发布的门槛,就像代码以测试为门槛一样。

在安全方面,文档是不可信输入。文档内的文本,例如“忽略之前的指令”,绝不能改变该功能的行为。将指令放在系统消息中,用清晰的分隔符包裹文档,并且绝不给这个功能配备能对用户数据执行操作的工具。

拉开差距的地方:内容边界情况

弱假设文档都能装下

没有针对长文档或糟糕总结的方案。

良好能处理长度

对长文档使用 map-reduce 或分块。

强能处理长度、质量和注入

缓存章节总结以支持增量更新,以评测集作为提示词发布的门槛,并将文档文本视为不可信输入。

变体

7.1 输入时的自动补全

一个对延迟要求极高的变体:在几百毫秒内给出建议。使用小而快的模型,对按键做防抖,果断取消过期请求,并按前缀缓存。

7.2 语音功能

双向音频需要 WebSockets 或 WebRTC、插话处理,以及每一步更紧的延迟预算。

7.3 后台功能

可以等待的功能,例如每日夜间摘要,使用成本更低的批量 API,且绝不与交互流量争夺配额。

7.4 多供应商

依赖单一模型供应商意味着它的故障就是你的故障。在前面加一个路由层,做健康检查并按供应商跟踪错误率。保持提示词可移植,在同一评测集上评估每个供应商,且只故障转移到通过评估的供应商。缓存键必须包含供应商和模型,因为输出会不同。

可迁移的模式

实时 AI 功能是稀缺资源的入口。缓存重复内容,只放行能容纳的请求,在失败前降级,取消没人等待的请求,并对一切进行追踪。同样的入口可以保护任何昂贵的后端:支付处理器、搜索集群、GPU 池。

回顾:30 秒答案

  • 让它具体化。 一个功能,配上数字:每秒 290 个请求,峰值时每秒 87,000 个输出 token。
  • 按内容版本缓存,对进行中的请求去重。 最便宜的 token 是从未生成过的那个。
  • 按预估等待时间放行。 短队列、快速拒绝,优先降级免费流量。
  • 断开即取消;限制输出。 成本取决于输出长度和命中率。
  • 追踪每个请求。 将延迟拆分为排队时间和模型时间,并跟踪每个请求的 token 数。

测验

+为什么为 API 服务器自动扩容无法解决这个功能的过载问题?

瓶颈在于模型容量,即每分钟的 token 配额。更多的 API 服务器只会向同一个配额发送更多请求,从而拉长队列并导致超时。

+为什么缓存键中要包含提示词版本?

新的提示词会产生不同的摘要。如果键中没有版本信息,更改提示词后用户仍会看到用旧提示词生成的摘要。

+按预估等待时间放行能带来什么?

等待时间会超过截止时间的请求会被立即拒绝,而不是在漫长等待后失败。被放行的请求保持快速,用户也能得到诚实的反馈。

+为什么用户关闭面板时要取消模型调用?

没人会读剩余的输出,但它仍在消耗 token 和容量。取消调用可以把位置让给正在等待的人。

+在排查故障的故事中,哪个指标指出了原因?

在延迟翻倍的那一刻,每个请求的输入 token 数翻倍,而排队等待时间保持平稳。这指向提示词变更,而非过载。

+一个功能如何总结超出模型上下文长度的文档?

将文档拆分成能容纳的块,并行总结各块,然后再总结这些摘要。按内容哈希缓存各章节的摘要,可以让后续编辑变得廉价。

+为什么文档被视为不可信输入?

其文本可能包含试图改变模型行为的指令。将指令保留在系统消息中、对文档进行分隔界定、并且不给该功能提供任何工具,可以限制损害。

参考资料与延伸阅读

NextVideo Generation Pipeline