上下文窗口决定了什么
上下文窗口(Context Window)就是模型一次能”看到”的文本长度上限。Hermes 3 基于 Llama 3.1 架构,原生支持 128K token 的上下文窗口——大约相当于 10 万字中文或一本中等篇幅的小说。
但”支持 128K”和”用好 128K”是两码事。在实际使用中,你会遇到几个问题:
- 越长的上下文,推理速度越慢,显存占用越大
- 模型对长文本中间部分的注意力可能不如开头和结尾(”Lost in the Middle”现象)
- 使用 GGUF 量化部署时,默认上下文长度可能只有 4K 或 8K,需要手动扩展
这篇文章就来讲讲怎么在实际使用中处理好长文本的问题。
RoPE 位置编码:长上下文的基石
要理解长上下文,需要先理解 RoPE(Rotary Position Embedding,旋转位置编码)。
传统 Transformer 用绝对位置编码——给每个位置分配一个固定的向量。这种方式的问题是:如果训练时最长只见过 2048 个 token,那位置 2049 对应的编码就是”未知领域”。
RoPE 的做法不同——它用旋转矩阵来编码相对位置关系。两个 token 之间的注意力分数只取决于它们的相对距离,而不是绝对位置。这种设计天然支持外推到更长的序列。
RoPE 的核心公式涉及一个 base 参数(通常叫 rope_theta),Llama 3.1 把这个值设成了 500000(比 Llama 2 的 10000 高了 50 倍),这是它能原生支持 128K 上下文的关键因素之一。
NTK-aware Scaling:让模型看得更远
即使有了高 rope_theta,在实际推理中你有时候还是需要超过训练长度。NTK-aware Scaling 是目前最有效的位置编码缩放方法之一。
NTK 的核心思路:不是简单地按比例缩放所有频率分量,而是主要缩放高频分量(对应短距离位置关系),保留低频分量(对应长距离位置关系)。
在 llama.cpp 中使用 YaRN(NTK 的改进版):
1 2 3 4 5
| ./llama-cli -m hermes3-8b-q5km.gguf \ -c 32768 \ --rope-scaling yarn \ --rope-scale 4.0 \ --rope-freq-base 500000
|
在 llama-cpp-python 中:
1 2 3 4 5 6 7 8 9 10
| from llama_cpp import Llama
llm = Llama( model_path="./hermes3-8b-q5km.gguf", n_ctx=32768, n_gpu_layers=-1, rope_scaling_type=2, rope_freq_base=500000, rope_freq_scale=0.25 )
|
不过对于 Hermes 3 / Llama 3.1 来说,它原生训练时就用了 128K 上下文,通常不需要额外做 scaling。只有当你需要超过 128K 或者使用较老的 Hermes 版本时才需要。
实际使用中的上下文长度选择
虽然 Hermes 3 支持 128K,但不代表你应该总是设成 128K。上下文长度直接影响显存占用和推理速度。
KV Cache 显存占用(Hermes 3 8B 近似):
- 8K 上下文:~512 MB
- 32K 上下文:~2 GB
- 128K 上下文:~8 GB
| 使用场景 |
推荐 n_ctx |
理由 |
| 日常对话/问答 |
4096-8192 |
足够了,速度快 |
| 代码生成/分析 |
8192-16384 |
代码文件可能较长 |
| 文档摘要 |
16384-32768 |
需要放入完整文档 |
| 长篇写作 |
16384-32768 |
保持长距离连贯性 |
| 论文/书籍分析 |
65536-131072 |
真正需要大窗口的场景 |
在 Ollama 环境 中设置上下文长度:
1 2 3 4 5
| curl http://localhost:11434/api/generate -d '{ "model": "hermes3", "prompt": "...", "options": {"num_ctx": 16384} }'
|
滑动窗口策略:处理超长文本
如果文本长度超过了上下文窗口怎么办?最常用的方案是滑动窗口(Sliding Window):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| def sliding_window_process(llm, text, window_size=6000, overlap=500, task_prompt="请总结以下文本的要点:"): chunks = [] start = 0 while start < len(text): end = min(start + window_size, len(text)) chunks.append(text[start:end]) if end >= len(text): break start = end - overlap
results = [] for i, chunk in enumerate(chunks): messages = [ {"role": "system", "content": f"你正在处理一篇长文档的第{i+1}/{len(chunks)}个段落。"}, {"role": "user", "content": f"{task_prompt}\n\n{chunk}"} ] response = llm.create_chat_completion(messages=messages, max_tokens=1024, temperature=0.3) results.append(response["choices"][0]["message"]["content"]) return results
|
Map-Reduce 摘要:长文本的经典处理模式
对于长文本摘要这种常见需求,Map-Reduce 模式是最实用的方案:
第一步(Map): 对每个文本段落分别生成摘要
第二步(Reduce): 把所有段落摘要合并起来,生成最终摘要
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| class LongDocumentSummarizer: def __init__(self, llm, chunk_size=5000, overlap=300): self.llm = llm self.chunk_size = chunk_size self.overlap = overlap
def summarize(self, document: str) -> str: chunks = self._split(document)
if len(chunks) == 1: return self._single_summary(chunks[0])
chunk_summaries = [] for i, chunk in enumerate(chunks): summary = self._map_summary(chunk, i, len(chunks)) chunk_summaries.append(summary)
combined = "\n\n".join([f"【段落{i+1}摘要】{s}" for i, s in enumerate(chunk_summaries)])
if len(combined) > self.chunk_size * 2: return self.summarize(combined)
return self._reduce_summary(combined)
def _split(self, text): chunks = [] start = 0 while start < len(text): end = min(start + self.chunk_size, len(text)) if end < len(text): for sep in ["。", "!", "?", "\n"]: last_sep = text[start:end].rfind(sep) if last_sep > self.chunk_size * 0.7: end = start + last_sep + 1 break chunks.append(text[start:end]) if end >= len(text): break start = end - self.overlap return chunks
def _map_summary(self, chunk, index, total): messages = [ {"role": "system", "content": "你是一个专业的文档摘要助手。"}, {"role": "user", "content": f"这是长文档的第{index+1}/{total}部分。请提取关键信息:\n\n{chunk}"} ] response = self.llm.create_chat_completion(messages=messages, max_tokens=512, temperature=0.2) return response["choices"][0]["message"]["content"]
def _reduce_summary(self, combined_summaries): messages = [ {"role": "system", "content": "请将多个段落摘要整合为一篇连贯完整的最终摘要。去除重复信息,保留核心要点。"}, {"role": "user", "content": f"请整合以下摘要:\n\n{combined_summaries}"} ] response = self.llm.create_chat_completion(messages=messages, max_tokens=1024, temperature=0.3) return response["choices"][0]["message"]["content"]
def _single_summary(self, text): messages = [ {"role": "system", "content": "生成简洁全面的摘要。"}, {"role": "user", "content": f"请摘要以下文本:\n\n{text}"} ] response = self.llm.create_chat_completion(messages=messages, max_tokens=1024, temperature=0.3) return response["choices"][0]["message"]["content"]
|
Lost in the Middle:长上下文的注意力陷阱
研究表明,大语言模型在处理长上下文时,对开头和结尾部分的信息利用率明显高于中间部分。
应对策略:
把最重要的信息放在开头或结尾 — 在 RAG 场景 中,把最相关的检索结果排在第一位和最后一位
添加提示标记引导注意力
1 2 3 4 5 6 7 8 9 10
| context = f""" 【重要参考资料开始】 {important_document} 【重要参考资料结束】
其他参考资料: {other_documents}
请特别注意上面标记为"重要"的参考资料。 """
|
- 分段处理后合并 — 就是前面讲的 Map-Reduce 模式
长上下文的性能优化
1. KV Cache 量化
把 KV Cache 从 FP16 量化到 INT8,显存占用减半,质量几乎不受影响:
1 2 3
| ./llama-cli -m hermes3-8b.gguf -c 32768 \ --cache-type-k q8_0 \ --cache-type-v q8_0
|
2. Flash Attention
Flash Attention 改变了注意力计算的内存访问模式,大幅降低显存占用并加速计算。llama.cpp 和 vLLM 默认支持。
3. 上下文缓存和复用
如果多个请求共享同一个长前缀(比如同一个 system prompt + 同一份参考文档),可以缓存 KV Cache:
1 2
| llm = LLM(model="NousResearch/Hermes-3-Llama-3.1-8B", enable_prefix_caching=True)
|
长文本的 Tokenizer 注意事项
中文的 token 效率比英文低。Llama 3.1 的 tokenizer 对中文的编码效率大约是英文的 1.5-2 倍(同样的语义内容,中文需要更多 token)。128K token 对中文来说不是 12.8 万字,而是大约 6-8 万字。
1 2 3 4 5 6 7 8 9 10 11 12
| from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("NousResearch/Hermes-3-Llama-3.1-8B")
cn_text = "这是一段中文测试文本。" * 100 en_text = "This is a test text in English. " * 100
cn_tokens = len(tokenizer.encode(cn_text)) en_tokens = len(tokenizer.encode(en_text))
print(f"中文: {len(cn_text)}字符 -> {cn_tokens} tokens") print(f"英文: {len(en_text)}字符 -> {en_tokens} tokens")
|
在做文本切分时,建议用 token 数而不是字符数来控制长度,避免某个块的 token 数超过预期。
长上下文处理不是一个单一的技术点,而是一组策略的组合。根据你的具体场景选择合适的策略,才能把 Hermes 的长上下文能力真正用好。更多关于 Hermes 模型选择的考量,可以看 多模型路由策略 那篇。