Show native prefill and generation timing in chat tests
This commit is contained in:
@@ -154,3 +154,5 @@ ist kein Nachweis für fehlerfreien Betrieb. Bestätigte Cgroup-OOM-Kills werden
|
|||||||
als RAM-OOM gemeldet; ein GPU-OOM wird ohne eindeutigen Nachweis nicht behauptet.
|
als RAM-OOM gemeldet; ein GPU-OOM wird ohne eindeutigen Nachweis nicht behauptet.
|
||||||
Ein nicht eindeutig diagnostizierter Prozessabbruch nennt Speicher, Modell/Build
|
Ein nicht eindeutig diagnostizierter Prozessabbruch nennt Speicher, Modell/Build
|
||||||
oder Zeitlimit als mögliche Ursachen. Bestehende Anwendungslogs werden nicht gelesen.
|
oder Zeitlimit als mögliche Ursachen. Bestehende Anwendungslogs werden nicht gelesen.
|
||||||
|
|
||||||
|
Der interne Chat-Test liefert `job.timings` (Prefill/Generierung: Token, Millisekunden und Token/s) und `job.usage` aus der finalen llama.cpp-Streamantwort. Fehlende Messwerte bleiben leer, insbesondere bei Abbruch. Prefill zählt berechnete Token, usage die gesamte Eingabe inklusive wiederverwendetem Kontext. Modellladen und Warteschlange sind nicht Teil der Token/s.
|
||||||
|
|||||||
+5
-1
@@ -4,13 +4,17 @@ window.ChatTestUI=(()=>{
|
|||||||
async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
|
async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
|
||||||
function put(el,html){if(el._chatHtml!==html){el.innerHTML=html;el._chatHtml=html;}}
|
function put(el,html){if(el._chatHtml!==html){el.innerHTML=html;el._chatHtml=html;}}
|
||||||
function html(){return `<section id="chat-test"><h2>Sprachmodell testen</h2><p>Ein einfacher Textchat mit deinem gespeicherten Profil. Nutzt denselben Worker wie API-Anfragen; eine öffentliche Profilfreigabe ist nicht erforderlich.</p><form id="chat-test-form" class="card"><div class="form-grid"><label>Chatprofil<select id="chat-profile" required></select></label><label>Maximale Antwort-Token<input id="chat-limit" type="number" min="1" max="4096" value="1024" required></label></div><p id="chat-profile-info"></p><div id="chat-transcript" aria-live="polite"></div><label>Nachricht<textarea id="chat-prompt" rows="4" maxlength="8000" required placeholder="Schreibe eine kurze Testnachricht …"></textarea></label><div class="card-actions"><button id="chat-send" disabled>Senden</button><button type="button" class="secondary" id="chat-clear">Neuer Chat</button></div><p class="small">Der Verlauf bleibt nur in dieser Ansicht und wird bei Profilwechsel oder Neuladen verworfen. Prompts und Antworten werden nicht als Chatdatei gespeichert.</p></form><p id="chat-message" role="alert"></p><section class="card"><h3>Laufzeit & Diagnose</h3><div id="chat-state" role="status"></div><div class="card-actions"><button class="secondary" id="chat-cancel" disabled>Test abbrechen</button><button class="secondary" id="chat-unload">Modell entladen</button></div><p>Entladen ist nur ohne laufende Anfragen möglich. Abbrechen beendet nur diesen Test; andere Antworten werden nicht gestoppt.</p></section><section class="card"><h3>Speicherprüfung</h3><div id="chat-memory"></div><p class="small">Prognose mit Reserve, keine Garantie für jede Anfrage. GPU-Layer können automatisch auf die CPU ausgelagert werden. Bei Fehlern helfen häufig kleinerer Kontext oder Microbatch. <a class="link" href="#hardware">Hardware-Messwerte →</a></p></section></section>`;}
|
function html(){return `<section id="chat-test"><h2>Sprachmodell testen</h2><p>Ein einfacher Textchat mit deinem gespeicherten Profil. Nutzt denselben Worker wie API-Anfragen; eine öffentliche Profilfreigabe ist nicht erforderlich.</p><form id="chat-test-form" class="card"><div class="form-grid"><label>Chatprofil<select id="chat-profile" required></select></label><label>Maximale Antwort-Token<input id="chat-limit" type="number" min="1" max="4096" value="1024" required></label></div><p id="chat-profile-info"></p><div id="chat-transcript" aria-live="polite"></div><label>Nachricht<textarea id="chat-prompt" rows="4" maxlength="8000" required placeholder="Schreibe eine kurze Testnachricht …"></textarea></label><div class="card-actions"><button id="chat-send" disabled>Senden</button><button type="button" class="secondary" id="chat-clear">Neuer Chat</button></div><p class="small">Der Verlauf bleibt nur in dieser Ansicht und wird bei Profilwechsel oder Neuladen verworfen. Prompts und Antworten werden nicht als Chatdatei gespeichert.</p></form><p id="chat-message" role="alert"></p><section class="card"><h3>Laufzeit & Diagnose</h3><div id="chat-state" role="status"></div><div class="card-actions"><button class="secondary" id="chat-cancel" disabled>Test abbrechen</button><button class="secondary" id="chat-unload">Modell entladen</button></div><p>Entladen ist nur ohne laufende Anfragen möglich. Abbrechen beendet nur diesen Test; andere Antworten werden nicht gestoppt.</p></section><section class="card"><h3>Speicherprüfung</h3><div id="chat-memory"></div><p class="small">Prognose mit Reserve, keine Garantie für jede Anfrage. GPU-Layer können automatisch auf die CPU ausgelagert werden. Bei Fehlern helfen häufig kleinerer Kontext oder Microbatch. <a class="link" href="#hardware">Hardware-Messwerte →</a></p></section></section>`;}
|
||||||
|
function metrics(j){
|
||||||
|
const t=j?.timings||{},u=j?.usage||{},num=(v,d=0)=>typeof v==='number'?v.toLocaleString('de-DE',{maximumFractionDigits:d}):'—',sec=v=>v==null?'—':num(v/1000,3)+' s';
|
||||||
|
return `<h4>Token & Geschwindigkeit · letzter Test</h4><table><thead><tr><th>Phase</th><th>Token</th><th>Dauer</th><th>Token/s</th></tr></thead><tbody><tr><td>Eingabe / Prefill (berechnet)</td><td>${num(t.prompt_n)}</td><td>${sec(t.prompt_ms)}</td><td>${num(t.prompt_per_second,2)}</td></tr><tr><td>Ausgabe / Generierung</td><td>${num(t.predicted_n)}</td><td>${sec(t.predicted_ms)}</td><td>${num(t.predicted_per_second,2)}</td></tr></tbody></table><p>Eingabe gesamt: ${num(u.prompt_tokens)} Token · Ausgabe gesamt: ${num(u.completion_tokens)} Token</p><p class="small">Messwerte von llama.cpp nach Abschluss der Antwort. Prefill zählt neu berechnete Eingabe-Token; wiederverwendeter Kontext kann die Arbeit reduzieren. Ausgabe umfasst auch Reasoning. Modellladen und Wartezeit sind nicht Teil der Token/s. — = noch nicht verfügbar, etwa während der Antwort oder nach Abbruch.</p>`;
|
||||||
|
}
|
||||||
function bind(){
|
function bind(){
|
||||||
const root=document.querySelector('#chat-test'),el=id=>root.querySelector('#'+id);let profiles=[],messages=[],turns=[],ownJob=null,finished=null,status=null,pending=false;
|
const root=document.querySelector('#chat-test'),el=id=>root.querySelector('#'+id);let profiles=[],messages=[],turns=[],ownJob=null,finished=null,status=null,pending=false;
|
||||||
const notice=t=>{if(root.isConnected)el('chat-message').textContent=t;};
|
const notice=t=>{if(root.isConnected)el('chat-message').textContent=t;};
|
||||||
function transcript(){put(el('chat-transcript'),turns.map(t=>`<div class="note"><strong>${t.role==='user'?'Du':'Modell'}</strong><p class="chat-text">${e(t.content)}</p>${t.reasoning?`<details><summary>Reasoning-Ausgabe</summary><pre>${e(t.reasoning)}</pre></details>`:''}</div>`).join(''));}
|
function transcript(){put(el('chat-transcript'),turns.map(t=>`<div class="note"><strong>${t.role==='user'?'Du':'Modell'}</strong><p class="chat-text">${e(t.content)}</p>${t.reasoning?`<details><summary>Reasoning-Ausgabe</summary><pre>${e(t.reasoning)}</pre></details>`:''}</div>`).join(''));}
|
||||||
function selection(){const p=profiles.find(p=>p.id===el('chat-profile').value),running=status?.job?.state==='running';el('chat-profile-info').textContent=p?`${p.parameters.context.toLocaleString('de-DE')} Token gesamt · ${p.parameters.slots} Slots · Batch ${p.parameters.batch} / Microbatch ${p.parameters.ubatch}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'Zuerst ein Sprachmodellprofil anlegen.';el('chat-send').disabled=pending||running||!p?.runnable;el('chat-profile').disabled=pending||running;el('chat-clear').disabled=pending||running;el('chat-cancel').disabled=!running;el('chat-unload').disabled=!!status?.scheduler.active_requests;}
|
function selection(){const p=profiles.find(p=>p.id===el('chat-profile').value),running=status?.job?.state==='running';el('chat-profile-info').textContent=p?`${p.parameters.context.toLocaleString('de-DE')} Token gesamt · ${p.parameters.slots} Slots · Batch ${p.parameters.batch} / Microbatch ${p.parameters.ubatch}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'Zuerst ein Sprachmodellprofil anlegen.';el('chat-send').disabled=pending||running||!p?.runnable;el('chat-profile').disabled=pending||running;el('chat-clear').disabled=pending||running;el('chat-cancel').disabled=!running;el('chat-unload').disabled=!!status?.scheduler.active_requests;}
|
||||||
async function refresh(){try{const [s,h]=await Promise.all([api('chat-tests'),api('hardware').catch(()=>({gpus:[]}))]);if(!root.isConnected)return;status=s;const j=s.job,w=s.worker,plan=w.memory_plan;
|
async function refresh(){try{const [s,h]=await Promise.all([api('chat-tests'),api('hardware').catch(()=>({gpus:[]}))]);if(!root.isConnected)return;status=s;const j=s.job,w=s.worker,plan=w.memory_plan;
|
||||||
put(el('chat-state'),`<p><strong>${e(j?.phase||'Noch kein Chat-Test gestartet.')}</strong></p><p>Worker: ${e(w.phase||w.state)} · ${e(w.profile_name||'kein Modell geladen')}<br>${s.scheduler.active_requests} laufende / ${s.scheduler.waiting_requests} wartende Anfragen</p>${j?.error?`<p role="alert">${e(j.error)}</p>`:''}${w.error?`<p>${e(w.error)}</p>`:''}${j?`<p>Dauer: ${Math.max(0,Math.round((j.finished_at||Date.now()/1000)-j.started_at))} s</p>`:''}`);
|
put(el('chat-state'),`<p><strong>${e(j?.phase||'Noch kein Chat-Test gestartet.')}</strong></p><p>Worker: ${e(w.phase||w.state)} · ${e(w.profile_name||'kein Modell geladen')}<br>${s.scheduler.active_requests} laufende / ${s.scheduler.waiting_requests} wartende Anfragen</p>${j?.error?`<p role="alert">${e(j.error)}</p>`:''}${w.error?`<p>${e(w.error)}</p>`:''}${metrics(j)}${j?`<p>Dauer: ${Math.max(0,Math.round((j.finished_at||Date.now()/1000)-j.started_at))} s</p>`:''}`);
|
||||||
put(el('chat-memory'),(plan?`<p>Letzte Prognose für ${e(plan.profile_name||w.profile_name||'das Modell')}</p><p><strong>${plan.fits?'Prognose passt einschließlich Reserve':'Prognose passt nicht in den verfügbaren Speicher'}</strong> · ${plan.context.toLocaleString('de-DE')} Token / ${plan.slots} Slots · GPU-Layerlimit ${plan.gpu_layers===999?'alle':plan.gpu_layers}</p>${plan.gpus.map(g=>`<p>${e(g.name)}: geschätzt ${gib(g.required_mib)} + ${gib(g.reserve_mib)} Reserve / ${gib(g.free_mib)} beim Prüfen frei</p>`).join('')}<p>RAM-Budget: ${gib(plan.host_required_mib)} benötigt / ${gib(plan.host_available_mib)} verfügbar</p>`:'<p>Noch keine Speicherprognose für einen Modellstart vorhanden.</p>')+`<h4>Aktuelle GPU-Belegung</h4>${(h.gpus||[]).map(g=>`<p>${e(g.name)}: ${gib(g.used_mib)} / ${gib(g.total_mib)} belegt · ${g.percent??'—'} % Auslastung</p>`).join('')||'<p>GPU-Messwerte nicht verfügbar.</p>'}`);
|
put(el('chat-memory'),(plan?`<p>Letzte Prognose für ${e(plan.profile_name||w.profile_name||'das Modell')}</p><p><strong>${plan.fits?'Prognose passt einschließlich Reserve':'Prognose passt nicht in den verfügbaren Speicher'}</strong> · ${plan.context.toLocaleString('de-DE')} Token / ${plan.slots} Slots · GPU-Layerlimit ${plan.gpu_layers===999?'alle':plan.gpu_layers}</p>${plan.gpus.map(g=>`<p>${e(g.name)}: geschätzt ${gib(g.required_mib)} + ${gib(g.reserve_mib)} Reserve / ${gib(g.free_mib)} beim Prüfen frei</p>`).join('')}<p>RAM-Budget: ${gib(plan.host_required_mib)} benötigt / ${gib(plan.host_available_mib)} verfügbar</p>`:'<p>Noch keine Speicherprognose für einen Modellstart vorhanden.</p>')+`<h4>Aktuelle GPU-Belegung</h4>${(h.gpus||[]).map(g=>`<p>${e(g.name)}: ${gib(g.used_mib)} / ${gib(g.total_mib)} belegt · ${g.percent??'—'} % Auslastung</p>`).join('')||'<p>GPU-Messwerte nicht verfügbar.</p>'}`);
|
||||||
if(j&&j.id===ownJob){let response=turns[turns.length-1];if(response?.role!=='assistant'){response={role:'assistant',content:'',reasoning:''};turns.push(response);}response.content=j.answer;response.reasoning=j.reasoning;transcript();
|
if(j&&j.id===ownJob){let response=turns[turns.length-1];if(response?.role!=='assistant'){response={role:'assistant',content:'',reasoning:''};turns.push(response);}response.content=j.answer;response.reasoning=j.reasoning;transcript();
|
||||||
if(j.state!=='running'&&finished!==j.id){finished=j.id;if(j.state==='complete'&&j.answer)messages.push({role:'assistant',content:j.answer});else{messages=[];notice('Der nächste Prompt beginnt einen neuen Kontext; der letzte Test wurde nicht vollständig beantwortet.');}if(j.finish_reason==='length')notice('Antwort am Tokenlimit beendet. Bei Bedarf das Antwortlimit erhöhen.');}
|
if(j.state!=='running'&&finished!==j.id){finished=j.id;if(j.state==='complete'&&j.answer)messages.push({role:'assistant',content:j.answer});else{messages=[];notice('Der nächste Prompt beginnt einen neuen Kontext; der letzte Test wurde nicht vollständig beantwortet.');}if(j.finish_reason==='length')notice('Antwort am Tokenlimit beendet. Bei Bedarf das Antwortlimit erhöhen.');}
|
||||||
|
|||||||
+8
-2
@@ -1,5 +1,6 @@
|
|||||||
"""Ephemeral admin chat tests using the same worker and leases as the API."""
|
"""Ephemeral admin chat tests using the same worker and leases as the API."""
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -25,7 +26,7 @@ class ChatTests:
|
|||||||
if not profile or not profile['runnable']:raise ValueError('Kein ausführbares Chatprofil. Modell und CUDA-Build prüfen.')
|
if not profile or not profile['runnable']:raise ValueError('Kein ausführbares Chatprofil. Modell und CUDA-Build prüfen.')
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.job and self.job['state']=='running':raise ValueError('Ein Chat-Test läuft bereits.')
|
if self.job and self.job['state']=='running':raise ValueError('Ein Chat-Test läuft bereits.')
|
||||||
self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf freie Modellreservierung',profile_id=profile['id'],profile_name=profile['name'],started_at=time.time(),answer='',reasoning='',error=None)
|
self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf freie Modellreservierung',profile_id=profile['id'],profile_name=profile['name'],started_at=time.time(),answer='',reasoning='',error=None,timings={},usage={})
|
||||||
threading.Thread(target=self._run,args=(profile,messages,limit),daemon=True).start()
|
threading.Thread(target=self._run,args=(profile,messages,limit),daemon=True).start()
|
||||||
return dict(self.job)
|
return dict(self.job)
|
||||||
def phase(self,text):
|
def phase(self,text):
|
||||||
@@ -51,7 +52,7 @@ class ChatTests:
|
|||||||
with self.scheduler.lease(key,profile['parameters']['slots'],prepare,allowed=allowed):
|
with self.scheduler.lease(key,profile['parameters']['slots'],prepare,allowed=allowed):
|
||||||
if self.cancel.is_set():raise InterruptedError()
|
if self.cancel.is_set():raise InterruptedError()
|
||||||
self.phase('Antwort wird erzeugt')
|
self.phase('Antwort wird erzeugt')
|
||||||
body=dict(model=profile['name'],messages=messages,max_tokens=limit,stream=True,**{k:profile['parameters'][k] for k in ('temperature','top_p','top_k')})
|
body=dict(model=profile['name'],messages=messages,max_tokens=limit,stream=True,stream_options={"include_usage":True},**{k:profile['parameters'][k] for k in ('temperature','top_p','top_k')})
|
||||||
conn,token=self.worker.connect()
|
conn,token=self.worker.connect()
|
||||||
conn.request('POST','/v1/chat/completions',json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+token})
|
conn.request('POST','/v1/chat/completions',json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+token})
|
||||||
with self.lock:self.socket=conn.sock
|
with self.lock:self.socket=conn.sock
|
||||||
@@ -73,6 +74,11 @@ class ChatTests:
|
|||||||
if payload==b'[DONE]':done=True;break
|
if payload==b'[DONE]':done=True;break
|
||||||
event=json.loads(payload)
|
event=json.loads(payload)
|
||||||
if 'error' in event:raise InferenceError('Der Modellworker meldet einen Fehler während der Antwort.')
|
if 'error' in event:raise InferenceError('Der Modellworker meldet einen Fehler während der Antwort.')
|
||||||
|
with self.lock:
|
||||||
|
for section,fields in [('timings',('prompt_n','prompt_ms','prompt_per_second','predicted_n','predicted_ms','predicted_per_second')),('usage',('prompt_tokens','completion_tokens','total_tokens'))]:
|
||||||
|
values=event.get(section)
|
||||||
|
if isinstance(values,dict):
|
||||||
|
self.job[section]={k:v for k,v in values.items() if k in fields and type(v) in (int,float) and math.isfinite(v) and v>=0}
|
||||||
for choice in event.get('choices',[]):
|
for choice in event.get('choices',[]):
|
||||||
delta=choice.get('delta',{})
|
delta=choice.get('delta',{})
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ class ChatTestsTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):self.manager.unload()
|
with self.assertRaises(ValueError):self.manager.unload()
|
||||||
def test_truncated_stream_is_not_success(self):
|
def test_truncated_stream_is_not_success(self):
|
||||||
self.worker.conn.getresponse.return_value=Reply([b'data: {"choices":[]}\n\n']);self.manager.start(self.request());self.assertEqual(self.finish()['state'],'failed')
|
self.worker.conn.getresponse.return_value=Reply([b'data: {"choices":[]}\n\n']);self.manager.start(self.request());self.assertEqual(self.finish()['state'],'failed')
|
||||||
|
def test_native_timings_and_usage_from_final_empty_choices(self):
|
||||||
|
event=dict(choices=[],timings=dict(prompt_n=100,prompt_ms=200,prompt_per_second=500,predicted_n=20,predicted_ms=1000,predicted_per_second=20,secret='excluded'),usage=dict(prompt_tokens=150,completion_tokens=20,total_tokens=170))
|
||||||
|
self.worker.conn.getresponse.return_value=Reply([('data: '+json.dumps(event)+'\n\n').encode(),b'data: [DONE]\n\n'])
|
||||||
|
self.manager.start(self.request());job=self.finish()
|
||||||
|
self.assertEqual(job['timings']['prompt_per_second'],500);self.assertEqual(job['usage']['prompt_tokens'],150);self.assertNotIn('secret',job['timings'])
|
||||||
|
body=json.loads(self.worker.conn.request.call_args.args[2]);self.assertTrue(body['stream_options']['include_usage'])
|
||||||
|
def test_missing_and_invalid_timings_are_not_invented(self):
|
||||||
|
self.worker.conn.getresponse.return_value=Reply([b'data: {"choices":[],"timings":{"prompt_n":true,"prompt_ms":-1,"predicted_per_second":"42"},"usage":null}\n\n',b'data: [DONE]\n\n'])
|
||||||
|
self.manager.start(self.request());job=self.finish();self.assertEqual(job['timings'],{});self.assertEqual(job['usage'],{})
|
||||||
def test_validation(self):
|
def test_validation(self):
|
||||||
for data in [dict(self.request(),max_tokens=True),dict(self.request(),messages=[dict(role='user',content='')]),dict(self.request(),profile_id='missing')]:
|
for data in [dict(self.request(),max_tokens=True),dict(self.request(),messages=[dict(role='user',content='')]),dict(self.request(),profile_id='missing')]:
|
||||||
with self.assertRaises(ValueError):self.manager.start(data)
|
with self.assertRaises(ValueError):self.manager.start(data)
|
||||||
|
|||||||
Reference in New Issue
Block a user