2600 lines
87 KiB
React
2600 lines
87 KiB
React
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|