ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

推理模型思考预算调优:死循环治理与分级路由实践

推理模型思考预算调优:死循环治理与分级路由实践

推理模型上线第一周,监控面板上最刺眼的往往不是错误率,而是两个数字:P99 延迟和每千次请求的输出 token 消耗。业务方看到的是“回答变慢了”,财务看到的是“账单涨了三倍”,而你在日志里看到的,是模型对着一道两行代码的判断题,写了满满一千二百个 token 的自我推演,最后给出一个和没思考时一样的结论。于是很自然地,你想把思考长度压下去。

压下去之后,问题换了一副面孔。数学题开始漏步骤,多跳问答在第二跳就跑偏,代码补丁改到了无关的行,本来能一次跑通的脚本开始需要人肉返工。你只好把预算调回来,接着又撞上第三种毛病:模型在同一个思路上来回打转,反复推翻自己、反复确认同一个前提,输出了几千 token,逻辑却一步没往前挪。这三种状态互相拉扯,构成了一个非常典型的工程困境——想让它想得少、能力不掉、还不死循环,同时还要保留高档位推理能力,很难靠“调一个参数”解决。

这篇文章要讲的,就是怎么把这件事从“凭感觉调参”变成“有据可依的剂量实验”。标题里那个模型代号(Qwen3.8-27B)和“XH 档”属于项目内部叫法,你在自己环境里替换成实际的模型 ID 和推理档位即可,方法本身是通用的。读完之后你应当能拿到三样东西:一套能跑起来的评测脚本、一条判断“砍到哪里算安全”的曲线、以及一组可以直接抄去做基线的起始参数。

1. 先把问题拆开:所谓“剂量”其实是四个独立旋钮

很多人一上来就盯着 temperature 调,调完发现要么没变化,要么变化完全不可控。原因很简单:控制推理模型“想多久、想多稳”的并不是一个参数,而是四个相互独立、彼此还会互相抵消的旋钮。只有把它们分开看,你才知道自己刚才那一刀到底砍在了哪里。

第一个旋钮是思考预算,也就是允许模型在给出最终答案之前生成多少推理内容。在支持思考开关的模型上,这一层通常有两级控制:一级是开关(是否输出思考过程,Qwen3 系列在 chat template 层面可以通过enable_thinking这类参数控制,具体支持情况以你部署的版本为准),另一级是长度上限(max_tokens或者推理服务提供的 thinking budget 类参数)。开关决定“有没有中间计算”,上限决定“中间计算最多做多少”。

第二个旋钮是采样随机性,主要是temperature、top_p、top_k。它影响的是模型在每一步选词时的分散程度。调高会让推理路径更多样,也更容易跑偏;调低会让输出更确定,但过低时模型容易贴着一条错误路径一路走到黑,反而更顽固。

第三个旋钮是重复抑制,包括repetition_penalty、presence_penalty、frequency_penalty。这是对付死循环最直接的药,但剂量大了会有副作用:模型不敢重复必要的数学符号、变量名、模板化措辞,输出会变得别扭甚至语法错误。很多“调完以后模型变笨了”的案例,其实是重复惩罚给重了。

第四个旋钮是提示层约束,包括 system prompt、few-shot 示例、stop停止序列、结构化输出格式要求。这一层最容易被忽略,但性价比最高。一句“直接给出结论,最多三步推导”的效果,经常比把max_tokens砍掉一半更好,而且不损失任何计算能力,只是改变了计算的表达方式。

旋钮主要影响调大的后果调小的后果
思考预算延迟、成本、复杂任务成功率延迟与成本上升,冗余推演增多多步任务掉点,步骤缺失
temperature / top_p路径多样性跑偏、格式不稳、循环概率上升输出僵化,错误路径难自救
重复惩罚复读与摆动循环术语变形、符号丢失、语法异常复读循环压不住
提示层约束输出形状与推理风格约束过强会压制必要推导自由发挥,风格漂移

