针对 OpenAI playground 题目的完整解答:产品问题、技术栈选择、预设数据模型、API、流式补全、提示词与补全范围的前端状态管理、撤销、并发,以及导出为代码。

作者:The Forward Deployed 编辑团队已审阅

属于 OpenAI 系统设计题库。该题目具有该轮面试的代表性。分析与解答为本站原创。

问题描述

页面包含一个大编辑器、一个设置面板和一个提交按钮。补全内容出现在提示词之后,以高亮显示,并随模型逐字生成。开发者反复迭代,保存自己喜欢的预设,之后可以再次加载。这一轮通常是一块白板讨论,并附有一张完成页面的参考原型图。

澄清问题

  • 用户是谁? 拥有 API 账号的开发者,在编写代码之前先进行实验。
  • 有哪些设置? 模型、temperature、最大 token 数、top-p、停止序列,以及频率惩罚和存在惩罚。
  • 预设可以分享吗? 假设默认为私有,只读分享链接作为进阶目标。
  • 运行记录会保存吗? 假设使用量会被记录用于计费和限额,而运行输出默认不存储。
  • 规模? 练习用:每天 50,000 名用户,每人每天 40 次运行和 3 次保存。
  • 移动端? 桌面端优先。

Playground 为什么难

后端很小。难点集中在两个地方。

编辑器要在同一个文档中容纳两类文本。用户的提示词和模型的补全并排存在,补全必须保持高亮,然后用户可以在任意位置编辑,包括补全内容内部,并再次提交。在文本不断变化的同时,UI 必须追踪哪些字符来自模型。

而且这个产品的存在意义就是把实验转化为代码。一个遗漏了某项设置(例如停止序列)的预设,会在开发者自己的代码中产生不同的结果,他们就会失去对这个工具的信任。

因此核心矛盾是自由编辑与精确状态之间的冲突。用户自由编辑;系统必须始终知道提示词是什么、模型写了什么,以及产生该结果的确切设置。

flowchart LR B([浏览器:编辑器 + 设置]):::user --> API[Playground 后端]:::svc API --> M[模型 API]:::store API --> DB[(预设、使用量)]:::store M -->|tokens| API -->|SSE| B classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思路。 从问题出发:缩短从想法到可用的 API 调用之间的循环。这个目标决定了需要追踪哪些状态,以及预设必须固化哪些内容。

关键概念

补全与聊天

补全模型会续写文本。提示词是纯文本,输出是最可能的续写内容。这就是为什么 UI 将补全内容追加在提示词之后,显示在同一个文档中。

采样设置

Temperature 控制随机性:0 几乎是确定性的,值越高变化越大。Top-p 将采样限制在概率之和达到 p 的最小 token 集合内。Maximum tokens 限制输出长度。Stop sequences 在模型写出这些序列时结束生成。Penalties 抑制重复。每一项都会改变输出,因此每一项都应包含在预设中。

文本范围

范围是文档中的起始和结束偏移量。将补全内容作为一个范围进行跟踪,并在用户编辑时更新它,编辑器由此知道该高亮哪些内容。

乐观并发控制

每个预设都有一个版本号。保存时会发送加载时的版本。如果服务器上的版本已经更新,保存会被拒绝,由客户端解决冲突。

核心思想。 补全模型续写文本,因此 UI 是一个带有被跟踪范围的文档。每一项采样设置都会改变输出,因此预设必须冻结所有设置。

需求

在继续阅读之前。 用一句话写出问题陈述,然后写出需求。哪种故障会让开发者停止使用这个工具?

1.1 功能需求

  • 输入提示词并接收流式补全,追加在提示词之后并高亮显示。
  • 调整模型和采样设置,并按模型进行校验。
  • 停止正在进行的补全。
  • 撤销上一次补全。
  • 将提示词和设置保存为命名预设;列出、加载、重命名、删除预设。
  • 将当前状态导出为 curl 或 SDK 代码形式的 API 调用。

1.2 非功能需求

  • 首 token 快速。 补全在大约一秒内开始。
  • 编辑流畅。 即使补全正在流式输出,输入也绝不卡顿。
  • 预设忠实还原。 加载预设能精确复现保存时的设置。
  • 安全性。 浏览器中不存放 API 密钥;按用户设置速率限制。

1.3 约束与属性

忠实还原的预设是属性。 这个工具的价值在于将实验带入代码。编辑器复杂性是约束。 在一个可自由编辑的文档中同时跟踪提示词和补全,决定了前端的设计。

