tchbcb commited on
Commit
9834e2a
·
verified ·
1 Parent(s): ac0d936

chat server (flask, port 7861, force_think+antiloop+votes)

Browse files
Files changed (1) hide show
  1. chat_server.py +295 -0
chat_server.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """s4_server.py — samai-4b 网页聊天demo (Colab T4, port 7861) [v4, 移植自 r18 v3.2]
3
+ 协议适配 Spark 模板:
4
+ - force_think=True(默认): 生成提示 = ...<|Bot|><think> + "\\n" (与 SFT 训练格式一致)
5
+ - force_think=False: 生成提示 = ...<|Bot|></think> + "\\n" (跳过思考)
6
+ - eos=[1] (<|end▁of▁sentence|>); decode 不剥特殊token, 手工清理模板标记
7
+ 继承: AntiLoop 复读截断 / best-of-n 投票 / 强制思考开关 / ponder 步数展示
8
+ 端点: GET / | GET /health | POST /chat {message, history, force_think, n_votes}
9
+ """
10
+ import json, os, re, threading, time, uuid
11
+ from collections import Counter
12
+ import torch
13
+
14
+ from flask import Flask, request, jsonify, Response
15
+ from transformers import AutoModelForCausalLM, AutoTokenizer, StoppingCriteria, StoppingCriteriaList
16
+
17
+ MODEL_DIR = "/content/samai-4b-sft"
18
+ PORT = 7861
19
+ MAX_NEW_THINK = 320
20
+ MAX_NEW_AUTO = 192
21
+ MAX_PROMPT_TOKENS = 900
22
+ EOS_IDS = [1]
23
+
24
+ STATE = {"loaded": False, "error": None, "ckpt": os.path.basename(MODEL_DIR),
25
+ "t0": time.time()}
26
+ LOCK = threading.Lock()
27
+ JOBS = {}
28
+ JOBS_MU = threading.Lock()
29
+ MODEL = {"tok": None, "m": None}
30
+
31
+ app = Flask(__name__)
32
+
33
+
34
+ class AntiLoop(StoppingCriteria):
35
+ """末尾片段(L=3..16 token)连续重复 >=3 次判定为复读退化, 提前截断."""
36
+ def __call__(self, input_ids, scores, **kwargs):
37
+ ids = input_ids[0].tolist()
38
+ tail = ids[-64:]
39
+ if len(tail) < 9:
40
+ return False
41
+ for L in range(3, 17):
42
+ if len(tail) < 3 * L:
43
+ break
44
+ seg = tail[-L:]
45
+ if seg == tail[-2 * L:-L] == tail[-3 * L:-2 * L]:
46
+ return True
47
+ return False
48
+
49
+
50
+ def build():
51
+ global MODEL, STATE
52
+ print("[build] loading tokenizer...", flush=True)
53
+ tok = AutoTokenizer.from_pretrained(MODEL_DIR)
54
+ print("[build] loading model fp16 -> cuda ...", flush=True)
55
+ model = AutoModelForCausalLM.from_pretrained(
56
+ MODEL_DIR, trust_remote_code=True, dtype=torch.float16).cuda().eval()
57
+ MODEL["tok"], MODEL["m"] = tok, model
58
+ STATE["loaded"] = True
59
+ print("[build] DONE", type(model).__name__, flush=True)
60
+
61
+
62
+ MARKS = ["<think>", "</think>", "<|User|>", "<|Bot|>", "<|System|>", "<|Tool|>",
63
+ "<|start▁of▁sentence|>", "<|end▁of▁sentence|>", "<|▁pad▁|>",
64
+ "<|start▁of▁text|>", "<|end▁of▁text|>", "<unk>"]
65
+
66
+
67
+ def build_inputs(msgs, force_think):
68
+ tok = MODEL["tok"]
69
+ text = tok.apply_chat_template(msgs, add_generation_prompt=True,
70
+ tokenize=False, enable_thinking=force_think)
71
+ # force=True: ...<|Bot|><think> + "\n"; force=False: ...<|Bot|></think> + "\n"
72
+ text += "\n"
73
+ return tok(text, return_tensors="pt", return_dict=True, add_special_tokens=False)
74
+
75
+
76
+ def split_think(text):
77
+ text = text.split("<|end▁of▁sentence|>")[0]
78
+
79
+ def clean(s):
80
+ for mk in MARKS:
81
+ s = s.replace(mk, "")
82
+ return s.strip()
83
+
84
+ if "<think>" in text and "</think>" in text:
85
+ a, b = text.split("<think>", 1)
86
+ th, rest = b.split("</think>", 1)
87
+ return th.strip(), clean(a + rest)
88
+ if "<think>" in text:
89
+ return text.split("<think>", 1)[1].strip(), ""
90
+ if "</think>" in text:
91
+ th, rest = text.split("</think>", 1)
92
+ return th.strip(), clean(rest)
93
+ return "", clean(text)
94
+
95
+
96
+ @app.route("/health")
97
+ def health():
98
+ gpu = ""
99
+ try:
100
+ import subprocess
101
+ r = subprocess.run(["nvidia-smi", "--query-gpu=memory.used",
102
+ "--format=csv,noheader"], capture_output=True, text=True)
103
+ gpu = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
104
+ except Exception:
105
+ pass
106
+ return jsonify({"loaded": STATE["loaded"], "error": STATE["error"],
107
+ "ckpt": STATE["ckpt"],
108
+ "uptime_s": round(time.time() - STATE["t0"]),
109
+ "gpu_mem_used": gpu})
110
+
111
+
112
+ @app.route("/chat", methods=["POST"])
113
+ def chat():
114
+ if not STATE["loaded"]:
115
+ return jsonify({"error": "model loading"}), 503
116
+ d = request.get_json(force=True)
117
+ msg = (d.get("message") or "").strip()
118
+ history = d.get("history") or []
119
+ force = bool(d.get("force_think", True))
120
+ try:
121
+ n_votes = max(1, min(5, int(d.get("n_votes") or 1)))
122
+ except Exception:
123
+ n_votes = 1
124
+ if not msg:
125
+ return jsonify({"error": "empty"}), 400
126
+ msgs = [m for m in history if m.get("role") in ("user", "assistant") and m.get("content")]
127
+ msgs = msgs[-8:] + [{"role": "user", "content": msg}]
128
+ jid = uuid.uuid4().hex[:12]
129
+ with JOBS_MU:
130
+ JOBS[jid] = {"status": "running", "reply": "", "think": "", "steps": None,
131
+ "elapsed_s": 0, "error": None}
132
+ if len(JOBS) > 32:
133
+ for k in [k for k, v in JOBS.items() if v["status"] != "running"][:-16]:
134
+ JOBS.pop(k, None)
135
+ threading.Thread(target=run_job, args=(jid, msgs, force, n_votes),
136
+ daemon=True).start()
137
+ return jsonify({"job_id": jid})
138
+
139
+
140
+ def run_job(jid, msgs, force_think, n_votes=1):
141
+ job = JOBS[jid]
142
+ tok, model = MODEL["tok"], MODEL["m"]
143
+ with LOCK:
144
+ try:
145
+ enc = build_inputs(msgs, force_think)
146
+ n_in = enc["input_ids"].shape[1]
147
+ if n_in > MAX_PROMPT_TOKENS:
148
+ msgs = msgs[-4:]
149
+ enc = build_inputs(msgs, force_think)
150
+ n_in = enc["input_ids"].shape[1]
151
+ enc = {k: v.to(model.device) for k, v in enc.items()}
152
+ max_new = MAX_NEW_THINK if force_think else MAX_NEW_AUTO
153
+ temp = 0.6 if force_think else 1.0
154
+
155
+ def gen_once():
156
+ n_log = len(model._ponder_log)
157
+ t0 = time.time()
158
+ with torch.no_grad():
159
+ out = model.generate(**enc, max_new_tokens=max_new,
160
+ do_sample=True, temperature=temp, top_p=0.95,
161
+ repetition_penalty=1.05,
162
+ pad_token_id=2,
163
+ eos_token_id=EOS_IDS,
164
+ stopping_criteria=StoppingCriteriaList([AntiLoop()]))
165
+ el = round(time.time() - t0, 1)
166
+ text = tok.decode(out[0][n_in:], skip_special_tokens=False)
167
+ entries = model._ponder_log[n_log:]
168
+ steps = None
169
+ if entries:
170
+ steps = round(sum(e.get("steps_mean", e.get("executed", 0)) or 0
171
+ for e in entries) / len(entries), 2)
172
+ new_tokens = int(out.shape[1] - n_in)
173
+ stopped = int(out[0][-1]) in EOS_IDS
174
+ loop_hit = (not stopped) and (new_tokens < max_new)
175
+ return text, steps, new_tokens, stopped, loop_hit
176
+
177
+ def vote_key(reply):
178
+ nums = re.findall(r"\d[\d,]*(?:\.\d+)?", (reply or "").replace(",", ""))
179
+ if nums:
180
+ return "n:" + nums[-1]
181
+ return "t:" + (reply or "").strip()[:40]
182
+
183
+ t0 = time.time()
184
+ votes = None
185
+ if n_votes <= 1:
186
+ text, steps, new_tokens, stopped, loop = gen_once()
187
+ think, reply = split_think(text)
188
+ else:
189
+ rs = []
190
+ for _ in range(n_votes):
191
+ text_i, st, nt, sp, lp = gen_once()
192
+ th, rp = split_think(text_i)
193
+ rs.append({"think": th, "reply": rp, "steps": st,
194
+ "new_tokens": nt, "stopped": sp, "loop": lp})
195
+ cnt = Counter(vote_key(r["reply"]) for r in rs)
196
+ bk = cnt.most_common(1)[0][0]
197
+ best = next(r for r in rs if vote_key(r["reply"]) == bk)
198
+ think, reply = best["think"], best["reply"]
199
+ steps, new_tokens = best["steps"], best["new_tokens"]
200
+ stopped, loop = best["stopped"], best["loop"]
201
+ votes = {(k[2:] if k[:2] in ("n:", "t:") else k): c
202
+ for k, c in cnt.most_common()}
203
+ el = round(time.time() - t0, 1)
204
+ job.update({"status": "done", "reply": reply or text[:400],
205
+ "think": think, "steps": steps, "elapsed_s": el,
206
+ "new_tokens": new_tokens, "stopped": stopped, "loop": loop,
207
+ "votes": votes,
208
+ "mode": ("think" if force_think else "auto")
209
+ + ("x%d" % n_votes if n_votes > 1 else ""),
210
+ "temp": temp})
211
+ except Exception as e:
212
+ import traceback
213
+ traceback.print_exc()
214
+ job.update({"status": "error", "error": repr(e)[:300]})
215
+
216
+
217
+ @app.route("/result")
218
+ def result():
219
+ jid = request.args.get("id", "")
220
+ with JOBS_MU:
221
+ job = JOBS.get(jid)
222
+ if job is None:
223
+ return jsonify({"error": "unknown job"}), 404
224
+ return jsonify(dict(job))
225
+
226
+
227
+ PAGE = """<!doctype html><html lang="zh"><head><meta charset="utf-8">
228
+ <meta name="viewport" content="width=device-width,initial-scale=1">
229
+ <title>samai-4b · chat</title><style>
230
+ :root{--bg:#0f1115;--card:#171a21;--line:#262b36;--fg:#e6e9ef;--dim:#8b93a5;--acc:#7c5bff;--ok:#3fbf7f}
231
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);
232
+ font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;display:flex;flex-direction:column;height:100vh}
233
+ header{padding:12px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
234
+ header b{font-size:15px}.pill{font-size:12px;padding:2px 10px;border-radius:999px;border:1px solid var(--line);color:var(--dim)}
235
+ .pill.ok{color:var(--ok);border-color:var(--ok)}
236
+ label.pill{cursor:pointer;user-select:none;display:flex;align-items:center;gap:5px}
237
+ label.pill input{accent-color:#7c5bff}
238
+ #log{flex:1;overflow-y:auto;padding:18px;display:flex;flex-direction:column;gap:14px}
239
+ .msg{max-width:82%;padding:10px 14px;border-radius:14px;line-height:1.6;white-space:pre-wrap;word-break:break-word;font-size:14.5px}
240
+ .u{align-self:flex-end;background:var(--acc);color:#fff;border-bottom-right-radius:4px}
241
+ .a{align-self:flex-start;background:var(--card);border:1px solid var(--line);border-bottom-left-radius:4px}
242
+ .meta{align-self:flex-start;font-size:11.5px;color:var(--dim)}
243
+ details{margin-top:6px}summary{cursor:pointer;color:var(--dim);font-size:12px}
244
+ details pre{white-space:pre-wrap;font-size:12.5px;color:var(--dim);margin:6px 0 0}
245
+ footer{border-top:1px solid var(--line);padding:12px;display:flex;gap:10px}
246
+ textarea{flex:1;background:var(--card);border:1px solid var(--line);color:var(--fg);border-radius:10px;
247
+ padding:10px 12px;font-size:14.5px;resize:none;height:52px;font-family:inherit}
248
+ button{background:var(--acc);border:0;color:#fff;border-radius:10px;padding:0 22px;font-size:14.5px;cursor:pointer}
249
+ button:disabled{opacity:.5}</style></head><body>
250
+ <header><b>samai-4b · pnet-dMoE (Spark-X2.5 骨干)</b><span class="pill" id="st">loading…</span><span class="pill" id="ck"></span>
251
+ <label class="pill" title="开: 强制思考 T=0.6 (数学/推理稳) · 关: 跳过思考直接答 T=1.0">
252
+ <input type="checkbox" id="ft" checked>💭 强制思考</label>
253
+ <label class="pill" title="best-of-n: 采样5次提取答案取众数 (数学/计算题稳, 耗时≈×5)">
254
+ <input type="checkbox" id="vb">🎯 投票×5</label></header>
255
+ <div id="log"><div class="meta">samai-4b v4 · Spark-X2.5-4B + Pondernet(8专家/后8层) SFT · 强制思考 T=0.6 max320 · 反复读截断 · 🎯投票×5 · eos=[1]</div></div>
256
+ <footer><textarea id="in" placeholder="说点什么… (Enter 发送)"></textarea><button id="go">发送</button></footer>
257
+ <script>
258
+ const log=document.getElementById('log'),inp=document.getElementById('in'),go=document.getElementById('go'),
259
+ st=document.getElementById('st'),ck=document.getElementById('ck'),ft=document.getElementById('ft'),
260
+ vb=document.getElementById('vb');let hist=[],busy=false;
261
+ function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}
262
+ function add(cls,html){const d=document.createElement('div');d.className=cls;d.innerHTML=html;log.appendChild(d);log.scrollTop=1e9;return d}
263
+ async function poll(){try{const r=await fetch('/health');const j=await r.json();
264
+ if(j.loaded){st.textContent='ready';st.className='pill ok';ck.textContent=j.ckpt;}
265
+ else{st.textContent=j.error?('error: '+j.error):'loading model…';}}catch(e){st.textContent='offline'}setTimeout(poll,3000)}poll();
266
+ async function send(){if(busy)return;const m=inp.value.trim();if(!m)return;busy=true;go.disabled=true;inp.value='';
267
+ add('u',esc(m));const w=add('a meta','思考中…');
268
+ try{const r=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},
269
+ body:JSON.stringify({message:m,history:hist,force_think:ft.checked,n_votes:(vb.checked?5:1)})});const j=await r.json();
270
+ if(j.error||!j.job_id){w.remove();add('a meta','⚠ '+esc(j.error||'no job'));busy=false;go.disabled=false;return}
271
+ let res=null;for(let i=0;i<400;i++){await new Promise(s=>setTimeout(s,1500));
272
+ const rr=await fetch('/result?id='+j.job_id);res=await rr.json();
273
+ if(res.status!=='running')break;}
274
+ w.remove();
275
+ if(res.error){add('a meta','⚠ '+esc(res.error))}else{
276
+ let h=esc(res.reply||'');
277
+ if(res.think)h='<details><summary>💭 思考过程</summary><pre>'+esc(res.think)+'</pre></details>'+h;
278
+ add('a',h);
279
+ add('meta','🧠 '+(res.mode&&res.mode.indexOf('think')===0?'强制思考 T=0.6':'自动 T=1.0')+' · '+res.steps+' 步 · '+res.elapsed_s+'s · '+(res.new_tokens||'')+' tokens'
280
+ +(res.loop?' · ⚠反循环截断':(res.stopped?'':' · ⚠长度截断'))
281
+ +(res.votes?' · 🎯 '+Object.entries(res.votes).map(p=>p[0]+'×'+p[1]).join(' / '):''));
282
+ hist.push({role:'user',content:m},{role:'assistant',content:res.reply||''});hist=hist.slice(-8);}
283
+ }catch(e){w.remove();add('a meta','⚠ '+e)}busy=false;go.disabled=false;inp.focus()}
284
+ go.onclick=send;inp.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}});
285
+ </script></body></html>"""
286
+
287
+
288
+ @app.route("/")
289
+ def index():
290
+ return Response(PAGE, mimetype="text/html")
291
+
292
+
293
+ if __name__ == "__main__":
294
+ threading.Thread(target=build, daemon=True).start()
295
+ app.run(host="0.0.0.0", port=PORT, threaded=True)