看清这张表之后,一个关键判断就出来了:“想得少”应该主要由提示层和思考预算来完成,“不死循环”主要由重复惩罚和停止条件来完成,“不掉能力”则要靠保住必要的那部分思考预算。如果你用重复惩罚去压长度,或者用 temperature 去治循环,方向就错了,副作用一定比收益大。

1.1 一个常见误区:把“思考长度”当成“能力强度”

新手最容易犯的错,是默认思考越长模型越聪明,于是把预算拉满,结果拿到一堆又慢又啰嗦、正确率还不一定更高的输出。这里真正需要理解的是:思考长度和正确率不是线性关系,而是一条先上升、后走平、最后略微下滑的曲线。

在曲线左侧,思考 token 是实打实的计算步骤,砍掉就掉点。在中间那段平台区,多出来的 token 大多花在自我复述、反复确认前提、把同一个结论换三种说法上,这部分是纯冗余。到了右侧,过长的思考还会引入新风险:模型在长上下文里开始怀疑已经验证过的结论,或者被自己早前的错误推导带偏。

所以“剂量”这个词用得很准。你要找的不是“最小值”,而是那条曲线拐点附近的一个区间——足够覆盖必要推导,又不至于进入冗余区和自怀疑区。下面所有的实验设计,目标都是把这条曲线在你的业务数据上画出来。

2. 死循环的三种形态:先分清是哪种病

“死循环”是个笼统说法,实际上至少有三类,成因和药方完全不同。分不清类型就上重复惩罚,很可能治好了甲、治坏了乙。

第一类是复读循环。表现为同一句话、同一段推理、同一个编号列表被连续输出多次,有时是逐字重复,有时只差几个标点。这类最常见,通常和temperature偏高、repetition_penalty缺失、以及上下文里已经存在一段重复文本有关——模型会顺着已有的模式继续复制。

第二类是摆动循环。表现是 A→B→A→B,模型在两个方案之间反复横跳,每次都给出“但是另一个方案也……”的理由,永远不做决定。这类循环的 token 消耗往往比复读更高,因为每一轮都有实质内容,看着像在认真思考。成因通常是任务本身存在真实的权衡,而提示里没有给出裁决标准,模型只能无限比较下去。

第三类是工具循环。在 Agent 场景里极其常见:调用一次检索或接口,对结果不满意,换一个几乎相同的参数再调一次,再不满意,再调。这类问题不在模型采样参数上,而在工具返回结果的处理逻辑和最大轮次限制上,光调temperature是没用的。

对应的处理策略也不一样。复读循环优先加重复惩罚、收紧top_p、补stop序列;摆动循环优先改提示,明确“必须在两种方案中选一个并给出理由,不允许并列”;工具循环优先在编排层加硬性轮次上限和结果缓存,同时把工具返回内容做裁剪,避免把大量无关字段塞回上下文。

在实际项目里,我建议先做一件事:把你的循环样本捞出来,人工标一遍属于哪一类。五十条样本足够看出分布。如果三类混在一起占比都不低,说明问题不在参数,而在提示设计和编排逻辑,这时候调参只是在给结构性问题打补丁。

3. 环境准备与评测基线搭建

在动手调参之前,必须先把“measuring stick”立起来。没有基线,任何参数改动都无法判断是变好还是变坏,你只会陷入“感觉这次好像快了点”的循环。

环境方面,一台能跑推理服务或者能调远程接口的机器即可。本文示例使用 Python 3.10 以上版本配合 OpenAI 兼容客户端,这是目前兼容性最好的接入方式——vLLM、SGLang、Ollama 以及各家云服务基本都提供 OpenAI 兼容端点。具体版本请以你实际部署的环境为准,本文重点演示通用思路,不绑定特定发行版。

# 创建独立环境,避免污染系统 Python python -m venv .venv source .venv/bin/activate # Windows 使用 .venv\Scripts\activate # 安装依赖:客户端 + 数据处理 pip install "openai>=1.30" pandas tqdm

配置通过环境变量管理,不要把密钥写进代码:

# 文件路径:.env(不要提交到 Git) export OPENAI_BASE_URL="http://127.0.0.1:8000/v1" export OPENAI_API_KEY="EMPTY" # 本地推理服务通常不校验,云服务请填真实 key export MODEL_ID="your-model-id" # 替换成你部署的实际模型 ID

