本地模型输出在 token 中间被截断(Ollama / llama.cpp)

本地模型生成到一半停在单词或汉字中间,没有 EOS、没有报错。逐一排查 num_predict 上限、按显存推算的 num_ctx 默认值、stop 序列、代理缓冲与 UTF-8 字节切割。

你在 Ollama 或 llama.cpp 上跑本地的 Llama 3.1 8B 或 Qwen2.5,模型生成一段回复后,在单词中间戛然而止 —— 比如停在 The recommended approach is to use Docke —— 没有结束符(EOS token)、没有报错、看不出任何异常,客户端就是不再收到数据了。

最快的修法: 先看结束原因(finish reason)。Ollama 的 /api/generate 响应里看 done_reason;任何 OpenAI 兼容客户端(/v1、vLLM、llama-server)里看 choices[0].finish_reason。如果是 length,说明撞到了 token 上限 —— 调大 num_predict / max_tokens(或把 num_predict 设为 -1),截断就消失了。如果是 stop 但文字明显没写完,那通常不是真正的结束:可能是上下文窗口被填满、stop 序列在正文中间被命中、代理缓冲了流,或者你的客户端解码了一个没拼完整的 UTF-8 字符。(有一个例外值得知道:截至 2026 年 6 月,Ollama 的 OpenAI 兼容端点有一个已知 bug,输出实际被 max_tokens 截断时它仍可能返回 finish_reason: stop —— 所以拿不准时,交叉核对一下原生的 done_reason。)按下面的分支逐条往下排查。

你属于哪一类

症状特征最可能的原因跳转
done_reason / finish_reasonlengthnum_predict / max_tokens 上限被撞原因 1
不论长短都在同一处截断;stop;prompt 很长num_ctx 太小,上下文被填满原因 2
停在 \n\n### 或代码围栏后面stop 序列在正文中间被命中原因 3
直连 :11434 正常,经过代理就截断反向代理缓冲了流原因 4
末尾字符乱码,常见于中文/CJK 字符;仅流式UTF-8 字符跨 token 分块被切开原因 5
同一 prompt 每次都在同一处截断,确定性出现GGUF 损坏或激进量化的解码 bug原因 6

常见原因

按命中率从高到低排列。

1. num_predict / max_tokens 在单词中间撞上限

最常见的原因,也是唯一会返回 length 结束原因的那一类。分词器会把单词切成子词片段 —— Docker 可能被分成 ["Do", "cker"]。如果上限恰好落在 Do 这个 token 上,输出就以 Do 结尾,读起来像是在单词中间被截断,尽管上限其实是被精确遵守了的。

有两个坑会让人困惑:

  • Ollama 的原生端点(/api/generate/api/chat)会静默忽略 OpenAI 风格的 max_tokens 参数。你必须改在 options 对象里传 num_predict(截至 2026 年 6 月,原生端点支持 max_tokens 别名仍只是一个功能请求)。
  • 只有 OpenAI 兼容端点 /v1/chat/completions 接受 max_tokens,并在内部映射成 num_predict

怎么判断: 结束原因是 length,而且你的上限是个整数(128、256、512、1024)。Ollama 文档里 num_predict 的默认值是 128,所以在原生调用上没设上限时,常常正好停在第 128 个 token。把上限调大,截断就消失了。

2. 生成过程中 context window(num_ctx)被填满

这是默认值改动最近的一个原因,所以那些写在改动之前的文章和工具最容易踩到它。截至 2026 年 6 月,新版 Ollama 不再使用固定的 2048 token 默认值 —— 它按可用显存来定 num_ctx:大致是显存低于 24 GiB 用 4K 上下文,24–48 GiB 用 32K,48 GiB 及以上用 256K(可用 OLLAMA_CONTEXT_LENGTHnum_ctx 选项显式设置)。旧版本、第三方封装以及许多 Modelfile 仍然把它钉在 2048。当 prompt 加上已生成的 token 触及 num_ctx 时,生成就会停下 —— 而 Ollama 是静默丢弃最早的那部分 prompt token 来腾空间,而不是报错,所以回复可能在句子中间结束,且 done_reason: stop

怎么判断: 数一下 prompt 的 token 数,再加上你的 num_predict。如果两者之和逼近 num_ctx,那就是它。运行 ollama show <model>(或在 llama-server 上看 --ctx-size)查看当前生效值;在长 prompt 场景下那里写着 2048 就是铁证。

调大 num_ctx 时有一点要注意:KV 缓存会随上下文窗口增大,一旦它撑爆显存,Ollama 会把它溢出到 CPU 内存,吞吐可能从 50–100 tok/s 掉到 2–5 tok/s。按 prompt 实际需要来调,不要一上来就拉满。

3. stop 序列在正文中间被命中

"\n\n""###""<|eot_id|>" 这样的 stop 序列,命中了回复中间的文本,而不是末尾。在流式模式下,服务端会在命中处立刻切断 —— 往往就切在代码块里(每个三反引号围栏处)或一个段落换行处。

