Files
latency-lab/src/App.jsx
T

2600 lines
87 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useRef, memo } from "react";
import {
ReactFlow,
Background,
Controls,
Handle,
Position,
applyNodeChanges,
applyEdgeChanges,
MarkerType,
} from "@xyflow/react";
import {
ArrowUpRight,
ArrowRight,
Activity,
AudioLines,
GitBranch,
Clock3,
Layers3,
Plus,
ChevronDown,
ChevronRight,
Download,
Upload,
RotateCcw,
LayoutGrid,
Trash2,
Copy,
Check,
Search,
BookOpen,
SlidersHorizontal,
Play,
Pause,
X,
Info,
Link2,
Braces,
Mic,
Volume2,
BrainCircuit,
Workflow,
ExternalLink,
Menu,
} from "lucide-react";
import {
RECORDS,
SOURCES,
CUTOFF,
RELEASE,
EVIDENCE,
METRICS,
recordValue,
HARDWARE,
SIZE_OPTIONS,
CALIBRATION,
} from "./catalog.js";
import {
DEFAULT_GLOBALS,
KINDS,
MODEL_KINDS,
simulateGraph,
estimateNode,
validateGraph,
validateDocument,
autoLayout,
getRecord,
ms,
} from "./engine.js";
import { PRESETS, makeDocument, nodeData, toDocument } from "./presets.js";
const STORE = "latency-lab-v1";
const icons = {
input: Mic,
asr: AudioLines,
llm: BrainCircuit,
speech: AudioLines,
tts: Volume2,
program: Braces,
merge: GitBranch,
output: Volume2,
};
function loadSaved() {
try {
const d = JSON.parse(localStorage.getItem(STORE) || "null");
if (d && !validateDocument(d).length) return d;
} catch {}
return makeDocument();
}
function download(name, value, type = "application/json") {
const blob = new Blob([value], { type }),
url = URL.createObjectURL(blob),
a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const options = (entries) =>
entries.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
));
function Field({ label, children, hint }) {
return (
<label className="field">
<span>{label}</span>
{children}
{hint && <small>{hint}</small>}
</label>
);
}
function Num({ value, onChange, min = 0, max = 1e6, step = 1, label }) {
return (
<input
aria-label={label}
type="number"
min={min}
max={max}
step={step}
value={value ?? 0}
onChange={(e) =>
onChange(Math.min(max, Math.max(min, Number(e.target.value) || 0)))
}
/>
);
}
function Toggle({ checked, onChange, children }) {
return (
<label className="toggle">
<input
type="checkbox"
checked={!!checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span>{children}</span>
</label>
);
}
function Tag({ type = "neutral", children }) {
return <span className={`tag ${type}`}>{children}</span>;
}
function SourceLink({ id, children }) {
const s = SOURCES[id];
return s ? (
<a className="source-link" href={s.url} target="_blank" rel="noreferrer">
{children || s.publisher}
<ArrowUpRight size={13} />
</a>
) : null;
}
const StageNode = memo(function StageNode({ data, selected }) {
const Icon = icons[data.kind] || Braces,
r = data.result;
return (
<div
className={`stage-node kind-${data.kind} ${selected ? "is-selected" : ""} ${data.critical ? "is-critical" : ""}`}
>
{data.kind !== "input" && (
<Handle
type="target"
position={Position.Left}
aria-label={`${data.label} 输入连接点`}
/>
)}
<div className="stage-top">
<span className="stage-icon">
<Icon size={16} />
</span>
<span>{KINDS[data.kind]}</span>
{data.maxSteps > 1 && (
<span className="loop-mark">↻ {data.maxSteps}</span>
)}
</div>
<strong>{data.label}</strong>
<div className="stage-model">
{MODEL_KINDS.includes(data.kind)
? data.mode === "reference"
? getRecord(data.profileId)?.name || "来源参考"
: `${data.arch === "moe" ? `${data.size}B / A${data.active}B` : `${data.size}B`} · ${HARDWARE.find((h) => h.id === data.hardware)?.name || "自定义"}`
: data.kind === "output"
? "声音 / 最终结果的测量终点"
: data.kind === "input"
? "t = 0 · 用户停说"
: `${data.durationMs} ms · 可修改`}
</div>
<div className="stage-bottom">
<span>
首出 <b>{r ? ms(r.first - r.start) : "—"}</b>
</span>
<span>
完成 <b>{r ? ms(r.end - r.start) : "—"}</b>
</span>
</div>
{data.kind !== "output" && (
<Handle
type="source"
position={Position.Right}
aria-label={`${data.label} 输出连接点`}
/>
)}
</div>
);
});
const nodeTypes = { stage: StageNode };
function Hero({ onStart, onCompare }) {
return (
<section className="hero wrap">
<div className="hero-copy">
<div className="eyebrow">
<span className="live-dot" /> INFERENCE / VOICE / AGENTS{" "}
<span className="edition">研究快照 2026.11</span>
</div>
<h1>
从模型的毫秒,
<br />
到对话的<span>节奏。</span>
</h1>
<p>
看清不同尺寸模型的输出速度,
<br className="desktop-only" />
再把节点连起来,计算一场对话真正的等待。
</p>
<div className="hero-actions">
<button className="primary" onClick={onStart}>
搭建对话流程 <ArrowRight size={17} />
</button>
<button className="text-button" onClick={onCompare}>
查看模型对照 <ArrowUpRight size={17} />
</button>
</div>
<div className="hero-foot">
公开来源逐条标注 <i /> 理论与报告分开 <i /> 估算参数可校准
</div>
</div>
<div
className="hero-visual"
aria-label="流式对话时延的示意图,非实测数据"
>
<div className="diagram-head">
<span>一场对话的时间切片</span>
<span>ILLUSTRATION / ms</span>
</div>
<div className="wave-line">
{Array.from({ length: 52 }, (_, i) => (
<i
key={i}
style={{
height:
10 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.31)) * 47,
}}
/>
))}
</div>
<div className="voice-end">
<span>用户停说</span>
<i />
<span>模型持续处理</span>
<i />
<span>首段声音</span>
</div>
<div className="diagram-rows">
<div>
<span>输入流</span>
<b className="input-stream" />
<em>持续监听</em>
</div>
<div>
<span>推理流</span>
<b className="think-stream" />
<em>并行工作</em>
</div>
<div>
<span>输出流</span>
<b className="output-stream" />
<em>流式合成</em>
</div>
</div>
<div className="visual-caption">
<span className="tiny-mark">↗</span>
<div>
<strong>开始得早,结束也要算清。</strong>
<p>首token ≠ 首音频 ≠ 完整任务</p>
</div>
</div>
</div>
<div className="hero-stats">
<div>
<b>
{RECORDS.length}
<span> 条</span>
</b>
<p>带来源的性能记录</p>
</div>
<div>
<b>
0.5–2,400<span> B</span>
</b>
<p>覆盖主干 / 总参数规模</p>
</div>
<div>
<b>
170<span> ms</span>
</b>
<p>
7B语音模型公开接续报告 <SourceLink id="persona">来源</SourceLink>
</p>
</div>
<div>
<b>
≤ 100<span> 步</span>
</b>
<p>每个模型节点的loop预算</p>
</div>
</div>
</section>
);
}
function Comparison({ addProfile }) {
const [modality, setModality] = useState("all"),
[evidence, setEvidence] = useState("all"),
[metric, setMetric] = useState("all"),
[query, setQuery] = useState(""),
[expanded, setExpanded] = useState(null),
[view, setView] = useState("records"),
[sort, setSort] = useState("default");
const [hw, setHw] = useState("h200"),
[cards, setCards] = useState(1),
[prompt, setPrompt] = useState(1024),
[tokens, setTokens] = useState(128),
[spec, setSpec] = useState(1);
const filtered = useMemo(() => {
const rows = RECORDS.filter(
(r) =>
(modality === "all" || r.modality === modality) &&
(evidence === "all" || r.evidence === evidence) &&
(metric === "all" || r.metric === metric) &&
`${r.name} ${r.hardware} ${r.params}`
.toLowerCase()
.includes(query.toLowerCase()),
);
if (sort === "size")
rows.sort((a, b) => (a.size ?? Infinity) - (b.size ?? Infinity));
return rows;
}, [modality, evidence, metric, query, sort]);
const exportCsv = () => {
const rows = [
[
"模型",
"模态",
"参数口径",
"指标",
"数值",
"单位",
"硬件",
"量化",
"输入tokens",
"并发",
"证据",
"时间",
"来源",
"限制",
],
...filtered.map((r) => [
r.name,
r.modality,
r.params,
METRICS[r.metric],
r.value ?? "",
r.metric === "tps" ? "tok/s" : r.metric === "rtf" ? "RTF" : "ms",
r.hardware,
r.precision || "",
r.input ?? "",
r.concurrency ?? "",
EVIDENCE[r.evidence],
r.date,
SOURCES[r.source].url,
r.note || SOURCES[r.source].note,
]),
];
download(
"model-latency-20261106.csv",
"\uFEFF" +
rows
.map((r) =>
r.map((v) => '"' + String(v).replaceAll('"', '""') + '"').join(","),
)
.join("\n"),
"text/csv;charset=utf-8",
);
};
return (
<section id="compare" className="compare-section wrap">
<div className="section-title">
<div>
<div className="eyebrow">01 / THE EVIDENCE</div>
<h2>模型时延对照</h2>
<p>先比较同口径,再讨论极限。尺寸相同,部署条件仍可能相差很大。</p>
</div>
<button className="outline" onClick={exportCsv}>
<Download size={15} /> 导出筛选数据
</button>
</div>
<div className="view-tabs">
<button
className={view === "records" ? "active" : ""}
onClick={() => setView("records")}
>
公开性能记录 <span>{RECORDS.length}</span>
</button>
<button
className={view === "estimate" ? "active" : ""}
onClick={() => setView("estimate")}
>
统一条件尺寸估算 <Tag type="amber">工程外推</Tag>
</button>
</div>
{view === "records" ? (
<>
<div className="filters">
<div className="segmented">
{[
["all", "全部"],
["text", "纯文本"],
["speech", "端到端语音"],
["tts", "TTS组件"],
].map(([v, l]) => (
<button
className={modality === v ? "active" : ""}
onClick={() => setModality(v)}
key={v}
>
{l}
</button>
))}
</div>
<div className="filter-controls">
<label className="search-box">
<Search size={15} />
<input
aria-label="搜索模型"
placeholder="模型、尺寸或硬件"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<select
aria-label="证据类型"
value={evidence}
onChange={(e) => setEvidence(e.target.value)}
>
{options([["all", "全部证据"], ...Object.entries(EVIDENCE)])}
</select>
<select
aria-label="时延指标"
value={metric}
onChange={(e) => setMetric(e.target.value)}
>
{options([["all", "全部指标"], ...Object.entries(METRICS)])}
</select>
<select
aria-label="模型排序"
value={sort}
onChange={(e) => setSort(e.target.value)}
>
{options([
["default", "来源分组"],
["size", "尺寸升序"],
])}
</select>
</div>
</div>
<div className="table-summary">
<span>
{filtered.length} 条记录 · 截止 {CUTOFF}
</span>
<span>点击一行展开条件与限制</span>
</div>
<div className="table-scroll">
<table className="research-table">
<thead>
<tr>
<th>模型 / 规模</th>
<th>指标与结果</th>
<th>部署条件</th>
<th>证据与来源</th>
<th aria-label="操作" />
</tr>
</thead>
<tbody>
{filtered.map((r) => (
<React.Fragment key={r.id}>
<tr
className={expanded === r.id ? "expanded" : ""}
onClick={() =>
setExpanded(expanded === r.id ? null : r.id)
}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter")
setExpanded(expanded === r.id ? null : r.id);
}}
>
<td>
<strong>{r.name}</strong>
<span className="subline">
{r.params}
{r.active ? ` · 激活${r.active}B` : ""}{" "}
<span className="modality-dot">
{r.modality === "text"
? "文本"
: r.modality === "tts"
? "TTS"
: "语音"}
</span>
</span>
</td>
<td>
<b className="value">{recordValue(r)}</b>
<span className="subline">{r.scope}</span>
{r.metric === "tps" && (
<small className="derived">
≈ {(1000 / r.value).toFixed(2)} ms/token
</small>
)}
</td>
<td>
{r.hardware}
<span className="subline">
{r.precision || "精度未披露"}
{r.concurrency ? ` · 并发${r.concurrency}` : ""}
</span>
</td>
<td>
<Tag
type={
r.evidence === "theoretical"
? "amber"
: r.evidence === "conditional"
? "purple"
: r.evidence === "unknown"
? "neutral"
: "green"
}
>
{EVIDENCE[r.evidence]}
</Tag>
<span className="subline">
<SourceLink id={r.source} />
<span className="record-date">{r.date}</span>
</span>
</td>
<td>
<ChevronDown
size={16}
className={expanded === r.id ? "rotate" : ""}
/>
</td>
</tr>
{expanded === r.id && (
<tr className="detail-row">
<td colSpan={5}>
<div className="record-detail">
<div>
<strong>这条数字说明什么</strong>
<p>{r.note || SOURCES[r.source].note}</p>
<p className="muted">
输入:
{r.input
? `${r.input.toLocaleString()} token`
: r.inputSeconds
? `${r.inputSeconds}秒音频/视频`
: "未充分披露"}
 输出:
{r.output ? `${r.output} token` : "未充分披露"}
 {r.ttft ? `另报告首token ${r.ttft}ms。` : ""}
{r.tps ? ` 另报告文本速度${r.tps} tok/s。` : ""}
{r.rtf ? ` RTF ${r.rtf}。` : ""}
</p>
<SourceLink id={r.source}>
{SOURCES[r.source].title}
</SourceLink>
</div>
{r.value != null && r.metric !== "interrupt" && (
<button
className="outline"
onClick={(e) => {
e.stopPropagation();
addProfile(r);
}}
>
<Plus size={15} /> 作为流程节点参考
</button>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
{!filtered.length && (
<div className="empty-state">
没有匹配记录,试试其他筛选条件。
</div>
)}
</div>
<div className="comparison-notes">
<p>
<Info size={16} />
<span>
<b>“最佳”必须带条件。</b>
历史晶圆级结果、单卡GPU、受控接受率、生产API观测不构成统一排行榜。公开报告也不等于本网站实测。
</span>
</p>
<p>
<span className="note-mark">A</span>
<span>
<b>MoE看两种尺寸。</b>
总参数影响存储,激活参数影响每步计算;不能把30B-A3B当作30B稠密模型。
</span>
</p>
</div>
</>
) : (
<>
<div className="estimate-controls">
<Field label="硬件">
<select value={hw} onChange={(e) => setHw(e.target.value)}>
{HARDWARE.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</Field>
<Field label="GPU卡数">
<Num
label="对照GPU卡数"
value={cards}
onChange={setCards}
min={1}
max={64}
/>
</Field>
<Field label="输入token">
<Num label="对照输入token" value={prompt} onChange={setPrompt} />
</Field>
<Field label="输出token">
<Num
label="对照输出token"
value={tokens}
onChange={setTokens}
min={1}
/>
</Field>
<Field label="推测加速假设">
<Num
label="对照推测倍率"
value={spec}
onChange={setSpec}
min={1}
max={4}
step={0.1}
/>
</Field>
</div>
<p className="inline-note">
<Info size={16} />{" "}
FP8、模型已热身、低负载、不计网络与排队。以Qwen3/H20为速率锚点,硬件倍率和首token都是可校准的工程假设;不是硬件实测或物理下界。
</p>
<div className="table-scroll">
<table className="research-table">
<thead>
<tr>
<th>稠密模型规模</th>
<th>估计首token</th>
<th>估计输出速度</th>
<th>完整输出估计</th>
<th>可部署性 / 外推</th>
</tr>
</thead>
<tbody>
{[0.5, 1.5, 3, 7, 8, 14, 27, 32, 70, 120, 235, 405].map(
(size) => {
const r = estimateNode({
...nodeData("llm", "尺寸扫描"),
hardware: hw,
cards,
size,
inputTokens: prompt,
outputTokens: tokens,
speculative: spec,
});
return (
<tr key={size}>
<td>
<strong>{size}B</strong>
<span className="subline">稠密 · FP8</span>
</td>
<td className="value">{ms(r.first)}</td>
<td>
{r.rate.toFixed(1)} tok/s
<span className="subline">
{(1000 / r.rate).toFixed(1)} ms/token
</span>
</td>
<td>{ms(r.end)}</td>
<td>
{r.warnings.find((w) => w.includes("显存")) ? (
<Tag type="amber">显存不足</Tag>
) : (
<Tag>估算</Tag>
)}
<small className="subline">
{r.warnings.find((w) => w.includes("显存")) ||
"参数变化仅代表此工程模型下的趋势。"}
</small>
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
</>
)}
</section>
);
}
function NumberField({
data,
patch,
keyName,
label,
hint,
min = 0,
max = 1e6,
step = 1,
}) {
return (
<Field label={label} hint={hint}>
<Num
label={label}
value={data[keyName]}
onChange={(v) => patch({ [keyName]: v })}
min={min}
max={max}
step={step}
/>
</Field>
);
}
function NodeInspector({ node, result, patch, remove, duplicate }) {
if (!node)
return (
<div className="inspector-empty">
<SlidersHorizontal size={27} />
<h3>选择一个节点</h3>
<p>
点击流程中的节点,编辑模型、参数和loop步数。也可以先从左侧添加节点。
</p>
</div>
);
const d = node.data,
isModel = MODEL_KINDS.includes(d.kind),
isText = d.kind === "llm";
const profiles =
d.kind === "asr"
? []
: RECORDS.filter(
(r) =>
r.value != null &&
r.metric !== "interrupt" &&
(isText
? r.modality === "text"
: d.kind === "tts"
? r.modality === "tts"
: r.modality === "speech"),
);
const chooseProfile = (id) => {
const r = getRecord(id);
patch({
profileId: id,
mode: "reference",
...(r?.size ? { size: r.size } : {}),
...(r?.active ? { active: r.active, arch: "moe" } : { arch: "dense" }),
});
};
return (
<>
<div className="inspector-heading">
<div>
<span className="eyebrow">NODE CONFIG</span>
<h3>节点设置</h3>
</div>
<div className="icon-actions">
<button aria-label="复制节点" title="复制节点" onClick={duplicate}>
<Copy size={16} />
</button>
<button aria-label="删除节点" title="删除节点" onClick={remove}>
<Trash2 size={16} />
</button>
</div>
</div>
<Field label="节点名称">
<input
aria-label="节点名称"
value={d.label}
maxLength={80}
onChange={(e) => patch({ label: e.target.value })}
/>
</Field>
<div className="inspector-kind">
<Tag>{KINDS[d.kind]}</Tag>
{result && <Tag type="green">{result.label}</Tag>}
</div>
{isModel && (
<>
<Field label="数值来源">
<select
aria-label="节点数值来源"
value={d.mode}
onChange={(e) => {
if (
e.target.value === "reference" &&
!d.profileId &&
profiles.length
)
chooseProfile(profiles[0].id);
else patch({ mode: e.target.value });
}}
>
{options([
["estimate", "按类型、规模与硬件估算"],
...(profiles.length
? [["reference", "采用公开记录作为锚点"]]
: []),
["custom", "手动校准全部时延"],
])}
</select>
</Field>
{d.mode === "reference" ? (
<>
<Field label="模型性能记录">
<select
aria-label="模型性能记录"
value={d.profileId || ""}
onChange={(e) => chooseProfile(e.target.value)}
>
<option value="" disabled>
选择一条性能记录
</option>
{profiles.map((r) => (
<option value={r.id} key={r.id}>
{r.name} · {recordValue(r)} · {r.hardware}
</option>
))}
</select>
</Field>
{getRecord(d.profileId) && (
<div className="ref-note">
<SourceLink id={getRecord(d.profileId).source}>
{SOURCES[getRecord(d.profileId).source].title}
</SourceLink>
<p>
{getRecord(d.profileId).note ||
SOURCES[getRecord(d.profileId).source].note}
</p>
</div>
)}
</>
) : (
<>
<div className="field-pair">
<Field label="结构">
<select
aria-label="模型结构"
value={d.arch}
onChange={(e) => patch({ arch: e.target.value })}
>
{options([
["dense", "稠密 Dense"],
["moe", "混合专家 MoE"],
])}
</select>
</Field>
<Field label="参数量(B)">
<input
aria-label="模型参数量"
type="number"
list="model-sizes"
min="0.1"
step="0.1"
value={d.size}
onChange={(e) =>
patch({
size: Math.max(0.1, Number(e.target.value) || 0.1),
})
}
/>
</Field>
</div>
{d.arch === "moe" && (
<NumberField
data={d}
patch={patch}
keyName="active"
label="每token激活参数(B)"
min={0.1}
max={d.size}
step={0.1}
/>
)}
<Field label="硬件">
<select
aria-label="节点硬件"
value={d.hardware}
onChange={(e) => patch({ hardware: e.target.value })}
>
{HARDWARE.map((h) => (
<option value={h.id} key={h.id}>
{h.name}
</option>
))}
</select>
</Field>
<div className="field-pair">
<NumberField
data={d}
patch={patch}
keyName="cards"
label="GPU卡数"
min={1}
max={256}
/>
<Field label="量化">
<select
aria-label="节点量化"
value={d.quant}
onChange={(e) => patch({ quant: e.target.value })}
>
{options([
["bf16", "BF16"],
["fp8", "FP8 / INT8"],
["int4", "INT4"],
])}
</select>
</Field>
</div>
</>
)}
{d.mode === "custom" && (
<div className="setting-group">
<h4>手动校准</h4>
<NumberField
data={d}
patch={patch}
keyName="customFirstMs"
label={
isText ? "首个可见token前时延(ms)" : "首包 / 尾包时延(ms)"
}
/>
{isText ? (
<NumberField
data={d}
patch={patch}
keyName="customTps"
label="生成速度(token/s)"
min={0.1}
step={0.1}
/>
) : (
<NumberField
data={d}
patch={patch}
keyName="customRtf"
label="实时因子 RTF"
min={0.001}
max={100}
step={0.01}
/>
)}
</div>
)}
{isText && (
<>
<div className="setting-group">
<h4>一次推理的工作量</h4>
<div className="field-pair">
<NumberField
data={d}
patch={patch}
keyName="inputTokens"
label="首步输入token"
/>
<NumberField
data={d}
patch={patch}
keyName="outputTokens"
label="每步可见输出token"
min={1}
/>
</div>
<NumberField
data={d}
patch={patch}
keyName="reasoningTokens"
label="每步隐藏思考token"
hint="在第一个可见token前完成;会增加等待。"
/>
<NumberField
data={d}
patch={patch}
keyName="cacheRatio"
label="初始上下文缓存比例"
min={0}
max={0.99}
step={0.05}
/>
{d.mode === "estimate" && (
<NumberField
data={d}
patch={patch}
keyName="speculative"
label="推测解码加速假设"
min={1}
max={4}
step={0.1}
/>
)}
</div>
</>
)}
{d.kind === "asr" && (
<>
<Toggle
checked={d.streamingInput}
onChange={(v) => patch({ streamingInput: v })}
>
用户说话时持续识别
</Toggle>
{d.mode !== "custom" && (
<NumberField
data={d}
patch={patch}
keyName="durationMs"
label="识别尾包时延(ms)"
/>
)}
</>
)}
<div className="setting-group loop-group">
<div className="group-title">
<h4>Agent / 模型 loop</h4>
<Tag type="purple">逐步累计</Tag>
</div>
<div className="field-pair">
<NumberField
data={d}
patch={patch}
keyName="maxSteps"
label="loop 最大步数"
min={1}
max={100}
/>
<NumberField
data={d}
patch={patch}
keyName="expectedSteps"
label="预计实际步数"
min={1}
max={d.maxSteps}
/>
</div>
{d.maxSteps > 1 && (
<>
<NumberField
data={d}
patch={patch}
keyName="toolMs"
label="两步之间工具等待(ms)"
hint="每轮之间计一次,共N−1次;不要再重复加入同一工具预算。"
/>
{isText && (
<NumberField
data={d}
patch={patch}
keyName="contextGrowth"
label="每步新增上下文token"
/>
)}
<Toggle
checked={d.finalOnly}
onChange={(v) => patch({ finalOnly: v })}
>
仅最后一步向下游输出
</Toggle>
<p className="micro-copy">
关闭后首包可能是中间反馈。loop指节点内部重复调用,不代表真实对话往返轮数。
</p>
</>
)}
</div>
<Field
label="共享资源组(可选)"
hint="填写相同名称的模型节点串行占用同一资源;留空则按全局设置。"
>
<input
aria-label="共享资源组"
value={d.resource || ""}
maxLength={30}
placeholder="例如 gpu-a"
onChange={(e) => patch({ resource: e.target.value })}
/>
</Field>
</>
)}
{(d.kind === "program" || d.kind === "merge" || d.kind === "output") && (
<NumberField
data={d}
patch={patch}
keyName="durationMs"
label={
d.kind === "output" ? "播放 / 输出缓冲(ms)" : "默认执行耗时(ms)"
}
/>
)}
{d.kind === "output" && (
<>
<Field label="输出类型">
<select
aria-label="输出类型"
value={d.outputType}
onChange={(e) => patch({ outputType: e.target.value })}
>
{options([
["audio", "可播放音频"],
["text", "文本 / 程序结果"],
])}
</select>
</Field>
<Toggle
checked={d.target !== false}
onChange={(v) => patch({ target: v })}
>
作为主结果测量终点
</Toggle>
</>
)}
{d.kind === "input" && (
<p className="ref-note">
该节点代表用户停止说话。判停等待在“全局参数”中调整。流式识别可以提前处理用户说话期间的输入。
</p>
)}
<Field label="多分支输入策略">
<select
aria-label="汇总等待策略"
value={d.join || "all"}
onChange={(e) => patch({ join: e.target.value })}
>
{options([
["all", "等全部分支(max)"],
["any", "取最早可用分支(min)"],
])}
</select>
</Field>
{result && (
<div className="node-estimate">
<h4>本节点的时间</h4>
<dl>
<div>
<dt>开始</dt>
<dd>{ms(result.start)}</dd>
</div>
<div>
<dt>首出</dt>
<dd>{ms(result.first)}</dd>
</div>
<div>
<dt>结束</dt>
<dd>{ms(result.end)}</dd>
</div>
{result.resourceWait > 0 && (
<div>
<dt>资源等待</dt>
<dd>{ms(result.resourceWait)}</dd>
</div>
)}
</dl>
{result.assumptions.map((s, i) => (
<p key={i}>{s}</p>
))}
{result.warnings.map((s, i) => (
<p key={i} className="warn-text">
{s}
</p>
))}
</div>
)}
</>
);
}
function Timeline({ sim, nodes, selected, setSelected }) {
const [playing, setPlaying] = useState(false),
[cursor, setCursor] = useState(0),
[stepsOpen, setStepsOpen] = useState(false),
timer = useRef();
const total = sim.complete || 1;
useEffect(() => {
if (!playing) return;
let start = performance.now(),
from = cursor;
const tick = (now) => {
const next = from + (now - start) * (total / 6000);
if (next >= total) {
setCursor(total);
setPlaying(false);
} else {
setCursor(next);
timer.current = requestAnimationFrame(tick);
}
};
timer.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(timer.current);
}, [playing, total]);
useEffect(() => {
setCursor(0);
setPlaying(false);
}, [total]);
const result = selected ? sim.results?.[selected] : null;
return (
<div className="timeline-panel">
<div className="timeline-heading">
<div>
<h3>
流程时间线 <span>用户停说 = 0</span>
</h3>
<p>绿色标出首结果关键路径。浅色区域是准备/等待,实色区域是输出。</p>
</div>
<button
className="outline compact"
onClick={() => {
if (cursor >= total) setCursor(0);
setPlaying(!playing);
}}
>
{playing ? <Pause size={14} /> : <Play size={14} />} 播放时序
</button>
</div>
<div className="timeline-axis">
<span>0</span>
<span>{ms(total * 0.25)}</span>
<span>{ms(total * 0.5)}</span>
<span>{ms(total * 0.75)}</span>
<span>{ms(total)}</span>
</div>
<div className="timeline-rows">
{sim.order.map((id) => {
const r = sim.results[id],
width = Math.max(0.7, ((r.end - r.start) / total) * 100),
left = (r.start / total) * 100,
first = ((r.first - r.start) / Math.max(1, r.end - r.start)) * 100;
return (
<button
key={id}
className={`timeline-row ${selected === id ? "selected" : ""}`}
onClick={() => setSelected(id)}
>
<span>
{r.title}
{r.stepsCount > 1 && <small>↻{r.stepsCount}</small>}
</span>
<div className="bar-track">
<div
className={`time-bar ${sim.criticalNodes.has(id) ? "critical" : ""}`}
style={{ left: `${left}%`, width: `${width}%` }}
>
<i style={{ width: `${Math.min(100, first)}%` }} />
</div>
{cursor > 0 && (
<b
className="playhead"
style={{ left: `${(cursor / total) * 100}%` }}
/>
)}
</div>
<strong>{ms(r.end - r.start)}</strong>
</button>
);
})}
</div>
<label className="scrubber">
<span>时序位置 {ms(cursor)}</span>
<input
aria-label="时序位置"
type="range"
min="0"
max={total}
step="1"
value={Math.min(total, cursor)}
onChange={(e) => {
setPlaying(false);
setCursor(Number(e.target.value));
}}
/>
</label>
{result?.steps.length > 1 && (
<div className="step-breakdown">
<button
className="details-toggle"
onClick={() => setStepsOpen(!stepsOpen)}
>
{stepsOpen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}{" "}
{result.title} · {result.stepsCount}/{result.maxSteps}步展开
</button>
{stepsOpen && (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>步</th>
<th>输入 / 输出 / 思考</th>
<th>开始</th>
<th>首出</th>
<th>结束</th>
<th>步前工具等待</th>
</tr>
</thead>
<tbody>
{result.steps.map((s) => (
<tr key={s.step}>
<td>{s.step}</td>
<td>
{s.input} / {s.output} / {s.reasoning}
</td>
<td>{ms(s.start)}</td>
<td>{ms(s.first)}</td>
<td>{ms(s.end)}</td>
<td>{ms(s.toolWait)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
function Studio({ doc, setDoc, selected, setSelected, toast }) {
const nodes = doc.nodes,
edges = doc.edges,
g = doc.globals;
const [flow, setFlow] = useState(null),
[panel, setPanel] = useState("node"),
[edgeId, setEdgeId] = useState(null),
[from, setFrom] = useState(""),
[to, setTo] = useState(""),
[release, setRelease] = useState("complete"),
[focus, setFocus] = useState(false),
[focusSettings, setFocusSettings] = useState(false);
const inputRef = useRef(),
canvasRef = useRef(),
history = useRef([]);
const sim = useMemo(() => simulateGraph(nodes, edges, g), [nodes, edges, g]);
useEffect(() => {
if (!focus) return;
const old = document.body.style.overflow;
document.body.style.overflow = "hidden";
const escape = (e) => {
if (e.key === "Escape") setFocus(false);
};
window.addEventListener("keydown", escape);
return () => {
document.body.style.overflow = old;
window.removeEventListener("keydown", escape);
};
}, [focus]);
const saveHistory = () => {
history.current.push(structuredClone(doc));
if (history.current.length > 35) history.current.shift();
};
const update = (next) => {
saveHistory();
setDoc(typeof next === "function" ? next(doc) : next);
};
const patchNode = (patch) =>
update({
...doc,
nodes: nodes.map((n) =>
n.id === selected ? { ...n, data: { ...n.data, ...patch } } : n,
),
});
const patchGlobals = (patch) =>
update({ ...doc, globals: { ...g, ...patch } });
const current = nodes.find((n) => n.id === selected),
edge = edges.find((e) => e.id === edgeId);
const fit = () =>
setTimeout(() => flow?.fitView({ padding: 0.18, duration: 300 }), 80);
const choosePreset = (key) => {
update(makeDocument(key));
setSelected(null);
setEdgeId(null);
fit();
};
const addNode = (kind) => {
if (nodes.length >= 100) {
toast("最多支持100个节点。");
return;
}
const id = `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
const rect = canvasRef.current?.getBoundingClientRect();
const pos =
flow && rect
? flow.screenToFlowPosition({
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
})
: { x: 100, y: 100 };
update({
...doc,
nodes: [
...nodes,
{ id, type: "stage", position: pos, data: nodeData(kind, KINDS[kind]) },
],
});
setSelected(id);
setPanel("node");
};
const connect = (c) => {
if (!c.source || !c.target) {
toast("请选择起点和终点。");
return;
}
if (edges.some((e) => e.source === c.source && e.target === c.target)) {
toast("这两个节点已经相连。");
return;
}
const next = {
id: `edge-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`,
type: "smoothstep",
source: c.source,
target: c.target,
data: { release: c.release || "complete", tokens: 8 },
};
const err = validateGraph(nodes, [...edges, next]);
if (err.length) {
toast(err[0]);
return;
}
update({ ...doc, edges: [...edges, next] });
setEdgeId(next.id);
setPanel("edge");
};
const removeNode = () => {
update({
...doc,
nodes: nodes.filter((n) => n.id !== selected),
edges: edges.filter(
(e) => e.source !== selected && e.target !== selected,
),
});
setSelected(null);
};
const duplicate = () => {
if (!current) return;
const id = `n-${Date.now().toString(36)}`;
update({
...doc,
nodes: [
...nodes,
{
...structuredClone(current),
id,
position: { x: current.position.x + 35, y: current.position.y + 165 },
data: { ...current.data, label: current.data.label + " 副本" },
},
],
});
setSelected(id);
};
const renderNodes = useMemo(
() =>
nodes.map((n) => ({
...n,
selected: n.id === selected,
data: {
...n.data,
result: sim.results?.[n.id],
critical: sim.criticalNodes?.has(n.id),
},
})),
[nodes, selected, sim],
);
const renderEdges = useMemo(
() =>
edges.map((e) => ({
...e,
selected: e.id === edgeId,
animated: e.data?.release !== "complete" && !e.data?.control,
label: e.data?.control
? "控制路径"
: e.data?.release === "chunk"
? `${e.data.tokens || 8} token后`
: e.data?.release === "first"
? "首包后"
: "完成后",
markerEnd: {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color: sim.criticalEdges?.has(e.id) ? "#2d7854" : "#91a39a",
},
style: {
stroke: sim.criticalEdges?.has(e.id) ? "#2d7854" : "#91a39a",
strokeWidth: sim.criticalEdges?.has(e.id) ? 2.2 : 1.5,
...(e.data?.control ? { strokeDasharray: "5 5" } : {}),
},
labelStyle: { fill: "#50645b", fontSize: 10 },
labelBgStyle: { fill: "#f7f8f4" },
labelBgPadding: [5, 3],
})),
[edges, edgeId, sim],
);
const exportDoc = () =>
download(
"dialogue-flow.json",
JSON.stringify(toDocument(nodes, edges, g, doc.name), null, 2),
);
const importDoc = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
if (file.size > 2e6) throw new Error("文件超过2MB。");
const parsed = JSON.parse(await file.text());
const err = validateDocument(parsed);
if (err.length) throw new Error(err[0]);
update(parsed);
setSelected(null);
fit();
toast("已导入流程。");
} catch (error) {
toast(`导入失败:${error.message}`);
}
e.target.value = "";
};
const onChanges = (changes) =>
setDoc((prev) => ({
...prev,
nodes: applyNodeChanges(changes, prev.nodes),
}));
return (
<section id="studio" className="studio-section">
<div className="wrap">
<div className="section-title">
<div>
<div className="eyebrow">02 / BUILD THE CONVERSATION</div>
<h2>把一场对话,搭出来。</h2>
<p>拖拽、连线、并行、汇总。改变一个节点,看等待如何沿流程传递。</p>
</div>
<div className="saved-indicator">
<Check size={14} /> 自动保存在本机
</div>
</div>
<div className="preset-bar">
<span>从一个结构开始</span>
{Object.entries(PRESETS)
.filter(([k]) => k !== "blank")
.map(([key, p]) => (
<button
key={key}
onClick={() => choosePreset(key)}
className={doc.name === p.name ? "active" : ""}
>
{key === "native" ? (
<AudioLines size={15} />
) : key === "parallel" ? (
<GitBranch size={15} />
) : key === "agent" ? (
<RotateCcw size={15} />
) : (
<Workflow size={15} />
)}{" "}
{p.name}
</button>
))}
</div>
<div
className={`studio-shell ${focus ? "focused" : ""} ${focusSettings ? "with-settings" : ""}`}
>
<aside className="node-palette">
<span className="eyebrow">添加节点</span>
{Object.entries(KINDS).map(([kind, label]) => {
const Icon = icons[kind];
return (
<button
key={kind}
onClick={() => addNode(kind)}
title={`添加${label}`}
>
<span className={`palette-icon kind-${kind}`}>
<Icon size={17} />
</span>
<span>{label}</span>
<Plus size={13} />
</button>
);
})}
<div className="palette-help">
<Link2 size={18} />
<p>
拖动节点两侧的圆点连线。
<br />
触屏可使用右侧“连线”表单。
</p>
</div>
</aside>
<div className="canvas-column">
<div className="canvas-toolbar">
<span className="flow-name">
{doc.name || "自定义流程"}
{!sim.errors.length && (
<span className="toolbar-metric">
首出 {ms(sim.first)} <i /> 完成 {ms(sim.complete)}
</span>
)}
</span>
<div>
<button
className="focus-button"
onClick={() => {
setFocus(!focus);
setFocusSettings(false);
fit();
}}
>
{focus ? "退出专注" : "专注画布"}
</button>
{focus && (
<button
className="focus-button"
onClick={() => {
setFocusSettings(!focusSettings);
fit();
}}
>
{focusSettings ? "隐藏设置" : "显示设置"}
</button>
)}
<button
aria-label="撤销编辑"
title="撤销编辑"
onClick={() => {
const old = history.current.pop();
if (old) {
setDoc(old);
setSelected(null);
} else toast("没有可撤销的编辑。");
}}
>
<RotateCcw size={15} />
</button>
<button
aria-label="自动布局"
title="自动布局"
onClick={() => {
update({ ...doc, nodes: autoLayout(nodes, edges) });
fit();
}}
>
<LayoutGrid size={15} />
</button>
<button
aria-label="导出流程JSON"
title="导出流程JSON"
onClick={exportDoc}
>
<Download size={15} />
</button>
<button
aria-label="导入流程JSON"
title="导入流程JSON"
onClick={() => inputRef.current.click()}
>
<Upload size={15} />
</button>
<button
aria-label="清空流程"
title="清空流程(可撤销)"
onClick={() => choosePreset("blank")}
>
<Trash2 size={15} />
</button>
</div>
<input
ref={inputRef}
type="file"
accept=".json,application/json"
hidden
onChange={importDoc}
/>
</div>
<div className="flow-canvas" ref={canvasRef}>
<ReactFlow
nodes={renderNodes}
edges={renderEdges}
nodeTypes={nodeTypes}
onNodesChange={onChanges}
onEdgesChange={(changes) =>
setDoc((prev) => ({
...prev,
edges: applyEdgeChanges(changes, prev.edges),
}))
}
onConnect={connect}
onInit={setFlow}
onNodeClick={(_, n) => {
setSelected(n.id);
setPanel("node");
if (focus) setFocusSettings(true);
}}
onEdgeClick={(_, e) => {
setEdgeId(e.id);
setPanel("edge");
if (focus) setFocusSettings(true);
}}
onNodeDragStart={saveHistory}
onPaneClick={() => {
setSelected(null);
setEdgeId(null);
}}
onNodesDelete={(ns) => {
setDoc((prev) => ({
...prev,
edges: prev.edges.filter(
(e) =>
!ns.some((n) => n.id === e.source || n.id === e.target),
),
}));
}}
isValidConnection={(c) =>
!validateGraph(nodes, [...edges, { id: "candidate", ...c }])
.length
}
fitView
fitViewOptions={{ padding: 0.16 }}
minZoom={0.16}
maxZoom={1.6}
deleteKeyCode={["Backspace", "Delete"]}
connectOnClick
selectionOnDrag={false}
ariaLabelConfig={{
"controls.zoomIn.ariaLabel": "放大流程",
"controls.zoomOut.ariaLabel": "缩小流程",
"controls.fitView.ariaLabel": "显示完整流程",
"controls.interactive.ariaLabel": "切换交互",
}}
>
<Background color="#cdd8ce" gap={20} size={1} />
<Controls showInteractive={false} />
</ReactFlow>
{!nodes.length && (
<div className="canvas-empty">
<Workflow size={40} />
<h3>从第一个节点开始</h3>
<p>点击左侧添加节点,或者加载一个示例结构。</p>
<button
className="primary"
onClick={() => choosePreset("cascade")}
>
加载流式级联示例
</button>
</div>
)}
</div>
<div className="canvas-footer">
<span>
<i className="legend-critical" /> 首结果关键路径
</span>
<span>
<i className="legend-stream" /> 流式连接
</span>
<button
onClick={() => {
setPanel("edge");
setEdgeId(null);
}}
>
用表单添加连线 <Link2 size={12} />
</button>
</div>
</div>
<aside className="inspector">
<div className="inspector-tabs">
{[
["node", "节点"],
["edge", "连线"],
["global", "全局参数"],
].map(([v, l]) => (
<button
key={v}
className={panel === v ? "active" : ""}
onClick={() => setPanel(v)}
>
{l}
</button>
))}
</div>
<div className="inspector-body">
{panel === "node" && (
<NodeInspector
node={current}
result={sim.results?.[selected]}
patch={patchNode}
remove={removeNode}
duplicate={duplicate}
/>
)}
{panel === "edge" && (
<>
<div className="inspector-heading">
<div>
<span className="eyebrow">CONNECTION</span>
<h3>{edge ? "连线设置" : "添加连线"}</h3>
</div>
{edge && (
<button
className="icon-button"
aria-label="删除连线"
onClick={() => {
update({
...doc,
edges: edges.filter((e) => e.id !== edgeId),
});
setEdgeId(null);
}}
>
<Trash2 size={16} />
</button>
)}
</div>
{edge ? (
<>
<p className="edge-description">
{nodes.find((n) => n.id === edge.source)?.data.label}{" "}
<ArrowRight size={12} />{" "}
{nodes.find((n) => n.id === edge.target)?.data.label}
</p>
<Field label="下游何时开始">
<select
aria-label="连线触发方式"
value={edge.data?.release || "complete"}
onChange={(e) =>
update({
...doc,
edges: edges.map((x) =>
x.id === edgeId
? {
...x,
data: {
...x.data,
release: e.target.value,
},
}
: x,
),
})
}
>
{options([
["complete", "等完整结果"],
["first", "首包一到就开始"],
["chunk", "积累若干文本token后"],
])}
</select>
</Field>
{edge.data?.release === "chunk" && (
<Field label="需要积累的token">
<Num
label="连线积累token"
value={edge.data.tokens || 8}
min={1}
max={10000}
onChange={(v) =>
update({
...doc,
edges: edges.map((x) =>
x.id === edgeId
? { ...x, data: { ...x.data, tokens: v } }
: x,
),
})
}
/>
</Field>
)}
<Field
label="此连线网络时延(ms)"
hint="留空使用全局单程网络值。输入0表示同进程。"
>
<input
aria-label="连线网络时延"
type="number"
min="0"
max="100000"
value={edge.data?.networkMs ?? ""}
placeholder={`全局 ${g.networkMs} ms`}
onChange={(e) =>
update({
...doc,
edges: edges.map((x) =>
x.id === edgeId
? {
...x,
data: {
...x.data,
networkMs:
e.target.value === ""
? undefined
: Math.max(
0,
Number(e.target.value) || 0,
),
},
}
: x,
),
})
}
/>
</Field>
<Toggle
checked={edge.data?.control}
onChange={(v) =>
update({
...doc,
edges: edges.map((x) =>
x.id === edgeId
? { ...x, data: { ...x.data, control: v } }
: x,
),
})
}
>
标记为控制连线(虚线)
</Toggle>
<p className="micro-copy">
控制标记改变显示方式;仍按所选触发条件计算。打断分支通常应连接独立控制终点,并取消其“主结果”身份。
</p>
<button
className="outline full-width"
onClick={() => setEdgeId(null)}
>
<Plus size={14} /> 添加其他连线
</button>
</>
) : (
<>
<Field label="起点">
<select
aria-label="连线起点"
value={from}
onChange={(e) => setFrom(e.target.value)}
>
<option value="">选择节点</option>
{nodes
.filter((n) => n.data.kind !== "output")
.map((n) => (
<option value={n.id} key={n.id}>
{n.data.label}
</option>
))}
</select>
</Field>
<Field label="终点">
<select
aria-label="连线终点"
value={to}
onChange={(e) => setTo(e.target.value)}
>
<option value="">选择节点</option>
{nodes
.filter((n) => n.data.kind !== "input")
.map((n) => (
<option value={n.id} key={n.id}>
{n.data.label}
</option>
))}
</select>
</Field>
<Field label="触发方式">
<select
aria-label="新增连线触发方式"
value={release}
onChange={(e) => setRelease(e.target.value)}
>
{options([
["complete", "完整结果"],
["first", "首包"],
["chunk", "8个文本token"],
])}
</select>
</Field>
<button
className="primary full-width"
onClick={() =>
connect({ source: from, target: to, release })
}
>
<Link2 size={15} /> 连接节点
</button>
<p className="micro-copy">
同一节点连到多个下游就形成并行分支。多个分支连到汇总节点后,可在节点中选择等全部或取最早。
</p>
</>
)}
<div className="connection-list">
<h4>已有连线 · {edges.length}</h4>
{edges.map((e) => (
<button
key={e.id}
onClick={() => setEdgeId(e.id)}
className={edgeId === e.id ? "active" : ""}
>
<span>
{nodes.find((n) => n.id === e.source)?.data.label}
</span>
<ArrowRight size={12} />
<span>
{nodes.find((n) => n.id === e.target)?.data.label}
</span>
</button>
))}
</div>
</>
)}
{panel === "global" && (
<>
<div className="inspector-heading">
<div>
<span className="eyebrow">SCENARIO</span>
<h3>全局估算条件</h3>
</div>
</div>
<p className="micro-copy">
调整全流程的计算、程序和传输开销。这里的参数是设计假设,可用自己的实测替换。
</p>
<NumberField
data={g}
patch={patchGlobals}
keyName="computeScale"
label="全部模型耗时倍率"
min={0.1}
max={10}
step={0.1}
hint="1为当前基线,2为所有模型阶段慢一倍。"
/>
<NumberField
data={g}
patch={patchGlobals}
keyName="programScale"
label="全部程序 / 工具耗时倍率"
min={0.1}
max={10}
step={0.1}
/>
<div className="field-pair">
<NumberField
data={g}
patch={patchGlobals}
keyName="networkMs"
label="每条边单程网络(ms)"
max={100000}
/>
<NumberField
data={g}
patch={patchGlobals}
keyName="queueMs"
label="每次模型排队(ms)"
max={100000}
/>
</div>
<NumberField
data={g}
patch={patchGlobals}
keyName="vadMs"
label="用户停说后的判停等待(ms)"
max={10000}
hint="原生接续参考若已含判停,应设0避免重复。"
/>
<div className="field-pair">
<NumberField
data={g}
patch={patchGlobals}
keyName="inputSeconds"
label="用户输入音频(秒)"
min={0.1}
max={3600}
step={0.5}
/>
<NumberField
data={g}
patch={patchGlobals}
keyName="speechSeconds"
label="回复自然播放(秒)"
min={0.1}
max={3600}
step={0.5}
/>
</div>
<Field label="loop 计算情景">
<select
aria-label="loop计算情景"
value={g.loopPlan}
onChange={(e) =>
patchGlobals({ loopPlan: e.target.value })
}
>
{options([
["max", "跑满最大步数"],
["expected", "采用各节点预计步数"],
])}
</select>
</Field>
<Toggle
checked={g.sharedGPU}
onChange={(v) => patchGlobals({ sharedGPU: v })}
>
所有未分组模型共用同一GPU资源
</Toggle>
<p className="micro-copy">
共享组按拓扑顺序串行调度。这是保守资源假设;不模拟真实continuous
batching。各节点显式资源组优先。
</p>
<div className="duplex-callout">
<AudioLines size={20} />
<h4>全双工怎么表示</h4>
<p>
输入监听和输出生成可同时运行;用流式连线表示提前消费,用独立分支表示打断控制。图计算单次回复;长对话反馈通过节点loop建模。
</p>
</div>
</>
)}
</div>
</aside>
</div>
{sim.errors.length ? (
<div className="error-box">{sim.errors.join(" ")}</div>
) : (
<>
<div className="result-strip" aria-live="polite">
<div className="primary-result">
<span>
{sim.hasAudio ? "首段可播放音频" : "首个目标结果"}
<Tag>方案估计</Tag>
</span>
<strong>{ms(sim.first)}</strong>
<small>
敏感性范围 {ms(sim.range[0])} – {ms(sim.range[1])}
</small>
</div>
<div>
<span>全部主目标完成</span>
<strong>{ms(sim.complete)}</strong>
<small>音频目标包含 {g.speechSeconds}s 自然播放</small>
</div>
<div>
<span>跑满loop预算</span>
<strong>{ms(sim.maxFirst)}</strong>
<small>首结果 / 完成 {ms(sim.maxComplete)}</small>
</div>
<div>
<span>持续处理负载</span>
<strong className={sim.rtf > 1 ? "warn-text" : ""}>
{sim.rtf.toFixed(2)} <em>RTF</em>
</strong>
<small>
{sim.rtf > 1
? "当前假设可能跟不上实时"
: "理想资源下具备实时余量"}
</small>
</div>
</div>
<div className="result-note">
<Info size={15} />
<p>
范围只把模型和程序阶段乘0.75–1.5,固定网络/判停不变;不是p95或统计置信区间。改变模型、隐藏思考、共享资源或loop后会立即重算。打断控制能力仍需真实音频系统验证。
</p>
</div>
{sim.warnings.length > 0 && (
<details className="warnings">
<summary>
估算条件与待校准项 <span>{sim.warnings.length}</span>
<ChevronDown size={15} />
</summary>
<ul>
{sim.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</details>
)}
<Timeline
sim={sim}
nodes={nodes}
selected={selected}
setSelected={(id) => {
setSelected(id);
setPanel("node");
}}
/>
</>
)}
<datalist id="model-sizes">
{SIZE_OPTIONS.map((s) => (
<option value={s} key={s} />
))}
</datalist>
</div>
</section>
);
}
function Method() {
return (
<section id="method" className="method-section wrap">
<div className="section-title">
<div>
<div className="eyebrow">03 / THE MODEL BEHIND THE MODEL</div>
<h2>这些时间,怎么算。</h2>
<p>结果的可信度,取决于计时边界和输入假设是否说清。</p>
</div>
</div>
<div className="method-grid">
<article className="method-intro">
<h3>先定义你在等什么。</h3>
<p>
文本首token是模型开始生成的时间。隐藏思考可能发生在可见答案之前。语音首包是第一段可播放音频就绪的时间;终端播放还需要网络与缓冲。完整回复则要考虑所有输出和自然说话时长。
</p>
<div className="metric-defs">
<div>
<span>TTFT</span>
<p>首次模型token;对推理模型应另计首个可见答案。</p>
</div>
<div>
<span>TPOT</span>
<p>持续输出阶段每token平均耗时,近似1000÷token/s。</p>
</div>
<div>
<span>TTFA</span>
<p>首段可播放音频的时间,不能用文本TTFT代替。</p>
</div>
<div>
<span>RTF</span>
<p>处理/生成音频所需时间÷音频时长。小于1才有持续实时余量。</p>
</div>
</div>
</article>
<div className="formula-list">
<article>
<span>01</span>
<div>
<h3>串行与并行</h3>
<code>start(v) = max(各前驱满足条件的时刻)</code>
<p>
默认等全部分支。选择“任一就绪”取min;前提是最早分支的结果可用。独立硬件分支可以重叠,共享资源组会增加排队。
</p>
</div>
</article>
<article>
<span>02</span>
<div>
<h3>流式开始,持续供给</h3>
<code>首包 + (k − 1) / 输出速率</code>
<p>
文本积累k个token后启动下游。完成时刻还受上游最终数据就绪约束,避免把提前启动误当作全部工作已完成。这里采用平均供给率,未模拟音素对齐、每帧抖动与流控背压。
</p>
</div>
</article>
<article>
<span>03</span>
<div>
<h3>Agent loop</h3>
<code>
Σ[首token + (思考 + 输出 − 1) / 速率] + (N − 1) × 工具等待
</code>
<p>
每步可增加上下文;默认仅最后一步对下游放行。最大步数定义预算情景,不保证真实任务能在预算内成功。选择预计步数后,同时显示跑满预算的结果。
</p>
</div>
</article>
<article>
<span>04</span>
<div>
<h3>参数规模外推</h3>
<code>速率 = H20锚点 × 硬件系数 × 量化系数 × 并行收益</code>
<p>
稠密锚点采用Qwen3官方FP8数据做对数插值;32B以上按反比外推。MoE用激活参数并乘额外开销。卡数收益上限4倍,不能靠无限加卡把时延降到零。
</p>
</div>
</article>
</div>
</div>
<details className="calibration-details">
<summary>
查看全部默认系数与计算限制 <ChevronDown size={16} />
</summary>
<div className="calibration-grid">
<div>
<h4>可复查的默认值</h4>
<table>
<thead>
<tr>
<th>硬件</th>
<th>相对H20速率系数</th>
</tr>
</thead>
<tbody>
{HARDWARE.map((h) => (
<tr key={h.id}>
<td>{h.name}</td>
<td>{h.factor}×(假设)</td>
</tr>
))}
</tbody>
</table>
<p>
量化速率系数:BF16 0.62,FP8 1,INT4
1.06。它们是近似值,不代表所有模型的固定规律。
</p>
</div>
<div>
<h4>首token与显存</h4>
<p>
Prefill默认8B/H20为18,000
token/s,固定开销14ms;随激活规模、缓存和硬件调整。缺少首token来源时使用该模型。公开记录中的硬件条件固定;更换条件应切回工程估算。
</p>
<p>
权重显存粗算:BF16 2字节/参数,FP8 1.05,INT4
0.55;另加max(2GB,8%)运行余量。这不是完整KV-cache容量规划。
</p>
<h4>未建模的因素</h4>
<p>
任务正确性、语音自然度、错误重试、冷启动、长上下文attention非线性、真实调度、网络抖动、设备音频延迟与多用户拥塞。模型间tokenizer也不同,不把token直接等同汉字。
</p>
</div>
</div>
</details>
<div className="reading-note">
<h3>怎样理解“理想时延”</h3>
<p>
统一条件估算采用已热身、低并发、稳定网络。端到端语音通过边听边处理缩短停说后的等待,不能因此声称复杂任务已完成。一个170ms的接续可以很自然,却不能代表检索、工具和多轮Agent也在170ms内结束。网站中的数据为公开资料调研,本次没有运行模型效果或硬件性能实验。
</p>
<p>
覆盖公开可核验的代表性尺寸与部署条件;不穷尽全部模型。未公开的参数规模保持未知,缺少首音频数据时也保留空缺。
</p>
</div>
</section>
);
}
function Sources() {
return (
<section id="sources" className="sources-section wrap">
<div className="section-title">
<div>
<div className="eyebrow">04 / PRIMARY SOURCES</div>
<h2>让每个数字,都有来处。</h2>
<p>截止 {CUTOFF} 查阅。只引用官方文档、官方模型卡或原始研究论文。</p>
</div>
<Tag>{Object.keys(SOURCES).length} 个一手来源</Tag>
</div>
<div className="source-list">
{Object.entries(SOURCES).map(([id, s], i) => (
<article key={id}>
<span className="source-number">
{String(i + 1).padStart(2, "0")}
</span>
<div>
<a href={s.url} target="_blank" rel="noreferrer">
{s.title}
<ArrowUpRight size={16} />
</a>
<span>
{s.publisher} / {s.date}
</span>
<p>{s.note}</p>
</div>
</article>
))}
</div>
</section>
);
}
export default function App() {
const [tab, setTab] = useState(() =>
location.hash === "#studio"
? "studio"
: location.hash === "#method"
? "method"
: location.hash === "#sources"
? "sources"
: "overview",
),
[doc, setDoc] = useState(loadSaved),
[selected, setSelected] = useState(null),
[message, setMessage] = useState(""),
[mobileMenu, setMobileMenu] = useState(false);
const toastTimer = useRef();
const toast = (text) => {
setMessage(text);
clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setMessage(""), 4500);
};
useEffect(() => {
const timer = setTimeout(() => {
try {
localStorage.setItem(
STORE,
JSON.stringify(
toDocument(doc.nodes, doc.edges, doc.globals, doc.name),
),
);
} catch {
toast("本机存储不可用,请导出JSON保存流程。");
}
}, 500);
return () => clearTimeout(timer);
}, [doc]);
const navigate = (t) => {
setTab(t);
setMobileMenu(false);
history.replaceState(null, "", t === "overview" ? "#overview" : `#${t}`);
window.scrollTo({ top: 0, behavior: "instant" });
};
const addProfile = (r) => {
const id = `n-${Date.now().toString(36)}`,
kind =
r.modality === "text" ? "llm" : r.modality === "tts" ? "tts" : "speech",
data = {
...nodeData(kind, r.name),
mode: "reference",
profileId: r.id,
...(r.size ? { size: r.size } : {}),
...(r.active ? { active: r.active, arch: "moe" } : {}),
};
setDoc((prev) => ({
...prev,
name: "自定义对话流程",
nodes: [
...prev.nodes,
{
id,
type: "stage",
position: { x: 120, y: 120 + prev.nodes.length * 30 },
data,
},
],
}));
setSelected(id);
navigate("studio");
toast("已添加参考节点;首包或持续速度缺失时会明确使用工程假设。");
};
return (
<>
<header className="site-header">
<a
className="brand"
href="#overview"
onClick={(e) => {
e.preventDefault();
navigate("overview");
}}
>
<span className="brand-icon">
<Activity size={23} />
</span>
<span>
模型时延实验室<small>LATENCY LAB</small>
</span>
</a>
<nav className={mobileMenu ? "open" : ""}>
{[
["overview", "模型对照"],
["studio", "流程实验室"],
["method", "计算方法"],
["sources", "资料来源"],
].map(([v, l]) => (
<button
key={v}
className={tab === v ? "active" : ""}
onClick={() => navigate(v)}
>
{l}
{v === "studio" && <span className="nav-new">交互</span>}
</button>
))}
</nav>
<div className="header-right">
<a href="https://blog.k1412.top" target="_blank" rel="noreferrer">
K1412 RESEARCH <ArrowUpRight size={14} />
</a>
<button
className="menu-button"
aria-label="打开导航"
onClick={() => setMobileMenu(!mobileMenu)}
>
{mobileMenu ? <X /> : <Menu />}
</button>
</div>
</header>
<main>
{tab === "overview" && (
<>
<Hero
onStart={() => navigate("studio")}
onCompare={() =>
document
.getElementById("compare")
.scrollIntoView({ behavior: "smooth" })
}
/>
<Comparison addProfile={addProfile} />
<section className="studio-invite wrap">
<div>
<span className="eyebrow">FROM A NUMBER TO A SYSTEM</span>
<h2>
一个模型快,
<br />
整条链路也快吗?
</h2>
<p>
把多个模型、工具和语音模块连起来,找出真正决定体验的那条路径。
</p>
</div>
<button
className="primary light"
onClick={() => navigate("studio")}
>
打开流程实验室 <ArrowRight size={17} />
</button>
</section>
</>
)}
{tab === "studio" && (
<Studio
doc={doc}
setDoc={setDoc}
selected={selected}
setSelected={setSelected}
toast={toast}
/>
)}{" "}
{tab === "method" && <Method />}
{tab === "sources" && <Sources />}
</main>
<footer className="site-footer wrap">
<div>
<span className="brand-word">LATENCY LAB</span>
<p>公开证据 × 可校准的系统估算</p>
</div>
<div>
<span>资料截止 {CUTOFF}</span>
<a
href="https://git.k1412.top/wuyang/latency-lab"
target="_blank"
rel="noreferrer"
>
源代码与数据 <ExternalLink size={13} />
</a>
<button onClick={() => navigate("method")}>计算假设与边界</button>
</div>
<small>{RELEASE} · 本机计算与存储,无需模型API密钥</small>
</footer>
{message && (
<div className="toast" role="status">
<Info size={17} />
{message}
<button aria-label="关闭提示" onClick={() => setMessage("")}>
<X size={15} />
</button>
</div>
)}
</>
);
}