我把大模型当成一个稳定的函数写进了自动化流程,结果同样的输入每次跑出的结果都不一样、测试时灵时不灵,我对着这种飘忽不定排查了大半天的复盘
这是一个让我对"大模型到底是什么"这件事,认知被彻底重塑的故事。我做了一个自动化处理流程,中间有一步,是调用大模型(LLM):给它一段固定的文本,让它抽取出几个结构化字段返回。我在开发时测了几遍,结果都对,就放心地接进了流程。可上线后,怪事来了:同样的一段输入文本,这次跑,抽出来是 A;过一会再跑,抽出来却是 A';再跑一次,又变成了 A''——结果每次都不完全一样!有时字段顺序变了,有时多了一句解释,有时某个值的措辞不同。这导致我下游依赖这个结果做精确匹配的逻辑,时对时错、时灵时不灵;我写的单元测试,更是今天过、明天挂,飘忽得让人抓狂。
我一开始以为是我的代码有并发 bug、或者 prompt 写得有歧义,改了半天毫无起色。直到我静下心来,去理解大模型的工作原理,才终于揭开真相,补上了我对 AI 一个最根本的认知漏洞:问题的核心,是我从一开始,就用错了"心智模型"——我把大模型,当成了一个像 add(1, 2) 永远返回 3 那样的"确定性函数(deterministic function)";可大模型,本质上,根本不是一个确定性函数,而是一个"概率性的生成模型"。它生成文本的方式,是每生成一个词(token),都先算出"下一个词"在整个词表上的概率分布,然后,从这个分布里,按概率"采样(sample)"出一个词来。而只要这个"采样"过程带有随机性(由一个叫 temperature 温度的参数控制,默认通常大于 0),那么,即使输入完全相同,它每一步采样到的词,也可能不同;一步不同,后面就步步不同,最终,整个输出,就千差万别。这,就是我那个流程"同样输入、每次结果不同"的根本原因!我这才痛彻地明白:大模型的输出,天生就带有不确定性,这不是 bug,而是它作为概率生成模型的本质特征。把一个"本质上会给出不同答案"的东西,当成一个"每次都给相同答案"的函数,去构建需要稳定、可复现的系统,从一开始,就注定要翻车。用好 LLM 的前提,是先接受并尊重它的"不确定性",再用工程手段去约束和驾驭它,而不是天真地假设它会像传统代码一样,稳定可靠。
故障现场:把 LLM 当确定性函数,依赖它返回稳定结果
我把这个"结果飘忽"的现场,用代码摊开给你看:
# ✗ 灾难: 把 LLM 当成确定性函数, 依赖它每次返回一模一样的结果
def extract_fields(text):
resp = llm.chat(
model="some-llm",
messages=[{"role": "user", "content": f"从下面文本抽取姓名和金额:\n{text}"}],
# ✗ 没设 temperature → 用默认值(通常 0.7~1.0), 采样带随机性!
)
return resp.content # ✗ 直接拿自由文本去下游做精确匹配
# 下游(✗ 假设 LLM 输出永远一致):
result = extract_fields(doc)
if result == "姓名: 张三, 金额: 100": # ✗ 文本稍有不同就匹配失败
do_something()
# 单元测试(✗ 断言固定输出):
def test_extract():
assert extract_fields(SAMPLE) == "姓名: 张三, 金额: 100" # ✗ 今天过, 明天挂
# 为什么每次结果不同?
# - LLM 生成 = 逐 token 预测"下一个词的概率分布", 再从分布采样。
# - temperature > 0 → 采样有随机性 → 同样输入, 每次采样出的词可能不同。
# - 一个 token 不同, 后续全跟着变 → 整段输出千差万别。
# - 默认 temperature 往往不是 0 → 默认就是"不确定"的!
# 现象: 同输入跑多次:
# "姓名: 张三, 金额: 100"
# "姓名:张三 金额:100元"
# "抽取结果如下: 张三, 100" ← 措辞/格式/顺序都可能变
# 根因: 把概率性生成模型当确定性函数用, 依赖它返回稳定一致的结果。
看着这段代码,我才算彻底想明白了这场"结果飘忽"的根源。问题的核心,是我把一个概率性的生成模型,当成了确定性函数来用:我没设 temperature(于是用了默认值,通常是 0.7~1.0,采样带随机性),还直接拿它返回的自由文本,去下游做精确匹配,甚至在单元测试里断言它等于一个固定字符串。为什么结果每次都不同?因为 LLM 的生成方式,是逐个 token 地预测"下一个词的概率分布",再从这个分布里采样;只要 temperature > 0,采样就有随机性,同样的输入,每次采样出的词都可能不同;而一个 token 一变,后续就步步跟着变,整段输出便千差万别。更关键的是:默认的 temperature 往往不是 0,也就是说——它默认就是"不确定"的!于是,同样一段输入,跑多次,你会得到"姓名: 张三, 金额: 100""姓名:张三 金额:100元""抽取结果如下: 张三, 100"……措辞、格式、顺序,都可能变。归根结底:把概率性生成模型当确定性函数用,依赖它返回稳定一致的结果,这,就是飘忽的根源。
第一件事:搞懂 LLM 为什么天生不确定
定位到根源,我必须把"LLM 为什么不确定"这件事,从原理上彻底吃透:
LLM 的本质: 逐 token 采样的概率生成模型, 天生不确定
# LLM 是怎么生成文本的?
# 1. 给定前文, 算出"下一个 token"在整个词表上的概率分布。
# 例: 下一个词 P(张)=0.4, P(李)=0.3, P(姓名)=0.2, ...
# 2. 从这个分布里"采样"出一个 token(不一定选概率最高的)。
# 3. 把采到的 token 接到前文后面, 重复 1~2, 直到结束。
# temperature(温度): 控制采样的随机性
# - temperature = 0: 贪心/确定 → 永远选概率最高的 token → 接近确定性。
# - temperature 越高(如 0.7~1.5): 分布越平滑 → 越可能采到低概率词 → 越随机、越有创意。
# - 默认值通常 > 0 → 默认就是随机采样 → 同输入不同输出!
# 其他影响随机性的参数:
# - top_p / top_k: 限制采样范围(只从概率前几名里采)。
# - seed: 部分 API 支持固定随机种子, 提高可复现性(但不绝对保证)。
# 为什么 temperature=0 也不"绝对"确定?
# - 浮点运算、并行计算顺序、模型服务端版本变化、负载均衡到不同实例
# → 可能仍有微小差异。所以 LLM 只能"尽量复现", 不能"绝对复现"。
# 关键认知: 不确定性是 LLM 的特性, 不是 bug。
# - 它本就是为"生成多样、自然的语言"而设计的。
# - 别指望它像 add(1,2)=3 那样, 永远给你一字不差的相同输出。
# 正确心智: 把 LLM 当"会给出合理但可能不同答案的智能助手",
# 而不是"输入相同输出必相同的纯函数"。
# 核心: LLM 是逐token采样的概率生成模型, temperature>0 时天生不确定;
# 这是特性不是bug, 不能当确定性函数依赖其稳定输出。
原理终于清晰了。LLM 是怎么生成文本的?——给定前文,它先算出"下一个 token"在整个词表上的概率分布(比如下一个词 P(张)=0.4、P(李)=0.3……),然后从这个分布里"采样"出一个(不一定选概率最高的),接到前文后面,再重复,直到结束。而 temperature(温度),正是控制采样随机性的旋钮:temperature = 0,是贪心——永远选概率最高的 token,接近确定性;temperature 越高,分布越平滑、越可能采到低概率词,越随机、越有创意;而默认值通常大于 0,所以默认就是随机采样、同输入不同输出!影响随机性的还有 top_p/top_k(限制采样范围)、seed(固定随机种子,提高可复现性)。但要注意:即使 temperature=0,也不"绝对"确定——浮点运算、并行顺序、服务端版本、负载均衡到不同实例,都可能带来微小差异;所以 LLM 只能"尽量复现",不能"绝对复现"。由此,我刻下一个关键认知:不确定性,是 LLM 的特性,不是 bug——它本就是为"生成多样、自然的语言"而设计的;别指望它像 add(1,2)=3 那样,永远给你一字不差的相同输出。正确的心智,是把 LLM 当成"会给出合理、但可能不同答案的智能助手",而不是"输入相同输出必相同的纯函数"。
第二件事:正解——降低不确定性,并约束输出
搞懂了原理,正解就清晰了:一方面用参数尽量降低不确定性,另一方面用工程手段约束并校验输出,不再天真地依赖它"自觉一致"。
# ✓ 正解: 降低随机性 + 结构化约束 + 校验, 把不确定性关进笼子
import json
def extract_fields(text):
resp = llm.chat(
model="some-llm",
messages=[
# ✓ 用 system 明确要求只输出 JSON, 给定固定 schema
{"role": "system", "content": "只返回 JSON, 格式: {\"name\": str, \"amount\": number}, 不要任何多余文字"},
{"role": "user", "content": f"从文本抽取姓名和金额:\n{text}"},
],
temperature=0, # ✓ 关键: 温度设 0, 走贪心, 尽量确定
seed=42, # ✓ 若 API 支持, 固定 seed 提高可复现
# response_format={"type": "json_object"}, # ✓ 若支持, 强制 JSON 输出
)
# ✓ 校验: 把不确定的自由文本, 解析成确定的结构, 解析失败就重试/兜底
try:
data = json.loads(resp.content)
assert "name" in data and "amount" in data # ✓ 校验必需字段
return data
except (json.JSONDecodeError, AssertionError):
return retry_or_fallback(text) # ✓ 失败有兜底, 不让脏数据流入下游
# 下游(✓ 依赖"结构化的字段", 而不是"自由文本的字面值"):
data = extract_fields(doc)
if data["amount"] > 0: # ✓ 用解析后的结构化值判断, 而非字符串精确匹配
do_something()
# 三层防御:
# 1. 降随机: temperature=0 (+ seed) → 尽量让输出稳定。
# 2. 约结构: 要求 JSON + 给 schema (+ response_format) → 缩小输出空间。
# 3. 校验+兜底: 解析校验, 失败重试/降级 → 不信任, 只验证。
# 核心: temperature=0 降随机 + 强制结构化输出 + 解析校验兜底,
# 把 LLM 的不确定性约束在可控范围, 别裸依赖它的自由文本。
修复的方向,是三层防御、层层设防。第一层,降随机:把 temperature 设为 0(走贪心、尽量确定),若 API 支持再固定 seed,提高可复现性。第二层,约结构:用 system 提示明确要求只输出 JSON、并给定固定的 schema,若模型支持 response_format={"type":"json_object"} 就强制 JSON 输出——这相当于把输出的"自由度"大幅缩小,让它"就算措辞会变,但结构必须一致"。第三层,校验+兜底:拿到结果后,解析成确定的结构(json.loads)、校验必需字段;解析或校验失败,就重试或降级兜底,绝不让脏数据流入下游。而下游逻辑,也要相应改造:依赖"解析后的结构化字段值"(data["amount"] > 0),而不是"自由文本的字面精确匹配"。归根结底:temperature=0 降随机 + 强制结构化输出 + 解析校验兜底,把 LLM 的不确定性,约束在可控范围内;不信任它的自由文本,只信任你校验过的结构。
第三件事:哪些场景该用 LLM、哪些场景不该裸用
这次踩坑也让我反思:不是所有环节,都适合直接把 LLM 接进去。我总结了一套判断:
LLM 选型判断: 看任务对"确定性/正确性"的要求有多高
# LLM 擅长 & 适合(它的不确定性可接受甚至是优点):
# - 内容生成: 写文案、摘要、润色、翻译(多样性是优点)。
# - 语义理解: 意图识别、情感分析、分类(给定有限选项)。
# - 模糊任务: 没有唯一标准答案的开放问题。
# - 辅助/草稿: 由人类最终把关的场景。
# LLM 不该"裸用"(需强约束 + 校验, 或干脆别用):
# - 精确计算: 算账、算数 → 让它生成代码/调用工具去算, 别让它"心算"。
# - 确定性查询: 查数据库能解决的, 别问 LLM(它可能"幻觉"编造)。
# - 关键决策: 涉及钱/权限/安全的判定 → 必须有规则校验兜底。
# - 要求 100% 一致复现的流程 → LLM 本质做不到绝对一致。
# 用 LLM 的工程原则:
# 1. 缩小它的"发挥空间": 给选项让它选(分类), 别让它自由发挥(生成)。
# —— "把金额抽出来" 不如 "判断金额是否>100, 只回 yes/no"。
# 2. 结构化输出 + 校验: 永远解析+校验, 别信任自由文本。
# 3. 关键逻辑用代码做, LLM 只做它擅长的"理解/生成"那一环。
# 4. 把 LLM 当"不可靠的外部服务": 要超时、重试、降级、兜底。
# 关键认知: LLM 是强大的"模糊问题求解器", 不是"精确计算器"。
# 扬长(理解、生成)避短(精确、确定), 才能把它用好。
# 核心: 按任务对确定性的要求选型; 精确/关键场景别裸用LLM,
# 要么缩小发挥空间+校验兜底, 要么用代码/工具替它做精确的那部分。
这套判断,让我对"该在哪里用 LLM"有了清醒的边界感。LLM 擅长且适合的,是那些"不确定性可接受、甚至是优点"的任务:内容生成(写文案、摘要、翻译,多样性反而好)、语义理解(分类、意图识别)、模糊的开放问题、以及由人最终把关的辅助/草稿。而它不该"裸用"的,是那些要求精确、确定的场景:精确计算(算账,应让它生成代码/调工具去算,别让它"心算");确定性查询(数据库能查的别问它,免得它幻觉编造);涉及钱/权限/安全的关键决策(必须有规则校验兜底);以及要求 100% 一致复现的流程(它本质做不到绝对一致)。由此提炼出几条工程原则:其一,缩小它的发挥空间——"把金额抽出来"不如"判断金额是否>100,只回 yes/no"(给选项让它选,胜过让它自由发挥);其二,永远结构化输出 + 校验;其三,关键逻辑用代码做,LLM 只做它擅长的"理解/生成"那一环;其四,把 LLM 当成"不可靠的外部服务",要超时、重试、降级、兜底。归根结底:LLM 是强大的"模糊问题求解器",不是"精确计算器";扬长避短,按任务对确定性的要求选型——精确/关键场景别裸用,要么缩小发挥空间加校验,要么用代码替它做精确的那部分。
下面这张图,是这次"结果飘忽"的认知纠正与解法:
第四件事:temperature 等关键参数的取值对照
修复时我把 LLM 这几个影响输出的关键参数,按场景整理成一张对照表,不再靠默认值"裸奔"。
| 参数 | 作用 | 低值场景 | 高值场景 |
|---|---|---|---|
| temperature | 采样随机性 | 0:抽取/分类/要稳定 | 0.7~1.2:创意写作/头脑风暴 |
| top_p | 核采样, 限累积概率范围 | 低(如0.1):更聚焦确定 | 高(如0.9):更多样 |
| seed | 固定随机种子 | 固定值:追求可复现 | 不设:每次都新鲜 |
| max_tokens | 最大输出长度 | 小:控成本/防啰嗦 | 大:长文生成 |
| response_format | 强制输出格式 | json_object:要结构化 | 不限:自由文本 |
这张表,把我从"所有参数都用默认值"的盲区里拽了出来。最该刻进脑子的,是 temperature 和场景的对应关系:做抽取、分类、需要稳定可复现的任务,就温度设 0、追求确定;做创意写作、头脑风暴,才把温度调高、要多样性。其余参数同理:top_p(核采样)调低更聚焦、调高更多样;seed 固定可提高复现性;max_tokens 控制长度与成本;response_format 设 json_object 强制结构化。它们共同的启示是:这些参数,是你"驾驭"LLM 行为的方向盘;用默认值,等于放任它自由发挥;而按你的任务目标,显式地把它们调对,才是把 LLM 从"玄学"用成"工程"的第一步。
第五件事:测试 LLM 应用,不能用传统的精确断言
这次事故里,"单元测试今天过明天挂"也给我上了一课:测试 LLM 应用,需要一套全新的思路。我把它梳理清楚了。
| 测试维度 | 传统精确断言(✗) | LLM 应用应该怎么测 |
|---|---|---|
| 输出内容 | assert out == "固定字符串" | 断言结构/关键字段/范围, 而非逐字相等 |
| 稳定性 | 跑一次就信 | 同输入跑多次, 看结果是否都满足约束 |
| 正确性 | 只比对字面 | 语义评估: 用规则/另一个LLM当裁判打分 |
| 边界/异常 | 不测 | 构造脏输入, 验证解析失败能正确兜底 |
| 回归 | 固定快照 | 维护评测集(eval set), 跑通过率而非全等 |
这张表,彻底重塑了我对"怎么测 LLM"的理解。核心转变,是从"断言逐字相等"转向"断言满足约束":既然输出措辞天生会变,就别再去比对固定字符串,而是去验证"结构对不对、关键字段在不在、值在不在合理范围"。稳定性也要换种测法:同一输入跑多次,看结果是否都满足约束(而不是跑一次就信);正确性,要做语义评估——用规则、或另一个 LLM 当裁判打分;边界,要构造脏输入、验证解析失败能正确兜底;回归,则要维护一个评测集(eval set)、跑"通过率",而不是固定快照全等比对。它给我的最大启发是:当你的系统里引入了一个"概率性"的组件,你的测试哲学,也必须从"确定性的精确验证",升级为"概率性的统计评估"——你要保证的,不再是"它每次都给出那个标准答案",而是"它足够高概率地、给出满足要求的答案"。
第六件事:要把 LLM 接进系统时,我现在会怎么决策
现在,每当我准备把一个 LLM 调用,接进某个系统环节,脑子里都会过一遍这张决策图——核心就一个问题:这个环节,能容忍"答案会变"吗?
这张图的灵魂,是那个必问的问题:这个环节,能不能容忍"答案每次会变"?如果能(生成类任务、或有人最终把关),那直接用、甚至适当调高 temperature 要多样性;如果不能(要稳定、要精确),就先降随机(temperature=0 + seed),再追问:能不能把输出约束成结构化?能,就强制 JSON + schema、解析校验、失败兜底;涉及精确计算/查询的,就让 LLM 生成代码或调工具去做,别让它直接算。再追问:是关键决策吗(涉及钱/权限)?是,就必须加规则校验或人工复核兜底,绝不裸信 LLM。最后,贯穿始终的两条:把 LLM 当不可靠的外部服务(超时、重试、降级);用评测集统计通过率来保证质量,而不是精确断言。
我立下的几条规矩
这场"同输入每次结果不同"的事故,换来了我做 AI 应用时,刻进骨子里的几条铁律:
- LLM 是概率生成模型,不是确定性函数。同输入可能不同输出是它的本质特性,不是 bug,别用确定性的假设去依赖它。
- 要稳定就 temperature=0(+seed)。抽取、分类、关键流程一律降随机;要创意才调高温度。
- 永远结构化输出 + 解析校验。强制 JSON+schema,拿到就解析校验,失败重试/降级,绝不裸信自由文本。
- 缩小它的发挥空间。能给选项让它选,就别让它自由生成;"判断是否>100回yes/no"胜过"把值抽出来"。
- 精确的事交给代码,LLM 只做理解/生成。算账、查询别让它心算/瞎编,让它生成代码或调工具。
- 关键决策必须有校验兜底。涉及钱、权限、安全,LLM 的判断之上一定要有规则或人工复核。
- 测试用统计评估,不用精确断言。多次跑看是否都满足约束,维护评测集跑通过率,别断言逐字相等。
附:一段"用统计而非精确断言"测 LLM 的示例
说一千道一万,不如给一段能落地的测试代码。下面这段,把"测 LLM 应用"该有的样子,具体写了出来:
import json
# ✓ 不断言"逐字相等", 而是"多次运行都满足约束"
def test_extract_is_robust():
sample = "客户张三, 本次消费金额 100 元"
results = [extract_fields(sample) for _ in range(5)] # ✓ 同输入跑 5 次
for data in results:
# ✓ 断言结构, 而非字面: 字段在不在、类型对不对、值合不合理
assert isinstance(data, dict)
assert "name" in data and "amount" in data
assert isinstance(data["amount"], (int, float))
assert data["amount"] == 100 # 关键值可断言(已降随机+结构化)
assert data["name"] == "张三"
# ✓ 用"评测集 + 通过率"做回归, 而非单点全等快照
EVAL_SET = [
{"input": "张三消费100元", "expect_name": "张三", "expect_amount": 100},
{"input": "李四付了 200", "expect_name": "李四", "expect_amount": 200},
# ... 几十上百条覆盖各种表述
]
def test_eval_pass_rate():
passed = 0
for case in EVAL_SET:
data = extract_fields(case["input"])
if data.get("name") == case["expect_name"] and data.get("amount") == case["expect_amount"]:
passed += 1
rate = passed / len(EVAL_SET)
# ✓ 要求通过率达标(如 95%), 而非 100% 全等 —— 接受概率系统的现实
assert rate >= 0.95, f"通过率 {rate:.2%} 低于阈值, 模型/prompt 可能退化"
# ✓ 脏输入要能正确兜底, 不崩、不污染下游
def test_dirty_input_fallback():
data = extract_fields("一段完全无关的乱七八糟文本@#$%")
assert data is not None # 有兜底返回(而不是抛异常/返回脏数据)
# 核心: 测 LLM 用"多次跑看是否都满足约束 + 评测集通过率 + 脏输入兜底",
# 把"确定性的精确验证"升级为"概率性的统计评估"。
这段测试,是我现在写 LLM 应用的标准测试范式。它和传统测试,有三个根本不同:其一,同一输入跑多次,断言每次都满足约束(结构对、字段在、值合理),而不是跑一次、断言它等于某个固定字符串;其二,用评测集 + 通过率做回归——维护几十上百条覆盖各种表述的用例,要求通过率达标(比如 95%),而不强求 100% 全等(这是对"概率系统现实"的坦然接受);其三,专门测脏输入,验证它能正确兜底、不崩、不污染下游。这套范式背后,是一次彻底的观念升级:当系统里住进了一个"概率性"的灵魂,我们衡量它的标尺,也必须从"它是否每次都精确无误",变成"它是否足够高概率地、稳健地满足我们的要求"。这,正是我想用这段代码,留给每一个正在拥抱 AI 的工程师的最后一句话:别用旧世界的尺子,去丈量新世界的造物;学会与"不确定性"共处,用统计和约束去驾驭它——这,才是 AI 工程真正的成熟。
写在最后
回头看,这场由"把 LLM 当确定性函数"引发的、结果飘忽不定的事故,真正教给我的,是一个比"设 temperature"本身更根本的道理:用好任何一项新技术的前提,是先建立起一个关于"它到底是什么"的、正确的心智模型;心智模型错了,你写的每一行代码,都是建立在沙地之上。我之前所有的痛苦,都源于一个根本性的错配:我用着面向"确定性机器"(传统代码,add(1,2) 永远等于 3)养成的全部直觉和习惯,去对待一个"概率性的智能体"(大模型,它给的是"一个"合理答案,而非"那个"标准答案)。这种错配,让我在它身上,寻求一种它本质上无法提供的"绝对一致性",自然处处碰壁。所以,拥抱 AI 时代,最重要的不是学会调几个参数,而是完成一次思维方式的"范式转移":从"我命令机器,它精确执行"的确定性思维,转向"我引导智能体,它概率性地协作,而我负责约束、校验和兜底"的概率性思维。真正会用 AI 的人,既懂得欣赏它"能给出多样、智能的答案"这份强大,也始终清醒于它"不保证每次都对、都一致"这份局限,并用扎实的工程手段,在这份强大与局限之间,搭起一座可靠的桥。先理解它是什么,再决定怎么用它——这,是我用一次"结果飘忽"的崩溃,换来的、关于 AI 工程最朴素、也最深刻的领悟。如果这篇复盘,能让你在下一次调用大模型之前,先想一句"它这次会和上次一样吗",那我对着那些飘忽的结果熬的这大半天,就值了。
—— 别看了 · 2026