
努力程度选择器是 Perplexity 这类 AI 问答产品里一个略带技术色彩却又直接决定用户体验的组件。它把“模型回答问题时愿意花多少计算量”这件事交还给用户用户选择快速系统就缩短推理过程用户选择深度系统就允许模型展开更长的思考链路。所谓“新粒度”核心是把原本只有两三个粗糙档位的能力选择器拆成更细的等级。这篇内容会把这个功能当作一个前后端协作的开发任务从产品需求、前端交互、后端参数映射、埋点验证和排错链路完整过一遍。如果你正准备在搜索、问答或知识型产品中加入类似功能这是一份可以直接落到代码上的实现参考。1. 先理解努力程度选择器在解决什么问题1.1 努力程度选择器是什么通俗地说努力程度选择器就是让用户告诉系统“这个回答你帮我用多大劲去处理。”用户选“极速”系统会优先返回一段更短、更直接的答案用户选“深度研究”系统会主动检索更多资料、做更多推理并输出更长的结构化内容。在技术层面这个组件并没有一个独立的底层模型需要开发。它通常是一套参数映射机制前端把用户选择变成effort_level后端再把它翻译成模型推理时真正需要的参数比如reasoning_effort、temperature、max_tokens、搜索轮数、引用数量等。理解这一点很关键选择器本身只是交互外壳真正影响体验的是后端如何“翻译”这个档位。如果后端只是把档位存下来却没有作用到模型调用参数上用户会明显感觉到“选了深度结果和快速模式一模一样”这也是这个功能最容易翻车的地方。1.2 为什么“新粒度”会成为开发点很多问答产品最初的档位只有两档快速、深入。这种设计对产品团队最省事但对用户不一定公平。同一个用户在不同场景下的需求差异很大查一个成语解释快速档就够了。写一段面试自我介绍标准档已经能覆盖。对比两种数据库的适用场景需要深入档。整理一份带引用来源的技术调研需要接近研究报告的完整度。如果只有两档用户要么为了速度牺牲质量要么为了深度忍受不必要的长延迟。于是“新粒度”就出现了把二档或三档拆成四档、五档甚至允许用户在一个语义连续但离散展示的标尺上调节。“粒度”这个词在实现时至少包含两层含义。第一层是档位数量从 2 个变成 4 个或 5 个。第二层是档位内部的能力组合同一档位可能需要同时控制模型推理长度、搜索轮次、引用数量和回答格式而不是只调整一个temperature。1.3 粒度设计不是越细越好新的粒度选择器开发时最容易陷入“档位越多越专业”的误区。实际上档位数量的增加会带来三笔额外成本成本来源具体表现交互成本用户需要理解“极速、均衡、深入、深度研究”之间的差异选择器占用的界面空间也会变大后端维护成本每个档位都要维护一组参数映射、缓存策略和降级规则模型验证成本档位太接近时用户无法感知差异会认为功能是假的因此设计新粒度时应该围绕用户任务分层而不是围绕模型能力分层。下表是一种常用的设计思路档位延迟预期成本预期适用场景极速低低查资料、查定义、口语问答均衡中中日常问答、代码片段、常规写作深入较高较高技术分析、方案对比、长文生成深度研究高高调研报告、学习总结、带引用的长文在新粒度落地前产品侧还应该确认一个问题用户到底需要“连续调节”还是只需要“几个明确档位”。很多开发在第一步就选择了滑块但滑块隐含连续变化而模型参数通常是离散的用户拖到一个位置后系统很难解释“当前到底选了什么”。对大多数问答产品分段单选按钮或按钮组比滑块更合适。注意不要只验证程序能启动还要验证不同档位确实在延迟、回答长度、引用数量上产生了可感知差异否则功能上线后很容易变成“无效选择器”。2. 需求定义把“力度”拆成可交互的档位2.1 从用户任务反推档位开发新粒度选择器之前第一件事不是写代码而是定义“每个档位到底代表什么”。可以先把产品内的典型用户问题分成几类事实查询型答案有明确边界不需要长篇分析。理解解释型需要举例子、换角度说明。决策分析型需要对比多个选项给出依据。研究综述型需要多来源检索输出结构化长文。然后为每一类问题分配一个或多个档位。这样档位不是“拍脑袋”来的而是有用户任务支撑的。例如设计四个档位fast极速摘要默认不做额外搜索回答长度控制在较短的范围内。standard均衡模式做一次基础检索回答包含要点和少量解释。deep深入模式多次检索回答包含对比、边界条件和典型场景。research深度研究检索轮数最多引用来源更丰富输出结构完整的长文。档位命名尽量避免使用“高、中、低”因为用户很难判断“高”到底高在哪。使用行为化描述更容易理解极速、均衡、深入、深度研究。2.2 选择交互形态时的取舍新粒度选择器的交互形态主要有四种分段控件、下拉菜单、按钮组、滑块。交互形态优点缺点分段控件档位一目了然适合 2 到 5 个离散选项档位太多时会占满整行下拉菜单节省空间用户需要额外点击切换效率低按钮组适合设置面板可搭配说明文案移动端横向空间有限滑块视觉上灵活适合连续调节不适合离散档位且难以准确表达选择结果从开发角度看分段控件最容易配合键盘和读屏器实现。结构上使用radiogroup每个档位是一个radio选中的状态用aria-checked标记。这样不仅视觉上有选中态辅助设备也能正确播报。选择器的位置也很重要。常见做法是放在输入框附近用户在提问前就能看到并切换。如果放在搜索结果底部用户往往已经发出请求切换后还需要再次提问体验就会断掉。2.3 默认值、记忆与恢复策略档位不是每次请求都必须让用户手动选择。产品需要明确三件事默认档位是什么。用户切换后是否记忆。分享链接时是否保留档位。推荐的策略是用户没有做过选择时使用产品默认档位通常是standard。用户点击其他档位后把选择写入localStorage或用户偏好接口。分享或刷新页面时通过 URL 参数?effort_leveldeep恢复例如搜索页面支持/?qxxxeffort_leveldeep。但要注意不是所有状态都适合放进 URL。像“当前回答的折叠状态”“展示视图”这类碎片信息放进 URL 会让链接变长而且容易造成缓存混乱。档位是对结果有实际影响的参数才值得放进 URL。恢复优先级建议为URL 参数 localStorage 用户偏好 全局默认值。并且解析到非法值时不要直接抛错而是回退到全局默认值同时在前端日志里记录一条告警。3. 前端实现选择器组件与请求联动3.1 组件目录与技术栈这里以一个常见的 React TypeScript 项目为例。选择器组件可以放在src/ components/ EffortSelector/ index.tsx effort.ts effort-selector.csseffort.ts负责类型和常量定义index.tsx负责渲染和事件处理。把协议层单独拆出来是为了让前后端代码在枚举值上保持一致减少“前端传deep后端只认research”这类问题。3.2 最小可运行的档位定义先定义枚举和文案映射// effort.ts export type EffortLevel fast | standard | deep | research; export const EFFORT_LEVELS: EffortLevel[] [ fast, standard, deep, research, ]; export const EFFORT_LABELS: RecordEffortLevel, string { fast: 极速, standard: 均衡, deep: 深入, research: 深度研究, };这里用字符串枚举而不是数字枚举原因是这个值需要出现在 API 请求和 URL 参数中。字符串可读性更高也方便排查日志。然后实现选择器组件// index.tsx import { EFFORT_LEVELS, EFFORT_LABELS, EffortLevel } from ./effort; interface EffortSelectorProps { value: EffortLevel; onChange: (level: EffortLevel) void; } export function EffortSelector({ value, onChange }: EffortSelectorProps) { return ( div classNameeffort-selector roleradiogroup aria-label努力程度 {EFFORT_LEVELS.map((level) ( button key{level} className{level value ? effort-option active : effort-option} onClick{() onChange(level)} roleradio aria-checked{level value} {EFFORT_LABELS[level]} /button ))} /div ); }这段代码的核心不是样式而是把“可选项”和“当前选项”明确表达出来。EFFORT_LEVELS数组控制展示顺序value控制选中态onChange把选择抛给父组件。组件本身不需要关心请求逻辑保持纯粹。这样在单测、设计稿预览和后续迁移到其他 UI 框架时都会更轻松。3.3 状态管理、URL 同步与请求参数联动父组件需要管理两个状态当前档位和当前请求参数。当用户点击某个档位时要同时更新本地状态和 URL 参数并在下一次搜索时把effort_level放到请求体里。import { useState } from react; import { useSearchParams } from react-router-dom; import { EffortSelector } from ../components/EffortSelector/EffortSelector; import { EffortLevel } from ../components/EffortSelector/effort; function getInitialEffortLevel(params: URLSearchParams): EffortLevel { const level params.get(effort_level); if (level fast || level standard || level deep || level research) { return level; } return standard; } export function SearchPage() { const [searchParams, setSearchParams] useSearchParams(); const [effortLevel, setEffortLevel] useStateEffortLevel(() getInitialEffortLevel(searchParams) ); const [inputValue, setInputValue] useState(); function handleEffortChange(level: EffortLevel) { setEffortLevel(level); const next new URLSearchParams(searchParams); next.set(effort_level, level); setSearchParams(next); } async function handleSearch() { const response await fetch(/api/search, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ query: inputValue, effort_level: effortLevel, }), }); const data await response.json(); // 渲染回答结果 } return ( div classNamesearch-page div classNamesearch-bar input value{inputValue} onChange{(event) setInputValue(event.target.value)} / button onClick{handleSearch}搜索/button /div EffortSelector value{effortLevel} onChange{handleEffortChange} / /div ); }这里有一个值得注意的设计切换档位时不要立即重新发送请求。因为选择器应该只影响“下一次搜索”如果用户连续点击多个档位会触发大量浪费的请求。更好的做法是当前搜索完成后如果用户重新点击搜索则使用最新档位如果用户只想看不同档位下的同一问题答案才需要显式的“重新生成”按钮。3.4 前端常见的边界处理档位切换还会带来请求竞态。用户可能先选择fast触发了请求 A紧接着选择research触发了请求 B。如果 A 比 B 晚返回页面上就会显示一个旧档位的结果。解决方案是给请求加序号或者使用AbortController取消前一个请求。const requestRef useRefAbortController | null(null); async function handleSearch() { requestRef.current?.abort(); const controller new AbortController(); requestRef.current controller; const response await fetch(/api/search, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ query: inputValue, effort_level: effortLevel }), signal: controller.signal, }); // 处理响应 }另一个容易遗漏的问题是移动端触控区域。按钮式选择器在桌面上很正常但在手机上每个按钮的高度建议不小于 44px否则用户容易点错。可以用 CSS 统一处理.effort-option { min-height: 44px; padding: 8px 12px; }4. 后端接入把档位映射为模型可执行的策略4.1 接口设计与参数校验前端会通过接口把effort_level传给后端。一个典型请求体如下{ query: Kubernetes 和 Docker 的边界在哪里, effort_level: deep }后端接参时必须校验枚举不能把前端传入的字符串直接放进模型请求。校验通过后才能继续。以 Node.js TypeScript 为例可以使用zod做协议校验import { z } from zod; export const searchRequestSchema z.object({ query: z.string().min(1).max(1000), effort_level: z.enum([fast, standard, deep, research]), }); export type SearchRequest z.infertypeof searchRequestSchema;校验的主要目的是防止三类问题前端版本落后传了后端不认识的档位。恶意请求传入非法枚举值。URL 构造时参数拼写错误。校验失败时服务端应该返回可读的错误信息而不是直接抛出 500。前端接收到 422 后可以把非法值回退为默认档位并重新请求。4.2 档位到模型参数的映射这是整个功能最核心的一层。不同模型对“努力程度”的支持方式不同常见参数包括reasoning_effort直接指定推理强度。max_tokens限制回答最大长度。temperature控制随机性。top_p控制采样范围。search_rounds检索多少轮补充几次搜索结果。建议为每个档位维护一张独立映射表。下面是一个示意实现// effortMapping.ts import { EffortLevel } from ../components/EffortSelector/effort; export interface EffortConfig { reasoning_effort?: low | medium | high; max_tokens: number; temperature: number; search_rounds: number; } export const EFFORT_CONFIG: RecordEffortLevel, EffortConfig { fast: { reasoning_effort: low, max_tokens: 800, temperature: 0.2, search_rounds: 0, }, standard: { reasoning_effort: medium, max_tokens: 1500, temperature: 0.3, search_rounds: 1, }, deep: { reasoning_effort: high, max_tokens: 2500, temperature: 0.4, search_rounds: 2, }, research: { reasoning_effort: high, max_tokens: 4000, temperature: 0.5, search_rounds: 4, }, };这里面的数值是示例不是通用于所有模型的推荐值。落地前需要结合你所接入的模型能力和成本预算单独校准。映射后调用上游模型时把这些参数合并进去function buildModelRequest(req: SearchRequest) { const config EFFORT_CONFIG[req.effort_level]; return { prompt: req.query, reasoning_effort: config.reasoning_effort, max_tokens: config.max_tokens, temperature: config.temperature, search_rounds: config.search_rounds, }; }4.3 为什么不能只调 temperature一个常见误区是用户选择“努力程度”时后端只把temperature调低或调高。temperature影响的是随机性并不决定回答会不会更长、更完整、更深入。如果fast和research之间只差 0.2 的temperature结果可能只是措辞不同长度和结构几乎一样。用户感知不到选择器的价值。更合理的做法是优先控制三类变量推理长度通过max_tokens控制回答上限。思考深度通过reasoning_effort控制模型内部的推理预算。信息广度通过search_rounds控制搜索/检索次数。如果接入的模型不支持reasoning_effort也至少要让search_rounds或max_tokens随着档位变化否则这个功能就没有真实的“努力程度”差异。4.4 缓存与降级策略档位会影响模型输入参数因此缓存 key 必须把effort_level纳入。简单拼接 query 作为 key会导致用户切换档位后仍然命中旧缓存。推荐结构const cacheKey ${effort_level}:${normalizeQuery(query)};normalizeQuery可以做全角半角转换、大小写归一化、去除多余空格。这样可以提高普通场景的缓存命中率但不会误伤不同档位的结果。另一个需要考虑的是降级策略。深度档位可能因为上游模型超时、限流或 token 超限而失败。此时不能直接报错而应该尝试用低一档的参数重试并在响应中标记降级信息。async function searchWithEffort(req: SearchRequest) { try { return await callModel(buildModelRequest(req)); } catch (error) { if (req.effort_level research) { const fallbackReq { ...req, effort_level: deep as const }; const result await callModel(buildModelRequest(fallbackReq)); return { ...result, degraded_from: research, degraded_to: deep, }; } throw error; } }前端读取到degraded_from后可以在结果区域展示一行提示“当前服务压力较大该回答已临时降级为深入模式。”这样用户不会认为档位选择器失效。注意相同 query 在不同 effort_level 下应该视为不同请求。缓存 key 一旦漏掉 effort_level就会让“新粒度”变得毫无意义。5. 验证与埋点确认每一档真的在起作用5.1 本地验证链路新粒度选择器开发完成后需要按三层链路验证第一层浏览器 Network 面板。切换档位后查看搜索请求的请求体确认effort_level是否正确传递。第二层后端访问日志。确认服务端收到的effort_level与前端一致并且日志里能看到映射后的模型参数。推荐打印一行结构化日志{ event: model_request, effort_level: deep, reasoning_effort: high, max_tokens: 2500, search_rounds: 2 }第三层上游模型请求体。通过拦截器或日志确认最终发给模型的请求是否包含了reasoning_effort、max_tokens、search_rounds等参数。这三层中只要有一层断了档位就不生效。5.2 埋点记录选择分布与效果差异为了判断“新粒度”是否真的满足用户需求需要采集用户点击了哪个档位。从默认档位切换到其他档位的比例。每个档位的平均延迟、成功率和 token 消耗。用户对结果的反馈是否随档位变化。一个基础埋点事件可以这样设计{ event: search_completed, payload: { effort_level: deep, latency_ms: 4200, answer_chars: 2800, search_count: 2, token_usage: 3200, source: selector } }通过latency_ms和answer_chars可以验证不同档位确实在“执行力度”上有差异。如果fast和research的latency_ms几乎相同说明后端映射有问题需要回到 4.2 的映射表检查。5.3 端到端验收清单验收项检查方式预期结果切换档位后请求参数变化浏览器 Network 面板请求体中的effort_level正确变化非法档位回退手动修改 URL 参数自动回退为默认档位缓存按档位隔离用同一查询请求两次不同档位两次请求不因缓存冲突而返回相同结果上游参数正确后端日志日志中出现reasoning_effort、max_tokens降级标记可见模拟上游超时响应包含degraded_from前端展示提示移动端可点击在手机宽度下检查每个按钮高度不低于 44px6. 常见问题排查与开发陷阱6.1 常见问题现象、原因与处理方式问题现象可能原因检查方式处理方案选了深度回答依然很短后端没有把档位映射到max_tokens或reasoning_effort查看模型请求体修正映射表补全参数切换档位后结果完全没变缓存 key 未包含effort_level检查缓存 key 拼接逻辑将档位加入缓存 key默认档位总是fast优先读取顺序错误或 localStorage 被污染查看初始值函数调整恢复优先级增加兜底移动端选择器点不到按钮高度小于 44px 或父容器 overflow 异常浏览器开发者工具检查元素尺寸增大触控区域修复样式后端返回 422前端传了非法档位值查看请求体和后端错误信息前端回退默认值后端打印告警深度档位经常超时报错上游模型 p95 延迟过高查看耗时曲线和错误日志增加超时重试按档位降级6.2 档位接入后报错的排查顺序当某个档位接入模型后报错建议按以下顺序排查先确认前端是否真的传了对应档位值。再确认后端是否解析到了档位并映射成了正确的模型参数。接着确认模型是否支持该参数取值。例如传入reasoning_effort: ultra但模型只支持low/medium/high就会直接报错。确认是否触发 token 上限。深度档位通常会放大max_tokens如果模型上下文长度不够会返回context_length_exceeded。最后确认是否有外部限流。深度档位往往伴随多次搜索和更长推理QPS 一高就可能触发限流。排查时优先看日志关键字invalid_reasoning_effort、context_length_exceeded、timeout、rate_limit。这些错误直接指向参数、容量或限流三类问题。6.3 开发中至少要注意的五个坑第一个坑是前端枚举和后端枚举不一致。前端定义deep后端定义research结果接口校验永远失败。正确做法是让协议层共享同一份枚举定义或者至少用同一组测试用例覆盖。第二个坑是使用滑块表达离散档位。滑块会暗示用户可以选择任意中间值但对模型来说“中间值”没有明确语义。离散档位用分段控件更准确。第三个坑是缓存 key 遗忘effort_level。这是隐藏最深的坑因为测试时如果只用一个档位问题不会触发。切到第二个档位后结果依旧返回旧档位用户会认为选择器是假的。第四个坑是降级时没有告知用户。如果深度档位失败后悄悄降级到标准档位用户看到长问题但拿到短答案会困惑。必须在响应中带上degraded_from前端再做提示。第五个坑是只调temperature装作“努力程度变化”。这一步不会让回答结构产生本质差异最终会被用户识破。排错优先级先确认前端传参再确认后端解析与映射最后才查上游模型错误。不要一开始就去改模型 prompt那样很容易掩盖真实问题。7. 最佳实践与扩展方向7.1 发布前可复用检查清单在新粒度选择器发布前可以按这份清单逐项检查前后端档位枚举是否完全一致。每个档位是否有独立的模型参数映射。缓存 key 是否包含effort_level。非法档位是否有统一回退逻辑。默认档位是否明确。用户选择是否被正确记忆。URL 参数解析是否有安全回退。降级后是否在响应中标记。是否采集了档位点击和结果质量数据。移动端触控区域是否足够大。帮助文案是否说明各档位差异。是否存在关闭某个档位的临时开关。7.2 生产环境还需要做哪些额外保障生产环境里档位映射表不应该硬编码在业务代码中。推荐把每个档位的参数写入配置中心运行中通过配置下发更新这样调整max_tokens或search_rounds时不需要发版。还要为每个档位建立独立的监控维度。重点观察各档位 p95 延迟。各档位调用成功率和错误分布。各档位 token 成本和一次搜索平均费用。深度档位触发降级的比例。如果深度档位只有少数用户使用但占用了大部分模型成本可以考虑将深度档位设为登录用户才能使用的能力或者在高峰期限流。7.3 扩展方向新粒度选择器的下一步不是继续增加档位而是让“档位”更智能。第一个方向是动态推荐档位。前端可以根据 query 长度、问题类型或用户历史行为自动给出推荐档位比如搜索型问题默认fast分析型问题默认deep。第二个方向是用户级偏好设置。允许用户在个人偏好里设置默认努力程度同时对匿名用户使用全局默认值。第三个方向是多模型适配。不同模型对reasoning_effort的取值定义不同可以通过配置表统一维护“产品档位 - 模型参数”的映射关系而不需要为每种模型写一套 if-else。第四个方向是组织级策略。企业版用户可以强制默认使用深度档位保证答案质量免费版用户则可以设置更保守的延迟和成本上限。努力程度选择器的本质是把计算开销这种后端资源变成一个用户能理解的交互选择。新粒度不等于无限增加档位而是让每个档位都能对应到一组可感知的模型行为差异。开发时先把端到端参数链路打通再逐步优化交互细节会比一开始就追求“档位更细”稳妥得多。如果你准备在搜索或问答产品中加入这个能力先让极速和深度研究两个极端档位产生明显差异剩下的档位自然就有了定位依据。