同一句提示,为什么每次不一样、账单还偏高
你给学习计划页加了一句“今日建议”:同一段提示,有时回答稳定,有时跑题;按字数估的费用,月底账单却高出一截。本课用四个本地小脚本把原因拆开看:token 怎样计数,模型怎样按概率挑下一个词,温度、top-p 和输出上限分别改变什么,最后从 usage 记录算出每次任务的真实开销。
完成后你会得到:一个玩具分词器、一个按概率采样的生成器、一张参数对照表和一个成本估算脚本。准备 Python 3.12 与一个空目录 token-lab,全部只用标准库,不联网、不调用任何模型。最后一节是在你自己账号上做的对照练习,会产生少量费用,本站不代为调用。
第一步:看清 token 不等于字
模型不直接读“字”,而是读分词器切出来的片段。下面用一个只有十几个词的玩具词表模拟“最长匹配 + 字节兜底”:词表里有的整段切出,没有的字退回成 UTF-8 字节。保存为 tokens.py,运行 python tokens.py:
# 教学用玩具词表:真实分词器的词表从大量文本中训练得到,规模通常是数万级以上
VOCAB = {"机器学习", "机器", "学习", "模型", "温度", "采样", "的", "是",
"learn", "ing", "token", " "}
MAX_LEN = max(len(word) for word in VOCAB)
def tokenize(text):
tokens, i = [], 0
while i < len(text):
for size in range(min(MAX_LEN, len(text) - i), 0, -1):
piece = text[i:i + size]
if piece in VOCAB:
tokens.append(piece)
i += size
break
else:
# 词表里没有的字退回为 UTF-8 字节,一个常用汉字通常占 3 个字节
tokens.extend(f"<{b:02X}>" for b in text[i].encode("utf-8"))
i += 1
return tokens
samples = ["机器学习的模型", "温度是采样的", "learning token", "鲲鹏"]
for text in samples:
tokens = tokenize(text)
print(f"{text!r}: {len(text)} 个字符 -> {len(tokens)} 个 token {tokens}")
assert tokenize("机器学习的模型") == ["机器学习", "的", "模型"]
assert len(tokenize("鲲鹏")) == 6
实际输出:
'机器学习的模型': 7 个字符 -> 3 个 token ['机器学习', '的', '模型']
'温度是采样的': 6 个字符 -> 4 个 token ['温度', '是', '采样', '的']
'learning token': 14 个字符 -> 4 个 token ['learn', 'ing', ' ', 'token']
'鲲鹏': 2 个字符 -> 6 个 token ['<E9>', '<B2>', '<B2>', '<E9>', '<B9>', '<8F>']
7 个常用字只算 3 个 token,2 个不在词表的字却算 6 个。真实分词器更复杂,但结论相同:token 数取决于具体模型的词表,常见词便宜,生僻字、表情、乱码和长串数字往往更贵;同一段中文换一个模型,计数也会变。所以“汉字数乘一个固定系数”只能粗估,预算应以平台提供的计数工具或每次返回的 usage 为准。
第二步:把打分变成概率,再看温度和 top-p
模型每一步给所有候选词打分(logits),再换算成概率,按概率抽一个。温度先把分数除以 T 再换算:T 小,高分词更占优;T 大,分布更平。top-p 则把候选按概率从高到低累加,加到不少于 p 就截断,只在留下的词里抽。保存为 sampling.py:
import math
import random
# 教学假设:模型读到“今天先去”后给下一个词的打分(logits),不是任何真实模型的数值
LOGITS = {"复习": 2.0, "练习": 1.6, "休息": 0.8, "看视频": 0.2, "打游戏": -0.5}
def softmax(logits, temperature):
if temperature <= 0: # 温度为 0 时按惯例退化为“总选最高分”
best = max(logits, key=logits.get)
return {k: 1.0 if k == best else 0.0 for k in logits}
scaled = {k: v / temperature for k, v in logits.items()}
peak = max(scaled.values())
exps = {k: math.exp(v - peak) for k, v in scaled.items()}
total = sum(exps.values())
return {k: v / total for k, v in exps.items()}
def top_p_filter(probs, top_p):
ranked = sorted(probs.items(), key=lambda kv: (-kv[1], kv[0]))
kept, cumulative = [], 0.0
for token, p in ranked:
kept.append((token, p))
cumulative += p
if cumulative >= top_p:
break
total = sum(p for _, p in kept)
return {token: p / total for token, p in kept}
def sample(probs, rng):
r, acc = rng.random(), 0.0
for token, p in probs.items():
acc += p
if r < acc:
return token
return token # 浮点误差兜底
print("温度对概率的影响:")
for t in (0, 0.5, 1.0, 1.5):
probs = softmax(LOGITS, t)
print(f" T={t}: " + " ".join(f"{k}{v:.3f}" for k, v in probs.items()))
print("top-p 保留的候选(T=1.0):")
for p in (0.5, 0.8, 1.0):
kept = top_p_filter(softmax(LOGITS, 1.0), p)
print(f" top_p={p}: {list(kept)}")
print("各抽 1000 次的结果:")
for t, p in ((0.5, 1.0), (1.5, 1.0), (1.5, 0.8)):
rng = random.Random(42)
probs = top_p_filter(softmax(LOGITS, t), p)
counts = {}
for _ in range(1000):
token = sample(probs, rng)
counts[token] = counts.get(token, 0) + 1
print(f" T={t}, top_p={p}: {dict(sorted(counts.items(), key=lambda kv: -kv[1]))}")
assert softmax(LOGITS, 0)["复习"] == 1.0
assert abs(sum(softmax(LOGITS, 1.5).values()) - 1) < 1e-9
assert list(top_p_filter(softmax(LOGITS, 1.0), 0.5)) == ["复习", "练习"]
实际输出:
温度对概率的影响:
T=0: 复习1.000 练习0.000 休息0.000 看视频0.000 打游戏0.000
T=0.5: 复习0.635 练习0.285 休息0.058 看视频0.017 打游戏0.004
T=1.0: 复习0.451 练习0.302 休息0.136 看视频0.074 打游戏0.037
T=1.5: 复习0.370 练习0.283 休息0.166 看视频0.111 打游戏0.070
top-p 保留的候选(T=1.0):
top_p=0.5: ['复习', '练习']
top_p=0.8: ['复习', '练习', '休息']
top_p=1.0: ['复习', '练习', '休息', '看视频', '打游戏']
各抽 1000 次的结果:
T=0.5, top_p=1.0: {'复习': 618, '练习': 293, '休息': 63, '看视频': 17, '打游戏': 9}
T=1.5, top_p=1.0: {'复习': 351, '练习': 289, '休息': 165, '看视频': 115, '打游戏': 80}
T=1.5, top_p=0.8: {'复习': 432, '练习': 354, '休息': 214}
看三处:T=1.5 时“打游戏”从千分之四升到百分之七,跑题词被抽中的机会明显变多;同样是 T=1.5,加上 top_p=0.8 后尾部三个词被整段剪掉;抽样次数和概率接近但不相等(618 对 0.635),少量重复不能证明参数的效果,需要多跑几次再比较。温度为 0 在这里等于“永远选最高分”,真实服务因实现与并发计算等原因,很多平台并不保证温度为 0 时每次完全一致。
第三步:生成整句,观察截断和多样性
一句话是逐词抽出来的,每一步都受温度和 top-p 影响;输出上限(常见参数名 max_tokens)只管“最多抽几步”,到了上限就停,不管句子是否说完。保存为 generate.py:
import math
import random
# 教学假设的“下一个词打分表”:每一行表示读到左边的词后,下一个词的打分
NEXT = {
"<开始>": {"今天": 2.0, "建议": 1.0},
"今天": {"先": 1.5, "复习": 1.0, "练习": 0.5},
"建议": {"先": 1.2, "复习": 1.0},
"先": {"复习": 2.0, "练习": 1.6, "休息": 0.5},
"复习": {"昨天的": 1.5, "错题": 1.2, "<结束>": 0.6},
"练习": {"三道题": 1.5, "错题": 1.0, "<结束>": 0.6},
"休息": {"<结束>": 1.5, "一下": 1.0},
"一下": {"<结束>": 2.0, "再": 0.5},
"昨天的": {"笔记": 2.0, "错题": 1.0},
"笔记": {"<结束>": 2.0, "再": 0.8},
"错题": {"<结束>": 2.0, "再": 0.8},
"三道题": {"<结束>": 2.0, "再": 0.6},
"再": {"练习": 1.0, "休息": 1.0, "复习": 0.8},
}
def next_probs(logits, temperature, top_p):
if temperature <= 0:
best = max(logits, key=logits.get)
return {best: 1.0}
peak = max(logits.values())
exps = {k: math.exp((v - peak) / temperature) for k, v in logits.items()}
total = sum(exps.values())
ranked = sorted(exps.items(), key=lambda kv: -kv[1])
kept, cumulative = {}, 0.0
for token, weight in ranked:
kept[token] = weight / total
cumulative += weight / total
if cumulative >= top_p:
break
norm = sum(kept.values())
return {k: v / norm for k, v in kept.items()}
def pick(probs, rng):
r, acc = rng.random(), 0.0
for token, p in probs.items():
acc += p
if r < acc:
return token
return token # 浮点误差兜底
def generate(temperature=1.0, top_p=1.0, max_tokens=20, seed=0):
rng, word, out = random.Random(seed), "<开始>", []
while len(out) < max_tokens:
word = pick(next_probs(NEXT[word], temperature, top_p), rng)
if word == "<结束>":
return "".join(out), "stop", len(out)
out.append(word)
return "".join(out), "length", len(out)
for label, kwargs in [("T=0", {"temperature": 0}),
("T=1.0", {"temperature": 1.0}),
("T=1.0 top_p=0.5", {"temperature": 1.0, "top_p": 0.5}),
("max_tokens=3", {"temperature": 1.0, "max_tokens": 3})]:
runs = [generate(seed=s, **kwargs) for s in range(5)]
print(f"{label}: 不同结果 {len({r[0] for r in runs})} 种")
for text, reason, n in runs[:3]:
print(f" {text} finish_reason={reason} 输出 {n} 个 token")
print("各跑 500 次,max_tokens=8:")
for t in (0.3, 1.0, 2.0):
runs = [generate(temperature=t, max_tokens=8, seed=s) for s in range(500)]
avg = sum(r[2] for r in runs) / len(runs)
cut = sum(r[1] == "length" for r in runs) / len(runs)
print(f" T={t}: 平均输出 {avg:.2f} 个 token,被截断 {cut:.1%}")
assert generate(temperature=0) == generate(temperature=0, seed=99)
assert generate(temperature=1.0, max_tokens=3, seed=1)[1] in {"stop", "length"}
实际输出:
T=0: 不同结果 1 种
今天先复习昨天的笔记 finish_reason=stop 输出 5 个 token
今天先复习昨天的笔记 finish_reason=stop 输出 5 个 token
今天先复习昨天的笔记 finish_reason=stop 输出 5 个 token
T=1.0: 不同结果 5 种
建议复习昨天的笔记 finish_reason=stop 输出 4 个 token
今天练习错题 finish_reason=stop 输出 3 个 token
建议复习昨天的笔记再复习错题 finish_reason=stop 输出 7 个 token
T=1.0 top_p=0.5: 不同结果 2 种
今天先复习昨天的笔记 finish_reason=stop 输出 5 个 token
今天先复习昨天的笔记 finish_reason=stop 输出 5 个 token
今天先复习昨天的笔记 finish_reason=stop 输出 5 个 token
max_tokens=3: 不同结果 4 种
建议复习昨天的 finish_reason=length 输出 3 个 token
今天练习错题 finish_reason=length 输出 3 个 token
建议复习昨天的 finish_reason=length 输出 3 个 token
各跑 500 次,max_tokens=8:
T=0.3: 平均输出 4.37 个 token,被截断 0.6%
T=1.0: 平均输出 4.00 个 token,被截断 3.4%
T=2.0: 平均输出 4.02 个 token,被截断 8.8%
注意 max_tokens=3 那一组的“今天练习错题”:读起来是完整句子,标记却是 length,因为抽到第 3 个词就停了,模型并没有选择结束。判断是否截断要看 finish_reason,不能凭句子像不像说完。在这张玩具打分表里,温度升高后平均长度变化不大,撞上限的比例却从 0.6% 升到 8.8%;这是本表的结果,换一个模型或提示,趋势需要你自己重测。
第四步:从 usage 记录估算费用
计费按 token 而不是按字。以阿里云百炼的 OpenAI 兼容接口文档为例(2026-10 核对官方页面):每次响应的 usage 给出 prompt_tokens、completion_tokens、total_tokens,命中缓存的输入在 prompt_tokens_details.cached_tokens,思考过程的计数在 completion_tokens_details.reasoning_tokens;finish_reason 为 length 表示输出过长被提前停止。其他平台字段名可能不同,以所用平台文档为准。
下面的单价和调用记录都是教学虚构,用来练习“把每次请求的 usage 汇总成任务成本”。保存为 cost.py:
import json
# 教学假设单价(元/百万 token),不是任何平台的真实价格;使用前换成你所用平台当前的价目表
PRICE_PER_MILLION = {"input": 2.0, "output": 8.0}
# 教学虚构的调用记录:每行是一次请求返回的 usage,字段名按常见的 OpenAI 兼容格式
USAGE_LOG = """\
{"id": "r1", "finish_reason": "stop", "usage": {"prompt_tokens": 1200, "completion_tokens": 300}}
{"id": "r2", "finish_reason": "length", "usage": {"prompt_tokens": 1200, "completion_tokens": 512}}
{"id": "r2-retry", "finish_reason": "stop", "usage": {"prompt_tokens": 1250, "completion_tokens": 640}}
{"id": "r3", "finish_reason": "stop", "usage": {"prompt_tokens": 4800, "completion_tokens": 280}}
{"id": "r4", "error": "timeout"}
"""
def cost_yuan(usage):
return (usage["prompt_tokens"] * PRICE_PER_MILLION["input"]
+ usage["completion_tokens"] * PRICE_PER_MILLION["output"]) / 1_000_000
rows = [json.loads(line) for line in USAGE_LOG.splitlines() if line.strip()]
known, unknown, truncated = [], [], []
for row in rows:
usage = row.get("usage")
if not usage:
unknown.append(row["id"]) # 没有 usage 不等于没花钱,单独列出去核对账单
continue
known.append((row["id"], usage, cost_yuan(usage)))
if row.get("finish_reason") == "length":
truncated.append(row["id"])
for rid, usage, cost in known:
print(f"{rid:9} 输入 {usage['prompt_tokens']:5} 输出 {usage['completion_tokens']:4} 费用 {cost:.6f} 元")
total_in = sum(u["prompt_tokens"] for _, u, _ in known)
total_out = sum(u["completion_tokens"] for _, u, _ in known)
total_cost = sum(c for _, _, c in known)
out_share = total_out * PRICE_PER_MILLION["output"] / 1_000_000 / total_cost
print(f"已知合计:输入 {total_in},输出 {total_out},费用 {total_cost:.6f} 元,输出占费用 {out_share:.0%}")
print(f"被截断:{truncated};缺少 usage:{unknown}")
naive = 4 * (500 * PRICE_PER_MILLION["input"] + 300 * PRICE_PER_MILLION["output"]) / 1_000_000
print(f"按“每次输入500、输出300”拍脑袋估 4 次:{naive:.6f} 元,实际已知部分是它的 {total_cost / naive:.1f} 倍")
per_task = total_cost / 3 # r1、r2(含重试)、r3 三个任务完成
print(f"每个完成任务平均 {per_task:.6f} 元;每天 2000 个任务约 {per_task * 2000:.2f} 元(未含失败请求)")
assert unknown == ["r4"] and truncated == ["r2"]
assert abs(cost_yuan({"prompt_tokens": 1_000_000, "completion_tokens": 0}) - 2.0) < 1e-12
实际输出:
r1 输入 1200 输出 300 费用 0.004800 元
r2 输入 1200 输出 512 费用 0.006496 元
r2-retry 输入 1250 输出 640 费用 0.007620 元
r3 输入 4800 输出 280 费用 0.011840 元
已知合计:输入 8450,输出 1732,费用 0.030756 元,输出占费用 45%
被截断:['r2'];缺少 usage:['r4']
按“每次输入500、输出300”拍脑袋估 4 次:0.013600 元,实际已知部分是它的 2.3 倍
每个完成任务平均 0.010252 元;每天 2000 个任务约 20.50 元(未含失败请求)
拍脑袋估算差了一倍多,原因都能在记录里找到:输入不只是用户那句话,还有系统提示和历史消息(这份虚构记录里,r3 的 4800 代表带上了很长的对话历史);被截断的 r2 白花了一次钱,重试又付一次;超时的 r4 没有 usage,脚本把它列为“未知”而不是记作 0,是否计费要拿平台账单核对。缓存命中、思考 token、图片等输入的计价各平台不同,有这些用量时按价目表分别加列,不要并进同一个单价。
在自己的平台上做一次对照(练习方案(本站未实跑))
这一节需要你自己的模型平台账号,会产生少量费用;本站没有替你运行,下面没有任何实测结果。按 OpenAI 兼容格式准备请求体,模型名换成你账号里可用的:
{
"model": "<你账号里可用的模型名>",
"messages": [
{"role": "system", "content": "你是学习助手。只输出一句中文建议,不超过30个字,不加解释。"},
{"role": "user", "content": "我今天只有20分钟,刚学完JSON解析,接下来学什么?"}
],
"temperature": 0.2,
"max_tokens": 60
}
依次做三组:temperature 0.2 发送 5 次;只把 temperature 改成 1.2 再发 5 次;恢复 0.2,把 max_tokens 改成 8 发 1 次。每次新开请求,记录输出全文、finish_reason、prompt_tokens、completion_tokens。预期:高温度那组措辞更分散;max_tokens 为 8 时 finish_reason 应为 length;5 次的 prompt_tokens 应几乎相同。
两点要按平台文档核对(2026-10 核对官方页面):百炼文档写 temperature 取值 [0, 2)、top_p 取值 (0, 1],并建议两者只设置其中一个;max_tokens 被标为即将废弃,推荐改用 max_completion_tokens,且对多数模型只限制回答、不含思考过程;你的平台若也这样推荐,把请求体里的字段名换掉即可,数值不变。其他平台或推理模型可能不支持调温度,发请求前先查文档。
常见失败与修改
回答写到一半停住。 先看 finish_reason:是 length 就是撞了上限。不要直接把上限调到很大,先在提示里限定长度和格式,例如“分三条列出,每条不超过40字”,再把上限设为预期长度的 1.5 倍左右留余量;仍被截断说明任务本身该拆成多步。
高温度下 JSON 偶尔解析失败。 结构化输出、抽取、分类这类有标准答案的任务,用低温度,并按JSON 实操对每次返回做解析与字段校验;校验失败最多重试一两次,并把失败计入统计,不要无限重试。
温度设 0,结果仍然不完全一样。 不要在产品里承诺“完全可复现”。需要比较两版提示时,用同一组固定题各跑多次,按评测实操记录通过率,而不是挑一次好看的。
估算远低于账单。 逐项检查:系统提示和历史是否计入输入;重试、截断、超时请求是否漏算;是否有思考 token、缓存、图片等单独计价项;单价是否用了过期价目。从 usage 日志重算一遍,与账单的差额写进记录,原因不明就标“未知”。
同时调了温度和 top-p,说不清谁起作用。 一次只改一个参数,其余保持不变,每组至少重复 5 次再比较。
保存和使用结果
在 token-lab 目录保留四个脚本,另建 sampling-log.csv,列为:日期、模型名、temperature、top_p、max_tokens、输出、finish_reason、prompt_tokens、completion_tokens。cost.py 里的单价换成你核对过的价目,并在注释里写上核对日期。以后改提示或换模型时,先用这张表重跑一遍,再决定参数。
自测与答案
- 一段 100 字的中文,能否断定是 100 个 token?不能,计数取决于具体分词器,以平台计数或 usage 为准。
- temperature=1.5、top_p=0.8 时,概率很低的词还可能出现吗?在第二步的例子里不会,它们已被 top-p 剪掉;只调高温度而 top_p=1 时会。
- 输出看起来完整,finish_reason 却是 length,算成功吗?不算,按截断处理,检查是否缺内容。
- 一次请求超时没有 usage,估算时记 0 可以吗?不可以,标为未知并核对账单。
交付物是四个脚本的实际输出、一张参数对照记录和一份标明单价来源的成本估算。玩具模型的数字只说明机制,不代表任何真实模型的表现。
来源与内容说明
AIScope 中文手册 · AIScope 编辑
本篇为本站原创学习内容,引用资料保留参考来源。
收录:2026年10月19日
适用范围:尚未标注,请结合原文与实际环境核对
人工核验:尚无核验记录