粗略估算

  • 运行次数:50,000 × 40 = 每天两百万次,平均每秒约 23 次,峰值可能达到 100 次。
  • 峰值时打开的流:每秒 100 × 平均 8 秒 = 800。对于一个小型代理集群来说微不足道。
  • 保存:每天 150,000 次,每秒不到 2 次。一个 Postgres 实例,外加一个用于故障转移的副本。
  • 预设大小:几 KB 的提示词加上几百字节的设置。一百万个预设大约是几个 GB。

直截了当地说明规模不是问题,把时间花在状态和正确性上。

API 设计

阅读前思考。 用户加载了一个预设并对其进行编辑,与此同时另一个标签页保存了同一个预设。第二次保存应该怎么做?

拒绝并提示冲突,让用户选择:覆盖,或另存为新预设。静默覆盖会毁掉第一个标签页的工作。

POST   /api/completions
  {model, prompt, params: {temperature, max_tokens, top_p, stop[], ...}}
  -> text/event-stream: delta {text} ... done {finish_reason, usage}

GET    /api/models              -> [{id, context_window, param_ranges}]

GET    /api/presets             -> [{id, name, updated_at}]
POST   /api/presets             {name, prompt, model, params} -> {id, version}
GET    /api/presets/:id         -> {id, name, prompt, model, params, version}
PUT    /api/presets/:id         {name, prompt, model, params, version}
                                -> 200 {version+1} | 409 {current}
DELETE /api/presets/:id
POST   /api/presets/:id/share   -> {share_token}   (read-only copy link)
GET    /api/export?format=curl|python|node  (body: current state) -> code
GET /api/models

驱动设置面板:取值范围和默认值来自服务器,因此新模型上线不需要发布前端。

数据模型

users    (id, email, org_id, created_at)
presets  (id, owner_id, name, prompt_text, model, params_json,
          version, created_at, updated_at, deleted_at)
          index (owner_id, updated_at desc)
shares   (token, preset_id, created_at, revoked_at)
usage    (user_id, day, runs, prompt_tokens, completion_tokens)

设置存放在一个 JSON 列中,并按模型对应的 schema 进行校验。新设置经常出现,如果每个设置单独一列,每次都需要做迁移。

高层设计

5.1 调用模型的静态页面

页面直接调用模型 API,密钥写在 JavaScript 包里。用于演示没问题,但密钥会泄露给每个访问者。

5.2 方案一:后端代理

一个小型后端保存密钥,负责用户认证、限流、校验设置、流式返回补全结果,并记录用量。

flowchart LR B([Browser]):::user --> P[Backend: auth, validate, stream]:::new --> M[Model API]:::store P --> U[(Usage)]:::new classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

5.3 方案二:带版本的预设

预设表带一个版本列。保存时提交加载时的版本;过期的保存返回 409。

5.4 方案三:跟踪补全范围的编辑器

前端在状态中保存文本和补全的范围,每次编辑都更新该范围,并高亮显示。

5.5 组合后的设计

sequenceDiagram
  autonumber
  actor U as Developer
  participant E as Editor state
  participant B as Backend
  participant M as Model API
  U->>E: click Submit
  E->>E: snapshot text for undo, completion range starts at end of text
  E->>B: POST /api/completions {model, prompt=text, params}
  B->>B: auth, rate limit, validate params for model
  B->>M: stream request
  loop tokens
    M-->>B: chunk
    B-->>E: delta
    E->>E: append to text, extend range (batched per frame)
  end
  B-->>E: done + usage
  U->>E: click Save preset
  E->>B: PUT /api/presets/:id {prompt, params, version}
  B-->>E: 200 or 409

深入探讨

6.1 编辑器状态

阅读前思考。 补全内容已被高亮。用户在其中间输入了三个字符。高亮会怎样变化,下一次 Submit 的提示词是什么?

状态:

type EditorState = {
  text: string;                               // prompt + accepted completions
  completion: { start: number; end: number } | null;
  model: string;
  params: Params;
  status: "idle" | "streaming" | "error";
  preset: { id: string; version: number; dirty: boolean } | null;
  undo: Snapshot[];                           // text + completion range before each run
};

每次编辑时对范围做变换。完全在范围之前的编辑使范围平移;在范围之后的编辑不影响它;在范围内部的编辑会把模型文本与用户文本分开:最简单的规则是在编辑处结束高亮,编辑之后的所有内容都算作用户文本。带有标记(mark)或装饰(decoration)API 的编辑器会自动处理:补全是一个标记,标记会随编辑移动。

