努力程度选择器是 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_level=deep恢复,例如搜索页面支持/?q=xxx&effort_level=deep。
但要注意,不是所有状态都适合放进 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: Record<EffortLevel, 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 className="effort-selector" role="radiogroup" aria-label="努力程度"> {EFFORT_LEVELS.map((level) => ( <button key={level} className={level === value ? 'effort-option active' : 'effort-option'} onClick={() => onChange(level)} role="radio" 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] = useState<EffortLevel>(() => 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 className="search-page"> <div className="search-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 = useRef<AbortController | 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.infer<typeof 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: Record<EffortLevel, 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,前端展示提示 |
| 移动端可点击 | 在手机宽度下检查 | 每个按钮高度不低于 44px |
6. 常见问题排查与开发陷阱
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。
第四个方向是组织级策略。企业版用户可以强制默认使用深度档位,保证答案质量;免费版用户则可以设置更保守的延迟和成本上限。
努力程度选择器的本质,是把计算开销这种后端资源,变成一个用户能理解的交互选择。新粒度不等于无限增加档位,而是让每个档位都能对应到一组可感知的模型行为差异。开发时先把端到端参数链路打通,再逐步优化交互细节,会比一开始就追求“档位更细”稳妥得多。如果你准备在搜索或问答产品中加入这个能力,先让极速和深度研究两个极端档位产生明显差异,剩下的档位自然就有了定位依据。