针对 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 数翻倍,而排队等待时间保持平稳。这指向提示词变更,而非过载。
+一个功能如何总结超出模型上下文长度的文档?
将文档拆分成能容纳的块,并行总结各块,然后再总结这些摘要。按内容哈希缓存各章节的摘要,可以让后续编辑变得廉价。
+为什么文档被视为不可信输入?
其文本可能包含试图改变模型行为的指令。将指令保留在系统消息中、对文档进行分隔界定、并且不给该功能提供任何工具,可以限制损害。
参考资料与延伸阅读
- Google SRE 手册:处理过载 涵盖了准入控制、负载削减和优雅降级。
- OpenTelemetry:追踪 描述了 span 和追踪上下文传播。
- HTML Living Standard:server-sent events 定义了流式传输格式。
- 成本与延迟 和 可观测性 从部署角度涵盖了本轮的两个方面。