Ollama 模型上下文扩展实战:把 Qwen3.8-27B 从 256K 扩到 1M

背景

最近在本地一台 Jetson AGX Orin 设备(64GB 统一内存)上用 Ollama 跑 Qwen3.8-27B(IQ4_NL 量化,约 16GB)。这代模型原生支持 256K 上下文,官方文档明确说可以通过 YaRN 技术扩展到 1M。

我很好奇这个扩展到底怎么落地——不是用 vLLM 或 SGLang,而是 Ollama 这种”开箱即用”的推理框架。折腾了一晚上,踩了不少坑,记录一下完整过程。

先交代一下环境:一台懒猫算力仓(Jetson AGX Orin 开发套件),Ollama 跑在 Docker 容器里(懒猫 AI Pod 预装的 Ollama 服务,镜像名带 jetson-ollama 字样),模型文件挂载在 SSD 上。

一、前置知识:YaRN 是什么

YaRN(Yet another RoPE extensioN)是一种位置编码扩展技术,可以让训练时上下文有限的模型在推理时处理更长的文本,不需要重新训练。

Qwen3.8-27B 官方 README 给出了明确的 YaRN 配置:

  • rope_type: yarn
  • factor: 4.0(从 262144 扩展到 1048576,正好 4 倍)
  • original_max_position_embeddings: 262144
  • partial_rotary_factor: 0.25
  • rope_theta: 10000000

也就是说,模型权重不用动,只要让推理引擎在加载时启用 YaRN 缩放即可。

二、核心思路:改 GGUF 元数据

Ollama 底层是 llama.cpp,而 llama.cpp 会读取 GGUF 文件头部的元数据来决定 rope scaling 行为。所以思路很直接:

  1. 复制一份现有的 GGUF 文件
  2. 往元数据里添加 rope.scaling.* 字段(type=yarn, factor=4.0, original_context_length=262144)
  3. context_length 字段改成 1048576(这一步很关键,原因见下文)
  4. ollama create 基于新 GGUF 创建新模型

坑 1:gguf-set-metadata 不能新增字段

llama.cpp 官方提供了 gguf-set-metadata 工具,但它只能修改已存在的字段,新增字段会报 Field not found。而 rope.scaling.* 在原 GGUF 里根本不存在。

解决办法:用 Python 的 gguf 库写脚本,重新生成整个 GGUF。流程是读取原文件 → 复制所有元数据字段和 tensor → 添加新字段 → 写出新文件。16GB 的文件必须流式处理,不能一次性载入内存。

坑 2:tokenizer 数组复制会翻车

tokenizer.ggml.tokens 这类字符串数组,GGUF 里每个元素是”长度前缀 + 内容”两部分。Python 库读出来之后,如果索引取错,会把长度前缀当成字符串内容,导致 tokens 数量翻倍(248320 → 496640),加载时报 Index out of array bounds for toktypes

还有更隐蔽的:tokenizer 里有些字节不是合法 UTF-8,如果先 decode 再 encode 会直接崩,必须全程按原始字节处理。

坑 3:tensor shape 会被反转

GGUF 的 tensor shape 存储顺序和 numpy 的布局相反。写脚本时如果传错 shape,加载时会报 check_tensor_dims: wrong shape。这个坑不亲自踩一遍很难发现,因为大部分 tensor 恰好不受影响,只有特定结构的(比如 SSM 卷积层)会暴露出来。

坑 4:ollama 会把 num_ctx 钳制在 context_length

新模型创建好之后,我用 API 请求 num_ctx: 1048576,结果 ollama ps 显示 CONTEXT 还是 262144——ollama 的调度器会参考 GGUF 里的 context_length 字段做上限钳制。

解决办法:把 GGUF 的 context_length 元数据也改成 1048576。这相当于告诉 ollama”这个模型支持 1M”,加上 YaRN 字段告诉 llama.cpp”1M 时用 YaRN 缩放”,两边配合才完整。

改完重新 ollama createollama show 显示的 context length 就变成 1048576 了。

三、内存才是真正的瓶颈

模型建好了,但实测 1M 上下文加载时进程反复被杀。看日志:

1
2
llama_kv_cache: size = 34816.00 MiB (1048576 cells, 16 layers, 1/1 seqs)
llama_kv_cache: K (q8_0): 17408.00 MiB, V (q8_0): 17408.00 MiB

KV cache 直接吃了 34GB!加上模型权重 14GB 和各种缓冲,总需求 55GB 以上,逼近 61GB 物理内存上限。

这台设备上还跑着一个 earlyoom 守护进程(可用内存低于 5% 就主动杀进程),所以进程不是被 OOM killer 杀的,而是被 earlyoom 提前终止的——日志里表现为 signal: terminated 而不是 killed,排查起来更隐蔽。

解决方案:KV cache 量化

KV cache 精度从 q8_0 降到 q4_0,内存直接减半:

1
2
llama_kv_cache: size = 18432.00 MiB (1048576 cells, 16 layers, 1/1 seqs)
llama_kv_cache: K (q4_0): 9216.00 MiB, V (q4_0): 9216.00 MiB

总需求降到约 39GB,1M 上下文稳定运行,100% GPU,ollama ps 显示 CONTEXT=1048576。