评测集是这一步最值钱的产出,也是最容易被敷衍的环节。不要拿公开榜单的题目凑数,那些题模型大概率见过,测出来全是高分,看不出差异。应该从你自己的业务里抽题,覆盖五类:

  1. 单步事实问答(用来确认基础能力没被削)。
  2. 多步推理或计算(用来观察思考预算的影响)。
  3. 代码修改或补全(对上下文和格式敏感)。
  4. 指令遵循(比如“只输出 JSON,不要解释”)。
  5. 长文本归纳(容易被冗余思考拖慢)。

规模上,每类 20 到 40 条,总共 100 到 200 条就够做参数比较了。再多的话,跑一轮网格实验的时间成本会超出收益。每条样本必须有唯一 ID 和期望答案,期望答案尽量做成可程序化判定的形式(数字、关键词、正则、JSON 字段),实在不行再用人工抽检。

// 文件路径:eval_set.jsonl {"id": "math_001", "category": "multi_step", "prompt": "一家店进货价 120 元,先加价 40% 标价,再打八折出售,最终毛利是多少元?只输出数字。", "answer": "14.4", "judge": "exact"} {"id": "code_001", "category": "code", "prompt": "下面的 Python 函数在输入为空列表时会抛异常,请指出原因并用一句话说明修复方式:\n\ndef avg(xs):\n return sum(xs) / len(xs)", "answer": "ZeroDivisionError", "judge": "contains"} {"id": "fmt_001", "category": "instruction", "prompt": "把这句话里的日期提取出来,只输出 JSON,字段名为 date:会议定在 2024 年 3 月 15 日下午两点。", "answer": "2024-03-15", "judge": "json_field:date"} {"id": "long_001", "category": "summarize", "prompt": "用不超过 30 个字概括下面这段文字的核心结论:……", "answer": "", "judge": "manual"}

注意judge字段,它决定了后面统计脚本怎么算分。把判定逻辑做成声明式配置,比在脚本里写一堆if category == ...要可维护得多,加新题型时只需要加数据,不用改代码。

4. 实验设计:怎么扫参数才算“可比较”

有了评测集,接下来是实验设计。这里最容易出的问题是:一次改三个参数,结果变好了,但你不知道是哪个参数起的作用。正确的做法是单变量扫描 + 关键组合验证:先每个旋钮单独扫一遍确定边界,再在边界附近做小规模组合。

第一轮,固定住其他参数,只扫思考预算。建议取四个档位:最低档(几乎不思考)、中档、高档、最高档(也就是你说的 XH 档)。记录每档的准确率、平均输出 token、循环率、截断率。这一轮的目标是画出那条“先升后平”的曲线,找到平台区的起点。

第二轮,在选定的预算档位上单独扫temperature。范围从 0 到 1.0,取 0、0.2、0.4、0.6、0.8 五档。你会发现一个很有规律的现象:对推理任务来说,temperature超过 0.7 之后准确率下降通常比循环率上升更明显,也就是说代价先体现在“答错”,而不是“卡住”。

第三轮,扫重复惩罚。范围建议 1.0 到 1.2,步长 0.05。这一轮要特别关注“术语变形率”——模型有没有把len(xs)写成len(x s)之类的东西。超过 1.15 之后这种问题会明显变多。

第四轮才是组合验证,只在前面选出的候选点附近做,比如 (预算中档, temp 0.3, penalty 1.05) 对 (预算高档, temp 0.3, penalty 1.05)。组合数量控制在 6 到 9 组,否则跑不完。

还有一个必须做的对照实验:开启 XH 档但不加任何约束,作为上界参考。这组数字虽然慢,但它是你的“能力天花板”,后面所有压缩方案都要拿它来比,看掉了多少。没有这个参照,你没法回答“到底掉没掉能力”这个问题。

5. 完整示例代码:从调用封装到网格扫描