下一次 Submit 会把整段文本作为提示词发送。一旦补全被接受进文本,它就成为提示词的一部分。补全演练场就是这样工作的:通过编辑续写内容并再次续写来迭代。

撤销会恢复上一次运行之前的快照:文本和范围一起恢复。

区分答案优劣的点:编辑器状态

弱只存一个字符串

无法在编辑后正确高亮补全内容,也无法干净地撤销一次运行。

良好跟踪补全范围

保存一个范围,在编辑时平移它,并在每次运行前做快照以支持撤销。

强利用编辑器的标记模型

将补全表示为随编辑移动的标记,定义其内部编辑时的行为,并对流式更新进行批处理,使输入永不卡顿。

6.2 将流式输出接入用户可操作的编辑器

在补全流式输出的过程中,用户可能会滚动、选择文本,甚至输入。有两种方案:

  • 在流式输出期间锁定编辑器。简单且可预测。显示一个“停止”按钮。
  • 允许在补全范围之外进行编辑。将 token 追加到范围的末尾而非光标处,并照常对该范围进行变换。

建议 v1 采用锁定方案:运行只需几秒钟,期间进行编辑的情况很少见。按动画帧批量追加 token,这样即使流式输出很快也不会阻塞输入处理。

6.3 设置校验与模型切换

每个模型都有自己的取值范围:上下文窗口、最大 token 数、支持的设置。后端从

/api/models

返回这些数据,面板根据这些数据进行渲染。当用户切换模型时,将设置限制到新范围内,并显示发生了哪些变化。提交前,在客户端统计提示词的 token 数,如果提示词加最大 token 数超过上下文窗口则发出警告。后端会再次校验,因为客户端可能出错。

6.4 可复现的预设

在继续阅读之前。 一位开发者将预设导出为代码后,得到的结果与在 playground 中不同。请列出可能的原因。

缺少设置:某个停止序列、top-p 或惩罚参数停留在 playground 的默认值上,而代码中没有设置。模型版本不同:playground 使用了一个别名,而该别名后来已指向其他版本。随机性:温度大于零时,输出本来就会不同。

因此,预设应显式存储每一项设置,包括默认值,并固定确切的模型版本。导出时将每一项设置写入代码。如果温度大于零,导出的代码中应包含一条注释,说明输出会因运行而异,并在 API 支持的情况下显示 seed 参数。

6.5 并发

每次保存都会发送其加载时的版本号,服务器使用

WHERE id = ? AND version = ?

进行更新。如果没有匹配的行,则返回 409 以及当前预设。客户端显示“此预设已在其他地方被修改”,并提供覆盖、另存为新预设或重新加载的选项。自动保存看似诱人,但会成倍增加冲突;应采用显式保存,配合未保存更改指示器以及离开页面前警告。

6.6 导出为代码

导出功能将当前状态(而非已保存的预设)渲染为 curl、Python 或 Node 代码,且每一项设置都显式写出。API 密钥以环境变量引用的形式出现。这个功能很小,却是产品存在的理由:一次成功的实验,一键即可变成代码。

6.7 布局与主题

面试官可能会问到布局。左侧是宽版编辑器,右侧是设置,编辑器下方是提交按钮和 token 计数,顶部下拉菜单中是预设。补全文本用背景色高亮显示,在浅色和深色主题下都符合对比度标准。键盘快捷键:Ctrl+Enter 提交,Escape 停止。

6.8 速率限制、用量与成本显示

在继续阅读之前。 用户将最大 token 设为 4,000,并在一分钟内点击提交 20 次。后端应该怎么做,用户应该看到什么?

按每分钟 token 数设置限制,针对每个用户和每个组织,因为一个请求的成本可能是另一个的一百倍。在准入时,从用户预算中预留提示词 token 加最大 token;流结束时退还未使用的部分。如果预留不满足,返回 429 并附上预算恢复所需的时间,并显示在提交按钮旁边。

在每次运行前后显示成本。之前:根据客户端计数估算的提示词 token,以及最大输出。之后:来自

done

事件的实际用量。开发者使用 playground 来了解生产环境调用的成本,所以这是一个功能,而不是锦上添花。

记录每次运行的用量用于计费,包括模型、token 计数和用户。按组织按天汇总用于仪表盘。

6.9 白板推演

