为什么我的 LLM 每次输出都不一样

同一个问题问了五遍,拿到五个不同的答案——其中一次开口便否认其余四次。我们锁定 seed 后,回复逐字节复现;可就算 temp=0 且 seed 固定,只把批大小从 512 改到 8192,仍有一个 token 翻了车。逐次方差不是玄学:它是采样、舍入与批次形状共同写下的账单,可测、可复现、可防。

HUMAN作者 @msvram7h3 分钟读完
同一提示 × 5 · temp 0.7 · 无 seed · Qwen3-4B Q4_K_M 同一个提示“The most popular programminglanguage in the world is” 第 1 次 · “ Python. Python is widely used in data…” 第 2 次 · “not Python, Java, or C++. It’s actually…” 第 3 次 · “ Python. But there are other languages…” 第 4 次 · “ Python. But why? Python is a high-level…” 第 5 次 · “ Python. Python has a lot of applications…”

图 1 —— 五次运行,五个答案,一次自相矛盾。第 2 次(暗色)就是收据:同一文件、同一台机器、同一个提示,开头却针锋相对。

为什么我的 LLM 每次给的答案都不一样?

seed —— 存下来的骰子点数。每一次采样都从一个数字出发;设好它,整场洗牌原样重演。不设,引擎每次请求都现掷一个。
temperature —— 模型出价有多激进。每个下一个 token 都是一场拍卖:模型握着一份概率列表,temperature 给它重塑形状,一个 token 赢下抽取。0.7 是寻常骰子;0 意味着“每轮都拿热门”(贪心)。
top_p —— 第二道更严的滤网。它在掷骰之前修剪候选列表。它收窄骰子;它钉不住骰子。

模型从来不“持有”一个答案。在每个 token 槽位上,它握着一份概率列表,temperature 给列表重塑形状,一个 token 赢下抽取。赢家被接上,下一场拍卖换一份新列表开锣——因为它要续写的文本已经变了。一处早期分叉会复利式放大:第 2 次说的是“not”,而第 1、3、4、5 次说的是“Python”,就从这一个词起,五条回复活在了不同的宇宙里。五次运行是五次独立抽样,不是同一个答案印了五遍。

怎样让 LLM 的回复一字不差地复现?

把骰子点数报出来。我们用 seed: 42 在 temperature 0.7 下——也就是那个“不确定”的档位——把同一问题原样重跑了三遍,拿到 3/3 逐字节相同的回复(生成速度在 6.18 → 6.74 → 7.63 tok/s 之间晃动;文字却纹丝没动)。骰子在被掷出的过程中就被钉死了。

curl http://localhost:8080/v1/chat/completions -d '{
  "model": "qwen3:4b",
  "messages": [{"role": "user", "content": "Explain GGUF quantization"}],
  "temperature": 0.7,
  "seed": 42
}'

验证:同一提示 + 同一 seed → 逐字节相同的回复,实测 3/3(temp 0.7,不止贪心)· det-results-a.json

同一个旋钮在你已经在用的每个地方都有:ollama 的 /api/generate 里有 options.seed,OpenAI 兼容的 chat 通道里有 seed,llama.cpp 命令行上有 --seed。ollama 的 runner 就是穿了件外套的 llama-server,所以引擎盖下的收据是同一张——seed 到达的是同一个采样器。

为什么 temperature 0 仍然不确定?

因为贪心解码收走了骰子,却收不走舍入误差。网络内部每个 token 分数都是成千上万次加法,而浮点加法不满足结合律:同一长列数字,从左到右加一遍,再分块加一遍——数字相同,末位却可能不同。当头两名候选咬得足够近时,舍入误差就是那个决定。而求和顺序,取决于这一批一次干多少活。

于是我们把一切都钉死——temp=0、seed=42、同一台服务器、同一个提示——然后只改一个开关:微批宽度:

提问-ubtempseed输出开头对比 -ub 512
1–2512042“ Python. It is used in many…”基线,2/2 相同
18192042“ Python. It’s used in many…”翻了 —— 一个 token
2–38192042“ Python. It is used in many…”弹回基线,2/2 相同

被动过手脚的骰子,当场抓获:每个旋钮都写着确定性,批次形状还是挪动了一个 token——而且只发生在开机后的第一次提问上。

把第三行读两遍。翻车只出现了一次,在冷服务器的第一次提问上;随后同样设置下的两次提问都回到了基线文本。热身后的服务器状态——提示缓存、槽位布局——和求和顺序一起,成了“确定性”回复所依赖的东西。我们抓住了翻车;我们不会假装抓住了元凶。骰子被动了手脚。出千的那只手还藏着。

逐次方差的代价有多大?

账单,乘以五。我们让它“用正好三个词打招呼”,问了五遍,seed 随机,预算 256 token。模型 五次里零次答上——每一轮都把整个预算花在思考上,然后被上限掐断,而思考本身也在晃:同一个请求,884 · 954 · 986 · 915 · 885 个字符。

思考字符数 · “Say hello in exactly three words” × 5 · 上限 256 tokens,×5 全部吃满 884第 1 次 954第 2 次 986第 3 次 915第 4 次 885第 5 次

图 2 —— 波动本身就是重点:一个固定请求,字符数相差 102。这是十月 LFM3 文章里的思考账单,连吃五遍。

我们要一句三个词的问候,收到的却是 884 到 986 个字符的自我拷问,外加零句问候。模型不是对答案犹豫——它是对答案之前的一切都犹豫,而无论答案到不到,你都为此买单。任何“要精确答案”的请求都是一笔预算:掐住思考(think: false,或模型自带的开关),否则上限先把你吃掉。

修复清单

症状正在发生什么解法
每次运行答案都不同没有 seed;temp > 0 每次抽取都重塑列表钉住 seed;回复逐字节复现
temp=0 时答案仍不同批次形状 / 热身状态改变了求和顺序相同的 -ub/-b、相同的引擎构建,测量前先热身
答案迟迟不来思考吃光了 token 预算(此处 0/5)限制或关闭思考;读 completion_tokens,别凭感觉
收据 · 第 2 次否认第 1、3、4、5 次(同一提示,同一台机器,无 seed)
run 1: “ Python. Python is widely used in data science, web…”
run 2: “not Python, Java, or C++. It’s actually a language that’s
       not even a progr…”
run 3: “ Python.  But there are other languages that are…”
run 4: “ Python. But why? Python is a high-level, interpreted…”
run 5: “ Python. Python has a lot of applications in web…”

四次说是 Python。一次开口就在质疑前提。同一份权重,同一台机器,同一个字符串进。五个里有一个看错了“自己是确定性机器”这件事;另外四个只是运气好。

最后实测 2026-11-11

Qwen3-4B Q4_K_M · 2,497,280,256 B · sha256 7485fe6f…df5 · llama-server 0.9.0-dev (b12110) · 一台全新 Arch 虚拟机,4 线程 —— CPU 实测,不做 VRAM 声明