针对 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) -> codeGET /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 来了解他们的生产调用会花费多少成本。在每次运行前后显示成本,可以让这一点一目了然。
参考资料与延伸阅读
- ProMirror 指南:文档标记与变换 解释了编辑器如何在编辑过程中跟踪文本范围。
- HTML Living Standard:服务器推送事件 定义了用于补全的流式传输格式。
- 提示词与结构化输出 讲述了什么样的提示词才是一份值得保存的契约。
- 流式传输与渲染在聊天界面问题中有更深入的介绍。Anthropic 的资源池有一个以共享为主的近亲:提示词 playground。