这一轮以图示驱动。白板上的清晰顺序:

  • 问题,用一句话,写在左上角。
  • 三个框:浏览器、后端、模型 API。在后端标注关键点。
  • 两条主要流程用编号箭头表示:运行(流式)和保存预设。
  • 数据模型:用户、预设、运行、用量。
  • 前端状态对象,放在浏览器框旁边。
  • 然后是面试官选择的深入探讨:编辑器范围、并发、导出。

边画边讲,并将每个决策表述为有理由的选择:用 Postgres 因为数据小且是关系型的;用 SSE 因为流是单向的;设置用一个 JSON 列因为模型经常变化。

flowchart LR
  subgraph Browser
    ED[Editor state:<br/>text, completion range, params, preset]:::user
  end
  subgraph Backend
    API[API: auth, limits, validation]:::svc
    PG[(Postgres: users, presets, runs, usage)]:::store
  end
  ED -->|1. POST /completions| API
  API -->|2. stream| MA[Model API]:::store
  MA -->|3. tokens| API -->|4. SSE| ED
  ED -->|5. PUT /presets with version| API --> PG
  classDef user fill:#e6efec,stroke:#315e55,color:#171717;
  classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
  classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;

区分答案水平的关键:主导权

弱:罗列技术

说出技术栈但不给理由,且从不陈述问题。

良好:为每个选择给出理由

先陈述问题,并为每个技术选择给出一个理由。

强:为开发者的工作流而设计

增加基于 token 的限制并显示成本、可复现的预设、导出为代码,以及面试官可以逐步跟随的白板流程。

变体

7.1 聊天模型

对于聊天模型,编辑器变成带角色的消息列表。预设存储系统消息和示例对话轮次。流式和预设逻辑保持不变。

7.2 对比设置

用两种设置并排运行同一个提示词。每次运行是独立的流;UI 显示两个窗格,一个预设可以同时存储两者。

7.3 保存运行

如果保存了运行记录,请连同确切的提示词和设置一起存储输出,以便开发者日后比较不同运行。注意存储和隐私:提示词可能包含客户数据,因此要为用户提供清晰的保留期限设置。

7.4 用户量达到十倍时

在每日 50 万用户的情况下,运行平均达到每秒约 230 次。后端可水平扩展;Postgres 增加读副本以支持预设列表。真正的限制是每个组织的模型 API 配额,这需要在同一组织的用户之间公平共享,并在高峰期提供带有可见等待时间的队列。

可迁移的模式

一个 playground 就是自由格式编辑器背后的一台精确状态机,外加一个守护昂贵资源的代理。同样的模式出现在查询控制台、笔记本工具和 API 探索器中。明确输出到底依赖什么,把所有这些都存储下来,并让从实验到生产的路径只需一步。

回顾:30 秒答案

  • 问题优先。 缩短从想法到可用的 API 调用的循环。
  • 一个小型后端。 保管密钥、按模型校验设置、流式传输、计量。
  • 带补全范围的编辑器状态。 标记随编辑移动;快照支持撤销。
  • 预设冻结一切。 每项设置都显式指定,固定模型版本,保存时带版本号。
  • 导出为代码。 这个功能证明了产品的价值。

测验

+为什么在某个编辑器中,补全内容看起来是追加在提示词之后的?

补全模型会续写给定的文本。将续写内容就地显示,便于用户编辑结果并再次续写,这正是人们迭代的方式。

+用户编辑之后,编辑器如何知道该高亮什么?

它将补全内容作为一个范围或标记来跟踪,并在每次编辑时对其进行变换:之前的编辑使其平移,之后的编辑使其保持不变,内部的编辑则使其拆分或结束。

+为什么预设中要存储每一项设置,包括默认值?

因为 playground 和开发者代码中的默认值可能不同,而且它们会随时间变化。显式存储每一个值,可以让预设在任何地方重现相同的行为。

+预设上的版本列能防止什么?

丢失更新。只有当预设仍具有编辑器加载时的版本时,保存才会成功;否则服务器会返回冲突,而不是静默覆盖另一个标签页的工作。

+为什么要从后端获取模型设置的范围?

这样一来,新增模型或更改限制只需修改后端。设置面板根据数据渲染,而不是硬编码的范围。

+为什么在准入时为限流预留最大 token 数?

后端无法预先知道输出长度。预留最大值可以限定成本上限,事后退还未使用的 token 则能保持限制的公平性。

+为什么要在运行前显示预估成本?

开发者使用 playground 来了解他们的生产调用会花费多少成本。在每次运行前后显示成本,可以让这一点一目了然。

参考资料与延伸阅读

Next大规模实时 AI 功能