下面这套代码可以直接跑,分成三部分:调用封装(含循环检测)、单组参数评测、网格扫描与结果落盘。我把它拆成多个文件,方便你替换其中任何一块。

5.1 调用封装与循环检测

# 文件路径:runner.py import os import re import json from collections import Counter from openai import OpenAI client = OpenAI( base_url=os.environ["OPENAI_BASE_URL"], api_key=os.environ.get("OPENAI_API_KEY", "EMPTY"), ) MODEL_ID = os.environ["MODEL_ID"] def detect_loop(text: str, window: int = 80, repeat: int = 3) -> bool: """粗糙但有效的复读检测:滑动窗口看是否有连续重复片段。""" if len(text) < window * repeat: return False chunks = [text[i:i + window] for i in range(0, len(text) - window + 1, window)] counter = Counter(chunks) return counter.most_common(1)[0][1] >= repeat def detect_swing(text: str) -> bool: """摆动检测:同一组转折词高频出现,通常意味着模型在反复推翻自己。""" markers = ["但是", "不过", "然而", "另一方面", "综合考虑"] hits = sum(text.count(m) for m in markers) return hits >= 6 def call_model(prompt: str, cfg: dict) -> dict: """调用一次模型,返回文本与耗时、token 统计。""" extra = {} # repetition_penalty 不是 OpenAI 标准字段,需要经 extra_body 透传给推理服务 if "repetition_penalty" in cfg: extra["repetition_penalty"] = cfg["repetition_penalty"] if "top_k" in cfg: extra["top_k"] = cfg["top_k"] resp = client.chat.completions.create( model=MODEL_ID, messages=[ {"role": "system", "content": cfg.get("system", "你是严谨的助手,给出答案前先简要推理,最终结论放在最后一行。")}, {"role": "user", "content": prompt}, ], temperature=cfg.get("temperature", 0.3), top_p=cfg.get("top_p", 0.9), max_tokens=cfg.get("max_tokens", 2048), stop=cfg.get("stop"), extra_body=extra or None, ) content = resp.choices[0].message.content or "" usage = resp.usage return { "text": content, "completion_tokens": getattr(usage, "completion_tokens", 0), "finish_reason": resp.choices[0].finish_reason, "loop": detect_loop(content), "swing": detect_swing(content), }

这里有两个细节值得说。第一,repetition_penalty不属于 OpenAI 官方字段,能不能传取决于你的推理服务实现,vLLM 这类服务通常支持通过extra_body透传。如果你的服务不支持,这一项就跳过,改用frequency_penalty替代,效果弱一些但兼容性好。第二,detect_loop用的是滑动窗口 + 计数,简单粗暴但足够筛出大部分复读样本;更严谨的做法是计算 token 级 n-gram 重复率,但对判断趋势来说没必要。

5.2 判定逻辑与单组评测

# 文件路径:evaluate.py import json import re from tqdm import tqdm from runner import call_model def judge(sample: dict, text: str) -> bool: """根据 judge 字段做程序化判定。""" kind = sample.get("judge", "exact") answer = str(sample.get("answer", "")).strip() if kind == "manual": return False # 人工题暂时不计入自动分 if kind == "exact": return answer.lower() in text.strip().lower() if kind == "contains": return answer.lower() in text.lower() if kind.startswith("json_field:"): field = kind.split(":", 1)[1] match = re.search(r"\{.*\}", text, re.S) if not match: return False try: data = json.loads(match.group(0)) except json.JSONDecodeError: return False return str(data.get(field, "")).strip() == answer return False def run_eval(eval_path: str, cfg: dict, limit: int = None): """跑完整个评测集,返回汇总指标。""" total = correct = loop_cnt = swing_cnt = trunc_cnt = 0 token_sum = 0 details = [] with open(eval_path, encoding="utf-8") as f: samples = [json.loads(line) for line in f if line.strip()] if limit: samples = samples[:limit] for sample in tqdm(samples, desc="eval"): result = call_model(sample["prompt"], cfg) ok = judge(sample, result["text"]) total += 1 correct += int(ok) loop_cnt += int(result["loop"]) swing_cnt += int(result["swing"]) trunc_cnt += int(result["finish_reason"] == "length") token_sum += result["completion_tokens"] details.append({ "id": sample["id"], "correct": ok, "tokens": result["completion_tokens"], "loop": result["loop"], "swing": result["swing"], "finish_reason": result["finish_reason"], }) return { "config": cfg, "accuracy": correct / total if total else 0, "avg_tokens": token_sum / total if total else 0, "loop_rate": loop_cnt / total if total else 0, "swing_rate": swing_cnt / total if total else 0, "trunc_rate": trunc_cnt / total if total else 0, "details": details, }

