patdev commited on
Commit
8a96929
·
verified ·
1 Parent(s): 4b823d5

Upload distill/generate.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. distill/generate.py +280 -0
distill/generate.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Étape 1 — collecte des réponses de Kimi-K3 pour distiller vers Kimi-Linear.
2
+
3
+ L'API officielle ne rend pas les logits : la distillation est donc au niveau
4
+ séquence (le socle apprend à imiter les sorties de K3), pas au niveau
5
+ distribution. C'est moins efficace en tokens qu'une KD sur logits, mais c'est
6
+ la seule voie disponible sans poids K3 locaux -- et c'est la recette déjà
7
+ validée dans Kimi-K3-L4-Distill-9B-LoRA.
8
+
9
+ Le budget est un plafond dur, vérifié avant chaque appel : l'endpoint facture
10
+ ~15 $/1M de tokens de sortie, et une boucle non bornée y passerait des crédits
11
+ sans prévenir.
12
+
13
+ python generate.py --budget 2.0 --out data/k3.jsonl
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import pathlib
22
+ import random
23
+ import sys
24
+ import time
25
+ import urllib.error
26
+ import urllib.request
27
+
28
+ ENDPOINT = "https://api.runpod.ai/v2/moonshot-kimi/runsync"
29
+ PRICE_OUT = 15.0 / 1_000_000 # $/token de sortie, tarif affiche
30
+ PRICE_IN = 3.0 / 1_000_000
31
+
32
+
33
+ def api_key() -> str:
34
+ """Le jeton n'est jamais journalise ni affiche."""
35
+ k = os.environ.get("RUNPOD_API_KEY")
36
+ if k:
37
+ return k.strip()
38
+ for p in (r"G:\AI\runpod\.env", ".env"):
39
+ f = pathlib.Path(p)
40
+ if f.exists():
41
+ for line in f.read_text(encoding="utf-8", errors="ignore").splitlines():
42
+ if line.startswith("RUNPOD_API_KEY="):
43
+ v = line.split("=", 1)[1].strip().strip('"').strip("'")
44
+ if v:
45
+ return v
46
+ sys.exit("RUNPOD_API_KEY introuvable (env ou G:\\AI\\runpod\\.env)")
47
+
48
+
49
+ # ---------------------------------------------------------------------- prompts
50
+ # Cible les écarts mesurés entre Kimi-Linear et K3, pas des tâches génériques :
51
+ # le français imprécis ("gate" traduit en "gâteau"), l'usage d'outils, et
52
+ # l'édition de code, qui est le régime réel d'un agent.
53
+
54
+ CODE = [
55
+ "Écris une fonction Python qui parse un fichier .env en dictionnaire, en gérant les commentaires, les guillemets et les lignes vides. Code seulement.",
56
+ "Refactorise ce code pour éliminer la duplication :\n\n```python\ndef get_user(id):\n r = requests.get(f'/users/{id}')\n if r.status_code != 200: raise Exception('fail')\n return r.json()\n\ndef get_post(id):\n r = requests.get(f'/posts/{id}')\n if r.status_code != 200: raise Exception('fail')\n return r.json()\n```",
57
+ "Explique pourquoi ce code a une condition de course et corrige-le :\n\n```python\ncounter = 0\ndef worker():\n global counter\n for _ in range(100000):\n counter += 1\n```",
58
+ "Écris un décorateur Python qui réessaie une fonction avec un backoff exponentiel et un jitter. Type hints complets.",
59
+ "Convertis cette boucle en compréhension et explique si c'est vraiment plus lisible :\n\n```python\nout = []\nfor x in items:\n if x.active:\n out.append(x.name.upper())\n```",
60
+ "Trouve le bug :\n\n```python\ndef binary_search(a, t):\n lo, hi = 0, len(a)\n while lo < hi:\n mid = (lo + hi) // 2\n if a[mid] < t: lo = mid\n elif a[mid] > t: hi = mid\n else: return mid\n return -1\n```",
61
+ "Écris un contexte manager asynchrone Python qui limite la concurrence à N tâches.",
62
+ "Implémente un LRU cache en Python sans utiliser functools, avec O(1) en lecture et écriture.",
63
+ ]
64
+
65
+ FRENCH = [
66
+ "Explique en trois phrases précises ce qu'est le mécanisme de gating dans un modèle mixture-of-experts. Emploie la terminologie française correcte.",
67
+ "Quelle est la différence entre la mémoïsation et la programmation dynamique ? Réponds en français technique rigoureux.",
68
+ "Explique ce qu'est une condition de concurrence et donne un exemple concret en français.",
69
+ "Décris le fonctionnement d'un cache de préfixe dans un moteur d'inférence, en français, sans anglicismes inutiles.",
70
+ "Explique la différence entre latence et débit pour un serveur d'inférence, avec un exemple chiffré.",
71
+ "Qu'est-ce que la quantification d'un modèle de langage ? Explique les compromis en français précis.",
72
+ ]
73
+
74
+ # Les outils sont passes dans le champ `tools` de la requete, pas decrits en
75
+ # prose : sollicite ainsi, K3 repond a juste titre "aucun outil ne m'est
76
+ # reellement fourni", et l'exemple collecte est inutilisable pour apprendre
77
+ # l'usage d'outils.
78
+ TOOL_SCHEMA = [
79
+ {"type": "function", "function": {"name": "read_file", "description": "Lit un fichier",
80
+ "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
81
+ {"type": "function", "function": {"name": "write_file", "description": "Ecrit un fichier",
82
+ "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}},
83
+ {"type": "function", "function": {"name": "grep", "description": "Cherche un motif",
84
+ "parameters": {"type": "object", "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}}, "required": ["pattern"]}}},
85
+ {"type": "function", "function": {"name": "list_dir", "description": "Liste un dossier",
86
+ "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
87
+ {"type": "function", "function": {"name": "run_command", "description": "Execute une commande shell",
88
+ "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}, "required": ["cmd"]}}},
89
+ ]
90
+
91
+ TOOLS = [
92
+ "Lis /etc/config.yaml, remplace le port 8080 par 9090, et reecris le fichier.",
93
+ "Trouve tous les fichiers Python de plus de 500 lignes dans ce depot.",
94
+ "Trouve ou la variable MAX_RETRIES est definie, puis montre son contexte.",
95
+ "Determine quel framework de test utilise ce projet.",
96
+ ]
97
+
98
+ REASONING = [
99
+ "Un serveur traite 1200 requêtes/s avec 8 workers. On passe à 12 workers et le débit monte à 1500 req/s. Que peut-on en déduire sur le goulot d'étranglement ?",
100
+ "Un modèle lit 4,7 Go de poids par token et la carte offre 696 Go/s. Quel est le plafond théorique en tokens/s ? Comment le décodage spéculatif le dépasse-t-il ?",
101
+ "Pourquoi un cache KV quantifié en 8 bits peut-il être refusé par un moteur alors que les poids en 4 bits sont acceptés ?",
102
+ ]
103
+
104
+
105
+ def build_prompts(n: int, seed: int = 0) -> list[str]:
106
+ """Mélange déterministe : deux exécutions avec la même graine donnent le
107
+ même jeu, ce qui rend une reprise après interruption reproductible."""
108
+ pool = CODE * 3 + FRENCH * 3 + TOOLS * 2 + REASONING * 2
109
+ rng = random.Random(seed)
110
+ rng.shuffle(pool)
111
+ return (pool * (n // len(pool) + 1))[:n]
112
+
113
+
114
+ def _req(url: str, key: str, body: bytes | None = None, timeout: int = 300):
115
+ req = urllib.request.Request(
116
+ url, data=body,
117
+ headers={"Authorization": f"Bearer {key}",
118
+ "Content-Type": "application/json",
119
+ # Cloudflare, devant l'API Runpod, rejette la signature par
120
+ # defaut d'urllib avec un 403 "error code: 1010".
121
+ "User-Agent": "Mozilla/5.0 (distill)"})
122
+ with urllib.request.urlopen(req, timeout=timeout) as r:
123
+ return json.loads(r.read())
124
+
125
+
126
+ def call_k3(prompt: str, key: str, max_tokens: int, timeout: int = 300,
127
+ tools: list | None = None) -> dict | None:
128
+ """Soumet puis, si necessaire, suit la tache jusqu'a son terme.
129
+
130
+ `runsync` ne rend le resultat que si la generation tient dans son attente
131
+ interne ; au-dela il renvoie un identifiant et un statut IN_QUEUE ou
132
+ IN_PROGRESS. Ne pas le suivre donnait une reponse sans `output`, donc vide
133
+ et sans cout -- un symptome qui ressemblait a tort a un probleme de modele.
134
+ """
135
+ body = json.dumps({"input": {
136
+ "model": "kimi-k3",
137
+ "messages": [{"role": "user", "content": prompt}],
138
+ "max_tokens": max_tokens,
139
+ # K3 refuse toute temperature autre que 1 :
140
+ # "invalid temperature: only 1 is allowed for this model"
141
+ # L'erreur n'apparaissait pas tant que la tache n'etait pas suivie.
142
+ "temperature": 1,
143
+ **({"tools": tools, "tool_choice": "auto"} if tools else {}),
144
+ }}).encode()
145
+ try:
146
+ r = _req("https://api.runpod.ai/v2/moonshot-kimi/runsync", key, body, timeout)
147
+ except urllib.error.HTTPError as e:
148
+ print(f" HTTP {e.code}: {e.read()[:200].decode('utf-8', 'ignore')}", flush=True)
149
+ return None
150
+ except Exception as e:
151
+ print(f" {type(e).__name__}: {e}", flush=True)
152
+ return None
153
+
154
+ if r.get("output") is not None:
155
+ return r
156
+
157
+ job = r.get("id")
158
+ status = r.get("status")
159
+ if not job:
160
+ print(f" reponse sans output ni id : {str(r)[:160]}", flush=True)
161
+ return None
162
+
163
+ deadline = time.time() + timeout
164
+ while time.time() < deadline:
165
+ time.sleep(3)
166
+ try:
167
+ r = _req(f"https://api.runpod.ai/v2/moonshot-kimi/status/{job}", key)
168
+ except Exception as e:
169
+ print(f" suivi : {type(e).__name__}", flush=True)
170
+ continue
171
+ status = r.get("status")
172
+ if status == "COMPLETED":
173
+ return r
174
+ if status in ("FAILED", "CANCELLED", "TIMED_OUT"):
175
+ print(f" tache {status}: {str(r.get('error'))[:160]}", flush=True)
176
+ return None
177
+ print(f" tache toujours {status} apres {timeout} s", flush=True)
178
+ return None
179
+
180
+
181
+ def extract(resp: dict) -> tuple[str, str, dict, float, list]:
182
+ """Renvoie (contenu, trace de raisonnement, usage, coût réel).
183
+
184
+ K3 expose son raisonnement dans `reasoning_content`. On le conserve à part :
185
+ l'entraîner ou non est une décision à prendre en aval, pas ici.
186
+ """
187
+ out = resp.get("output")
188
+ if isinstance(out, list) and out:
189
+ out = out[0]
190
+ res = (out or {}).get("result") or {}
191
+ ch = (res.get("choices") or [{}])[0]
192
+ msg = ch.get("message") or {}
193
+ usage = res.get("usage") or {}
194
+ cost = (out or {}).get("cost")
195
+ if cost is None:
196
+ cost = (usage.get("completion_tokens", 0) * PRICE_OUT
197
+ + usage.get("prompt_tokens", 0) * PRICE_IN)
198
+ return (msg.get("content") or "", msg.get("reasoning_content") or "",
199
+ usage, float(cost), msg.get("tool_calls") or [])
200
+
201
+
202
+ def main() -> None:
203
+ ap = argparse.ArgumentParser()
204
+ ap.add_argument("--budget", type=float, default=1.0, help="plafond dur en dollars")
205
+ ap.add_argument("--out", default="data/k3.jsonl")
206
+ # K3 raisonne avant de repondre : un budget trop court ne produit que de la
207
+ # reflexion. 2000 laisse la place a la trace ET a la reponse.
208
+ ap.add_argument("--max-tokens", type=int, default=2000)
209
+ ap.add_argument("--n", type=int, default=400)
210
+ ap.add_argument("--seed", type=int, default=0)
211
+ a = ap.parse_args()
212
+
213
+ key = api_key()
214
+ path = pathlib.Path(a.out)
215
+ path.parent.mkdir(parents=True, exist_ok=True)
216
+
217
+ # Reprise : on saute ce qui est déjà collecté plutôt que de le repayer.
218
+ done = set()
219
+ if path.exists():
220
+ for line in path.open(encoding="utf-8"):
221
+ try:
222
+ done.add(json.loads(line)["prompt"])
223
+ except Exception:
224
+ pass
225
+ print(f"reprise : {len(done)} exemples deja collectes")
226
+
227
+ prompts = [p for p in build_prompts(a.n, a.seed) if p not in done]
228
+ spent = 0.0
229
+ kept = 0
230
+ t0 = time.time()
231
+
232
+ with path.open("a", encoding="utf-8") as f:
233
+ for i, p in enumerate(prompts, 1):
234
+ # Verification AVANT l'appel : depasser le plafond puis s'en
235
+ # apercevoir ne sert a rien, l'argent est deja parti.
236
+ if spent >= a.budget:
237
+ print(f"plafond de {a.budget} $ atteint, arret propre")
238
+ break
239
+ r = call_k3(p, key, a.max_tokens,
240
+ tools=TOOL_SCHEMA if p in TOOLS else None)
241
+ if not r:
242
+ continue
243
+ content, reasoning, usage, cost, tool_calls = extract(r)
244
+ spent += cost
245
+ # Un tour qui n'emet que des appels d'outils est un exemple
246
+ # valide : c'est meme le comportement qu'on cherche a distiller.
247
+ if not content.strip() and not tool_calls:
248
+ # K3 est un modele a raisonnement : il remplit d'abord
249
+ # reasoning_content. Avec un max_tokens trop bas il consomme
250
+ # tout son budget en reflexion et n'emet jamais la reponse.
251
+ # Le distinguer d'une vraie reponse vide evite de chercher
252
+ # ailleurs.
253
+ why = (f"raisonnement seul ({len(reasoning)} car., "
254
+ f"{usage.get('completion_tokens',0)} tokens) "
255
+ f"-- augmentez --max-tokens"
256
+ if reasoning.strip() else "reponse totalement vide")
257
+ print(f" {i}: {why}")
258
+ continue
259
+ f.write(json.dumps({
260
+ "prompt": p,
261
+ "completion": content,
262
+ "reasoning": reasoning,
263
+ "tool_calls": tool_calls,
264
+ "usage": usage,
265
+ "cost": cost,
266
+ }, ensure_ascii=False) + "\n")
267
+ f.flush()
268
+ kept += 1
269
+ if kept % 10 == 0 or i == 1:
270
+ print(f" {kept} gardes | {spent:.3f} $ / {a.budget} $ | "
271
+ f"{time.time()-t0:.0f} s", flush=True)
272
+
273
+ print(f"\n{kept} exemples ecrits dans {path}")
274
+ print(f"cout reel : {spent:.3f} $")
275
+ if kept:
276
+ print(f"soit {spent/kept*1000:.2f} $ pour 1000 exemples")
277
+
278
+
279
+ if __name__ == "__main__":
280
+ main()