本地部署大模型:输出夹乱码但日志全绿,这类故障怎么定位
📌 本文部分内容由 AI 辅助整理,已经人工核对。文中涉及的第三方 bug 报告,是本人 2026-07-30 通过 GitHub 公开接口取回原文并逐项核对的,非转载、非估算。我手上没有那个级别的 GPU,没有复现过它,文中凡涉及那份报告的内容都注明了「转述」。文中脚本是我自己写的,贴的输出是真跑出来的。
你换了张显卡,或者调了 offload 的层数,或者从 Q5 换到了 Q4。
服务起来了。日志翻到底,没有报错,没有 NaN 警告,没有 assert,进程返回码是 0。
然后你看到输出里夹着一小段乱码。
这时候你怎么判断——是模型答得不好,还是这台机器算错了?
这篇想说的就是这件事。它不是某个模型的问题,是本地部署里一类通用的、而且相当贵的故障:不报错的错。
一、这类故障长什么样
先说清楚它跟"跑崩了"的区别。
跑崩了其实是好事。CUDA error、显存不够、assert 失败——这些都会把你拦下来,你会立刻知道要去修。
麻烦的是另一种:所有你习惯用来判断"跑没跑对"的信号,全是绿的。
- 没有 CUDA 报错
- 没有 NaN / Inf 警告
- 没有 assert,没有崩溃
- 进程正常退出,返回码是 0
唯独输出是坏的。
更麻烦的是它坏的形态。如果是满屏乱码,一眼就看出来了。真正难缠的是这种——我从一份公开的 bug 报告里摘一段真实输出,模型回答的是一句 “hello”:
edReader is? Hmm, letAsc晴天Hello! I'm an AI assistant basedION6TK6T7QY7N5C0U
盯着中间看:Hello! I'm an AI assistant based —— 这半句完全正确、语法通顺。
它前面顶着 letAsc晴天,后面接着 ION6TK6T7QY7N5C0U。
通顺的句子被夹在乱码中间。你扫一眼,第一反应往往是"这模型有点飘",而不是"这台机器的计算路径是错的"。
批量跑的时候这事最致命。 几百上千条输出没人逐条读,坏的那些就这么进了下游。等你发现的时候,已经不知道哪一批是脏的了。
二、一个真实的案例(转述)
上面那段输出来自 llama.cpp 仓库里的一份 issue,编号 #26027。我 07-30 拉接口核对过,它当时还开着,标签是 bug-unconfirmed。
先把边界说清楚,不然就是吓唬人:
① 维护者还没确认它。 bug-unconfirmed 的意思是,它目前是一份单方报告,不是已定性的缺陷。
② 硬件是特定的。 报告者用的是 Blackwell 架构的专业卡(SM120),他自己都说了,不确定是这一代卡专有的,还是更普遍的问题只是恰好被这个模型的参数形状触发。
③ 我没有复现过。 我手上没有那个级别的硬件,也没有两百多 GiB 的显存去跑那一档。上面和下面所有关于这个 case 的描述,都是转述那份公开报告。
所以我不打算在这篇里下"某某有 bug"的结论。我关心的是他排查的方法——那部分跟硬件、跟模型、跟推理框架都没关系,谁都能用。
自己查一眼这份报告的状态,一行命令:
curl -s "https://api.github.com/repos/ggml-org/llama.cpp/issues/26027" \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
print(d['state'], [l['name'] for l in d['labels']], d['updated_at'])"
顺带说个容易踩的:GitHub 上 closed 不等于 merged。 关掉的 PR 里有相当一部分是被否掉的,不是被合的。判断一个修复到底进没进主线,得看 pull_request.merged_at 是不是 null,光看 state 会误判。
三、定位方法:固定所有变量,只动一个
这是这篇里最值钱的部分,也是可以直接抄走的东西。
那位报告者做的事很简单:同一个 prompt(就一句 “hello”)、同样的生成长度、同一个模型文件,只动 offload 到 GPU 的层数(llama.cpp 里是 -ngl):
-ngl | 送上 GPU 的东西 | 结果 |
|---|---|---|
| 0 | 什么都不送,纯 CPU | ✅ 正确 |
| 1 | 只送最后的 output 层 | ✅ 正确 |
| 2 | 再加一层「本来就没参与计算」的层 | ✅ 正确 |
| 3 | 再加一层 —— 第一个真正的 transformer 层上 GPU | 🔴 损坏 |
他的结论下得很克制,我原样转述:
这个边界不是某个"中邪的层号",而是「GPU 上没有任何真实注意力层」到「至少有一个真实注意力层」的这个跳变。
这句话是整份报告里最有价值的一句。它把问题从"某个权重文件坏了"或者"某一层数值溢出",收敛到了那条 GPU 计算路径本身。
这个思路可以套到任何本地部署上:
怀疑输出不对的时候,先把 offload 层数一路降到 0。
如果纯 CPU 那档是对的,那问题就不在模型文件、不在量化档、不在 prompt,
而在 GPU 那条路径上。
一句话概括:先证明"不用加速器时是对的",再逐级加回去,看哪一档开始变坏。
这个套路不只对显卡有效。换 backend、换推理框架、换量化档、开关某个 flag——任何一次变更,都可以用同一个办法二分。前提只有一个:固定住其他所有变量,一次只动一个。
还有一件事值得单独说:他主动堵死了几个更省事的解释。怀疑是某个已知的内核 bug,他就换一条计算路径重新编译,照样复现;怀疑是即时编译的问题,他就编成真实机器码不走 JIT,照样复现;怀疑是另一个已知的长 prompt 崩溃,他就用十几个 token 的短 prompt,照样复现。
排除掉的东西,往往比发现的东西更能说明问题。 一份 bug 报告可不可信,看的就是这个。
四、把它变成自动的:五类形态特征
二分定位有个前提——你得先知道输出坏了。
人眼盯着看是不行的,尤其批量场景。所以我写了个小脚本,把"看起来不对劲"这件事变成可以自动跑的检查。
我选了五类有形态特征的损坏。为什么是这五类,得说清楚判据,不然就是拍脑袋:
1)随机字母数字串 —— 形如 ION6TK6T7QY7N5C0U。判据是连续 6 位以上的大写字母数字混合,且字母和数字都得有。纯大写单词(HTTP、JSON)和纯数字(20260730)不算,否则技术文本里误报满天飞。
2)语种突变 —— 英文语境里毫无理由地蹦出一两个汉字。这一类的判据最不好定,我第一版就写错了,后面第六节专门讲。
3)词边界粘连 —— basedION、letAsc 这种小写直接顶大写的断裂。它常常和第一类同时出现,单独看是弱信号,所以权重给得低。
4)退化重复 —— 同一个 n-gram 反复刷屏。这个不一定是计算路径坏了,采样参数没设好也会这样,但都值得看一眼。
5)解码残骸 —— U+FFFD 替换字符、不可打印控制字符。这类基本可以确定不正常。
按命中数加权打分,红色特征权重 3,弱信号权重 1。0 分是没看出问题,超过 3 分就值得去二分定位了。
五、脚本
零依赖,只用标准库,不需要 GPU。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
output_sanity.py —— 捞本地模型输出里的「静默损坏」。
有一类推理故障不崩溃、不报错、不打 NaN 警告,进程正常退出返回码 0,
唯独输出是坏的:通顺的句子里混进一段乱码。批量跑的时候没人逐条看,
坏的那些就这么进了下游。
按五类形态特征打分:随机串 / 语种孤岛 / 词边界粘连 / 退化重复 / 解码残骸。
⚠️ 边界:只能捞**有形态特征**的损坏。模型一本正经胡说八道、算错数、
记错事实,它一概查不出来。定位是冒烟测试,不是质检报告。
只用标准库,不需要 GPU。
python3 output_sanity.py --text "..." # 直接传文本
python3 output_sanity.py --file out.txt # 从文件读
cat out.txt | python3 output_sanity.py # 接管道
python3 output_sanity.py --demo # 跑内置对照样本
"""
import argparse
import re
import sys
import unicodedata
from collections import Counter
# 连续 6 位以上大写字母/数字混合,且字母数字都得有。
# 纯大写单词(HTTP、JSON)和纯数字(20260730)不算,否则误报满天飞。
#
# 左右边界不能用 \b:这类乱码最典型的形态就是直接粘在正常单词屁股后面
# (basedION6TK6T7QY7N5C0U),d 和 I 都是 word 字符,中间不成立词边界,
# \b 会把最该抓的情况整个漏掉。改用不消费字符的断言。
RE_RANDOM = re.compile(
r"(?<![A-Z0-9])(?=[A-Z0-9]*[A-Z])(?=[A-Z0-9]*[0-9])[A-Z0-9]{6,}(?![A-Z0-9])")
# 小写直接顶大写:basedION(后续还是大写)、letAsc(大写后跟小写但不像驼峰)
RE_GLUE = re.compile(r"[a-z]{2,}(?=[A-Z]{2,})|[a-z]{3,}(?=[A-Z][a-z]{2,})")
CJK = r"一-鿿-ヿ가-"
RE_LATIN = re.compile(r"[A-Za-z]")
def ctx(text, start, end, pad=24):
"""截可疑片段的上下文,换行压平便于单行显示。"""
a, b = max(0, start - pad), min(len(text), end + pad)
s = text[a:b].replace("\n", "⏎")
return ("…" if a > 0 else "") + s + ("…" if b < len(text) else "")
def find_random(text):
return [(m.group(), m.start(), m.end()) for m in RE_RANDOM.finditer(text)]
def find_glue(text):
return [(text[m.start():min(len(text), m.end() + 6)], m.start(), m.end())
for m in RE_GLUE.finditer(text)]
def find_switch(text):
"""
语种突变。
判据不是「少数派字符的全局占比」——那个是错的:70 字符的英文里插两个
汉字占比就有 3.8%,任何百分比阈值都会放过去。
真正区分正常混排和损坏输出的是 **CJK 片段长度**:正常混排汉字成段出现,
损坏输出是 1~3 字的孤岛、两边直接顶着拉丁字母(letAsc晴天Hello)。
所以只报「短 CJK 片段 + 紧贴拉丁字母」,成段中文一律不报。
"""
out = []
for m in re.finditer(f"[{CJK}]+", text):
if len(m.group()) > 3: # 成段中文,正常混排
continue
s, e = m.start(), m.end()
left = text[s - 1] if s > 0 else ""
right = text[e] if e < len(text) else ""
# 紧贴拉丁字母才算突变;被空格或标点隔开的短词属于正常引用
if RE_LATIN.match(left or "") or RE_LATIN.match(right or ""):
out.append((text[max(0, s - 4):min(len(text), e + 4)], s, e))
return out
def find_repeat(text, n=5, thresh=4):
"""按字符 n-gram 找退化重复。"""
s = re.sub(r"\s+", " ", text)
if len(s) < n * 2:
return []
grams = Counter(s[i:i + n] for i in range(len(s) - n + 1))
out = [(g, c) for g, c in grams.items() if c >= thresh and g.strip()]
return sorted(out, key=lambda x: -x[1])[:5]
def find_bad_chars(text):
out = []
for i, ch in enumerate(text):
if ch == "�":
out.append(("U+FFFD 替换字符", i, i + 1))
elif unicodedata.category(ch) in ("Cc", "Cf", "Co", "Cs") and ch not in "\n\r\t":
out.append((f"U+{ord(ch):04X} 不可打印", i, i + 1))
return out
# (检测函数, 权重, 图标, 名称)
CHECKS = [
(find_random, 3, "🔴", "随机字母数字串"),
(find_switch, 3, "🔴", "语种突变(短 CJK 孤岛紧贴拉丁字母)"),
(find_glue, 1, "🟡", "词边界粘连"),
(find_bad_chars, 3, "🔴", "异常字符"),
]
def report(text, label=None):
if label:
print(f"\n{'=' * 70}\n {label}\n{'=' * 70}")
print(f" 长度 {len(text)} 字符")
score, lines = 0, []
for fn, w, icon, name in CHECKS:
hits = fn(text)
if not hits:
continue
score += w * len(hits)
lines.append(f" {icon} {name} ×{len(hits)}")
for g, s, e in hits[:3]:
lines.append(f" 「{g}」 ← {ctx(text, s, e)}")
rep = find_repeat(text)
if rep:
score += 2 * len(rep)
lines.append(f" 🟡 退化重复 ×{len(rep)}")
for g, c in rep[:3]:
lines.append(f" 「{g}」 出现 {c} 次")
print()
for l in lines or [" ✅ 五类特征均未命中。"]:
print(l)
print(f"\n 可疑度 {score} → ", end="")
if score == 0:
print("看不出静默损坏的形态特征。")
elif score <= 3:
print("有轻微疑点,建议人工扫一眼。")
else:
print("**高度可疑**。建议二分定位:")
print(" 把 offload 层数逐级降到 0(llama.cpp 是 -ngl),")
print(" 同一个 prompt 每档跑一次,看从哪一档开始变坏——")
print(" 纯 CPU 那档如果是对的,问题就在 GPU 那条计算路径上。")
return score
DEMO = [
("样本 A:一段真实的损坏输出",
"edReader is? Hmm, letAsc晴天Hello! I'm an AI assistant basedION6TK6T7QY7N5C0U"),
("样本 B:正常英文输出(对照)",
"Hello! I'm an AI assistant based on a large language model. "
"I can help you with writing, analysis, math, and coding. "
"What would you like to work on today?"),
("样本 C:正常中英混排(对照,防误报)",
"你好,我是一个大语言模型助手。我可以帮你写代码、分析数据、"
"解释概念。用 vLLM 或 llama.cpp 都可以在本地把我跑起来。"),
]
def main():
p = argparse.ArgumentParser(description="检查模型输出有没有静默损坏")
g = p.add_mutually_exclusive_group()
g.add_argument("--text", help="直接传一段文本")
g.add_argument("--file", help="从文件读")
g.add_argument("--demo", action="store_true", help="跑内置对照样本")
a = p.parse_args()
if a.demo:
for label, txt in DEMO:
report(txt, label)
return 0
if a.text:
txt = a.text
elif a.file:
txt = open(a.file, encoding="utf-8", errors="replace").read()
elif not sys.stdin.isatty():
txt = sys.stdin.read()
else:
p.print_help()
return 1
return 0 if report(txt) == 0 else 2
if __name__ == "__main__":
sys.exit(main())
六、跑给你看
脚本自带三个对照样本,一个坏的两个好的:
python3 output_sanity.py --demo
======================================================================
样本 A:一段真实的损坏输出
======================================================================
长度 75 字符
🔴 随机字母数字串 ×1
「ION6TK6T7QY7N5C0U」 ← …'m an AI assistant basedION6TK6T7QY7N5C0U
🔴 语种突变(短 CJK 孤岛紧贴拉丁字母) ×1
「tAsc晴天Hell」 ← edReader is? Hmm, letAsc晴天Hello! I'm an AI assista…
🟡 词边界粘连 ×2
「letAsc晴天H」 ← edReader is? Hmm, letAsc晴天Hello! I'm an AI as…
「basedION6TK」 ← …lo! I'm an AI assistant basedION6TK6T7QY7N5C0U
可疑度 8 → **高度可疑**。建议二分定位:
把 offload 层数逐级降到 0(llama.cpp 是 -ngl),
同一个 prompt 每档跑一次,看从哪一档开始变坏——
纯 CPU 那档如果是对的,问题就在 GPU 那条计算路径上。
======================================================================
样本 B:正常英文输出(对照)
======================================================================
长度 154 字符
✅ 五类特征均未命中。
可疑度 0 → 看不出静默损坏的形态特征。
======================================================================
样本 C:正常中英混排(对照,防误报)
======================================================================
长度 65 字符
✅ 五类特征均未命中。
可疑度 0 → 看不出静默损坏的形态特征。
样本 C 是专门放的。误报比漏报更能毁掉一个检查工具——动不动就报警的脚本,用两天就没人看了。所以我拿正常的中英混排技术文本反复压。
再压一个最容易误伤的场景:技术文本里全是大写缩写和长数字。
python3 output_sanity.py --text "调用 HTTP API 返回 JSON,模型 ID 是 GLM52,构建号 b10174,日期 20260730,用 CUDA 12.9 和 GGML 后端。"
✅ 五类特征均未命中。
可疑度 0 → 看不出静默损坏的形态特征。
该报的还是得报。退化重复:
python3 output_sanity.py --text "好的好的好的好的好的好的好的好的好的好的"
🟡 退化重复 ×2
「好的好的好」 出现 8 次
「的好的好的」 出现 8 次
可疑度 4 → **高度可疑**。建议二分定位:
接管道,直接怼在推理后面:
llama-cli -m model.gguf -p "hello" -n 30 2>/dev/null | python3 output_sanity.py
返回码:干净是 0,有疑点是 2。可以直接塞进 CI,跑批之前先过一遍。
七、写这个脚本时踩的两个坑
都是真跑之后才发现的,光看代码发现不了。
坑一:正则的 \b 把最该抓的情况漏了
第一版我是这么写的:
RE_RANDOM = re.compile(r"\b(?=[A-Z0-9]*[A-Z])(?=[A-Z0-9]*[0-9])[A-Z0-9]{6,}\b")
看着挺对。跑样本 A,ION6TK6T7QY7N5C0U 没抓到。
原因:它粘在 based 后面。d 和 I 都是 word 字符,中间根本不成立词边界,\b 直接失配。
而"粘在正常单词屁股后面"恰恰是这类乱码最典型的形态——我的正则把最该抓的情况给排除了。改成前后各用一个不消费字符的断言就好了。
坑二:判据本身选错了,不是参数调错了
语种突变这一项,我一开始的思路是"少数派字符全局占比低于 1% 就算突兀插入"。
跑样本 A:75 个字符里两个汉字,占比 3.8%,高于阈值,直接放过。
我第一反应是把阈值往上调。但调到多少?调高了,正常的中英混排全得中招。
停下来想了想——这不是阈值的问题,是判据选错了。 全局占比这个指标,根本区分不了这两种情况。真正的区别在于 CJK 片段的长度分布:
- 正常混排:汉字成段出现,「你好,我是一个大语言模型助手」
- 损坏输出:1~3 个汉字的孤岛,两边直接顶着拉丁字母,「letAsc晴天Hello」
改成只报"短 CJK 片段 + 紧贴拉丁字母",成段中文一律不报。样本 A 抓到了,样本 C 依然干净。
这两个坑合起来,让样本 A 从 2 分(“轻微疑点”)变成 8 分(“高度可疑”)。如果我没真跑,交付的就是一个看起来能用、实际把重度损坏判成轻微疑点的脚本。
顺便说个更朴素的教训:当你发现自己在反复调阈值的时候,先停下来想想是不是判据选错了。 调参数是在原地打转,换判据才是往前走。
八、它查不出什么
这点必须说在前面,不然就是把工具吹成银弹。
它只能捞有形态特征的损坏。模型一本正经地胡说八道、把数字算错、把事实记错——它一概查不出来。那是另一个维度的问题,得靠评测集和人工抽检。
它也不能替你定位根因。它只回答"这段输出看起来不对劲",接下来该干嘛,还是第三节那套二分。
它的定位就是冒烟测试:换了硬件、改了 offload、换了量化档、换了 backend 之后,先跑一遍这个,确认没踩到"不报错的错",再去谈生成质量。
小结
- 本地部署有一类故障不崩溃、不报错、返回码是 0,唯独输出是坏的,所有常规信号全是绿的
- 它最难缠的形态不是满屏乱码,是通顺的句子夹在乱码中间——扫一眼容易当成"模型答得不好"
- 定位方法可以直接抄:固定所有变量只动一个,把 offload 一路降到 0;纯 CPU 那档对了,问题就在加速器那条路径上
- 判断一份 bug 报告可不可信,看它排除掉了什么,而不是看它主张什么
- 脚本只捞有形态特征的损坏,查不了事实错误。当冒烟测试用,别当质检报告
- 写检查工具时,误报比漏报更致命;反复调阈值调不出来的时候,八成是判据选错了
文中那份第三方 bug 报告的编号和查询命令都在正文里,它的状态每天都在变——你看到这篇的时候可能已经修了,也可能被确认成更大范围的问题。以你自己跑出来的为准。
另外我对那位报告者没有任何评价上的意思。相反,那是我这段时间见过的写得最扎实的报告之一:固定变量、逐级二分、主动排除、结论克制。这篇里"方法"那部分的价值,一多半来自他。
更多推荐


所有评论(0)