本地模型输出在 token 中间被截断

本地 LLM 在输出中间突然停止,响应不完整,有时在单词或汉字中间截断。定位 max_tokens 限制、EOS token 误触、流式传输缓冲三类根因并给出修复方法。

使用 Ollama 0.4 调用 Qwen2.5-72B-Instruct 生成一篇文章,输出进行到第三段中间突然停止,没有任何错误信息,API 返回的 finish_reasonlength 而不是 stop。换成直接用 llama.cpp 调用,输出在某个汉字的第一个字节处截断,终端出现乱码。这两种场景都是截断,但原因完全不同:前者是 max_tokens 到达上限,后者是 UTF-8 多字节字符在字节流中间被切割。

常见原因

1. max_tokens / num_predict 设置过小

最常见的截断原因。API 调用时 max_tokens 默认值因客户端不同而异:Ollama 的 /api/generate 默认 num_predict 为 128,OpenAI 兼容 API 可能是 256 或 512。生成稍长的内容就会触发上限。

怎么判断:检查 API 响应中的 finish_reason 字段,若为 length(而非 stop),确认是 max_tokens 触发了截断;查看调用代码中 max_tokens/num_predict/max_new_tokens 的设置值。

2. 聊天模板中 EOS token 被错误触发

某些模型的聊天模板在特定模式(如 markdown 代码块结束、特定标点符号序列)下会提前生成 EOS token。Llama-3 的 <|eot_id|> 和 Qwen2 的 <|im_end|> 在某些量化版本或错误模板下可能在内容中间被触发。

怎么判断:使用 --verbose--debug 模式运行,观察停止位置前的 token 序列;若看到 EOS/EOT token ID 出现在预期之外的位置,就是这个原因。

3. 流式传输中 UTF-8 字符在字节边界被切割

llama.cpp 的流式模式(--stream)按 token 输出,而一个 Unicode 字符可能对应多个 token 或者 token 在 UTF-8 字节中间,下游应用直接截断字节流而未做字符边界对齐会产生乱码和截断感。

怎么判断:将流式输出改为非流式(等待完整响应),若截断消失,问题在流式传输的缓冲处理逻辑。

4. context window 被填满导致生成提前终止

输入 prompt 过长,加上已生成的 token,超过了模型的上下文窗口。后续生成会被强制终止,有时是在句子中间。

怎么判断:计算 prompt token 数 + 预期输出 token 数,是否超过 --ctx-size(llama.cpp)或 context_length(Ollama)设置的值;若 prompt 超过 80% 的上下文窗口,生成空间不足。

5. stop 序列配置错误匹配到正文内容

在 API 调用中设置了 stop 参数(停止词),若 stop 序列在正文中自然出现,生成会在该位置提前停止。例如将 "\n\n" 设为 stop 序列,但正文使用段落间双换行格式,每段都会触发截断。

怎么判断:检查 API 调用参数中的 stop 字段;临时移除 stop 参数重新测试。

6. 量化模型的 token 解码 bug

某些激进量化版本(如 IQ3_XXS)在解码特定 token 序列时存在 bug,生成到特定字符组合时输出 NULL 字符或垃圾字节,下游接收端截断处理后表现为输出中断。

怎么判断:换成 Q4_K_M 或 Q8_0 版本重复同一 prompt,若截断消失,是量化版本 bug。

最短修复路径

Step 1:检查 finish_reason 并提高 max_tokens

import openai

client = openai.OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama"
)

response = client.chat.completions.create(
    model="qwen2.5:72b",
    messages=[{"role": "user", "content": "写一篇 500 字的文章"}],
    max_tokens=2048  # 从默认值提高到足够大
)

print("finish_reason:", response.choices[0].finish_reason)
# 应该是 "stop",不是 "length"
print(response.choices[0].message.content)

Step 2:Ollama API 设置足够大的 num_predict

# Ollama 原生 API
curl http://localhost:11434/api/generate -d '{
  "model": "qwen2.5:72b",
  "prompt": "写一篇 500 字的文章",
  "options": {
    "num_predict": 2048,
    "num_ctx": 8192
  },
  "stream": false
}'

Step 3:llama.cpp 命令行增加生成长度限制

# -n 指定最大生成 token 数,-1 表示不限制(直到 EOS)
./llama-cli -m model-Q4_K_M.gguf \
  -p "写一篇 500 字的文章" \
  -n -1 \
  --ctx-size 8192 \
  --temp 0.7

Step 4:处理流式传输的 UTF-8 截断问题

import sys

def stream_safe_decode(chunks):
    """安全处理流式 UTF-8 输出,避免在字符中间截断"""
    buffer = b""
    for chunk in chunks:
        if hasattr(chunk.choices[0].delta, 'content') and chunk.choices[0].delta.content:
            buffer += chunk.choices[0].delta.content.encode('utf-8')
            # 找到最后一个完整 UTF-8 字符的位置
            try:
                decoded = buffer.decode('utf-8')
                sys.stdout.write(decoded)
                sys.stdout.flush()
                buffer = b""
            except UnicodeDecodeError:
                # 继续等待更多字节
                pass

Step 5:清除或修正 stop 序列

# 移除可能误触发的 stop 序列
response = client.chat.completions.create(
    model="qwen2.5:72b",
    messages=[{"role": "user", "content": prompt}],
    max_tokens=2048,
    stop=None  # 明确设为 None 禁用 stop 序列
)

预防建议

  • 对于长文生成任务,始终显式设置 max_tokens 为任务预期输出的 1.5 倍以上,不依赖默认值。
  • API 调用后始终检查 finish_reason,若不是 stop 则记录警告日志并提示用户内容可能不完整。
  • 使用流式传输时,在应用层做完整 UTF-8 字符重组,而不是直接截断字节流显示。
  • 避免将常见标点符号或空白字符设为 stop 序列,若必须使用,测试正文内容是否会意外触发。
  • 生成前估算 prompt token 数,确保 prompt + 预期输出不超过上下文窗口的 90%。
  • 对于代码生成场景,将代码块结束符(如 ```)从 stop 序列中移除,避免代码被截断。
  • 定期对比不同量化版本的输出,发现截断问题时优先换 Q4_K_M 测试以排除量化 bug。

常见问答 (FAQ)

Q: finish_reasonstop 但输出明显不完整,句子没有结尾,怎么解释? A: 模型生成了 EOS token,认为输出完成了,但实际上是模型提前认为任务结束。这通常是 chat template 问题或 system prompt 影响了模型的完成判断。尝试在 system prompt 中加入”请完整回答,不要中途停止”的指令。

Q: Ollama 的默认 num_predict 是多少? A: Ollama 0.4 对 /api/generate 端点的默认 num_predict 是 -1(不限制),但 OpenAI 兼容接口 /v1/chat/completions 的默认 max_tokens 是 4096。两个接口的默认值不同,注意区分。

Q: 输出截断后能否续写? A: 可以,将截断的输出作为 assistant 消息放入历史,再发送一个”请继续”的 user 消息。但多次续写会累积 prompt 长度,最终还是会触及上下文窗口限制。

Q: 为什么中文比英文更容易出现截断显示乱码? A: 中文字符是 UTF-8 3字节编码,一个汉字被切割的概率是英文字符的 3 倍。在流式传输下尤为明显。使用支持 UTF-8 流式处理的客户端库(如 Python 的 httpx 配合 charset-normalizer)可以解决。

相关阅读

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