判定函数里,manual类型直接返回False是刻意的:它保证人工题不会被自动分污染,同时这类样本仍然会跑、会记录 token 和循环情况。等参数选定后,再单独抽这批样本人工看一遍,判断质量有没有塌陷——这一步不能省,程序化判定永远覆盖不到“答得对不对但味道不对”的问题。

5.3 网格扫描与结果落盘

# 文件路径:sweep.py import json import itertools from evaluate import run_eval BUDGETS = { "low": {"max_tokens": 256}, "mid": {"max_tokens": 768}, "high": {"max_tokens": 1536}, "xh": {"max_tokens": 4096}, } BASE = { "temperature": 0.3, "top_p": 0.9, "system": "你是严谨的助手。先给简要推理,最多三步;最终结论单独一行,以“答案:”开头。", } results = [] # 第一轮:只扫思考预算 for name, budget in BUDGETS.items(): cfg = {**BASE, **budget, "tag": f"budget_{name}"} results.append(run_eval("eval_set.jsonl", cfg)) # 第二轮:在 mid 档上扫 temperature for temp in [0.0, 0.2, 0.4, 0.6, 0.8]: cfg = {**BASE, **BUDGETS["mid"], "temperature": temp, "tag": f"mid_temp_{temp}"} results.append(run_eval("eval_set.jsonl", cfg)) # 第三轮:在最优温度附近扫重复惩罚 for rp in [1.0, 1.05, 1.10, 1.15, 1.20]: cfg = {**BASE, **BUDGETS["mid"], "temperature": 0.3, "repetition_penalty": rp, "tag": f"mid_rp_{rp}"} results.append(run_eval("eval_set.jsonl", cfg)) # 落盘:先存全量,再存不带细节的汇总 with open("sweep_results.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) with open("sweep_summary.jsonl", "w", encoding="utf-8") as f: for r in results: row = {k: v for k, v in r.items() if k not in ("details",)} f.write(json.dumps(row, ensure_ascii=False) + "\n")

运行方式很直接:

python sweep.py

跑完之后,sweep_summary.jsonl就是你的剂量对照表。用 pandas 读进来按accuracy和avg_tokens排序,基本一眼就能看出拐点在哪:

python -c " import pandas as pd df = pd.read_json('sweep_summary.jsonl', lines=True) df['config'] = df['config'].apply(lambda c: c.get('tag')) print(df.sort_values('accuracy', ascending=False)[['config','accuracy','avg_tokens','loop_rate','trunc_rate']].to_string(index=False)) "

6. 结果判读:三个指标必须一起看

拿到结果表之后,最忌讳的做法是只挑accuracy最高的那一行。准确率是必要指标,但它单独看会骗人——一组参数可能准确率只低 1 个百分点,token 却省了一半,这在工程上是明显更优的选择。

正确的判读方式是三个指标联立:准确率看能力有没有掉,平均输出 token看成本和延迟,循环率 + 截断率看稳定性。理想的候选点应该长这样:准确率距离 XH 基线在可接受范围内(比如 2 到 3 个百分点内),平均 token 显著低于 XH 档,循环率低于 2%,截断率接近 0。

截断率这一项特别值得单独强调。它的含义是finish_reason == "length",也就是模型话还没说完就被max_tokens掐断了。截断率高,说明你不是“让它想得少”,而是“让它没想完”,这两件事在业务上的后果完全不同:后者会给出半截答案、断掉的 JSON、缺右括号的代码,用户看到的是明显的故障,而不是“稍微笨一点”。如果你的实验中某一档位 accuracy 还行但截断率超过 5%,这一档就不能用,必须调高预算或者缩短输出格式要求。