q4_0 KV 对输出质量的影响很小(KV cache 量化是业界公认的低风险优化),但为了不动懒猫算力仓预装服务管理的原始 compose 文件,我用了 Docker Compose 的 override 机制:

1
2
3
4
services:
ollama:
environment:
- OLLAMA_KV_CACHE_TYPE=q4_0

override 文件放 compose 同目录自动加载,原始文件一行没改,以后懒猫算力仓系统升级也不会冲突。

四、别忘了视觉能力

Qwen3.8-27B 是多模态模型,原模型在 Ollama 里有主模型 GGUF + 两个 CLIP projector(F32 和 F16 版本各一个,tensor 结构完全一样)。

只 FROM 主模型创建的新模型会丢掉 vision 能力。Modelfile 里要把 projector 一起挂上:

1
2
3
4
FROM <模型存储路径>/blobs/sha256-<主模型>
FROM <模型存储路径>/blobs/sha256-<projector1>
FROM <模型存储路径>/blobs/sha256-<projector2>
TEMPLATE {{ .Prompt }}

挂上之后 ollama show 的 Capabilities 里就有 vision 了,传图测试识别正常。

五、最终结果与限制

最终模型参数:

  • 架构 qwen35,27.3B 参数,IQ4_NL 量化,约 16GB
  • 上下文:YaRN 扩展,context length = 1048576(1M)
  • 视觉:支持(CLIP projector 已挂载)

实测数据:

上下文 KV cache 总内存 状态
256K(原生) ~10GB ~26GB 正常
512K q8_0 17GB ~36GB 正常
1M q8_0 34GB ~55GB+ earlyoom 杀进程
1M q4_0 17GB ~39GB 正常

一个现实限制:1M 上下文 + 视觉编码同时使用会 OOM(视觉编码需要额外显存缓冲)。实测 512K + 视觉没问题,1M 下纯文本没问题。如果需要 1M 长上下文 + 视觉,得换更大的内存设备,或者把 KV 再压一压。

六、补充:外部客户端接入的隐藏限制

模型调好之后,还有个小插曲。另一台机器把懒猫算力仓的 Ollama 配成了 OpenAI 兼容的推理端点,跑 Hermes 之类的 AI 客户端。客户端在上下文较长时会做”压缩”操作(把历史对话总结成摘要),压缩请求一次会发几万 tokens。

结果压缩一直失败,报错:

1
request (24697 tokens) exceeds the available context size (8192 tokens)

排查后发现是第二个”数字陷阱”:Ollama 的 OpenAI 兼容 API 默认上下文是 OLLAMA_CONTEXT_LENGTH 环境变量,默认 8192。客户端走 /v1/chat/completions 时不传 num_ctx 参数(OpenAI 格式里没有这个字段),Ollama 就用 8192 兜底——哪怕模型本身支持 1M。

而我们前面改了 GGUF 的 context_length 元数据,客户端从模型信息里读到”这个模型支持 1M”,于是放心发大请求,结果被服务器的默认值卡住。

修复同样走 compose override,把默认上下文调大:

1
2
3
4
5
services:
ollama:
environment:
- OLLAMA_KV_CACHE_TYPE=q4_0
- OLLAMA_CONTEXT_LENGTH=131072

调成 128K 是因为:q4_0 KV 下 128K 大约 2GB/seq,内存无压力,同时覆盖了客户端压缩请求的规模(几万 tokens 级别)。如果以后客户端对话更长,再往上调即可;显式传 num_ctx 的请求不受这个默认值影响,仍然可以到 1M。

这个坑的启示:GGUF 元数据说”模型支持多少上下文”是一回事,服务器端实际允许多少是另一回事。Ollama 有两个不同的”上下文数字”:

数字 位置 作用
context_length(GGUF 元数据) 模型文件里 告诉调度器模型上限,决定 num_ctx 钳制值
OLLAMA_CONTEXT_LENGTH(环境变量) 服务器配置 OpenAI 兼容 API 的默认上下文,客户端不传参时兜底

排查这类问题先分清客户端走到的是哪条 API 路径、有没有显式传 num_ctx,再看对应的那个”上下文数字”。

七、总结

这次折腾的核心收获:

  1. Ollama 里做 YaRN 扩展 = 改 GGUF 元数据,不需要动模型权重,rope.scaling.* + context_length 两个字段配合
  2. 16GB 量级的 GGUF 重写必须流式处理,Python 库的 field.parts 布局和字符串数组结构有坑
  3. 大上下文的真正敌人是 KV cache 内存,q8_0 → q4_0 是最有效的调优手段
  4. 嵌入式设备的 earlyoom 会让 OOM 排查更难terminated 不等于崩溃,先查内存守护
  5. Docker Compose override 是改第三方服务参数的安全姿势,不动原始配置,可随时回退
  6. 模型支持多少上下文 ≠ 服务器默认允许多少,GGUF 元数据管 num_ctx 钳制,OLLAMA_CONTEXT_LENGTH 管 API 默认值,两套数字都要对齐

整个过程踩的坑已经整理成内部 skill,下次给其他模型做上下文扩展可以直接复用。

如果对模型上下文扩展感兴趣,可以看看: