ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LLM输出不稳定?Harness层后处理归一化实战

LLM输出不稳定?Harness层后处理归一化实战

非确定性输出的归一化:Harness层后处理

做LLM应用的不确定性,最常被挂在嘴边的就是采样温度、top-p,好像把这两个参数稳住,输出就稳了。直到有一次我排查一个线上事故:同一批prompt、同一个模型版本、同一组参数,只是发版排期差了半天,用户的反馈是“结果和昨天不一样了,而且格式开始飘”,当时第一反应是模型被人动过,查了一圈之后发现根本没动,最后才把锅扣在了一堆看似不起眼的工程细节上。

那次之后我认真做了一件事:在harness层加了一套输出后处理,专门用来归一化非确定性输出。这里说的“非确定性”,不是算法上故意保留的那部分随机性,而是工程实现带来的、本不该出现的差异。把这类差异在harness层消化掉,下游拿到的结果才谈得上稳定、可比、可回归。这篇文章就把当时踩过的坑和这套后处理的实现思路完整拆开讲。

1. 先想清楚:要归一化的到底哪里不确定

1.1 两种非确定性:设计与工程要分开看

LLM输出的非确定性来自两个层面。一个是采样机制本身,比如temperature大于0时每次结果不一样,这属于设计上故意保留的随机性,给足了对话的“生命力”,不需要归一化,也不能归一化;另一种是工程实现引入的差异,例如GPU算子执行顺序、浮点累加误差、批量padding对attention mask的影响、缓存命中状态不一致等等。这类差异有一个共同特点:即使把随机种子固定、把采样参数调成完全一致,输出仍然会抖。

我们团队做的是某企业内部知识库问答系统的推理层,底层接了开源的中等规模模型。当时线上遇到的现象很有意思:用户问同样一句话,只要不问同一个批次的请求,返回的答案一会儿是“74.5%”,一会儿是“74.499999999%”,一会儿是“74.5 %”。这就是典型的浮点路径不同产生的差异,加上模型在生成数字token时对精度极其敏感,一个小数点的形式差异就会让下游的数据模块直接解析失败。

再深挖一点,模型自回归生成时,每一步的logits都依赖之前所有token的路径。如果第一步因为padding mask的形状不同产生了一个微小浮点偏差,这个偏差会随着生成长度逐步累积。短文本还不明显,长文本到了后面可能连答案的主干都换了。

1.2 底层差异如何穿透到最终输出

我做过一次典型的对比实验:固定seed,同一个prompt,batch size分别设为1和4,贪心解码,不做任何后处理。batch size为4的时候,每条prompt为了和其他样本对齐高度,都会被pad到相同长度。表面上padding token被mask掉了,但这种mask是通过给attention score加一个极大的负数实现的,在fp16精度下,极负数加上gradient累积之后,softmax分母的极小差异会衍生出截然不同的采样路径。

更隐蔽的是前缀缓存(prefix cache)这一层。请求如果命中了缓存,decode从缓存断点开始走;没命中的话从prompt第一token开始算。两边的浮点路径天然不一致,导致即使两个请求的prompt完全一样,命中缓存和不命中缓存返回的结果也可能不一样。我在线下复现时发现,这种差异在temperature=0的贪心解码里都能出现。

这也是为什么必须把归一化放在harness层——光靠“统一参数”根本无法消除这类差异,差异是穿透所有参数设置直达最终文本的。

2. 归一化为什么必须放在Harness层

2.1 三种归一化位置的取舍

关于归一化逻辑放在哪一层,我考虑过三个位置:推理引擎内部、harness层、应用侧。

推理引擎内部最彻底,比如在采样算子前强制对齐浮点路径,在attention计算后统一做一次数值规整。但这么做代价极高,引擎是第三方开源组件,改动意味着要维护fork版本,一旦上游升级,所有补丁都要重新适配,回归成本太大。应用侧也能做,但应用侧面对的是最终用户的直接请求,每个调用方对“稳定”的理解不一样,有人希望格式统一,有人希望保留markdown,规范写在应用侧管不住其他人怎么调用。