怎么判断: 检查请求里每一个 stop 序列。在 Ollama 上用 ollama show <model> --modelfile | grep -i stop 以及你 API 调用里的 stop 字段;在 llama-server 上看 --stop 标志。用不带任何 stop 序列的同一 prompt 重跑一遍;如果这次写完了,那就是某个 stop 字符串触发的。

4. 反向代理缓冲了流

如果 Ollama 或 llama-server 跑在 nginx、Caddy 或负载均衡器后面,而没有配置流式传输,代理可能会缓冲响应,并在超时时一次性刷出,从而在 token 中间切断了流。模型其实生成了完整答案,只是没有完整送达客户端。

怎么判断: 绕过代理,向 Ollama 端口(http://127.0.0.1:11434)发同一个请求。如果直连能写完、但走代理的路径会截断,那问题就在代理上。

5. UTF-8 字符跨流式分块被切开

在流式模式下,服务端一次发出一个 token,而单个 Unicode 字符(任何 CJK 字、emoji 或带重音的字母)可能横跨 token / 字节边界。如果客户端逐块独立解码字节 —— 而不是缓冲到一个完整字符再解码 —— 就会丢弃或弄乱末尾那半个字符,看起来就像在 token 中间被截断。这正是为什么 CJK 输出比 ASCII 更容易截断和乱码:一个汉字是 3 个 UTF-8 字节,所以分块正好结束在字符中间的概率,大约是 1 字节 ASCII 字母的三倍。

怎么判断: 把同一个调用切换为非流式("stream": false)。如果截断消失了,那 bug 在你客户端的流解码逻辑里,而不在模型里。

6. GGUF 损坏或激进量化的解码 bug

部分下载或损坏的 GGUF 会让 llama.cpp 后端在损坏的张量边界处停止解码。另外,非常激进的量化(IQ2/IQ3 档)在某些 token 序列上可能吐出一个杂散的 NULL 或垃圾字节,被下游读取方当成流结束。

怎么判断: 截断是确定性的 —— 同一 prompt、同一 seed,每次都在完全相同的位置停下,与 prompt 内容无关。换成同一模型的 Q4_K_MQ8_0 版本重跑;如果截断消失了,那之前的文件或量化档就是问题所在。

最短修复路径

Step 1:检查结束原因,然后调大 token 上限

# OpenAI 兼容端点(适用于 Ollama /v1、vLLM、llama-server)
import openai
client = openai.OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

resp = client.chat.completions.create(
    model="qwen2.5:7b",
    messages=[{"role": "user", "content": "Explain Docker networking in detail"}],
    max_tokens=2048,            # 调到远高于预期长度
)
print("finish_reason:", resp.choices[0].finish_reason)  # 想要 "stop",不要 "length"
print(resp.choices[0].message.content)

在 Ollama 的原生 API 上,max_tokens 会被忽略 —— 要在 options 里设 num_predict。用 -1 表示一直生成到 EOS(或到上下文用完),-2 表示填满整个上下文窗口:

curl -s http://localhost:11434/api/generate -d '{
  "model": "llama3.1:8b",
  "prompt": "Explain Docker networking in detail",
  "stream": false,
  "options": { "num_predict": -1, "num_ctx": 8192 }
}' | python3 -m json.tool | grep -E '"(done_reason|response)"'

done_reason: length 确认是撞了上限;done_reason: stop 表示模型吐出了 EOS(那就从 Step 2 往后看)。

Step 2:把 num_ctx 设得足够大,让上下文永远填不满

# llama-server
./llama-server -m models/llama-3.1-8b-instruct-Q4_K_M.gguf \
  --ctx-size 8192 --n-predict 2048

# Ollama:按调用单独设置(推荐)……
curl -s http://localhost:11434/api/generate -d '{
  "model": "llama3.1:8b", "prompt": "your long prompt",
  "options": { "num_ctx": 8192, "num_predict": 2048 }
}'

# ……或给整个服务全局设置(重启 ollama serve 生效)
OLLAMA_CONTEXT_LENGTH=8192 ollama serve

ollama show llama3.1:8b 确认这个值真的生效了 —— 这里仍然写着 2048 是「调了 num_ctx 却好像没用」最常见的原因。

Step 3:审查并去掉 stop 序列

ollama show llama3.1:8b --modelfile | grep -i stop
# OpenAI 兼容调用:先禁用 stop 序列以隔离问题
resp = client.chat.completions.create(
    model="llama3.1:8b",
    messages=[{"role": "user", "content": "Explain Docker networking"}],
    max_tokens=2048,
    stop=None,                   # 原本是 ["###", "\n\n"]——逐个加回来
)

如果设 stop=None 后输出能写完,就逐个把 stop 字符串加回来,找出罪魁。永远不要把三反引号围栏或裸 \n\n 放进散文或代码输出的 stop 里。

Step 4:对比直连与代理,再修复代理

# 直连 Ollama,绕过任何代理
curl -N -s http://127.0.0.1:11434/api/generate \
  -d '{"model": "llama3.1:8b", "prompt": "Count from 1 to 50", "stream": true}'

如果直连能写完、走代理就截断,那就在代理上关掉缓冲。以 nginx 为例:

location /api/ {
    proxy_pass http://127.0.0.1:11434;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    chunked_transfer_encoding on;
}

