Build model latency reference and duplex dialogue flow estimator
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
test
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
+14
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
Generated
+1115
File diff suppressed because it is too large
Load Diff
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
@@ -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>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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
@@ -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}}));
|
||||||
|
}
|
||||||
@@ -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>);
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -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);}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user