下面的表格演示的是结果记录格式和判读逻辑,数值请以你自己的跑测为准,这里不给出任何虚构跑分:

配置准确率平均输出 token循环率截断率判读
XH 无约束(基线)基线值最高偏高低能力天花板,只做参照
XH + 提示约束接近基线明显下降明显下降低通常是最优解附近
中档 + 提示约束略低于基线中低低多数业务的性价比甜点
低档 + 提示约束明显下降最低低中高只适合简单任务
中档 + 高重复惩罚下降中极低低术语变形风险,慎用

从表里能读出一个很重要的结论:“XH 档 + 提示约束”经常比“中档 + 无约束”更好。原因是高档位保住了计算能力,而提示约束砍掉的是冗余表达,两者作用在不同层面,不冲突。这也是为什么我在第 1 节里强调,四个旋钮要分开用——你完全可以让模型在最高档位上思考,但要求它别把思考过程写出来,或者只写三步以内的摘要。这样既没砍计算,又砍了输出 token。

还有一种判读方式值得一试:按题目类别拆开看。总额准确率掉 2 个点,可能是某一类掉了 10 个点、其他类没动。如果掉分集中在多步推理上,说明预算砍太狠;如果掉分集中在指令遵循上,说明提示约束和格式要求打架了;如果掉分集中在代码题上,很可能是重复惩罚把变量名搞变形了。分类别看,比看总数有用得多。

7. 常见问题与排查思路

调参过程中遇到的问题高度集中在几类,把它们整理成表,遇到现象直接对号入座,比漫无目的地改参数快得多。

问题现象可能原因排查方式解决方案
输出中途被切断,JSON 缺右括号max_tokens不足以覆盖思考 + 答案看finish_reason是否大量为length提高预算,或要求模型不输出思考过程只给结论
同一段推理连续重复温度偏高、无重复惩罚、上下文已有重复模式用detect_loop统计循环率,人工看几条样本加repetition_penalty到 1.05 附近,收紧top_p
术语、变量名被写坏重复惩罚过重检查输出里是否出现被拆开的标识符惩罚降到 1.05 以下,改用frequency_penalty
模型在两个方案间反复横跳任务存在真实权衡,提示未给裁决标准看转折词密度,detect_swing命中率高提示中明确“必须选一个,不允许并列”
老版本能答对新版本答错提示模板变化、评测集口径不一致对比同一模型在旧配置下的分类别得分固定评测集版本,配置纳入版本管理
同类请求延迟波动极大思考长度不受控,长尾样本拖高 P99统计输出 token 的分位数而非均值用分级路由,长尾走缓存或降档
加了 stop 序列后答案被截半stop 字符串命中了答案本身检查 stop 是否与输出格式冲突换成更独特的终止标记,如</answer>
云服务和本地部署结果差异大采样实现、默认值、量化精度不同在同一评测集上对跑统一参数默认值,记录量化方式

这张表里有两个问题特别容易被误判。第一个是“输出被切断”,很多人第一反应是模型能力问题,其实是max_tokens和思考长度叠加超了预算——模型把 700 token 花在思考上,留给答案的只剩 100 token,答案当然写不完。第二个是“延迟波动”,均值看着还行,P99 却高得离谱,原因是有少量样本触发了超长思考或摆动循环,这时候该做的是路由和超时兜底,而不是继续调温度。

另外提醒一句:所有涉及生产环境的参数变更,都应该先在测试环境验证,保留旧配置可回滚,并且改动要有灰度比例。参数调整看起来只是换个数字,但它直接改变线上输出分布,影响面和一次代码发布没有区别。

8. 生产落地:分级路由比“找一个万能参数”更现实

实验做完你会发现一个尴尬的事实:不存在一组参数能同时满足所有请求。简单问答希望越快越好,复杂分析希望越准越好,两者的最优剂量差得很远。硬要找一个折中点,结果往往是两头都不满意。