Step 5:流式字节缓冲到每个 UTF-8 字符完整再输出

# 永远不要逐块单独解码原始字节——累积到能解码为止
import sys

def safe_stream(byte_chunks):
    buf = b""
    for chunk in byte_chunks:
        buf += chunk
        try:
            text = buf.decode("utf-8")   # 只有落在字符边界上才会成功
            sys.stdout.write(text); sys.stdout.flush()
            buf = b""
        except UnicodeDecodeError:
            continue                      # 半个字符:再等更多字节

如果你用的是官方 OpenAI 或 ollama-python SDK,这一步已经替你处理好了 —— 这个 bug 只会出现在手写的字节读取代码里。

Step 6:截断是确定性的就校验 GGUF 完整性

# 不跑推理,直接检查元数据
python3 -c "
import gguf
r = gguf.GGUFReader('models/llama-3.1-8b-instruct-Q4_K_M.gguf')
print('Tensors:', len(r.tensors))
print('Arch:', r.fields['general.architecture'])
"

如果文件不完整,重新下载,并把它的 SHA256 与 Hugging Face 仓库文件卡上的值核对;解码稳定性上优先选 Q4_K_MQ8_0,而不是 IQ2/IQ3 量化。

如何确认已修复

  1. 结束原因现在读出 stop(Ollama 原生看 done_reason/v1finish_reason),并且文字在一个完整句子处结束。
  2. 用大约 2 倍于你最长真实 prompt 的输入重跑一遍 —— 回复依然能写完。
  3. 对于中文或 emoji 输出,末尾字符正确渲染,没有替换符()。
  4. 如果你修的是代理,那么走代理的路径现在和直连 :11434 的路径逐字节一致。

预防建议

  • 永远记录结束原因。stop 是健康的;length 表示是你自己设小了上限;一个没写完的 stop 则意味着上下文、stop 序列、代理或解码器有问题。
  • num_predict 设为 -1,或至少设为你最长预期回复的 2 倍 —— 永远别信框架默认值(Ollama 原生是 128)。
  • num_ctx 设为(最长 prompt + 最长回复 + 约 512 余量)。别假设还是旧的 2048 默认值;截至 2026 年 6 月它是按显存推算的、因机器而异,所以要显式钉死。
  • 别把 \n\n### 和代码围栏放进 stop 序列;任何 stop 字符串先单独测试。
  • 在任何 Ollama 前面的反向代理上,部署前先设好 proxy_buffering off 和 300s 读超时。
  • 把流式字节缓冲到 UTF-8 字符边界,或者用一个已经替你做好这件事的 SDK。
  • 大体积 GGUF 下载后校验 SHA256,生产解码优先用 Q4_K_M/Q8_0 而非 IQ2/IQ3

常见问答 (FAQ)

Q:模型到底是到了 EOS,还是被截断了? A:在 Ollama 的 /api/generate 上看 done_reasonstop = 真正的 EOS/stop token,length = num_predict 用尽。在任何 OpenAI 兼容端点(/v1、vLLM、llama-server)上看 choices[0].finish_reasonstoplength 含义相同。

Q:Ollama 忽略我的 max_tokens——为什么? A:因为原生的 /api/generate/api/chat 端点不认识 max_tokens;它们用 options 对象里的 num_predict。只有 /v1/chat/completions 接受 max_tokens(并映射成 num_predict)。截至 2026 年 6 月,原生端点的 max_tokens 别名仍是一个开着的功能请求。

Q:Ollama 聊天界面里输出干净,走 API 就截断——为什么? A:交互界面会一直生成到 EOS,而你的 API 调用在给输出设上限——通常是 OpenAI SDK 的某个默认 max_tokens,或者原生调用上的 num_predict: 128。把上限显式设大(2048 以上或 -1)。

Q:为什么我的中文(或 emoji)输出只在最末尾乱码,英文却不会? A:一个 CJK 字符是 3 个 UTF-8 字节,所以一个流式分块结束在字符中间的概率,大约是 1 字节 ASCII 字母的三倍。把字节缓冲到能干净解码为止(Step 5),或者改用 "stream": false

Q:finish_reasonstop,但句子明显被砍断了。 A:通常是模型过早吐出了 EOS——常见原因有 num_ctx 被填满(调大它,Step 2)、stop 序列在正文中间被命中(Step 3)、chat template 不匹配注入了过早的 <|eot_id|> / <|im_end|>,或者代理切断了流(Step 4)。还有一个上游 bug 要排除:截至 2026 年 6 月,Ollama 的 /v1 端点在输出实际被 max_tokens 截断时也可能返回 finish_reason: stop,所以在追查其他原因前,先交叉核对原生 done_reason(或者干脆把上限调大,看文字会不会变长)。

Q:能不能续写被截断的回复,而不是整段重生成? A:可以——把那段不完整的文本作为 assistant 消息追加进去,再发一个简短的 continue user 轮次。不过每次续写都会让 prompt 变长,最终还是会撞上 num_ctx;一开始就把上限调大更省心。

相关阅读

标签: #local-llm #ollama #排查