Publish Sophon Realtime calling tool protocol and latency guide

This commit is contained in:
wuyang6
2026-11-09 12:30:33 +08:00
commit 83f47dd857
17 changed files with 2244 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
dist
node_modules
*.log
work
.env*
+6
View File
@@ -0,0 +1,6 @@
dist/
node_modules/
.DS_Store
.env*
work/
*.log
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json build.mjs index.html styles.css app.js model.js favicon.svg robots.txt release.json ./
RUN npm run build
FROM nginx:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chmod=0644 /app/dist/ /usr/share/nginx/html/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 K1412
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+15
View File
@@ -0,0 +1,15 @@
# Sophon Realtime 调用与工具机制研究
独立研究网页,提供模型构建、调用入口、工具运行流程、公开指标、时延口径与协议覆盖的源代码核对。
公开地址为 https://sophon-realtime.k1412.top/ 。原模型项目为 https://github.com/adventists-ai/sophon-realtime 。
## 开发
使用 Node.js 20 或更新版本,运行 `npm run check` 与 `npm run build`。运行 `npm run dev` 后可在终端显示的本地地址预览。
页面全部使用本地静态资源。交互生成启动步骤与协议片段,工具过程使用显式标注的合成示例。GPU推理与真实业务工具执行的评测见正文验收边界。
## 许可
本站原创代码、文字与绘图代码采用 MIT 许可。外部模型、代码与数据遵循其自身许可和声明,正文列出了需要澄清的上游声明差异。
+150
View File
@@ -0,0 +1,150 @@
import { builds, callModes, makeRecipe, toolSteps } from "./model.js";
const buildSelect = document.getElementById("build-select");
const modeSelect = document.getElementById("mode-select");
let currentBuild = "flash";
let currentStep = 0;
let currentPart = "startup";
function renderBuild() {
const selected = builds[currentBuild];
const flow = document.getElementById("build-flow");
flow.replaceChildren();
selected.flow.forEach((label, index) => {
if (index) {
const arrow = document.createElement("b");
arrow.textContent = "→";
arrow.setAttribute("aria-hidden", "true");
flow.append(arrow);
}
const node = document.createElement("span");
node.textContent = label;
flow.append(node);
});
document.getElementById("build-detail").textContent = selected.detail;
document.querySelectorAll("[data-build]").forEach((button) => {
button.setAttribute(
"aria-pressed",
String(button.dataset.build === currentBuild),
);
});
buildSelect.value = currentBuild;
const previousMode = modeSelect.value;
modeSelect.replaceChildren();
for (const key of selected.modes) {
const option = document.createElement("option");
option.value = key;
option.textContent = callModes[key].title;
modeSelect.append(option);
}
modeSelect.value = selected.modes.includes(previousMode)
? previousMode
: selected.modes[0];
renderRecipe();
}
function renderRecipe() {
const mode = callModes[modeSelect.value];
document.getElementById("endpoint-kind").textContent = mode.kind;
document.getElementById("endpoint-value").textContent = mode.endpoint;
document.getElementById("endpoint-detail").textContent = mode.detail;
document.getElementById("recipe-label").textContent =
`${builds[currentBuild].title} · ${mode.title}`;
document.getElementById("call-recipe").textContent = makeRecipe(
currentBuild,
modeSelect.value,
)[currentPart];
document.querySelectorAll("[data-part]").forEach((button) => {
button.setAttribute(
"aria-pressed",
String(button.dataset.part === currentPart),
);
});
document.getElementById("copy-status").textContent = "";
}
function renderStep() {
const selected = toolSteps[currentStep];
document.getElementById("step-number").textContent = String(
currentStep + 1,
).padStart(2, "0");
document.getElementById("step-title").textContent = selected.title;
document.getElementById("step-explanation").textContent =
selected.explanation;
document.getElementById("step-code").textContent = selected.code;
document.getElementById("step-progress").textContent =
`${currentStep + 1} / ${toolSteps.length}`;
document.getElementById("previous-step").disabled = currentStep === 0;
document.getElementById("next-step").disabled =
currentStep === toolSteps.length - 1;
document.querySelectorAll("[data-step]").forEach((button) => {
button.setAttribute(
"aria-pressed",
String(Number(button.dataset.step) === currentStep),
);
});
}
const stepList = document.getElementById("tool-steps");
toolSteps.forEach((step, index) => {
const button = document.createElement("button");
button.type = "button";
button.dataset.step = String(index);
button.textContent = `${index + 1} ${step.title}`;
button.addEventListener("click", () => {
currentStep = index;
renderStep();
});
stepList.append(button);
});
document.querySelectorAll("[data-build]").forEach((button) => {
button.addEventListener("click", () => {
currentBuild = button.dataset.build;
renderBuild();
});
});
buildSelect.addEventListener("change", () => {
currentBuild = buildSelect.value;
renderBuild();
});
modeSelect.addEventListener("change", renderRecipe);
document.querySelectorAll("[data-part]").forEach((button) => {
button.addEventListener("click", () => {
currentPart = button.dataset.part;
renderRecipe();
});
});
document.getElementById("previous-step").addEventListener("click", () => {
currentStep -= 1;
renderStep();
});
document.getElementById("next-step").addEventListener("click", () => {
currentStep += 1;
renderStep();
});
document.getElementById("copy-recipe").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(
document.getElementById("call-recipe").textContent,
);
document.getElementById("copy-status").textContent = "已复制调用示例。";
} catch {
document.getElementById("copy-status").textContent =
"可以在上方代码中手动选择并复制。";
}
});
const themeButton = document.getElementById("theme-toggle");
themeButton.addEventListener("click", () => {
const theme =
document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = theme;
themeButton.setAttribute(
"aria-label",
theme === "dark" ? "切换浅色主题" : "切换深色主题",
);
});
renderBuild();
renderStep();
+17
View File
@@ -0,0 +1,17 @@
import { mkdirSync, copyFileSync } from "node:fs";
const files = [
"index.html",
"styles.css",
"app.js",
"model.js",
"favicon.svg",
"robots.txt",
"release.json",
];
mkdirSync(new URL("./dist/", import.meta.url), { recursive: true });
for (const file of files)
copyFileSync(
new URL(file, import.meta.url),
new URL(`dist/${file}`, import.meta.url),
);
console.log(`Built ${files.length} public assets.`);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#287557"/><path d="M17 19v27m0-11 16-16m-13 13 15 14" stroke="#f7f6f2" stroke-width="5" fill="none" stroke-linecap="round"/><circle cx="45" cy="44" r="4" fill="#f7f6f2"/></svg>

After

Width:  |  Height:  |  Size: 280 B