更现实的做法是分级路由:在请求进入模型之前,先用一个轻量规则或小模型判断复杂度,然后分派到不同档位。

# 文件路径:router.py import re COMPLEX_HINTS = [ r"证明|推导|为什么.*成立", r"多步|逐步|分别计算", r"重构|优化.*性能|分析.*复杂度", r"比较.*优缺点|权衡", ] def pick_tier(prompt: str, history_len: int = 0) -> str: """极简复杂度路由,返回档位名。生产环境可换成小模型分类器。""" text = prompt.strip() # 超短且不含复杂信号:直接低档,甚至可关闭思考 if len(text) <= 40 and not any(re.search(p, text) for p in COMPLEX_HINTS): return "low" # 命中复杂信号,或上下文很长:走高/最高档 if any(re.search(p, text) for p in COMPLEX_HINTS) or history_len > 6000: return "xh" return "mid" TIER_CONFIG = { "low": {"max_tokens": 256, "temperature": 0.2, "repetition_penalty": 1.03}, "mid": {"max_tokens": 768, "temperature": 0.3, "repetition_penalty": 1.05}, "xh": {"max_tokens": 3072, "temperature": 0.3, "repetition_penalty": 1.05}, }

这个路由函数很粗糙,但它把最重要的一件事表达清楚了:档位不是全局开关,而是请求级属性。真正上线时,你可以把pick_tier换成一个小分类模型,或者用历史统计(同一用户同一会话前几轮的平均输出长度)来推断,效果会比关键词匹配稳得多。

配套还需要三样东西。第一是输出缓存,把相同或高度相似的请求结果缓存起来,尤其是那些走了高档位的请求,命中一次就省一次完整推理。第二是超时与降级,给每个档位设一个时间上限,超时就返回当前已生成内容并要求用户细化问题,而不是干等。第三是监控埋点,按档位分别统计准确率反馈、token 消耗、循环率、截断率,只有分开统计,你才知道哪个档位的剂量需要再调。

还有一条容易被忽略的实践:把提示模板和参数配置一起做版本管理。这两者耦合度很高,只改其中一个都会让历史对比失效。建议每次实验都记录完整的配置快照,包括 system prompt 的哈希值,这样回头看结果时才能确认到底是参数变了还是提示变了。

9. 一组可以直接抄的起始参数

如果你不想从零开始扫,可以先拿这组做基线,再按自己的评测结果微调。它不是最优解,但通常不会太离谱。

对于通用问答和轻量任务,起始点可以设成:temperature=0.2、top_p=0.9、max_tokens=256、repetition_penalty=1.03,system prompt 里加一句“最多三步推理,结论单独一行”。这组参数的目标是把延迟压到可接受范围,代价是复杂任务会掉点,所以必须配合路由,别让它接复杂请求。

对于多步推理和代码任务,建议temperature=0.3、top_p=0.9、max_tokens=1536到 3072、repetition_penalty=1.05,system prompt 用“先简要推理,不要重复已确认的前提,最终结论放最后”。这里的关键是那句“不要重复已确认的前提”,它专门对付摆动循环,比调重复惩罚更精准,而且没有副作用。

对于必须走 XH 档的场景,思路不是压预算,而是压表达:保留最高档位,但要求模型只输出结论和关键步骤,把详细推演留在内部。很多推理服务支持单独返回思考内容和最终答案两个字段,你可以只把最终答案展示给用户,思考内容仅用于日志排查。这样延迟成本还在,但用户体验和输出 token 都能明显改善。

最后留一个提醒。这套方法最容易被误用的地方,是把离线评测的结论直接搬到线上。离线评测集是固定的,线上流量是漂移的,今天调好的剂量过两个月可能就不合适了。稳妥的做法是把评测跑成周期性任务,每月用新采集的样本重跑一次,观察曲线有没有偏移。参数调优不是一次性动作,而是一个需要持续维护的工程环节——真正省事的不是找到那组“完美数字”,而是把这套测量流程固化下来,让下一次调整有据可依。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进