harness层是推理服务和上层应用之间的编排层,所有请求和响应都必须经过这里。在这里做归一化有三个好处:第一是位置天然适合做“兜底”,不管上游引擎输出什么,harness层都有机会修正;第二是不污染引擎本身,发布和回滚都友好;第三是接口协议稳定,下游应用拿到的一直是同一套规范化的结构,不会因为接入方变了输出格式就崩。

2.2 Harness层后处理的边界

有人会担心,归一化会不会把模型真正“想表达”的内容给改掉。这个担心是有道理的,所以我给后处理定了一条硬性原则:只做形式归一化,不做内容改写。

具体来说,凡是影响下游解析、展示、统计、比较的形式差异,都在后处理范围内。例如JSON输出的键顺序不同、数字精度不同、markdown代码块标记不同、全半角符号混用、换行符不统一、列表符号风格漂移,这些都要处理。而模型生成的语义内容,比如答案的主体文字、观点方向、关键结论,一律不碰。这条边界守不住,归一化就会变成一把杀敌一千自损八百的刀。

我还做了一件事:在后处理前后各打一份日志,把原始输出和处理后的输出同时记录下来。这样既能追溯问题,也能检验归一化规则是否越界——如果某条规则把语义内容也不小心改了,通过对比日志能快速定位是哪条规则干的。

2.3 我们实际用的Harness结构

我在代码里搭了一个非常轻量的后处理管线,核心思路是把“抽取、规范化、重排、校验”四步串起来:

class OutputNormalizer: def normalize(self, raw_output: str) -> str: text = self._extract_content(raw_output) text = self._canonicalize_format(text) text = self._rebuild_structure(text) self._validate(text, raw_output) return text

每一步都有独立的规则模块,方便单独调试。抽取看图、格式化管格式、重建管结构、校验管安全,职责分开后,规则越加越多也能保持可控。

3. 这几类差异是真正坑过我的,逐一说明

3.1 JSON键顺序与缩进差异

模型生成的JSON,键顺序经常不固定。今天返回{"name": "...", "score": 0.97},明天就变成{"score": 0.97, "name": "..."}。如果下游直接拿字符串做hash对比、或者按位置解析字段,就会出现“内容没变,程序觉得变了”的诡异问题。

我在归一化里强制做了canonical JSON处理:键按字典序排序、冒号后不留空格、逗号后不留空格、确保ASCII字符不转义。用Python的话就是:

import json def canonicalize_json(obj): return json.dumps( obj, sort_keys=True, ensure_ascii=False, separators=(',', ':') )

值得注意的坑是ensure_ascii:默认值True会把中文转成\uXXXX,这会让同一个中文内容在字符串层面看起来完全不同。必须置为False。另外,sort_keys对嵌套的dict同样生效,这能保证最终结果的稳定。

3.2 数字精度与格式差异

数字是重灾区。模型在生成数字token时,一个token是“74.5”,下一个token可能是“%”,也可能是“.0%”。还有科学计数法“7.45e+01”和普通计数“74.5”混用的情况。

归一化策略分三步:先尝试把字符串转成float,再把规范化的数字文本提取出来;然后统一精度,例如统一保留1位或按需求保留固定小数位;最后对单位、百分号等后缀做对齐。代码思路如下:

import re from decimal import Decimal, ROUND_HALF_UP def normalize_number(text: str) -> str: text = text.strip().replace(',', '') if text.endswith('%'): value = Decimal(text[:-1]).quantize( Decimal('0.1'), rounding=ROUND_HALF_UP) return f"{value}%" try: value = Decimal(text).quantize( Decimal('0.1'), rounding=ROUND_HALF_UP) return str(value) except Exception: return text.strip()

这里用Decimal而不是float,是因为float在二进制表示下会出现74.499999999这种奇葩值,Decimal配合ROUND_HALF_UP能稳定四舍五入。

3.3 全角半角,空格、换行这些“看不见的敌人”

