Format the estimator and research UI source for review
This commit is contained in:
+12
-3
@@ -4,13 +4,22 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#102923" />
|
<meta name="theme-color" content="#102923" />
|
||||||
<meta name="description" content="比较不同参数规模的文本与实时语音模型时延,搭建支持并行、汇总和 Agent loop 的对话流程,估算首包、关键路径与完整耗时。" />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="比较不同参数规模的文本与实时语音模型时延,搭建支持并行、汇总和 Agent loop 的对话流程,估算首包、关键路径与完整耗时。"
|
||||||
|
/>
|
||||||
<meta name="release" content="K1412-LATENCY-LAB-20261106-v1" />
|
<meta name="release" content="K1412-LATENCY-LAB-20261106-v1" />
|
||||||
<meta property="og:title" content="模型时延实验室 · Latency Lab" />
|
<meta property="og:title" content="模型时延实验室 · Latency Lab" />
|
||||||
<meta property="og:description" content="从模型的毫秒,到对话的节奏。模型实测对照与全双工流程时延估算。" />
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="从模型的毫秒,到对话的节奏。模型实测对照与全双工流程时延估算。"
|
||||||
|
/>
|
||||||
<link rel="canonical" href="https://latency-lab.k1412.top" />
|
<link rel="canonical" href="https://latency-lab.k1412.top" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>模型时延实验室 · Latency Lab</title>
|
<title>模型时延实验室 · Latency Lab</title>
|
||||||
</head>
|
</head>
|
||||||
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-1
@@ -15,5 +15,7 @@
|
|||||||
"react": "19.3.1",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.1"
|
"react-dom": "19.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": { "vite": "8.3.6" }
|
"devDependencies": {
|
||||||
|
"vite": "8.3.6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2565
-106
File diff suppressed because it is too large
Load Diff
+641
-71
@@ -1,85 +1,655 @@
|
|||||||
export const CUTOFF = '2026-11-06';
|
export const CUTOFF = "2026-11-06";
|
||||||
export const RELEASE = 'K1412-LATENCY-LAB-20261106-v1';
|
export const RELEASE = "K1412-LATENCY-LAB-20261106-v1";
|
||||||
export const SOURCES = {
|
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的行,近似生成速度。不是跨硬件最佳成绩。' },
|
qwen3: {
|
||||||
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 不可混用。' },
|
title: "Qwen3 官方速度基准",
|
||||||
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。' },
|
publisher: "Qwen",
|
||||||
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。' },
|
date: "2025 · 在线文档",
|
||||||
swift: { title: 'SwiftSpec 论文', publisher: 'ByteDance Seed 等', date: '2025-06-12', url: 'https://arxiv.org/html/2506.11309v1', note: '单请求低时延优化,8张 Hopper GPU、异步推测解码。速度随任务和接受率变化。' },
|
url: "https://github.com/QwenLM/Qwen3/blob/main/docs/source/getting_started/speed_benchmark.md",
|
||||||
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 可用性承诺。' },
|
note: "H20 96GB、SGLang 0.4.6、batch=1。原始 speed=(输入+输出)/总时间;本表只取输入1、输出2048的行,近似生成速度。不是跨硬件最佳成绩。",
|
||||||
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,属于条件化性能点。' },
|
glm: {
|
||||||
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与放宽接受验证;不能视为输出分布不变的免费加速。' },
|
title: "GLM-4 官方推理数据",
|
||||||
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更快,但音质有变化。' },
|
publisher: "Z.ai",
|
||||||
moshi: { title: 'Moshi 技术论文', publisher: 'Kyutai', date: '2024 · 论文v2', url: 'https://arxiv.org/html/2410.00037v2', note: '160ms为架构理论时延,论文称实际200ms。连续流延迟与一问一答的完整任务耗时不同。' },
|
date: "2025 · 在线文档",
|
||||||
persona: { title: 'PersonaPlex 官方模型卡', publisher: 'NVIDIA', date: '2026-01 · v1', url: 'https://huggingface.co/nvidia/personaplex-7b-v1', note: 'A100 80GB、PyTorch、FullDuplexBench。接续170ms;打断停止240ms是不同指标。主要为英语。' },
|
url: "https://github.com/zai-org/GLM-4/blob/main/inference/README.md",
|
||||||
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检查点混用。' },
|
note: "H100 80GB,具体卡数和输入长度逐行列出。短输入与长输入的 TTFT 不可混用。",
|
||||||
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型号。' },
|
groq8: {
|
||||||
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。论文只说扩展到数千亿参数,未公开两变体精确尺寸;部署资源不同,不能据此比较单位算力效率。' },
|
title: "Groq · Llama 3.1 8B",
|
||||||
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。' },
|
publisher: "Groq",
|
||||||
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是首段可播放音频,也未给出该数值的完整硬件条件。' },
|
date: "在线文档 · 查询于截止日",
|
||||||
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响应。' },
|
url: "https://console.groq.com/docs/model/llama-3.1-8b-instant",
|
||||||
live: { title: 'GPT-Live 官方文档', publisher: 'OpenAI', date: '在线文档 · 截止日查阅', url: 'https://developers.openai.com/api/docs/guides/live', note: '原生全双工语音与后台任务委托;没有可用于按参数规模比较的公开精确尺寸和统一时延基准。后台任务完成时延需要另算。' },
|
note: "服务商标称 token speed,未提供本地可复现的完整负载条件;不是 SLA。",
|
||||||
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。模型尺寸和统一的用户停说至首音频时延缺少公开可比数据。' },
|
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 });
|
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 = [
|
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'}),
|
[0.6, 458.03],
|
||||||
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'}),
|
[1.7, 333.9],
|
||||||
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'}),
|
[4, 200.61],
|
||||||
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'}),
|
[8, 150.25],
|
||||||
...[[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'})),
|
[14, 97.11],
|
||||||
row('groq8','Llama 3.1 8B · Groq','text',8,'tps',560,'groq8',{hardware:'Groq LPU 服务',precision:'服务商数值格式',scope:'服务商标称生成速度',date:'查询于2026-11-06',note:'无统一TTFT和完整并发条件。'}),
|
[32, 46.17],
|
||||||
row('groq70','Llama 3.3 70B · Groq','text',70,'tps',280,'groq70',{hardware:'Groq LPU 服务',scope:'服务商标称生成速度',date:'查询于2026-11-06'}),
|
].map(([s, v]) =>
|
||||||
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(`q3-${s}`, `Qwen3-${s}B`, "text", s, "tps", v, "qwen3", {
|
||||||
row('cb70','Llama 3.1 70B · Cerebras','text',70,'tps',2100,'cb70',{hardware:'Cerebras 晶圆级系统',precision:'16位 + 推测解码',scope:'历史每用户生成结果',date:'2024-10-24',historical:true}),
|
hardware: "1×H20 96GB",
|
||||||
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}),
|
precision: "FP8",
|
||||||
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'}),
|
input: 1,
|
||||||
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;不能当成自然负载的无条件实测速率。'}),
|
output: 2048,
|
||||||
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:'配置包含放宽接受验证,输出分布保持性不能由该速度证明。'}),
|
concurrency: 1,
|
||||||
...[[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,未计公网与播放缓冲;尺寸仅语言主干。'})),
|
scope: "纯文本生成近似",
|
||||||
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;与较大块的语音质量不同,不能只按速度排名。'}),
|
date: "2025",
|
||||||
row('llama-omni','LLaMA-Omni','speech',8,'audio',346.73,'omni2',{params:'8B 主干',hardware:'1×L40',scope:'论文复测首段语音',date:'2025-05-05'}),
|
note: "极短输入,原指标含1个输入token。未报告首token;不等同长上下文速度。",
|
||||||
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("q3-32-q4", "Qwen3-32B", "text", 32, "tps", 47.67, "qwen3", {
|
||||||
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接续指标;不代表回答正确率。'}),
|
hardware: "1×H20 96GB",
|
||||||
row('persona-interrupt','PersonaPlex · 打断','speech',7,'interrupt',240,'persona',{params:'7B 主干',hardware:'A100 80GB',scope:'用户打断→停止说话',date:'2026-01-15',duplex:true}),
|
precision: "AWQ INT4",
|
||||||
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首音频;未给出本条件下的可比首音频数。'}),
|
input: 1,
|
||||||
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不同检查点合并。'}),
|
output: 2048,
|
||||||
...[[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'})),
|
concurrency: 1,
|
||||||
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'}),
|
scope: "纯文本生成近似",
|
||||||
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'}),
|
date: "2025",
|
||||||
...[[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("q3-30", "Qwen3-30B-A3B", "text", 30, "tps", 155.55, "qwen3", {
|
||||||
row('lfm3','LFM3-Audio-2B','speech',2,'ttft',50,'lfm3',{scope:'流式输入后的首token下限',date:'2026-10-27',note:'官方“低至”数值,硬件条件未完整说明;没有可比的首音频值。'}),
|
active: 3,
|
||||||
...[[.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'})),
|
hardware: "1×H20 96GB",
|
||||||
row('gpt-live','GPT-Live','speech',null,'unknown',null,'live',{scope:'全双工API · 统一时延未知',date:'查询于2026-11-06',duplex:true,evidence:'unknown'}),
|
precision: "FP8",
|
||||||
row('gemini-live','Gemini Live','speech',null,'unknown',null,'gemini',{scope:'原生音频API · 统一时延未知',date:'查询于2026-11-06',evidence:'unknown'}),
|
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.8, 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: "官方“低至”数值,硬件条件未完整说明;没有可比的首音频值。",
|
||||||
|
}),
|
||||||
|
...[
|
||||||
|
[0.6, 1, 97, 0.288],
|
||||||
|
[1.7, 1, 101, 0.313],
|
||||||
|
[0.6, 3, 179, 0.338],
|
||||||
|
[1.7, 3, 195, 0.363],
|
||||||
|
[0.6, 6, 299, 0.434],
|
||||||
|
[1.7, 6, 333, 0.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 EVIDENCE = {
|
||||||
export const METRICS = { tps:'输出速度', ttft:'首token', audio:'首音频 / 接续', interrupt:'打断停止', rtf:'实时因子', unknown:'未报告' };
|
reported: "公开报告",
|
||||||
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`;
|
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 = [
|
export const HARDWARE = [
|
||||||
{id:'h20',name:'H20 96GB',factor:1,bandwidth:4, memory:96},
|
{ 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: "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: "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: "b200", name: "B200 180GB", factor: 2.4, bandwidth: 8, memory: 180 },
|
||||||
{id:'gb300',name:'GB300 288GB',factor:3.1,bandwidth:8,memory:288},
|
{ 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: "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},
|
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,
|
||||||
];
|
];
|
||||||
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.
|
// These are engineering defaults, deliberately separate from the source records above.
|
||||||
export const CALIBRATION = {
|
export const CALIBRATION = {
|
||||||
denseAnchors: [[0.6,458.03],[1.7,333.90],[4,200.61],[8,150.25],[14,97.11],[32,46.17]],
|
denseAnchors: [
|
||||||
|
[0.6, 458.03],
|
||||||
|
[1.7, 333.9],
|
||||||
|
[4, 200.61],
|
||||||
|
[8, 150.25],
|
||||||
|
[14, 97.11],
|
||||||
|
[32, 46.17],
|
||||||
|
],
|
||||||
prefillTokensPerSecondAt8B: 18000,
|
prefillTokensPerSecondAt8B: 18000,
|
||||||
prefillFloorMs: 14,
|
prefillFloorMs: 14,
|
||||||
quantRateFactors: { bf16: 0.62, fp8: 1, int4: 1.06 },
|
quantRateFactors: { bf16: 0.62, fp8: 1, int4: 1.06 },
|
||||||
|
|||||||
+527
-150
@@ -1,238 +1,615 @@
|
|||||||
import { RECORDS, HARDWARE, CALIBRATION } from './catalog.js';
|
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 DEFAULT_GLOBALS = {
|
||||||
export const KINDS = { input:'语音输入', asr:'流式识别 / 编码', llm:'文本模型 / Agent', speech:'原生语音模型', tts:'流式语音合成', program:'程序 / 工具', merge:'分支汇总', output:'音频播放 / 目标输出' };
|
computeScale: 1,
|
||||||
export const MODEL_KINDS = ['llm','speech','tts','asr'];
|
programScale: 1,
|
||||||
const finite = (v,f=0) => Number.isFinite(Number(v)) ? Number(v) : f;
|
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 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]));
|
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 ms = (v) =>
|
||||||
export const getRecord = id => recordMap.get(id);
|
!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) {
|
function interpolate(size) {
|
||||||
const a=CALIBRATION.denseAnchors, s=Math.max(.1,size);
|
const a = CALIBRATION.denseAnchors,
|
||||||
if(s<=a[0][0]) return a[0][1]*(a[0][0]/s)**.2;
|
s = Math.max(0.1, size);
|
||||||
for(let i=1;i<a.length;i++) if(s<=a[i][0]) {
|
if (s <= a[0][0]) return a[0][1] * (a[0][0] / s) ** 0.2;
|
||||||
const p=(Math.log(s)-Math.log(a[i-1][0]))/(Math.log(a[i][0])-Math.log(a[i-1][0]));
|
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 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;
|
return (a.at(-1)[1] * a.at(-1)[0]) / s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function estimateNode(data, globals=DEFAULT_GLOBALS, forceMax=false) {
|
export function estimateNode(
|
||||||
const g={...DEFAULT_GLOBALS,...globals}, d=data, kind=d.kind;
|
data,
|
||||||
const scale=Math.max(.01,num(g.computeScale,1,100)), progScale=Math.max(.01,num(g.programScale,1,100));
|
globals = DEFAULT_GLOBALS,
|
||||||
const model=MODEL_KINDS.includes(kind), rec=getRecord(d.profileId);
|
forceMax = false,
|
||||||
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 g = { ...DEFAULT_GLOBALS, ...globals },
|
||||||
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;
|
d = data,
|
||||||
const quant=d.quant||'fp8', quantFactor=CALIBRATION.quantRateFactors[quant]||1;
|
kind = d.kind;
|
||||||
const tpGain= Math.min(4,cards**.6)/(1+Math.log2(cards)*.08);
|
const scale = Math.max(0.01, num(g.computeScale, 1, 100)),
|
||||||
|
progScale = Math.max(0.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(0.1, num(d.size, 8, 1e5)),
|
||||||
|
active =
|
||||||
|
d.arch === "moe"
|
||||||
|
? Math.max(0.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 ** 0.6) / (1 + Math.log2(cards) * 0.08);
|
||||||
const audioHardware = hw.factor * tpGain * quantFactor;
|
const audioHardware = hw.factor * tpGain * quantFactor;
|
||||||
const speculative = Math.max(1, num(d.speculative, 1, 8));
|
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 rate = Math.max(
|
||||||
let assumptions=[], warnings=[], sourceId=null;
|
1,
|
||||||
|
(interpolate(active) * hw.factor * quantFactor * tpGain * speculative) /
|
||||||
|
(d.arch === "moe" ? 1.25 : 1),
|
||||||
|
);
|
||||||
|
let assumptions = [],
|
||||||
|
warnings = [],
|
||||||
|
sourceId = null;
|
||||||
if (ref) {
|
if (ref) {
|
||||||
sourceId = ref.source;
|
sourceId = ref.source;
|
||||||
const rr=ref.metric==='tps'?ref.value:ref.tps;
|
const rr = ref.metric === "tps" ? ref.value : ref.tps;
|
||||||
if (rr) rate = rr;
|
if (rr) rate = rr;
|
||||||
assumptions.push('采用选中来源的部署配置;硬件/量化外推参数不参与该参考速率。');
|
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.metric === "tps" && !ref.ttft)
|
||||||
if(ref.historical) warnings.push('参考是历史性能点,不能保证当前API仍提供同样服务。');
|
assumptions.push("来源未给首token,首token另用工程模型估算。");
|
||||||
if(kind==='llm'&&ref.input&&num(d.inputTokens,1024)!==ref.input) warnings.push(`来源输入${ref.input} token,当前输入${num(d.inputTokens,1024)} token;首token或生成速度的迁移包含额外假设。`);
|
if (ref.evidence === "theoretical")
|
||||||
if(ref.inputSeconds&&ref.inputSeconds!==num(g.inputSeconds,3)) warnings.push(`来源使用${ref.inputSeconds}秒输入,当前为${num(g.inputSeconds,3)}秒;首包参考未由音频长度重新标定。`);
|
warnings.push("参考值是理论口径,计算结果仍是方案估计。");
|
||||||
} else if(mode==='custom') {
|
if (ref.evidence === "conditional")
|
||||||
rate=Math.max(.1,num(d.customTps,100,1e6));
|
warnings.push("参考速率依赖特定接受率或验证条件。");
|
||||||
assumptions.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(0.1, num(d.customTps, 100, 1e6));
|
||||||
|
assumptions.push("使用手动填写的时延和速度。");
|
||||||
} else {
|
} else {
|
||||||
assumptions.push('速度由H20/Qwen3锚点按参数规模、硬件系数、量化和卡数外推。');
|
assumptions.push(
|
||||||
if(active>32||cards>1||hw.id!=='h20'||d.arch==='moe') warnings.push('该硬件/规模组合包含外推,请用实测校准。');
|
"速度由H20/Qwen3锚点按参数规模、硬件系数、量化和卡数外推。",
|
||||||
if(speculative>1) warnings.push(`推测解码${speculative}倍为手动假设,接受率不足时不成立。`);
|
);
|
||||||
|
if (active > 32 || cards > 1 || hw.id !== "h20" || d.arch === "moe")
|
||||||
|
warnings.push("该硬件/规模组合包含外推,请用实测校准。");
|
||||||
|
if (speculative > 1)
|
||||||
|
warnings.push(`推测解码${speculative}倍为手动假设,接受率不足时不成立。`);
|
||||||
}
|
}
|
||||||
rate /= scale;
|
rate /= scale;
|
||||||
const maxSteps = Math.max(1, Math.round(num(d.maxSteps, 1, 100)));
|
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 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 stepsCount = model ? plannedSteps : 1;
|
||||||
const outputTokens = Math.max(1, Math.round(num(d.outputTokens, 128, 1e6)));
|
const outputTokens = Math.max(1, Math.round(num(d.outputTokens, 128, 1e6)));
|
||||||
const hiddenTokens = Math.round(num(d.reasoningTokens, 0, 1e6));
|
const hiddenTokens = Math.round(num(d.reasoningTokens, 0, 1e6));
|
||||||
const inputTokens = Math.round(num(d.inputTokens, 1024, 1e6));
|
const inputTokens = Math.round(num(d.inputTokens, 1024, 1e6));
|
||||||
const growth = Math.round(num(d.contextGrowth, 256, 1e6));
|
const growth = Math.round(num(d.contextGrowth, 256, 1e6));
|
||||||
const cached=Math.min(.99,num(d.cacheRatio,0,1));
|
const cached = Math.min(0.99, num(d.cacheRatio, 0, 1));
|
||||||
const toolMs = num(d.toolMs, 0) * progScale;
|
const toolMs = num(d.toolMs, 0) * progScale;
|
||||||
const q = num(g.queueMs, 0);
|
const q = num(g.queueMs, 0);
|
||||||
let first=0,end=0,rtf=0,tail=0,steps=[],busy=0;
|
let first = 0,
|
||||||
if(kind==='input') { first=end=num(g.vadMs,120); assumptions=['时间原点为用户停止说话;此前音频采集时间不计入等待。']; }
|
end = 0,
|
||||||
else if(kind==='program'||kind==='merge') { first=end=num(d.durationMs,40)*progScale; assumptions=['程序节点耗时为可编辑的单次预算。']; }
|
rtf = 0,
|
||||||
else if(kind==='output') { first=num(d.durationMs,g.playbackBufferMs); end=first+(d.outputType==='text'?0:num(g.speechSeconds,4)*1000); assumptions=['播放完成包含自然语音时长;上游供给过慢会推迟结束。']; }
|
tail = 0,
|
||||||
else if(model) {
|
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 audioMs = num(g.speechSeconds, 4) * 1000;
|
||||||
const refFirst=ref ? (ref.metric==='audio'||ref.metric==='ttft'?ref.value:ref.ttft) : null;
|
const refFirst = ref
|
||||||
const refRtf=ref?.metric==='rtf'?ref.value:ref?.rtf;
|
? 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++) {
|
for (let i = 0; i < stepsCount; i++) {
|
||||||
const start = end + (i ? toolMs : 0);
|
const start = end + (i ? toolMs : 0);
|
||||||
const tokens = inputTokens + i * growth;
|
const tokens = inputTokens + i * growth;
|
||||||
const uncached=i===0?tokens*(1-cached):inputTokens*(1-cached)+i*growth;
|
const uncached =
|
||||||
const prefillRate=CALIBRATION.prefillTokensPerSecondAt8B*(8/active)**.65*hw.factor*tpGain;
|
i === 0
|
||||||
let prefill=(CALIBRATION.prefillFloorMs+1000*uncached/prefillRate)*scale;
|
? tokens * (1 - cached)
|
||||||
|
: inputTokens * (1 - cached) + i * growth;
|
||||||
|
const prefillRate =
|
||||||
|
CALIBRATION.prefillTokensPerSecondAt8B *
|
||||||
|
(8 / active) ** 0.65 *
|
||||||
|
hw.factor *
|
||||||
|
tpGain;
|
||||||
|
let prefill =
|
||||||
|
(CALIBRATION.prefillFloorMs + (1000 * uncached) / prefillRate) * scale;
|
||||||
if (refFirst != null) {
|
if (refFirst != null) {
|
||||||
prefill = refFirst * scale;
|
prefill = refFirst * scale;
|
||||||
if(kind==='llm'&&ref.input>1) prefill*=Math.max(.15,uncached/ref.input);
|
if (kind === "llm" && ref.input > 1)
|
||||||
|
prefill *= Math.max(0.15, uncached / ref.input);
|
||||||
}
|
}
|
||||||
if(mode==='custom') prefill=num(d.customFirstMs,100)*scale;
|
if (mode === "custom") prefill = num(d.customFirstMs, 100) * scale;
|
||||||
let sf=0,se=0,srtf=0;
|
let sf = 0,
|
||||||
if(kind==='llm') {
|
se = 0,
|
||||||
sf=prefill+hiddenTokens/rate*1000;
|
srtf = 0;
|
||||||
se=sf+(outputTokens-1)/rate*1000;
|
if (kind === "llm") {
|
||||||
|
sf = prefill + (hiddenTokens / rate) * 1000;
|
||||||
|
se = sf + ((outputTokens - 1) / rate) * 1000;
|
||||||
tail = 1000 / rate;
|
tail = 1000 / rate;
|
||||||
} else if(kind==='speech') {
|
} else if (kind === "speech") {
|
||||||
// A text TTFT (e.g. LFM3's 50ms) is deliberately not reused as first audio.
|
// 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);
|
sf =
|
||||||
if(ref?.metric==='ttft') assumptions.push('来源只给首token;本节点首音频使用工程假设,未把二者等同。');
|
mode === "custom"
|
||||||
if(ref?.metric==='rtf') assumptions.push('来源只给RTF,首音频另按工程假设;0.21 RTF不等于210ms首包。');
|
? num(d.customFirstMs, 300) * scale
|
||||||
srtf=(mode==='custom'?num(d.customRtf,.45,100):refRtf??(0.24+active*.025)/audioHardware)*scale;
|
: ref?.metric === "audio"
|
||||||
if(ref&&refRtf==null) assumptions.push('来源未报告RTF,持续生成速度采用工程假设。');
|
? 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, 0.45, 100)
|
||||||
|
: (refRtf ?? (0.24 + active * 0.025) / audioHardware)) * scale;
|
||||||
|
if (ref && refRtf == null)
|
||||||
|
assumptions.push("来源未报告RTF,持续生成速度采用工程假设。");
|
||||||
se = sf + audioMs * srtf;
|
se = sf + audioMs * srtf;
|
||||||
tail = 80 * srtf;
|
tail = 80 * srtf;
|
||||||
} else if(kind==='tts') {
|
} 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;
|
sf =
|
||||||
srtf=(mode==='custom'?num(d.customRtf,.18,100):refRtf??(.12+size*.035)/audioHardware)*scale;
|
mode === "custom"
|
||||||
se=sf+audioMs*srtf; tail=80*srtf;
|
? num(d.customFirstMs, 100) * scale
|
||||||
} else if(kind==='asr') {
|
: ref?.metric === "audio"
|
||||||
|
? ref.value * scale
|
||||||
|
: ((70 + Math.sqrt(size) * 35) / audioHardware) * scale;
|
||||||
|
srtf =
|
||||||
|
(mode === "custom"
|
||||||
|
? num(d.customRtf, 0.18, 100)
|
||||||
|
: (refRtf ?? (0.12 + size * 0.035) / audioHardware)) * scale;
|
||||||
|
se = sf + audioMs * srtf;
|
||||||
|
tail = 80 * srtf;
|
||||||
|
} else if (kind === "asr") {
|
||||||
const seconds = num(g.inputSeconds, 3);
|
const seconds = num(g.inputSeconds, 3);
|
||||||
sf=(mode==='custom'?num(d.customFirstMs,100):num(d.durationMs,100)*(size/.6)**.5/audioHardware)*scale;
|
sf =
|
||||||
srtf=(mode==='custom'?num(d.customRtf,.15,100):.15*(size/.6)**.6/audioHardware)*scale;
|
(mode === "custom"
|
||||||
|
? num(d.customFirstMs, 100)
|
||||||
|
: (num(d.durationMs, 100) * (size / 0.6) ** 0.5) / audioHardware) *
|
||||||
|
scale;
|
||||||
|
srtf =
|
||||||
|
(mode === "custom"
|
||||||
|
? num(d.customRtf, 0.15, 100)
|
||||||
|
: (0.15 * (size / 0.6) ** 0.6) / audioHardware) * scale;
|
||||||
se = sf + (d.streamingInput === false ? seconds * 1000 * srtf : 0);
|
se = sf + (d.streamingInput === false ? seconds * 1000 * srtf : 0);
|
||||||
sf=se; tail=se;
|
sf = se;
|
||||||
assumptions.push(d.streamingInput===false?'整段识别,计入全段处理时间。':'用户说话时持续处理,仅将尾包处理计入停说后的等待。');
|
tail = se;
|
||||||
if(mode!=='custom')assumptions.push('尾包基线按0.6B/H20定义,并随规模、硬件与量化外推。');
|
assumptions.push(
|
||||||
|
d.streamingInput === false
|
||||||
|
? "整段识别,计入全段处理时间。"
|
||||||
|
: "用户说话时持续处理,仅将尾包处理计入停说后的等待。",
|
||||||
|
);
|
||||||
|
if (mode !== "custom")
|
||||||
|
assumptions.push(
|
||||||
|
"尾包基线按0.6B/H20定义,并随规模、硬件与量化外推。",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
sf+=q; se+=q;
|
sf += q;
|
||||||
steps.push({step:i+1,start,first:start+sf,end:start+se,input:tokens,output:outputTokens,reasoning:hiddenTokens,toolWait:i?toolMs:0});
|
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;
|
if (i === 0) first = start + sf;
|
||||||
end = start + se;
|
end = start + se;
|
||||||
busy += se;
|
busy += se;
|
||||||
rtf = Math.max(rtf, srtf);
|
rtf = Math.max(rtf, srtf);
|
||||||
}
|
}
|
||||||
if (d.finalOnly !== false && steps.length) first = steps.at(-1).first;
|
if (d.finalOnly !== false && steps.length) first = steps.at(-1).first;
|
||||||
if(kind==='llm') rtf=busy/Math.max(1000,audioMs);
|
if (kind === "llm") rtf = busy / Math.max(1000, audioMs);
|
||||||
}
|
}
|
||||||
if(!ref&&mode!=='custom'&&model) {
|
if (!ref && mode !== "custom" && model) {
|
||||||
const bytesPerWeight=quant==='bf16'?2:quant==='int4'?.55:1.05;
|
const bytesPerWeight =
|
||||||
|
quant === "bf16" ? 2 : quant === "int4" ? 0.55 : 1.05;
|
||||||
const weightsGB = size * bytesPerWeight;
|
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,超出所选显存;数值不能视为可部署结果。`);
|
if (weightsGB + Math.max(2, weightsGB * 0.08) > hw.memory * cards)
|
||||||
|
warnings.push(
|
||||||
|
`估计权重与最小运行余量约${Math.round(weightsGB + Math.max(2, weightsGB * 0.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'?'手动校准':'工程外推' };
|
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) {
|
export function validateGraph(nodes, edges) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
if(!Array.isArray(nodes)||!Array.isArray(edges)) return ['流程必须包含节点和连线数组。'];
|
if (!Array.isArray(nodes) || !Array.isArray(edges))
|
||||||
if(nodes.length>100||edges.length>300) return ['最多支持100个节点和300条连线。'];
|
return ["流程必须包含节点和连线数组。"];
|
||||||
|
if (nodes.length > 100 || edges.length > 300)
|
||||||
|
return ["最多支持100个节点和300条连线。"];
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if(!n||typeof n.id!=='string'||!n.id||ids.has(n.id)) { errors.push('节点ID缺失或重复。'); continue; }
|
if (!n || typeof n.id !== "string" || !n.id || ids.has(n.id)) {
|
||||||
|
errors.push("节点ID缺失或重复。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ids.add(n.id);
|
ids.add(n.id);
|
||||||
if (!n.data || !KINDS[n.data.kind]) errors.push(`节点${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('节点坐标必须是有限数值。');
|
if (!Number.isFinite(n.position?.x) || !Number.isFinite(n.position?.y))
|
||||||
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}超出范围。`);
|
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,[]]));
|
const indeg = new Map(nodes.map((n) => [n.id, 0])),
|
||||||
|
adj = new Map(nodes.map((n) => [n.id, []]));
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
if(!ids.has(e.source)||!ids.has(e.target)) { errors.push('连线指向了不存在的节点。'); continue; }
|
if (!ids.has(e.source) || !ids.has(e.target)) {
|
||||||
if(e.source===e.target) errors.push('请用节点内的loop步数表示循环。');
|
errors.push("连线指向了不存在的节点。");
|
||||||
indeg.set(e.target,indeg.get(e.target)+1); adj.get(e.source).push(e.target);
|
continue;
|
||||||
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=[];
|
if (e.source === e.target) errors.push("请用节点内的loop步数表示循环。");
|
||||||
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); } }
|
indeg.set(e.target, indeg.get(e.target) + 1);
|
||||||
if(order.length!==nodes.length) errors.push('流程存在环。图上使用有向无环结构,Agent循环放在模型节点内。');
|
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)];
|
return [...new Set(errors)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedule(nodes, edges, g, forceMax = false) {
|
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 results = {},
|
||||||
const byId=new Map(nodes.map(n=>[n.id,n]));
|
indeg = new Map(nodes.map((n) => [n.id, 0])),
|
||||||
edges.forEach(e=>{indeg.set(e.target,indeg.get(e.target)+1);outgoing.get(e.source).push(e);});
|
outgoing = new Map(nodes.map((n) => [n.id, []]));
|
||||||
const ready=nodes.filter(n=>!indeg.get(n.id)).map(n=>n.id), resources=new Map(), order=[];
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||||
while(ready.length) {
|
edges.forEach((e) => {
|
||||||
const id=ready.shift(), n=byId.get(id), d=n.data, cost=estimateNode(d,g,forceMax), incoming=edges.filter(e=>e.target===id);
|
indeg.set(e.target, indeg.get(e.target) + 1);
|
||||||
const arrivals=incoming.map(e=>{
|
outgoing.get(e.source).push(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;
|
const ready = nodes.filter((n) => !indeg.get(n.id)).map((n) => n.id),
|
||||||
let start=selected?.at||0, parent=selected, resourceWait=0;
|
resources = new Map(),
|
||||||
const resource=cost.model?(d.resource?.trim()||(g.sharedGPU?'shared-gpu':'')):'';
|
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;
|
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}; }
|
if (prev && prev.end > start) {
|
||||||
let first=start+cost.first,end=start+cost.end,endParent=parent;
|
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.
|
// 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)) {
|
for (const a of arrivals)
|
||||||
|
if (a.stream && (d.join !== "any" || a === selected)) {
|
||||||
const floor = results[a.id].end + a.network + cost.tail;
|
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 (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);
|
}
|
||||||
|
if (d.kind === "output" && d.outputType !== "text")
|
||||||
|
end = Math.max(end, first + num(g.speechSeconds, 4) * 1000);
|
||||||
first = Math.min(first, end);
|
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}))};
|
results[id] = {
|
||||||
if(resource)resources.set(resource,{end,id}); order.push(id);
|
...cost,
|
||||||
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);}
|
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 targets = nodes.filter(
|
||||||
const audioTargets=targets.filter(n=>n.data.outputType!=='text');
|
(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 firstTargets = audioTargets.length ? audioTargets : terminals;
|
||||||
const firstTarget=firstTargets.reduce((a,n)=>!a||results[n.id].first<results[a.id].first?n:a,null);
|
const firstTarget = firstTargets.reduce(
|
||||||
const completeTarget=terminals.reduce((a,n)=>!a||results[n.id].end>results[a.id].end?n:a,null);
|
(a, n) => (!a || results[n.id].first < results[a.id].first ? n : a),
|
||||||
const criticalNodes=new Set(),criticalEdges=new Set(),completionNodes=new Set();
|
null,
|
||||||
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);
|
const completeTarget = terminals.reduce(
|
||||||
if(completeTarget)trace(completeTarget.id,'end',completionNodes,new Set(),false);
|
(a, n) => (!a || results[n.id].end > results[a.id].end ? n : a),
|
||||||
const loads=new Map();
|
null,
|
||||||
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 criticalNodes = new Set(),
|
||||||
const warnings=Object.values(results).flatMap(r=>r.warnings.map(w=>`${r.title}:${w}`));
|
criticalEdges = new Set(),
|
||||||
if(targets.length) {
|
completionNodes = new Set();
|
||||||
const connected=new Set(targets.map(n=>n.id)),queue=[...connected];
|
function trace(id, event, set, seen = new Set(), captureEdges = true) {
|
||||||
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);}}
|
if (!id || seen.has(`${id}:${event}`)) return;
|
||||||
for(const n of nodes)if(!connected.has(n.id)&&n.data.target!==false)warnings.push(`${n.data.label}未连到主输出,当前不计入主结果耗时。`);
|
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(!targets.length)warnings.push('没有主输出节点,当前结果取叶子节点;添加播放/目标输出可明确测量终点。');
|
}
|
||||||
if(nodes.some(n=>n.data.join==='any'&&edges.filter(e=>e.target===n.id).length>1))warnings.push('“任一就绪”假设最早分支结果可用;未建模失败重试与答案正确性。');
|
if (firstTarget) trace(firstTarget.id, "first", criticalNodes);
|
||||||
if(rtf>1)warnings.push('持续负载RTF超过1,所选资源/输出预算可能跟不上实时音频。');
|
if (completeTarget)
|
||||||
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};
|
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) {
|
export function simulateGraph(nodes, edges, globals = DEFAULT_GLOBALS) {
|
||||||
const errors = validateGraph(nodes, edges);
|
const errors = validateGraph(nodes, edges);
|
||||||
if (errors.length) return { errors };
|
if (errors.length) return { errors };
|
||||||
const g = { ...DEFAULT_GLOBALS, ...globals };
|
const g = { ...DEFAULT_GLOBALS, ...globals };
|
||||||
const base=schedule(nodes,edges,g), max=schedule(nodes,edges,g,true);
|
const base = schedule(nodes, edges, g),
|
||||||
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});
|
max = schedule(nodes, edges, g, true);
|
||||||
return {...base,errors:[],maxFirst:max.first,maxComplete:max.complete,range:[low.first,high.first],completeRange:[low.complete,high.complete]};
|
const low = schedule(nodes, edges, {
|
||||||
|
...g,
|
||||||
|
computeScale: g.computeScale * 0.75,
|
||||||
|
programScale: g.programScale * 0.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) {
|
export function validateDocument(doc) {
|
||||||
if(!doc||typeof doc!=='object'||doc.version!==1)return ['不是受支持的流程文件(version=1)。'];
|
if (!doc || typeof doc !== "object" || doc.version !== 1)
|
||||||
|
return ["不是受支持的流程文件(version=1)。"];
|
||||||
const errors = validateGraph(doc.nodes, doc.edges);
|
const errors = validateGraph(doc.nodes, doc.edges);
|
||||||
if(!doc.globals||typeof doc.globals!=='object')errors.push('全局参数缺失。');
|
if (!doc.globals || typeof doc.globals !== "object")
|
||||||
for(const [k,v] of Object.entries(doc.globals||{}))if(typeof v==='number'&&(!Number.isFinite(v)||v<0||v>1e7))errors.push(`全局参数${k}无效。`);
|
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;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function autoLayout(nodes, edges) {
|
export function autoLayout(nodes, edges) {
|
||||||
if (validateGraph(nodes, edges).length) return nodes;
|
if (validateGraph(nodes, edges).length) return nodes;
|
||||||
const levels={}, pending=new Set(nodes.map(n=>n.id));
|
const levels = {},
|
||||||
for(let i=0;i<nodes.length;i++)for(const id of [...pending]) {
|
pending = new Set(nodes.map((n) => n.id));
|
||||||
const parents=edges.filter(e=>e.target===id).map(e=>e.source);
|
for (let i = 0; i < nodes.length; i++)
|
||||||
if(parents.every(p=>levels[p]!=null)){levels[id]=parents.length?Math.max(...parents.map(p=>levels[p]))+1:0;pending.delete(id);}
|
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}}));
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from "react-dom/client";
|
||||||
import App from './App.jsx';
|
import App from "./App.jsx";
|
||||||
import '@xyflow/react/dist/style.css';
|
import "@xyflow/react/dist/style.css";
|
||||||
import './styles.css';
|
import "./styles.css";
|
||||||
createRoot(document.getElementById('root')).render(<React.StrictMode><App /></React.StrictMode>);
|
createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|||||||
+183
-21
@@ -1,29 +1,191 @@
|
|||||||
import { DEFAULT_GLOBALS, autoLayout } from './engine.js';
|
import { DEFAULT_GLOBALS, autoLayout } from "./engine.js";
|
||||||
|
|
||||||
export function nodeData(kind, label) {
|
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' };
|
return {
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
mode: "estimate",
|
||||||
|
size: kind === "tts" ? 0.6 : kind === "asr" ? 0.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: 0.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 n = (id, kind, label, more = {}) => ({
|
||||||
const e=(source,target,release='complete',more={})=>({id:`${source}-${target}`,source,target,type:'smoothstep',data:{release,tokens:8,...more}});
|
id,
|
||||||
const preset=(name,description,nodes,edges,globals={})=>({name,description,version:1,nodes:autoLayout(nodes,edges),edges,globals:{...DEFAULT_GLOBALS,...globals}});
|
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 = {
|
export const PRESETS = {
|
||||||
native:preset('原生全双工','输入与输出在同一模型内持续运行;另设独立的打断控制路径。',[
|
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})
|
n("in", "input", "用户停止说话"),
|
||||||
],[e('in','asr','first'),e('asr','llm','complete'),e('llm','tts','chunk'),e('tts','play','first'),e('in','cancel','first',{networkMs:0,control:true})]),
|
n("voice", "speech", "原生语音对话", {
|
||||||
parallel:preset('并行检索与汇总','检索和工具分支并行,汇总模型等到全部分支完成后组织回复。',[
|
mode: "reference",
|
||||||
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','主回复播放')
|
profileId: "persona",
|
||||||
],[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')]),
|
size: 7,
|
||||||
agent:preset('Agent 多步循环','模型最多运行4步,每步输出96个token,步骤之间预留250ms工具等待。',[
|
customRtf: 0.45,
|
||||||
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')]),
|
n("play", "output", "主回复播放"),
|
||||||
blank:preset('空白流程','从左侧添加节点,也可用下方的连线表单连接。',[],[]),
|
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 makeDocument(key = "cascade") {
|
||||||
export function toDocument(nodes,edges,globals,name='自定义对话流程') {
|
return structuredClone(PRESETS[key]);
|
||||||
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};
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2942
-9
File diff suppressed because one or more lines are too long
+279
-69
@@ -1,90 +1,300 @@
|
|||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import {simulateGraph,estimateNode,validateGraph,validateDocument,DEFAULT_GLOBALS,autoLayout} from '../src/engine.js';
|
import {
|
||||||
import {makeDocument,nodeData,PRESETS} from '../src/presets.js';
|
simulateGraph,
|
||||||
import {RECORDS,SOURCES} from '../src/catalog.js';
|
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 g = {
|
||||||
const node=(id,kind,extra={})=>({id,type:'stage',position:{x:0,y:0},data:{...nodeData(kind,id),...extra}});
|
...DEFAULT_GLOBALS,
|
||||||
const edge=(source,target,release='complete',extra={})=>({id:`${source}-${target}`,source,target,data:{release,...extra}});
|
networkMs: 0,
|
||||||
const near=(a,b)=>assert.ok(Math.abs(a-b)<.00001,`${a} != ${b}`);
|
vadMs: 0,
|
||||||
|
queueMs: 0,
|
||||||
test('serial stages add their service times',()=>{
|
speechSeconds: 1,
|
||||||
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);
|
const node = (id, kind, extra = {}) => ({
|
||||||
near(s.first,300);near(s.complete,300);
|
id,
|
||||||
|
type: "stage",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { ...nodeData(kind, id), ...extra },
|
||||||
});
|
});
|
||||||
test('parallel all-join waits for longest branch rather than summing branches',()=>{
|
const edge = (source, target, release = "complete", extra = {}) => ({
|
||||||
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'})];
|
id: `${source}-${target}`,
|
||||||
const e=[edge('in','a'),edge('in','b'),edge('a','join'),edge('b','join'),edge('join','out')];
|
source,
|
||||||
const s=simulateGraph(n,e,g);near(s.first,360);assert.ok(s.criticalNodes.has('b'));assert.ok(!s.criticalNodes.has('a'));
|
target,
|
||||||
const any=n.map(x=>x.id==='join'?{...x,data:{...x.data,join:'any'}}:x);
|
data: { release, ...extra },
|
||||||
|
});
|
||||||
|
const near = (a, b) => assert.ok(Math.abs(a - b) < 0.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);
|
near(simulateGraph(any, e, g).first, 160);
|
||||||
});
|
});
|
||||||
test('streaming starts early, completion remains bounded by final upstream data',()=>{
|
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 n = [
|
||||||
const e=[edge('llm','tts','first'),edge('tts','out','first')],s=simulateGraph(n,e,g);
|
node("llm", "llm", {
|
||||||
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);
|
mode: "custom",
|
||||||
const full=simulateGraph(n,[edge('llm','tts'),edge('tts','out','first')],g);
|
customFirstMs: 100,
|
||||||
|
customTps: 100,
|
||||||
|
outputTokens: 101,
|
||||||
|
}),
|
||||||
|
node("tts", "tts", { mode: "custom", customFirstMs: 50, customRtf: 0.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);
|
assert.ok(full.first > s.first);
|
||||||
});
|
});
|
||||||
test('token gate adds k-1 decoding intervals',()=>{
|
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'})];
|
const n = [
|
||||||
near(simulateGraph(n,[edge('llm','out','chunk',{tokens:8})],g).first,170);
|
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',()=>{
|
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 d = {
|
||||||
const r=estimateNode(d,g);near(r.end,700);near(r.first,600);assert.deepEqual(r.steps.map(x=>x.input),[100,120,140]);
|
...nodeData("llm", "agent"),
|
||||||
const partial=estimateNode({...d,finalOnly:false},g);near(partial.first,100);near(partial.end,700);
|
mode: "custom",
|
||||||
const s=simulateGraph([node('agent','llm',d),node('out','output',{outputType:'text',durationMs:0})],[edge('agent','out','first')],{...g,loopPlan:'expected'});
|
customFirstMs: 100,
|
||||||
near(s.first,350);near(s.maxFirst,600);near(s.maxComplete,700);near(s.results.agent.end,450);
|
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',()=>{
|
test("hidden reasoning delays the first visible token", () => {
|
||||||
const d={...nodeData('llm','reasoner'),mode:'custom',customFirstMs:100,customTps:100,outputTokens:11,reasoningTokens:50};
|
const d = {
|
||||||
const r=estimateNode(d,g);near(r.first,600);near(r.end,700);
|
...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',()=>{
|
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 n = [
|
||||||
const e=[edge('in','a'),edge('in','b'),edge('a','out'),edge('b','out')];
|
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);
|
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);
|
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',()=>{
|
test("source text TTFT is not interpreted as first playable audio", () => {
|
||||||
const r=estimateNode({...nodeData('speech','LFM3'),mode:'reference',profileId:'lfm3',size:2},g);
|
const r = estimateNode(
|
||||||
assert.notEqual(r.first,50);assert.ok(r.assumptions.some(x=>x.includes('只给首token')));
|
{
|
||||||
|
...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',()=>{
|
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);
|
const r = estimateNode(
|
||||||
near(r.rtf,.21);assert.ok(r.assumptions.some(x=>x.includes('来源只给RTF')));assert.notEqual(r.first,210);
|
{
|
||||||
|
...nodeData("speech", "MiniCPM"),
|
||||||
|
mode: "reference",
|
||||||
|
profileId: "minicpm",
|
||||||
|
size: 9,
|
||||||
|
},
|
||||||
|
g,
|
||||||
|
);
|
||||||
|
near(r.rtf, 0.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',()=>{
|
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 n = [
|
||||||
const s=simulateGraph(n,[edge('a','out')],{...g,programScale:2});near(s.first,200);assert.ok(s.warnings.some(x=>x.includes('orphan未连到主输出')));
|
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',()=>{
|
test("audio component size and hardware settings affect engineering estimate", () => {
|
||||||
const a=estimateNode({...nodeData('asr','ASR'),hardware:'h20',size:.6},g);
|
const a = estimateNode(
|
||||||
const b=estimateNode({...nodeData('asr','ASR'),hardware:'h20',size:2},g);
|
{ ...nodeData("asr", "ASR"), hardware: "h20", size: 0.6 },
|
||||||
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);
|
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',()=>{
|
test("network and endpoint wait are counted once at their boundaries", () => {
|
||||||
const n=[node('in','input'),node('out','output',{durationMs:60,outputType:'text'})];
|
const n = [
|
||||||
near(simulateGraph(n,[edge('in','out','first')],{...g,vadMs:120,networkMs:20}).first,200);
|
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',()=>{
|
test("invalid cycles and dangling edges are rejected including imports", () => {
|
||||||
const n=[node('a','program'),node('b','program')];
|
const n = [node("a", "program"), node("b", "program")];
|
||||||
assert.ok(validateGraph(n,[edge('a','b'),edge('b','a')]).some(x=>x.includes('环')));
|
assert.ok(
|
||||||
assert.ok(validateGraph(n,[edge('a','missing')]).length);
|
validateGraph(n, [edge("a", "b"), edge("b", "a")]).some((x) =>
|
||||||
assert.ok(validateDocument({version:1,nodes:n,edges:[],globals:{computeScale:-1}}).length);
|
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',()=>{
|
test("memory over-capacity is surfaced rather than silently claiming deployability", () => {
|
||||||
const r=estimateNode({...nodeData('llm','405B'),size:405,hardware:'4090',cards:1},g);
|
const r = estimateNode(
|
||||||
assert.ok(r.warnings.some(x=>x.includes('显存')));
|
{ ...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',()=>{
|
test("source records have provenance and do not confuse the units of metrics", () => {
|
||||||
const ids = new Set();
|
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);}
|
for (const r of RECORDS) {
|
||||||
const q=RECORDS.find(r=>r.id==='q3-8');near(q.value,150.25);assert.equal(q.metric,'tps');
|
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',()=>{
|
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);}
|
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