Build model latency reference and duplex dialogue flow estimator

This commit is contained in:
wuyang6
2026-11-06 21:36:16 +08:00
commit b9b33c8ba1
18 changed files with 1854 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
.git
test
*.log
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log
+14
View File
@@ -0,0 +1,14 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --no-audit --no-fund
COPY index.html ./
COPY src ./src
COPY public ./public
RUN npm run build
FROM nginx:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chmod=0755 /app/dist/ /usr/share/nginx/html/
RUN find /usr/share/nginx/html -type f -exec chmod 644 {} \;
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 K1412
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42
View File
@@ -0,0 +1,42 @@
# 模型时延实验室 / Latency Lab
公开模型时延对照与对话系统流程估算器。资料截止 **2026-11-06**。
- 58条带出处的性能记录,区分公开报告、理论时延、条件化结果和未知数据。
- 文本、原生语音、TTS组件对照;参数规模、激活参数、硬件、精度、输入长度和并发条件。
- 可编辑的有向流程:串行、多层级联、并行分支、全部/任一汇总、首包/文本块/完整结果触发。
- 模型节点loop最大步数(1–100)、预计步数、每步思考与输出、上下文增长、步骤间工具等待。
- 首结果关键路径、全部目标完成、自然语音播放、持续负载RTF、共享资源调度与敏感性区间。
- 本地自动保存、JSON导入导出、CSV数据导出、专注画布和可缩放时间线。
## 本地运行
需要Node.js 24或满足Vite 8要求的Node版本。
```sh
npm ci
npm test
npm run dev
```
生产构建:`npm run build`。Docker镜像使用多阶段构建,Nginx监听8080,健康检查为`/healthz`。
## 计算边界
`src/catalog.js`保存公开来源和性能记录。`src/engine.js`保存工程估算与图调度,两者明确分开。
时间原点是用户停止说话。流式输入可以在此前持续预填充;只有尾包处理与判停等待计入后续延迟。图上禁止环路,重复调用在模型节点内部展开。并行组默认拥有独立资源,显式共享资源组按拓扑顺序串行调度。
流式连接允许提前启动下游,并保留上游最终数据对完成时间的约束。它采用平均供给率近似,不能替代逐帧背压和音素对齐仿真。RTF按预设每轮音频预算计算;模型Agent的循环工作也占用该预算,因此只用于设计负载检查。
最大步数表示预算情景,不代表任务一定成功或实际一定跑满。敏感性区间为模型与程序阶段的0.75–1.5倍变化,不是统计置信区间、p95或SLA。跨硬件倍率、首token模型、MoE与大尺寸外推均为可校准工程假设;未在本项目中执行模型硬件基准。
公开记录不能直接组成统一排行榜。未知参数量保持未知;首文本token、首音频、打断停止和RTF不混用。历史Cerebras结果不代表当前可用性。受控MTP接受数、放宽验证和纯理论数字有独立标记。
## 验证
Node测试覆盖串行/并行关键路径、任一汇总、流式完成约束、token门槛、loop上界、隐藏思考、共享资源、判停/网络边界、循环拒绝、导入验证和显存不足提示。浏览器检查桌面与移动布局及主要交互。
## 数据与许可
代码采用MIT许可。性能数字和说明由公开一手资料整理;每条记录保留来源链接,原始论文、商标及第三方资料权利属于各自作者。本仓库不分发第三方模型权重。
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#102923" />
<meta name="description" content="比较不同参数规模的文本与实时语音模型时延,搭建支持并行、汇总和 Agent loop 的对话流程,估算首包、关键路径与完整耗时。" />
<meta name="release" content="K1412-LATENCY-LAB-20261106-v1" />
<meta property="og:title" content="模型时延实验室 · Latency Lab" />
<meta property="og:description" content="从模型的毫秒,到对话的节奏。模型实测对照与全双工流程时延估算。" />
<link rel="canonical" href="https://latency-lab.k1412.top" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>模型时延实验室 · Latency Lab</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>
+12
View File
@@ -0,0 +1,12 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header X-Frame-Options SAMEORIGIN always;
location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; }
location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; }
location / { add_header Cache-Control "no-cache"; try_files $uri $uri/ /index.html; }
}
+1115
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "latency-lab",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 4178",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4178",
"test": "node --test test/*.test.js"
},
"dependencies": {
"@xyflow/react": "12.12.1",
"lucide-react": "1.70.0",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": { "vite": "8.3.6" }
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#102923"/><path d="M12 34h10l6-17 9 30 6-19 5 6h5" fill="none" stroke="#bcf178" stroke-width="4" stroke-linejoin="round" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 255 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720">
<rect width="1200" height="720" fill="#eef2e7"/><path d="M60 112H1140M60 634H1140" stroke="#cbd8bf"/><text x="62" y="78" fill="#66804e" font-family="monospace" font-size="15" letter-spacing="4">K1412 RESEARCH / LATENCY LAB</text>
<text x="60" y="211" fill="#193d2d" font-family="sans-serif" font-size="60" font-weight="600">模型时延实验室</text><text x="63" y="258" fill="#7e9270" font-family="sans-serif" font-size="22">文本与实时语音模型对照 · 全双工流程估算</text>
<g stroke="#91ad78" fill="none" stroke-width="3"><path d="M275 439H365V384H470M365 439V502H470M650 384H728V439H810M650 502H728"/><path d="M370 384l-9-5v10zM470 384l-9-5v10zM470 502l-9-5v10zM810 439l-9-5v10z" fill="#91ad78"/></g>
<g font-family="sans-serif"><rect x="60" y="393" width="215" height="92" rx="9" fill="#fff" stroke="#bbceb0"/><text x="83" y="423" font-size="12" fill="#93a583">INPUT / STREAM</text><text x="83" y="455" font-size="22" fill="#426839">持续监听</text><rect x="470" y="338" width="180" height="92" rx="9" fill="#fff" stroke="#bdacca"/><text x="491" y="369" font-size="12" fill="#a18bad">AGENT / LOOP</text><text x="491" y="401" font-size="22" fill="#6d557f">模型 × N步</text><rect x="470" y="456" width="180" height="92" rx="9" fill="#fff" stroke="#abc5cb"/><text x="491" y="488" font-size="12" fill="#88a7b1">PARALLEL</text><text x="491" y="519" font-size="22" fill="#4f7c89">工具与检索</text><rect x="810" y="393" width="327" height="92" rx="9" fill="#1f5136"/><text x="836" y="425" font-size="12" fill="#b3d187">FIRST AUDIO / CRITICAL PATH</text><text x="836" y="458" font-size="22" fill="#e5f4cf">汇总、生成与播放</text></g><text x="63" y="679" font-family="monospace" font-size="15" fill="#8da478">58 SOURCE-ATTRIBUTED RECORDS / NOV 2026</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+140
View File
@@ -0,0 +1,140 @@
import React, { useState,useEffect,useMemo,useRef,memo } from 'react';
import { ReactFlow,Background,Controls,Handle,Position,applyNodeChanges,applyEdgeChanges,MarkerType } from '@xyflow/react';
import { ArrowUpRight,ArrowRight,Activity,AudioLines,GitBranch,Clock3,Layers3,Plus,ChevronDown,ChevronRight,Download,Upload,RotateCcw,LayoutGrid,Trash2,Copy,Check,Search,BookOpen,SlidersHorizontal,Play,Pause,X,Info,Link2,Braces,Mic,Volume2,BrainCircuit,Workflow,ExternalLink,Menu } from 'lucide-react';
import { RECORDS,SOURCES,CUTOFF,RELEASE,EVIDENCE,METRICS,recordValue,HARDWARE,SIZE_OPTIONS,CALIBRATION } from './catalog.js';
import { DEFAULT_GLOBALS,KINDS,MODEL_KINDS,simulateGraph,estimateNode,validateGraph,validateDocument,autoLayout,getRecord,ms } from './engine.js';
import { PRESETS,makeDocument,nodeData,toDocument } from './presets.js';
const STORE='latency-lab-v1';
const icons={input:Mic,asr:AudioLines,llm:BrainCircuit,speech:AudioLines,tts:Volume2,program:Braces,merge:GitBranch,output:Volume2};
function loadSaved(){try{const d=JSON.parse(localStorage.getItem(STORE)||'null');if(d&&!validateDocument(d).length)return d;}catch{}return makeDocument();}
function download(name,value,type='application/json'){const blob=new Blob([value],{type}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
const options = entries => entries.map(([value,label])=><option key={value} value={value}>{label}</option>);
function Field({label,children,hint}) {return <label className="field"><span>{label}</span>{children}{hint&&<small>{hint}</small>}</label>;}
function Num({value,onChange,min=0,max=1e6,step=1,label}){return <input aria-label={label} type="number" min={min} max={max} step={step} value={value??0} onChange={e=>onChange(Math.min(max,Math.max(min,Number(e.target.value)||0)))}/>;}
function Toggle({checked,onChange,children}){return <label className="toggle"><input type="checkbox" checked={!!checked} onChange={e=>onChange(e.target.checked)}/><span>{children}</span></label>;}
function Tag({type='neutral',children}){return <span className={`tag ${type}`}>{children}</span>;}
function SourceLink({id,children}){const s=SOURCES[id];return s?<a className="source-link" href={s.url} target="_blank" rel="noreferrer">{children||s.publisher}<ArrowUpRight size={13}/></a>:null;}
const StageNode=memo(function StageNode({data,selected}){
const Icon=icons[data.kind]||Braces,r=data.result;
return <div className={`stage-node kind-${data.kind} ${selected?'is-selected':''} ${data.critical?'is-critical':''}`}>
{data.kind!=='input'&&<Handle type="target" position={Position.Left} aria-label={`${data.label} 输入连接点`}/>}
<div className="stage-top"><span className="stage-icon"><Icon size={16}/></span><span>{KINDS[data.kind]}</span>{data.maxSteps>1&&<span className="loop-mark">↻ {data.maxSteps}</span>}</div>
<strong>{data.label}</strong>
<div className="stage-model">{MODEL_KINDS.includes(data.kind)?data.mode==='reference'?(getRecord(data.profileId)?.name||'来源参考'):`${data.arch==='moe'?`${data.size}B / A${data.active}B`:`${data.size}B`} · ${HARDWARE.find(h=>h.id===data.hardware)?.name||'自定义'}`:data.kind==='output'?'声音 / 最终结果的测量终点':data.kind==='input'?'t = 0 · 用户停说':`${data.durationMs} ms · 可修改`}</div>
<div className="stage-bottom"><span>首出 <b>{r?ms(r.first-r.start):'—'}</b></span><span>完成 <b>{r?ms(r.end-r.start):'—'}</b></span></div>
{data.kind!=='output'&&<Handle type="source" position={Position.Right} aria-label={`${data.label} 输出连接点`}/>}
</div>;
});
const nodeTypes={stage:StageNode};
function Hero({onStart,onCompare}){
return <section className="hero wrap">
<div className="hero-copy"><div className="eyebrow"><span className="live-dot"/> INFERENCE / VOICE / AGENTS <span className="edition">研究快照 2026.11</span></div><h1>从模型的毫秒,<br/>到对话的<span>节奏。</span></h1><p>看清不同尺寸模型的输出速度,<br className="desktop-only"/>再把节点连起来,计算一场对话真正的等待。</p><div className="hero-actions"><button className="primary" onClick={onStart}>搭建对话流程 <ArrowRight size={17}/></button><button className="text-button" onClick={onCompare}>查看模型对照 <ArrowUpRight size={17}/></button></div><div className="hero-foot">公开来源逐条标注 <i/> 理论与报告分开 <i/> 估算参数可校准</div></div>
<div className="hero-visual" aria-label="流式对话时延的示意图,非实测数据"><div className="diagram-head"><span>一场对话的时间切片</span><span>ILLUSTRATION / ms</span></div><div className="wave-line">{Array.from({length:52},(_,i)=><i key={i} style={{height:10+Math.abs(Math.sin(i*1.7)*Math.cos(i*.31))*47}}/>)}</div><div className="voice-end"><span>用户停说</span><i/><span>模型持续处理</span><i/><span>首段声音</span></div><div className="diagram-rows"><div><span>输入流</span><b className="input-stream"/><em>持续监听</em></div><div><span>推理流</span><b className="think-stream"/><em>并行工作</em></div><div><span>输出流</span><b className="output-stream"/><em>流式合成</em></div></div><div className="visual-caption"><span className="tiny-mark">↗</span><div><strong>开始得早,结束也要算清。</strong><p>首token ≠ 首音频 ≠ 完整任务</p></div></div></div>
<div className="hero-stats"><div><b>{RECORDS.length}<span> 条</span></b><p>带来源的性能记录</p></div><div><b>0.5–2,400<span> B</span></b><p>覆盖主干 / 总参数规模</p></div><div><b>170<span> ms</span></b><p>7B语音模型公开接续报告 <SourceLink id="persona">来源</SourceLink></p></div><div><b>≤ 100<span> 步</span></b><p>每个模型节点的loop预算</p></div></div>
</section>;
}
function Comparison({addProfile}){
const [modality,setModality]=useState('all'),[evidence,setEvidence]=useState('all'),[metric,setMetric]=useState('all'),[query,setQuery]=useState(''),[expanded,setExpanded]=useState(null),[view,setView]=useState('records'),[sort,setSort]=useState('default');
const [hw,setHw]=useState('h200'),[cards,setCards]=useState(1),[prompt,setPrompt]=useState(1024),[tokens,setTokens]=useState(128),[spec,setSpec]=useState(1);
const filtered=useMemo(()=>{const rows=RECORDS.filter(r=>(modality==='all'||r.modality===modality)&&(evidence==='all'||r.evidence===evidence)&&(metric==='all'||r.metric===metric)&&`${r.name} ${r.hardware} ${r.params}`.toLowerCase().includes(query.toLowerCase()));if(sort==='size')rows.sort((a,b)=>(a.size??Infinity)-(b.size??Infinity));return rows;},[modality,evidence,metric,query,sort]);
const exportCsv=()=>{const rows=[['模型','模态','参数口径','指标','数值','单位','硬件','量化','输入tokens','并发','证据','时间','来源','限制'],...filtered.map(r=>[r.name,r.modality,r.params,METRICS[r.metric],r.value??'',r.metric==='tps'?'tok/s':r.metric==='rtf'?'RTF':'ms',r.hardware,r.precision||'',r.input??'',r.concurrency??'',EVIDENCE[r.evidence],r.date,SOURCES[r.source].url,r.note||SOURCES[r.source].note])];download('model-latency-20261106.csv','\uFEFF'+rows.map(r=>r.map(v=>'"'+String(v).replaceAll('"','""')+'"').join(',')).join('\n'),'text/csv;charset=utf-8');};
return <section id="compare" className="compare-section wrap"><div className="section-title"><div><div className="eyebrow">01 / THE EVIDENCE</div><h2>模型时延对照</h2><p>先比较同口径,再讨论极限。尺寸相同,部署条件仍可能相差很大。</p></div><button className="outline" onClick={exportCsv}><Download size={15}/> 导出筛选数据</button></div>
<div className="view-tabs"><button className={view==='records'?'active':''} onClick={()=>setView('records')}>公开性能记录 <span>{RECORDS.length}</span></button><button className={view==='estimate'?'active':''} onClick={()=>setView('estimate')}>统一条件尺寸估算 <Tag type="amber">工程外推</Tag></button></div>
{view==='records'?<><div className="filters"><div className="segmented">{[['all','全部'],['text','纯文本'],['speech','端到端语音'],['tts','TTS组件']].map(([v,l])=><button className={modality===v?'active':''} onClick={()=>setModality(v)} key={v}>{l}</button>)}</div><div className="filter-controls"><label className="search-box"><Search size={15}/><input aria-label="搜索模型" placeholder="模型、尺寸或硬件" value={query} onChange={e=>setQuery(e.target.value)}/></label><select aria-label="证据类型" value={evidence} onChange={e=>setEvidence(e.target.value)}>{options([['all','全部证据'],...Object.entries(EVIDENCE)])}</select><select aria-label="时延指标" value={metric} onChange={e=>setMetric(e.target.value)}>{options([['all','全部指标'],...Object.entries(METRICS)])}</select><select aria-label="模型排序" value={sort} onChange={e=>setSort(e.target.value)}>{options([['default','来源分组'],['size','尺寸升序']])}</select></div></div>
<div className="table-summary"><span>{filtered.length} 条记录 · 截止 {CUTOFF}</span><span>点击一行展开条件与限制</span></div><div className="table-scroll"><table className="research-table"><thead><tr><th>模型 / 规模</th><th>指标与结果</th><th>部署条件</th><th>证据与来源</th><th aria-label="操作"/></tr></thead><tbody>{filtered.map(r=><React.Fragment key={r.id}><tr className={expanded===r.id?'expanded':''} onClick={()=>setExpanded(expanded===r.id?null:r.id)} tabIndex={0} onKeyDown={e=>{if(e.key==='Enter')setExpanded(expanded===r.id?null:r.id);}}><td><strong>{r.name}</strong><span className="subline">{r.params}{r.active?` · 激活${r.active}B`:''} <span className="modality-dot">{r.modality==='text'?'文本':r.modality==='tts'?'TTS':'语音'}</span></span></td><td><b className="value">{recordValue(r)}</b><span className="subline">{r.scope}</span>{r.metric==='tps'&&<small className="derived">≈ {(1000/r.value).toFixed(2)} ms/token</small>}</td><td>{r.hardware}<span className="subline">{r.precision||'精度未披露'}{r.concurrency?` · 并发${r.concurrency}`:''}</span></td><td><Tag type={r.evidence==='theoretical'?'amber':r.evidence==='conditional'?'purple':r.evidence==='unknown'?'neutral':'green'}>{EVIDENCE[r.evidence]}</Tag><span className="subline"><SourceLink id={r.source}/><span className="record-date">{r.date}</span></span></td><td><ChevronDown size={16} className={expanded===r.id?'rotate':''}/></td></tr>{expanded===r.id&&<tr className="detail-row"><td colSpan={5}><div className="record-detail"><div><strong>这条数字说明什么</strong><p>{r.note||SOURCES[r.source].note}</p><p className="muted">输入:{r.input?`${r.input.toLocaleString()} token`:r.inputSeconds?`${r.inputSeconds}秒音频/视频`:'未充分披露'} 输出:{r.output?`${r.output} token`:'未充分披露'} {r.ttft?`另报告首token ${r.ttft}ms。`:''}{r.tps?` 另报告文本速度${r.tps} tok/s。`:''}{r.rtf?` RTF ${r.rtf}。`:''}</p><SourceLink id={r.source}>{SOURCES[r.source].title}</SourceLink></div>{r.value!=null&&r.metric!=='interrupt'&&<button className="outline" onClick={e=>{e.stopPropagation();addProfile(r);}}><Plus size={15}/> 作为流程节点参考</button>}</div></td></tr>}</React.Fragment>)}</tbody></table>{!filtered.length&&<div className="empty-state">没有匹配记录,试试其他筛选条件。</div>}</div>
<div className="comparison-notes"><p><Info size={16}/><span><b>“最佳”必须带条件。</b>历史晶圆级结果、单卡GPU、受控接受率、生产API观测不构成统一排行榜。公开报告也不等于本网站实测。</span></p><p><span className="note-mark">A</span><span><b>MoE看两种尺寸。</b>总参数影响存储,激活参数影响每步计算;不能把30B-A3B当作30B稠密模型。</span></p></div></>:<><div className="estimate-controls"><Field label="硬件"><select value={hw} onChange={e=>setHw(e.target.value)}>{HARDWARE.map(h=><option key={h.id} value={h.id}>{h.name}</option>)}</select></Field><Field label="GPU卡数"><Num label="对照GPU卡数" value={cards} onChange={setCards} min={1} max={64}/></Field><Field label="输入token"><Num label="对照输入token" value={prompt} onChange={setPrompt}/></Field><Field label="输出token"><Num label="对照输出token" value={tokens} onChange={setTokens} min={1}/></Field><Field label="推测加速假设"><Num label="对照推测倍率" value={spec} onChange={setSpec} min={1} max={4} step={.1}/></Field></div><p className="inline-note"><Info size={16}/> FP8、模型已热身、低负载、不计网络与排队。以Qwen3/H20为速率锚点,硬件倍率和首token都是可校准的工程假设;不是硬件实测或物理下界。</p><div className="table-scroll"><table className="research-table"><thead><tr><th>稠密模型规模</th><th>估计首token</th><th>估计输出速度</th><th>完整输出估计</th><th>可部署性 / 外推</th></tr></thead><tbody>{[.5,1.5,3,7,8,14,27,32,70,120,235,405].map(size=>{const r=estimateNode({...nodeData('llm','尺寸扫描'),hardware:hw,cards,size,inputTokens:prompt,outputTokens:tokens,speculative:spec});return <tr key={size}><td><strong>{size}B</strong><span className="subline">稠密 · FP8</span></td><td className="value">{ms(r.first)}</td><td>{r.rate.toFixed(1)} tok/s<span className="subline">{(1000/r.rate).toFixed(1)} ms/token</span></td><td>{ms(r.end)}</td><td>{r.warnings.find(w=>w.includes('显存'))?<Tag type="amber">显存不足</Tag>:<Tag>估算</Tag>}<small className="subline">{r.warnings.find(w=>w.includes('显存'))||'参数变化仅代表此工程模型下的趋势。'}</small></td></tr>;})}</tbody></table></div></>}
</section>;
}
function NumberField({data,patch,keyName,label,hint,min=0,max=1e6,step=1}){return <Field label={label} hint={hint}><Num label={label} value={data[keyName]} onChange={v=>patch({[keyName]:v})} min={min} max={max} step={step}/></Field>;}
function NodeInspector({node,result,patch,remove,duplicate}){
if(!node)return <div className="inspector-empty"><SlidersHorizontal size={27}/><h3>选择一个节点</h3><p>点击流程中的节点,编辑模型、参数和loop步数。也可以先从左侧添加节点。</p></div>;
const d=node.data,isModel=MODEL_KINDS.includes(d.kind),isText=d.kind==='llm';
const profiles=d.kind==='asr'?[]:RECORDS.filter(r=>r.value!=null&&r.metric!=='interrupt'&&(isText?r.modality==='text':d.kind==='tts'?r.modality==='tts':r.modality==='speech'));
const chooseProfile=id=>{const r=getRecord(id);patch({profileId:id,mode:'reference',...(r?.size?{size:r.size}:{}),...(r?.active?{active:r.active,arch:'moe'}:{arch:'dense'})});};
return <><div className="inspector-heading"><div><span className="eyebrow">NODE CONFIG</span><h3>节点设置</h3></div><div className="icon-actions"><button aria-label="复制节点" title="复制节点" onClick={duplicate}><Copy size={16}/></button><button aria-label="删除节点" title="删除节点" onClick={remove}><Trash2 size={16}/></button></div></div><Field label="节点名称"><input aria-label="节点名称" value={d.label} maxLength={80} onChange={e=>patch({label:e.target.value})}/></Field><div className="inspector-kind"><Tag>{KINDS[d.kind]}</Tag>{result&&<Tag type="green">{result.label}</Tag>}</div>
{isModel&&<><Field label="数值来源"><select aria-label="节点数值来源" value={d.mode} onChange={e=>{if(e.target.value==='reference'&&!d.profileId&&profiles.length)chooseProfile(profiles[0].id);else patch({mode:e.target.value});}}>{options([['estimate','按类型、规模与硬件估算'],...(profiles.length?[['reference','采用公开记录作为锚点']]:[]),['custom','手动校准全部时延']])}</select></Field>
{d.mode==='reference'?<><Field label="模型性能记录"><select aria-label="模型性能记录" value={d.profileId||''} onChange={e=>chooseProfile(e.target.value)}><option value="" disabled>选择一条性能记录</option>{profiles.map(r=><option value={r.id} key={r.id}>{r.name} · {recordValue(r)} · {r.hardware}</option>)}</select></Field>{getRecord(d.profileId)&&<div className="ref-note"><SourceLink id={getRecord(d.profileId).source}>{SOURCES[getRecord(d.profileId).source].title}</SourceLink><p>{getRecord(d.profileId).note||SOURCES[getRecord(d.profileId).source].note}</p></div>}</>:<><div className="field-pair"><Field label="结构"><select aria-label="模型结构" value={d.arch} onChange={e=>patch({arch:e.target.value})}>{options([['dense','稠密 Dense'],['moe','混合专家 MoE']])}</select></Field><Field label="参数量(B)"><input aria-label="模型参数量" type="number" list="model-sizes" min="0.1" step="0.1" value={d.size} onChange={e=>patch({size:Math.max(.1,Number(e.target.value)||.1)})}/></Field></div>{d.arch==='moe'&&<NumberField data={d} patch={patch} keyName="active" label="每token激活参数(B)" min={.1} max={d.size} step={.1}/>}<Field label="硬件"><select aria-label="节点硬件" value={d.hardware} onChange={e=>patch({hardware:e.target.value})}>{HARDWARE.map(h=><option value={h.id} key={h.id}>{h.name}</option>)}</select></Field><div className="field-pair"><NumberField data={d} patch={patch} keyName="cards" label="GPU卡数" min={1} max={256}/><Field label="量化"><select aria-label="节点量化" value={d.quant} onChange={e=>patch({quant:e.target.value})}>{options([['bf16','BF16'],['fp8','FP8 / INT8'],['int4','INT4']])}</select></Field></div></>}
{d.mode==='custom'&&<div className="setting-group"><h4>手动校准</h4><NumberField data={d} patch={patch} keyName="customFirstMs" label={isText?'首个可见token前时延(ms)':'首包 / 尾包时延(ms)'}/>{isText?<NumberField data={d} patch={patch} keyName="customTps" label="生成速度(token/s)" min={.1} step={.1}/>:<NumberField data={d} patch={patch} keyName="customRtf" label="实时因子 RTF" min={.001} max={100} step={.01}/>}</div>}
{isText&&<><div className="setting-group"><h4>一次推理的工作量</h4><div className="field-pair"><NumberField data={d} patch={patch} keyName="inputTokens" label="首步输入token"/><NumberField data={d} patch={patch} keyName="outputTokens" label="每步可见输出token" min={1}/></div><NumberField data={d} patch={patch} keyName="reasoningTokens" label="每步隐藏思考token" hint="在第一个可见token前完成;会增加等待。"/><NumberField data={d} patch={patch} keyName="cacheRatio" label="初始上下文缓存比例" min={0} max={.99} step={.05}/>{d.mode==='estimate'&&<NumberField data={d} patch={patch} keyName="speculative" label="推测解码加速假设" min={1} max={4} step={.1}/>}</div></>}
{d.kind==='asr'&&<><Toggle checked={d.streamingInput} onChange={v=>patch({streamingInput:v})}>用户说话时持续识别</Toggle>{d.mode!=='custom'&&<NumberField data={d} patch={patch} keyName="durationMs" label="识别尾包时延(ms)"/>}</>}
<div className="setting-group loop-group"><div className="group-title"><h4>Agent / 模型 loop</h4><Tag type="purple">逐步累计</Tag></div><div className="field-pair"><NumberField data={d} patch={patch} keyName="maxSteps" label="loop 最大步数" min={1} max={100}/><NumberField data={d} patch={patch} keyName="expectedSteps" label="预计实际步数" min={1} max={d.maxSteps}/></div>{d.maxSteps>1&&<><NumberField data={d} patch={patch} keyName="toolMs" label="两步之间工具等待(ms)" hint="每轮之间计一次,共N−1次;不要再重复加入同一工具预算。"/>{isText&&<NumberField data={d} patch={patch} keyName="contextGrowth" label="每步新增上下文token"/>}<Toggle checked={d.finalOnly} onChange={v=>patch({finalOnly:v})}>仅最后一步向下游输出</Toggle><p className="micro-copy">关闭后首包可能是中间反馈。loop指节点内部重复调用,不代表真实对话往返轮数。</p></>}</div><Field label="共享资源组(可选)" hint="填写相同名称的模型节点串行占用同一资源;留空则按全局设置。"><input aria-label="共享资源组" value={d.resource||''} maxLength={30} placeholder="例如 gpu-a" onChange={e=>patch({resource:e.target.value})}/></Field></>}
{(d.kind==='program'||d.kind==='merge'||d.kind==='output')&&<NumberField data={d} patch={patch} keyName="durationMs" label={d.kind==='output'?'播放 / 输出缓冲(ms)':'默认执行耗时(ms)'}/>}
{d.kind==='output'&&<><Field label="输出类型"><select aria-label="输出类型" value={d.outputType} onChange={e=>patch({outputType:e.target.value})}>{options([['audio','可播放音频'],['text','文本 / 程序结果']])}</select></Field><Toggle checked={d.target!==false} onChange={v=>patch({target:v})}>作为主结果测量终点</Toggle></>}
{d.kind==='input'&&<p className="ref-note">该节点代表用户停止说话。判停等待在“全局参数”中调整。流式识别可以提前处理用户说话期间的输入。</p>}
<Field label="多分支输入策略"><select aria-label="汇总等待策略" value={d.join||'all'} onChange={e=>patch({join:e.target.value})}>{options([['all','等全部分支(max)'],['any','取最早可用分支(min)']])}</select></Field>
{result&&<div className="node-estimate"><h4>本节点的时间</h4><dl><div><dt>开始</dt><dd>{ms(result.start)}</dd></div><div><dt>首出</dt><dd>{ms(result.first)}</dd></div><div><dt>结束</dt><dd>{ms(result.end)}</dd></div>{result.resourceWait>0&&<div><dt>资源等待</dt><dd>{ms(result.resourceWait)}</dd></div>}</dl>{result.assumptions.map((s,i)=><p key={i}>{s}</p>)}{result.warnings.map((s,i)=><p key={i} className="warn-text">{s}</p>)}</div>}
</>;
}
function Timeline({sim,nodes,selected,setSelected}){
const [playing,setPlaying]=useState(false),[cursor,setCursor]=useState(0),[stepsOpen,setStepsOpen]=useState(false),timer=useRef();
const total=sim.complete||1;
useEffect(()=>{if(!playing)return;let start=performance.now(),from=cursor;const tick=now=>{const next=from+(now-start)*(total/6000);if(next>=total){setCursor(total);setPlaying(false);}else{setCursor(next);timer.current=requestAnimationFrame(tick);}};timer.current=requestAnimationFrame(tick);return()=>cancelAnimationFrame(timer.current);},[playing,total]);
useEffect(()=>{setCursor(0);setPlaying(false);},[total]);
const result=selected?sim.results?.[selected]:null;
return <div className="timeline-panel"><div className="timeline-heading"><div><h3>流程时间线 <span>用户停说 = 0</span></h3><p>绿色标出首结果关键路径。浅色区域是准备/等待,实色区域是输出。</p></div><button className="outline compact" onClick={()=>{if(cursor>=total)setCursor(0);setPlaying(!playing);}}>{playing?<Pause size={14}/>:<Play size={14}/>} 播放时序</button></div><div className="timeline-axis"><span>0</span><span>{ms(total*.25)}</span><span>{ms(total*.5)}</span><span>{ms(total*.75)}</span><span>{ms(total)}</span></div><div className="timeline-rows">{sim.order.map(id=>{const r=sim.results[id],width=Math.max(.7,(r.end-r.start)/total*100),left=r.start/total*100,first=(r.first-r.start)/Math.max(1,r.end-r.start)*100;return <button key={id} className={`timeline-row ${selected===id?'selected':''}`} onClick={()=>setSelected(id)}><span>{r.title}{r.stepsCount>1&&<small>↻{r.stepsCount}</small>}</span><div className="bar-track"><div className={`time-bar ${sim.criticalNodes.has(id)?'critical':''}`} style={{left:`${left}%`,width:`${width}%`}}><i style={{width:`${Math.min(100,first)}%`}}/></div>{cursor>0&&<b className="playhead" style={{left:`${cursor/total*100}%`}}/>}</div><strong>{ms(r.end-r.start)}</strong></button>;})}</div><label className="scrubber"><span>时序位置 {ms(cursor)}</span><input aria-label="时序位置" type="range" min="0" max={total} step="1" value={Math.min(total,cursor)} onChange={e=>{setPlaying(false);setCursor(Number(e.target.value));}}/></label>{result?.steps.length>1&&<div className="step-breakdown"><button className="details-toggle" onClick={()=>setStepsOpen(!stepsOpen)}>{stepsOpen?<ChevronDown size={15}/>:<ChevronRight size={15}/>} {result.title} · {result.stepsCount}/{result.maxSteps}步展开</button>{stepsOpen&&<div className="table-scroll"><table><thead><tr><th>步</th><th>输入 / 输出 / 思考</th><th>开始</th><th>首出</th><th>结束</th><th>步前工具等待</th></tr></thead><tbody>{result.steps.map(s=><tr key={s.step}><td>{s.step}</td><td>{s.input} / {s.output} / {s.reasoning}</td><td>{ms(s.start)}</td><td>{ms(s.first)}</td><td>{ms(s.end)}</td><td>{ms(s.toolWait)}</td></tr>)}</tbody></table></div>}</div>}</div>;
}
function Studio({doc,setDoc,selected,setSelected,toast}){
const nodes=doc.nodes,edges=doc.edges,g=doc.globals;
const [flow,setFlow]=useState(null),[panel,setPanel]=useState('node'),[edgeId,setEdgeId]=useState(null),[from,setFrom]=useState(''),[to,setTo]=useState(''),[release,setRelease]=useState('complete'),[focus,setFocus]=useState(false),[focusSettings,setFocusSettings]=useState(false);
const inputRef=useRef(),canvasRef=useRef(),history=useRef([]);
const sim=useMemo(()=>simulateGraph(nodes,edges,g),[nodes,edges,g]);
useEffect(()=>{if(!focus)return;const old=document.body.style.overflow;document.body.style.overflow='hidden';const escape=e=>{if(e.key==='Escape')setFocus(false);};window.addEventListener('keydown',escape);return()=>{document.body.style.overflow=old;window.removeEventListener('keydown',escape);};},[focus]);
const saveHistory=()=>{history.current.push(structuredClone(doc));if(history.current.length>35)history.current.shift();};
const update=next=>{saveHistory();setDoc(typeof next==='function'?next(doc):next);};
const patchNode=patch=>update({...doc,nodes:nodes.map(n=>n.id===selected?{...n,data:{...n.data,...patch}}:n)});
const patchGlobals=patch=>update({...doc,globals:{...g,...patch}});
const current=nodes.find(n=>n.id===selected),edge=edges.find(e=>e.id===edgeId);
const fit=()=>setTimeout(()=>flow?.fitView({padding:.18,duration:300}),80);
const choosePreset=key=>{update(makeDocument(key));setSelected(null);setEdgeId(null);fit();};
const addNode=kind=>{if(nodes.length>=100){toast('最多支持100个节点。');return;}const id=`n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,5)}`;const rect=canvasRef.current?.getBoundingClientRect();const pos=flow&&rect?flow.screenToFlowPosition({x:rect.x+rect.width/2,y:rect.y+rect.height/2}):{x:100,y:100};update({...doc,nodes:[...nodes,{id,type:'stage',position:pos,data:nodeData(kind,KINDS[kind])}]});setSelected(id);setPanel('node');};
const connect=c=>{if(!c.source||!c.target){toast('请选择起点和终点。');return;}if(edges.some(e=>e.source===c.source&&e.target===c.target)){toast('这两个节点已经相连。');return;}const next={id:`edge-${Date.now()}-${Math.random().toString(36).slice(2,5)}`,type:'smoothstep',source:c.source,target:c.target,data:{release:c.release||'complete',tokens:8}};const err=validateGraph(nodes,[...edges,next]);if(err.length){toast(err[0]);return;}update({...doc,edges:[...edges,next]});setEdgeId(next.id);setPanel('edge');};
const removeNode=()=>{update({...doc,nodes:nodes.filter(n=>n.id!==selected),edges:edges.filter(e=>e.source!==selected&&e.target!==selected)});setSelected(null);};
const duplicate=()=>{if(!current)return;const id=`n-${Date.now().toString(36)}`;update({...doc,nodes:[...nodes,{...structuredClone(current),id,position:{x:current.position.x+35,y:current.position.y+165},data:{...current.data,label:current.data.label+' 副本'}}]});setSelected(id);};
const renderNodes=useMemo(()=>nodes.map(n=>({...n,selected:n.id===selected,data:{...n.data,result:sim.results?.[n.id],critical:sim.criticalNodes?.has(n.id)}})),[nodes,selected,sim]);
const renderEdges=useMemo(()=>edges.map(e=>({...e,selected:e.id===edgeId,animated:e.data?.release!=='complete'&&!e.data?.control,label:e.data?.control?'控制路径':e.data?.release==='chunk'?`${e.data.tokens||8} token后`:e.data?.release==='first'?'首包后':'完成后',markerEnd:{type:MarkerType.ArrowClosed,width:16,height:16,color:sim.criticalEdges?.has(e.id)?'#2d7854':'#91a39a'},style:{stroke:sim.criticalEdges?.has(e.id)?'#2d7854':'#91a39a',strokeWidth:sim.criticalEdges?.has(e.id)?2.2:1.5,...(e.data?.control?{strokeDasharray:'5 5'}:{})},labelStyle:{fill:'#50645b',fontSize:10},labelBgStyle:{fill:'#f7f8f4'},labelBgPadding:[5,3]})),[edges,edgeId,sim]);
const exportDoc=()=>download('dialogue-flow.json',JSON.stringify(toDocument(nodes,edges,g,doc.name),null,2));
const importDoc=async e=>{const file=e.target.files?.[0];if(!file)return;try{if(file.size>2e6)throw new Error('文件超过2MB。');const parsed=JSON.parse(await file.text());const err=validateDocument(parsed);if(err.length)throw new Error(err[0]);update(parsed);setSelected(null);fit();toast('已导入流程。');}catch(error){toast(`导入失败:${error.message}`);}e.target.value='';};
const onChanges=changes=>setDoc(prev=>({...prev,nodes:applyNodeChanges(changes,prev.nodes)}));
return <section id="studio" className="studio-section"><div className="wrap"><div className="section-title"><div><div className="eyebrow">02 / BUILD THE CONVERSATION</div><h2>把一场对话,搭出来。</h2><p>拖拽、连线、并行、汇总。改变一个节点,看等待如何沿流程传递。</p></div><div className="saved-indicator"><Check size={14}/> 自动保存在本机</div></div><div className="preset-bar"><span>从一个结构开始</span>{Object.entries(PRESETS).filter(([k])=>k!=='blank').map(([key,p])=><button key={key} onClick={()=>choosePreset(key)} className={doc.name===p.name?'active':''}>{key==='native'?<AudioLines size={15}/>:key==='parallel'?<GitBranch size={15}/>:key==='agent'?<RotateCcw size={15}/>:<Workflow size={15}/>} {p.name}</button>)}</div>
<div className={`studio-shell ${focus?'focused':''} ${focusSettings?'with-settings':''}`}><aside className="node-palette"><span className="eyebrow">添加节点</span>{Object.entries(KINDS).map(([kind,label])=>{const Icon=icons[kind];return <button key={kind} onClick={()=>addNode(kind)} title={`添加${label}`}><span className={`palette-icon kind-${kind}`}><Icon size={17}/></span><span>{label}</span><Plus size={13}/></button>;})}<div className="palette-help"><Link2 size={18}/><p>拖动节点两侧的圆点连线。<br/>触屏可使用右侧“连线”表单。</p></div></aside>
<div className="canvas-column"><div className="canvas-toolbar"><span className="flow-name">{doc.name||'自定义流程'}{!sim.errors.length&&<span className="toolbar-metric">首出 {ms(sim.first)} <i/> 完成 {ms(sim.complete)}</span>}</span><div><button className="focus-button" onClick={()=>{setFocus(!focus);setFocusSettings(false);fit();}}>{focus?'退出专注':'专注画布'}</button>{focus&&<button className="focus-button" onClick={()=>{setFocusSettings(!focusSettings);fit();}}>{focusSettings?'隐藏设置':'显示设置'}</button>}<button aria-label="撤销编辑" title="撤销编辑" onClick={()=>{const old=history.current.pop();if(old){setDoc(old);setSelected(null);}else toast('没有可撤销的编辑。');}}><RotateCcw size={15}/></button><button aria-label="自动布局" title="自动布局" onClick={()=>{update({...doc,nodes:autoLayout(nodes,edges)});fit();}}><LayoutGrid size={15}/></button><button aria-label="导出流程JSON" title="导出流程JSON" onClick={exportDoc}><Download size={15}/></button><button aria-label="导入流程JSON" title="导入流程JSON" onClick={()=>inputRef.current.click()}><Upload size={15}/></button><button aria-label="清空流程" title="清空流程(可撤销)" onClick={()=>choosePreset('blank')}><Trash2 size={15}/></button></div><input ref={inputRef} type="file" accept=".json,application/json" hidden onChange={importDoc}/></div><div className="flow-canvas" ref={canvasRef}>
<ReactFlow nodes={renderNodes} edges={renderEdges} nodeTypes={nodeTypes} onNodesChange={onChanges} onEdgesChange={changes=>setDoc(prev=>({...prev,edges:applyEdgeChanges(changes,prev.edges)}))} onConnect={connect} onInit={setFlow} onNodeClick={(_,n)=>{setSelected(n.id);setPanel('node');if(focus)setFocusSettings(true);}} onEdgeClick={(_,e)=>{setEdgeId(e.id);setPanel('edge');if(focus)setFocusSettings(true);}} onNodeDragStart={saveHistory} onPaneClick={()=>{setSelected(null);setEdgeId(null);}} onNodesDelete={ns=>{setDoc(prev=>({...prev,edges:prev.edges.filter(e=>!ns.some(n=>n.id===e.source||n.id===e.target))}));}} isValidConnection={c=>!validateGraph(nodes,[...edges,{id:'candidate',...c}]).length} fitView fitViewOptions={{padding:.16}} minZoom={.16} maxZoom={1.6} deleteKeyCode={['Backspace','Delete']} connectOnClick selectionOnDrag={false} ariaLabelConfig={{'controls.zoomIn.ariaLabel':'放大流程','controls.zoomOut.ariaLabel':'缩小流程','controls.fitView.ariaLabel':'显示完整流程','controls.interactive.ariaLabel':'切换交互'}}><Background color="#cdd8ce" gap={20} size={1}/><Controls showInteractive={false}/></ReactFlow>
{!nodes.length&&<div className="canvas-empty"><Workflow size={40}/><h3>从第一个节点开始</h3><p>点击左侧添加节点,或者加载一个示例结构。</p><button className="primary" onClick={()=>choosePreset('cascade')}>加载流式级联示例</button></div>}
</div><div className="canvas-footer"><span><i className="legend-critical"/> 首结果关键路径</span><span><i className="legend-stream"/> 流式连接</span><button onClick={()=>{setPanel('edge');setEdgeId(null);}}>用表单添加连线 <Link2 size={12}/></button></div></div>
<aside className="inspector"><div className="inspector-tabs">{[['node','节点'],['edge','连线'],['global','全局参数']].map(([v,l])=><button key={v} className={panel===v?'active':''} onClick={()=>setPanel(v)}>{l}</button>)}</div><div className="inspector-body">
{panel==='node'&&<NodeInspector node={current} result={sim.results?.[selected]} patch={patchNode} remove={removeNode} duplicate={duplicate}/>}
{panel==='edge'&&<><div className="inspector-heading"><div><span className="eyebrow">CONNECTION</span><h3>{edge?'连线设置':'添加连线'}</h3></div>{edge&&<button className="icon-button" aria-label="删除连线" onClick={()=>{update({...doc,edges:edges.filter(e=>e.id!==edgeId)});setEdgeId(null);}}><Trash2 size={16}/></button>}</div>{edge?<><p className="edge-description">{nodes.find(n=>n.id===edge.source)?.data.label} <ArrowRight size={12}/> {nodes.find(n=>n.id===edge.target)?.data.label}</p><Field label="下游何时开始"><select aria-label="连线触发方式" value={edge.data?.release||'complete'} onChange={e=>update({...doc,edges:edges.map(x=>x.id===edgeId?{...x,data:{...x.data,release:e.target.value}}:x)})}>{options([['complete','等完整结果'],['first','首包一到就开始'],['chunk','积累若干文本token后']])}</select></Field>{edge.data?.release==='chunk'&&<Field label="需要积累的token"><Num label="连线积累token" value={edge.data.tokens||8} min={1} max={10000} onChange={v=>update({...doc,edges:edges.map(x=>x.id===edgeId?{...x,data:{...x.data,tokens:v}}:x)})}/></Field>}<Field label="此连线网络时延(ms)" hint="留空使用全局单程网络值。输入0表示同进程。"><input aria-label="连线网络时延" type="number" min="0" max="100000" value={edge.data?.networkMs??''} placeholder={`全局 ${g.networkMs} ms`} onChange={e=>update({...doc,edges:edges.map(x=>x.id===edgeId?{...x,data:{...x.data,networkMs:e.target.value===''?undefined:Math.max(0,Number(e.target.value)||0)}}:x)})}/></Field><Toggle checked={edge.data?.control} onChange={v=>update({...doc,edges:edges.map(x=>x.id===edgeId?{...x,data:{...x.data,control:v}}:x)})}>标记为控制连线(虚线)</Toggle><p className="micro-copy">控制标记改变显示方式;仍按所选触发条件计算。打断分支通常应连接独立控制终点,并取消其“主结果”身份。</p><button className="outline full-width" onClick={()=>setEdgeId(null)}><Plus size={14}/> 添加其他连线</button></>:<><Field label="起点"><select aria-label="连线起点" value={from} onChange={e=>setFrom(e.target.value)}><option value="">选择节点</option>{nodes.filter(n=>n.data.kind!=='output').map(n=><option value={n.id} key={n.id}>{n.data.label}</option>)}</select></Field><Field label="终点"><select aria-label="连线终点" value={to} onChange={e=>setTo(e.target.value)}><option value="">选择节点</option>{nodes.filter(n=>n.data.kind!=='input').map(n=><option value={n.id} key={n.id}>{n.data.label}</option>)}</select></Field><Field label="触发方式"><select aria-label="新增连线触发方式" value={release} onChange={e=>setRelease(e.target.value)}>{options([['complete','完整结果'],['first','首包'],['chunk','8个文本token']])}</select></Field><button className="primary full-width" onClick={()=>connect({source:from,target:to,release})}><Link2 size={15}/> 连接节点</button><p className="micro-copy">同一节点连到多个下游就形成并行分支。多个分支连到汇总节点后,可在节点中选择等全部或取最早。</p></>}<div className="connection-list"><h4>已有连线 · {edges.length}</h4>{edges.map(e=><button key={e.id} onClick={()=>setEdgeId(e.id)} className={edgeId===e.id?'active':''}><span>{nodes.find(n=>n.id===e.source)?.data.label}</span><ArrowRight size={12}/><span>{nodes.find(n=>n.id===e.target)?.data.label}</span></button>)}</div></>}
{panel==='global'&&<><div className="inspector-heading"><div><span className="eyebrow">SCENARIO</span><h3>全局估算条件</h3></div></div><p className="micro-copy">调整全流程的计算、程序和传输开销。这里的参数是设计假设,可用自己的实测替换。</p><NumberField data={g} patch={patchGlobals} keyName="computeScale" label="全部模型耗时倍率" min={.1} max={10} step={.1} hint="1为当前基线,2为所有模型阶段慢一倍。"/><NumberField data={g} patch={patchGlobals} keyName="programScale" label="全部程序 / 工具耗时倍率" min={.1} max={10} step={.1}/><div className="field-pair"><NumberField data={g} patch={patchGlobals} keyName="networkMs" label="每条边单程网络(ms)" max={100000}/><NumberField data={g} patch={patchGlobals} keyName="queueMs" label="每次模型排队(ms)" max={100000}/></div><NumberField data={g} patch={patchGlobals} keyName="vadMs" label="用户停说后的判停等待(ms)" max={10000} hint="原生接续参考若已含判停,应设0避免重复。"/><div className="field-pair"><NumberField data={g} patch={patchGlobals} keyName="inputSeconds" label="用户输入音频(秒)" min={.1} max={3600} step={.5}/><NumberField data={g} patch={patchGlobals} keyName="speechSeconds" label="回复自然播放(秒)" min={.1} max={3600} step={.5}/></div><Field label="loop 计算情景"><select aria-label="loop计算情景" value={g.loopPlan} onChange={e=>patchGlobals({loopPlan:e.target.value})}>{options([['max','跑满最大步数'],['expected','采用各节点预计步数']])}</select></Field><Toggle checked={g.sharedGPU} onChange={v=>patchGlobals({sharedGPU:v})}>所有未分组模型共用同一GPU资源</Toggle><p className="micro-copy">共享组按拓扑顺序串行调度。这是保守资源假设;不模拟真实continuous batching。各节点显式资源组优先。</p><div className="duplex-callout"><AudioLines size={20}/><h4>全双工怎么表示</h4><p>输入监听和输出生成可同时运行;用流式连线表示提前消费,用独立分支表示打断控制。图计算单次回复;长对话反馈通过节点loop建模。</p></div></>}
</div></aside>
</div>
{sim.errors.length?<div className="error-box">{sim.errors.join(' ')}</div>:<><div className="result-strip" aria-live="polite"><div className="primary-result"><span>{sim.hasAudio?'首段可播放音频':'首个目标结果'}<Tag>方案估计</Tag></span><strong>{ms(sim.first)}</strong><small>敏感性范围 {ms(sim.range[0])} – {ms(sim.range[1])}</small></div><div><span>全部主目标完成</span><strong>{ms(sim.complete)}</strong><small>音频目标包含 {g.speechSeconds}s 自然播放</small></div><div><span>跑满loop预算</span><strong>{ms(sim.maxFirst)}</strong><small>首结果 / 完成 {ms(sim.maxComplete)}</small></div><div><span>持续处理负载</span><strong className={sim.rtf>1?'warn-text':''}>{sim.rtf.toFixed(2)} <em>RTF</em></strong><small>{sim.rtf>1?'当前假设可能跟不上实时':'理想资源下具备实时余量'}</small></div></div><div className="result-note"><Info size={15}/><p>范围只把模型和程序阶段乘0.75–1.5,固定网络/判停不变;不是p95或统计置信区间。改变模型、隐藏思考、共享资源或loop后会立即重算。打断控制能力仍需真实音频系统验证。</p></div>{sim.warnings.length>0&&<details className="warnings"><summary>估算条件与待校准项 <span>{sim.warnings.length}</span><ChevronDown size={15}/></summary><ul>{sim.warnings.map((w,i)=><li key={i}>{w}</li>)}</ul></details>}<Timeline sim={sim} nodes={nodes} selected={selected} setSelected={id=>{setSelected(id);setPanel('node');}}/></>}
<datalist id="model-sizes">{SIZE_OPTIONS.map(s=><option value={s} key={s}/>)}</datalist>
</div></section>;
}
function Method(){
return <section id="method" className="method-section wrap"><div className="section-title"><div><div className="eyebrow">03 / THE MODEL BEHIND THE MODEL</div><h2>这些时间,怎么算。</h2><p>结果的可信度,取决于计时边界和输入假设是否说清。</p></div></div><div className="method-grid"><article className="method-intro"><h3>先定义你在等什么。</h3><p>文本首token是模型开始生成的时间。隐藏思考可能发生在可见答案之前。语音首包是第一段可播放音频就绪的时间;终端播放还需要网络与缓冲。完整回复则要考虑所有输出和自然说话时长。</p><div className="metric-defs"><div><span>TTFT</span><p>首次模型token;对推理模型应另计首个可见答案。</p></div><div><span>TPOT</span><p>持续输出阶段每token平均耗时,近似1000÷token/s。</p></div><div><span>TTFA</span><p>首段可播放音频的时间,不能用文本TTFT代替。</p></div><div><span>RTF</span><p>处理/生成音频所需时间÷音频时长。小于1才有持续实时余量。</p></div></div></article><div className="formula-list"><article><span>01</span><div><h3>串行与并行</h3><code>start(v) = max(各前驱满足条件的时刻)</code><p>默认等全部分支。选择“任一就绪”取min;前提是最早分支的结果可用。独立硬件分支可以重叠,共享资源组会增加排队。</p></div></article><article><span>02</span><div><h3>流式开始,持续供给</h3><code>首包 + (k − 1) / 输出速率</code><p>文本积累k个token后启动下游。完成时刻还受上游最终数据就绪约束,避免把提前启动误当作全部工作已完成。这里采用平均供给率,未模拟音素对齐、每帧抖动与流控背压。</p></div></article><article><span>03</span><div><h3>Agent loop</h3><code>Σ[首token + (思考 + 输出 − 1) / 速率] + (N − 1) × 工具等待</code><p>每步可增加上下文;默认仅最后一步对下游放行。最大步数定义预算情景,不保证真实任务能在预算内成功。选择预计步数后,同时显示跑满预算的结果。</p></div></article><article><span>04</span><div><h3>参数规模外推</h3><code>速率 = H20锚点 × 硬件系数 × 量化系数 × 并行收益</code><p>稠密锚点采用Qwen3官方FP8数据做对数插值;32B以上按反比外推。MoE用激活参数并乘额外开销。卡数收益上限4倍,不能靠无限加卡把时延降到零。</p></div></article></div></div><details className="calibration-details"><summary>查看全部默认系数与计算限制 <ChevronDown size={16}/></summary><div className="calibration-grid"><div><h4>可复查的默认值</h4><table><thead><tr><th>硬件</th><th>相对H20速率系数</th></tr></thead><tbody>{HARDWARE.map(h=><tr key={h.id}><td>{h.name}</td><td>{h.factor}×(假设)</td></tr>)}</tbody></table><p>量化速率系数:BF16 0.62,FP8 1,INT4 1.06。它们是近似值,不代表所有模型的固定规律。</p></div><div><h4>首token与显存</h4><p>Prefill默认8B/H20为18,000 token/s,固定开销14ms;随激活规模、缓存和硬件调整。缺少首token来源时使用该模型。公开记录中的硬件条件固定;更换条件应切回工程估算。</p><p>权重显存粗算:BF16 2字节/参数,FP8 1.05,INT4 0.55;另加max(2GB,8%)运行余量。这不是完整KV-cache容量规划。</p><h4>未建模的因素</h4><p>任务正确性、语音自然度、错误重试、冷启动、长上下文attention非线性、真实调度、网络抖动、设备音频延迟与多用户拥塞。模型间tokenizer也不同,不把token直接等同汉字。</p></div></div></details><div className="reading-note"><h3>怎样理解“理想时延”</h3><p>统一条件估算采用已热身、低并发、稳定网络。端到端语音通过边听边处理缩短停说后的等待,不能因此声称复杂任务已完成。一个170ms的接续可以很自然,却不能代表检索、工具和多轮Agent也在170ms内结束。网站中的数据为公开资料调研,本次没有运行模型效果或硬件性能实验。</p><p>覆盖公开可核验的代表性尺寸与部署条件;不穷尽全部模型。未公开的参数规模保持未知,缺少首音频数据时也保留空缺。</p></div></section>;
}
function Sources(){return <section id="sources" className="sources-section wrap"><div className="section-title"><div><div className="eyebrow">04 / PRIMARY SOURCES</div><h2>让每个数字,都有来处。</h2><p>截止 {CUTOFF} 查阅。只引用官方文档、官方模型卡或原始研究论文。</p></div><Tag>{Object.keys(SOURCES).length} 个一手来源</Tag></div><div className="source-list">{Object.entries(SOURCES).map(([id,s],i)=><article key={id}><span className="source-number">{String(i+1).padStart(2,'0')}</span><div><a href={s.url} target="_blank" rel="noreferrer">{s.title}<ArrowUpRight size={16}/></a><span>{s.publisher} / {s.date}</span><p>{s.note}</p></div></article>)}</div></section>;}
export default function App(){
const [tab,setTab]=useState(()=>location.hash==='#studio'?'studio':location.hash==='#method'?'method':location.hash==='#sources'?'sources':'overview'),[doc,setDoc]=useState(loadSaved),[selected,setSelected]=useState(null),[message,setMessage]=useState(''),[mobileMenu,setMobileMenu]=useState(false);
const toastTimer=useRef();
const toast=text=>{setMessage(text);clearTimeout(toastTimer.current);toastTimer.current=setTimeout(()=>setMessage(''),4500);};
useEffect(()=>{const timer=setTimeout(()=>{try{localStorage.setItem(STORE,JSON.stringify(toDocument(doc.nodes,doc.edges,doc.globals,doc.name)));}catch{toast('本机存储不可用,请导出JSON保存流程。');}},500);return()=>clearTimeout(timer);},[doc]);
const navigate=t=>{setTab(t);setMobileMenu(false);history.replaceState(null,'',t==='overview'?'#overview':`#${t}`);window.scrollTo({top:0,behavior:'instant'});};
const addProfile=r=>{const id=`n-${Date.now().toString(36)}`,kind=r.modality==='text'?'llm':r.modality==='tts'?'tts':'speech',data={...nodeData(kind,r.name),mode:'reference',profileId:r.id,...(r.size?{size:r.size}:{}),...(r.active?{active:r.active,arch:'moe'}:{})};setDoc(prev=>({...prev,name:'自定义对话流程',nodes:[...prev.nodes,{id,type:'stage',position:{x:120,y:120+prev.nodes.length*30},data}]}));setSelected(id);navigate('studio');toast('已添加参考节点;首包或持续速度缺失时会明确使用工程假设。');};
return <><header className="site-header"><a className="brand" href="#overview" onClick={e=>{e.preventDefault();navigate('overview');}}><span className="brand-icon"><Activity size={23}/></span><span>模型时延实验室<small>LATENCY LAB</small></span></a><nav className={mobileMenu?'open':''}>{[['overview','模型对照'],['studio','流程实验室'],['method','计算方法'],['sources','资料来源']].map(([v,l])=><button key={v} className={tab===v?'active':''} onClick={()=>navigate(v)}>{l}{v==='studio'&&<span className="nav-new">交互</span>}</button>)}</nav><div className="header-right"><a href="https://blog.k1412.top" target="_blank" rel="noreferrer">K1412 RESEARCH <ArrowUpRight size={14}/></a><button className="menu-button" aria-label="打开导航" onClick={()=>setMobileMenu(!mobileMenu)}>{mobileMenu?<X/>:<Menu/>}</button></div></header>
<main>{tab==='overview'&&<><Hero onStart={()=>navigate('studio')} onCompare={()=>document.getElementById('compare').scrollIntoView({behavior:'smooth'})}/><Comparison addProfile={addProfile}/><section className="studio-invite wrap"><div><span className="eyebrow">FROM A NUMBER TO A SYSTEM</span><h2>一个模型快,<br/>整条链路也快吗?</h2><p>把多个模型、工具和语音模块连起来,找出真正决定体验的那条路径。</p></div><button className="primary light" onClick={()=>navigate('studio')}>打开流程实验室 <ArrowRight size={17}/></button></section></>}{tab==='studio'&&<Studio doc={doc} setDoc={setDoc} selected={selected} setSelected={setSelected} toast={toast}/>} {tab==='method'&&<Method/>}{tab==='sources'&&<Sources/>}</main>
<footer className="site-footer wrap"><div><span className="brand-word">LATENCY LAB</span><p>公开证据 × 可校准的系统估算</p></div><div><span>资料截止 {CUTOFF}</span><a href="https://git.k1412.top/wuyang/latency-lab" target="_blank" rel="noreferrer">源代码与数据 <ExternalLink size={13}/></a><button onClick={()=>navigate('method')}>计算假设与边界</button></div><small>{RELEASE} · 本机计算与存储,无需模型API密钥</small></footer>{message&&<div className="toast" role="status"><Info size={17}/>{message}<button aria-label="关闭提示" onClick={()=>setMessage('')}><X size={15}/></button></div>}
</>;
}
+88
View File
@@ -0,0 +1,88 @@
export const CUTOFF = '2026-11-06';
export const RELEASE = 'K1412-LATENCY-LAB-20261106-v1';
export const SOURCES = {
qwen3: { title: 'Qwen3 官方速度基准', publisher: 'Qwen', date: '2025 · 在线文档', url: 'https://github.com/QwenLM/Qwen3/blob/main/docs/source/getting_started/speed_benchmark.md', note: 'H20 96GB、SGLang 0.4.6、batch=1。原始 speed=(输入+输出)/总时间;本表只取输入1、输出2048的行,近似生成速度。不是跨硬件最佳成绩。' },
glm: { title: 'GLM-4 官方推理数据', publisher: 'Z.ai', date: '2025 · 在线文档', url: 'https://github.com/zai-org/GLM-4/blob/main/inference/README.md', note: 'H100 80GB,具体卡数和输入长度逐行列出。短输入与长输入的 TTFT 不可混用。' },
groq8: { title: 'Groq · Llama 3.1 8B', publisher: 'Groq', date: '在线文档 · 查询于截止日', url: 'https://console.groq.com/docs/model/llama-3.1-8b-instant', note: '服务商标称 token speed,未提供本地可复现的完整负载条件;不是 SLA。' },
groq70: { title: 'Groq · Llama 3.3 70B', publisher: 'Groq', date: '在线文档 · 查询于截止日', url: 'https://console.groq.com/docs/model/llama-3.3-70b-versatile', note: 'LPU 专用推理平台,不能套用到单张 GPU。' },
swift: { title: 'SwiftSpec 论文', publisher: 'ByteDance Seed 等', date: '2025-06-12', url: 'https://arxiv.org/html/2506.11309v1', note: '单请求低时延优化,8张 Hopper GPU、异步推测解码。速度随任务和接受率变化。' },
cb70: { title: 'Cerebras · 70B 推理结果', publisher: 'Cerebras', date: '2024-10-24 · 历史结果', url: 'https://www.cerebras.ai/blog/cerebras-inference-3x-faster', note: '晶圆级硬件、16位权重与推测解码。厂商报告每用户2100 token/s,文中提示速度可上下波动约20%。不是当前 API 可用性承诺。' },
cb405: { title: 'Cerebras · 405B 推理结果', publisher: 'Cerebras', date: '2024-11-18 · 历史结果', url: 'https://www.cerebras.ai/blog/llama-405b-inference', note: '1000-token 输入时报告969输出token/s、240ms TTFT;支持128K不等于该TTFT来自128K输入。' },
qwen38: { title: 'TensorRT LLM · Qwen3.8 MoE 部署基准', publisher: 'NVIDIA', date: '1.4.0rc0 · 截止日可见版本', url: 'https://nvidia.github.io/TensorRT-LLM/1.4.0rc0/deployment-guide/deployment-guide-for-qwen3.8-qwen3.5-on-trtllm.html', note: 'GB300、TP16/EP1、并发1、8192输入/1024输出。MTP3的383.051 token/s使用受控接受数2.3,属于条件化性能点。' },
r1: { title: 'TensorRT LLM · DeepSeek-R1 低时延优化', publisher: 'NVIDIA', date: '2025 · 技术文档', url: 'https://nvidia.github.io/TensorRT-LLM/blogs/tech_blog/blog1_Pushing_Latency_Boundaries_Optimizing_DeepSeek-R1_Performance_on_NVIDIA_B200_GPUs.html', note: '8×B200,1K输入/2K输出。峰值配置含NVFP4、MTP与放宽接受验证;不能视为输出分布不变的免费加速。' },
omni2: { title: 'LLaMA-Omni 2 · 表6', publisher: 'ICT/CAS', date: '2025-05-05', url: 'https://arxiv.org/html/2505.02625v1#A2', note: '单张L40、从收到语音指令到第一段合成语音。0.5B至14B指语言主干;未计公网和终端播放。R=3/W=10为主实验;R=1/W=5更快,但音质有变化。' },
moshi: { title: 'Moshi 技术论文', publisher: 'Kyutai', date: '2024 · 论文v2', url: 'https://arxiv.org/html/2410.00037v2', note: '160ms为架构理论时延,论文称实际200ms。连续流延迟与一问一答的完整任务耗时不同。' },
persona: { title: 'PersonaPlex 官方模型卡', publisher: 'NVIDIA', date: '2026-01 · v1', url: 'https://huggingface.co/nvidia/personaplex-7b-v1', note: 'A100 80GB、PyTorch、FullDuplexBench。接续170ms;打断停止240ms是不同指标。主要为英语。' },
voicechat: { title: 'Nemotron 3 VoiceChat 官方模型卡', publisher: 'NVIDIA', date: '2026-03-16 · v1', url: 'https://build.nvidia.com/nvidia/nemotron-voicechat/modelcard', note: '12B总参数、9B主干、H100、vLLM,报告260ms接续。早期评估版本;不与后续11B NemotronLabs检查点混用。' },
minicpm: { title: 'MiniCPM-o 4.5 · 表11/12', publisher: 'OpenBMB', date: '2026-04-30', url: 'https://arxiv.org/html/2604.27393v1', note: '9B总参数。RTX4090/llama.cpp-omni/INT4的RTF=0.21;0.58s的首token来自64帧视觉输入,不是语音首包。' },
qomni3: { title: 'Qwen3-Omni · 表1/2', publisher: 'Qwen', date: '2025-09-22', url: 'https://arxiv.org/html/2509.17765v1#S2.T2', note: 'Thinker 30B-A3B,另有3B-A0.3B Talker和编解码模块。234ms是无历史上下文的理论音频首包;表2并发1/4/6分别为234/728/1172ms。未给出可复现的具体GPU型号。' },
qomni35: { title: 'Qwen3.5-Omni · 表1/2', publisher: 'Qwen', date: '2026-04-17', url: 'https://arxiv.org/html/2604.15804v1#S2.T2', note: '理论首包;Flash235ms/Plus435ms。论文只说扩展到数千亿参数,未公开两变体精确尺寸;部署资源不同,不能据此比较单位算力效率。' },
qomni38: { title: 'Qwen3.8-Omni-Flash · 实际API性能', publisher: 'Qwen', date: '2026-09-18', url: 'https://qwen.ai/blog?id=qwen3.8-omni-flash#throughput--latency', note: '官方生产环境观测,纯音频6/12/20秒输入时首音频约978/983/1026ms。未公布精确参数、GPU、样本数及百分位。网络和端点判停边界未充分说明,不能视作端到端SLA。' },
lfm3: { title: 'LFM3-Audio-2B 官方模型卡', publisher: 'Liquid AI', date: '2026-10-27', url: 'https://huggingface.co/LiquidAI/LFM3-Audio-2B', note: '流式编码可在用户说话时prefill,官方称首token低至50ms;没有声明50ms是首段可播放音频,也未给出该数值的完整硬件条件。' },
qtts: { title: 'Qwen3-TTS · 表2', publisher: 'Qwen', date: '2026-01-22', url: 'https://arxiv.org/html/2601.15621v1#S3.T2', note: '12Hz版本0.6B/1.7B在并发1时报告97/101ms首包,RTF=0.288/0.313。内部vLLM V0、编解码compile与CUDA Graph;未公开具体GPU。这是TTS组件,不含语音识别和LLM响应。' },
live: { title: 'GPT-Live 官方文档', publisher: 'OpenAI', date: '在线文档 · 截止日查阅', url: 'https://developers.openai.com/api/docs/guides/live', note: '原生全双工语音与后台任务委托;没有可用于按参数规模比较的公开精确尺寸和统一时延基准。后台任务完成时延需要另算。' },
vad: { title: 'Realtime VAD 官方文档', publisher: 'OpenAI', date: '在线文档 · 截止日查阅', url: 'https://developers.openai.com/api/docs/guides/realtime-vad', note: 'silence_duration_ms可调,官方示例为500ms。判停等待是额外时延;更短等待可能增加误抢话。' },
gemini: { title: 'Gemini Live API 官方文档', publisher: 'Google', date: '在线文档 · 截止日查阅', url: 'https://ai.google.dev/gemini-api/docs/live', note: '原生实时音频API。模型尺寸和统一的用户停说至首音频时延缺少公开可比数据。' },
};
const row = (id, name, modality, size, metric, value, source, extra = {}) => ({ id, name, modality, size, metric, value, source, evidence: 'reported', hardware: '未披露', params: size == null ? '未公开' : `${size}B`, ...extra });
export const RECORDS = [
...[[0.6,458.03],[1.7,333.90],[4,200.61],[8,150.25],[14,97.11],[32,46.17]].map(([s,v])=>row(`q3-${s}`,`Qwen3-${s}B`,'text',s,'tps',v,'qwen3',{hardware:'1×H20 96GB',precision:'FP8',input:1,output:2048,concurrency:1,scope:'纯文本生成近似',date:'2025',note:'极短输入,原指标含1个输入token。未报告首token;不等同长上下文速度。'})),
row('q3-32-q4','Qwen3-32B','text',32,'tps',47.67,'qwen3',{hardware:'1×H20 96GB',precision:'AWQ INT4',input:1,output:2048,concurrency:1,scope:'纯文本生成近似',date:'2025'}),
row('q3-30','Qwen3-30B-A3B','text',30,'tps',155.55,'qwen3',{active:3,hardware:'1×H20 96GB',precision:'FP8',input:1,output:2048,concurrency:1,scope:'MoE生成近似',date:'2025'}),
row('q3-235','Qwen3-235B-A22B','text',235,'tps',74.5,'qwen3',{active:22,hardware:'8×H20 96GB',precision:'BF16',input:1,output:2048,concurrency:1,scope:'MoE生成近似',date:'2025'}),
row('q3-235-fp8','Qwen3-235B-A22B','text',235,'tps',71.65,'qwen3',{active:22,hardware:'4×H20 96GB',precision:'FP8',input:1,output:2048,concurrency:1,scope:'MoE生成近似',date:'2025'}),
...[[9,1000,50,44.4,1],[9,8000,390,39,1],[9,32000,2290,18.7,1],[9,100000,6800,14.1,1],[32,1000,160,24.4,1],[32,8000,1370,16.9,1],[32,32000,6750,8.1,2],[32,100000,37830,3,4]].map(([s,input,value,tps,cards])=>row(`glm-${s}-${input}`,`GLM-4-${s}B-0414`,'text',s,'ttft',value,'glm',{tps,input,cards,hardware:`${cards}×H100 80GB`,precision:'BF16',scope:'首个文本token',date:'2025-04'})),
row('groq8','Llama 3.1 8B · Groq','text',8,'tps',560,'groq8',{hardware:'Groq LPU 服务',precision:'服务商数值格式',scope:'服务商标称生成速度',date:'查询于2026-11-06',note:'无统一TTFT和完整并发条件。'}),
row('groq70','Llama 3.3 70B · Groq','text',70,'tps',280,'groq70',{hardware:'Groq LPU 服务',scope:'服务商标称生成速度',date:'查询于2026-11-06'}),
row('swift70','Llama 3 70B · SwiftSpec','text',70,'tps',348,'swift',{hardware:'8×Hopper GPU',precision:'论文优化配置',scope:'单请求低时延解码',concurrency:1,date:'2025-06-12',note:'任务相关的推测解码接受率;不是所有任务统一速度。'}),
row('cb70','Llama 3.1 70B · Cerebras','text',70,'tps',2100,'cb70',{hardware:'Cerebras 晶圆级系统',precision:'16位 + 推测解码',scope:'历史每用户生成结果',date:'2024-10-24',historical:true}),
row('cb405','Llama 3.1 405B · Cerebras','text',405,'tps',969,'cb405',{ttft:240,input:1000,hardware:'Cerebras 晶圆级系统',precision:'16位',scope:'历史每用户生成结果',date:'2024-11-18',historical:true}),
row('q38','Qwen3.8-2.4T-A95B','text',2400,'tps',133.502,'qwen38',{active:95,input:8192,output:1024,concurrency:1,hardware:'16×GB300 · TP16/EP1',precision:'FP8 · 无MTP',scope:'每用户解码速度',date:'2026 · 文档1.4.0rc0'}),
row('q38-mtp','Qwen3.8-2.4T-A95B · MTP3','text',2400,'tps',383.051,'qwen38',{active:95,input:8192,output:1024,concurrency:1,hardware:'16×GB300 · TP16/EP1',precision:'FP8 · MTP3',evidence:'conditional',scope:'受控接受数性能点',date:'2026 · 文档1.4.0rc0',note:'接受草稿数固定为2.3;不能当成自然负载的无条件实测速率。'}),
row('r1','DeepSeek-R1 · 峰值探索','text',671,'tps',368,'r1',{active:37,hardware:'8×B200',input:1000,output:2000,concurrency:1,precision:'混合精度 · NVFP4/MTP',evidence:'conditional',scope:'条件化峰值解码',date:'2025',note:'配置包含放宽接受验证,输出分布保持性不能由该速度证明。'}),
...[[0.5,542.71],[1.5,552.76],[3,567.84],[7,582.91],[14,663.32]].map(([s,v])=>row(`omni2-${s}`,`LLaMA-Omni2-${s}B`,'speech',s,'audio',v,'omni2',{params:`${s}B 主干`,hardware:'1×L40',scope:'收到指令→首段语音',date:'2025-05-05',note:'R=3/W=10,未计公网与播放缓冲;尺寸仅语言主干。'})),
row('omni2-7-fast','LLaMA-Omni2-7B · 小块输出','speech',7,'audio',457.29,'omni2',{params:'7B 主干',hardware:'1×L40',scope:'收到指令→首段语音',date:'2025-05-05',note:'R=1/W=5;与较大块的语音质量不同,不能只按速度排名。'}),
row('llama-omni','LLaMA-Omni','speech',8,'audio',346.73,'omni2',{params:'8B 主干',hardware:'1×L40',scope:'论文复测首段语音',date:'2025-05-05'}),
row('glmvoice','GLM-4-Voice','speech',9,'audio',1562.81,'omni2',{params:'9B 主干',hardware:'1×L40',scope:'论文复测首段语音',date:'2025-05-05',note:'同一论文中的基线复测,不能代表该模型所有优化部署。'}),
row('moshi-theory','Moshi · 理论','speech',7,'audio',160,'moshi',{params:'7B 主干',evidence:'theoretical',scope:'连续流架构延迟',date:'2024',note:'算法时延,非公网用户停说至首音频SLA。'}),
row('moshi','Moshi · 实际','speech',7,'audio',200,'moshi',{params:'7B 主干',scope:'论文称实际流延迟',date:'2024'}),
row('persona','PersonaPlex-7B-v1','speech',7,'audio',170,'persona',{params:'7B 主干',hardware:'A100 80GB',precision:'PyTorch',scope:'用户停说→开始回复',date:'2026-01-15',duplex:true,note:'FullDuplexBench接续指标;不代表回答正确率。'}),
row('persona-interrupt','PersonaPlex · 打断','speech',7,'interrupt',240,'persona',{params:'7B 主干',hardware:'A100 80GB',scope:'用户打断→停止说话',date:'2026-01-15',duplex:true}),
row('minicpm','MiniCPM-o 4.5','speech',9,'rtf',0.21,'minicpm',{params:'9B 总参数',hardware:'RTX4090',precision:'INT4 · llama.cpp-omni',scope:'生成实时因子',date:'2026-04-30',duplex:true,note:'不是210ms首音频;未给出本条件下的可比首音频数。'}),
row('voicechat','Nemotron 3 VoiceChat v1','speech',12,'audio',260,'voicechat',{params:'12B总量 / 9B主干',hardware:'H100',precision:'vLLM',scope:'FullDuplexBench接续',date:'2026-03-16',duplex:true,note:'早期评估版本,不与后续NemotronLabs不同检查点合并。'}),
...[[1,234,0.47],[4,728,0.56],[6,1172,0.66]].map(([c,v,rtf])=>row(`qomni3-${c}`,'Qwen3-Omni-30B-A3B','speech',30,'audio',v,'qomni3',{active:3,params:'Thinker 30B-A3B + Talker 3B-A0.3B',concurrency:c,rtf,evidence:'theoretical',scope:'理论音频首包',hardware:'未披露具体GPU',precision:'vLLM + compile / CUDA Graph',date:'2025-09-22'})),
row('qomni35-flash','Qwen3.5-Omni-Flash','speech',null,'audio',235,'qomni35',{params:'精确尺寸未公开',concurrency:1,rtf:0.178,evidence:'theoretical',scope:'理论音频首包',date:'2026-04-17'}),
row('qomni35-plus','Qwen3.5-Omni-Plus','speech',null,'audio',435,'qomni35',{params:'数千亿级 / 精确尺寸未公开',concurrency:1,rtf:0.187,evidence:'theoretical',scope:'理论音频首包',date:'2026-04-17'}),
...[[6,978.36,591.26,84.87],[12,982.74,604.80,83.18],[20,1026.39,617.98,81.06]].map(([sec,v,ttft,tps])=>row(`qomni38-${sec}`,'Qwen3.8-Omni-Flash-Realtime','speech',null,'audio',v,'qomni38',{ttft,tps,rtf:0.1538,params:'未公开',inputSeconds:sec,scope:'生产API首音频',date:'2026-09-18',note:'官方生产观测;未披露p50/p95、硬件与完整计时边界。'})),
row('qomni38-video','Qwen3.8-Omni-Flash-Realtime · 音视频','speech',null,'audio',1214.73,'qomni38',{ttft:837.96,tps:84.89,rtf:0.1524,inputSeconds:6,scope:'6秒音视频首音频',date:'2026-09-18'}),
row('lfm3','LFM3-Audio-2B','speech',2,'ttft',50,'lfm3',{scope:'流式输入后的首token下限',date:'2026-10-27',note:'官方“低至”数值,硬件条件未完整说明;没有可比的首音频值。'}),
...[[.6,1,97,.288],[1.7,1,101,.313],[.6,3,179,.338],[1.7,3,195,.363],[.6,6,299,.434],[1.7,6,333,.463]].map(([s,c,v,rtf])=>row(`qtts-${s}-${c}`,`Qwen3-TTS-12Hz-${s}B`,'tts',s,'audio',v,'qtts',{rtf,concurrency:c,scope:'TTS组件首音频包',hardware:'未披露具体GPU',precision:'内部vLLM V0 + compile',date:'2026-01-22'})),
row('gpt-live','GPT-Live','speech',null,'unknown',null,'live',{scope:'全双工API · 统一时延未知',date:'查询于2026-11-06',duplex:true,evidence:'unknown'}),
row('gemini-live','Gemini Live','speech',null,'unknown',null,'gemini',{scope:'原生音频API · 统一时延未知',date:'查询于2026-11-06',evidence:'unknown'}),
];
export const EVIDENCE = { reported:'公开报告', theoretical:'理论口径', conditional:'条件化结果', unknown:'数据未公开' };
export const METRICS = { tps:'输出速度', ttft:'首token', audio:'首音频 / 接续', interrupt:'打断停止', rtf:'实时因子', unknown:'未报告' };
export const recordValue = r => r.value == null ? '—' : r.metric==='tps' ? `${r.value.toLocaleString('en-US',{maximumFractionDigits:2})} tok/s` : r.metric==='rtf' ? `${r.value} RTF` : `${r.value.toLocaleString('en-US',{maximumFractionDigits:1})} ms`;
export const HARDWARE = [
{id:'h20',name:'H20 96GB',factor:1,bandwidth:4, memory:96},
{id:'h100',name:'H100 80GB',factor:1.3,bandwidth:3.35,memory:80},
{id:'h200',name:'H200 141GB',factor:1.6,bandwidth:4.8,memory:141},
{id:'b200',name:'B200 180GB',factor:2.4,bandwidth:8,memory:180},
{id:'gb300',name:'GB300 288GB',factor:3.1,bandwidth:8,memory:288},
{id:'4090',name:'RTX 4090 24GB',factor:0.48,bandwidth:1,memory:24},
{id:'5090',name:'RTX 5090 32GB',factor:0.78,bandwidth:1.79,memory:32},
{id:'cpu',name:'CPU / 统一内存(泛化)',factor:0.07,bandwidth:0.12,memory:64},
];
export const SIZE_OPTIONS = [0.5,0.6,1.5,1.7,2,3,4,7,8,9,12,14,27,32,70,120,235,405,671,2400];
// These are engineering defaults, deliberately separate from the source records above.
export const CALIBRATION = {
denseAnchors: [[0.6,458.03],[1.7,333.90],[4,200.61],[8,150.25],[14,97.11],[32,46.17]],
prefillTokensPerSecondAt8B: 18000,
prefillFloorMs: 14,
quantRateFactors: {bf16:0.62,fp8:1,int4:1.06},
speculativeDefault: 1,
sensitivity: [0.75,1.5],
};
+238
View File
@@ -0,0 +1,238 @@
import { RECORDS, HARDWARE, CALIBRATION } from './catalog.js';
export const DEFAULT_GLOBALS = { computeScale:1, programScale:1, networkMs:20, queueMs:0, vadMs:120, playbackBufferMs:60, speechSeconds:4, inputSeconds:3, sharedGPU:false, loopPlan:'max' };
export const KINDS = { input:'语音输入', asr:'流式识别 / 编码', llm:'文本模型 / Agent', speech:'原生语音模型', tts:'流式语音合成', program:'程序 / 工具', merge:'分支汇总', output:'音频播放 / 目标输出' };
export const MODEL_KINDS = ['llm','speech','tts','asr'];
const finite = (v,f=0) => Number.isFinite(Number(v)) ? Number(v) : f;
const num = (v,f=0,max=1e7) => Math.max(0,Math.min(max,finite(v,f)));
const recordMap = new Map(RECORDS.map(r=>[r.id,r]));
export const ms = v => !Number.isFinite(v) ? '—' : Math.abs(v)>=1000 ? `${(v/1000).toFixed(v>=10000?1:2)} s` : `${Math.round(v)} ms`;
export const getRecord = id => recordMap.get(id);
function interpolate(size) {
const a=CALIBRATION.denseAnchors, s=Math.max(.1,size);
if(s<=a[0][0]) return a[0][1]*(a[0][0]/s)**.2;
for(let i=1;i<a.length;i++) if(s<=a[i][0]) {
const p=(Math.log(s)-Math.log(a[i-1][0]))/(Math.log(a[i][0])-Math.log(a[i-1][0]));
return Math.exp(Math.log(a[i-1][1])*(1-p)+Math.log(a[i][1])*p);
}
return a.at(-1)[1]*a.at(-1)[0]/s;
}
export function estimateNode(data, globals=DEFAULT_GLOBALS, forceMax=false) {
const g={...DEFAULT_GLOBALS,...globals}, d=data, kind=d.kind;
const scale=Math.max(.01,num(g.computeScale,1,100)), progScale=Math.max(.01,num(g.programScale,1,100));
const model=MODEL_KINDS.includes(kind), rec=getRecord(d.profileId);
const mode=d.mode||'estimate', ref=mode==='reference'&&rec&&rec.value!=null&&rec.metric!=='interrupt' ? rec:null;
const hw=HARDWARE.find(h=>h.id===d.hardware)||HARDWARE[0], cards=Math.max(1,Math.round(num(d.cards,1,256)));
const size=Math.max(.1,num(d.size,8,1e5)), active=d.arch==='moe'?Math.max(.1,Math.min(size,num(d.active,3,1e5))):size;
const quant=d.quant||'fp8', quantFactor=CALIBRATION.quantRateFactors[quant]||1;
const tpGain= Math.min(4,cards**.6)/(1+Math.log2(cards)*.08);
const audioHardware=hw.factor*tpGain*quantFactor;
const speculative=Math.max(1,num(d.speculative,1,8));
let rate=Math.max(1,interpolate(active)*hw.factor*quantFactor*tpGain*speculative/(d.arch==='moe'?1.25:1));
let assumptions=[], warnings=[], sourceId=null;
if(ref) {
sourceId=ref.source;
const rr=ref.metric==='tps'?ref.value:ref.tps;
if(rr) rate=rr;
assumptions.push('采用选中来源的部署配置;硬件/量化外推参数不参与该参考速率。');
if(ref.metric==='tps'&&!ref.ttft) assumptions.push('来源未给首token,首token另用工程模型估算。');
if(ref.evidence==='theoretical') warnings.push('参考值是理论口径,计算结果仍是方案估计。');
if(ref.evidence==='conditional') warnings.push('参考速率依赖特定接受率或验证条件。');
if(ref.historical) warnings.push('参考是历史性能点,不能保证当前API仍提供同样服务。');
if(kind==='llm'&&ref.input&&num(d.inputTokens,1024)!==ref.input) warnings.push(`来源输入${ref.input} token,当前输入${num(d.inputTokens,1024)} token;首token或生成速度的迁移包含额外假设。`);
if(ref.inputSeconds&&ref.inputSeconds!==num(g.inputSeconds,3)) warnings.push(`来源使用${ref.inputSeconds}秒输入,当前为${num(g.inputSeconds,3)}秒;首包参考未由音频长度重新标定。`);
} else if(mode==='custom') {
rate=Math.max(.1,num(d.customTps,100,1e6));
assumptions.push('使用手动填写的时延和速度。');
} else {
assumptions.push('速度由H20/Qwen3锚点按参数规模、硬件系数、量化和卡数外推。');
if(active>32||cards>1||hw.id!=='h20'||d.arch==='moe') warnings.push('该硬件/规模组合包含外推,请用实测校准。');
if(speculative>1) warnings.push(`推测解码${speculative}倍为手动假设,接受率不足时不成立。`);
}
rate/=scale;
const maxSteps=Math.max(1,Math.round(num(d.maxSteps,1,100)));
const plannedSteps=forceMax||g.loopPlan==='max'?maxSteps:Math.max(1,Math.min(maxSteps,Math.round(num(d.expectedSteps,Math.min(2,maxSteps),100))));
const stepsCount=model?plannedSteps:1;
const outputTokens=Math.max(1,Math.round(num(d.outputTokens,128,1e6)));
const hiddenTokens=Math.round(num(d.reasoningTokens,0,1e6));
const inputTokens=Math.round(num(d.inputTokens,1024,1e6));
const growth=Math.round(num(d.contextGrowth,256,1e6));
const cached=Math.min(.99,num(d.cacheRatio,0,1));
const toolMs=num(d.toolMs,0)*progScale;
const q=num(g.queueMs,0);
let first=0,end=0,rtf=0,tail=0,steps=[],busy=0;
if(kind==='input') { first=end=num(g.vadMs,120); assumptions=['时间原点为用户停止说话;此前音频采集时间不计入等待。']; }
else if(kind==='program'||kind==='merge') { first=end=num(d.durationMs,40)*progScale; assumptions=['程序节点耗时为可编辑的单次预算。']; }
else if(kind==='output') { first=num(d.durationMs,g.playbackBufferMs); end=first+(d.outputType==='text'?0:num(g.speechSeconds,4)*1000); assumptions=['播放完成包含自然语音时长;上游供给过慢会推迟结束。']; }
else if(model) {
const audioMs=num(g.speechSeconds,4)*1000;
const refFirst=ref ? (ref.metric==='audio'||ref.metric==='ttft'?ref.value:ref.ttft) : null;
const refRtf=ref?.metric==='rtf'?ref.value:ref?.rtf;
for(let i=0;i<stepsCount;i++) {
const start=end+(i?toolMs:0);
const tokens=inputTokens+i*growth;
const uncached=i===0?tokens*(1-cached):inputTokens*(1-cached)+i*growth;
const prefillRate=CALIBRATION.prefillTokensPerSecondAt8B*(8/active)**.65*hw.factor*tpGain;
let prefill=(CALIBRATION.prefillFloorMs+1000*uncached/prefillRate)*scale;
if(refFirst!=null) {
prefill=refFirst*scale;
if(kind==='llm'&&ref.input>1) prefill*=Math.max(.15,uncached/ref.input);
}
if(mode==='custom') prefill=num(d.customFirstMs,100)*scale;
let sf=0,se=0,srtf=0;
if(kind==='llm') {
sf=prefill+hiddenTokens/rate*1000;
se=sf+(outputTokens-1)/rate*1000;
tail=1000/rate;
} else if(kind==='speech') {
// A text TTFT (e.g. LFM3's 50ms) is deliberately not reused as first audio.
sf=(mode==='custom'?num(d.customFirstMs,300)*scale:ref?.metric==='audio'?ref.value*scale:Math.max(80,(160+active*9)/Math.sqrt(audioHardware))*scale);
if(ref?.metric==='ttft') assumptions.push('来源只给首token;本节点首音频使用工程假设,未把二者等同。');
if(ref?.metric==='rtf') assumptions.push('来源只给RTF,首音频另按工程假设;0.21 RTF不等于210ms首包。');
srtf=(mode==='custom'?num(d.customRtf,.45,100):refRtf??(0.24+active*.025)/audioHardware)*scale;
if(ref&&refRtf==null) assumptions.push('来源未报告RTF,持续生成速度采用工程假设。');
se=sf+audioMs*srtf;
tail=80*srtf;
} else if(kind==='tts') {
sf=mode==='custom'?num(d.customFirstMs,100)*scale:ref?.metric==='audio'?ref.value*scale:(70+Math.sqrt(size)*35)/audioHardware*scale;
srtf=(mode==='custom'?num(d.customRtf,.18,100):refRtf??(.12+size*.035)/audioHardware)*scale;
se=sf+audioMs*srtf; tail=80*srtf;
} else if(kind==='asr') {
const seconds=num(g.inputSeconds,3);
sf=(mode==='custom'?num(d.customFirstMs,100):num(d.durationMs,100)*(size/.6)**.5/audioHardware)*scale;
srtf=(mode==='custom'?num(d.customRtf,.15,100):.15*(size/.6)**.6/audioHardware)*scale;
se=sf+(d.streamingInput===false?seconds*1000*srtf:0);
sf=se; tail=se;
assumptions.push(d.streamingInput===false?'整段识别,计入全段处理时间。':'用户说话时持续处理,仅将尾包处理计入停说后的等待。');
if(mode!=='custom')assumptions.push('尾包基线按0.6B/H20定义,并随规模、硬件与量化外推。');
}
sf+=q; se+=q;
steps.push({step:i+1,start,first:start+sf,end:start+se,input:tokens,output:outputTokens,reasoning:hiddenTokens,toolWait:i?toolMs:0});
if(i===0) first=start+sf;
end=start+se;
busy+=se;
rtf=Math.max(rtf,srtf);
}
if(d.finalOnly!==false&&steps.length) first=steps.at(-1).first;
if(kind==='llm') rtf=busy/Math.max(1000,audioMs);
}
if(!ref&&mode!=='custom'&&model) {
const bytesPerWeight=quant==='bf16'?2:quant==='int4'?.55:1.05;
const weightsGB=size*bytesPerWeight;
if(weightsGB+Math.max(2,weightsGB*.08)>hw.memory*cards) warnings.push(`估计权重与最小运行余量约${Math.round(weightsGB+Math.max(2,weightsGB*.08))}GB,超出所选显存;数值不能视为可部署结果。`);
}
return { first,end,tail,rate,rtf,steps,stepsCount,maxSteps,outputTokens,model,sourceId,assumptions:[...new Set(assumptions)],warnings:[...new Set(warnings)],label:ref?'来源锚点 + 方案估计':mode==='custom'?'手动校准':'工程外推' };
}
export function validateGraph(nodes,edges) {
const errors=[];
if(!Array.isArray(nodes)||!Array.isArray(edges)) return ['流程必须包含节点和连线数组。'];
if(nodes.length>100||edges.length>300) return ['最多支持100个节点和300条连线。'];
const ids=new Set();
for(const n of nodes) {
if(!n||typeof n.id!=='string'||!n.id||ids.has(n.id)) { errors.push('节点ID缺失或重复。'); continue; }
ids.add(n.id);
if(!n.data||!KINDS[n.data.kind]) errors.push(`节点${n.id}的类型无效。`);
if(!Number.isFinite(n.position?.x)||!Number.isFinite(n.position?.y)) errors.push('节点坐标必须是有限数值。');
for(const [k,v] of Object.entries(n.data||{})) if(typeof v==='number'&&(!Number.isFinite(v)||v<0||v>1e7)) errors.push(`节点${n.id}的${k}超出范围。`);
}
const indeg=new Map(nodes.map(n=>[n.id,0])), adj=new Map(nodes.map(n=>[n.id,[]]));
for(const e of edges) {
if(!ids.has(e.source)||!ids.has(e.target)) { errors.push('连线指向了不存在的节点。'); continue; }
if(e.source===e.target) errors.push('请用节点内的loop步数表示循环。');
indeg.set(e.target,indeg.get(e.target)+1); adj.get(e.source).push(e.target);
if(e.data?.networkMs!=null&&(!Number.isFinite(e.data.networkMs)||e.data.networkMs<0)) errors.push('连线网络时延必须为非负数。');
}
const ready=nodes.filter(n=>indeg.get(n.id)===0).map(n=>n.id), order=[];
while(ready.length) { const id=ready.shift(); order.push(id); for(const t of adj.get(id)) { indeg.set(t,indeg.get(t)-1); if(!indeg.get(t))ready.push(t); } }
if(order.length!==nodes.length) errors.push('流程存在环。图上使用有向无环结构,Agent循环放在模型节点内。');
return [...new Set(errors)];
}
function schedule(nodes,edges,g,forceMax=false) {
const results={}, indeg=new Map(nodes.map(n=>[n.id,0])), outgoing=new Map(nodes.map(n=>[n.id,[]]));
const byId=new Map(nodes.map(n=>[n.id,n]));
edges.forEach(e=>{indeg.set(e.target,indeg.get(e.target)+1);outgoing.get(e.source).push(e);});
const ready=nodes.filter(n=>!indeg.get(n.id)).map(n=>n.id), resources=new Map(), order=[];
while(ready.length) {
const id=ready.shift(), n=byId.get(id), d=n.data, cost=estimateNode(d,g,forceMax), incoming=edges.filter(e=>e.target===id);
const arrivals=incoming.map(e=>{
const p=results[e.source], mode=e.data?.release||'complete', network=num(e.data?.networkMs,g.networkMs);
let at=p.end,event='end';
if(mode==='first'||mode==='chunk') {
const tokens=mode==='chunk'?Math.min(p.outputTokens,Math.max(1,num(e.data?.tokens,8))):1;
at=p.first+(p.kind==='llm'?(tokens-1)/p.rate*1000:0); at=Math.min(p.end,at);event='first';
}
return {at:at+network,id:e.source,event,edge:e.id,network,stream:mode!=='complete'};
});
const selected=arrivals.length ? arrivals.reduce((a,b)=>d.join==='any'?(a.at<=b.at?a:b):(a.at>=b.at?a:b)) : null;
let start=selected?.at||0, parent=selected, resourceWait=0;
const resource=cost.model?(d.resource?.trim()||(g.sharedGPU?'shared-gpu':'')):'';
const prev=resource?resources.get(resource):null;
if(prev&&prev.end>start) { resourceWait=prev.end-start;start=prev.end;parent={id:prev.id,event:'end',resource:true}; }
let first=start+cost.first,end=start+cost.end,endParent=parent;
// Streaming means an early start, never that the producer's remaining output disappears.
for(const a of arrivals) if(a.stream&&(d.join!=='any'||a===selected)) {
const floor=results[a.id].end+a.network+cost.tail;
if(floor>end) {end=floor;endParent={id:a.id,event:'end',edge:a.edge};}
}
if(d.kind==='output'&&d.outputType!=='text') end=Math.max(end,first+num(g.speechSeconds,4)*1000);
first=Math.min(first,end);
results[id]={...cost,id,kind:d.kind,title:d.label,start,first,end,parent,endParent,resourceWait,resource,steps:cost.steps.map(s=>({...s,start:s.start+start,first:s.first+start,end:s.end+start}))};
if(resource)resources.set(resource,{end,id}); order.push(id);
for(const e of outgoing.get(id)) {indeg.set(e.target,indeg.get(e.target)-1);if(!indeg.get(e.target))ready.push(e.target);}
}
const targets=nodes.filter(n=>n.data.kind==='output'&&n.data.target!==false);
const terminals=targets.length?targets:nodes.filter(n=>!outgoing.get(n.id).length&&n.data.target!==false);
const audioTargets=targets.filter(n=>n.data.outputType!=='text');
const firstTargets=audioTargets.length?audioTargets:terminals;
const firstTarget=firstTargets.reduce((a,n)=>!a||results[n.id].first<results[a.id].first?n:a,null);
const completeTarget=terminals.reduce((a,n)=>!a||results[n.id].end>results[a.id].end?n:a,null);
const criticalNodes=new Set(),criticalEdges=new Set(),completionNodes=new Set();
function trace(id,event,set,seen=new Set(),captureEdges=true) { if(!id||seen.has(`${id}:${event}`))return;seen.add(`${id}:${event}`);set.add(id);const r=results[id],p=event==='end'?r.endParent:r.parent;if(p) {if(p.edge&&captureEdges)criticalEdges.add(p.edge);trace(p.id,p.event,set,seen,captureEdges);} }
if(firstTarget)trace(firstTarget.id,'first',criticalNodes);
if(completeTarget)trace(completeTarget.id,'end',completionNodes,new Set(),false);
const loads=new Map();
for(const r of Object.values(results)) if(r.model) { const key=r.resource||r.id;loads.set(key,(loads.get(key)||0)+r.rtf); }
const rtf=Math.max(0,...loads.values());
const warnings=Object.values(results).flatMap(r=>r.warnings.map(w=>`${r.title}:${w}`));
if(targets.length) {
const connected=new Set(targets.map(n=>n.id)),queue=[...connected];
while(queue.length){const id=queue.shift();for(const e of edges.filter(x=>x.target===id))if(!connected.has(e.source)){connected.add(e.source);queue.push(e.source);}}
for(const n of nodes)if(!connected.has(n.id)&&n.data.target!==false)warnings.push(`${n.data.label}未连到主输出,当前不计入主结果耗时。`);
}
if(!targets.length)warnings.push('没有主输出节点,当前结果取叶子节点;添加播放/目标输出可明确测量终点。');
if(nodes.some(n=>n.data.join==='any'&&edges.filter(e=>e.target===n.id).length>1))warnings.push('“任一就绪”假设最早分支结果可用;未建模失败重试与答案正确性。');
if(rtf>1)warnings.push('持续负载RTF超过1,所选资源/输出预算可能跟不上实时音频。');
return {results,order,first:firstTarget?results[firstTarget.id].first:0,complete:completeTarget?results[completeTarget.id].end:0,firstTarget:firstTarget?.id,completeTarget:completeTarget?.id,criticalNodes,criticalEdges,completionNodes,rtf,warnings:[...new Set(warnings)],hasAudio:audioTargets.length>0};
}
export function simulateGraph(nodes,edges,globals=DEFAULT_GLOBALS) {
const errors=validateGraph(nodes,edges);
if(errors.length)return {errors};
const g={...DEFAULT_GLOBALS,...globals};
const base=schedule(nodes,edges,g), max=schedule(nodes,edges,g,true);
const low=schedule(nodes,edges,{...g,computeScale:g.computeScale*.75,programScale:g.programScale*.75}),high=schedule(nodes,edges,{...g,computeScale:g.computeScale*1.5,programScale:g.programScale*1.5});
return {...base,errors:[],maxFirst:max.first,maxComplete:max.complete,range:[low.first,high.first],completeRange:[low.complete,high.complete]};
}
export function validateDocument(doc) {
if(!doc||typeof doc!=='object'||doc.version!==1)return ['不是受支持的流程文件(version=1)。'];
const errors=validateGraph(doc.nodes,doc.edges);
if(!doc.globals||typeof doc.globals!=='object')errors.push('全局参数缺失。');
for(const [k,v] of Object.entries(doc.globals||{}))if(typeof v==='number'&&(!Number.isFinite(v)||v<0||v>1e7))errors.push(`全局参数${k}无效。`);
return errors;
}
export function autoLayout(nodes,edges) {
if(validateGraph(nodes,edges).length)return nodes;
const levels={}, pending=new Set(nodes.map(n=>n.id));
for(let i=0;i<nodes.length;i++)for(const id of [...pending]) {
const parents=edges.filter(e=>e.target===id).map(e=>e.source);
if(parents.every(p=>levels[p]!=null)){levels[id]=parents.length?Math.max(...parents.map(p=>levels[p]))+1:0;pending.delete(id);}
}
const grouped={};nodes.forEach(n=>(grouped[levels[n.id]]??=[]).push(n.id));
return nodes.map(n=>({...n,position:{x:levels[n.id]*290,y:100+grouped[levels[n.id]].indexOf(n.id)*170}}));
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import '@xyflow/react/dist/style.css';
import './styles.css';
createRoot(document.getElementById('root')).render(<React.StrictMode><App /></React.StrictMode>);
+29
View File
@@ -0,0 +1,29 @@
import { DEFAULT_GLOBALS, autoLayout } from './engine.js';
export function nodeData(kind,label) {
return { kind,label,mode:'estimate',size:kind==='tts'?.6:kind==='asr'?.6:8,active:3,arch:'dense',hardware:'h200',cards:1,quant:'fp8',inputTokens:1024,outputTokens:128,reasoningTokens:0,contextGrowth:256,cacheRatio:0,speculative:1,maxSteps:1,expectedSteps:1,toolMs:0,finalOnly:true,streamingInput:true,durationMs:kind==='program'?80:kind==='merge'?20:kind==='asr'?100:kind==='output'?60:0,customTps:150,customFirstMs:100,customRtf:.25,resource:'',join:'all',target:true,outputType:'audio' };
}
const n=(id,kind,label,more={})=>({id,type:'stage',position:{x:0,y:0},data:{...nodeData(kind,label),...more}});
const e=(source,target,release='complete',more={})=>({id:`${source}-${target}`,source,target,type:'smoothstep',data:{release,tokens:8,...more}});
const preset=(name,description,nodes,edges,globals={})=>({name,description,version:1,nodes:autoLayout(nodes,edges),edges,globals:{...DEFAULT_GLOBALS,...globals}});
export const PRESETS = {
native:preset('原生全双工','输入与输出在同一模型内持续运行;另设独立的打断控制路径。',[
n('in','input','用户停止说话'),n('voice','speech','原生语音对话',{mode:'reference',profileId:'persona',size:7,customRtf:.45}),n('play','output','主回复播放'),n('cancel','program','打断检测与取消',{durationMs:80,target:false})
],[e('in','voice','first'),e('voice','play','first'),e('in','cancel','first',{networkMs:0,control:true})],{vadMs:0}),
cascade:preset('流式级联','边听边识别;文本积累8个token后启动TTS,输入与输出可重叠。',[
n('in','input','用户停止说话'),n('asr','asr','流式语音识别'),n('llm','llm','对话模型 · 8B'),n('tts','tts','流式TTS · 0.6B'),n('play','output','主回复播放'),n('cancel','program','并行打断控制',{durationMs:80,target:false})
],[e('in','asr','first'),e('asr','llm','complete'),e('llm','tts','chunk'),e('tts','play','first'),e('in','cancel','first',{networkMs:0,control:true})]),
parallel:preset('并行检索与汇总','检索和工具分支并行,汇总模型等到全部分支完成后组织回复。',[
n('in','input','用户停止说话'),n('asr','asr','流式语音识别'),n('plan','llm','意图与任务规划',{size:3,outputTokens:32}),n('search','program','知识检索',{durationMs:180}),n('tool','program','业务工具',{durationMs:350}),n('merge','llm','汇总模型 · 14B',{size:14,outputTokens:160}),n('tts','tts','流式TTS'),n('play','output','主回复播放')
],[e('in','asr','first'),e('asr','plan'),e('plan','search'),e('plan','tool'),e('search','merge'),e('tool','merge'),e('merge','tts','chunk'),e('tts','play','first')]),
agent:preset('Agent 多步循环','模型最多运行4步,每步输出96个token,步骤之间预留250ms工具等待。',[
n('in','input','用户停止说话'),n('asr','asr','流式语音识别'),n('agent','llm','工具Agent · 14B',{size:14,maxSteps:4,expectedSteps:2,outputTokens:96,reasoningTokens:64,contextGrowth:384,toolMs:250,finalOnly:true}),n('tts','tts','流式TTS'),n('play','output','最终结果播放')
],[e('in','asr','first'),e('asr','agent'),e('agent','tts','chunk'),e('tts','play','first')]),
blank:preset('空白流程','从左侧添加节点,也可用下方的连线表单连接。',[],[]),
};
export function makeDocument(key='cascade') { return structuredClone(PRESETS[key]); }
export function toDocument(nodes,edges,globals,name='自定义对话流程') {
return {version:1,name,createdAt:new Date().toISOString(),nodes:nodes.map(({id,type,position,data})=>({id,type,position,data})),edges:edges.map(({id,source,target,type,data})=>({id,source,target,type,data})),globals};
}
+9
View File
File diff suppressed because one or more lines are too long
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {simulateGraph,estimateNode,validateGraph,validateDocument,DEFAULT_GLOBALS,autoLayout} from '../src/engine.js';
import {makeDocument,nodeData,PRESETS} from '../src/presets.js';
import {RECORDS,SOURCES} from '../src/catalog.js';
const g={...DEFAULT_GLOBALS,networkMs:0,vadMs:0,queueMs:0,speechSeconds:1};
const node=(id,kind,extra={})=>({id,type:'stage',position:{x:0,y:0},data:{...nodeData(kind,id),...extra}});
const edge=(source,target,release='complete',extra={})=>({id:`${source}-${target}`,source,target,data:{release,...extra}});
const near=(a,b)=>assert.ok(Math.abs(a-b)<.00001,`${a} != ${b}`);
test('serial stages add their service times',()=>{
const n=[node('a','program',{durationMs:100}),node('b','program',{durationMs:200}),node('out','output',{durationMs:0,outputType:'text'})];
const s=simulateGraph(n,[edge('a','b'),edge('b','out')],g);
near(s.first,300);near(s.complete,300);
});
test('parallel all-join waits for longest branch rather than summing branches',()=>{
const n=[node('in','input'),node('a','program',{durationMs:100}),node('b','program',{durationMs:300}),node('join','merge',{durationMs:20}),node('out','output',{durationMs:40,outputType:'text'})];
const e=[edge('in','a'),edge('in','b'),edge('a','join'),edge('b','join'),edge('join','out')];
const s=simulateGraph(n,e,g);near(s.first,360);assert.ok(s.criticalNodes.has('b'));assert.ok(!s.criticalNodes.has('a'));
const any=n.map(x=>x.id==='join'?{...x,data:{...x.data,join:'any'}}:x);
near(simulateGraph(any,e,g).first,160);
});
test('streaming starts early, completion remains bounded by final upstream data',()=>{
const n=[node('llm','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:101}),node('tts','tts',{mode:'custom',customFirstMs:50,customRtf:.1}),node('out','output',{durationMs:0})];
const e=[edge('llm','tts','first'),edge('tts','out','first')],s=simulateGraph(n,e,g);
near(s.first,150);near(s.results.llm.end,1100);assert.ok(s.results.tts.end>=s.results.llm.end);assert.ok(s.complete>=s.first+1000);
const full=simulateGraph(n,[edge('llm','tts'),edge('tts','out','first')],g);
assert.ok(full.first>s.first);
});
test('token gate adds k-1 decoding intervals',()=>{
const n=[node('llm','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:101}),node('out','output',{durationMs:0,outputType:'text'})];
near(simulateGraph(n,[edge('llm','out','chunk',{tokens:8})],g).first,170);
});
test('loop maximum, inter-step tools, final-only output and context growth are explicit',()=>{
const d={...nodeData('llm','agent'),mode:'custom',customFirstMs:100,customTps:100,outputTokens:11,maxSteps:3,expectedSteps:2,toolMs:50,contextGrowth:20,inputTokens:100,finalOnly:true};
const r=estimateNode(d,g);near(r.end,700);near(r.first,600);assert.deepEqual(r.steps.map(x=>x.input),[100,120,140]);
const partial=estimateNode({...d,finalOnly:false},g);near(partial.first,100);near(partial.end,700);
const s=simulateGraph([node('agent','llm',d),node('out','output',{outputType:'text',durationMs:0})],[edge('agent','out','first')],{...g,loopPlan:'expected'});
near(s.first,350);near(s.maxFirst,600);near(s.maxComplete,700);near(s.results.agent.end,450);
});
test('hidden reasoning delays the first visible token',()=>{
const d={...nodeData('llm','reasoner'),mode:'custom',customFirstMs:100,customTps:100,outputTokens:11,reasoningTokens:50};
const r=estimateNode(d,g);near(r.first,600);near(r.end,700);
});
test('shared model resources serialize parallel requests and change critical dependency',()=>{
const n=[node('in','input'),node('a','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:41}),node('b','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:41}),node('out','output',{outputType:'text',durationMs:0})];
const e=[edge('in','a'),edge('in','b'),edge('a','out'),edge('b','out')];
near(simulateGraph(n,e,g).complete,500);
const s=simulateGraph(n,e,{...g,sharedGPU:true});near(s.complete,1000);near(s.results.b.resourceWait,500);
});
test('source text TTFT is not interpreted as first playable audio',()=>{
const r=estimateNode({...nodeData('speech','LFM3'),mode:'reference',profileId:'lfm3',size:2},g);
assert.notEqual(r.first,50);assert.ok(r.assumptions.some(x=>x.includes('只给首token')));
});
test('RTF-only reference uses its real factor without claiming first audio',()=>{
const r=estimateNode({...nodeData('speech','MiniCPM'),mode:'reference',profileId:'minicpm',size:9},g);
near(r.rtf,.21);assert.ok(r.assumptions.some(x=>x.includes('来源只给RTF')));assert.notEqual(r.first,210);
});
test('program scaling is global, and disconnected work is called out',()=>{
const n=[node('a','program',{durationMs:100}),node('orphan','program',{durationMs:20}),node('out','output',{durationMs:0,outputType:'text'})];
const s=simulateGraph(n,[edge('a','out')],{...g,programScale:2});near(s.first,200);assert.ok(s.warnings.some(x=>x.includes('orphan未连到主输出')));
});
test('audio component size and hardware settings affect engineering estimate',()=>{
const a=estimateNode({...nodeData('asr','ASR'),hardware:'h20',size:.6},g);
const b=estimateNode({...nodeData('asr','ASR'),hardware:'h20',size:2},g);
assert.ok(b.end>a.end);const t1=estimateNode({...nodeData('tts','TTS'),cards:1},g),t2=estimateNode({...nodeData('tts','TTS'),cards:2},g);assert.ok(t2.end<t1.end);
});
test('network and endpoint wait are counted once at their boundaries',()=>{
const n=[node('in','input'),node('out','output',{durationMs:60,outputType:'text'})];
near(simulateGraph(n,[edge('in','out','first')],{...g,vadMs:120,networkMs:20}).first,200);
});
test('invalid cycles and dangling edges are rejected including imports',()=>{
const n=[node('a','program'),node('b','program')];
assert.ok(validateGraph(n,[edge('a','b'),edge('b','a')]).some(x=>x.includes('环')));
assert.ok(validateGraph(n,[edge('a','missing')]).length);
assert.ok(validateDocument({version:1,nodes:n,edges:[],globals:{computeScale:-1}}).length);
});
test('memory over-capacity is surfaced rather than silently claiming deployability',()=>{
const r=estimateNode({...nodeData('llm','405B'),size:405,hardware:'4090',cards:1},g);
assert.ok(r.warnings.some(x=>x.includes('显存')));
});
test('source records have provenance and do not confuse the units of metrics',()=>{
const ids=new Set();
for(const r of RECORDS){assert.ok(!ids.has(r.id));ids.add(r.id);assert.ok(SOURCES[r.source]?.url.startsWith('https://'));assert.ok(r.scope);if(r.evidence==='unknown')assert.equal(r.value,null);else assert.ok(r.value>0);}
const q=RECORDS.find(r=>r.id==='q3-8');near(q.value,150.25);assert.equal(q.metric,'tps');
});
test('all presets compute finite targets and survive JSON round trip',()=>{
for(const key of Object.keys(PRESETS)){const d=makeDocument(key);assert.deepEqual(validateDocument(d),[]);const s=simulateGraph(d.nodes,d.edges,d.globals);assert.deepEqual(s.errors,[]);assert.ok(Number.isFinite(s.first));assert.ok(Number.isFinite(s.complete));assert.deepEqual(validateDocument(JSON.parse(JSON.stringify(d))),[]);assert.equal(autoLayout(d.nodes,d.edges).length,d.nodes.length);}
});