中英文混排场景下,模型经常一会儿输出全角逗号“,”一会儿输出半角逗号“,”,JSON字段名后面一会儿有空格一会儿没空格,换行符可能是\n可能是\r\n。对用户来说肉眼几乎无差别,但对文本比较、格式解析来说完全是两个字符串。

我的归一化规则里加了一条全角转半角的逻辑,但只针对标点符号,不转换中文内容本身的字符:

FULL_TO_HALF = { ',': ',', '。': '.', ':': ':', ';': ';', '(': '(', ')': ')', '!': '!', '?': '?', '“': '"', '”': '"', '‘': "'", '’': "'" } def normalize_punctuation(text: str) -> str: for full, half in FULL_TO_HALF.items(): text = text.replace(full, half) return text.replace('\r\n', '\n').strip()

换行统一用\n,首尾空格去掉。如果下游是数据库或网页展示,这步能省掉无数无效报警。

3.4 Markdown代码块与标记语言差异

很多模型的输出是带格式的:```开头结尾、前后有语言标记、内部有缩进。如果下游是一个纯文本展示组件,代码块标记会原样显示出来,观感很差;如果下游是一个解析器,标记不一致又会导致整段解析失败。

我做的后处理规则是:检测markdown fence标记,提取代码块内的正文内容,再根据下游需求决定是否保留语言标记。例如当返回文本不需要代码块时,直接把```python替换成空字符串,并把代码块中的内容保留为普通文本。

import re FENCE_RE = re.compile(r'```[a-zA-Z0-9_+-]*\n(.*?)```', re.DOTALL) def extract_code_block(text: str) -> str: match = FENCE_RE.search(text) if match: return match.group(1).strip() return text.strip()

这条规则特别适合用在知识库答案抽取的场景——我只需要答案正文,不需要一堆fence干扰下游展示。

3.5 伴随状态剔除与统一

模型有时候会在正式答案前加一些状态描述,比如“好的,我来分析一下这个问题”、“根据我的理解”、“需要注意的是”。这些语句本身不携带关键信息,但对需要做纯结构解析的下游来说就是噪音。后处理会把这层概述去掉,只保留答案主干;如果需要给用户看“对话感”,再在harness层重新拼接模板。

做这步时一定要谨慎。我只能剥离“状态描述”,不能剥离“结论性语句”。判断标准是:如果去掉这句话,答案的语义是否受影响。不受影响才允许剥离。

4. 在引擎侧做的几个配套确定性保障

4.1 选择确定性解码算子

harness层的归一化只是兜底,不能只靠它。我在推理引擎侧也做了一系列配套工作。第一件事就是把采样路径固定下来。

开源模型的推理代码里,sampling部分通常会调torch.multinomial、F.softmax等算子。这些算子在GPU上有非确定性实现,但很多框架也提供了deterministic开关。我在配置里强制打开确定性模式,并固定torch.manual_seed。这个操作不能简单写一行,要在每次推理前重置seed,同时确保多卡并行时各卡拿到一致的seed。

import torch def setup_deterministic(seed: int): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

benchmark=False要解释一下:cuDNN的benchmark模式会根据输入形状选择最快的卷积实现,速度快,但选中的算子可能随输入尺寸变化而变化,导致同一模型在不同batch size下走不同实现、产生不同浮点结果。关了之后性能略降,但换来的是稳定性。

4.2 消除padding引入的logits噪声

padding是隐藏最深的不确定源。动态batch通常会把同批次请求pad到相同长度,不同批次的pad位置不同,attention mask覆盖的范围不同,softmax计算时的分母也会不同,差异就顺着自回归路径传下来了。

我在harness层做了一个调度调整:按请求的max_new_tokens和输入长度分组,让同批次请求尽可能有相近的输入长度。长度差异太大的请求拆到不同批次里,这样被padding影响的概率就小了。再来,我在后处理中做了一次归一化校验——如果发现同一请求被引擎以不同批次处理时输出明显不一致,就记录下来并触发分批次重算。

这里有个取舍要说明:拆batch会降低吞吐,但因为后处理payload本身不大,实际线上耗时增量可以接受。

