Instructions to use pipenetwork/DeepSeek-V4-Flash-MLX-mixed-4_8bit with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use pipenetwork/DeepSeek-V4-Flash-MLX-mixed-4_8bit with MLX:
# Make sure mlx-lm is installed # pip install --upgrade mlx-lm # if on a CUDA device, also pip install mlx[cuda] # Generate text with mlx-lm from mlx_lm import load, generate model, tokenizer = load("pipenetwork/DeepSeek-V4-Flash-MLX-mixed-4_8bit") prompt = "Once upon a time in" text = generate(model, tokenizer, prompt=prompt, verbose=True) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- MLX LM
How to use pipenetwork/DeepSeek-V4-Flash-MLX-mixed-4_8bit with MLX LM:
Generate or start a chat session
# Install MLX LM uv tool install mlx-lm # Generate some text mlx_lm.generate --model "pipenetwork/DeepSeek-V4-Flash-MLX-mixed-4_8bit" --prompt "Once upon a time"
- Atomic Chat
Add files using upload-large-folder tool
Browse files- README.md +103 -0
- config.json +116 -0
- deepseek_v4_mlx/attention.py +239 -0
- deepseek_v4_mlx/cache.py +142 -0
- deepseek_v4_mlx/compressor.py +154 -0
- deepseek_v4_mlx/config.py +152 -0
- deepseek_v4_mlx/convert.py +218 -0
- deepseek_v4_mlx/dequant.py +92 -0
- deepseek_v4_mlx/fakequant.py +95 -0
- deepseek_v4_mlx/generate.py +37 -0
- deepseek_v4_mlx/hyper_connections.py +118 -0
- deepseek_v4_mlx/indexer.py +113 -0
- deepseek_v4_mlx/layers.py +132 -0
- deepseek_v4_mlx/load.py +68 -0
- deepseek_v4_mlx/model.py +102 -0
- deepseek_v4_mlx/moe.py +140 -0
- deepseek_v4_mlx/sparse_attention.py +71 -0
- encoding/README.md +174 -0
- encoding/encoding_dsv4.py +760 -0
- encoding/test_encoding_dsv4.py +89 -0
- encoding/tests/test_input_1.json +81 -0
- encoding/tests/test_input_2.json +24 -0
- encoding/tests/test_input_3.json +159 -0
- encoding/tests/test_input_4.json +28 -0
- encoding/tests/test_output_1.txt +36 -0
- encoding/tests/test_output_2.txt +1 -0
- encoding/tests/test_output_3.txt +38 -0
- encoding/tests/test_output_4.txt +29 -0
- generation_config.json +9 -0
- model-00001-of-00033.safetensors +3 -0
- model-00004-of-00033.safetensors +3 -0
- model-00005-of-00033.safetensors +3 -0
- model-00011-of-00033.safetensors +3 -0
- model-00012-of-00033.safetensors +3 -0
- model-00013-of-00033.safetensors +3 -0
- model-00014-of-00033.safetensors +3 -0
- model-00016-of-00033.safetensors +3 -0
- model-00017-of-00033.safetensors +3 -0
- model-00018-of-00033.safetensors +3 -0
- model-00019-of-00033.safetensors +3 -0
- model-00020-of-00033.safetensors +3 -0
- model-00021-of-00033.safetensors +3 -0
- model-00024-of-00033.safetensors +3 -0
- model-00025-of-00033.safetensors +3 -0
- model-00026-of-00033.safetensors +3 -0
- model-00032-of-00033.safetensors +3 -0
- model-00033-of-00033.safetensors +3 -0
- model.safetensors.index.json +0 -0
- tokenizer.json +0 -0
- tokenizer_config.json +34 -0
README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
base_model: deepseek-ai/DeepSeek-V4-Flash-0731
|
| 4 |
+
base_model_relation: quantized
|
| 5 |
+
pipeline_tag: text-generation
|
| 6 |
+
library_name: mlx
|
| 7 |
+
tags:
|
| 8 |
+
- mlx
|
| 9 |
+
- moe
|
| 10 |
+
- deepseek
|
| 11 |
+
- deepseek-v4
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# DeepSeek-V4-Flash-MLX-mixed-4_8bit
|
| 15 |
+
|
| 16 |
+
MLX (Apple Silicon) conversion of [deepseek-ai/DeepSeek-V4-Flash-0731](https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-0731),
|
| 17 |
+
quantized with **4-bit routed experts and 8-bit everything else** (affine group quant, group size 64).
|
| 18 |
+
|
| 19 |
+
**Code / loader:** [github.com/PipeNetwork/deepseek-v4-mlx](https://github.com/PipeNetwork/deepseek-v4-mlx)
|
| 20 |
+
|
| 21 |
+
DeepSeek-V4-Flash is a **304B-parameter** sparse-MoE model. Its `deepseek_v4`
|
| 22 |
+
architecture is **not** implemented in any released runtime — not `transformers`,
|
| 23 |
+
and not `mlx-lm` (which carries `deepseek`, `_v2`, `_v3`, `_v32`). This repo bundles
|
| 24 |
+
a from-scratch MLX port under `deepseek_v4_mlx/`, validated against DeepSeek's own
|
| 25 |
+
reference implementation.
|
| 26 |
+
|
| 27 |
+
## Builds
|
| 28 |
+
|
| 29 |
+
| Variant | Size | wikitext-2 ppl | Notes |
|
| 30 |
+
|---|---:|---:|---|
|
| 31 |
+
| [8bit](https://huggingface.co/pipenetwork/DeepSeek-V4-Flash-MLX-8bit) | ~304 GB | 5.9878 | highest fidelity |
|
| 32 |
+
| [6bit](https://huggingface.co/pipenetwork/DeepSeek-V4-Flash-MLX-6bit) | ~233 GB | 6.0260 | +0.6% vs 8-bit for 70 GB less |
|
| 33 |
+
| [mixed-4_8bit](https://huggingface.co/pipenetwork/DeepSeek-V4-Flash-MLX-mixed-4_8bit) | ~165 GB | 6.1262 | **recommended** — 4-bit experts, 8-bit everything else |
|
| 34 |
+
| [4bit](https://huggingface.co/pipenetwork/DeepSeek-V4-Flash-MLX-4bit) | ~162 GB | 6.3005 | smallest; +5.2% vs 8-bit |
|
| 35 |
+
|
| 36 |
+
Perplexity is teacher-forced over **wikitext-2-raw-v1 test**, 199,485 tokens in 195
|
| 37 |
+
windows of 1024 — identical windows for every build. Differences are established by
|
| 38 |
+
a **paired** bootstrap over shared windows, which removes window-difficulty variance:
|
| 39 |
+
independent intervals overlap almost entirely and settle nothing, while the paired
|
| 40 |
+
interval is ~10x tighter. Every gap quoted here is significant at 95%.
|
| 41 |
+
|
| 42 |
+
### Why `mixed-4_8bit` is the recommended build
|
| 43 |
+
|
| 44 |
+
The routed experts are **98.1%** of all quantized parameters, and they ship natively
|
| 45 |
+
as FP4. Keeping them at 4 bits while raising everything else — attention,
|
| 46 |
+
embeddings, shared experts, about 5B parameters — to 8 bits costs **3 GB** and
|
| 47 |
+
recovers **56%** of the gap between 4-bit and 8-bit. Per parameter, the non-expert
|
| 48 |
+
weights are roughly 50x more quantization-sensitive.
|
| 49 |
+
|
| 50 |
+
Two variants were built, measured, and **not** published:
|
| 51 |
+
|
| 52 |
+
* **group-size 32** (matching FP4's native 32-value scale blocks) is 0.5% *worse*
|
| 53 |
+
than group 64 and 18 GB larger. Finer scaling improves raw tensor reconstruction
|
| 54 |
+
but not prediction — the mismatch is that FP4's levels are non-uniform while
|
| 55 |
+
affine's are evenly spaced, which no group size fixes.
|
| 56 |
+
* **3-bit experts** collapse the model entirely (perplexity 1.4e7, degenerate token
|
| 57 |
+
loops). 4-bit sits just above a cliff for this checkpoint, not in the middle of a
|
| 58 |
+
gentle curve.
|
| 59 |
+
|
| 60 |
+
## ⚠️ Loading requires the bundled loader
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
pip install mlx mlx-lm transformers
|
| 64 |
+
```
|
| 65 |
+
```python
|
| 66 |
+
from deepseek_v4_mlx.load import load
|
| 67 |
+
from deepseek_v4_mlx.generate import greedy_generate, load_tokenizer
|
| 68 |
+
|
| 69 |
+
model, args = load("/path/to/this/repo")
|
| 70 |
+
tok = load_tokenizer() # DeepSeek-V3's tokenizer — see below
|
| 71 |
+
ids = tok("The capital of France is")["input_ids"]
|
| 72 |
+
print(tok.decode(greedy_generate(model, args, ids, max_new_tokens=32)))
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
**The base repo ships no tokenizer** — only `encoding/encoding_dsv4.py`, which renders
|
| 76 |
+
prompt *strings*. DeepSeek-V3's tokenizer is the correct one: same padded `vocab_size`
|
| 77 |
+
129280 over 128815 real tokens, `bos=0`/`eos=1` matching V4's `generation_config.json`,
|
| 78 |
+
and every V4 special token present.
|
| 79 |
+
|
| 80 |
+
## What this port implements
|
| 81 |
+
|
| 82 |
+
| Mechanism | Notes |
|
| 83 |
+
|---|---|
|
| 84 |
+
| **Hyper-Connections** | the residual stream carries **4 parallel copies**, mixed by a Sinkhorn-normalized combination matrix |
|
| 85 |
+
| **Hash-routed experts** | the first 3 MoE layers pick experts from a `tid2eid[vocab, 6]` table keyed on token id — routing independent of context |
|
| 86 |
+
| **Learned KV compression** | gated pooling over spans, alternating ratio 4/128, overlapping windows at ratio 4 |
|
| 87 |
+
| **Sparse-attention indexer** | scores compressed spans, keeps the top 512 for attention to read |
|
| 88 |
+
| **MLA + grouped output LoRA** | single shared KV vector; `o_groups: 8` block-diagonal output projection |
|
| 89 |
+
| **sqrt-softplus routing**, clamped SwiGLU, attention sinks | |
|
| 90 |
+
|
| 91 |
+
The source checkpoint is **doubly pre-quantized** — FP8 `e4m3` with `e8m0` 128x128
|
| 92 |
+
block scales for most matmuls, FP4 `e2m1` packed two-per-byte for routed experts — so
|
| 93 |
+
conversion dequantizes both formats before requantizing. Both decode paths are
|
| 94 |
+
bit-exact against an independent implementation.
|
| 95 |
+
|
| 96 |
+
Dropped: the multi-token-prediction head and **DSpark** (speculative decode over
|
| 97 |
+
5-token blocks in the last three layers). Both hang off the reference's `forward_spec`
|
| 98 |
+
path and are unused for ordinary generation.
|
| 99 |
+
|
| 100 |
+
`wo_a` is kept unquantized: it is a block-diagonal projection the model reshapes by
|
| 101 |
+
group, and a packed quantized weight cannot be reshaped that way. Costs ~2% of build size.
|
| 102 |
+
|
| 103 |
+
License: MIT (inherits the base model).
|
config.json
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"DeepseekV4ForCausalLM"
|
| 4 |
+
],
|
| 5 |
+
"attention_bias": false,
|
| 6 |
+
"attention_dropout": 0.0,
|
| 7 |
+
"bos_token_id": 0,
|
| 8 |
+
"eos_token_id": 1,
|
| 9 |
+
"hc_eps": 1e-06,
|
| 10 |
+
"hc_mult": 4,
|
| 11 |
+
"hc_sinkhorn_iters": 20,
|
| 12 |
+
"head_dim": 512,
|
| 13 |
+
"hidden_act": "silu",
|
| 14 |
+
"hidden_size": 4096,
|
| 15 |
+
"index_head_dim": 128,
|
| 16 |
+
"index_n_heads": 64,
|
| 17 |
+
"index_topk": 512,
|
| 18 |
+
"initializer_range": 0.02,
|
| 19 |
+
"max_position_embeddings": 1048576,
|
| 20 |
+
"model_type": "deepseek_v4",
|
| 21 |
+
"moe_intermediate_size": 2048,
|
| 22 |
+
"n_routed_experts": 256,
|
| 23 |
+
"n_shared_experts": 1,
|
| 24 |
+
"norm_topk_prob": true,
|
| 25 |
+
"num_attention_heads": 64,
|
| 26 |
+
"num_experts_per_tok": 6,
|
| 27 |
+
"num_hidden_layers": 43,
|
| 28 |
+
"num_hash_layers": 3,
|
| 29 |
+
"num_key_value_heads": 1,
|
| 30 |
+
"num_nextn_predict_layers": 1,
|
| 31 |
+
"o_groups": 8,
|
| 32 |
+
"o_lora_rank": 1024,
|
| 33 |
+
"q_lora_rank": 1024,
|
| 34 |
+
"qk_rope_head_dim": 64,
|
| 35 |
+
"rms_norm_eps": 1e-06,
|
| 36 |
+
"rope_scaling": {
|
| 37 |
+
"beta_fast": 32,
|
| 38 |
+
"beta_slow": 1,
|
| 39 |
+
"factor": 16,
|
| 40 |
+
"original_max_position_embeddings": 65536,
|
| 41 |
+
"type": "yarn"
|
| 42 |
+
},
|
| 43 |
+
"rope_theta": 10000,
|
| 44 |
+
"routed_scaling_factor": 1.5,
|
| 45 |
+
"scoring_func": "sqrtsoftplus",
|
| 46 |
+
"sliding_window": 128,
|
| 47 |
+
"swiglu_limit": 10.0,
|
| 48 |
+
"tie_word_embeddings": false,
|
| 49 |
+
"topk_method": "noaux_tc",
|
| 50 |
+
"torch_dtype": "bfloat16",
|
| 51 |
+
"transformers_version": "4.57.1",
|
| 52 |
+
"use_cache": true,
|
| 53 |
+
"vocab_size": 129280,
|
| 54 |
+
"compress_rope_theta": 160000,
|
| 55 |
+
"compress_ratios": [
|
| 56 |
+
0,
|
| 57 |
+
0,
|
| 58 |
+
4,
|
| 59 |
+
128,
|
| 60 |
+
4,
|
| 61 |
+
128,
|
| 62 |
+
4,
|
| 63 |
+
128,
|
| 64 |
+
4,
|
| 65 |
+
128,
|
| 66 |
+
4,
|
| 67 |
+
128,
|
| 68 |
+
4,
|
| 69 |
+
128,
|
| 70 |
+
4,
|
| 71 |
+
128,
|
| 72 |
+
4,
|
| 73 |
+
128,
|
| 74 |
+
4,
|
| 75 |
+
128,
|
| 76 |
+
4,
|
| 77 |
+
128,
|
| 78 |
+
4,
|
| 79 |
+
128,
|
| 80 |
+
4,
|
| 81 |
+
128,
|
| 82 |
+
4,
|
| 83 |
+
128,
|
| 84 |
+
4,
|
| 85 |
+
128,
|
| 86 |
+
4,
|
| 87 |
+
128,
|
| 88 |
+
4,
|
| 89 |
+
128,
|
| 90 |
+
4,
|
| 91 |
+
128,
|
| 92 |
+
4,
|
| 93 |
+
128,
|
| 94 |
+
4,
|
| 95 |
+
128,
|
| 96 |
+
4,
|
| 97 |
+
128,
|
| 98 |
+
4,
|
| 99 |
+
0,
|
| 100 |
+
0,
|
| 101 |
+
0
|
| 102 |
+
],
|
| 103 |
+
"dspark_block_size": 5,
|
| 104 |
+
"dspark_noise_token_id": 128799,
|
| 105 |
+
"dspark_target_layer_ids": [
|
| 106 |
+
40,
|
| 107 |
+
41,
|
| 108 |
+
42
|
| 109 |
+
],
|
| 110 |
+
"dspark_markov_rank": 256,
|
| 111 |
+
"quantization": {
|
| 112 |
+
"group_size": 64,
|
| 113 |
+
"bits": 8,
|
| 114 |
+
"expert_bits": 4
|
| 115 |
+
}
|
| 116 |
+
}
|
deepseek_v4_mlx/attention.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DeepSeek-V4 attention — MLA over a sliding window plus selected compressed spans.
|
| 2 |
+
|
| 3 |
+
Shape of one layer:
|
| 4 |
+
|
| 5 |
+
* queries go through a low-rank bottleneck (``wq_a`` -> ``q_norm`` -> ``wq_b``),
|
| 6 |
+
then get a **weightless** per-head RMS normalization;
|
| 7 |
+
* a single shared KV vector per position (MLA) — key and value are the same tensor;
|
| 8 |
+
* every layer attends to the last ``window_size`` tokens; compression layers also
|
| 9 |
+
attend to a set of pooled spans, chosen by :class:`Indexer` at ratio 4 or taken
|
| 10 |
+
wholesale at ratio 128;
|
| 11 |
+
* the output has the rotary embedding applied **in reverse** before a grouped
|
| 12 |
+
low-rank projection (``wo_a`` per group, then a shared ``wo_b``).
|
| 13 |
+
|
| 14 |
+
Both paths live here. ``__call__`` prefills a whole sequence and, given a cache,
|
| 15 |
+
leaves it in the state decode would have produced; ``step`` decodes one token
|
| 16 |
+
against that cache. They share the q/kv/output projections so the two cannot drift
|
| 17 |
+
apart.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import mlx.core as mx
|
| 23 |
+
import mlx.nn as nn
|
| 24 |
+
|
| 25 |
+
from .compressor import Compressor
|
| 26 |
+
from .config import ModelArgs
|
| 27 |
+
from .fakequant import fake_quant_kv
|
| 28 |
+
from .indexer import Indexer
|
| 29 |
+
from .layers import RMSNorm, apply_rotary_emb, precompute_freqs_cis
|
| 30 |
+
from .sparse_attention import sparse_attn
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def window_topk_idxs(window_size: int, seqlen: int) -> mx.array:
|
| 34 |
+
"""[s, w] indices of the local window for each query, -1 where out of range."""
|
| 35 |
+
base = mx.arange(seqlen).reshape(-1, 1)
|
| 36 |
+
w = min(seqlen, window_size)
|
| 37 |
+
idx = mx.maximum(base - window_size + 1, 0) + mx.arange(w).reshape(1, -1)
|
| 38 |
+
return mx.where(idx > base, mx.array(-1, mx.int32), idx.astype(mx.int32))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def compress_topk_idxs(ratio: int, seqlen: int, offset: int) -> mx.array:
|
| 42 |
+
"""[s, s//ratio] indices of every *complete* span, -1 where not yet visible."""
|
| 43 |
+
nb = seqlen // ratio
|
| 44 |
+
m = mx.broadcast_to(mx.arange(nb).reshape(1, -1), (seqlen, nb))
|
| 45 |
+
vis = m < (mx.arange(1, seqlen + 1).reshape(-1, 1) // ratio)
|
| 46 |
+
return mx.where(vis, (m + offset).astype(mx.int32), mx.array(-1, mx.int32))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Attention(nn.Module):
|
| 50 |
+
def __init__(self, layer_id: int, args: ModelArgs):
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.layer_id = layer_id
|
| 53 |
+
self.dim = args.dim
|
| 54 |
+
self.n_heads = args.n_heads
|
| 55 |
+
self.head_dim = args.head_dim
|
| 56 |
+
self.rope_head_dim = args.rope_head_dim
|
| 57 |
+
self.q_lora_rank = args.q_lora_rank
|
| 58 |
+
self.o_lora_rank = args.o_lora_rank
|
| 59 |
+
self.n_groups = args.o_groups
|
| 60 |
+
self.window_size = args.window_size
|
| 61 |
+
self.compress_ratio = args.compress_ratio(layer_id)
|
| 62 |
+
self.eps = args.norm_eps
|
| 63 |
+
self.softmax_scale = args.head_dim ** -0.5
|
| 64 |
+
|
| 65 |
+
self.attn_sink = mx.zeros((self.n_heads,), dtype=mx.float32)
|
| 66 |
+
self.wq_a = nn.Linear(self.dim, self.q_lora_rank, bias=False)
|
| 67 |
+
self.q_norm = RMSNorm(self.q_lora_rank, self.eps)
|
| 68 |
+
self.wq_b = nn.Linear(self.q_lora_rank, self.n_heads * self.head_dim, bias=False)
|
| 69 |
+
self.wkv = nn.Linear(self.dim, self.head_dim, bias=False)
|
| 70 |
+
self.kv_norm = RMSNorm(self.head_dim, self.eps)
|
| 71 |
+
self.wo_a = nn.Linear(self.n_heads * self.head_dim // self.n_groups,
|
| 72 |
+
self.n_groups * self.o_lora_rank, bias=False)
|
| 73 |
+
self.wo_b = nn.Linear(self.n_groups * self.o_lora_rank, self.dim, bias=False)
|
| 74 |
+
|
| 75 |
+
if self.compress_ratio:
|
| 76 |
+
self.compressor = Compressor(args, self.compress_ratio, self.head_dim)
|
| 77 |
+
self.indexer = Indexer(args, self.compress_ratio) if self.compress_ratio == 4 else None
|
| 78 |
+
|
| 79 |
+
# compression layers use YaRN on a longer rope base; pure sliding-window
|
| 80 |
+
# layers disable YaRN entirely and use the standard base
|
| 81 |
+
if self.compress_ratio:
|
| 82 |
+
orig_len, theta = args.original_seq_len, args.compress_rope_theta
|
| 83 |
+
else:
|
| 84 |
+
orig_len, theta = 0, args.rope_theta
|
| 85 |
+
self._rope = (args.rope_head_dim, orig_len, theta, args.rope_factor,
|
| 86 |
+
args.beta_fast, args.beta_slow)
|
| 87 |
+
self._cos = None
|
| 88 |
+
self._sin = None
|
| 89 |
+
|
| 90 |
+
def _freqs(self, seqlen: int):
|
| 91 |
+
if self._cos is None or self._cos.shape[0] < seqlen:
|
| 92 |
+
rd, orig_len, theta, factor, bf, bs = self._rope
|
| 93 |
+
self._cos, self._sin = precompute_freqs_cis(rd, seqlen, orig_len, theta,
|
| 94 |
+
factor, bf, bs)
|
| 95 |
+
return self._cos[:seqlen], self._sin[:seqlen]
|
| 96 |
+
|
| 97 |
+
def _project_q(self, x, cos, sin, pos0: int):
|
| 98 |
+
"""Shared query path: low-rank, weightless per-head RMS, rope."""
|
| 99 |
+
bsz, seqlen, _ = x.shape
|
| 100 |
+
rd = self.rope_head_dim
|
| 101 |
+
qr = self.q_norm(self.wq_a(x))
|
| 102 |
+
q = self.wq_b(qr).reshape(bsz, seqlen, self.n_heads, self.head_dim)
|
| 103 |
+
qf = q.astype(mx.float32)
|
| 104 |
+
q = (qf * mx.rsqrt(mx.mean(mx.square(qf), axis=-1, keepdims=True) + self.eps)).astype(q.dtype)
|
| 105 |
+
c, s = cos[pos0:pos0 + seqlen], sin[pos0:pos0 + seqlen]
|
| 106 |
+
q = mx.concatenate([q[..., :-rd], apply_rotary_emb(q[..., -rd:], c, s)], axis=-1)
|
| 107 |
+
return q, qr
|
| 108 |
+
|
| 109 |
+
def _project_kv(self, x, cos, sin, pos0: int):
|
| 110 |
+
"""Shared KV path: single MLA vector, rope on the tail, FP8 on the rest."""
|
| 111 |
+
rd = self.rope_head_dim
|
| 112 |
+
seqlen = x.shape[1]
|
| 113 |
+
kv = self.kv_norm(self.wkv(x))
|
| 114 |
+
c, s = cos[pos0:pos0 + seqlen], sin[pos0:pos0 + seqlen]
|
| 115 |
+
kv = mx.concatenate([kv[..., :-rd], apply_rotary_emb(kv[..., -rd:], c, s)], axis=-1)
|
| 116 |
+
return fake_quant_kv(kv, rd, 64)
|
| 117 |
+
|
| 118 |
+
def _out(self, o, cos, sin, pos0: int, bsz: int, seqlen: int, dtype):
|
| 119 |
+
"""Inverse rope, then the grouped low-rank projection.
|
| 120 |
+
|
| 121 |
+
``dtype`` is the caller's working precision — hardcoding bf16 here silently
|
| 122 |
+
downcasts an fp32 forward.
|
| 123 |
+
"""
|
| 124 |
+
rd = self.rope_head_dim
|
| 125 |
+
c, s = cos[pos0:pos0 + seqlen], sin[pos0:pos0 + seqlen]
|
| 126 |
+
o = mx.concatenate([o[..., :-rd], apply_rotary_emb(o[..., -rd:], c, s, inverse=True)],
|
| 127 |
+
axis=-1)
|
| 128 |
+
o = o.reshape(bsz, seqlen, self.n_groups, -1)
|
| 129 |
+
wo_a = self.wo_a.weight.reshape(self.n_groups, self.o_lora_rank, -1)
|
| 130 |
+
o = mx.einsum("bsgd,grd->bsgr", o.astype(mx.float32), wo_a.astype(mx.float32))
|
| 131 |
+
return self.wo_b(o.reshape(bsz, seqlen, -1).astype(dtype))
|
| 132 |
+
|
| 133 |
+
def step(self, x: mx.array, cache) -> mx.array:
|
| 134 |
+
"""Decode one token against ``cache``. ``x`` is [b, 1, dim]."""
|
| 135 |
+
bsz = x.shape[0]
|
| 136 |
+
pos = cache.offset
|
| 137 |
+
cos, sin = self._freqs(pos + 1)
|
| 138 |
+
|
| 139 |
+
q, qr = self._project_q(x, cos, sin, pos)
|
| 140 |
+
kv = self._project_kv(x, cos, sin, pos)
|
| 141 |
+
cache.put_window(pos, kv[:, 0])
|
| 142 |
+
|
| 143 |
+
idxs = cache.window_idxs(pos)[None, None, :]
|
| 144 |
+
if self.compress_ratio:
|
| 145 |
+
if self.indexer is not None:
|
| 146 |
+
cidx = self.indexer.step(x, qr, cos, sin, pos, cache, self.window_size)
|
| 147 |
+
else:
|
| 148 |
+
ci = cache.compressed_idxs(pos)
|
| 149 |
+
cidx = ci[None, None, :] if ci.size else None
|
| 150 |
+
pooled = self.compressor.step(x, pos, cache.comp, cos, sin)
|
| 151 |
+
if pooled is not None:
|
| 152 |
+
cache.put_compressed(pos // self.compress_ratio, pooled[:, 0])
|
| 153 |
+
if cidx is not None and cidx.shape[-1] > 0:
|
| 154 |
+
idxs = mx.concatenate([mx.broadcast_to(idxs, (bsz, 1, idxs.shape[-1])),
|
| 155 |
+
mx.broadcast_to(cidx, (bsz, 1, cidx.shape[-1]))], axis=-1)
|
| 156 |
+
|
| 157 |
+
idxs = mx.broadcast_to(idxs, (bsz, 1, idxs.shape[-1]))
|
| 158 |
+
o = sparse_attn(q, cache.kv, self.attn_sink, idxs, self.softmax_scale)
|
| 159 |
+
out = self._out(o, cos, sin, pos, bsz, 1, x.dtype)
|
| 160 |
+
cache.offset = pos + 1
|
| 161 |
+
return out
|
| 162 |
+
|
| 163 |
+
def __call__(self, x: mx.array, cache=None) -> mx.array:
|
| 164 |
+
if cache is not None and x.shape[1] == 1 and cache.offset > 0:
|
| 165 |
+
return self.step(x, cache)
|
| 166 |
+
bsz, seqlen, _ = x.shape
|
| 167 |
+
rd = self.rope_head_dim
|
| 168 |
+
cos, sin = self._freqs(seqlen)
|
| 169 |
+
|
| 170 |
+
# --- queries: low-rank, then a weightless per-head RMS ---
|
| 171 |
+
qr = self.q_norm(self.wq_a(x))
|
| 172 |
+
q = self.wq_b(qr).reshape(bsz, seqlen, self.n_heads, self.head_dim)
|
| 173 |
+
qf = q.astype(mx.float32)
|
| 174 |
+
q = (qf * mx.rsqrt(mx.mean(mx.square(qf), axis=-1, keepdims=True) + self.eps)).astype(q.dtype)
|
| 175 |
+
q = mx.concatenate([q[..., :-rd], apply_rotary_emb(q[..., -rd:], cos, sin)], axis=-1)
|
| 176 |
+
|
| 177 |
+
# --- shared KV (MLA) ---
|
| 178 |
+
kv = self.kv_norm(self.wkv(x))
|
| 179 |
+
kv = mx.concatenate([kv[..., :-rd], apply_rotary_emb(kv[..., -rd:], cos, sin)], axis=-1)
|
| 180 |
+
kv = fake_quant_kv(kv, rd, 64)
|
| 181 |
+
|
| 182 |
+
idxs = mx.broadcast_to(window_topk_idxs(self.window_size, seqlen)[None],
|
| 183 |
+
(bsz, seqlen, min(seqlen, self.window_size)))
|
| 184 |
+
|
| 185 |
+
if self.compress_ratio:
|
| 186 |
+
offset = seqlen # compressed entries follow the window KV
|
| 187 |
+
if self.indexer is not None:
|
| 188 |
+
cidx = self.indexer(x, qr, cos, sin, offset)
|
| 189 |
+
else:
|
| 190 |
+
cidx = mx.broadcast_to(
|
| 191 |
+
compress_topk_idxs(self.compress_ratio, seqlen, offset)[None],
|
| 192 |
+
(bsz, seqlen, seqlen // self.compress_ratio))
|
| 193 |
+
if cidx is not None:
|
| 194 |
+
idxs = mx.concatenate([idxs, cidx], axis=-1)
|
| 195 |
+
kv_c = self.compressor(x, cos, sin)
|
| 196 |
+
if kv_c is not None:
|
| 197 |
+
kv = mx.concatenate([kv, kv_c], axis=1)
|
| 198 |
+
|
| 199 |
+
o = sparse_attn(q, kv, self.attn_sink, idxs, self.softmax_scale)
|
| 200 |
+
|
| 201 |
+
if cache is not None:
|
| 202 |
+
self._seed_cache(cache, x, kv, seqlen, cos, sin)
|
| 203 |
+
|
| 204 |
+
return self._out(o, cos, sin, 0, bsz, seqlen, x.dtype)
|
| 205 |
+
|
| 206 |
+
def _seed_cache(self, cache, x, kv, seqlen, cos, sin):
|
| 207 |
+
"""Leave the cache in exactly the state decode would have produced.
|
| 208 |
+
|
| 209 |
+
Prefill pools spans in one shot, so the compressors' rolling state has to be
|
| 210 |
+
replayed. Replay starts at the **last complete span**, not merely at the
|
| 211 |
+
remainder: for overlapping (ratio-4) compressors, closing that span is what
|
| 212 |
+
moves it into the "previous span" half of the state, which the next pooling
|
| 213 |
+
reads. Starting at the remainder would leave that half empty and quietly
|
| 214 |
+
change the first few decoded tokens.
|
| 215 |
+
|
| 216 |
+
Slots beyond the replayed remainder keep stale values, which is harmless —
|
| 217 |
+
a span only closes once every slot in the current half has been rewritten.
|
| 218 |
+
"""
|
| 219 |
+
cache.put_window_prefill(kv[:, :seqlen])
|
| 220 |
+
if not self.compress_ratio:
|
| 221 |
+
cache.offset = seqlen
|
| 222 |
+
return
|
| 223 |
+
|
| 224 |
+
ratio = self.compress_ratio
|
| 225 |
+
n_span = seqlen // ratio
|
| 226 |
+
if n_span:
|
| 227 |
+
cache.put_compressed_prefill(kv[:, seqlen:seqlen + n_span])
|
| 228 |
+
if self.indexer is not None:
|
| 229 |
+
idx_kv = self.indexer.compressor(x, cos, sin)
|
| 230 |
+
if idx_kv is not None:
|
| 231 |
+
cache._adopt(idx_kv.dtype)
|
| 232 |
+
cache.idx_kv[:, :idx_kv.shape[1]] = idx_kv
|
| 233 |
+
|
| 234 |
+
start = max(0, (n_span - 1) * ratio)
|
| 235 |
+
for p in range(start, seqlen):
|
| 236 |
+
self.compressor.step(x[:, p:p + 1], p, cache.comp, cos, sin)
|
| 237 |
+
if self.indexer is not None:
|
| 238 |
+
self.indexer.compressor.step(x[:, p:p + 1], p, cache.idx_comp, cos, sin)
|
| 239 |
+
cache.offset = seqlen
|
deepseek_v4_mlx/cache.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Incremental decode state for DeepSeek-V4.
|
| 2 |
+
|
| 3 |
+
Three things persist between tokens, and they have different shapes:
|
| 4 |
+
|
| 5 |
+
* **the sliding window** — a *circular* buffer of ``window_size`` KV entries.
|
| 6 |
+
Position ``p`` lives at slot ``p % window_size``, so reading it back in
|
| 7 |
+
chronological order means rotating the slot list, not slicing it.
|
| 8 |
+
* **compressed spans** — appended after the window in the same buffer, one entry
|
| 9 |
+
per completed span at slot ``window_size + p // ratio``.
|
| 10 |
+
* **the compressor's partial span** — the tokens seen since the last span closed,
|
| 11 |
+
held as running ``kv``/``score`` state. Ratio-4 layers keep two spans' worth,
|
| 12 |
+
because their pooling overlaps the previous span.
|
| 13 |
+
|
| 14 |
+
The indexer keeps its own compressor state and its own compressed-KV buffer,
|
| 15 |
+
independent of attention's.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import mlx.core as mx
|
| 21 |
+
|
| 22 |
+
NEG_INF = -1e30
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class CompressorCache:
|
| 26 |
+
"""Rolling state for one compressor's in-progress span."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, bsz: int, ratio: int, head_dim: int, overlap: bool):
|
| 29 |
+
coff = 1 + overlap
|
| 30 |
+
self.ratio = ratio
|
| 31 |
+
self.overlap = overlap
|
| 32 |
+
self.head_dim = head_dim
|
| 33 |
+
self.kv_state = mx.zeros((bsz, coff * ratio, coff * head_dim), dtype=mx.float32)
|
| 34 |
+
self.score_state = mx.full((bsz, coff * ratio, coff * head_dim), NEG_INF,
|
| 35 |
+
dtype=mx.float32)
|
| 36 |
+
|
| 37 |
+
def write(self, slot: int, kv: mx.array, score: mx.array):
|
| 38 |
+
"""Record one token's contribution at ``slot``."""
|
| 39 |
+
self.kv_state[:, slot] = kv
|
| 40 |
+
self.score_state[:, slot] = score
|
| 41 |
+
|
| 42 |
+
def shift(self):
|
| 43 |
+
"""After closing an overlapping span, the current half becomes the previous."""
|
| 44 |
+
r = self.ratio
|
| 45 |
+
self.kv_state[:, :r] = self.kv_state[:, r:]
|
| 46 |
+
self.score_state[:, :r] = self.score_state[:, r:]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class LayerCache:
|
| 50 |
+
"""Per-layer decode state: window + compressed KV, plus compressor state."""
|
| 51 |
+
|
| 52 |
+
def __init__(self, bsz: int, args, layer_id: int, max_seq_len: int,
|
| 53 |
+
dtype=mx.bfloat16):
|
| 54 |
+
self.window = args.window_size
|
| 55 |
+
self.ratio = args.compress_ratio(layer_id)
|
| 56 |
+
self.offset = 0
|
| 57 |
+
self.dtype = dtype
|
| 58 |
+
|
| 59 |
+
n_comp = (max_seq_len // self.ratio) if self.ratio else 0
|
| 60 |
+
self.kv = mx.zeros((bsz, self.window + n_comp, args.head_dim), dtype=dtype)
|
| 61 |
+
|
| 62 |
+
if self.ratio:
|
| 63 |
+
self.comp = CompressorCache(bsz, self.ratio, args.head_dim, self.ratio == 4)
|
| 64 |
+
else:
|
| 65 |
+
self.comp = None
|
| 66 |
+
|
| 67 |
+
if self.ratio == 4:
|
| 68 |
+
self.idx_comp = CompressorCache(bsz, self.ratio, args.index_head_dim, True)
|
| 69 |
+
self.idx_kv = mx.zeros((bsz, n_comp, args.index_head_dim), dtype=dtype)
|
| 70 |
+
else:
|
| 71 |
+
self.idx_comp = None
|
| 72 |
+
self.idx_kv = None
|
| 73 |
+
|
| 74 |
+
def _adopt(self, dtype):
|
| 75 |
+
"""Match the precision actually flowing through the model.
|
| 76 |
+
|
| 77 |
+
Allocating bf16 while the forward runs in fp32 truncates every cached KV on
|
| 78 |
+
write. The absolute error is small, but it flips discrete decisions
|
| 79 |
+
downstream — expert top-k and indexer span selection — so decode drifts away
|
| 80 |
+
from full recomputation far faster than the rounding alone suggests.
|
| 81 |
+
"""
|
| 82 |
+
if dtype == self.dtype:
|
| 83 |
+
return
|
| 84 |
+
self.dtype = dtype
|
| 85 |
+
self.kv = self.kv.astype(dtype)
|
| 86 |
+
if self.idx_kv is not None:
|
| 87 |
+
self.idx_kv = self.idx_kv.astype(dtype)
|
| 88 |
+
|
| 89 |
+
# ---- window ----
|
| 90 |
+
|
| 91 |
+
def put_window(self, pos: int, kv: mx.array):
|
| 92 |
+
self._adopt(kv.dtype)
|
| 93 |
+
self.kv[:, pos % self.window] = kv
|
| 94 |
+
|
| 95 |
+
def put_window_prefill(self, kv: mx.array):
|
| 96 |
+
"""Seed the circular buffer from a prefill of ``s`` tokens.
|
| 97 |
+
|
| 98 |
+
Keeps the last ``window`` entries, placed so that position ``p`` sits at
|
| 99 |
+
slot ``p % window`` — the same invariant decode maintains.
|
| 100 |
+
"""
|
| 101 |
+
self._adopt(kv.dtype)
|
| 102 |
+
s = kv.shape[1]
|
| 103 |
+
if s <= self.window:
|
| 104 |
+
self.kv[:, :s] = kv
|
| 105 |
+
return
|
| 106 |
+
tail = kv[:, -self.window:] # positions s-window .. s-1
|
| 107 |
+
cut = s % self.window # where position s-1 lands + 1
|
| 108 |
+
self.kv[:, cut:self.window] = tail[:, :self.window - cut]
|
| 109 |
+
self.kv[:, :cut] = tail[:, self.window - cut:]
|
| 110 |
+
|
| 111 |
+
def put_compressed(self, span: int, kv: mx.array):
|
| 112 |
+
self._adopt(kv.dtype)
|
| 113 |
+
self.kv[:, self.window + span] = kv
|
| 114 |
+
|
| 115 |
+
def put_compressed_prefill(self, kv: mx.array):
|
| 116 |
+
self._adopt(kv.dtype)
|
| 117 |
+
n = kv.shape[1]
|
| 118 |
+
self.kv[:, self.window:self.window + n] = kv
|
| 119 |
+
|
| 120 |
+
# ---- index lists for one decoding step ----
|
| 121 |
+
|
| 122 |
+
def window_idxs(self, pos: int) -> mx.array:
|
| 123 |
+
"""Window slots in chronological order for a query at ``pos``."""
|
| 124 |
+
w = self.window
|
| 125 |
+
if pos >= w - 1:
|
| 126 |
+
p = pos % w
|
| 127 |
+
return mx.concatenate([mx.arange(p + 1, w), mx.arange(0, p + 1)]).astype(mx.int32)
|
| 128 |
+
return mx.concatenate([mx.arange(pos + 1),
|
| 129 |
+
mx.full((w - pos - 1,), -1)]).astype(mx.int32)
|
| 130 |
+
|
| 131 |
+
def compressed_idxs(self, pos: int) -> mx.array:
|
| 132 |
+
"""Every span completed by ``pos``, addressed in the shared buffer."""
|
| 133 |
+
n = (pos + 1) // self.ratio
|
| 134 |
+
if n <= 0:
|
| 135 |
+
return mx.zeros((0,), dtype=mx.int32)
|
| 136 |
+
return (mx.arange(n) + self.window).astype(mx.int32)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def make_cache(args, bsz: int = 1, max_seq_len: int | None = None):
|
| 140 |
+
"""One :class:`LayerCache` per layer."""
|
| 141 |
+
max_seq_len = max_seq_len or 4096
|
| 142 |
+
return [LayerCache(bsz, args, i, max_seq_len) for i in range(args.n_layers)]
|
deepseek_v4_mlx/compressor.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Learned KV compression — V4 pools spans of tokens into single cache entries.
|
| 2 |
+
|
| 3 |
+
Instead of caching every position, compression layers pool ``compress_ratio``
|
| 4 |
+
consecutive tokens into one entry using a *learned gate*: a projection scores each
|
| 5 |
+
position, the scores are softmaxed across the span, and the KV vectors are summed
|
| 6 |
+
under those weights. A per-slot positional embedding (``ape``) lets the gate know
|
| 7 |
+
where inside the span a token sat.
|
| 8 |
+
|
| 9 |
+
Ratio-4 layers additionally **overlap**: each pooled entry sees 2x ratio positions
|
| 10 |
+
— the current span plus the preceding one — so information is not lost at span
|
| 11 |
+
boundaries. That is what the doubled ``wkv`` / ``wgate`` output width is for: the
|
| 12 |
+
first ``head_dim`` channels carry the overlapping (previous-span) contribution and
|
| 13 |
+
the second ``head_dim`` the current one.
|
| 14 |
+
|
| 15 |
+
Only the prefill path (``start_pos == 0``) lives here. Incremental decode
|
| 16 |
+
maintains rolling state buffers and is added alongside the attention cache.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import mlx.core as mx
|
| 22 |
+
import mlx.nn as nn
|
| 23 |
+
|
| 24 |
+
from .config import ModelArgs
|
| 25 |
+
from .fakequant import fake_quant_fp4, fake_quant_kv
|
| 26 |
+
from .layers import RMSNorm, apply_rotary_emb, hadamard_transform
|
| 27 |
+
|
| 28 |
+
NEG_INF = -1e30
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Compressor(nn.Module):
|
| 32 |
+
"""Pools ``compress_ratio`` tokens into one KV entry via a learned softmax gate."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, args: ModelArgs, compress_ratio: int = 4,
|
| 35 |
+
head_dim: int = 512, rotate: bool = False):
|
| 36 |
+
super().__init__()
|
| 37 |
+
self.dim = args.dim
|
| 38 |
+
self.head_dim = head_dim
|
| 39 |
+
self.rope_head_dim = args.rope_head_dim
|
| 40 |
+
self.compress_ratio = compress_ratio
|
| 41 |
+
self.overlap = compress_ratio == 4
|
| 42 |
+
self.rotate = rotate
|
| 43 |
+
coff = 1 + self.overlap
|
| 44 |
+
|
| 45 |
+
self.ape = mx.zeros((compress_ratio, coff * head_dim), dtype=mx.float32)
|
| 46 |
+
self.wkv = nn.Linear(self.dim, coff * head_dim, bias=False)
|
| 47 |
+
self.wgate = nn.Linear(self.dim, coff * head_dim, bias=False)
|
| 48 |
+
self.norm = RMSNorm(head_dim, args.norm_eps)
|
| 49 |
+
|
| 50 |
+
def _overlap_transform(self, t: mx.array, value: float) -> mx.array:
|
| 51 |
+
"""[b, n, ratio, 2d] -> [b, n, 2*ratio, d].
|
| 52 |
+
|
| 53 |
+
Slots ``[ratio:]`` take the *second* half of the channels from this span;
|
| 54 |
+
slots ``[:ratio]`` take the *first* half from the **previous** span. The
|
| 55 |
+
first span has no predecessor, so its leading slots stay at ``value``
|
| 56 |
+
(0 for KV, -inf for scores, so the softmax ignores them).
|
| 57 |
+
"""
|
| 58 |
+
b, n, r, _ = t.shape
|
| 59 |
+
d = self.head_dim
|
| 60 |
+
cur = t[..., d:] # [b, n, ratio, d]
|
| 61 |
+
prev = t[..., :d] # [b, n, ratio, d]
|
| 62 |
+
pad = mx.full((b, 1, r, d), value, dtype=t.dtype)
|
| 63 |
+
prev_shifted = mx.concatenate([pad, prev[:, :-1]], axis=1)
|
| 64 |
+
return mx.concatenate([prev_shifted, cur], axis=2) # [b, n, 2*ratio, d]
|
| 65 |
+
|
| 66 |
+
def __call__(self, x: mx.array, cos: mx.array, sin: mx.array):
|
| 67 |
+
"""Prefill: compress ``x`` [b, s, dim] into [b, s//ratio, head_dim].
|
| 68 |
+
|
| 69 |
+
Returns ``None`` when the sequence is shorter than one full span, matching
|
| 70 |
+
the reference's ``should_compress`` short-circuit.
|
| 71 |
+
"""
|
| 72 |
+
bsz, seqlen, _ = x.shape
|
| 73 |
+
ratio, d, rd = self.compress_ratio, self.head_dim, self.rope_head_dim
|
| 74 |
+
dtype = x.dtype
|
| 75 |
+
|
| 76 |
+
if seqlen < ratio:
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
xf = x.astype(mx.float32)
|
| 80 |
+
kv = self.wkv(xf)
|
| 81 |
+
score = self.wgate(xf)
|
| 82 |
+
|
| 83 |
+
remainder = seqlen % ratio
|
| 84 |
+
cutoff = seqlen - remainder
|
| 85 |
+
if remainder:
|
| 86 |
+
kv = kv[:, :cutoff]
|
| 87 |
+
score = score[:, :cutoff]
|
| 88 |
+
|
| 89 |
+
nb = cutoff // ratio
|
| 90 |
+
kv = kv.reshape(bsz, nb, ratio, -1)
|
| 91 |
+
score = score.reshape(bsz, nb, ratio, -1) + self.ape
|
| 92 |
+
|
| 93 |
+
if self.overlap:
|
| 94 |
+
kv = self._overlap_transform(kv, 0.0)
|
| 95 |
+
score = self._overlap_transform(score, NEG_INF)
|
| 96 |
+
|
| 97 |
+
kv = mx.sum(kv * mx.softmax(score, axis=2), axis=2) # [b, nb, d]
|
| 98 |
+
kv = self.norm(kv.astype(dtype))
|
| 99 |
+
|
| 100 |
+
# one rope position per pooled span, taken at the span's first token
|
| 101 |
+
c = cos[:cutoff:ratio]
|
| 102 |
+
s = sin[:cutoff:ratio]
|
| 103 |
+
rot = apply_rotary_emb(kv[..., -rd:], c, s)
|
| 104 |
+
kv = mx.concatenate([kv[..., :-rd], rot], axis=-1)
|
| 105 |
+
|
| 106 |
+
return self._finish(kv, rd)
|
| 107 |
+
|
| 108 |
+
def _finish(self, kv: mx.array, rd: int) -> mx.array:
|
| 109 |
+
# QAT simulation: the indexer's compressor rotates then fakes FP4 over the
|
| 110 |
+
# whole vector; the attention compressor fakes FP8 on the non-rope half only.
|
| 111 |
+
if self.rotate:
|
| 112 |
+
kv = hadamard_transform(kv)
|
| 113 |
+
return fake_quant_fp4(kv, 32)
|
| 114 |
+
return fake_quant_kv(kv, rd, 64)
|
| 115 |
+
|
| 116 |
+
def step(self, x: mx.array, pos: int, cache, cos: mx.array, sin: mx.array):
|
| 117 |
+
"""Decode one token. Returns the pooled entry when ``pos`` closes a span.
|
| 118 |
+
|
| 119 |
+
``x`` is [b, 1, dim]. Between span boundaries this only accumulates state
|
| 120 |
+
and returns ``None`` — the caller writes nothing to the KV buffer.
|
| 121 |
+
"""
|
| 122 |
+
ratio, d, rd = self.compress_ratio, self.head_dim, self.rope_head_dim
|
| 123 |
+
dtype = x.dtype
|
| 124 |
+
xf = x.astype(mx.float32)
|
| 125 |
+
kv = self.wkv(xf)[:, 0] # [b, coff*d]
|
| 126 |
+
score = self.wgate(xf)[:, 0] + self.ape[pos % ratio]
|
| 127 |
+
|
| 128 |
+
if self.overlap:
|
| 129 |
+
cache.write(ratio + pos % ratio, kv, score)
|
| 130 |
+
else:
|
| 131 |
+
cache.write(pos % ratio, kv, score)
|
| 132 |
+
|
| 133 |
+
if (pos + 1) % ratio != 0:
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
if self.overlap:
|
| 137 |
+
# first half of the channels from the previous span, second half from this
|
| 138 |
+
kv_s = mx.concatenate([cache.kv_state[:, :ratio, :d],
|
| 139 |
+
cache.kv_state[:, ratio:, d:]], axis=1)
|
| 140 |
+
sc_s = mx.concatenate([cache.score_state[:, :ratio, :d],
|
| 141 |
+
cache.score_state[:, ratio:, d:]], axis=1)
|
| 142 |
+
else:
|
| 143 |
+
kv_s, sc_s = cache.kv_state, cache.score_state
|
| 144 |
+
|
| 145 |
+
pooled = mx.sum(kv_s * mx.softmax(sc_s, axis=1), axis=1, keepdims=True)
|
| 146 |
+
if self.overlap:
|
| 147 |
+
cache.shift()
|
| 148 |
+
|
| 149 |
+
pooled = self.norm(pooled.astype(dtype))
|
| 150 |
+
# the span's rope position is its first token
|
| 151 |
+
p = pos + 1 - ratio
|
| 152 |
+
rot = apply_rotary_emb(pooled[..., -rd:], cos[p:p + 1], sin[p:p + 1])
|
| 153 |
+
pooled = mx.concatenate([pooled[..., :-rd], rot], axis=-1)
|
| 154 |
+
return self._finish(pooled, rd)
|
deepseek_v4_mlx/config.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration for DeepSeek-V4 (MLX port).
|
| 2 |
+
|
| 3 |
+
Parses the HuggingFace ``config.json`` layout published with
|
| 4 |
+
``deepseek-ai/DeepSeek-V4-Flash-0731``. The reference ``inference/config.json``
|
| 5 |
+
uses different key names for the same values (``dim`` vs ``hidden_size``,
|
| 6 |
+
``n_layers`` vs ``num_hidden_layers``, ...), so both spellings are accepted —
|
| 7 |
+
handy for running the reference and this port off one file during parity work.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _get(d: dict, *names, default=None):
|
| 17 |
+
for n in names:
|
| 18 |
+
if n in d and d[n] is not None:
|
| 19 |
+
return d[n]
|
| 20 |
+
return default
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class ModelArgs:
|
| 25 |
+
vocab_size: int = 129280
|
| 26 |
+
dim: int = 4096
|
| 27 |
+
n_layers: int = 43
|
| 28 |
+
n_hash_layers: int = 3
|
| 29 |
+
moe_inter_dim: int = 2048
|
| 30 |
+
|
| 31 |
+
# attention (MLA)
|
| 32 |
+
n_heads: int = 64
|
| 33 |
+
head_dim: int = 512
|
| 34 |
+
rope_head_dim: int = 64
|
| 35 |
+
q_lora_rank: int = 1024
|
| 36 |
+
o_lora_rank: int = 1024
|
| 37 |
+
o_groups: int = 8
|
| 38 |
+
window_size: int = 128
|
| 39 |
+
norm_eps: float = 1e-6
|
| 40 |
+
|
| 41 |
+
# MoE
|
| 42 |
+
n_routed_experts: int = 256
|
| 43 |
+
n_shared_experts: int = 1
|
| 44 |
+
n_activated_experts: int = 6
|
| 45 |
+
score_func: str = "sqrtsoftplus"
|
| 46 |
+
route_scale: float = 1.5
|
| 47 |
+
swiglu_limit: float = 10.0
|
| 48 |
+
|
| 49 |
+
# KV compression: one ratio per layer (0 = none, pure sliding window)
|
| 50 |
+
compress_ratios: tuple = ()
|
| 51 |
+
compress_rope_theta: float = 160000.0
|
| 52 |
+
|
| 53 |
+
# rope / YaRN
|
| 54 |
+
original_seq_len: int = 65536
|
| 55 |
+
rope_theta: float = 10000.0
|
| 56 |
+
rope_factor: float = 16.0
|
| 57 |
+
beta_fast: int = 32
|
| 58 |
+
beta_slow: int = 1
|
| 59 |
+
max_seq_len: int = 1048576
|
| 60 |
+
|
| 61 |
+
# sparse-attention indexer
|
| 62 |
+
index_n_heads: int = 64
|
| 63 |
+
index_head_dim: int = 128
|
| 64 |
+
index_topk: int = 512
|
| 65 |
+
|
| 66 |
+
# hyper-connections
|
| 67 |
+
hc_mult: int = 4
|
| 68 |
+
hc_sinkhorn_iters: int = 20
|
| 69 |
+
hc_eps: float = 1e-6
|
| 70 |
+
|
| 71 |
+
# REAP: experts kept in the *scored* layers (None = unpruned)
|
| 72 |
+
reap_kept_experts: int | None = None
|
| 73 |
+
|
| 74 |
+
# side-paths dropped for inference (kept for provenance)
|
| 75 |
+
n_mtp_layers: int = 0
|
| 76 |
+
dspark_block_size: int = 0
|
| 77 |
+
dspark_target_layer_ids: tuple = ()
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def nope_head_dim(self) -> int:
|
| 81 |
+
return self.head_dim - self.rope_head_dim
|
| 82 |
+
|
| 83 |
+
def compress_ratio(self, layer_id: int) -> int:
|
| 84 |
+
"""Compression ratio for a layer; 0 means pure sliding-window attention."""
|
| 85 |
+
if layer_id < len(self.compress_ratios):
|
| 86 |
+
return int(self.compress_ratios[layer_id])
|
| 87 |
+
return 0
|
| 88 |
+
|
| 89 |
+
def is_hash_layer(self, layer_id: int) -> bool:
|
| 90 |
+
"""First ``n_hash_layers`` route experts by token-id lookup, not by score."""
|
| 91 |
+
return layer_id < self.n_hash_layers
|
| 92 |
+
|
| 93 |
+
def n_experts_for(self, layer_id: int) -> int:
|
| 94 |
+
"""Routed-expert count for a layer — REAP prunes only the scored layers.
|
| 95 |
+
|
| 96 |
+
Hash layers index a fixed ``tid2eid`` table, so dropping an expert there
|
| 97 |
+
would leave table entries pointing at nothing. They keep the full set, and
|
| 98 |
+
a pruned checkpoint therefore has *two* expert counts. Storing one global
|
| 99 |
+
``n_routed_experts`` would silently build the hash layers too small and
|
| 100 |
+
load garbage into them.
|
| 101 |
+
"""
|
| 102 |
+
if self.reap_kept_experts and not self.is_hash_layer(layer_id):
|
| 103 |
+
return self.reap_kept_experts
|
| 104 |
+
return self.n_routed_experts
|
| 105 |
+
|
| 106 |
+
@classmethod
|
| 107 |
+
def from_dict(cls, c: dict) -> "ModelArgs":
|
| 108 |
+
rope = _get(c, "rope_scaling", default={}) or {}
|
| 109 |
+
return cls(
|
| 110 |
+
vocab_size=_get(c, "vocab_size", default=129280),
|
| 111 |
+
dim=_get(c, "hidden_size", "dim", default=4096),
|
| 112 |
+
n_layers=_get(c, "num_hidden_layers", "n_layers", default=43),
|
| 113 |
+
n_hash_layers=_get(c, "num_hash_layers", "n_hash_layers", default=0),
|
| 114 |
+
moe_inter_dim=_get(c, "moe_intermediate_size", "moe_inter_dim", default=2048),
|
| 115 |
+
n_heads=_get(c, "num_attention_heads", "n_heads", default=64),
|
| 116 |
+
head_dim=_get(c, "head_dim", default=512),
|
| 117 |
+
rope_head_dim=_get(c, "qk_rope_head_dim", "rope_head_dim", default=64),
|
| 118 |
+
q_lora_rank=_get(c, "q_lora_rank", default=1024),
|
| 119 |
+
o_lora_rank=_get(c, "o_lora_rank", default=1024),
|
| 120 |
+
o_groups=_get(c, "o_groups", default=8),
|
| 121 |
+
window_size=_get(c, "sliding_window", "window_size", default=128),
|
| 122 |
+
norm_eps=_get(c, "rms_norm_eps", "norm_eps", default=1e-6),
|
| 123 |
+
n_routed_experts=_get(c, "n_routed_experts", default=256),
|
| 124 |
+
n_shared_experts=_get(c, "n_shared_experts", default=1),
|
| 125 |
+
n_activated_experts=_get(c, "num_experts_per_tok", "n_activated_experts", default=6),
|
| 126 |
+
score_func=_get(c, "scoring_func", "score_func", default="sqrtsoftplus"),
|
| 127 |
+
route_scale=_get(c, "routed_scaling_factor", "route_scale", default=1.5),
|
| 128 |
+
swiglu_limit=_get(c, "swiglu_limit", default=0.0),
|
| 129 |
+
compress_ratios=tuple(_get(c, "compress_ratios", default=()) or ()),
|
| 130 |
+
compress_rope_theta=_get(c, "compress_rope_theta", default=160000.0),
|
| 131 |
+
original_seq_len=_get(rope, "original_max_position_embeddings",
|
| 132 |
+
default=_get(c, "original_seq_len", default=65536)),
|
| 133 |
+
rope_theta=_get(c, "rope_theta", default=10000.0),
|
| 134 |
+
rope_factor=_get(rope, "factor", default=_get(c, "rope_factor", default=16.0)),
|
| 135 |
+
beta_fast=_get(rope, "beta_fast", default=_get(c, "beta_fast", default=32)),
|
| 136 |
+
beta_slow=_get(rope, "beta_slow", default=_get(c, "beta_slow", default=1)),
|
| 137 |
+
max_seq_len=_get(c, "max_position_embeddings", "max_seq_len", default=1048576),
|
| 138 |
+
index_n_heads=_get(c, "index_n_heads", default=64),
|
| 139 |
+
index_head_dim=_get(c, "index_head_dim", default=128),
|
| 140 |
+
index_topk=_get(c, "index_topk", default=512),
|
| 141 |
+
hc_mult=_get(c, "hc_mult", default=4),
|
| 142 |
+
hc_sinkhorn_iters=_get(c, "hc_sinkhorn_iters", default=20),
|
| 143 |
+
hc_eps=_get(c, "hc_eps", default=1e-6),
|
| 144 |
+
reap_kept_experts=(c.get("reap") or {}).get("kept_experts"),
|
| 145 |
+
n_mtp_layers=_get(c, "num_nextn_predict_layers", "n_mtp_layers", default=0),
|
| 146 |
+
dspark_block_size=_get(c, "dspark_block_size", default=0),
|
| 147 |
+
dspark_target_layer_ids=tuple(_get(c, "dspark_target_layer_ids", default=()) or ()),
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
@property
|
| 151 |
+
def raw(self) -> dict[str, Any]:
|
| 152 |
+
return {"model_type": "deepseek_v4"}
|
deepseek_v4_mlx/convert.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streaming DeepSeek-V4 checkpoint -> MLX, with optional affine quantization.
|
| 2 |
+
|
| 3 |
+
The published weights are FP8/FP4 (see :mod:`.dequant`), so conversion is
|
| 4 |
+
dequantize-then-requantize rather than a straight cast. It runs one source shard
|
| 5 |
+
at a time and the layout cooperates: no shard spans more than one layer, so a
|
| 6 |
+
layer's 256 experts are always resident together and can be stacked without
|
| 7 |
+
buffering the model.
|
| 8 |
+
|
| 9 |
+
Three transforms are applied:
|
| 10 |
+
|
| 11 |
+
* **dequantize** — every ``(weight, scale)`` pair to bf16; FP4 for routed experts,
|
| 12 |
+
FP8 for everything else;
|
| 13 |
+
* **stack experts** — the checkpoint stores ``ffn.experts.{0..255}.w{1,2,3}``
|
| 14 |
+
separately; MLX's ``SwitchGLU`` wants one ``[n_experts, out, in]`` tensor per
|
| 15 |
+
projection (``w1 -> gate_proj``, ``w3 -> up_proj``, ``w2 -> down_proj``);
|
| 16 |
+
* **drop the side-paths** — ``mtp.*`` covers both the multi-token-prediction head
|
| 17 |
+
and DSpark, neither of which is used for ordinary generation.
|
| 18 |
+
|
| 19 |
+
``bits=None`` writes bf16; ``bits in {2,3,4,5,6,8}`` affine-quantizes the matmul
|
| 20 |
+
weights, leaving norms, the router, ``tid2eid``, the compressor projections and the
|
| 21 |
+
Hyper-Connection parameters in their original precision.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import glob
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import re
|
| 30 |
+
import shutil
|
| 31 |
+
|
| 32 |
+
import mlx.core as mx
|
| 33 |
+
|
| 34 |
+
from .dequant import dequant_pair, is_fp4_expert
|
| 35 |
+
|
| 36 |
+
_EXPERT_RE = re.compile(r"^(layers\.\d+\.ffn)\.experts\.(\d+)\.(w[123])$")
|
| 37 |
+
_PROJ = {"w1": "gate_proj", "w3": "up_proj", "w2": "down_proj"}
|
| 38 |
+
|
| 39 |
+
_SHARD_CAP_BYTES = 5_000_000_000
|
| 40 |
+
|
| 41 |
+
# Leaves that get affine-quantized; everything else keeps its source precision.
|
| 42 |
+
#
|
| 43 |
+
# `wo_a` is deliberately excluded. It is a *block-diagonal* projection — input
|
| 44 |
+
# group g multiplies only block g of the weight — which attention implements by
|
| 45 |
+
# reshaping the weight to [n_groups, o_lora_rank, -1]. After nn.quantize a
|
| 46 |
+
# Linear's `.weight` is the packed uint32 tensor, so that reshape would carve up
|
| 47 |
+
# packed bytes rather than logical weights. Keeping it unquantized costs ~1.4B
|
| 48 |
+
# parameters (about 2% of a 4-bit build) and keeps the reshape meaningful. Moving
|
| 49 |
+
# it to a SwitchLinear would recover that, at the cost of a layout change.
|
| 50 |
+
_QUANT_LEAVES = {"wq_a", "wq_b", "wkv", "wo_b", "w1", "w2", "w3",
|
| 51 |
+
"gate_proj", "up_proj", "down_proj", "embed", "head"}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def should_drop(name: str) -> bool:
|
| 55 |
+
"""MTP and DSpark live under `mtp.` and are inference-irrelevant here."""
|
| 56 |
+
return name.startswith("mtp.")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def is_routed_expert(out_name: str) -> bool:
|
| 60 |
+
"""The stacked routed-expert matmuls — 98% of all quantized parameters.
|
| 61 |
+
|
| 62 |
+
They ship natively as FP4 (16 levels), which 4-bit affine reproduces almost
|
| 63 |
+
exactly, so precision spent above 4 bits here buys little while costing nearly
|
| 64 |
+
the whole build size. Everything else is ~5B parameters and effectively free to
|
| 65 |
+
keep at high precision.
|
| 66 |
+
"""
|
| 67 |
+
return ".ffn.experts." in out_name and ".shared_experts." not in out_name
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def is_quant_target(out_name: str, in_dim: int, group_size: int) -> bool:
|
| 71 |
+
if not out_name.endswith(".weight"):
|
| 72 |
+
return False
|
| 73 |
+
leaf = out_name[: -len(".weight")].rsplit(".", 1)[-1]
|
| 74 |
+
if leaf not in _QUANT_LEAVES:
|
| 75 |
+
return False
|
| 76 |
+
# the router gate and tid2eid share the `gate` prefix but are not in _QUANT_LEAVES
|
| 77 |
+
return in_dim % group_size == 0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def bits_for(out_name: str, bits: int, expert_bits: int | None) -> int:
|
| 81 |
+
"""Per-tensor bit width. ``expert_bits`` splits routed experts from the rest."""
|
| 82 |
+
if expert_bits is not None and is_routed_expert(out_name):
|
| 83 |
+
return expert_bits
|
| 84 |
+
return bits
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _collect(tensors: dict) -> dict:
|
| 88 |
+
"""Dequantize pairs, stack experts, drop side-paths. Returns MLX-named bf16."""
|
| 89 |
+
out: dict[str, mx.array] = {}
|
| 90 |
+
experts: dict[str, dict[int, mx.array]] = {}
|
| 91 |
+
|
| 92 |
+
names = [n for n in tensors if not should_drop(n)]
|
| 93 |
+
for name in names:
|
| 94 |
+
if name.endswith(".scale"):
|
| 95 |
+
continue # consumed with its weight
|
| 96 |
+
arr = tensors[name]
|
| 97 |
+
base = name[: -len(".weight")] if name.endswith(".weight") else name
|
| 98 |
+
scale_name = base + ".scale"
|
| 99 |
+
if scale_name in tensors:
|
| 100 |
+
arr = dequant_pair(base, arr, tensors[scale_name], mx.bfloat16)
|
| 101 |
+
|
| 102 |
+
m = _EXPERT_RE.match(base)
|
| 103 |
+
if m:
|
| 104 |
+
prefix, idx, w = m.group(1), int(m.group(2)), m.group(3)
|
| 105 |
+
key = f"{prefix}.experts.{_PROJ[w]}.weight"
|
| 106 |
+
experts.setdefault(key, {})[idx] = arr
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
if base.endswith("tid2eid"):
|
| 110 |
+
out[base] = arr.astype(mx.int32) # stored int64, only needs int32
|
| 111 |
+
continue
|
| 112 |
+
|
| 113 |
+
out[name if name.endswith(".weight") else base] = arr
|
| 114 |
+
|
| 115 |
+
for key, byidx in experts.items():
|
| 116 |
+
stacked = mx.stack([byidx[i] for i in sorted(byidx)], axis=0)
|
| 117 |
+
out[key] = stacked
|
| 118 |
+
return out
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def convert(src: str, dst: str, bits: int | None = None, group_size: int = 64,
|
| 122 |
+
out_dtype=mx.bfloat16, verbose: bool = True,
|
| 123 |
+
expert_bits: int | None = None) -> str:
|
| 124 |
+
os.makedirs(dst, exist_ok=True)
|
| 125 |
+
index = json.load(open(os.path.join(src, "model.safetensors.index.json")))
|
| 126 |
+
weight_map = index["weight_map"]
|
| 127 |
+
|
| 128 |
+
shard_to_names: dict[str, list[str]] = {}
|
| 129 |
+
for n, s in weight_map.items():
|
| 130 |
+
shard_to_names.setdefault(s, []).append(n)
|
| 131 |
+
|
| 132 |
+
out_index: dict[str, str] = {}
|
| 133 |
+
buf: dict[str, mx.array] = {}
|
| 134 |
+
nbytes = 0
|
| 135 |
+
shard_id = 0
|
| 136 |
+
dropped = 0
|
| 137 |
+
|
| 138 |
+
def flush():
|
| 139 |
+
nonlocal buf, nbytes, shard_id
|
| 140 |
+
if not buf:
|
| 141 |
+
return
|
| 142 |
+
shard_id += 1
|
| 143 |
+
fname = f"model-{shard_id:05d}.safetensors"
|
| 144 |
+
mx.save_safetensors(os.path.join(dst, fname), buf, metadata={"format": "mlx"})
|
| 145 |
+
for k in buf:
|
| 146 |
+
out_index[k] = fname
|
| 147 |
+
buf = {}
|
| 148 |
+
nbytes = 0
|
| 149 |
+
|
| 150 |
+
for shard in sorted(shard_to_names):
|
| 151 |
+
tensors = mx.load(os.path.join(src, shard))
|
| 152 |
+
dropped += sum(1 for n in tensors if should_drop(n))
|
| 153 |
+
converted = _collect(tensors)
|
| 154 |
+
for out_name, arr in converted.items():
|
| 155 |
+
if bits is not None and is_quant_target(out_name, arr.shape[-1], group_size):
|
| 156 |
+
b = bits_for(out_name, bits, expert_bits)
|
| 157 |
+
qw, sc, bi = mx.quantize(arr, group_size=group_size, bits=b)
|
| 158 |
+
base = out_name[: -len(".weight")]
|
| 159 |
+
items = ((out_name, qw), (base + ".scales", sc), (base + ".biases", bi))
|
| 160 |
+
else:
|
| 161 |
+
keep_fp32 = arr.dtype == mx.float32 or arr.dtype == mx.int32
|
| 162 |
+
items = ((out_name, arr if keep_fp32 else arr.astype(out_dtype)),)
|
| 163 |
+
for k, v in items:
|
| 164 |
+
mx.eval(v)
|
| 165 |
+
buf[k] = v
|
| 166 |
+
nbytes += v.nbytes
|
| 167 |
+
if nbytes >= _SHARD_CAP_BYTES:
|
| 168 |
+
flush()
|
| 169 |
+
del tensors, converted
|
| 170 |
+
if verbose:
|
| 171 |
+
print(f"[convert] {shard} -> {shard_id} shards written", flush=True)
|
| 172 |
+
flush()
|
| 173 |
+
|
| 174 |
+
_finalize(dst, out_index, shard_id)
|
| 175 |
+
_write_config(src, dst, bits, group_size, expert_bits)
|
| 176 |
+
_copy_aux(src, dst)
|
| 177 |
+
if verbose:
|
| 178 |
+
print(f"[convert] dropped {dropped} mtp/dspark tensors")
|
| 179 |
+
return dst
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _finalize(dst, out_index, n):
|
| 183 |
+
remap = {}
|
| 184 |
+
for i in range(1, n + 1):
|
| 185 |
+
old, new = f"model-{i:05d}.safetensors", f"model-{i:05d}-of-{n:05d}.safetensors"
|
| 186 |
+
if os.path.exists(os.path.join(dst, old)):
|
| 187 |
+
os.rename(os.path.join(dst, old), os.path.join(dst, new))
|
| 188 |
+
remap[old] = new
|
| 189 |
+
wm = {k: remap[v] for k, v in out_index.items()}
|
| 190 |
+
total = sum(os.path.getsize(os.path.join(dst, f)) for f in set(wm.values()))
|
| 191 |
+
json.dump({"metadata": {"total_size": total}, "weight_map": wm},
|
| 192 |
+
open(os.path.join(dst, "model.safetensors.index.json"), "w"), indent=2)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _write_config(src, dst, bits, group_size, expert_bits=None):
|
| 196 |
+
cfg = json.load(open(os.path.join(src, "config.json")))
|
| 197 |
+
# the source quantization_config describes FP8/FP4 weights that no longer exist
|
| 198 |
+
cfg.pop("quantization_config", None)
|
| 199 |
+
cfg.pop("expert_dtype", None)
|
| 200 |
+
if bits is not None:
|
| 201 |
+
q = {"group_size": group_size, "bits": bits}
|
| 202 |
+
if expert_bits is not None:
|
| 203 |
+
q["expert_bits"] = expert_bits
|
| 204 |
+
cfg["quantization"] = q
|
| 205 |
+
json.dump(cfg, open(os.path.join(dst, "config.json"), "w"), indent=2)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _copy_aux(src, dst):
|
| 209 |
+
for pat in ("tokenizer*", "*.json", "*.jinja", "*.txt", "encoding"):
|
| 210 |
+
for p in glob.glob(os.path.join(src, pat)):
|
| 211 |
+
base = os.path.basename(p)
|
| 212 |
+
if base in ("config.json", "model.safetensors.index.json"):
|
| 213 |
+
continue
|
| 214 |
+
target = os.path.join(dst, base)
|
| 215 |
+
if os.path.isdir(p):
|
| 216 |
+
shutil.copytree(p, target, dirs_exist_ok=True)
|
| 217 |
+
else:
|
| 218 |
+
shutil.copy2(p, target)
|
deepseek_v4_mlx/dequant.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dequantize DeepSeek-V4's natively-quantized checkpoint to bf16.
|
| 2 |
+
|
| 3 |
+
The published checkpoint is quantized in *two* different formats, and neither is
|
| 4 |
+
a format MLX can consume directly:
|
| 5 |
+
|
| 6 |
+
* **Most matmul weights** — FP8 ``e4m3`` stored as ``uint8`` ``[out, in]``, with
|
| 7 |
+
one ``e8m0`` (exponent-only) scale per ``128x128`` block:
|
| 8 |
+
``scale[ceil(out/128), ceil(in/128)]``.
|
| 9 |
+
* **Routed experts** (``expert_dtype: "fp4"``) — FP4 ``e2m1`` packed **two values
|
| 10 |
+
per byte** into ``int8 [out, in//2]``, with one ``e8m0`` scale per 32 values
|
| 11 |
+
along the input dim: ``scale[out, in//32]``.
|
| 12 |
+
|
| 13 |
+
Everything else (norms, gate weights, compressor projections, embeddings, the LM
|
| 14 |
+
head, the HC parameters) is already bf16 or fp32 and passes through untouched.
|
| 15 |
+
|
| 16 |
+
The `e8m0` format is exponent-only: byte ``b`` denotes ``2**(b - 127)``, with
|
| 17 |
+
``b == 0`` reserved for zero. That makes every scale an exact power of two, so
|
| 18 |
+
applying it is lossless in fp32.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import mlx.core as mx
|
| 24 |
+
|
| 25 |
+
FP8_BLOCK = 128 # e4m3 weights: 128x128 scale blocks
|
| 26 |
+
FP4_BLOCK = 32 # e2m1 experts: 32 values per scale, along the input dim
|
| 27 |
+
|
| 28 |
+
# FP4 e2m1 code -> value. 1 sign bit, 2 exponent bits, 1 mantissa bit; the 8
|
| 29 |
+
# representable magnitudes are {0, .5, 1, 1.5, 2, 3, 4, 6} with a sign.
|
| 30 |
+
# Transcribed from FP4_TABLE in the reference inference/convert.py — note code 8
|
| 31 |
+
# ("negative zero") maps to +0.0 there, so it does the same here.
|
| 32 |
+
_E2M1 = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0,
|
| 33 |
+
0.0, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0, -6.0]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def e8m0_to_float(scale_u8: mx.array) -> mx.array:
|
| 37 |
+
"""Decode exponent-only fp8 scales: byte b -> 2**(b-127), with 0 -> 0."""
|
| 38 |
+
e = scale_u8.astype(mx.int32)
|
| 39 |
+
val = mx.power(mx.array(2.0, mx.float32), (e - 127).astype(mx.float32))
|
| 40 |
+
return mx.where(e == 0, mx.array(0.0, mx.float32), val)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _expand_blocks(scale: mx.array, rows: int, cols: int, br: int, bc: int) -> mx.array:
|
| 44 |
+
"""Broadcast a per-block scale grid up to full [rows, cols], then crop.
|
| 45 |
+
|
| 46 |
+
Crop matters: block counts use ceil, so the expanded grid can overhang the
|
| 47 |
+
weight when a dimension is not a multiple of the block size.
|
| 48 |
+
"""
|
| 49 |
+
s = mx.repeat(mx.repeat(scale, br, axis=0), bc, axis=1)
|
| 50 |
+
return s[:rows, :cols]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def dequant_fp8(weight_u8: mx.array, scale_u8: mx.array, dtype=mx.bfloat16) -> mx.array:
|
| 54 |
+
"""FP8 e4m3 ``[out, in]`` + e8m0 ``[out/128, in/128]`` block scales -> ``dtype``."""
|
| 55 |
+
w = mx.from_fp8(weight_u8, mx.float32)
|
| 56 |
+
rows, cols = w.shape
|
| 57 |
+
s = _expand_blocks(e8m0_to_float(scale_u8), rows, cols, FP8_BLOCK, FP8_BLOCK)
|
| 58 |
+
return (w * s).astype(dtype)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def unpack_fp4(packed_i8: mx.array) -> mx.array:
|
| 62 |
+
"""Unpack ``int8 [out, in//2]`` of two e2m1 nibbles into float32 ``[out, in]``.
|
| 63 |
+
|
| 64 |
+
Low nibble holds the even (lower) input index, high nibble the odd one — the
|
| 65 |
+
same ordering torch uses for ``float4_e2m1fn_x2``.
|
| 66 |
+
"""
|
| 67 |
+
b = packed_i8.view(mx.uint8).astype(mx.int32)
|
| 68 |
+
lo = b & 0x0F
|
| 69 |
+
hi = (b >> 4) & 0x0F
|
| 70 |
+
lut = mx.array(_E2M1, mx.float32)
|
| 71 |
+
vals = mx.stack([lut[lo], lut[hi]], axis=-1) # [out, in//2, 2]
|
| 72 |
+
return vals.reshape(vals.shape[0], -1) # [out, in]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def dequant_fp4(weight_i8: mx.array, scale_u8: mx.array, dtype=mx.bfloat16) -> mx.array:
|
| 76 |
+
"""Packed FP4 e2m1 ``[out, in//2]`` + e8m0 ``[out, in//32]`` scales -> ``dtype``."""
|
| 77 |
+
w = unpack_fp4(weight_i8) # [out, in]
|
| 78 |
+
rows, cols = w.shape
|
| 79 |
+
s = _expand_blocks(e8m0_to_float(scale_u8), rows, cols, 1, FP4_BLOCK)
|
| 80 |
+
return (w * s).astype(dtype)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def is_fp4_expert(name: str) -> bool:
|
| 84 |
+
"""Routed-expert matmuls are the FP4 ones; shared experts are FP8."""
|
| 85 |
+
return ".experts." in name and ".shared_experts." not in name
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def dequant_pair(name: str, weight: mx.array, scale: mx.array, dtype=mx.bfloat16) -> mx.array:
|
| 89 |
+
"""Dequantize one (weight, scale) pair, choosing the format from the name."""
|
| 90 |
+
if is_fp4_expert(name):
|
| 91 |
+
return dequant_fp4(weight, scale, dtype)
|
| 92 |
+
return dequant_fp8(weight, scale, dtype)
|
deepseek_v4_mlx/fakequant.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Activation fake-quantization — the inference-time half of DeepSeek-V4's QAT.
|
| 2 |
+
|
| 3 |
+
V4 was quantization-aware trained, and the reference *simulates* that quantization
|
| 4 |
+
during inference: several activations are rounded through FP8 (or FP4) and back to
|
| 5 |
+
bf16 before use. These are the ``inplace=True`` calls to ``act_quant`` /
|
| 6 |
+
``fp4_act_quant`` in ``inference/model.py``, and they are not optional — skipping
|
| 7 |
+
them changes the numbers the model was trained to see.
|
| 8 |
+
|
| 9 |
+
Both follow the same shape:
|
| 10 |
+
|
| 11 |
+
1. take the absolute max over each block along the last axis,
|
| 12 |
+
2. round the scale **up to a power of two**: ``2**ceil(log2(amax / dtype_max))``
|
| 13 |
+
(this is what ``scale_fmt="ue8m0"`` means),
|
| 14 |
+
3. divide, round to the target format, multiply back.
|
| 15 |
+
|
| 16 |
+
Applied at:
|
| 17 |
+
|
| 18 |
+
* ``kv[..., :-rope_dim]`` in attention and the compressor — FP8, 64-wide blocks;
|
| 19 |
+
the rope channels stay bf16 so positional precision is not degraded,
|
| 20 |
+
* indexer queries and its compressed KV — FP4, 32-wide blocks, after a Hadamard
|
| 21 |
+
rotation.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import mlx.core as mx
|
| 27 |
+
|
| 28 |
+
FP8_MAX = 448.0 # e4m3fn
|
| 29 |
+
FP4_MAX = 6.0 # e2m1
|
| 30 |
+
|
| 31 |
+
# e2m1 magnitudes; used to round a scaled value to the nearest representable FP4.
|
| 32 |
+
_E2M1_POS = mx.array([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0], dtype=mx.float32)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _pow2_scale(amax: mx.array, dtype_max: float) -> mx.array:
|
| 36 |
+
"""``2**ceil(log2(amax / dtype_max))``, matching ``fast_round_scale``.
|
| 37 |
+
|
| 38 |
+
Guarded so an all-zero block yields scale 1 rather than 0 or a NaN.
|
| 39 |
+
"""
|
| 40 |
+
r = amax / dtype_max
|
| 41 |
+
r = mx.maximum(r, 1e-38)
|
| 42 |
+
return mx.power(mx.array(2.0, mx.float32), mx.ceil(mx.log2(r)))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _blockify(x: mx.array, block: int):
|
| 46 |
+
n = x.shape[-1]
|
| 47 |
+
if n % block:
|
| 48 |
+
raise ValueError(f"last dim {n} not divisible by block {block}")
|
| 49 |
+
return x.reshape(*x.shape[:-1], n // block, block)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def fake_quant_fp8(x: mx.array, block: int = 64) -> mx.array:
|
| 53 |
+
"""Round ``x`` through e4m3 with per-block power-of-two scales, back to input dtype."""
|
| 54 |
+
dtype = x.dtype
|
| 55 |
+
shape = x.shape
|
| 56 |
+
xb = _blockify(x.astype(mx.float32), block)
|
| 57 |
+
amax = mx.max(mx.abs(xb), axis=-1, keepdims=True)
|
| 58 |
+
s = _pow2_scale(amax, FP8_MAX)
|
| 59 |
+
q = mx.from_fp8(mx.to_fp8(xb / s), mx.float32) * s
|
| 60 |
+
return q.reshape(shape).astype(dtype)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def fake_quant_fp4(x: mx.array, block: int = 32) -> mx.array:
|
| 64 |
+
"""Round ``x`` through e2m1 with per-block power-of-two scales, back to input dtype.
|
| 65 |
+
|
| 66 |
+
MLX has no fp4 cast, so rounding is done by snapping |v| to the nearest of the
|
| 67 |
+
eight e2m1 magnitudes (ties-to-even is not modelled; the reference kernel casts
|
| 68 |
+
via hardware, which rounds to nearest-even, so extreme midpoints can differ by
|
| 69 |
+
one code — well below the noise this simulation represents).
|
| 70 |
+
"""
|
| 71 |
+
dtype = x.dtype
|
| 72 |
+
shape = x.shape
|
| 73 |
+
xb = _blockify(x.astype(mx.float32), block)
|
| 74 |
+
amax = mx.max(mx.abs(xb), axis=-1, keepdims=True)
|
| 75 |
+
s = _pow2_scale(amax, FP4_MAX)
|
| 76 |
+
v = mx.clip(xb / s, -FP4_MAX, FP4_MAX)
|
| 77 |
+
|
| 78 |
+
sign = mx.sign(v)
|
| 79 |
+
mag = mx.abs(v)
|
| 80 |
+
# nearest of the 8 magnitudes: compare against midpoints
|
| 81 |
+
lut = _E2M1_POS
|
| 82 |
+
diffs = mx.abs(mag[..., None] - lut.reshape(*([1] * mag.ndim), -1))
|
| 83 |
+
nearest = mx.argmin(diffs, axis=-1)
|
| 84 |
+
q = sign * lut[nearest]
|
| 85 |
+
return (q * s).reshape(shape).astype(dtype)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def fake_quant_kv(kv: mx.array, rope_dim: int, block: int = 64) -> mx.array:
|
| 89 |
+
"""FP8-simulate the non-rope channels of a KV vector, leaving rope channels alone.
|
| 90 |
+
|
| 91 |
+
Mirrors ``act_quant(kv[..., :-rd], 64, ..., inplace=True)`` in the reference,
|
| 92 |
+
which quantizes a *slice* in place.
|
| 93 |
+
"""
|
| 94 |
+
nope = fake_quant_fp8(kv[..., :-rope_dim], block)
|
| 95 |
+
return mx.concatenate([nope, kv[..., -rope_dim:]], axis=-1)
|
deepseek_v4_mlx/generate.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Greedy generation with an incremental cache."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import mlx.core as mx
|
| 6 |
+
|
| 7 |
+
from .cache import make_cache
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def greedy_generate(model, args, prompt_ids, max_new_tokens: int = 32,
|
| 11 |
+
eos_id: int | None = 1, max_seq_len: int | None = None,
|
| 12 |
+
verbose: bool = False):
|
| 13 |
+
"""Prefill the prompt, then decode one token at a time against the cache."""
|
| 14 |
+
ids = list(prompt_ids)
|
| 15 |
+
cache = make_cache(args, bsz=1,
|
| 16 |
+
max_seq_len=max_seq_len or (len(ids) + max_new_tokens + 8))
|
| 17 |
+
|
| 18 |
+
logits = model(mx.array([ids]), last_logit_only=True, cache=cache)
|
| 19 |
+
nxt = int(mx.argmax(logits[0, -1]).item())
|
| 20 |
+
ids.append(nxt)
|
| 21 |
+
if verbose:
|
| 22 |
+
print(f"[gen] prefilled {len(prompt_ids)} tokens", flush=True)
|
| 23 |
+
|
| 24 |
+
for _ in range(max_new_tokens - 1):
|
| 25 |
+
if eos_id is not None and nxt == eos_id:
|
| 26 |
+
break
|
| 27 |
+
logits = model(mx.array([[nxt]]), last_logit_only=True, cache=cache)
|
| 28 |
+
nxt = int(mx.argmax(logits[0, -1]).item())
|
| 29 |
+
ids.append(nxt)
|
| 30 |
+
return ids
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_tokenizer(path_or_repo: str = "deepseek-ai/DeepSeek-V3"):
|
| 34 |
+
"""DeepSeek-V4 ships no tokenizer; V3's matches (same padded 129280 vocab,
|
| 35 |
+
same bos/eos, all V4 special tokens present)."""
|
| 36 |
+
from transformers import AutoTokenizer
|
| 37 |
+
return AutoTokenizer.from_pretrained(path_or_repo, trust_remote_code=True)
|
deepseek_v4_mlx/hyper_connections.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hyper-Connections (HC) — DeepSeek-V4's replacement for the plain residual stream.
|
| 2 |
+
|
| 3 |
+
Instead of carrying one hidden state, a V4 block carries ``hc_mult`` (=4) parallel
|
| 4 |
+
copies. Each sub-layer:
|
| 5 |
+
|
| 6 |
+
* ``hc_pre`` reduces the 4 copies to 1 via learned per-copy weights, and also
|
| 7 |
+
emits the ``post`` (expand) weights and a ``comb`` (combination) matrix,
|
| 8 |
+
* the sub-layer (attention or MoE) runs on that single reduced state,
|
| 9 |
+
* ``hc_post`` writes the result back out to 4 copies, mixing in the previous 4
|
| 10 |
+
via ``comb``.
|
| 11 |
+
|
| 12 |
+
All three weight sets come from one projection of the flattened state, split and
|
| 13 |
+
normalized by :func:`split_sinkhorn`. ``comb`` is made doubly-stochastic-ish by
|
| 14 |
+
Sinkhorn iteration (alternating row/column normalization), which is what keeps the
|
| 15 |
+
4 streams from collapsing into each other.
|
| 16 |
+
|
| 17 |
+
Ported from the reference ``hc_split_sinkhorn_kernel`` in ``inference/kernel.py``
|
| 18 |
+
and ``Block.hc_pre`` / ``Block.hc_post`` in ``inference/model.py``. The kernel is
|
| 19 |
+
pure elementwise + reductions, so this is a faithful transcription rather than an
|
| 20 |
+
approximation.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import mlx.core as mx
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def split_sinkhorn(mixes: mx.array, hc_scale: mx.array, hc_base: mx.array,
|
| 29 |
+
hc_mult: int = 4, sinkhorn_iters: int = 20, eps: float = 1e-6):
|
| 30 |
+
"""Split one projection into (pre, post, comb).
|
| 31 |
+
|
| 32 |
+
``mixes``: [..., (2 + hc) * hc] -- the projected+normalized state
|
| 33 |
+
``hc_scale``: [3] -- one scale per output group
|
| 34 |
+
``hc_base``: [(2 + hc) * hc] -- one bias per output element
|
| 35 |
+
|
| 36 |
+
Returns ``pre`` [..., hc], ``post`` [..., hc], ``comb`` [..., hc, hc].
|
| 37 |
+
|
| 38 |
+
Layout matches the reference kernel exactly: the first ``hc`` entries feed
|
| 39 |
+
``pre``, the next ``hc`` feed ``post``, and the remaining ``hc*hc`` form
|
| 40 |
+
``comb`` in row-major order.
|
| 41 |
+
"""
|
| 42 |
+
hc = hc_mult
|
| 43 |
+
mixes = mixes.astype(mx.float32)
|
| 44 |
+
scale = hc_scale.astype(mx.float32)
|
| 45 |
+
base = hc_base.astype(mx.float32)
|
| 46 |
+
|
| 47 |
+
# pre: sigmoid(x*s0 + b) + eps -- strictly positive reduce weights
|
| 48 |
+
pre = mx.sigmoid(mixes[..., :hc] * scale[0] + base[:hc]) + eps
|
| 49 |
+
# post: 2*sigmoid(x*s1 + b) -- expand weights, mean 1 at init
|
| 50 |
+
post = 2.0 * mx.sigmoid(mixes[..., hc:2 * hc] * scale[1] + base[hc:2 * hc])
|
| 51 |
+
|
| 52 |
+
comb = mixes[..., 2 * hc:] * scale[2] + base[2 * hc:]
|
| 53 |
+
comb = comb.reshape(*comb.shape[:-1], hc, hc)
|
| 54 |
+
|
| 55 |
+
# Row softmax, then one column normalization, then (iters-1) full Sinkhorn
|
| 56 |
+
# sweeps. The eps terms are inside the divisions in the reference kernel;
|
| 57 |
+
# keeping them there matters because comb rows can get very small.
|
| 58 |
+
comb = mx.softmax(comb, axis=-1) + eps
|
| 59 |
+
comb = comb / (comb.sum(axis=-2, keepdims=True) + eps)
|
| 60 |
+
for _ in range(sinkhorn_iters - 1):
|
| 61 |
+
comb = comb / (comb.sum(axis=-1, keepdims=True) + eps)
|
| 62 |
+
comb = comb / (comb.sum(axis=-2, keepdims=True) + eps)
|
| 63 |
+
|
| 64 |
+
return pre, post, comb
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def hc_pre(x: mx.array, hc_fn: mx.array, hc_scale: mx.array, hc_base: mx.array,
|
| 68 |
+
hc_mult: int, sinkhorn_iters: int, norm_eps: float, hc_eps: float):
|
| 69 |
+
"""Reduce the ``hc`` residual copies to one state.
|
| 70 |
+
|
| 71 |
+
``x``: [b, s, hc, d] -> returns ([b, s, d], post [b,s,hc], comb [b,s,hc,hc]).
|
| 72 |
+
|
| 73 |
+
The projection input is the *flattened* [b, s, hc*d] state scaled by its RMS
|
| 74 |
+
reciprocal — note the reference multiplies by rsqrt AFTER the linear, which is
|
| 75 |
+
equivalent here only because rsqrt is per-token; it is transcribed in that
|
| 76 |
+
order to keep the arithmetic bit-for-bit comparable.
|
| 77 |
+
"""
|
| 78 |
+
shape = x.shape
|
| 79 |
+
dtype = x.dtype
|
| 80 |
+
xf = x.reshape(*shape[:2], -1).astype(mx.float32)
|
| 81 |
+
rsqrt = mx.rsqrt(mx.mean(mx.square(xf), axis=-1, keepdims=True) + norm_eps)
|
| 82 |
+
mixes = (xf @ hc_fn.astype(mx.float32).T) * rsqrt
|
| 83 |
+
pre, post, comb = split_sinkhorn(mixes, hc_scale, hc_base, hc_mult, sinkhorn_iters, hc_eps)
|
| 84 |
+
y = mx.sum(pre[..., None] * xf.reshape(shape), axis=2)
|
| 85 |
+
return y.astype(dtype), post, comb
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def hc_post(x: mx.array, residual: mx.array, post: mx.array, comb: mx.array) -> mx.array:
|
| 89 |
+
"""Expand the sub-layer output back to ``hc`` copies and mix in the residual.
|
| 90 |
+
|
| 91 |
+
``x``: [b, s, d]; ``residual``: [b, s, hc, d]; returns [b, s, hc, d].
|
| 92 |
+
|
| 93 |
+
Output copy ``k`` is ``post[k]*x + sum_j comb[j, k] * residual[j]`` — residual is
|
| 94 |
+
indexed by ``j``, the axis being summed, so it must sit on axis 2 of the product
|
| 95 |
+
(the reference's ``residual.unsqueeze(-2)``). Broadcasting it onto axis 3 instead
|
| 96 |
+
silently computes ``(sum_j comb[j, k]) * residual[k]``, which has the right shape
|
| 97 |
+
and stays finite, so only a value comparison catches it.
|
| 98 |
+
"""
|
| 99 |
+
prod = comb[..., None] * residual[..., :, None, :] # [b, s, j, k, d]
|
| 100 |
+
out = post[..., None] * x[..., None, :] + mx.sum(prod, axis=2)
|
| 101 |
+
return out.astype(x.dtype)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def hc_head(x: mx.array, hc_fn: mx.array, hc_scale: mx.array, hc_base: mx.array,
|
| 105 |
+
norm_eps: float, hc_eps: float) -> mx.array:
|
| 106 |
+
"""Final reduction before the LM head: like :func:`hc_pre` but weights only.
|
| 107 |
+
|
| 108 |
+
Uses the raw sigmoid gate without the Sinkhorn split, matching
|
| 109 |
+
``Block.hc_head`` in the reference.
|
| 110 |
+
"""
|
| 111 |
+
shape = x.shape
|
| 112 |
+
dtype = x.dtype
|
| 113 |
+
xf = x.reshape(*shape[:2], -1).astype(mx.float32)
|
| 114 |
+
rsqrt = mx.rsqrt(mx.mean(mx.square(xf), axis=-1, keepdims=True) + norm_eps)
|
| 115 |
+
mixes = (xf @ hc_fn.astype(mx.float32).T) * rsqrt
|
| 116 |
+
pre = mx.sigmoid(mixes * hc_scale + hc_base) + hc_eps
|
| 117 |
+
y = mx.sum(pre[..., None] * xf.reshape(shape), axis=2)
|
| 118 |
+
return y.astype(dtype)
|
deepseek_v4_mlx/indexer.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The sparse-attention indexer — a second attention that decides what the first reads.
|
| 2 |
+
|
| 3 |
+
Each ratio-4 layer runs this alongside its sliding window. It keeps its own
|
| 4 |
+
:class:`Compressor` (the Hadamard-rotated, FP4 variant), scores every compressed
|
| 5 |
+
span against the current queries, and returns the indices of the top
|
| 6 |
+
``index_topk`` spans. Attention then reads its local window **plus exactly those**.
|
| 7 |
+
|
| 8 |
+
Scoring is deliberately cheap and low-precision: queries are Hadamard-rotated and
|
| 9 |
+
FP4-simulated, scores are ReLU'd before a learned per-head weighting collapses the
|
| 10 |
+
head axis. Positions the query may not see are marked ``-1`` rather than dropped,
|
| 11 |
+
so the returned index block stays rectangular for the gather downstream.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import mlx.core as mx
|
| 17 |
+
import mlx.nn as nn
|
| 18 |
+
|
| 19 |
+
from .compressor import Compressor
|
| 20 |
+
from .config import ModelArgs
|
| 21 |
+
from .fakequant import fake_quant_fp4
|
| 22 |
+
from .layers import apply_rotary_emb, hadamard_transform
|
| 23 |
+
|
| 24 |
+
NEG_INF = -1e30
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Indexer(nn.Module):
|
| 28 |
+
def __init__(self, args: ModelArgs, compress_ratio: int = 4):
|
| 29 |
+
super().__init__()
|
| 30 |
+
self.n_heads = args.index_n_heads
|
| 31 |
+
self.head_dim = args.index_head_dim
|
| 32 |
+
self.rope_head_dim = args.rope_head_dim
|
| 33 |
+
self.index_topk = args.index_topk
|
| 34 |
+
self.compress_ratio = compress_ratio
|
| 35 |
+
self.softmax_scale = self.head_dim ** -0.5
|
| 36 |
+
|
| 37 |
+
self.wq_b = nn.Linear(args.q_lora_rank, self.n_heads * self.head_dim, bias=False)
|
| 38 |
+
self.weights_proj = nn.Linear(args.dim, self.n_heads, bias=False)
|
| 39 |
+
self.compressor = Compressor(args, compress_ratio, self.head_dim, rotate=True)
|
| 40 |
+
|
| 41 |
+
def __call__(self, x: mx.array, qr: mx.array, cos: mx.array, sin: mx.array,
|
| 42 |
+
offset: int):
|
| 43 |
+
"""Prefill: choose compressed spans for every query position.
|
| 44 |
+
|
| 45 |
+
``x`` [b, s, dim] — hidden state, feeds the compressor and weighting
|
| 46 |
+
``qr`` [b, s, q_lora_rank] — the shared low-rank query, before wq_b
|
| 47 |
+
``offset`` — where compressed entries begin in attention's KV cache, so the
|
| 48 |
+
returned indices address that cache directly.
|
| 49 |
+
|
| 50 |
+
Returns ``[b, s, k]`` int32 indices, with ``-1`` marking "not visible".
|
| 51 |
+
"""
|
| 52 |
+
bsz, seqlen, _ = x.shape
|
| 53 |
+
ratio, rd = self.compress_ratio, self.rope_head_dim
|
| 54 |
+
|
| 55 |
+
q = self.wq_b(qr).reshape(bsz, seqlen, self.n_heads, self.head_dim)
|
| 56 |
+
rot = apply_rotary_emb(q[..., -rd:], cos[:seqlen], sin[:seqlen])
|
| 57 |
+
q = mx.concatenate([q[..., :-rd], rot], axis=-1)
|
| 58 |
+
q = hadamard_transform(q)
|
| 59 |
+
q = fake_quant_fp4(q, 32)
|
| 60 |
+
|
| 61 |
+
kv = self.compressor(x, cos, sin) # [b, nb, head_dim] or None
|
| 62 |
+
if kv is None:
|
| 63 |
+
return None
|
| 64 |
+
nb = kv.shape[1]
|
| 65 |
+
|
| 66 |
+
# per-head score, ReLU'd, then collapsed by a learned per-head weight
|
| 67 |
+
w = self.weights_proj(x) * (self.softmax_scale * self.n_heads ** -0.5)
|
| 68 |
+
scores = mx.einsum("bshd,btd->bsht", q.astype(mx.float32), kv.astype(mx.float32))
|
| 69 |
+
scores = mx.maximum(scores, 0.0) * w[..., None].astype(mx.float32)
|
| 70 |
+
scores = mx.sum(scores, axis=2) # [b, s, nb]
|
| 71 |
+
|
| 72 |
+
# span t is visible to query s only once the span is complete: t < (s+1)//ratio
|
| 73 |
+
vis = mx.arange(nb)[None, :] < (mx.arange(1, seqlen + 1)[:, None] // ratio)
|
| 74 |
+
scores = scores + mx.where(vis, 0.0, NEG_INF)[None]
|
| 75 |
+
|
| 76 |
+
k = min(self.index_topk, nb)
|
| 77 |
+
# argpartition yields unsigned indices; go to int32 before the -1 sentinel
|
| 78 |
+
idx = mx.argpartition(-scores, k - 1, axis=-1)[..., :k].astype(mx.int32)
|
| 79 |
+
|
| 80 |
+
# invisible spans may still be picked when fewer than k are visible; mark them
|
| 81 |
+
picked_vis = mx.take_along_axis(
|
| 82 |
+
mx.broadcast_to(vis[None], (bsz, seqlen, nb)), idx, axis=-1)
|
| 83 |
+
return mx.where(picked_vis, idx + offset, mx.array(-1, mx.int32))
|
| 84 |
+
|
| 85 |
+
def step(self, x: mx.array, qr: mx.array, cos: mx.array, sin: mx.array,
|
| 86 |
+
pos: int, cache, offset: int):
|
| 87 |
+
"""Decode one token: extend the compressed index, then pick top-k spans."""
|
| 88 |
+
rd = self.rope_head_dim
|
| 89 |
+
bsz = x.shape[0]
|
| 90 |
+
|
| 91 |
+
pooled = self.compressor.step(x, pos, cache.idx_comp, cos, sin)
|
| 92 |
+
if pooled is not None:
|
| 93 |
+
cache._adopt(pooled.dtype)
|
| 94 |
+
cache.idx_kv[:, pos // self.compress_ratio] = pooled[:, 0]
|
| 95 |
+
|
| 96 |
+
nb = (pos + 1) // self.compress_ratio
|
| 97 |
+
if nb <= 0:
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
q = self.wq_b(qr).reshape(bsz, 1, self.n_heads, self.head_dim)
|
| 101 |
+
rot = apply_rotary_emb(q[..., -rd:], cos[pos:pos + 1], sin[pos:pos + 1])
|
| 102 |
+
q = mx.concatenate([q[..., :-rd], rot], axis=-1)
|
| 103 |
+
q = fake_quant_fp4(hadamard_transform(q), 32)
|
| 104 |
+
|
| 105 |
+
kv = cache.idx_kv[:, :nb]
|
| 106 |
+
w = self.weights_proj(x) * (self.softmax_scale * self.n_heads ** -0.5)
|
| 107 |
+
scores = mx.einsum("bshd,btd->bsht", q.astype(mx.float32), kv.astype(mx.float32))
|
| 108 |
+
scores = mx.maximum(scores, 0.0) * w[..., None].astype(mx.float32)
|
| 109 |
+
scores = mx.sum(scores, axis=2) # [b, 1, nb]
|
| 110 |
+
|
| 111 |
+
k = min(self.index_topk, nb)
|
| 112 |
+
idx = mx.argpartition(-scores, k - 1, axis=-1)[..., :k].astype(mx.int32)
|
| 113 |
+
return idx + offset
|
deepseek_v4_mlx/layers.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core DeepSeek-V4 layers: RMSNorm, YaRN rope, and the Hadamard rotation.
|
| 2 |
+
|
| 3 |
+
Transcribed from ``inference/model.py``. Two details are easy to get wrong and are
|
| 4 |
+
called out where they occur:
|
| 5 |
+
|
| 6 |
+
* rope is applied to the **last** ``rope_head_dim`` channels only, pairing
|
| 7 |
+
*adjacent* elements as (real, imag) — not the split-halves convention used by
|
| 8 |
+
Llama-style models;
|
| 9 |
+
* attention applies the rope **inverse** to its output (``apply_rotary_emb(...,
|
| 10 |
+
inverse=True)``), which is why the conjugate path exists here at all.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
|
| 17 |
+
import mlx.core as mx
|
| 18 |
+
import mlx.nn as nn
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class RMSNorm(nn.Module):
|
| 22 |
+
"""RMSNorm computed in fp32, returning the input dtype (matches reference)."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, dim: int, eps: float = 1e-6):
|
| 25 |
+
super().__init__()
|
| 26 |
+
self.eps = eps
|
| 27 |
+
self.weight = mx.ones((dim,), dtype=mx.float32)
|
| 28 |
+
|
| 29 |
+
def __call__(self, x: mx.array) -> mx.array:
|
| 30 |
+
dtype = x.dtype
|
| 31 |
+
xf = x.astype(mx.float32)
|
| 32 |
+
var = mx.mean(mx.square(xf), axis=-1, keepdims=True)
|
| 33 |
+
xf = xf * mx.rsqrt(var + self.eps)
|
| 34 |
+
return (self.weight * xf).astype(dtype)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def precompute_freqs_cis(dim: int, seqlen: int, original_seq_len: int, base: float,
|
| 38 |
+
factor: float, beta_fast: int, beta_slow: int):
|
| 39 |
+
"""YaRN-scaled rotary frequencies, returned as (cos, sin) of shape [seqlen, dim//2].
|
| 40 |
+
|
| 41 |
+
MLX has no complex dtype in the ops we need, so the reference's ``polar``
|
| 42 |
+
representation is carried as an explicit cos/sin pair.
|
| 43 |
+
|
| 44 |
+
``original_seq_len == 0`` disables YaRN entirely, which the reference uses for
|
| 45 |
+
the pure sliding-window layers.
|
| 46 |
+
"""
|
| 47 |
+
def find_correction_dim(num_rotations, dim, base, max_seq_len):
|
| 48 |
+
return dim * math.log(max_seq_len / (num_rotations * 2 * math.pi)) / (2 * math.log(base))
|
| 49 |
+
|
| 50 |
+
def find_correction_range(low_rot, high_rot, dim, base, max_seq_len):
|
| 51 |
+
low = math.floor(find_correction_dim(low_rot, dim, base, max_seq_len))
|
| 52 |
+
high = math.ceil(find_correction_dim(high_rot, dim, base, max_seq_len))
|
| 53 |
+
return max(low, 0), min(high, dim - 1)
|
| 54 |
+
|
| 55 |
+
def linear_ramp_factor(mn, mx_, d):
|
| 56 |
+
if mn == mx_:
|
| 57 |
+
mx_ += 0.001
|
| 58 |
+
lin = (mx.arange(d, dtype=mx.float32) - mn) / (mx_ - mn)
|
| 59 |
+
return mx.clip(lin, 0, 1)
|
| 60 |
+
|
| 61 |
+
freqs = 1.0 / (base ** (mx.arange(0, dim, 2, dtype=mx.float32) / dim))
|
| 62 |
+
if original_seq_len > 0:
|
| 63 |
+
low, high = find_correction_range(beta_fast, beta_slow, dim, base, original_seq_len)
|
| 64 |
+
smooth = 1 - linear_ramp_factor(low, high, dim // 2)
|
| 65 |
+
freqs = freqs / factor * (1 - smooth) + freqs * smooth
|
| 66 |
+
|
| 67 |
+
t = mx.arange(seqlen, dtype=mx.float32)
|
| 68 |
+
ang = t[:, None] * freqs[None, :] # [seqlen, dim//2]
|
| 69 |
+
return mx.cos(ang), mx.sin(ang)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def apply_rotary_emb(x: mx.array, cos: mx.array, sin: mx.array, inverse: bool = False) -> mx.array:
|
| 73 |
+
"""Rotate ``x`` [..., s, (h,) d] using adjacent-pair complex multiplication.
|
| 74 |
+
|
| 75 |
+
``cos``/``sin`` are [s, d//2]. ``inverse=True`` conjugates (de-rotates), which
|
| 76 |
+
attention uses on its output.
|
| 77 |
+
"""
|
| 78 |
+
dtype = x.dtype
|
| 79 |
+
xf = x.astype(mx.float32)
|
| 80 |
+
shape = xf.shape
|
| 81 |
+
xf = xf.reshape(*shape[:-1], shape[-1] // 2, 2)
|
| 82 |
+
xr, xi = xf[..., 0], xf[..., 1]
|
| 83 |
+
|
| 84 |
+
# broadcast [s, d//2] against [b, s, d//2] or [b, s, h, d//2]
|
| 85 |
+
if xr.ndim == 4:
|
| 86 |
+
c = cos[None, :, None, :]
|
| 87 |
+
s = sin[None, :, None, :]
|
| 88 |
+
else:
|
| 89 |
+
c = cos[None, :, :]
|
| 90 |
+
s = sin[None, :, :]
|
| 91 |
+
if inverse:
|
| 92 |
+
s = -s
|
| 93 |
+
|
| 94 |
+
out = mx.stack([xr * c - xi * s, xr * s + xi * c], axis=-1)
|
| 95 |
+
return out.reshape(shape).astype(dtype)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def hadamard_transform(x: mx.array, scale: float | None = None) -> mx.array:
|
| 99 |
+
"""Fast Walsh-Hadamard transform along the last axis (power-of-two length).
|
| 100 |
+
|
| 101 |
+
Stands in for ``fast_hadamard_transform.hadamard_transform``, which the indexer
|
| 102 |
+
uses to spread information across channels before FP4 quantization. The
|
| 103 |
+
reference always calls it with ``scale = d ** -0.5``; that is the default here.
|
| 104 |
+
"""
|
| 105 |
+
d = x.shape[-1]
|
| 106 |
+
if d & (d - 1):
|
| 107 |
+
raise ValueError(f"hadamard_transform needs a power-of-two last dim, got {d}")
|
| 108 |
+
if scale is None:
|
| 109 |
+
scale = d ** -0.5
|
| 110 |
+
|
| 111 |
+
dtype = x.dtype
|
| 112 |
+
y = x.astype(mx.float32)
|
| 113 |
+
lead = y.shape[:-1]
|
| 114 |
+
h = 1
|
| 115 |
+
while h < d:
|
| 116 |
+
# pair up elements h apart and apply the 2x2 butterfly
|
| 117 |
+
y = y.reshape(*lead, d // (2 * h), 2, h)
|
| 118 |
+
a, b = y[..., 0, :], y[..., 1, :]
|
| 119 |
+
y = mx.stack([a + b, a - b], axis=-2)
|
| 120 |
+
y = y.reshape(*lead, d)
|
| 121 |
+
h *= 2
|
| 122 |
+
return (y * scale).astype(dtype)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def swiglu(gate: mx.array, up: mx.array, limit: float = 0.0) -> mx.array:
|
| 126 |
+
"""SwiGLU with V4's asymmetric clamp: ``up`` two-sided, ``gate`` upper-only."""
|
| 127 |
+
g = gate.astype(mx.float32)
|
| 128 |
+
u = up.astype(mx.float32)
|
| 129 |
+
if limit > 0:
|
| 130 |
+
u = mx.clip(u, -limit, limit)
|
| 131 |
+
g = mx.minimum(g, limit)
|
| 132 |
+
return (g * mx.sigmoid(g)) * u
|
deepseek_v4_mlx/load.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load a converted DeepSeek-V4 MLX build."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import glob
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
import mlx.core as mx
|
| 10 |
+
import mlx.nn as nn
|
| 11 |
+
from mlx.utils import tree_flatten
|
| 12 |
+
|
| 13 |
+
from .config import ModelArgs
|
| 14 |
+
from .convert import bits_for, is_quant_target
|
| 15 |
+
from .model import Model
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def quant_predicate(group_size: int, bits: int, expert_bits: int | None):
|
| 19 |
+
"""Quantize exactly what the converter did, at the same per-tensor bit width.
|
| 20 |
+
|
| 21 |
+
Returning a dict rather than True lets a mixed build load: MLX applies the
|
| 22 |
+
per-module ``{"group_size", "bits"}`` it returns. Answering plain ``True`` would
|
| 23 |
+
quantize every module at the top-level ``bits`` and silently mis-read the
|
| 24 |
+
experts, which are stored at ``expert_bits``.
|
| 25 |
+
"""
|
| 26 |
+
def pred(path, module):
|
| 27 |
+
if not hasattr(module, "to_quantized"):
|
| 28 |
+
return False
|
| 29 |
+
w = getattr(module, "weight", None)
|
| 30 |
+
if w is None:
|
| 31 |
+
return False
|
| 32 |
+
name = path + ".weight"
|
| 33 |
+
if not is_quant_target(name, w.shape[-1], group_size):
|
| 34 |
+
return False
|
| 35 |
+
return {"group_size": group_size, "bits": bits_for(name, bits, expert_bits)}
|
| 36 |
+
return pred
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load(path: str, lazy: bool = False):
|
| 40 |
+
cfg = json.load(open(os.path.join(path, "config.json")))
|
| 41 |
+
args = ModelArgs.from_dict(cfg)
|
| 42 |
+
model = Model(args)
|
| 43 |
+
|
| 44 |
+
q = cfg.get("quantization")
|
| 45 |
+
if q:
|
| 46 |
+
nn.quantize(model, group_size=q["group_size"], bits=q["bits"],
|
| 47 |
+
class_predicate=quant_predicate(q["group_size"], q["bits"],
|
| 48 |
+
q.get("expert_bits")))
|
| 49 |
+
|
| 50 |
+
loaded = set()
|
| 51 |
+
for shard in sorted(glob.glob(os.path.join(path, "*.safetensors"))):
|
| 52 |
+
w = mx.load(shard)
|
| 53 |
+
model.load_weights(list(w.items()), strict=False)
|
| 54 |
+
if not lazy:
|
| 55 |
+
# materialize this shard now (bounded graph) rather than building one
|
| 56 |
+
# enormous eval over every parameter at the end
|
| 57 |
+
mx.eval(list(w.values()))
|
| 58 |
+
loaded.update(w.keys())
|
| 59 |
+
del w
|
| 60 |
+
|
| 61 |
+
expected = {k for k, _ in tree_flatten(model.parameters())}
|
| 62 |
+
missing = expected - loaded
|
| 63 |
+
if missing:
|
| 64 |
+
raise ValueError(f"{len(missing)} params missing from checkpoint, "
|
| 65 |
+
f"e.g. {sorted(missing)[:3]}")
|
| 66 |
+
|
| 67 |
+
model.eval()
|
| 68 |
+
return model, args
|
deepseek_v4_mlx/model.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DeepSeek-V4 block and full model.
|
| 2 |
+
|
| 3 |
+
The block is where Hyper-Connections meet the sub-layers: instead of
|
| 4 |
+
``x = x + attn(norm(x))`` it is
|
| 5 |
+
|
| 6 |
+
residual = x # [b, s, hc, d]
|
| 7 |
+
x, post, comb = hc_pre(x, ...) # 4 copies -> 1
|
| 8 |
+
x = attn(attn_norm(x))
|
| 9 |
+
x = hc_post(x, residual, post, comb) # 1 -> 4, mixing the previous 4
|
| 10 |
+
|
| 11 |
+
and the same again around the MoE. The MoE additionally takes ``input_ids``,
|
| 12 |
+
because hash-routed layers look their experts up by token id.
|
| 13 |
+
|
| 14 |
+
The model expands the embedding to ``hc_mult`` identical copies on the way in and
|
| 15 |
+
collapses them with a single gate (``hc_head``) on the way out.
|
| 16 |
+
|
| 17 |
+
DSpark and the MTP head are deliberately absent: they hang off the reference's
|
| 18 |
+
``forward_spec`` path and are not used for ordinary generation.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import mlx.core as mx
|
| 24 |
+
import mlx.nn as nn
|
| 25 |
+
|
| 26 |
+
from .attention import Attention
|
| 27 |
+
from .config import ModelArgs
|
| 28 |
+
from .hyper_connections import hc_head, hc_post, hc_pre
|
| 29 |
+
from .layers import RMSNorm
|
| 30 |
+
from .moe import MoE
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Block(nn.Module):
|
| 34 |
+
def __init__(self, layer_id: int, args: ModelArgs):
|
| 35 |
+
super().__init__()
|
| 36 |
+
self.layer_id = layer_id
|
| 37 |
+
self.norm_eps = args.norm_eps
|
| 38 |
+
self.hc_mult = args.hc_mult
|
| 39 |
+
self.hc_iters = args.hc_sinkhorn_iters
|
| 40 |
+
self.hc_eps = args.hc_eps
|
| 41 |
+
|
| 42 |
+
self.attn = Attention(layer_id, args)
|
| 43 |
+
self.ffn = MoE(layer_id, args)
|
| 44 |
+
self.attn_norm = RMSNorm(args.dim, args.norm_eps)
|
| 45 |
+
self.ffn_norm = RMSNorm(args.dim, args.norm_eps)
|
| 46 |
+
|
| 47 |
+
mix_hc = (2 + args.hc_mult) * args.hc_mult
|
| 48 |
+
hc_dim = args.hc_mult * args.dim
|
| 49 |
+
self.hc_attn_fn = mx.zeros((mix_hc, hc_dim), dtype=mx.float32)
|
| 50 |
+
self.hc_ffn_fn = mx.zeros((mix_hc, hc_dim), dtype=mx.float32)
|
| 51 |
+
self.hc_attn_base = mx.zeros((mix_hc,), dtype=mx.float32)
|
| 52 |
+
self.hc_ffn_base = mx.zeros((mix_hc,), dtype=mx.float32)
|
| 53 |
+
self.hc_attn_scale = mx.zeros((3,), dtype=mx.float32)
|
| 54 |
+
self.hc_ffn_scale = mx.zeros((3,), dtype=mx.float32)
|
| 55 |
+
|
| 56 |
+
def __call__(self, x: mx.array, input_ids: mx.array | None = None,
|
| 57 |
+
cache=None) -> mx.array:
|
| 58 |
+
residual = x
|
| 59 |
+
h, post, comb = hc_pre(x, self.hc_attn_fn, self.hc_attn_scale, self.hc_attn_base,
|
| 60 |
+
self.hc_mult, self.hc_iters, self.norm_eps, self.hc_eps)
|
| 61 |
+
h = self.attn(self.attn_norm(h), cache)
|
| 62 |
+
x = hc_post(h, residual, post, comb)
|
| 63 |
+
|
| 64 |
+
residual = x
|
| 65 |
+
h, post, comb = hc_pre(x, self.hc_ffn_fn, self.hc_ffn_scale, self.hc_ffn_base,
|
| 66 |
+
self.hc_mult, self.hc_iters, self.norm_eps, self.hc_eps)
|
| 67 |
+
h = self.ffn(self.ffn_norm(h), input_ids)
|
| 68 |
+
return hc_post(h, residual, post, comb)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class Model(nn.Module):
|
| 72 |
+
"""embed -> expand to hc copies -> blocks -> collapse -> norm -> logits."""
|
| 73 |
+
|
| 74 |
+
def __init__(self, args: ModelArgs):
|
| 75 |
+
super().__init__()
|
| 76 |
+
self.args = args
|
| 77 |
+
self.hc_mult = args.hc_mult
|
| 78 |
+
self.embed = nn.Embedding(args.vocab_size, args.dim)
|
| 79 |
+
self.layers = [Block(i, args) for i in range(args.n_layers)]
|
| 80 |
+
self.norm = RMSNorm(args.dim, args.norm_eps)
|
| 81 |
+
self.head = nn.Linear(args.dim, args.vocab_size, bias=False)
|
| 82 |
+
|
| 83 |
+
self.hc_head_fn = mx.zeros((args.hc_mult, args.hc_mult * args.dim), dtype=mx.float32)
|
| 84 |
+
self.hc_head_base = mx.zeros((args.hc_mult,), dtype=mx.float32)
|
| 85 |
+
self.hc_head_scale = mx.zeros((1,), dtype=mx.float32)
|
| 86 |
+
|
| 87 |
+
def __call__(self, input_ids: mx.array, last_logit_only: bool = False,
|
| 88 |
+
cache=None) -> mx.array:
|
| 89 |
+
h = self.embed(input_ids)
|
| 90 |
+
# the four Hyper-Connection streams start as identical copies
|
| 91 |
+
h = mx.broadcast_to(h[:, :, None, :], (*h.shape[:2], self.hc_mult, h.shape[-1]))
|
| 92 |
+
|
| 93 |
+
for i, layer in enumerate(self.layers):
|
| 94 |
+
h = layer(h, input_ids, cache[i] if cache is not None else None)
|
| 95 |
+
|
| 96 |
+
h = hc_head(h, self.hc_head_fn, self.hc_head_scale, self.hc_head_base,
|
| 97 |
+
self.args.norm_eps, self.args.hc_eps)
|
| 98 |
+
h = self.norm(h)
|
| 99 |
+
if last_logit_only:
|
| 100 |
+
h = h[:, -1:]
|
| 101 |
+
# the reference keeps the LM head in fp32
|
| 102 |
+
return self.head(h.astype(mx.float32))
|
deepseek_v4_mlx/moe.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DeepSeek-V4 MoE: hash-or-score routing, clamped-SwiGLU experts, one shared expert.
|
| 2 |
+
|
| 3 |
+
Two things differ from every other MoE in ``mlx-lm``:
|
| 4 |
+
|
| 5 |
+
* **The first ``n_hash_layers`` layers do not score experts at all.** They read the
|
| 6 |
+
six expert indices straight out of a ``tid2eid[vocab_size, n_activated]`` table
|
| 7 |
+
indexed by *token id*, so routing there is independent of context.
|
| 8 |
+
* **The selection bias does not reach the routing weights.** Scores are captured
|
| 9 |
+
*before* the bias is added; the bias only reorders the top-k. Applying it to the
|
| 10 |
+
weights too is the obvious mistake and would be invisible on a smoke test.
|
| 11 |
+
|
| 12 |
+
Routing weights are applied after ``down_proj`` rather than before it as in the
|
| 13 |
+
reference. ``down_proj`` is linear and the weight is one scalar per token, so
|
| 14 |
+
``w2(w * h) == w * w2(h)``; doing it afterwards is what lets the batched
|
| 15 |
+
:class:`SwitchGLU` gather-matmul be used instead of a 256-iteration Python loop.
|
| 16 |
+
|
| 17 |
+
That rewrite sums each token's expert outputs, whereas the reference scatters them.
|
| 18 |
+
The two agree as long as a token's expert indices are distinct — which holds for
|
| 19 |
+
this checkpoint: every sampled ``tid2eid`` row contains 6 distinct experts, and
|
| 20 |
+
scored routing takes a top-k, which cannot repeat.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import mlx.core as mx
|
| 26 |
+
import mlx.nn as nn
|
| 27 |
+
from mlx_lm.models.switch_layers import SwitchGLU
|
| 28 |
+
|
| 29 |
+
from .config import ModelArgs
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ClampedSwiGLU(nn.Module):
|
| 33 |
+
"""SwiGLU with V4's asymmetric clamp: ``up`` two-sided, ``gate`` upper-only.
|
| 34 |
+
|
| 35 |
+
Called as ``activation(x_up, x_gate)`` to match ``SwitchGLU``.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, limit: float = 0.0):
|
| 39 |
+
super().__init__()
|
| 40 |
+
self.limit = limit
|
| 41 |
+
|
| 42 |
+
def __call__(self, x, gate):
|
| 43 |
+
if self.limit > 0:
|
| 44 |
+
x = mx.clip(x, -self.limit, self.limit)
|
| 45 |
+
gate = mx.minimum(gate, self.limit)
|
| 46 |
+
return nn.silu(gate) * x
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Gate(nn.Module):
|
| 50 |
+
"""Routes each token to ``n_activated_experts`` experts.
|
| 51 |
+
|
| 52 |
+
``hash=True`` layers use the ``tid2eid`` lookup and carry no bias; scored
|
| 53 |
+
layers carry a per-expert selection bias.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, layer_id: int, args: ModelArgs):
|
| 57 |
+
super().__init__()
|
| 58 |
+
self.topk = args.n_activated_experts
|
| 59 |
+
self.score_func = args.score_func
|
| 60 |
+
self.route_scale = args.route_scale
|
| 61 |
+
self.hash = args.is_hash_layer(layer_id)
|
| 62 |
+
n_exp = args.n_experts_for(layer_id)
|
| 63 |
+
self.weight = mx.zeros((n_exp, args.dim))
|
| 64 |
+
if self.hash:
|
| 65 |
+
self.tid2eid = mx.zeros((args.vocab_size, args.n_activated_experts), dtype=mx.int32)
|
| 66 |
+
else:
|
| 67 |
+
self.bias = mx.zeros((n_exp,), dtype=mx.float32)
|
| 68 |
+
|
| 69 |
+
def __call__(self, x: mx.array, input_ids: mx.array | None = None):
|
| 70 |
+
scores = x.astype(mx.float32) @ self.weight.astype(mx.float32).T
|
| 71 |
+
if self.score_func == "softmax":
|
| 72 |
+
scores = mx.softmax(scores, axis=-1)
|
| 73 |
+
elif self.score_func == "sigmoid":
|
| 74 |
+
scores = mx.sigmoid(scores)
|
| 75 |
+
else: # sqrtsoftplus
|
| 76 |
+
scores = mx.sqrt(nn.softplus(scores))
|
| 77 |
+
|
| 78 |
+
original_scores = scores
|
| 79 |
+
if not self.hash:
|
| 80 |
+
# bias shifts *selection* only — weights are read from original_scores
|
| 81 |
+
scores = scores + self.bias
|
| 82 |
+
indices = mx.argpartition(-scores, self.topk - 1, axis=-1)[..., :self.topk]
|
| 83 |
+
# argpartition does not order within the partition; sort so the expert
|
| 84 |
+
# order matches topk() and the gathered weights line up.
|
| 85 |
+
part = mx.take_along_axis(scores, indices, axis=-1)
|
| 86 |
+
order = mx.argsort(-part, axis=-1)
|
| 87 |
+
indices = mx.take_along_axis(indices, order, axis=-1)
|
| 88 |
+
else:
|
| 89 |
+
indices = self.tid2eid[input_ids]
|
| 90 |
+
|
| 91 |
+
weights = mx.take_along_axis(original_scores, indices, axis=-1)
|
| 92 |
+
if self.score_func != "softmax":
|
| 93 |
+
weights = weights / mx.sum(weights, axis=-1, keepdims=True)
|
| 94 |
+
weights = weights * self.route_scale
|
| 95 |
+
return weights, indices
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class MoE(nn.Module):
|
| 99 |
+
"""256 routed experts (top-6) plus 1 always-on shared expert."""
|
| 100 |
+
|
| 101 |
+
def __init__(self, layer_id: int, args: ModelArgs):
|
| 102 |
+
super().__init__()
|
| 103 |
+
self.dim = args.dim
|
| 104 |
+
self.gate = Gate(layer_id, args)
|
| 105 |
+
act = ClampedSwiGLU(args.swiglu_limit)
|
| 106 |
+
self.experts = SwitchGLU(args.dim, args.moe_inter_dim,
|
| 107 |
+
args.n_experts_for(layer_id), activation=act)
|
| 108 |
+
self.shared_experts = SharedExpert(args.dim, args.moe_inter_dim, args.swiglu_limit)
|
| 109 |
+
|
| 110 |
+
def __call__(self, x: mx.array, input_ids: mx.array | None = None) -> mx.array:
|
| 111 |
+
shape = x.shape
|
| 112 |
+
xf = x.reshape(-1, self.dim)
|
| 113 |
+
ids = input_ids.reshape(-1) if input_ids is not None else None
|
| 114 |
+
|
| 115 |
+
weights, indices = self.gate(xf, ids)
|
| 116 |
+
y = self.experts(xf, indices) # [tokens, topk, dim]
|
| 117 |
+
y = (y * weights[..., None].astype(y.dtype)).sum(axis=-2)
|
| 118 |
+
y = y + self.shared_experts(xf)
|
| 119 |
+
return y.reshape(shape).astype(x.dtype)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class SharedExpert(nn.Module):
|
| 123 |
+
"""The always-on expert. Same clamped SwiGLU, no routing weight."""
|
| 124 |
+
|
| 125 |
+
def __init__(self, dim: int, inter_dim: int, limit: float = 0.0):
|
| 126 |
+
super().__init__()
|
| 127 |
+
self.w1 = nn.Linear(dim, inter_dim, bias=False)
|
| 128 |
+
self.w2 = nn.Linear(inter_dim, dim, bias=False)
|
| 129 |
+
self.w3 = nn.Linear(dim, inter_dim, bias=False)
|
| 130 |
+
self.limit = limit
|
| 131 |
+
|
| 132 |
+
def __call__(self, x: mx.array) -> mx.array:
|
| 133 |
+
dtype = x.dtype
|
| 134 |
+
gate = self.w1(x).astype(mx.float32)
|
| 135 |
+
up = self.w3(x).astype(mx.float32)
|
| 136 |
+
if self.limit > 0:
|
| 137 |
+
up = mx.clip(up, -self.limit, self.limit)
|
| 138 |
+
gate = mx.minimum(gate, self.limit)
|
| 139 |
+
h = nn.silu(gate) * up
|
| 140 |
+
return self.w2(h.astype(dtype))
|
deepseek_v4_mlx/sparse_attention.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gather-based sparse attention — the MLX stand-in for the TileLang kernel.
|
| 2 |
+
|
| 3 |
+
Each query position attends to an explicit list of KV indices rather than a
|
| 4 |
+
contiguous range. Semantics transcribed from ``sparse_attn_kernel`` in
|
| 5 |
+
``inference/kernel.py``:
|
| 6 |
+
|
| 7 |
+
* ``kv`` is a **single** shared vector per position — MLA, so key and value are
|
| 8 |
+
the same tensor, not two projections;
|
| 9 |
+
* an index of ``-1`` means "not visible": its logit is ``-inf`` and it contributes
|
| 10 |
+
nothing;
|
| 11 |
+
* the learned ``attn_sink`` enters the softmax **denominator only**. It carries no
|
| 12 |
+
value vector, so a head whose real logits are all small attends to essentially
|
| 13 |
+
nothing rather than being forced to distribute weight over whatever is present.
|
| 14 |
+
|
| 15 |
+
The reference computes this with an online (FlashAttention-style) softmax for
|
| 16 |
+
memory reasons. A plain max-subtracted softmax is numerically equivalent; queries
|
| 17 |
+
are chunked here so the gathered ``[b, chunk, k, d]`` block stays bounded.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import mlx.core as mx
|
| 23 |
+
|
| 24 |
+
NEG_INF = -1e30
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _gather_kv(kv: mx.array, idx: mx.array) -> mx.array:
|
| 28 |
+
"""``kv`` [b, n, d], ``idx`` [b, m, k] -> [b, m, k, d], row 0 for negative idx."""
|
| 29 |
+
b, n, d = kv.shape
|
| 30 |
+
flat = kv.reshape(b * n, d)
|
| 31 |
+
safe = mx.maximum(idx, 0).astype(mx.int32)
|
| 32 |
+
base = (mx.arange(b, dtype=mx.int32) * n).reshape(b, 1, 1)
|
| 33 |
+
return flat[(safe + base).reshape(-1)].reshape(*idx.shape, d)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def sparse_attn(q: mx.array, kv: mx.array, attn_sink: mx.array, topk_idxs: mx.array,
|
| 37 |
+
softmax_scale: float, chunk: int = 256) -> mx.array:
|
| 38 |
+
"""Attend each query to its own list of KV positions.
|
| 39 |
+
|
| 40 |
+
``q`` [b, m, h, d]
|
| 41 |
+
``kv`` [b, n, d] shared key/value (MLA)
|
| 42 |
+
``attn_sink`` [h] per-head null-attention logit
|
| 43 |
+
``topk_idxs`` [b, m, k] int32, -1 = masked
|
| 44 |
+
returns [b, m, h, d]
|
| 45 |
+
"""
|
| 46 |
+
b, m, h, d = q.shape
|
| 47 |
+
sink = attn_sink.astype(mx.float32).reshape(1, 1, h, 1)
|
| 48 |
+
|
| 49 |
+
outs = []
|
| 50 |
+
for start in range(0, m, chunk):
|
| 51 |
+
stop = min(start + chunk, m)
|
| 52 |
+
qc = q[:, start:stop].astype(mx.float32) # [b, c, h, d]
|
| 53 |
+
ic = topk_idxs[:, start:stop] # [b, c, k]
|
| 54 |
+
kvc = _gather_kv(kv, ic).astype(mx.float32) # [b, c, k, d]
|
| 55 |
+
|
| 56 |
+
# [b, c, h, k]
|
| 57 |
+
logits = mx.einsum("bchd,bckd->bchk", qc, kvc) * softmax_scale
|
| 58 |
+
valid = (ic >= 0)[:, :, None, :]
|
| 59 |
+
logits = mx.where(valid, logits, NEG_INF)
|
| 60 |
+
|
| 61 |
+
mmax = mx.max(logits, axis=-1, keepdims=True) # [b, c, h, 1]
|
| 62 |
+
# the sink competes with the real logits for the running max
|
| 63 |
+
mmax = mx.maximum(mmax, sink)
|
| 64 |
+
w = mx.exp(logits - mmax)
|
| 65 |
+
w = mx.where(valid, w, 0.0)
|
| 66 |
+
denom = mx.sum(w, axis=-1, keepdims=True) + mx.exp(sink - mmax)
|
| 67 |
+
|
| 68 |
+
o = mx.einsum("bchk,bckd->bchd", w, kvc) / denom
|
| 69 |
+
outs.append(o.astype(q.dtype))
|
| 70 |
+
|
| 71 |
+
return mx.concatenate(outs, axis=1) if len(outs) > 1 else outs[0]
|
encoding/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DeepSeek-V4 Encoding
|
| 2 |
+
|
| 3 |
+
This document describes the prompt encoding format used by DeepSeek-V4 series models. The encoding handles multi-turn conversations, tool calling, extended thinking (reasoning), and quick instruction tasks.
|
| 4 |
+
|
| 5 |
+
A self-contained reference implementation is provided in `encoding_dsv4.py`.
|
| 6 |
+
|
| 7 |
+
## Quick Start
|
| 8 |
+
|
| 9 |
+
```python
|
| 10 |
+
from encoding_dsv4 import encode_messages, parse_message_from_completion_text
|
| 11 |
+
|
| 12 |
+
# Encode a conversation
|
| 13 |
+
messages = [
|
| 14 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 15 |
+
{"role": "user", "content": "What is 2+2?"},
|
| 16 |
+
]
|
| 17 |
+
prompt = encode_messages(messages, thinking_mode="thinking")
|
| 18 |
+
# => "<|begin▁of▁sentence|>You are a helpful assistant.<|User|>What is 2+2?<|Assistant|><think>"
|
| 19 |
+
|
| 20 |
+
# Parse model output back to structured message
|
| 21 |
+
completion = "Simple arithmetic.</think>2 + 2 = 4.<|end▁of▁sentence|>"
|
| 22 |
+
parsed = parse_message_from_completion_text(completion, thinking_mode="thinking")
|
| 23 |
+
# => {"role": "assistant", "reasoning_content": "Simple arithmetic.", "content": "2 + 2 = 4.", "tool_calls": []}
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
> **Note:** The `parse_message_from_completion_text` function is designed to handle well-formatted model output only. It does not attempt to correct or recover from malformed output that the model might occasionally generate. For production use, additional error handling is recommended.
|
| 27 |
+
|
| 28 |
+
## Message Format
|
| 29 |
+
|
| 30 |
+
### Special Tokens
|
| 31 |
+
|
| 32 |
+
| Token | Purpose |
|
| 33 |
+
|-------|---------|
|
| 34 |
+
| `<|begin▁of▁sentence|>` | Beginning of sequence (BOS) |
|
| 35 |
+
| `<|end▁of▁sentence|>` | End of assistant turn (EOS) |
|
| 36 |
+
| `<|User|>` | User turn prefix |
|
| 37 |
+
| `<|Assistant|>` | Assistant turn prefix |
|
| 38 |
+
| `<|latest_reminder|>` | Latest reminder (date, locale, etc.) |
|
| 39 |
+
| `<think>` / `</think>` | Reasoning block delimiters |
|
| 40 |
+
| `|DSML|` | DSML markup token |
|
| 41 |
+
|
| 42 |
+
### Roles
|
| 43 |
+
|
| 44 |
+
The encoding supports the following message roles: `system`, `user`, `assistant`, `tool`, `latest_reminder`, and `developer`.
|
| 45 |
+
|
| 46 |
+
> **Note on the `developer` role:** The `developer` role is used exclusively in the internal search agent pipeline. It is not needed for general-purpose chat or tool-calling tasks, and the official API does not accept messages with this role.
|
| 47 |
+
|
| 48 |
+
### Basic Chat
|
| 49 |
+
|
| 50 |
+
A simple multi-turn conversation is encoded as:
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
<|begin▁of▁sentence|>{system_prompt}
|
| 54 |
+
<|User|>{user_message}<|Assistant|></think>{response}<|end▁of▁sentence|>
|
| 55 |
+
<|User|>{user_message_2}<|Assistant|></think>{response_2}<|end▁of▁sentence|>
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
- The BOS token is prepended at the very beginning of the conversation.
|
| 59 |
+
- In **chat mode** (`thinking_mode="chat"`), `</think>` is placed right after `<|Assistant|>` to immediately close the thinking block, so the model generates content directly.
|
| 60 |
+
|
| 61 |
+
### Interleaved Thinking Mode
|
| 62 |
+
|
| 63 |
+
In **thinking mode** (`thinking_mode="thinking"`), the model produces explicit reasoning inside `<think>...</think>` blocks before responding.
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
<|begin▁of▁sentence|>{system_prompt}
|
| 67 |
+
<|User|>{message}<|Assistant|><think>{reasoning}</think>{response}<|end▁of▁sentence|>
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
The `drop_thinking` parameter (default `True`) controls whether reasoning from earlier turns is preserved:
|
| 71 |
+
|
| 72 |
+
- **Without tools**: `drop_thinking` takes effect. Reasoning content from assistant turns **before** the last user message is stripped. Only the final assistant turn retains its `<think>...</think>` block.
|
| 73 |
+
- **With tools** (on system or developer message): `drop_thinking` is automatically disabled. All turns retain their reasoning, because tool-calling conversations require full context for the model to track multi-step reasoning across tool calls.
|
| 74 |
+
|
| 75 |
+
### Tool Calling (DSML Format)
|
| 76 |
+
|
| 77 |
+
Tools are defined on the `system` or `developer` message via the `tools` field (OpenAI-compatible format). When tools are present, the following schema block is injected into the system/user prompt:
|
| 78 |
+
|
| 79 |
+
```
|
| 80 |
+
## Tools
|
| 81 |
+
|
| 82 |
+
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|tool_calls>" block like the following:
|
| 83 |
+
|
| 84 |
+
<|DSML|tool_calls>
|
| 85 |
+
<|DSML|invoke name="$TOOL_NAME">
|
| 86 |
+
<|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
|
| 87 |
+
...
|
| 88 |
+
</|DSML|invoke>
|
| 89 |
+
<|DSML|invoke name="$TOOL_NAME2">
|
| 90 |
+
...
|
| 91 |
+
</|DSML|invoke>
|
| 92 |
+
</|DSML|tool_calls>
|
| 93 |
+
|
| 94 |
+
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
|
| 95 |
+
|
| 96 |
+
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
|
| 97 |
+
|
| 98 |
+
Otherwise, output directly after </think> with tool calls or final response.
|
| 99 |
+
|
| 100 |
+
### Available Tool Schemas
|
| 101 |
+
|
| 102 |
+
{tool_definitions_json}
|
| 103 |
+
|
| 104 |
+
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
An actual tool call in the assistant turn looks like:
|
| 108 |
+
|
| 109 |
+
```xml
|
| 110 |
+
<|DSML|tool_calls>
|
| 111 |
+
<|DSML|invoke name="function_name">
|
| 112 |
+
<|DSML|parameter name="param" string="true">string_value</|DSML|parameter>
|
| 113 |
+
<|DSML|parameter name="count" string="false">5</|DSML|parameter>
|
| 114 |
+
</|DSML|invoke>
|
| 115 |
+
</|DSML|tool_calls><|end▁of▁sentence|>
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
- `string="true"`: the parameter value is a raw string.
|
| 119 |
+
- `string="false"`: the parameter value is JSON (number, boolean, array, object).
|
| 120 |
+
|
| 121 |
+
Tool execution results are wrapped in `<tool_result>` tags within user messages:
|
| 122 |
+
|
| 123 |
+
```
|
| 124 |
+
<|User|><tool_result>{result_json}</tool_result><|Assistant|><think>...
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
When multiple tool results are present, they are sorted by the order of the corresponding `tool_calls` in the preceding assistant message.
|
| 128 |
+
|
| 129 |
+
### Reasoning Effort
|
| 130 |
+
|
| 131 |
+
In thinking mode, the `reasoning_effort` parameter selects one of three levels, which control how much deliberation the model spends before answering. The level is realized purely as a text prefix prepended at the very beginning of the prompt (before the system message); the rest of the encoding is identical across levels.
|
| 132 |
+
|
| 133 |
+
| `reasoning_effort` | Prompt prefix |
|
| 134 |
+
|:---|:---|
|
| 135 |
+
| `"low"` (default) | none |
|
| 136 |
+
| `"high"` | `Reasoning Effort: Absolute maximum ...` |
|
| 137 |
+
| `"max"` | `Reasoning Effort: Beyond maximum ...` |
|
| 138 |
+
|
| 139 |
+
`reasoning_effort` has no effect in chat mode (`thinking_mode="chat"`), where the model does not produce a reasoning block at all.
|
| 140 |
+
|
| 141 |
+
The full prefix text for `"high"`:
|
| 142 |
+
|
| 143 |
+
```
|
| 144 |
+
Reasoning Effort: Absolute maximum with no shortcuts permitted.
|
| 145 |
+
You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.
|
| 146 |
+
Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
And for `"max"`:
|
| 150 |
+
|
| 151 |
+
```
|
| 152 |
+
Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.
|
| 153 |
+
You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.
|
| 154 |
+
Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### Quick Instruction Special Tokens
|
| 158 |
+
|
| 159 |
+
Quick instruction tokens are used for auxiliary classification and generation tasks. They are appended to messages via the `"task"` field to trigger specialized model behavior for a single-token or short-form output.
|
| 160 |
+
|
| 161 |
+
| Special Token | Description | Format |
|
| 162 |
+
|:---|:---|:---|
|
| 163 |
+
| `<|action|>` | Determines whether the user prompt requires a web search or can be answered directly. | `...<|User|>{prompt}<|Assistant|><think><|action|>` |
|
| 164 |
+
| `<|title|>` | Generates a concise conversation title after the first assistant response. | `...<|Assistant|>{response}<|end▁of▁sentence|><|title|>` |
|
| 165 |
+
| `<|query|>` | Generates search queries for the user prompt. | `...<|User|>{prompt}<|query|>` |
|
| 166 |
+
| `<|authority|>` | Classifies the user prompt's demand for source authoritativeness. | `...<|User|>{prompt}<|authority|>` |
|
| 167 |
+
| `<|domain|>` | Identifies the domain of the user prompt. | `...<|User|>{prompt}<|domain|>` |
|
| 168 |
+
| `<|extracted_url|>` `<|read_url|>` | Determines whether each URL in the user prompt should be fetched and read. | `...<|User|>{prompt}<|extracted_url|>{url}<|read_url|>` |
|
| 169 |
+
|
| 170 |
+
Usage in message format:
|
| 171 |
+
|
| 172 |
+
- **`action`** on a user message: the `<|action|>` token is placed after the assistant prefix and thinking token, triggering a routing decision (e.g., "Search" or "Answer").
|
| 173 |
+
- **Other tasks** (`query`, `authority`, `domain`, `read_url`) on a user message: the task token is appended directly after the user content.
|
| 174 |
+
- **`title`** on an assistant message: the `<|title|>` token is appended after the assistant's EOS. The next assistant message provides the generated title.
|
encoding/encoding_dsv4.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DeepSeek-V4 Encoding
|
| 3 |
+
|
| 4 |
+
A self-contained implementation for encoding/decoding DeepSeek-V4 chat messages
|
| 5 |
+
with tool calling, thinking mode, and quick instruction task support.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Any, Dict, List, Union, Optional, Tuple
|
| 9 |
+
import copy
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
|
| 13 |
+
# ============================================================
|
| 14 |
+
# Special Tokens
|
| 15 |
+
# ============================================================
|
| 16 |
+
|
| 17 |
+
bos_token: str = "<|begin▁of▁sentence|>"
|
| 18 |
+
eos_token: str = "<|end▁of▁sentence|>"
|
| 19 |
+
thinking_start_token: str = "<think>"
|
| 20 |
+
thinking_end_token: str = "</think>"
|
| 21 |
+
dsml_token: str = "|DSML|"
|
| 22 |
+
|
| 23 |
+
USER_SP_TOKEN = "<|User|>"
|
| 24 |
+
ASSISTANT_SP_TOKEN = "<|Assistant|>"
|
| 25 |
+
LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"
|
| 26 |
+
|
| 27 |
+
# Task special tokens for internal classification tasks
|
| 28 |
+
DS_TASK_SP_TOKENS = {
|
| 29 |
+
"action": "<|action|>",
|
| 30 |
+
"query": "<|query|>",
|
| 31 |
+
"authority": "<|authority|>",
|
| 32 |
+
"domain": "<|domain|>",
|
| 33 |
+
"title": "<|title|>",
|
| 34 |
+
"read_url": "<|read_url|>",
|
| 35 |
+
}
|
| 36 |
+
VALID_TASKS = set(DS_TASK_SP_TOKENS.keys())
|
| 37 |
+
|
| 38 |
+
# ============================================================
|
| 39 |
+
# Templates
|
| 40 |
+
# ============================================================
|
| 41 |
+
|
| 42 |
+
system_msg_template: str = "{content}"
|
| 43 |
+
user_msg_template: str = "{content}"
|
| 44 |
+
latest_reminder_msg_template: str = "{content}"
|
| 45 |
+
assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token
|
| 46 |
+
assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"
|
| 47 |
+
thinking_template: str = "{reasoning_content}"
|
| 48 |
+
|
| 49 |
+
response_format_template: str = (
|
| 50 |
+
"## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}"
|
| 51 |
+
)
|
| 52 |
+
tool_call_template: str = (
|
| 53 |
+
"<{dsml_token}invoke name=\"{name}\">\n{arguments}\n</{dsml_token}invoke>"
|
| 54 |
+
)
|
| 55 |
+
tool_calls_template = (
|
| 56 |
+
"<{dsml_token}{tc_block_name}>\n{tool_calls}\n</{dsml_token}{tc_block_name}>"
|
| 57 |
+
)
|
| 58 |
+
tool_calls_block_name: str = "tool_calls"
|
| 59 |
+
|
| 60 |
+
tool_output_template: str = (
|
| 61 |
+
"<tool_result>{content}</tool_result>"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Reasoning effort levels. In thinking mode, the prompt for the selected level is
|
| 65 |
+
# prepended at the very beginning of the conversation. `low` is the default and
|
| 66 |
+
# adds nothing.
|
| 67 |
+
REASONING_EFFORT_PROMPTS: Dict[str, str] = {
|
| 68 |
+
"low": "",
|
| 69 |
+
"high": (
|
| 70 |
+
"Reasoning Effort: Absolute maximum with no shortcuts permitted.\n"
|
| 71 |
+
"You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n"
|
| 72 |
+
"Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n"
|
| 73 |
+
),
|
| 74 |
+
"max": (
|
| 75 |
+
"Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n"
|
| 76 |
+
"You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\n"
|
| 77 |
+
"Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n"
|
| 78 |
+
),
|
| 79 |
+
}
|
| 80 |
+
DEFAULT_REASONING_EFFORT = "low"
|
| 81 |
+
|
| 82 |
+
TOOLS_TEMPLATE = """## Tools
|
| 83 |
+
|
| 84 |
+
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
|
| 85 |
+
|
| 86 |
+
<{dsml_token}tool_calls>
|
| 87 |
+
<{dsml_token}invoke name="$TOOL_NAME">
|
| 88 |
+
<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
|
| 89 |
+
...
|
| 90 |
+
</{dsml_token}invoke>
|
| 91 |
+
<{dsml_token}invoke name="$TOOL_NAME2">
|
| 92 |
+
...
|
| 93 |
+
</{dsml_token}invoke>
|
| 94 |
+
</{dsml_token}tool_calls>
|
| 95 |
+
|
| 96 |
+
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
|
| 97 |
+
|
| 98 |
+
If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
|
| 99 |
+
|
| 100 |
+
Otherwise, output directly after {thinking_end_token} with tool calls or final response.
|
| 101 |
+
|
| 102 |
+
### Available Tool Schemas
|
| 103 |
+
|
| 104 |
+
{tool_schemas}
|
| 105 |
+
|
| 106 |
+
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
# ============================================================
|
| 110 |
+
# Utility Functions
|
| 111 |
+
# ============================================================
|
| 112 |
+
|
| 113 |
+
def to_json(value: Any) -> str:
|
| 114 |
+
"""Serialize a value to JSON string."""
|
| 115 |
+
try:
|
| 116 |
+
return json.dumps(value, ensure_ascii=False)
|
| 117 |
+
except:
|
| 118 |
+
return json.dumps(value, ensure_ascii=True)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def tools_from_openai_format(tools):
|
| 122 |
+
"""Extract function definitions from OpenAI-format tool list."""
|
| 123 |
+
return [tool["function"] for tool in tools]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def tool_calls_from_openai_format(tool_calls):
|
| 127 |
+
"""Convert OpenAI-format tool calls to internal format."""
|
| 128 |
+
return [
|
| 129 |
+
{
|
| 130 |
+
"name": tool_call["function"]["name"],
|
| 131 |
+
"arguments": tool_call["function"]["arguments"],
|
| 132 |
+
}
|
| 133 |
+
for tool_call in tool_calls
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def tool_calls_to_openai_format(tool_calls):
|
| 138 |
+
"""Convert internal tool calls to OpenAI format."""
|
| 139 |
+
return [
|
| 140 |
+
{
|
| 141 |
+
"type": "function",
|
| 142 |
+
"function": {
|
| 143 |
+
"name": tool_call["name"],
|
| 144 |
+
"arguments": tool_call["arguments"],
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
for tool_call in tool_calls
|
| 148 |
+
]
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def encode_arguments_to_dsml(tool_call: Dict[str, str]) -> str:
|
| 152 |
+
"""
|
| 153 |
+
Encode tool call arguments into DSML parameter format.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
tool_call: Dict with "name" and "arguments" (JSON string) keys.
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
DSML-formatted parameter string.
|
| 160 |
+
"""
|
| 161 |
+
p_dsml_template = '<{dsml_token}parameter name="{key}" string="{is_str}">{value}</{dsml_token}parameter>'
|
| 162 |
+
P_dsml_strs = []
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
arguments = json.loads(tool_call["arguments"])
|
| 166 |
+
except Exception as err:
|
| 167 |
+
arguments = {"arguments": tool_call["arguments"]}
|
| 168 |
+
|
| 169 |
+
for k, v in arguments.items():
|
| 170 |
+
p_dsml_str = p_dsml_template.format(
|
| 171 |
+
dsml_token=dsml_token,
|
| 172 |
+
key=k,
|
| 173 |
+
is_str="true" if isinstance(v, str) else "false",
|
| 174 |
+
value=v if isinstance(v, str) else to_json(v),
|
| 175 |
+
)
|
| 176 |
+
P_dsml_strs.append(p_dsml_str)
|
| 177 |
+
|
| 178 |
+
return "\n".join(P_dsml_strs)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]:
|
| 182 |
+
"""
|
| 183 |
+
Decode DSML parameters back to a tool call dict.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
tool_name: Name of the tool.
|
| 187 |
+
tool_args: Dict mapping param_name -> (value, is_string_flag).
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
Dict with "name" and "arguments" (JSON string) keys.
|
| 191 |
+
"""
|
| 192 |
+
def _decode_value(key: str, value: str, string: str):
|
| 193 |
+
if string == "true":
|
| 194 |
+
value = to_json(value)
|
| 195 |
+
return f"{to_json(key)}: {value}"
|
| 196 |
+
|
| 197 |
+
tool_args_json = "{" + ", ".join([_decode_value(k, v, string=is_str) for k, (v, is_str) in tool_args.items()]) + "}"
|
| 198 |
+
return dict(name=tool_name, arguments=tool_args_json)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:
|
| 202 |
+
"""
|
| 203 |
+
Render tool schemas into the system prompt format.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
tools: List of tool schema dicts (each with name, description, parameters).
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
Formatted tools section string.
|
| 210 |
+
"""
|
| 211 |
+
tools_json = [to_json(t) for t in tools]
|
| 212 |
+
|
| 213 |
+
return TOOLS_TEMPLATE.format(
|
| 214 |
+
tool_schemas="\n".join(tools_json),
|
| 215 |
+
dsml_token=dsml_token,
|
| 216 |
+
thinking_start_token=thinking_start_token,
|
| 217 |
+
thinking_end_token=thinking_end_token,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def find_last_user_index(messages: List[Dict[str, Any]]) -> int:
|
| 222 |
+
"""Find the index of the last user/developer message."""
|
| 223 |
+
last_user_index = -1
|
| 224 |
+
for idx in range(len(messages) - 1, -1, -1):
|
| 225 |
+
if messages[idx].get("role") in ["user", "developer"]:
|
| 226 |
+
last_user_index = idx
|
| 227 |
+
break
|
| 228 |
+
return last_user_index
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ============================================================
|
| 232 |
+
# Message Rendering
|
| 233 |
+
# ============================================================
|
| 234 |
+
|
| 235 |
+
def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: str, drop_thinking: bool = True, reasoning_effort: Optional[str] = None) -> str:
|
| 236 |
+
"""
|
| 237 |
+
Render a single message at the given index into its encoded string form.
|
| 238 |
+
|
| 239 |
+
This is the core function that converts each message in the conversation
|
| 240 |
+
into the DeepSeek-V4 format.
|
| 241 |
+
|
| 242 |
+
Args:
|
| 243 |
+
index: Index of the message to render.
|
| 244 |
+
messages: Full list of messages in the conversation.
|
| 245 |
+
thinking_mode: Either "chat" or "thinking".
|
| 246 |
+
drop_thinking: Whether to drop reasoning content from earlier turns.
|
| 247 |
+
reasoning_effort: Reasoning effort level, one of "low", "high", "max".
|
| 248 |
+
None is treated as "low".
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
Encoded string for this message.
|
| 252 |
+
"""
|
| 253 |
+
assert 0 <= index < len(messages)
|
| 254 |
+
assert thinking_mode in ["chat", "thinking"], f"Invalid thinking_mode `{thinking_mode}`"
|
| 255 |
+
|
| 256 |
+
prompt = ""
|
| 257 |
+
msg = messages[index]
|
| 258 |
+
last_user_idx = find_last_user_index(messages)
|
| 259 |
+
|
| 260 |
+
role = msg.get("role")
|
| 261 |
+
content = msg.get("content")
|
| 262 |
+
tools = msg.get("tools")
|
| 263 |
+
response_format = msg.get("response_format")
|
| 264 |
+
tool_calls = msg.get("tool_calls")
|
| 265 |
+
reasoning_content = msg.get("reasoning_content")
|
| 266 |
+
wo_eos = msg.get("wo_eos", False)
|
| 267 |
+
|
| 268 |
+
if tools:
|
| 269 |
+
tools = tools_from_openai_format(tools)
|
| 270 |
+
if tool_calls:
|
| 271 |
+
tool_calls = tool_calls_from_openai_format(tool_calls)
|
| 272 |
+
|
| 273 |
+
# Reasoning effort prefix (only at index 0 in thinking mode; "low" adds nothing)
|
| 274 |
+
reasoning_effort = reasoning_effort or DEFAULT_REASONING_EFFORT
|
| 275 |
+
assert reasoning_effort in REASONING_EFFORT_PROMPTS, \
|
| 276 |
+
f"Invalid reasoning effort: {reasoning_effort}, expected one of {list(REASONING_EFFORT_PROMPTS)}"
|
| 277 |
+
if index == 0 and thinking_mode == "thinking":
|
| 278 |
+
prompt += REASONING_EFFORT_PROMPTS[reasoning_effort]
|
| 279 |
+
|
| 280 |
+
if role == "system":
|
| 281 |
+
prompt += system_msg_template.format(content=content or "")
|
| 282 |
+
if tools:
|
| 283 |
+
prompt += "\n\n" + render_tools(tools)
|
| 284 |
+
if response_format:
|
| 285 |
+
prompt += "\n\n" + response_format_template.format(schema=to_json(response_format))
|
| 286 |
+
|
| 287 |
+
elif role == "developer":
|
| 288 |
+
assert content, f"Invalid message for role `{role}`: {msg}"
|
| 289 |
+
|
| 290 |
+
content_developer = USER_SP_TOKEN
|
| 291 |
+
content_developer += content
|
| 292 |
+
|
| 293 |
+
if tools:
|
| 294 |
+
content_developer += "\n\n" + render_tools(tools)
|
| 295 |
+
if response_format:
|
| 296 |
+
content_developer += "\n\n" + response_format_template.format(schema=to_json(response_format))
|
| 297 |
+
|
| 298 |
+
prompt += user_msg_template.format(content=content_developer)
|
| 299 |
+
|
| 300 |
+
elif role == "user":
|
| 301 |
+
prompt += USER_SP_TOKEN
|
| 302 |
+
|
| 303 |
+
# Handle content blocks (tool results mixed with text)
|
| 304 |
+
content_blocks = msg.get("content_blocks")
|
| 305 |
+
if content_blocks:
|
| 306 |
+
parts = []
|
| 307 |
+
for block in content_blocks:
|
| 308 |
+
block_type = block.get("type")
|
| 309 |
+
if block_type == "text":
|
| 310 |
+
parts.append(block.get("text", ""))
|
| 311 |
+
elif block_type == "tool_result":
|
| 312 |
+
tool_content = block.get("content", "")
|
| 313 |
+
if isinstance(tool_content, list):
|
| 314 |
+
text_parts = []
|
| 315 |
+
for b in tool_content:
|
| 316 |
+
if b.get("type") == "text":
|
| 317 |
+
text_parts.append(b.get("text", ""))
|
| 318 |
+
else:
|
| 319 |
+
text_parts.append(f"[Unsupported {b.get('type')}]")
|
| 320 |
+
tool_content = "\n\n".join(text_parts)
|
| 321 |
+
parts.append(tool_output_template.format(content=tool_content))
|
| 322 |
+
else:
|
| 323 |
+
parts.append(f"[Unsupported {block_type}]")
|
| 324 |
+
prompt += "\n\n".join(parts)
|
| 325 |
+
else:
|
| 326 |
+
prompt += content or ""
|
| 327 |
+
|
| 328 |
+
elif role == "latest_reminder":
|
| 329 |
+
prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content)
|
| 330 |
+
|
| 331 |
+
elif role == "tool":
|
| 332 |
+
raise NotImplementedError("deepseek_v4 merges tool messages into user; please preprocess with merge_tool_messages()")
|
| 333 |
+
|
| 334 |
+
elif role == "assistant":
|
| 335 |
+
thinking_part = ""
|
| 336 |
+
tc_content = ""
|
| 337 |
+
|
| 338 |
+
if tool_calls:
|
| 339 |
+
tc_list = [
|
| 340 |
+
tool_call_template.format(
|
| 341 |
+
dsml_token=dsml_token,
|
| 342 |
+
name=tc.get("name"),
|
| 343 |
+
arguments=encode_arguments_to_dsml(tc)
|
| 344 |
+
)
|
| 345 |
+
for tc in tool_calls
|
| 346 |
+
]
|
| 347 |
+
tc_content += '\n\n' + tool_calls_template.format(
|
| 348 |
+
dsml_token=dsml_token,
|
| 349 |
+
tool_calls="\n".join(tc_list),
|
| 350 |
+
tc_block_name=tool_calls_block_name,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
summary_content = content or ""
|
| 354 |
+
rc = reasoning_content or ""
|
| 355 |
+
|
| 356 |
+
# Check if previous message has a task - if so, this is a task output (no thinking)
|
| 357 |
+
prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None
|
| 358 |
+
|
| 359 |
+
if thinking_mode == "thinking" and not prev_has_task:
|
| 360 |
+
if not drop_thinking or index > last_user_idx:
|
| 361 |
+
thinking_part = thinking_template.format(reasoning_content=rc) + thinking_end_token
|
| 362 |
+
else:
|
| 363 |
+
thinking_part = ""
|
| 364 |
+
|
| 365 |
+
if wo_eos:
|
| 366 |
+
prompt += assistant_msg_wo_eos_template.format(
|
| 367 |
+
reasoning=thinking_part,
|
| 368 |
+
content=summary_content,
|
| 369 |
+
tool_calls=tc_content,
|
| 370 |
+
)
|
| 371 |
+
else:
|
| 372 |
+
prompt += assistant_msg_template.format(
|
| 373 |
+
reasoning=thinking_part,
|
| 374 |
+
content=summary_content,
|
| 375 |
+
tool_calls=tc_content,
|
| 376 |
+
)
|
| 377 |
+
else:
|
| 378 |
+
raise NotImplementedError(f"Unknown role: {role}")
|
| 379 |
+
|
| 380 |
+
# Append transition tokens based on what follows
|
| 381 |
+
if index + 1 < len(messages) and messages[index + 1].get("role") not in ["assistant", "latest_reminder"]:
|
| 382 |
+
return prompt
|
| 383 |
+
|
| 384 |
+
task = messages[index].get("task")
|
| 385 |
+
if task is not None:
|
| 386 |
+
# Task special token for internal classification tasks
|
| 387 |
+
assert task in VALID_TASKS, f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}"
|
| 388 |
+
task_sp_token = DS_TASK_SP_TOKENS[task]
|
| 389 |
+
|
| 390 |
+
if task != "action":
|
| 391 |
+
# Non-action tasks: append task sp token directly after the message
|
| 392 |
+
prompt += task_sp_token
|
| 393 |
+
else:
|
| 394 |
+
# Action task: append Assistant + thinking token + action sp token
|
| 395 |
+
prompt += ASSISTANT_SP_TOKEN
|
| 396 |
+
prompt += thinking_end_token if thinking_mode != "thinking" else thinking_start_token
|
| 397 |
+
prompt += task_sp_token
|
| 398 |
+
|
| 399 |
+
elif messages[index].get("role") in ["user", "developer"]:
|
| 400 |
+
# Normal generation: append Assistant + thinking token
|
| 401 |
+
prompt += ASSISTANT_SP_TOKEN
|
| 402 |
+
if not drop_thinking and thinking_mode == "thinking":
|
| 403 |
+
prompt += thinking_start_token
|
| 404 |
+
elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx:
|
| 405 |
+
prompt += thinking_start_token
|
| 406 |
+
else:
|
| 407 |
+
prompt += thinking_end_token
|
| 408 |
+
|
| 409 |
+
return prompt
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# ============================================================
|
| 413 |
+
# Preprocessing
|
| 414 |
+
# ============================================================
|
| 415 |
+
|
| 416 |
+
def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 417 |
+
"""
|
| 418 |
+
Merge tool messages into the preceding user message using content_blocks format.
|
| 419 |
+
|
| 420 |
+
DeepSeek-V4 does not have a standalone "tool" role; instead, tool results
|
| 421 |
+
are encoded as <tool_result> blocks within user messages.
|
| 422 |
+
|
| 423 |
+
This function converts a standard OpenAI-format conversation (with separate
|
| 424 |
+
"tool" role messages) into V4 format where tool results are merged into
|
| 425 |
+
user messages.
|
| 426 |
+
|
| 427 |
+
Args:
|
| 428 |
+
messages: List of message dicts in OpenAI format.
|
| 429 |
+
|
| 430 |
+
Returns:
|
| 431 |
+
Processed message list with tool messages merged into user messages.
|
| 432 |
+
"""
|
| 433 |
+
merged: List[Dict[str, Any]] = []
|
| 434 |
+
|
| 435 |
+
for msg in messages:
|
| 436 |
+
msg = copy.deepcopy(msg)
|
| 437 |
+
role = msg.get("role")
|
| 438 |
+
|
| 439 |
+
if role == "tool":
|
| 440 |
+
# Convert tool message to a user message with tool_result block
|
| 441 |
+
tool_block = {
|
| 442 |
+
"type": "tool_result",
|
| 443 |
+
"tool_use_id": msg.get("tool_call_id", ""),
|
| 444 |
+
"content": msg.get("content", ""),
|
| 445 |
+
}
|
| 446 |
+
# Merge into previous message if it's already a user (merged tool)
|
| 447 |
+
if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1]:
|
| 448 |
+
merged[-1]["content_blocks"].append(tool_block)
|
| 449 |
+
else:
|
| 450 |
+
merged.append({
|
| 451 |
+
"role": "user",
|
| 452 |
+
"content_blocks": [tool_block],
|
| 453 |
+
})
|
| 454 |
+
elif role == "user":
|
| 455 |
+
text_block = {"type": "text", "text": msg.get("content", "")}
|
| 456 |
+
if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1] and merged[-1].get("task") is None:
|
| 457 |
+
merged[-1]["content_blocks"].append(text_block)
|
| 458 |
+
else:
|
| 459 |
+
new_msg = {
|
| 460 |
+
"role": "user",
|
| 461 |
+
"content": msg.get("content", ""),
|
| 462 |
+
"content_blocks": [text_block],
|
| 463 |
+
}
|
| 464 |
+
# Preserve extra fields (task, wo_eos, mask, etc.)
|
| 465 |
+
for key in ("task", "wo_eos", "mask"):
|
| 466 |
+
if key in msg:
|
| 467 |
+
new_msg[key] = msg[key]
|
| 468 |
+
merged.append(new_msg)
|
| 469 |
+
else:
|
| 470 |
+
merged.append(msg)
|
| 471 |
+
|
| 472 |
+
return merged
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def sort_tool_results_by_call_order(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 476 |
+
"""
|
| 477 |
+
Sort tool_result blocks within user messages by the order of tool_calls
|
| 478 |
+
in the preceding assistant message.
|
| 479 |
+
|
| 480 |
+
Args:
|
| 481 |
+
messages: Preprocessed message list (after merge_tool_messages).
|
| 482 |
+
|
| 483 |
+
Returns:
|
| 484 |
+
Message list with sorted tool result blocks.
|
| 485 |
+
"""
|
| 486 |
+
last_tool_call_order: Dict[str, int] = {}
|
| 487 |
+
|
| 488 |
+
for msg in messages:
|
| 489 |
+
role = msg.get("role")
|
| 490 |
+
if role == "assistant" and msg.get("tool_calls"):
|
| 491 |
+
last_tool_call_order = {}
|
| 492 |
+
for idx, tc in enumerate(msg["tool_calls"]):
|
| 493 |
+
tc_id = tc.get("id") or tc.get("function", {}).get("id", "")
|
| 494 |
+
if tc_id:
|
| 495 |
+
last_tool_call_order[tc_id] = idx
|
| 496 |
+
|
| 497 |
+
elif role == "user" and msg.get("content_blocks"):
|
| 498 |
+
tool_blocks = [b for b in msg["content_blocks"] if b.get("type") == "tool_result"]
|
| 499 |
+
if len(tool_blocks) > 1 and last_tool_call_order:
|
| 500 |
+
sorted_blocks = sorted(
|
| 501 |
+
tool_blocks,
|
| 502 |
+
key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0)
|
| 503 |
+
)
|
| 504 |
+
sorted_idx = 0
|
| 505 |
+
new_blocks = []
|
| 506 |
+
for block in msg["content_blocks"]:
|
| 507 |
+
if block.get("type") == "tool_result":
|
| 508 |
+
new_blocks.append(sorted_blocks[sorted_idx])
|
| 509 |
+
sorted_idx += 1
|
| 510 |
+
else:
|
| 511 |
+
new_blocks.append(block)
|
| 512 |
+
msg["content_blocks"] = new_blocks
|
| 513 |
+
|
| 514 |
+
return messages
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
# ============================================================
|
| 518 |
+
# Main Encoding Function
|
| 519 |
+
# ============================================================
|
| 520 |
+
|
| 521 |
+
def encode_messages(
|
| 522 |
+
messages: List[Dict[str, Any]],
|
| 523 |
+
thinking_mode: str,
|
| 524 |
+
context: Optional[List[Dict[str, Any]]] = None,
|
| 525 |
+
drop_thinking: bool = True,
|
| 526 |
+
add_default_bos_token: bool = True,
|
| 527 |
+
reasoning_effort: Optional[str] = None,
|
| 528 |
+
) -> str:
|
| 529 |
+
"""
|
| 530 |
+
Encode a list of messages into the DeepSeek-V4 prompt format.
|
| 531 |
+
|
| 532 |
+
This is the main entry point for encoding conversations. It handles:
|
| 533 |
+
- BOS token insertion
|
| 534 |
+
- Thinking mode with optional reasoning content dropping
|
| 535 |
+
- Tool message merging into user messages
|
| 536 |
+
- Multi-turn conversation context
|
| 537 |
+
|
| 538 |
+
Args:
|
| 539 |
+
messages: List of message dicts to encode.
|
| 540 |
+
thinking_mode: Either "chat" or "thinking".
|
| 541 |
+
context: Optional preceding context messages (already encoded prefix).
|
| 542 |
+
drop_thinking: If True, drop reasoning_content from earlier assistant turns
|
| 543 |
+
(only keep reasoning for messages after the last user message).
|
| 544 |
+
add_default_bos_token: Whether to prepend BOS token at conversation start.
|
| 545 |
+
reasoning_effort: Reasoning effort level, one of "low", "high", "max".
|
| 546 |
+
Only takes effect in thinking mode. None is treated as "low".
|
| 547 |
+
|
| 548 |
+
Returns:
|
| 549 |
+
The encoded prompt string.
|
| 550 |
+
"""
|
| 551 |
+
context = context if context else []
|
| 552 |
+
|
| 553 |
+
# Preprocess: merge tool messages and sort tool results
|
| 554 |
+
messages = merge_tool_messages(messages)
|
| 555 |
+
messages = sort_tool_results_by_call_order(context + messages)[len(context):]
|
| 556 |
+
if context:
|
| 557 |
+
context = merge_tool_messages(context)
|
| 558 |
+
context = sort_tool_results_by_call_order(context)
|
| 559 |
+
|
| 560 |
+
full_messages = context + messages
|
| 561 |
+
|
| 562 |
+
prompt = bos_token if add_default_bos_token and len(context) == 0 else ""
|
| 563 |
+
|
| 564 |
+
# Resolve drop_thinking: if any message has tools defined, don't drop thinking
|
| 565 |
+
effective_drop_thinking = drop_thinking
|
| 566 |
+
if any(m.get("tools") for m in full_messages):
|
| 567 |
+
effective_drop_thinking = False
|
| 568 |
+
|
| 569 |
+
if thinking_mode == "thinking" and effective_drop_thinking:
|
| 570 |
+
full_messages = _drop_thinking_messages(full_messages)
|
| 571 |
+
# After dropping, recalculate how many messages to render
|
| 572 |
+
# (context may have shrunk too)
|
| 573 |
+
num_to_render = len(full_messages) - len(_drop_thinking_messages(context))
|
| 574 |
+
context_len = len(full_messages) - num_to_render
|
| 575 |
+
else:
|
| 576 |
+
num_to_render = len(messages)
|
| 577 |
+
context_len = len(context)
|
| 578 |
+
|
| 579 |
+
for idx in range(num_to_render):
|
| 580 |
+
prompt += render_message(
|
| 581 |
+
idx + context_len,
|
| 582 |
+
full_messages,
|
| 583 |
+
thinking_mode=thinking_mode,
|
| 584 |
+
drop_thinking=effective_drop_thinking,
|
| 585 |
+
reasoning_effort=reasoning_effort,
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
return prompt
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 592 |
+
"""
|
| 593 |
+
Drop reasoning_content and non-essential messages before the last user message.
|
| 594 |
+
|
| 595 |
+
Behavior:
|
| 596 |
+
- Messages with role in ["user", "system", "tool", "latest_reminder"] are always kept.
|
| 597 |
+
- Messages at or after the last user index are always kept.
|
| 598 |
+
- Assistant messages before the last user get reasoning_content removed.
|
| 599 |
+
- Developer messages before the last user are dropped entirely.
|
| 600 |
+
"""
|
| 601 |
+
last_user_idx = find_last_user_index(messages)
|
| 602 |
+
result = []
|
| 603 |
+
keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"}
|
| 604 |
+
|
| 605 |
+
for idx, msg in enumerate(messages):
|
| 606 |
+
role = msg.get("role")
|
| 607 |
+
if role in keep_roles or idx >= last_user_idx:
|
| 608 |
+
result.append(msg)
|
| 609 |
+
elif role == "assistant":
|
| 610 |
+
msg = copy.copy(msg)
|
| 611 |
+
msg.pop("reasoning_content", None)
|
| 612 |
+
result.append(msg)
|
| 613 |
+
# developer and other roles before last_user_idx are dropped
|
| 614 |
+
|
| 615 |
+
return result
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
# ============================================================
|
| 619 |
+
# Parsing (Decoding model output)
|
| 620 |
+
# ============================================================
|
| 621 |
+
|
| 622 |
+
def _read_until_stop(index: int, text: str, stop: List[str]) -> Tuple[int, str, Optional[str]]:
|
| 623 |
+
"""
|
| 624 |
+
Read text from index until one of the stop strings is found.
|
| 625 |
+
|
| 626 |
+
Returns:
|
| 627 |
+
Tuple of (new_index, content_before_stop, matched_stop_string_or_None).
|
| 628 |
+
"""
|
| 629 |
+
min_pos = len(text)
|
| 630 |
+
matched_stop = None
|
| 631 |
+
|
| 632 |
+
for s in stop:
|
| 633 |
+
pos = text.find(s, index)
|
| 634 |
+
if pos != -1 and pos < min_pos:
|
| 635 |
+
min_pos = pos
|
| 636 |
+
matched_stop = s
|
| 637 |
+
|
| 638 |
+
if matched_stop:
|
| 639 |
+
content = text[index:min_pos]
|
| 640 |
+
return min_pos + len(matched_stop), content, matched_stop
|
| 641 |
+
else:
|
| 642 |
+
content = text[index:]
|
| 643 |
+
return len(text), content, None
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Dict[str, str]]]:
|
| 647 |
+
"""
|
| 648 |
+
Parse DSML tool calls from text starting at the given index.
|
| 649 |
+
|
| 650 |
+
Args:
|
| 651 |
+
index: Starting position in text.
|
| 652 |
+
text: The full text to parse.
|
| 653 |
+
|
| 654 |
+
Returns:
|
| 655 |
+
Tuple of (new_index, last_stop_token, list_of_tool_call_dicts).
|
| 656 |
+
Each tool call dict has "name" and "arguments" keys.
|
| 657 |
+
"""
|
| 658 |
+
tool_calls: List[Dict[str, Any]] = []
|
| 659 |
+
stop_token = None
|
| 660 |
+
tool_calls_end_token = f"</{dsml_token}{tool_calls_block_name}>"
|
| 661 |
+
|
| 662 |
+
while index < len(text):
|
| 663 |
+
index, _, stop_token = _read_until_stop(index, text, [f"<{dsml_token}invoke", tool_calls_end_token])
|
| 664 |
+
if _ != ">\n":
|
| 665 |
+
raise ValueError(f"Tool call format error: expected '>\\n' but got '{_}'")
|
| 666 |
+
|
| 667 |
+
if stop_token == tool_calls_end_token:
|
| 668 |
+
break
|
| 669 |
+
|
| 670 |
+
if stop_token is None:
|
| 671 |
+
raise ValueError("Missing special token in tool calls")
|
| 672 |
+
|
| 673 |
+
index, tool_name_content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"</{dsml_token}invoke"])
|
| 674 |
+
|
| 675 |
+
p_tool_name = re.findall(r'^\s*name="(.*?)">\n$', tool_name_content, flags=re.DOTALL)
|
| 676 |
+
if len(p_tool_name) != 1:
|
| 677 |
+
raise ValueError(f"Tool name format error: '{tool_name_content}'")
|
| 678 |
+
tool_name = p_tool_name[0]
|
| 679 |
+
|
| 680 |
+
tool_args: Dict[str, Tuple[str, str]] = {}
|
| 681 |
+
while stop_token == f"<{dsml_token}parameter":
|
| 682 |
+
index, param_content, stop_token = _read_until_stop(index, text, [f"/{dsml_token}parameter"])
|
| 683 |
+
|
| 684 |
+
param_kv = re.findall(r'^ name="(.*?)" string="(true|false)">(.*?)<$', param_content, flags=re.DOTALL)
|
| 685 |
+
if len(param_kv) != 1:
|
| 686 |
+
raise ValueError(f"Parameter format error: '{param_content}'")
|
| 687 |
+
param_name, string, param_value = param_kv[0]
|
| 688 |
+
|
| 689 |
+
if param_name in tool_args:
|
| 690 |
+
raise ValueError(f"Duplicate parameter name: '{param_name}'")
|
| 691 |
+
tool_args[param_name] = (param_value, string)
|
| 692 |
+
|
| 693 |
+
index, content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"</{dsml_token}invoke"])
|
| 694 |
+
if content != ">\n":
|
| 695 |
+
raise ValueError(f"Parameter format error: expected '>\\n' but got '{content}'")
|
| 696 |
+
|
| 697 |
+
tool_call = decode_dsml_to_arguments(tool_name=tool_name, tool_args=tool_args)
|
| 698 |
+
tool_calls.append(tool_call)
|
| 699 |
+
|
| 700 |
+
return index, stop_token, tool_calls
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[str, Any]:
|
| 704 |
+
"""
|
| 705 |
+
Parse a model completion text into a structured assistant message.
|
| 706 |
+
|
| 707 |
+
This function takes the raw text output from the model (a single assistant turn)
|
| 708 |
+
and extracts:
|
| 709 |
+
- reasoning_content (thinking block)
|
| 710 |
+
- content (summary/response)
|
| 711 |
+
- tool_calls (if any)
|
| 712 |
+
|
| 713 |
+
NOTE: This function is designed to parse only correctly formatted strings and
|
| 714 |
+
will raise ValueError for malformed output.
|
| 715 |
+
|
| 716 |
+
Args:
|
| 717 |
+
text: The raw completion text (including EOS token).
|
| 718 |
+
thinking_mode: Either "chat" or "thinking".
|
| 719 |
+
|
| 720 |
+
Returns:
|
| 721 |
+
Dict with keys: "role", "content", "reasoning_content", "tool_calls".
|
| 722 |
+
tool_calls are in OpenAI format.
|
| 723 |
+
"""
|
| 724 |
+
summary_content, reasoning_content, tool_calls = "", "", []
|
| 725 |
+
index, stop_token = 0, None
|
| 726 |
+
tool_calls_start_token = f"\n\n<{dsml_token}{tool_calls_block_name}"
|
| 727 |
+
|
| 728 |
+
is_thinking = thinking_mode == "thinking"
|
| 729 |
+
is_tool_calling = False
|
| 730 |
+
|
| 731 |
+
if is_thinking:
|
| 732 |
+
index, content_delta, stop_token = _read_until_stop(index, text, [thinking_end_token, tool_calls_start_token])
|
| 733 |
+
reasoning_content = content_delta
|
| 734 |
+
assert stop_token == thinking_end_token, "Invalid thinking format: missing </think>"
|
| 735 |
+
|
| 736 |
+
index, content_delta, stop_token = _read_until_stop(index, text, [eos_token, tool_calls_start_token])
|
| 737 |
+
summary_content = content_delta
|
| 738 |
+
if stop_token == tool_calls_start_token:
|
| 739 |
+
is_tool_calling = True
|
| 740 |
+
else:
|
| 741 |
+
assert stop_token == eos_token, "Invalid format: missing EOS token"
|
| 742 |
+
|
| 743 |
+
if is_tool_calling:
|
| 744 |
+
index, stop_token, tool_calls = parse_tool_calls(index, text)
|
| 745 |
+
|
| 746 |
+
index, tool_ends_text, stop_token = _read_until_stop(index, text, [eos_token])
|
| 747 |
+
assert not tool_ends_text, "Unexpected content after tool calls"
|
| 748 |
+
|
| 749 |
+
assert len(text) == index and stop_token in [eos_token, None], "Unexpected content at end"
|
| 750 |
+
|
| 751 |
+
for sp_token in [bos_token, eos_token, thinking_start_token, thinking_end_token, dsml_token]:
|
| 752 |
+
assert sp_token not in summary_content and sp_token not in reasoning_content, \
|
| 753 |
+
f"Unexpected special token '{sp_token}' in content"
|
| 754 |
+
|
| 755 |
+
return {
|
| 756 |
+
"role": "assistant",
|
| 757 |
+
"content": summary_content,
|
| 758 |
+
"reasoning_content": reasoning_content,
|
| 759 |
+
"tool_calls": tool_calls_to_openai_format(tool_calls)
|
| 760 |
+
}
|
encoding/test_encoding_dsv4.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test suite for DeepSeek-V4 Encoding.
|
| 3 |
+
|
| 4 |
+
Run: python test_encoding_dsv4.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
from encoding_dsv4 import encode_messages, parse_message_from_completion_text
|
| 11 |
+
|
| 12 |
+
TESTS_DIR = os.path.join(os.path.dirname(__file__), "tests")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_case_1():
|
| 16 |
+
"""Thinking mode with tool calls (multi-turn, tool results merged into user)."""
|
| 17 |
+
with open(os.path.join(TESTS_DIR, "test_input_1.json")) as f:
|
| 18 |
+
td = json.load(f)
|
| 19 |
+
messages = td["messages"]
|
| 20 |
+
messages[0]["tools"] = td["tools"]
|
| 21 |
+
gold = open(os.path.join(TESTS_DIR, "test_output_1.txt")).read()
|
| 22 |
+
prompt = encode_messages(messages, thinking_mode="thinking")
|
| 23 |
+
assert prompt == gold
|
| 24 |
+
|
| 25 |
+
# Parse: assistant turn with tool call
|
| 26 |
+
marker = "<|Assistant|><think>"
|
| 27 |
+
first_start = prompt.find(marker) + len(marker)
|
| 28 |
+
first_end = prompt.find("<|User|>", first_start)
|
| 29 |
+
parsed_tc = parse_message_from_completion_text(prompt[first_start:first_end], thinking_mode="thinking")
|
| 30 |
+
assert parsed_tc["reasoning_content"] == "The user wants to know the weather in Beijing. I should use the get_weather tool."
|
| 31 |
+
assert parsed_tc["content"] == ""
|
| 32 |
+
assert len(parsed_tc["tool_calls"]) == 1
|
| 33 |
+
assert parsed_tc["tool_calls"][0]["function"]["name"] == "get_weather"
|
| 34 |
+
assert json.loads(parsed_tc["tool_calls"][0]["function"]["arguments"]) == {"location": "Beijing", "unit": "celsius"}
|
| 35 |
+
|
| 36 |
+
# Parse: final assistant turn with content
|
| 37 |
+
last_start = prompt.rfind(marker) + len(marker)
|
| 38 |
+
parsed_final = parse_message_from_completion_text(prompt[last_start:], thinking_mode="thinking")
|
| 39 |
+
assert parsed_final["reasoning_content"] == "Got the weather data. Let me format a nice response."
|
| 40 |
+
assert "22°C" in parsed_final["content"]
|
| 41 |
+
assert parsed_final["tool_calls"] == []
|
| 42 |
+
|
| 43 |
+
print(" [PASS] case 1: thinking with tools (encode + parse)")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_case_2():
|
| 47 |
+
"""Thinking mode without tools (drop_thinking removes earlier reasoning)."""
|
| 48 |
+
messages = json.load(open(os.path.join(TESTS_DIR, "test_input_2.json")))
|
| 49 |
+
gold = open(os.path.join(TESTS_DIR, "test_output_2.txt")).read()
|
| 50 |
+
prompt = encode_messages(messages, thinking_mode="thinking")
|
| 51 |
+
assert prompt == gold
|
| 52 |
+
|
| 53 |
+
# Parse: last assistant turn
|
| 54 |
+
marker = "<|Assistant|><think>"
|
| 55 |
+
last_start = prompt.rfind(marker) + len(marker)
|
| 56 |
+
parsed = parse_message_from_completion_text(prompt[last_start:], thinking_mode="thinking")
|
| 57 |
+
assert parsed["reasoning_content"] == "The user asks about the capital of France. It is Paris."
|
| 58 |
+
assert parsed["content"] == "The capital of France is Paris."
|
| 59 |
+
assert parsed["tool_calls"] == []
|
| 60 |
+
|
| 61 |
+
# Verify drop_thinking: first assistant's reasoning should be absent
|
| 62 |
+
assert "The user said hello" not in prompt
|
| 63 |
+
|
| 64 |
+
print(" [PASS] case 2: thinking without tools (encode + parse)")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_case_3():
|
| 68 |
+
"""Interleaved thinking + search (developer with tools, latest_reminder)."""
|
| 69 |
+
messages = json.load(open(os.path.join(TESTS_DIR, "test_input_3.json")))
|
| 70 |
+
gold = open(os.path.join(TESTS_DIR, "test_output_3.txt")).read()
|
| 71 |
+
assert encode_messages(messages, thinking_mode="thinking") == gold
|
| 72 |
+
print(" [PASS] case 3: interleaved thinking + search")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_case_4():
|
| 76 |
+
"""Quick instruction task with latest_reminder (chat mode, action task)."""
|
| 77 |
+
messages = json.load(open(os.path.join(TESTS_DIR, "test_input_4.json")))
|
| 78 |
+
gold = open(os.path.join(TESTS_DIR, "test_output_4.txt")).read()
|
| 79 |
+
assert encode_messages(messages, thinking_mode="chat") == gold
|
| 80 |
+
print(" [PASS] case 4: quick instruction task")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
if __name__ == "__main__":
|
| 84 |
+
print("Running DeepSeek-V4 Encoding Tests...\n")
|
| 85 |
+
test_case_1()
|
| 86 |
+
test_case_2()
|
| 87 |
+
test_case_3()
|
| 88 |
+
test_case_4()
|
| 89 |
+
print("\nAll 4 tests passed!")
|
encoding/tests/test_input_1.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tools": [
|
| 3 |
+
{
|
| 4 |
+
"type": "function",
|
| 5 |
+
"function": {
|
| 6 |
+
"name": "get_weather",
|
| 7 |
+
"description": "Get the weather for a specific location",
|
| 8 |
+
"parameters": {
|
| 9 |
+
"type": "object",
|
| 10 |
+
"properties": {
|
| 11 |
+
"location": {
|
| 12 |
+
"type": "string",
|
| 13 |
+
"description": "The city name"
|
| 14 |
+
},
|
| 15 |
+
"unit": {
|
| 16 |
+
"type": "string",
|
| 17 |
+
"enum": ["celsius", "fahrenheit"],
|
| 18 |
+
"description": "Temperature unit"
|
| 19 |
+
}
|
| 20 |
+
},
|
| 21 |
+
"required": ["location"]
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"type": "function",
|
| 27 |
+
"function": {
|
| 28 |
+
"name": "search",
|
| 29 |
+
"description": "Search the web for information",
|
| 30 |
+
"parameters": {
|
| 31 |
+
"type": "object",
|
| 32 |
+
"properties": {
|
| 33 |
+
"query": {
|
| 34 |
+
"type": "string",
|
| 35 |
+
"description": "Search query"
|
| 36 |
+
},
|
| 37 |
+
"num_results": {
|
| 38 |
+
"type": "integer",
|
| 39 |
+
"description": "Number of results to return"
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
"required": ["query"]
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
],
|
| 47 |
+
"messages": [
|
| 48 |
+
{
|
| 49 |
+
"role": "system",
|
| 50 |
+
"content": "You are a helpful assistant."
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"role": "user",
|
| 54 |
+
"content": "What's the weather in Beijing?"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"role": "assistant",
|
| 58 |
+
"reasoning_content": "The user wants to know the weather in Beijing. I should use the get_weather tool.",
|
| 59 |
+
"tool_calls": [
|
| 60 |
+
{
|
| 61 |
+
"id": "call_001",
|
| 62 |
+
"type": "function",
|
| 63 |
+
"function": {
|
| 64 |
+
"name": "get_weather",
|
| 65 |
+
"arguments": "{\"location\": \"Beijing\", \"unit\": \"celsius\"}"
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"role": "tool",
|
| 72 |
+
"tool_call_id": "call_001",
|
| 73 |
+
"content": "{\"temperature\": 22, \"condition\": \"sunny\", \"humidity\": 45}"
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"role": "assistant",
|
| 77 |
+
"reasoning_content": "Got the weather data. Let me format a nice response.",
|
| 78 |
+
"content": "The weather in Beijing is currently sunny with a temperature of 22°C and 45% humidity."
|
| 79 |
+
}
|
| 80 |
+
]
|
| 81 |
+
}
|
encoding/tests/test_input_2.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"role": "system",
|
| 4 |
+
"content": "You are a helpful assistant."
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"role": "user",
|
| 8 |
+
"content": "Hello"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"role": "assistant",
|
| 12 |
+
"reasoning_content": "The user said hello, I should greet back.",
|
| 13 |
+
"content": "Hi there! How can I help you?"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"role": "user",
|
| 17 |
+
"content": "What is the capital of France?"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"role": "assistant",
|
| 21 |
+
"reasoning_content": "The user asks about the capital of France. It is Paris.",
|
| 22 |
+
"content": "The capital of France is Paris."
|
| 23 |
+
}
|
| 24 |
+
]
|
encoding/tests/test_input_3.json
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"role": "system",
|
| 4 |
+
"content": "该助手为DeepSeek,由深度求索公司创造。"
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"role": "latest_reminder",
|
| 8 |
+
"content": "2026-02-21,星期六,广州,App,中文"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"role": "developer",
|
| 12 |
+
"content": "小柴胡冲剂和布洛芬能一起吃吗?\n\nCITATION FORMAT: 【{cursor_id}†L{start_line_id}(-L{end_line_id})?】",
|
| 13 |
+
"tools": [
|
| 14 |
+
{
|
| 15 |
+
"type": "function",
|
| 16 |
+
"function": {
|
| 17 |
+
"name": "search",
|
| 18 |
+
"description": "Web search. Split multiple queries with '||'.",
|
| 19 |
+
"parameters": {
|
| 20 |
+
"type": "object",
|
| 21 |
+
"properties": {
|
| 22 |
+
"queries": {
|
| 23 |
+
"type": "string",
|
| 24 |
+
"description": "query1||query2"
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
"required": [
|
| 28 |
+
"queries"
|
| 29 |
+
],
|
| 30 |
+
"additionalProperties": false,
|
| 31 |
+
"$schema": "http://json-schema.org/draft-07/schema#"
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"type": "function",
|
| 37 |
+
"function": {
|
| 38 |
+
"name": "open",
|
| 39 |
+
"description": "Batch open IDs (format 【{id}†...】) or URLs.",
|
| 40 |
+
"parameters": {
|
| 41 |
+
"type": "object",
|
| 42 |
+
"properties": {
|
| 43 |
+
"open_list": {
|
| 44 |
+
"type": "array",
|
| 45 |
+
"items": {
|
| 46 |
+
"type": "object",
|
| 47 |
+
"properties": {
|
| 48 |
+
"id": {
|
| 49 |
+
"description": "ID or URL",
|
| 50 |
+
"anyOf": [
|
| 51 |
+
{
|
| 52 |
+
"type": "integer"
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"type": "string"
|
| 56 |
+
}
|
| 57 |
+
],
|
| 58 |
+
"default": -1
|
| 59 |
+
},
|
| 60 |
+
"cursor": {
|
| 61 |
+
"type": "integer",
|
| 62 |
+
"description": "",
|
| 63 |
+
"default": -1
|
| 64 |
+
},
|
| 65 |
+
"loc": {
|
| 66 |
+
"type": "integer",
|
| 67 |
+
"description": "Start line",
|
| 68 |
+
"default": -1
|
| 69 |
+
},
|
| 70 |
+
"num_lines": {
|
| 71 |
+
"type": "integer",
|
| 72 |
+
"description": "",
|
| 73 |
+
"default": -1
|
| 74 |
+
},
|
| 75 |
+
"view_source": {
|
| 76 |
+
"type": "boolean",
|
| 77 |
+
"description": "",
|
| 78 |
+
"default": false
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
"additionalProperties": false
|
| 82 |
+
},
|
| 83 |
+
"description": ""
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
"required": [
|
| 87 |
+
"open_list"
|
| 88 |
+
],
|
| 89 |
+
"additionalProperties": false,
|
| 90 |
+
"$schema": "http://json-schema.org/draft-07/schema#"
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"type": "function",
|
| 96 |
+
"function": {
|
| 97 |
+
"name": "find",
|
| 98 |
+
"description": "Find exact text pattern in pages.",
|
| 99 |
+
"parameters": {
|
| 100 |
+
"type": "object",
|
| 101 |
+
"properties": {
|
| 102 |
+
"find_list": {
|
| 103 |
+
"type": "array",
|
| 104 |
+
"items": {
|
| 105 |
+
"type": "object",
|
| 106 |
+
"properties": {
|
| 107 |
+
"pattern": {
|
| 108 |
+
"type": "string",
|
| 109 |
+
"description": ""
|
| 110 |
+
},
|
| 111 |
+
"cursor": {
|
| 112 |
+
"type": "integer",
|
| 113 |
+
"description": "",
|
| 114 |
+
"default": -1
|
| 115 |
+
}
|
| 116 |
+
},
|
| 117 |
+
"required": [
|
| 118 |
+
"pattern"
|
| 119 |
+
],
|
| 120 |
+
"additionalProperties": false
|
| 121 |
+
},
|
| 122 |
+
"description": ""
|
| 123 |
+
}
|
| 124 |
+
},
|
| 125 |
+
"required": [
|
| 126 |
+
"find_list"
|
| 127 |
+
],
|
| 128 |
+
"additionalProperties": false,
|
| 129 |
+
"$schema": "http://json-schema.org/draft-07/schema#"
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
]
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"role": "assistant",
|
| 137 |
+
"content": "",
|
| 138 |
+
"reasoning_content": "用户想知道小柴胡冲剂和布洛芬能否一起服用。",
|
| 139 |
+
"tool_calls": [
|
| 140 |
+
{
|
| 141 |
+
"type": "function",
|
| 142 |
+
"function": {
|
| 143 |
+
"name": "search",
|
| 144 |
+
"arguments": "{\"queries\": \"小柴胡冲剂 布洛芬 相互作用 一起吃\"}"
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
]
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"role": "tool",
|
| 151 |
+
"content": "[0]"
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"role": "assistant",
|
| 155 |
+
"content": "请及时就医。",
|
| 156 |
+
"reasoning_content": "现在开始组织回答。",
|
| 157 |
+
"tool_calls": []
|
| 158 |
+
}
|
| 159 |
+
]
|
encoding/tests/test_input_4.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"role": "system",
|
| 4 |
+
"content": "该助手为DeepSeek-V3,由深度求索公司创造。\n今天是2025年10月17日,星期五。"
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"role": "latest_reminder",
|
| 8 |
+
"content": "2024-11-15,上海市,App,中文"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"role": "user",
|
| 12 |
+
"content": "热海大滚锅是世界著名温泉吗"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"role": "assistant",
|
| 16 |
+
"content": "关于热海大滚锅是否是世界著名温泉,可以这样总结:它在**中国乃至全球的地热奇观中占有重要地位**,但“世界著名”的称号更侧重于它作为**独特的地质现象和旅游景点**,而非普遍的温泉疗养体验。\n\n为了让你快速了解,我整理了一个简要的表格:\n\n| 维度 | 热海大滚锅的地位与特点 |\n| :--- | :--- |\n| **地质奇观** | **世界罕见**的高温地热系统,泉眼多、水温高、形态多样。 |\n| **国内声誉** | **中国三大地热区之一**,**国家5A级旅游景区**,自古闻名(徐霞客曾游历并记载)。 |\n| **国际知名度** | 在一些旅游平台被提及为“世界六大温泉”之一,但此说法流传不广,其国际声誉更多建立在地质独特性上。 |\n| **核心体验** | **观赏地热奇观**(如97℃沸腾的“大滚锅”)、**体验温泉煮鸡蛋**。 |\n\n### 💡 游玩攻略与温馨提示\n\n如果你计划前往热海大滚锅,这里有一些实用信息供你参考:\n\n- **门票与开放时间**:\n - **门票**:景区门票约为**50元/人**。如果选择包含温泉沐浴的套餐,价格会更高,例如约**288元**。\n - **开放时间**:景区一般**08:00-18:00**开放,但具体时间可能变动,建议提前核实。\n\n- **特色体验**:\n - **温泉煮鸡蛋**:这几乎是必试项目。可以在景区门口购买用草绳串起的生鸡蛋(约5-8元/串),然后到“大滚锅”旁的指定区域蒸煮,几分钟便可熟食,趣味十足。\n - **金汤足浴**:可以直接用从“大滚锅”流出的温泉水泡脚,缓解旅途疲劳。\n\n- **注意事项**:\n - **安全第一**:“大滚锅”水温极高,务必遵守游览规则,在指定区域内观赏,切勿随意触碰泉水。\n - **规划行程**:建议为热海景区预留**3-4小时**的游览时间。景区内步道不走回头路,出入口有观光车接送。\n\n希望这些信息能帮助你更好地了解热海大滚锅。如果你对腾冲的其他景点或者行程规划有更多疑问,我很乐意提供进一步的信息。",
|
| 17 |
+
"mask": 1
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"role": "user",
|
| 21 |
+
"content": "世界著名温泉有哪些",
|
| 22 |
+
"task": "action"
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"role": "assistant",
|
| 26 |
+
"content": "Search"
|
| 27 |
+
}
|
| 28 |
+
]
|
encoding/tests/test_output_1.txt
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|>You are a helpful assistant.
|
| 2 |
+
|
| 3 |
+
## Tools
|
| 4 |
+
|
| 5 |
+
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|tool_calls>" block like the following:
|
| 6 |
+
|
| 7 |
+
<|DSML|tool_calls>
|
| 8 |
+
<|DSML|invoke name="$TOOL_NAME">
|
| 9 |
+
<|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
|
| 10 |
+
...
|
| 11 |
+
</|DSML|invoke>
|
| 12 |
+
<|DSML|invoke name="$TOOL_NAME2">
|
| 13 |
+
...
|
| 14 |
+
</|DSML|invoke>
|
| 15 |
+
</|DSML|tool_calls>
|
| 16 |
+
|
| 17 |
+
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
|
| 18 |
+
|
| 19 |
+
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
|
| 20 |
+
|
| 21 |
+
Otherwise, output directly after </think> with tool calls or final response.
|
| 22 |
+
|
| 23 |
+
### Available Tool Schemas
|
| 24 |
+
|
| 25 |
+
{"name": "get_weather", "description": "Get the weather for a specific location", "parameters": {"type": "object", "properties": {"location": {"type": "string", "description": "The city name"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Temperature unit"}}, "required": ["location"]}}
|
| 26 |
+
{"name": "search", "description": "Search the web for information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Search query"}, "num_results": {"type": "integer", "description": "Number of results to return"}}, "required": ["query"]}}
|
| 27 |
+
|
| 28 |
+
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
|
| 29 |
+
<|User|>What's the weather in Beijing?<|Assistant|><think>The user wants to know the weather in Beijing. I should use the get_weather tool.</think>
|
| 30 |
+
|
| 31 |
+
<|DSML|tool_calls>
|
| 32 |
+
<|DSML|invoke name="get_weather">
|
| 33 |
+
<|DSML|parameter name="location" string="true">Beijing</|DSML|parameter>
|
| 34 |
+
<|DSML|parameter name="unit" string="true">celsius</|DSML|parameter>
|
| 35 |
+
</|DSML|invoke>
|
| 36 |
+
</|DSML|tool_calls><|end▁of▁sentence|><|User|><tool_result>{"temperature": 22, "condition": "sunny", "humidity": 45}</tool_result><|Assistant|><think>Got the weather data. Let me format a nice response.</think>The weather in Beijing is currently sunny with a temperature of 22°C and 45% humidity.<|end▁of▁sentence|>
|
encoding/tests/test_output_2.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|>You are a helpful assistant.<|User|>Hello<|Assistant|></think>Hi there! How can I help you?<|end▁of▁sentence|><|User|>What is the capital of France?<|Assistant|><think>The user asks about the capital of France. It is Paris.</think>The capital of France is Paris.<|end▁of▁sentence|>
|
encoding/tests/test_output_3.txt
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|>该助手为DeepSeek,由深度求索公司创造。<|latest_reminder|>2026-02-21,星期六,广州,App,中文<|User|>小柴胡冲剂和布洛芬能一起吃吗?
|
| 2 |
+
|
| 3 |
+
CITATION FORMAT: 【{cursor_id}†L{start_line_id}(-L{end_line_id})?】
|
| 4 |
+
|
| 5 |
+
## Tools
|
| 6 |
+
|
| 7 |
+
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|tool_calls>" block like the following:
|
| 8 |
+
|
| 9 |
+
<|DSML|tool_calls>
|
| 10 |
+
<|DSML|invoke name="$TOOL_NAME">
|
| 11 |
+
<|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
|
| 12 |
+
...
|
| 13 |
+
</|DSML|invoke>
|
| 14 |
+
<|DSML|invoke name="$TOOL_NAME2">
|
| 15 |
+
...
|
| 16 |
+
</|DSML|invoke>
|
| 17 |
+
</|DSML|tool_calls>
|
| 18 |
+
|
| 19 |
+
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
|
| 20 |
+
|
| 21 |
+
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
|
| 22 |
+
|
| 23 |
+
Otherwise, output directly after </think> with tool calls or final response.
|
| 24 |
+
|
| 25 |
+
### Available Tool Schemas
|
| 26 |
+
|
| 27 |
+
{"name": "search", "description": "Web search. Split multiple queries with '||'.", "parameters": {"type": "object", "properties": {"queries": {"type": "string", "description": "query1||query2"}}, "required": ["queries"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#"}}
|
| 28 |
+
{"name": "open", "description": "Batch open IDs (format 【{id}†...】) or URLs.", "parameters": {"type": "object", "properties": {"open_list": {"type": "array", "items": {"type": "object", "properties": {"id": {"description": "ID or URL", "anyOf": [{"type": "integer"}, {"type": "string"}], "default": -1}, "cursor": {"type": "integer", "description": "", "default": -1}, "loc": {"type": "integer", "description": "Start line", "default": -1}, "num_lines": {"type": "integer", "description": "", "default": -1}, "view_source": {"type": "boolean", "description": "", "default": false}}, "additionalProperties": false}, "description": ""}}, "required": ["open_list"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#"}}
|
| 29 |
+
{"name": "find", "description": "Find exact text pattern in pages.", "parameters": {"type": "object", "properties": {"find_list": {"type": "array", "items": {"type": "object", "properties": {"pattern": {"type": "string", "description": ""}, "cursor": {"type": "integer", "description": "", "default": -1}}, "required": ["pattern"], "additionalProperties": false}, "description": ""}}, "required": ["find_list"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#"}}
|
| 30 |
+
|
| 31 |
+
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
|
| 32 |
+
<|Assistant|><think>用户想知道小柴胡冲剂和布洛芬能否一起服用。</think>
|
| 33 |
+
|
| 34 |
+
<|DSML|tool_calls>
|
| 35 |
+
<|DSML|invoke name="search">
|
| 36 |
+
<|DSML|parameter name="queries" string="true">小柴胡冲剂 布洛芬 相互作用 一起吃</|DSML|parameter>
|
| 37 |
+
</|DSML|invoke>
|
| 38 |
+
</|DSML|tool_calls><|end▁of▁sentence|><|User|><tool_result>[0]</tool_result><|Assistant|><think>现在开始组织回答。</think>请及时就医。<|end▁of▁sentence|>
|
encoding/tests/test_output_4.txt
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<|begin▁of▁sentence|>该助手为DeepSeek-V3,由深度求索公司创造。
|
| 2 |
+
今天是2025年10月17日,星期五。<|latest_reminder|>2024-11-15,上海市,App,中文<|User|>热海大滚锅是世界著名温泉吗<|Assistant|></think>关于热海大滚锅是否是世界著名温泉,可以这样总结:它在**中国乃至全球的地热奇观中占有重要地位**,但“世界著名”的称号更侧重于它作为**独特的地质现象和旅游景点**,而非普遍的温泉疗养体验。
|
| 3 |
+
|
| 4 |
+
为了让你快速了解,我整理了一个简要的表格:
|
| 5 |
+
|
| 6 |
+
| 维度 | 热海大滚锅的地位与特点 |
|
| 7 |
+
| :--- | :--- |
|
| 8 |
+
| **地质奇观** | **世界罕见**的高温地热系统,泉眼多、水温高、形态多样。 |
|
| 9 |
+
| **国内声誉** | **中国三大地热区之一**,**国家5A级旅游景区**,自古闻名(徐霞客曾游历并记载)。 |
|
| 10 |
+
| **国际知名度** | 在一些旅游平台被提及为“世界六大温泉”之一,但此说法流传不广,其国际声誉更多建立在地质独特性上。 |
|
| 11 |
+
| **核心体验** | **观赏地热奇观**(如97℃沸腾的“大滚锅”)、**体验温泉煮鸡蛋**。 |
|
| 12 |
+
|
| 13 |
+
### 💡 游玩攻略与温馨提示
|
| 14 |
+
|
| 15 |
+
如果你计划前往热海大滚锅,这里有一些实用信息供你参考:
|
| 16 |
+
|
| 17 |
+
- **门票与开放时间**:
|
| 18 |
+
- **门票**:景区门票约为**50元/人**。如果选择包含温泉沐浴的套餐,价格会更高,例如约**288元**。
|
| 19 |
+
- **开放时间**:景区一般**08:00-18:00**开放,但具体时间可能变动,建议提前核实。
|
| 20 |
+
|
| 21 |
+
- **特色体验**:
|
| 22 |
+
- **温泉煮鸡蛋**:这几乎是必试项目。可以在景区门口购买用草绳串起的生鸡蛋(约5-8元/串),然后到“大滚锅”旁的指定区域蒸煮,几分钟便可熟食,趣味十足。
|
| 23 |
+
- **金汤足浴**:可以直接用从“大滚锅”流出的温泉水泡脚,缓解旅途疲劳。
|
| 24 |
+
|
| 25 |
+
- **注意事项**:
|
| 26 |
+
- **安全第一**:“大滚锅”水温极高,务必遵守游览规则,在指定区域内观赏,切勿随意触碰泉水。
|
| 27 |
+
- **规划行程**:建议为热海景区预留**3-4小时**的游览时间。景区内步道不走回头路,出入口有观光车接送。
|
| 28 |
+
|
| 29 |
+
希望这些信息能帮助你更好地了解热海大滚锅。如果你对腾冲的其他景点或者行程规划有更多疑问,我很乐意提供进一步的信息。<|end▁of▁sentence|><|User|>世界著名温泉有哪些<|Assistant|></think><|action|>Search<|end▁of▁sentence|>
|
generation_config.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_from_model_config": true,
|
| 3 |
+
"bos_token_id": 0,
|
| 4 |
+
"eos_token_id": 1,
|
| 5 |
+
"do_sample": true,
|
| 6 |
+
"temperature": 1.0,
|
| 7 |
+
"top_p": 1.0,
|
| 8 |
+
"transformers_version": "4.46.3"
|
| 9 |
+
}
|
model-00001-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5b666121207ac6ec09e2e1c8e51dbc0b95c14848110b61992ef22211a1fca0e3
|
| 3 |
+
size 5754875518
|
model-00004-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:77707e9b637a064371216eca651bbed3c2af55e529b7fff42224d8edc014ff6c
|
| 3 |
+
size 5218248749
|
model-00005-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2bc224b6915cbd8eae08f5b617d4f1ce1ce3d791469bdd330c5555dafe09923d
|
| 3 |
+
size 5034459549
|
model-00011-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:223de759a15abf1a90eae79508ddb7248111764553f0ba54c0dee6806855121a
|
| 3 |
+
size 5034459628
|
model-00012-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aaad41645f5796ece978cfbb99ef29c51ccd0b05b794b93afbbdcf2d14143872
|
| 3 |
+
size 5015628875
|
model-00013-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c923719ddf1e525ca79a162863ea0730ddb649dacb3c233e338bf7dd7ebd8354
|
| 3 |
+
size 5218248781
|
model-00014-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fafcfb4a24e33e47cbf1b18da8887c31c86aeabffb5cfeb8fcbc154b84f81933
|
| 3 |
+
size 5034459620
|
model-00016-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ff0b109dd632a834df972e54e7e062381bdd11311e4ed8de66bb5530a5d4fd76
|
| 3 |
+
size 5218248787
|
model-00017-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f9f5767e72a14f638d058a3fd54259b7a8e5bd20b8bf4648e73b22b4e7f37848
|
| 3 |
+
size 5034459622
|
model-00018-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a13e84e80fa5061d4f29dc623b4987f6b128ba9641531647738db966c418a795
|
| 3 |
+
size 5015628873
|
model-00019-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1fa79c548c4bfb3f967dc480805d81f7d623416ce6a01408c8c07ab5712ae9bd
|
| 3 |
+
size 5218248843
|
model-00020-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c9e1eecb7cdeddb858d7c3dae924c34e9aed2179ad81197b84eb97eb20c49381
|
| 3 |
+
size 5034459620
|
model-00021-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2095b2a1bef0e06fe83b85dd54769f7c7bf508fc655ff8fa1285c57fd8a38ad7
|
| 3 |
+
size 5015628885
|
model-00024-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:75195a4c89b7d7a11a1a5d236d47b5f039d13c53a36a891badb80f6412197d33
|
| 3 |
+
size 5015628883
|
model-00025-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a276e3fadfba9697307a481aafab5c416d59952d788a11e7eb0906d51b8787bf
|
| 3 |
+
size 5218248799
|
model-00026-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a8d6acd4f275078462e691ddd01c49ff3631e3b37ec0ab25c1def28b54d5d000
|
| 3 |
+
size 5034459616
|
model-00032-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b0888a88bea8985f40397f710eb21a529b266b3aa4f68ad1ae4cad050191b45e
|
| 3 |
+
size 5034459626
|
model-00033-of-00033.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:34dd8fd29e43b61453f1c4258df2c02fe149cb3086c6fcf1089e6efaf809ed86
|
| 3 |
+
size 1770857432
|
model.safetensors.index.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"add_bos_token": false,
|
| 3 |
+
"add_eos_token": false,
|
| 4 |
+
"bos_token": {
|
| 5 |
+
"__type": "AddedToken",
|
| 6 |
+
"content": "<|begin▁of▁sentence|>",
|
| 7 |
+
"lstrip": false,
|
| 8 |
+
"normalized": true,
|
| 9 |
+
"rstrip": false,
|
| 10 |
+
"single_word": false
|
| 11 |
+
},
|
| 12 |
+
"clean_up_tokenization_spaces": false,
|
| 13 |
+
"eos_token": {
|
| 14 |
+
"__type": "AddedToken",
|
| 15 |
+
"content": "<|end▁of▁sentence|>",
|
| 16 |
+
"lstrip": false,
|
| 17 |
+
"normalized": true,
|
| 18 |
+
"rstrip": false,
|
| 19 |
+
"single_word": false
|
| 20 |
+
},
|
| 21 |
+
"legacy": true,
|
| 22 |
+
"model_max_length": 1048576,
|
| 23 |
+
"pad_token": {
|
| 24 |
+
"__type": "AddedToken",
|
| 25 |
+
"content": "<|end▁of▁sentence|>",
|
| 26 |
+
"lstrip": false,
|
| 27 |
+
"normalized": true,
|
| 28 |
+
"rstrip": false,
|
| 29 |
+
"single_word": false
|
| 30 |
+
},
|
| 31 |
+
"sp_model_kwargs": {},
|
| 32 |
+
"unk_token": null,
|
| 33 |
+
"tokenizer_class": "PreTrainedTokenizerFast"
|
| 34 |
+
}
|