+796
View File
@@ -0,0 +1,796 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta
name="description"
content="基于公开代码核对 Sophon Realtime 的自托管入口、工具调用机制、Live委派与时延,提供调用路径和可复核的协议示例。"
/>
<meta name="theme-color" content="#f4f3ef" />
<meta
property="og:title"
content="Sophon Realtime 的调用、工具机制与时延"
/>
<meta property="og:type" content="article" />
<link rel="canonical" href="https://sophon-realtime.k1412.top/" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="stylesheet" href="styles.css" />
<script type="module" src="app.js"></script>
<title>Sophon Realtime 的调用、工具机制与时延 · K1412 Research</title>
</head>
<body id="top">
<a class="skip-link" href="#main">跳到正文</a>
<header class="site-header">
<a class="brand" href="https://blog.k1412.top"
><b>k.</b><span>K1412 <i>RESEARCH</i></span></a
>
<nav aria-label="顶部导航">
<a href="#calling">调用入口</a><a href="#tools">工具机制</a
><a href="https://voice-duplex.k1412.top/">双工评测 ↗</a
><button id="theme-toggle" type="button" aria-label="切换深浅主题">
◐
</button>
</nav>
</header>
<div class="layout">
<aside class="contents">
<span class="eyebrow">MODEL STUDY / 02</span>
<nav aria-label="文章目录">
<a href="#readiness">01 可调用性</a
><a href="#architecture">02 模型与构建</a
><a href="#calling">03 接口与调用</a><a href="#tools">04 工具机制</a
><a href="#capability">05 工具能力证据</a
><a href="#speed">06 与 GPT-Live 的时延</a
><a href="#limits">07 兼容性与验证</a><a href="#sources">08 来源</a>
</nav>
<div class="version-note">
资料核对<br /><strong>2026.11.09</strong><br /><span
>代码 b2754d47<br />GPU 推理待实测</span
>
</div>
</aside>
<main id="main">
<section class="hero">
<span class="eyebrow">PUBLIC MODEL · SOURCE AUDIT · API GUIDE</span>
<h1>Sophon Realtime 的<br />调用、工具机制与时延</h1>
<p class="deck">
沿着模型、协议与执行程序,<br />核对一条语音请求怎样真正调用工具。
</p>
<div class="hero-meta">
<span>模型研究</span><span>2026 年 11 月 9 日</span
><a href="https://github.com/adventists-ai/sophon-realtime"
>原始项目 ↗</a
>
</div>
<div
class="hero-route"
aria-label="语音经过模型生成结构化调用,外部工具执行后回到语音回应"
>
<span>语音</span><b>→</b><span>模型</span><b>→</b
><code>&lt;tool_call&gt;</code><b>→</b><span>外部执行</span><b>→</b
><span>语音回应</span>
</div>
<p class="lead">
Sophon 已公开模型权重、服务代码和客户端示例。Tag 与 Flash
提供自托管调用路径,Live
网关连接前台语音与后台代理。实际接入需要同时核对<strong>模型能力、协议覆盖、外部执行和时延口径</strong>。
</p>
<p>
本页以公开仓库提交
<code>b2754d4753e20ecfa860db0cda4325e4479f4427</code>
为代码依据。作者测量、代码观察和说明性示例分别标记,GPU
端到端调用列为待验证项。
</p>
</section>
<section id="readiness" class="article-section">
<div class="section-number">01 / READINESS</div>
<h2>当前有哪些可调用路径</h2>
<p>
Tag、Flash 和 E2E 均有公开权重目录。前两种构建走 vLLM-Omni,E2E
使用单独的研究预览引擎。仓库还提供客户端、启动脚本和网关,已经具备可检查的部署链路。<a
class="ref"
href="#source-root"
>[1]</a
>
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>构建</th>
<th>作者文档中的资源</th>
<th>主要入口</th>
<th>当前定位</th>
</tr>
</thead>
<tbody>
<tr>
<th>Tag</th>
<td>单张 80 GB NVIDIA GPU,实例约 65 GB 显存</td>
<td>HTTP + Realtime WebSocket</td>
<td>强调笑声、叹气等表现力</td>
</tr>
<tr>
<th>Flash</th>
<td>单张 80 GB NVIDIA GPU,实例约 55 GB 显存</td>
<td>HTTP + Realtime WebSocket</td>
<td>强调首音速度与并发</td>
</tr>
<tr>
<th>E2E</th>
<td>两张 80 GB GPU</td>
<td>独立网关 <code>/ws</code></td>
<td>连续向量语音生成的研究预览</td>
</tr>
</tbody>
</table>
</div>
<p class="source-line">
资源与入口来源
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/tag/README.md"
>Tag</a
>、<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/flash/README.md"
>Flash</a
>、<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/docs/deploy.md"
>E2E 部署文档</a
>。
</p>
<div class="evidence-note">
<strong>自托管与在线体验分别确认</strong>
<p>
官方模型卡给出了 ModelScope
体验页。当前核对支持“模型和服务代码可取得”,可直接申请密钥的
Sophon 托管 API
尚缺公开调用资料,体验页的实际语音会话也需单独验证。PyPI 已有
<code>sophon-realtime 1.0.0</code>,Tag / Flash 脚本使用 Python
3.12。<a
href="https://www.modelscope.cn/studios/AdventistsAI/Sophon-Realtime"
>体验页 ↗</a
>
<a href="https://pypi.org/project/sophon-realtime/">PyPI ↗</a>
</p>
</div>
</section>
<section id="architecture" class="article-section">
<div class="section-number">02 / ARCHITECTURE</div>
<h2>三种构建共享怎样的语音骨干</h2>
<p>
共同骨干由语音特征进入 32B 模型,承担理解、话轮决策和文本回答。Tag
与 Flash 用不同语音模块朗读带表现标签的回答。E2E
则把语音生成改为连续向量与专用解码。<a
class="ref"
href="#source-root"
>[1]</a
>
</p>
<div class="figure">
<div class="figure-heading">
<div>
<span class="eyebrow">BUILD EXPLORER</span>
<h3>选择构建,查看生成路径</h3>
</div>
<span class="tag audit">代码与文档核对</span>
</div>
<div class="segmented" role="group" aria-label="选择 Sophon 构建">
<button type="button" data-build="flash" aria-pressed="true">
Flash</button
><button type="button" data-build="tag" aria-pressed="false">
Tag</button
><button type="button" data-build="e2e" aria-pressed="false">
E2E 预览
</button>
</div>
<div id="build-flow" class="flow" aria-live="polite"></div>
<div
id="build-detail"
class="selected-detail"
aria-live="polite"
></div>
</div>
<p>
Tag 的 NOTICE 列出 Qwen3-VL-32B、DuplexJev-32B-Para、Qwen3-ASR
编码器及 tag-tts
等来源。当前构建把语言、声音和话轮决策组合起来,“一个服务”与内部推理阶段数需要分别描述。<a
class="ref"
href="#source-notice"
>[2]</a
>
</p>
<p>
Flash 的 80 ms 描述语音生成帧。持续听说的响应还受
VAD、话轮判断、生成调度和播放缓存影响。配置中仍可见外部语音活动检测,模型话轮判断与工程控制共同参与交互。<a
class="ref"
href="#source-flash"
>[3]</a
>
</p>
</section>
<section id="calling" class="article-section">
<div class="section-number">03 / CALLING</div>
<h2>从语音接口到工具接口</h2>
<p>
先确认语音引擎正常返回,再检查连续听说,最后接真实工具和后台。以下示例含可复制的启动步骤与协议片段。模型权重应固定
revision,并预先放入相应 <code>MODEL_DIR</code>。
</p>
<div class="figure">
<div class="figure-heading">
<div>
<span class="eyebrow">REQUEST GUIDE</span>
<h3>按任务选择调用入口</h3>
</div>
<span class="tag example">调用示例</span>
</div>
<div class="call-controls">
<label for="build-select"
>模型构建<select id="build-select">
<option value="flash">Flash</option>
<option value="tag">Tag</option>
<option value="e2e">E2E 预览</option>
</select></label
><label for="mode-select"
>接口路径<select id="mode-select"></select
></label>
</div>
<div class="endpoint">
<span id="endpoint-kind">HTTP</span
><code id="endpoint-value"></code>
</div>
<p id="endpoint-detail" class="small"></p>
<div class="recipe-parts" role="group" aria-label="示例内容">
<button type="button" data-part="startup" aria-pressed="true">
启动与验证</button
><button type="button" data-part="request" aria-pressed="false">
协议消息
</button>
</div>
<div class="code-heading">
<span id="recipe-label"></span
><button type="button" id="copy-recipe">复制示例</button>
</div>
<pre><code id="call-recipe"></code></pre>
<p id="copy-status" class="small" aria-live="polite"></p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>端口与角色</th>
<th>协议</th>
<th>接入要求</th>
</tr>
</thead>
<tbody>
<tr>
<th>8000 基础引擎</th>
<td>
<code>/v1/chat/completions</code><br /><code
>/v1/realtime</code
>
</td>
<td>
Tag / Flash
语音生成与双工引擎。真实工具需显式选择客户端模式并接工具桥。
</td>
</tr>
<tr>
<th>8700 本地工具桥</th>
<td>
<code>/bridge/&lt;key&gt;/events</code><br /><code
>/bridge/&lt;key&gt;/result</code
>
</td>
<td>
默认由引擎启动脚本按
<code>PORT + 700</code>
创建,绑定本机,用于外层网关取调用与回填结果。
</td>
</tr>
<tr>
<th>8780 外层网关</th>
<td>
<code>/v1/realtime</code><br /><code
>/v1/live/sessions</code
>
</td>
<td>
分别适配函数调用事件与 Live 客户端委派。额外 ASR 约占 2 GB
GPU 显存。
</td>
</tr>
<tr>
<th>E2E 独立网关</th>
<td><code>/ws</code></td>
<td>
使用自身的配置、音频、打断与结束事件,按研究预览部署文档接入。
</td>
</tr>
</tbody>
</table>
</div>
<p class="source-line">
接口依据
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/live/sophon_live/gateway.py"
>gateway.py</a
>、<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/live/sophon_live/adapters.py"
>adapters.py</a
>
与各构建文档。
</p>
<h3>HTTP 音频流有自己的数据形状</h3>
<p>
公开客户端先读 <code>GET /v1/models</code>,使用官方系统提示和
<code>input_audio</code> 发送完整 WAV。响应按 SSE 流式返回,顶层
<code>modality == "audio"</code> 时,音频数据位于
<code>choices[].delta.content</code>,内容为 base64 WAV 块,24
kHz。接入 SDK 时应按该实际字段解码。<a
class="ref"
href="#source-client"
>[4]</a
>
</p>
</section>
<section id="tools" class="article-section">
<div class="section-number">04 / TOOL CALLING</div>
<h2>提示词、调用标记和外部执行怎样配合</h2>
<p>
运行时把函数 schema 送入 chat template,模型按 Qwen/Hermes
格式输出函数名与参数。解析器读取
<code>&lt;tool_call&gt;</code>
分隔符,语音模块只接收前面的口语内容。外部程序执行函数后,结果回到模型上下文中,模型再生成语音回应。<a
class="ref"
href="#source-tools"
>[5]</a
>
</p>
<div class="figure">
<div class="figure-heading">
<div>
<span class="eyebrow">CALL TRACE</span>
<h3>逐步查看一条工具请求</h3>
</div>
<span class="tag example">合成示例</span>
</div>
<div
id="tool-steps"
class="step-list"
role="group"
aria-label="工具调用步骤"
></div>
<div class="step-head">
<span id="step-number"></span>
<h4 id="step-title"></h4>
</div>
<p id="step-explanation"></p>
<pre><code id="step-code"></code></pre>
<div class="step-controls">
<button type="button" id="previous-step">上一步</button
><span id="step-progress" aria-live="polite"></span
><button type="button" id="next-step">下一步</button>
</div>
</div>
<h3>“特殊 token”需要看两个层次</h3>
<p>
已核对的 Tag tokenizer 中,<code>&lt;tool_call&gt;</code> 与
<code>&lt;/tool_call&gt;</code> 对应独立词表 ID
<code>151657</code> 和 <code>151658</code>,其
<code>special</code> 字段均为
<code>false</code>。服务代码再按分隔符字符串解析
JSON。标记负责表示调用,函数执行由运行时和客户端完成。<a
class="ref"
href="#source-tokenizer"
>[6]</a
>
</p>
<div class="two-paths">
<div>
<span class="eyebrow">DIRECT TOOLS</span>
<h3>前台生成业务工具调用</h3>
<p>
给模型具体函数定义,例如
<code>get_weather(city)</code>。模型填写函数名和参数,网关转换成
<code>function_call</code> 事件,客户端执行并回传
<code>function_call_output</code>。
</p>
</div>
<div>
<span class="eyebrow">LIVE DELEGATION</span>
<h3>前台把任务交给后台</h3>
<p>
前台只拿到 <code>delegate(task)</code>。网关发出
<code>session.delegation.created</code
>,后台代理使用业务工具,结果经
<code>session.commentary.append</code> 返回语音前台。
</p>
</div>
</div>
<p>
启用 <code>SOPHON_LIVE_BACKEND</code> 后,代码会把前台工具替换为
<code>delegate</code
>,由配置的后台模型持有具体工具。该配置的表现应归于前后台系统。<a
class="ref"
href="#source-backend"
>[7]</a
>
</p>
<div class="evidence-note">
<strong>真实工具执行要显式接通</strong>
<p>
基础引擎的默认工具模式是
mock。未提供匹配模拟结果时,代码仍可返回含
<code>ok: true</code> 和
<code>note: "mock result"</code> 的对象。真实调用需要
<code>tool_mode: "client"</code>、会话工具桥及执行端。8780
网关适配器会设置客户端模式。<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/tag/plugin/sophon_realtime/duplex.py"
>引擎代码 ↗</a
>
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/live/sophon_live/adapters.py"
>适配器 ↗</a
>
</p>
</div>
<p>
公开微调样例也采用函数 schema、助手调用标记和
<code>role: tool</code>
结果。这展示了工具格式的训练路径,完整工具训练集及发布成绩的全部运行配置仍需更多资料。<a
class="ref"
href="#source-finetune"
>[8]</a
>
</p>
</section>
<section id="capability" class="article-section">
<div class="section-number">05 / EVIDENCE</div>
<h2>工具能力有哪些公开指标</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>证据</th>
<th>作者报告</th>
<th>解释范围</th>
</tr>
</thead>
<tbody>
<tr>
<th>FDB-v3 工具任务</th>
<td>
Pass@1 <strong>48%</strong><br />回应率
<strong>100%</strong>
</td>
<td>
作者自测,并注明从多次运行中取最佳。与原论文其他系统的历史成绩分别看待。
</td>
</tr>
<tr>
<th>Live 小样本委派</th>
<td>Tag / Flash 需委派项均为 <strong>7/7</strong></td>
<td>
14 条录音,7 条应委派、7 条应直接回答,使用 mock 后台。
</td>
</tr>
<tr>
<th>避免不必要委派</th>
<td>
Tag <strong>6/7</strong><br />Flash <strong>5/7</strong>
</td>
<td>说明部分可直接回答的问题仍会交给后台。样本规模有限。</td>
</tr>
</tbody>
</table>
</div>
<p class="source-line">
来源
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/README.md"
>作者评测说明</a
>
与
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/live/README.md"
>Live 测试说明</a
>。
</p>
<p>
完整接入还需要分开测量工具选择、参数正确、外部执行结果、重复调用、任务终态和正确告知。前台、后台、提示规则与参数校验配置都影响结果,应记录为同一份系统配置。
</p>
<p>
网关包含工具名检查与参数来源检查,结合 ASR、先前工具结果、指令及
schema
决定是否要求模型重做。当前代码还给拒绝重试设置了次数上限。公开的
FDB-v3 成绩没有充分说明这些配置的开启情况。<a
class="ref"
href="#source-grounding"
>[9]</a
>
</p>
</section>
<section id="speed" class="article-section">
<div class="section-number">06 / LATENCY</div>
<h2>回复速度与 GPT-Live 怎样比较</h2>
<p>
Flash 的作者报告显示较短的 HTTP 首包路径。Tag 的双工报告值和
GPT-Live
的官方轮替值接近,整体体验仍需统一环境、输入与计时终点后比较。
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>配置</th>
<th>报告值</th>
<th>计时对象</th>
</tr>
</thead>
<tbody>
<tr>
<th>Sophon Flash</th>
<td><strong>0.31 s</strong> 中位数,P90 0.34 s</td>
<td>
一张 H100 80 GB、单条 HTTP 音频请求,客户端 POST
前开始计时,到首个音频块。
</td>
</tr>
<tr>
<th>Sophon Tag</th>
<td><strong>约 0.7 s</strong></td>
<td>作者文档的全双工“用户语音结束到首音”口径。</td>
</tr>
<tr>
<th>GPT-Live-1</th>
<td><strong>0.798 s</strong></td>
<td>官方发布图中的 FDB-v1 轮替时延。</td>
</tr>
</tbody>
</table>
</div>
<p class="source-line">
来源
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/flash/README.md"
>Flash 测量条件</a
>、<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/flash/client_example.py"
>客户端计时</a
>、<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/tag/README.md"
>Tag</a
>、<a
href="https://openai.com/index/introducing-gpt-live-1-in-the-api/"
>GPT-Live 发布材料</a
>。
</p>
<div class="clock-boundaries">
<div>
<span>完整交互时间轴</span
><strong
>用户说完 → 上行与话轮判断 → 模型 / 工具 → 解码 →
实际播放</strong
>
</div>
<div>
<span>Flash 公开客户端的计时</span
><strong>提交已读入的 WAV → 收到并解码首块音频</strong>
</div>
</div>
<p>
单看这些数值还无法给出同场速度排名。工具请求又有三个不同时间点,首次承接、工具派发、首个正确结果。先说“我查一下”可以很快,结果仍取决于后台模型与实际工具。
</p>
<h3>并发需要附带音频连续性</h3>
<p>
Flash 作者在 40 路请求、10 秒内到达时,报告首音中位数 0.58 s、P90
0.67 s,stall 中位数 0.37 s。Tag 在 16 路时报告首音中位数 0.75
s、最大 1.07
s。并发数、尾时延和播放停顿一起报告,才有明确服务含义。<a
class="ref"
href="#source-flash"
>[3]</a
>
<a
class="ref"
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/tag/README.md"
>Tag 测量</a
>
</p>
</section>
<section id="limits" class="article-section">
<div class="section-number">07 / INTEGRATION</div>
<h2>接入时需要核对的实际边界</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>观察项</th>
<th>当前代码或资料</th>
<th>验收动作</th>
</tr>
</thead>
<tbody>
<tr>
<th>Realtime 取消</th>
<td><code>response.cancel</code> 当前为空操作。</td>
<td>
验证调用方取消能否停止生成与播放,并单测后台任务状态。
</td>
</tr>
<tr>
<th>历史截断</th>
<td>
<code>conversation.item.truncate</code>
当前主要回确认事件,截断尚未传播到引擎。
</td>
<td>用已播放、未播放的事实分别测试后续上下文。</td>
</tr>
<tr>
<th>文本输入</th>
<td>
普通文本
<code>conversation.item.create</code>
会被记录为忽略,工具结果输入单独处理。
</td>
<td>按实际客户端所用的消息类型逐项验证。</td>
</tr>
<tr>
<th>参数事件</th>
<td>
完整参数 JSON 可一次放进
<code>arguments.delta</code>,随后发 done。
</td>
<td>记录实际派发时间,区分事件命名与逐 token 执行。</td>
</tr>
<tr>
<th>预热与首会话</th>
<td>脚本包含预热,Live 文档记录首次会话可能无回应。</td>
<td>区分冷启动、预热和稳定服务样本,保留失败。</td>
</tr>
<tr>
<th>E2E 预览</th>
<td>模型目录与部署文档更新节奏有差异。</td>
<td>固定实际权重 revision、运行入口与引擎版本。</td>
</tr>
<tr>
<th>许可材料</th>
<td>
Sophon NOTICE 标为 Apache-2.0,上游 Para 模型说明含 CC BY-NC
4.0。
</td>
<td>适用权重许可范围需要发布方澄清。</td>
</tr>
</tbody>
</table>
</div>
<p class="source-line">
代码与声明
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/live/sophon_live/realtime.py"
>Realtime 适配器</a
>、<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/tag/NOTICE"
>Sophon NOTICE</a
>、<a href="https://github.com/adventists-ai/duplexjev#license"
>上游许可说明</a
>。
</p>
<p>
当前材料支持继续开展自托管和接口验证。模型效果、真实工具执行、API
协议覆盖与语音体验分别验收。接入与配置变更后的结果,应重新建立固定版本、固定场景的证据。
</p>
<a class="text-link" href="https://voice-duplex.k1412.top/#evaluation"
>完整的双工评测标准与指标方案 ↗</a
>
</section>
<section id="sources" class="article-section">
<div class="section-number">08 / SOURCES</div>
<h2>来源与核对范围</h2>
<p>
截至 2026 年 11 月 9
日。模型指标采用作者报告值,接口行为采用固定提交的代码观察,交互步骤使用合成示例。GPU
推理、真实声学链路与外部业务执行尚待实测。
</p>
<ol class="source-list">
<li id="source-root">
<a
href="https://github.com/adventists-ai/sophon-realtime/tree/b2754d4753e20ecfa860db0cda4325e4479f4427"
>Sophon Realtime 原始项目</a
><span
>构建与公开评测说明。本文独立解读,代码版本固定为
b2754d47。</span
>
</li>
<li id="source-notice">
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/tag/NOTICE"
>Tag NOTICE</a
><span>骨干、音频编码器与语音模块来源。</span>
</li>
<li id="source-flash">
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/flash/README.md"
>Flash README</a
><span>80 ms 声学帧、HTTP 首包与并发测量条件。</span>
</li>
<li id="source-client">
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/tag/client_example.py"
>Tag 客户端</a
><span>模型 ID 获取、请求体与音频流解码。</span>
</li>
<li id="source-tools">
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/tag/plugin/sophon_realtime/tools.py"
>工具解析与口语流裁剪</a
><span>提示模板、JSON 解析、mock 与工具标记。</span>
</li>
<li id="source-tokenizer">
<a
href="https://huggingface.co/adventists-ai/Sophon-Realtime-32B-Tag/blob/9a9ed240ab7a4ed7494ec3f45caf1242c1dace2a/thinker/tokenizer_config.json"
>Tag tokenizer 元信息</a
><span
>固定权重元信息 revision 9a9ed240,核对分隔符 ID 与 special
字段。</span
>
</li>
<li id="source-backend">
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/live/sophon_live/backend.py"
>后台工具循环</a
><span>delegate、后台模型配置与真实工具调用。</span>
</li>
<li id="source-finetune">
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/finetune/README.md"
>微调说明与样例</a
><span>函数 schema、调用格式与 tool 结果消息。</span>
</li>
<li id="source-grounding">
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/live/sophon_live/realtime.py"
>Realtime 网关</a
><span>函数调用事件、参数来源检查、取消与截断路径。</span>
</li>
<li>
<a
href="https://github.com/adventists-ai/sophon-realtime/blob/b2754d4753e20ecfa860db0cda4325e4479f4427/live/README.md"
>Live 文档</a
><span>网关部署、委派事件、ASR、14 条 mock 后台测试。</span>
</li>
<li>
<a
href="https://openai.com/index/introducing-gpt-live-1-in-the-api/"
>OpenAI GPT-Live-1 API 发布材料</a
><span
>FDB-v1
轮替时延及后台配置应分别解读,完整补充见通用评测站。</span
>
</li>
</ol>
</section>
<footer>
<span>K1412 RESEARCH · MODEL STUDY</span>
<div>
<a href="https://blog.k1412.top">研究目录 ↗</a
><a href="https://git.k1412.top/wuyang/sophon-realtime-guide"
>网页源码 ↗</a
><a href="#top">回到顶部 ↑</a>
</div>
<small>Release sophon-realtime-20261109-v1</small>
</footer>
</main>
</div>
</body>
</html>
+208
View File
@@ -0,0 +1,208 @@
export const builds = {
flash: {
title: "Flash",
directory: "flash",
modelDirectory: "Sophon-Realtime-32B-Flash",
flow: ["用户语音", "共享 32B 理解与文本", "Flash 语音头", "24 kHz 语音"],
detail:
"声音侧以每 80 ms 帧一次前向为设计。作者报告单 H100 HTTP 首包中位数 0.31 s,实例约 55 GB 显存。",
modes: ["http", "engine", "realtime", "live"],
},
tag: {
title: "Tag",
directory: "tag",
modelDirectory: "Sophon-Realtime-32B-Tag",
flow: [
"用户语音",
"共享 32B 与声音标签",
"tag-tts 语音模型",
"Flow + HiFT 语音",
],
detail:
"模型写出笑声、叹气等声音标签,由专用语音模块表现。作者报告单 80 GB GPU、实例约 65 GB 显存。",
modes: ["http", "engine", "realtime", "live"],
},
e2e: {
title: "E2E 预览",
directory: "",
modelDirectory: "Sophon-Realtime-32B-E2E",
flow: [
"用户语音",
"32B Thinker 与 Talker",
"连续语音向量",
"E2 解码与语音",
],
detail:
"采用独立推理引擎与 /ws 网关,文档要求两张 80 GB GPU。当前作为研究预览,部署文档与模型目录版本需逐项固定。",
modes: ["e2e"],
},
};
export const callModes = {
http: {
title: "HTTP 音频生成",
kind: "POST",
endpoint: "http://HOST:8000/v1/chat/completions",
detail:
"先用完整 WAV 验证语音生成。官方客户端先取 /v1/models,再流式解码文本与音频。",
},
engine: {
title: "基础引擎双工",
kind: "WebSocket",
endpoint: "ws://HOST:8000/v1/realtime",
detail:
"持续推送音频,模型参与话轮决策。原生工具桥需要自行对接,默认 mock 与真实执行分别核验。",
},
realtime: {
title: "网关函数调用",
kind: "WebSocket",
endpoint: "ws://HOST:8780/v1/realtime",
detail:
"启动 Live 网关后,使用其 Realtime 适配器接收 function_call 与回填结果。当前协议覆盖有边界。",
},
live: {
title: "Live 客户端委派",
kind: "WebSocket",
endpoint: "ws://HOST:8780/v1/live/sessions",
detail:
"前台生成 delegate(task),客户端接委派事件,并把后台结果经 commentary.append 返回。",
},
e2e: {
title: "E2E 自有协议",
kind: "WebSocket",
endpoint: "ws://HOST:8000/ws",
detail:
"按 E2E 文档使用 config、二进制 PCM16、text、barge_in、done 等事件。",
},
};
export function makeRecipe(buildKey, modeKey) {
const build = builds[buildKey];
if (!build || !build.modes.includes(modeKey)) {
throw new RangeError(
"The selected build does not support this documented path.",
);
}
if (modeKey === "e2e") {
return {
startup: `# 在已准备 Linux / CUDA / 权重的环境中
SKIP_DOWNLOAD=1 MODEL_DIR=/path/to/${build.modelDirectory} bash scripts/install.sh
SOPHON_MODEL_DIR=/path/to/${build.modelDirectory} THINKER_GPU=0 TALKER_GPU=1 bash scripts/start.sh`,
request: `连接 ws://HOST:8000/ws 后先发送
{"type":"config","lang":"zh","history":true}
随后持续发送 16 kHz、单声道 PCM16 二进制音频。
接收 text / 二进制 24 kHz 音频 / barge_in / metrics / done。`,
};
}
const startup = `# 在官方仓库 ${build.directory}/ 中,权重须预先准备
SKIP_DOWNLOAD=1 bash install.sh
MODEL_DIR=/path/to/${build.modelDirectory} GPU=0 PORT=8000 bash start.sh
# 等待 READY 与预热完成`;
if (modeKey === "http") {
return {
startup: `${startup}
python client_example.py --url http://HOST:8000 --wav question.wav --lang zh --voice chelsie`,
request: `先从 GET /v1/models 取得模型 ID。
POST /v1/chat/completions
{
"model": "<返回的模型 ID>",
"stream": true,
"modalities": ["text", "audio"],
"language": "zh",
"messages": [
{"role":"system","content":"<config/system_prompt.json 的 zh 字段>"},
{"role":"user","content":[
{"type":"text","text":"<|audio|>"},
{"type":"input_audio","input_audio":{"data":"<WAV_BASE64>","format":"wav"}}
]}
]
}`,
};
}
if (modeKey === "engine") {
return {
startup,
request: `连接 ws://HOST:8000/v1/realtime
{"type":"session.update","session":{"extra_body":{"auto_response":true,"language":"zh"}}}
{"type":"input_audio_buffer.append","audio":"<持续发送的 PCM16_BASE64>"}
接真实工具时,额外配置 tools / tool_mode=client / bridge_key。
工具桥默认在引擎本机 PORT+700,需配套执行与回填逻辑。`,
};
}
const gatewayStartup = `${startup}
# 同一环境启动外层网关
cd ../live
SOPHON_LIVE_ASR=/path/to/Qwen3-ASR-0.6B-hf BUILD=${build.directory} PORT=8000 LIVE_PORT=8780 bash start_live.sh`;
if (modeKey === "realtime") {
return {
startup: gatewayStartup,
request: `连接 ws://HOST:8780/v1/realtime 后配置工具
{"type":"session.update","session":{"instructions":"简短回答用户","tools":[
{"type":"function","name":"get_weather","description":"查询城市天气",
"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}
]}}
收到 function_call 后,由客户端执行真实函数,再回填
{"type":"conversation.item.create","item":{"type":"function_call_output","call_id":"<返回的 call_id>","output":"<真实结果>"}}
{"type":"response.create"}`,
};
}
return {
startup: gatewayStartup,
request: `连接 ws://HOST:8780/v1/live/sessions
{"type":"session.start","session":{"instructions":"用中文简短回答","delegation":{"type":"client"}}}
{"type":"session.input_audio.append","audio":"<PCM16_BASE64>"}
接收 session.delegation.created,后台完成后回填
{"type":"session.commentary.append","delegation_id":"<返回的委派 ID>","content":"<真实后台结果>"}`,
};
}
export const toolSteps = [
{
title: "提供函数定义",
explanation:
"调用方声明函数名、说明与参数 schema。运行时经 chat template 把这些定义提供给模型。",
code: '{"type":"function","name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}',
},
{
title: "模型生成调用",
explanation:
"模型可以先给一句承接语,再输出工具名与参数。下面的天气任务为合成示例。",
code: '我帮你查一下。\n<tool_call>\n{"name":"get_weather","arguments":{"city":"上海"}}\n</tool_call>',
},
{
title: "口语流与控制流分开",
explanation:
"cut_tool 保留调用标记前的口语。完整或尚未写完的工具标记会被拦住,避免进入语音合成。",
code: '送往语音模块\n我帮你查一下。\n\n留给工具解析器\n<tool_call>{"name":"get_weather","arguments":{"city":"上海"}}</tool_call>',
},
{
title: "解析与桥接",
explanation:
"Thinker 写完后,服务解析函数调用,发出带 call_id、name 和 arguments 的工具桥事件。此时承接语音可能仍在播放。",
code: '{"type":"tool_call","call_id":"call_demo","name":"get_weather","arguments":{"city":"上海"}}',
},
{
title: "客户端执行真实函数",
explanation:
"真实执行端取出函数和参数,调用业务系统。mock 模式则返回模拟值。两类证据必须分开记录。",
code: 'result = await actual_weather_service(city="上海")\n# 记录真实调用 ID、参数、返回、耗时与最终状态',
},
{
title: "回填结果",
explanation:
"结果经客户端工具桥,或网关的 function_call_output / commentary.append 返回。下方内容显式标为示意。",
code: '{"type":"function_call_output","call_id":"call_demo","output":"示例结果:天气晴朗"}',
},
{
title: "模型继续回应",
explanation:
"结果被加入工具历史,后续语音从该结果继续生成。正确选择函数、真实执行和正确告知各自评分。",
code: "role: tool\n示例结果:天气晴朗\n\nassistant\n查到了,示例结果显示天气晴朗。",
},
];
+35
View File
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { builds, callModes, makeRecipe, toolSteps } from "./model.js";
test("every documented build and mode has distinct startup and request examples", () => {
for (const [key, build] of Object.entries(builds)) {
for (const mode of build.modes) {
assert.ok(callModes[mode]);
const example = makeRecipe(key, mode);
assert.ok(example.startup.includes("SKIP_DOWNLOAD=1"));
assert.ok(example.request.length > 30);
}
}
});
test("E2E cannot select an undocumented OpenAI-style entry", () => {
assert.throws(() => makeRecipe("e2e", "http"), RangeError);
assert.equal(callModes.e2e.endpoint, "ws://HOST:8000/ws");
});
test("Live and Realtime gateways retain their distinct event formats", () => {
assert.ok(
makeRecipe("flash", "live").request.includes("session.commentary.append"),
);
assert.ok(
makeRecipe("tag", "realtime").request.includes("function_call_output"),
);
assert.ok(
makeRecipe("flash", "http").startup.includes("python client_example.py"),
);
});
test("tool walkthrough distinguishes model output and actual execution", () => {
assert.equal(toolSteps.length, 7);
assert.ok(toolSteps[1].code.includes("<tool_call>"));
assert.ok(toolSteps[4].code.includes("actual_weather_service"));
assert.ok(toolSteps[5].code.includes("示例结果"));
});
+12
View File
@@ -0,0 +1,12 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; }
location / { try_files $uri $uri/ =404; }
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "sophon-realtime-research-guide",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"check": "node --check app.js && node --check model.js && node --test model.test.mjs && node validate.mjs",
"build": "node build.mjs",
"dev": "python3 -m http.server 4174 --bind 127.0.0.1 --directory dist"
},
"engines": {
"node": ">=20"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"release": "sophon-realtime-20261109-v1",
"researchCutoff": "2026-11-09",
"canonicalUrl": "https://sophon-realtime.k1412.top/",
"codeAudited": "b2754d4753e20ecfa860db0cda4325e4479f4427",
"gpuInferenceExecuted": false
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+931
View File
@@ -0,0 +1,931 @@
:root {
color-scheme: light;
--paper: #f4f3ef;
--surface: #fffefa;
--ink: #202b29;
--muted: #69756f;
--line: #d5dcd3;
--green: #2d755c;
--green-soft: #e2ece5;
--blue: #3d68a3;
--code: #eaece7;
--amber: #94652b;
--amber-soft: #f1e8d7;
}
html[data-theme="dark"] {
color-scheme: dark;
--paper: #18201d;
--surface: #202a24;
--ink: #e2e9df;
--muted: #a6b1a5;
--line: #3c4c40;
--green: #9ac6a6;
--green-soft: #2d4434;
--blue: #9fbfe8;
--code: #28362c;
--amber: #dfb16e;
--amber-soft: #443b2c;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 100px;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", sans-serif;
line-height: 1.85;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: var(--green);
}
button,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.35;
cursor: default;
}
a:focus-visible,
button:focus-visible,
select:focus-visible {
outline: 3px solid var(--blue);
outline-offset: 4px;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
strong {
font-weight: 600;
}
code,
pre {
font-family: "SFMono-Regular", Consolas, monospace;
}
p code,
td code {
font-size: 0.87em;
overflow-wrap: anywhere;
color: var(--green);
}
.skip-link {
position: absolute;
top: -80px;
left: 20px;
background: var(--ink);
color: var(--paper);
padding: 8px 15px;
z-index: 20;
}
.skip-link:focus {
top: 15px;
}
.site-header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
height: 80px;
background: var(--paper);
border-bottom: 1px solid var(--line);
padding: 0 5vw;
}
.brand {
display: flex;
align-items: center;
gap: 17px;
}
.brand b {
font:
37px Georgia,
serif;
letter-spacing: -4px;
}
.brand > span {
font-size: 11px;
letter-spacing: 1.8px;
}
.brand i {
font-style: normal;
color: var(--muted);
margin-left: 9px;
}
.site-header nav {
display: flex;
gap: 24px;
align-items: center;
font-size: 12px;
}
#theme-toggle {
border: 1px solid var(--line);
background: transparent;
border-radius: 50%;
width: 35px;
height: 35px;
padding: 0;
font-size: 20px;
}
.layout {
display: grid;
grid-template-columns: 180px minmax(0, 900px);
gap: 70px;
max-width: 1300px;
margin: 0 auto;
padding: 0 45px;
}
.contents {
position: sticky;
top: 80px;
align-self: start;
padding-top: 63px;
}
.eyebrow {
color: var(--muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 1.6px;
}
.contents nav {
display: flex;
flex-direction: column;
gap: 15px;
margin-top: 32px;
font-size: 12px;
color: var(--muted);
}
.version-note {
margin-top: 32px;
padding-top: 21px;
border-top: 1px solid var(--line);
font-size: 11px;
color: var(--muted);
line-height: 1.9;
}
.version-note strong {
color: var(--ink);
font-size: 13px;
letter-spacing: 1px;
}
.version-note span {
display: block;
margin-top: 11px;
}
.hero {
padding-top: 60px;
}
h1 {
font-size: clamp(35px, 4.1vw, 56px);
line-height: 1.27;
font-weight: 600;
letter-spacing: -1.3px;
margin: 20px 0 22px;
}
.deck {
font-size: 17px;
color: var(--muted);
line-height: 1.9;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
color: var(--muted);
font-size: 11px;
margin: 23px 0 33px;
}
.hero-meta a {
color: var(--green);
}
.hero-route {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 26px 0;
margin-bottom: 31px;
font-size: 13px;
}
.hero-route b {
color: var(--muted);
font-weight: 400;
}
.hero-route code {
color: var(--green);
font-size: 15px;
background: var(--green-soft);
padding: 7px 12px;
}
main p {
font-size: 14px;
line-height: 1.95;
}
p + p {
margin-top: 17px;
}
main .lead {
font-size: 18px;
line-height: 1.95;
margin-bottom: 18px;
}
.hero > p:last-child {
color: var(--muted);
font-size: 12px;
}
.hero > p:last-child code {
overflow-wrap: anywhere;
}
.article-section {
border-top: 1px solid var(--line);
padding-top: 37px;
margin-top: 58px;
scroll-margin-top: 105px;
}
.section-number {
font-size: 10px;
color: var(--green);
letter-spacing: 1.8px;
font-weight: 600;
margin-bottom: 12px;
}
h2 {
font-size: 28px;
line-height: 1.5;
font-weight: 600;
letter-spacing: -0.3px;
margin-bottom: 23px;
}
h3 {
font-size: 19px;
font-weight: 600;
line-height: 1.6;
margin: 28px 0 13px;
}
.ref {
font-size: 11px;
color: var(--green);
margin-left: 6px;
white-space: nowrap;
}
.table-wrap {
margin: 25px 0;
overflow-x: auto;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
table {
border-collapse: collapse;
width: 100%;
min-width: 610px;
font-size: 12px;
line-height: 1.9;
}
th,
td {
padding: 14px 12px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid var(--line);
}
th {
font-weight: 600;
}
thead th {
font-size: 11px;
color: var(--muted);
}
tbody th {
width: 20%;
min-width: 100px;
}
tr:last-child > * {
border-bottom: 0;
}
th:first-child,
td:first-child {
padding-left: 0;
}
th:last-child,
td:last-child {
padding-right: 0;
}
.source-line {
color: var(--muted);
}
main .source-line {
font-size: 11px;
line-height: 1.9;
}
.source-line a {
color: var(--green);
}
.evidence-note {
border-left: 2px solid var(--green);
padding: 13px 0 13px 20px;
margin: 25px 0;
}
.evidence-note > strong {
font-size: 14px;
}
.evidence-note p {
margin-top: 9px;
font-size: 12px;
color: var(--muted);
}
.evidence-note a {
color: var(--green);
}
.figure {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 5px;
padding: 26px;
margin: 26px 0;
}
.figure-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.figure-heading h3 {
font-size: 20px;
margin: 5px 0 22px;
}
.tag {
font-size: 10px;
white-space: nowrap;
padding: 4px 7px;
border-radius: 3px;
line-height: 1.6;
}
.audit {
background: var(--green-soft);
color: var(--green);
}
.example {
background: var(--amber-soft);
color: var(--amber);
}
.segmented {
display: inline-flex;
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
}
.segmented button {
background: transparent;
border: 0;
padding: 8px 22px;
font-size: 12px;
}
.segmented button[aria-pressed="true"] {
background: var(--ink);
color: var(--paper);
}
.flow {
display: flex;
align-items: center;
gap: 9px;
justify-content: space-between;
margin: 30px 0 24px;
}
.flow > span {
flex: 1;
background: var(--code);
text-align: center;
padding: 15px 8px;
font-size: 12px;
line-height: 1.8;
}
.flow > span:first-child {
background: var(--green-soft);
}
.flow > b {
font-weight: 400;
color: var(--muted);
}
.selected-detail {
font-size: 12px;
color: var(--muted);
min-height: 45px;
border-top: 1px solid var(--line);
padding-top: 17px;
line-height: 1.9;
}
.call-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.call-controls label {
font-size: 11px;
color: var(--muted);
}
select {
display: block;
width: 100%;
border: 1px solid var(--line);
border-radius: 3px;
background: var(--paper);
padding: 9px 12px;
margin-top: 7px;
font-size: 13px;
}
.endpoint {
display: flex;
align-items: center;
gap: 12px;
margin: 22px 0 12px;
}
.endpoint > span {
font-size: 10px;
color: var(--green);
font-weight: 600;
}
.endpoint code {
font-size: 13px;
overflow-wrap: anywhere;
}
main .small {
font-size: 11px;
color: var(--muted);
line-height: 1.9;
}
.code-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin: 20px 0 11px;
font-size: 11px;
color: var(--muted);
}
.recipe-parts {
display: flex;
gap: 8px;
margin-top: 19px;
}
.recipe-parts button {
background: transparent;
border: 1px solid var(--line);
border-radius: 3px;
padding: 5px 12px;
font-size: 11px;
}
.recipe-parts button[aria-pressed="true"] {
background: var(--green-soft);
color: var(--green);
}
.code-heading button,
.step-controls button {
border: 1px solid var(--line);
border-radius: 3px;
background: transparent;
padding: 5px 12px;
font-size: 11px;
}
pre {
background: var(--code);
margin: 0 0 11px;
padding: 20px;
font-size: 11px;
line-height: 1.9;
overflow-x: auto;
tab-size: 2;
}
#copy-status {
min-height: 21px;
}
.step-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 24px;
}
.step-list button {
background: transparent;
border: 1px solid var(--line);
padding: 6px 9px;
font-size: 10px;
border-radius: 3px;
}
.step-list button[aria-pressed="true"] {
background: var(--green-soft);
color: var(--green);
border-color: var(--green);
}
.step-head {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 12px;
}
.step-head > span {
font:
28px Georgia,
serif;
color: var(--green);
}
.step-head h4 {
font-size: 17px;
font-weight: 600;
}
#step-explanation {
min-height: 59px;
margin-bottom: 19px;
font-size: 13px;
}
#step-code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.step-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18px;
}
.step-controls > span {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.two-paths {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 31px;
margin: 28px 0;
}
.two-paths > div {
border-top: 1px solid var(--line);
padding-top: 15px;
}
.two-paths h3 {
font-size: 17px;
margin: 8px 0 12px;
}
.two-paths p {
font-size: 12px;
}
.clock-boundaries {
margin: 26px 0;
}
.clock-boundaries > div {
padding: 15px 0;
border-top: 1px solid var(--line);
}
.clock-boundaries span {
color: var(--muted);
display: block;
font-size: 11px;
margin-bottom: 7px;
}
.clock-boundaries strong {
display: block;
font-size: 13px;
font-weight: 500;
}
.text-link {
display: inline-block;
margin-top: 22px;
color: var(--green);
font-size: 13px;
}
.source-list {
margin: 26px 0;
padding-left: 24px;
}
.source-list li {
border-top: 1px solid var(--line);
padding: 13px 0 13px 6px;
scroll-margin-top: 105px;
}
.source-list li::marker {
font-size: 11px;
color: var(--green);
}
.source-list a {
font-size: 13px;
font-weight: 500;
}
.source-list span {
display: block;
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
footer {
margin-top: 55px;
padding: 26px 0 35px;
border-top: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
color: var(--muted);
font-size: 10px;
}
footer > div {
display: flex;
gap: 20px;
}
footer small {
flex-basis: 100%;
font-size: 9px;
}
@media (max-width: 1050px) {
.layout {
grid-template-columns: 145px minmax(0, 1fr);
gap: 35px;
padding: 0 28px;
}
.contents nav {
font-size: 11px;
}
h1 {
font-size: 44px;
}
.figure {
padding: 22px;
}
.flow > span {
font-size: 11px;
}
}
@media (max-width: 780px) {
.site-header {
height: 68px;
padding: 0 22px;
}
.site-header nav {
gap: 17px;
font-size: 11px;
}
.brand {
gap: 10px;
}
.brand > span {
font-size: 10px;
}
.brand i {
display: none;
}
.layout {
display: block;
padding: 0 24px;
}
.contents {
position: static;
padding: 20px 0 0;
}
.contents > .eyebrow,
.version-note {
display: none;
}
.contents nav {
margin: 0;
flex-direction: row;
flex-wrap: wrap;
gap: 7px 17px;
padding-bottom: 17px;
border-bottom: 1px solid var(--line);
font-size: 10px;
}
.contents nav a:nth-child(n + 5) {
display: none;
}
.hero {
padding-top: 35px;
}
h1 {
font-size: 39px;
}
.deck {
font-size: 15px;
}
.article-section {
margin-top: 47px;
padding-top: 29px;
}
h2 {
font-size: 25px;
}
.hero-route {
font-size: 11px;
gap: 6px;
}
.hero-route code {
font-size: 12px;
padding: 6px 9px;
}
.flow {
gap: 7px;
}
}
@media (max-width: 480px) {
.site-header {
padding: 0 17px;
}
.site-header nav > a:first-child {
display: none;
}
.site-header nav {
gap: 14px;
}
.brand b {
font-size: 32px;
}
.layout {
padding: 0 19px;
}
.hero > .eyebrow {
font-size: 8px;
letter-spacing: 1px;
}
h1 {
font-size: clamp(28px, 7.9vw, 31px);
letter-spacing: -0.6px;
line-height: 1.35;
margin-top: 16px;
}
.hero-meta {
font-size: 10px;
gap: 7px 13px;
}
.hero-route {
flex-wrap: nowrap;
justify-content: space-between;
gap: 3px;
padding: 19px 0;
}
.hero-route > span {
font-size: 10px;
}
.hero-route code {
font-size: 10px;
padding: 5px 6px;
}
main .lead {
font-size: 16px;
}
main p {
font-size: 13px;
}
h2 {
font-size: clamp(21px, 5.9vw, 23px);
}
h3 {
font-size: 18px;
}
.figure {
padding: 18px 14px;
margin: 23px -3px;
}
.figure-heading {
display: block;
}
.figure-heading h3 {
font-size: 17px;
margin-bottom: 12px;
}
.tag {
font-size: 8px;
padding: 4px 5px;
display: inline-block;
margin-bottom: 17px;
}
.eyebrow {
font-size: 9px;
}
.segmented {
width: 100%;
}
.segmented button {
flex: 1;
padding: 8px 10px;
font-size: 11px;
}
.flow {
flex-direction: column;
gap: 7px;
margin: 22px 0;
}
.flow > span {
width: 100%;
padding: 11px;
font-size: 12px;
}
.flow > b {
line-height: 1;
transform: rotate(90deg);
}
.selected-detail {
font-size: 11px;
min-height: 73px;
}
.call-controls {
grid-template-columns: 1fr;
gap: 14px;
}
.endpoint {
align-items: flex-start;
gap: 9px;
}
.endpoint code {
font-size: 11px;
}
pre {
padding: 14px;
font-size: 10px;
}
.step-list button {
font-size: 9px;
padding: 6px 7px;
}
.step-head h4 {
font-size: 15px;
}
#step-explanation {
font-size: 12px;
min-height: 91px;
}
.two-paths {
grid-template-columns: 1fr;
gap: 24px;
}
.two-paths h3 {
font-size: 16px;
}
.clock-boundaries strong {
font-size: 12px;
}
.source-list a {
font-size: 12px;
}
footer {
display: block;
font-size: 9px;
}
footer > div {
margin: 16px 0;
gap: 15px;
}
footer small {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
@media print {
.site-header,
.contents,
button,
select {
display: none;
}
.layout {
display: block;
padding: 0;
}
.figure {
break-inside: avoid;
}
.table-wrap {
overflow: visible;
}
table {
min-width: 0;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
assert.equal(ids.length, new Set(ids).size);
for (const match of html.matchAll(/href="#([^"]+)"/g)) {
assert.ok(ids.includes(match[1]), `Missing anchor ${match[1]}`);
}
assert.ok(html.includes("GPU 推理待实测"));
assert.ok(html.includes("mock"));
assert.ok(html.includes("special"));
console.log(`Validated ${ids.length} anchors and model evidence labels.`);