4.3 固定随机源与统一批次形状

除了采样算子的seed,还要关注prompt拼接时使用的random函数是否被污染。有一次发现一个同事在构建prompt模板时用了random.choice来选开场白,虽然结果是合法的,但导致同一条prompt在两次请求中的实际输入不同。这种问题后处理完全兜不住,因为输入就不一致了。

后来在harness层加了一个prompt指纹校验:每次构建完prompt,计算一个hash值,如果同一业务请求的hash不一致,直接报错返回。这样能把“污染输入”的问题堵在源头。

5. 常见问题与排查技巧实录

5.1 盲区一:都是贪心解码,为什么还是不一致

这是被问得最多的一个问题。很多人的直觉是,temperature=0,解码就是argmax,应该完全确定。但实际不是。贪心解码过程中的每一步还是要走softmax算概率分布,再取最大概率token。GPU上softmax的实现可能因为数据并行、算子融合策略不同,在最后的浮点比较上产生细微差异。如果两个token的概率差在10的负6次方以下,这个差异就会被放大成不同的选择。

排查时先别怀疑模型,先把同一份输入多跑几次,把softmax输出的具体数值打印出来比较。如果每次的logits最后一位不一样,问题基本就在算子层面。

5.2 盲区二:归一化后,batch=1和batch=8的结果仍然不一致

这通常不是后处理的锅,而是padding对logits_mask的影响已经超出了形式层。归一化只能处理文本形式和结构差异,无法处理因为mask不同导致的语义走向不同。此时需要在harness层强制按“单条独立”或“同形状”方式调度。

我检查后发现,罪魁祸首是长度分组没做彻底。后来在调度逻辑里加了一个更严格的约束:同一批次的输入长度上限差值不能超过8个token,超出就拆分批次。

5.3 盲区三:日志与返回内容不一致

有阵子线上排查问题时发现,日志里的原始输出和用户看到的内容完全对不上。后来发现是后处理改了文本,但日志打的是原始内容,这其实很正常。问题是排查的人不知道后处理做了什么,误以为是引擎bug。

我的解法是让日志同时输出归一化前后的两个版本,并且附上归一化规则ID列表。这样任何人排查时都能快速定位,是引擎变了,还是后处理规则变了,还是下游解析逻辑变了。

5.4 盲区四:归一化不小心把多样性也抹掉了

在做多候选答案时遇到过一种尴尬:为了比较多个生成结果,我把它们全部做了归一化,结果两个本来语义不同的答案因为格式统一后变得难以区分——这不是归一化错,而是我忘记在比较前保留语义层面的差异。

我的建议是:归一化永远只用于“格式比较”和“结构解析”,不用于“语义质量评估”。语义评估必须回到原始输出上进行。后处理流程里加一个开关,有语义评估需求的场景,跳过归一化直接消费原始文本。

6. 最后分享一个调试技巧:用差分脚本快速定位是哪层引入的不确定性

如果线上又出现结果漂移,别急着改规则。先跑差分脚本,把同一个prompt分别经过“引擎直出”、“harness后处理”、“harness+引擎整体链路”三个路径,对比三个路径的输出差异。哪一步开始产生不一致,哪一步就是问题层。

我把这个脚本写成了一个简易工具:

python diff_probe.py \ --prompt "某条线上真实问题" \ --engine-output /tmp/engine.txt \ --harness-output /tmp/harness.txt \ --log-level debug

脚本会在harness层每个子规则前后打印文本快照和文本hash,让我瞬间定位到是哪条归一化规则把文本改了,还是引擎本身就飘了。这个方法救了我很多次,强烈建议有同样困扰的团队都搭一个。

踩过几次坑之后,我现在对“稳定输出”的理解已经变了——不是要求模型每次都一模一样,而是让所有不该变化的层面都稳定下来,把该保留的随机性留给模型自己。harness层后处理就是这最后一道闸门,兜住工程差异,把真实的不确定性还给大家。

RELATED READING

延伸阅读

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