From 601e448fa82cd85692fe053693913a076efe24f4 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:20:22 +0800 Subject: [PATCH 1/2] feat(seo): lead compare/blog titles with search phrase, stat-led + trimmed meta descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve SERP click-through on page-1 compare pages and blog posts. - Compare now leads with the GPU pair (the actual query, e.g. "B200 vs B300: GLM-5 Inference Benchmark | InferenceX") via title.absolute, bypassing the long root template; falls back "Inference Benchmark"→"Benchmark" for long pairs. New compareModelSeoName (single natural version name) + compareSeoTitle in compare-slug.ts. - Compare meta description is now stat-led from interpolated head-to-head numbers at the default interactivity target (tok/s/GPU + $/M-tok), ≤155 chars, gracefully falling back to compact boilerplate for sparse-data pairs. New compareMetaDescription / computeCompareStat in compare-ssr.ts. - Blog <title> uses the shorter "| InferenceX" suffix (title.absolute). - Blog meta/OG/Twitter descriptions use blogDescription = seoDescription ?? smartTruncate(subtitle, 155) (word-boundary cut, no mid-sentence truncation). Added optional seoDescription frontmatter to 9 long-subtitle posts. - Mirrored every change on /zh (compareMetaDescriptionZh, zh titles, zh seoDescription on the 9 translated posts). - Unit tests for compareModelSeoName, compareSeoTitle, compareMetaDescription (+ zh structural port), smartTruncate, and blogDescription. 中文:让 /compare 与博客页面的 SEO 元数据以搜索短语开头,提升谷歌搜索结果页点击率。 compare 的 <title> 改为以 GPU 对(真实搜索词,如“B200 vs B300: GLM-5 Inference Benchmark | InferenceX”)开头,使用 title.absolute 绕过冗长的根模板,长组合时将 “Inference Benchmark”缩短为“Benchmark”;新增 compareModelSeoName(单一自然版本名) 与 compareSeoTitle。compare 的 meta 描述改为在默认交互性目标下由插值出的正面对比 数据(tok/s/GPU 与 $/M-tok)驱动,控制在 155 字符内,数据稀疏时优雅回退到精简样板 文案;新增 compareMetaDescription / computeCompareStat。博客 <title> 改用更短的 “| InferenceX”后缀。博客 meta/OG/Twitter 描述改用 blogDescription = seoDescription ?? smartTruncate(subtitle, 155)(按词边界截断,避免句中截断),并为 9 篇长副标题文章新增 seoDescription frontmatter。所有改动均在 /zh 侧同步(新增 compareMetaDescriptionZh、中文标题、9 篇译文的中文 seoDescription)。补充相应单元测试。 --- ...nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx | 1 + ...vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx | 1 + ...h200-int4-kimi-k2-vllm-perf-per-dollar.mdx | 1 + ...b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx | 1 + ...nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx | 1 + ...max-open-source-inference-benchmarking.mdx | 1 + ...deepseek-v4-pro-sglang-110x-in-26-days.mdx | 1 + ...x-glm5-fp8-sglang-40-cheaper-than-b200.mdx | 1 + ...i355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx | 1 + ...nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx | 1 + ...vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx | 1 + ...h200-int4-kimi-k2-vllm-perf-per-dollar.mdx | 1 + ...b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx | 1 + ...nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx | 1 + ...max-open-source-inference-benchmarking.mdx | 1 + ...deepseek-v4-pro-sglang-110x-in-26-days.mdx | 1 + ...x-glm5-fp8-sglang-40-cheaper-than-b200.mdx | 1 + ...i355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx | 1 + packages/app/src/app/blog/[slug]/page.tsx | 13 +- packages/app/src/app/compare/[slug]/page.tsx | 36 ++-- packages/app/src/app/zh/blog/[slug]/page.tsx | 19 +- .../app/src/app/zh/compare/[slug]/page.tsx | 25 ++- packages/app/src/lib/blog.test.ts | 56 ++++++ packages/app/src/lib/blog.ts | 36 ++++ packages/app/src/lib/compare-slug.test.ts | 65 +++++++ packages/app/src/lib/compare-slug.ts | 41 +++++ packages/app/src/lib/compare-ssr-zh.ts | 71 +++++++- packages/app/src/lib/compare-ssr.test.ts | 170 +++++++++++++++++- packages/app/src/lib/compare-ssr.ts | 146 +++++++++++++++ .../app/src/lib/compare-variant-ssr.test.ts | 1 + 30 files changed, 666 insertions(+), 31 deletions(-) diff --git a/packages/app/content/blog/b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx b/packages/app/content/blog/b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx index 217217f6..38592c9a 100644 --- a/packages/app/content/blog/b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx +++ b/packages/app/content/blog/b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx @@ -1,6 +1,7 @@ --- title: 'B200 NVFP4 vs H200 FP8 on GLM-5: Up to 3.65x Better Performance per Dollar with SGLang MTP' subtitle: 'Both SKUs run SGLang EAGLE MTP; the Blackwell generation lifts perf/$ by ~1.2x at the peak and the NVIDIA GLM-5-NVFP4 checkpoint on FlashInfer TRT-LLM sparse MLA stacks another ~2.4–3.0x on 8K/1K' +seoDescription: 'B200 NVFP4 delivers up to 3.65x better perf/$ than H200 FP8 on GLM-5 with SGLang MTP, stacking Blackwell gains and a sparse-MLA checkpoint.' date: '2026-05-26' publishDate: '2026-05-26' tags: diff --git a/packages/app/content/blog/b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx b/packages/app/content/blog/b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx index e0e1f710..24b36501 100644 --- a/packages/app/content/blog/b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx +++ b/packages/app/content/blog/b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx @@ -1,6 +1,7 @@ --- title: 'B200 NVFP4 vs H100 FP8 on MiniMax-M2.5: Up to 8.2x Better Performance per Dollar with vLLM' subtitle: 'vLLM PR #36307 unlocks the trtllm-gen FP8 MoE kernel for MiniMax on B200; combined with NVFP4, perf/$ scales from 4.0x at 22 tok/s/user to 8.2x at 110 on 8K/1K' +seoDescription: 'B200 NVFP4 delivers up to 8.2x better perf/$ than H100 FP8 on MiniMax-M2.5 with vLLM — trtllm-gen FP8 MoE plus NVFP4 on 8K/1K.' date: '2026-05-26' publishDate: '2026-05-26' tags: diff --git a/packages/app/content/blog/b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx b/packages/app/content/blog/b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx index 4fb1167c..538aa54f 100644 --- a/packages/app/content/blog/b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx +++ b/packages/app/content/blog/b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx @@ -1,6 +1,7 @@ --- title: 'B200 NVFP4 vs H200 INT4 on Kimi K2.5/K2.6: Up to 2.95x Better Performance per Dollar' subtitle: "On vLLM 8K/1K the NVFP4 path on B200 is 2.71x–2.95x cheaper per million tokens than H200 INT4 across the entire 30–90 tok/s/user serving band, and 2.45x–2.74x cheaper than B200 INT4 on the same silicon. Both factors decompose cleanly into B200's HBM bandwidth, HBM capacity, and NVFP4 tensor cores" +seoDescription: 'B200 NVFP4 runs Kimi K2.5/K2.6 up to 2.95x cheaper per token than H200 INT4 on vLLM 8K/1K, across the full 30–90 tok/s/user serving band.' date: '2026-05-26' publishDate: '2026-05-26' tags: diff --git a/packages/app/content/blog/gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx b/packages/app/content/blog/gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx index a65b8a21..aa4bb2ff 100644 --- a/packages/app/content/blog/gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx +++ b/packages/app/content/blog/gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx @@ -1,6 +1,7 @@ --- title: 'GB200 NVL72 vs B200 on DeepSeek R1 670B: Up to 4.4x Throughput per GPU at 125 tok/s/user' subtitle: "DeepSeek R1 FP4 1k/1k. NVL72's 72-GPU NVLink scale-up fabric lets decode run wide EP up to EP=32, where B200's 8-GPU NVLink island caps out at EP=8 over RoCEv2" +seoDescription: 'GB200 NVL72 hits up to 4.4x more per-GPU throughput than B200 on DeepSeek R1 670B at 125 tok/s/user — 72-GPU NVLink enables wide EP=32.' date: '2026-05-23' publishDate: '2026-05-23' tags: diff --git a/packages/app/content/blog/gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx b/packages/app/content/blog/gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx index 4f8fac9e..fd310844 100644 --- a/packages/app/content/blog/gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx +++ b/packages/app/content/blog/gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx @@ -1,6 +1,7 @@ --- title: 'GB300 NVL72 vs GB200 NVL72 Inference Performance & Perf per Dollar - on DeepSeek-V4-Pro 1.6T: Up to 2.83x Throughput' subtitle: "DSv4-Pro FP4 8K/1K, Dynamo+vLLM, disaggregated on both racks. GB300's 50% extra HBM (288 vs 192 GB/GPU) unlocks a wider prefill+decode recipe GB200 can't fit — lifting middle-of-curve perf/$ by 2.31x despite a 20% per-GPU TCO premium." +seoDescription: 'GB300 NVL72 adds 50% more HBM, lifting DeepSeek-V4-Pro perf/$ up to 2.31x over GB200 NVL72 on vLLM FP4 8K/1K — up to 2.83x throughput per GPU.' date: '2026-05-27' publishDate: '2026-05-27' tags: diff --git a/packages/app/content/blog/inferencemax-open-source-inference-benchmarking.mdx b/packages/app/content/blog/inferencemax-open-source-inference-benchmarking.mdx index 4b6da177..32217ffa 100644 --- a/packages/app/content/blog/inferencemax-open-source-inference-benchmarking.mdx +++ b/packages/app/content/blog/inferencemax-open-source-inference-benchmarking.mdx @@ -1,6 +1,7 @@ --- title: 'InferenceMAX: Open Source Inference Benchmarking' subtitle: 'NVIDIA GB200 NVL72, AMD MI355X, Throughput Token per GPU, Latency Tok/s/user, Perf per Dollar, Cost per Million Tokens, Tokens per Provisioned Megawatt, DeepSeek R1 670B, GPTOSS 120B, Llama3 70B' +seoDescription: 'InferenceMAX: open-source inference benchmarks across NVIDIA GB200 NVL72 and AMD MI355X — throughput, latency, cost per token and perf per watt.' date: '2025-10-09' publishDate: '2025-10-09' tags: diff --git a/packages/app/content/blog/mi355x-deepseek-v4-pro-sglang-110x-in-26-days.mdx b/packages/app/content/blog/mi355x-deepseek-v4-pro-sglang-110x-in-26-days.mdx index b4e9c850..ed001196 100644 --- a/packages/app/content/blog/mi355x-deepseek-v4-pro-sglang-110x-in-26-days.mdx +++ b/packages/app/content/blog/mi355x-deepseek-v4-pro-sglang-110x-in-26-days.mdx @@ -1,6 +1,7 @@ --- title: 'MI355X DeepSeek-V4-Pro on SGLang: 110.5x Throughput per GPU in 26 Days' subtitle: 'The amd/deepseek_v4 side branch shipped TileLang attention indexer, Triton sparse MLA, fused RoPE/Hadamard, FlyDSL MoE, and FP4 weights across 31 performance optimizations PRs — lifting first-light 20 tok/s/GPU at 2.4 tok/s/user into 2,256 tok/s/GPU at 9.4 tok/s/user on 8K/1K, with both throughput and interactivity climbing together' +seoDescription: 'MI355X ran DeepSeek-V4-Pro 110.5x faster per GPU in 26 days on SGLang — from 20 to 2,256 tok/s/GPU via TileLang, sparse MLA and FP4 on 8K/1K.' date: '2026-05-26' publishDate: '2026-05-26' tags: diff --git a/packages/app/content/blog/mi355x-glm5-fp8-sglang-40-cheaper-than-b200.mdx b/packages/app/content/blog/mi355x-glm5-fp8-sglang-40-cheaper-than-b200.mdx index 353df6b4..b4212a6a 100644 --- a/packages/app/content/blog/mi355x-glm5-fp8-sglang-40-cheaper-than-b200.mdx +++ b/packages/app/content/blog/mi355x-glm5-fp8-sglang-40-cheaper-than-b200.mdx @@ -1,6 +1,7 @@ --- title: 'AMD MI355X GLM-5 Inference: Up to 40% Cheaper per Million Tokens than B200 on SGLang FP8' subtitle: '14 weeks after GLM-5 launched, AMD landed both MTP and non-MTP SGLang FP8 recipes on MI355X — fused MLA + FP8 KV cache via TileLang flips the single-node FP8 cost curve in AMD favor across most of the performance Pareto' +seoDescription: 'AMD MI355X runs GLM-5 up to 40% cheaper per million tokens than B200 on SGLang FP8, with fused MLA and FP8 KV cache via TileLang.' date: '2026-05-25' publishDate: '2026-05-25' tags: diff --git a/packages/app/content/blog/mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx b/packages/app/content/blog/mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx index 329e4479..94983de0 100644 --- a/packages/app/content/blog/mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx +++ b/packages/app/content/blog/mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx @@ -1,6 +1,7 @@ --- title: 'AMD MI355X Qwen3.5 397B-A17B Inference: Up to 19x Throughput per GPU in 3 Months on SGLang FP8' subtitle: 'From v0.5.8 (Feb) → v0.5.10rc0 (Apr) → v0.5.12 (May), three AITER kernel landings on MI355X plus a TP=8 → TP=2/TP=4 retune push Qwen3.5 8k/1k peak from 1.3k to 6.4k tok/s/GPU and extend the curve out to 75 tok/s/user' +seoDescription: 'AMD MI355X lifted Qwen3.5 397B-A17B up to 19x per-GPU throughput in 3 months on SGLang FP8 — 1.3k to 6.4k tok/s/GPU via AITER kernels.' date: '2026-05-25' publishDate: '2026-05-25' tags: diff --git a/packages/app/content/blog/zh/b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx b/packages/app/content/blog/zh/b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx index 36d30314..ec6db67a 100644 --- a/packages/app/content/blog/zh/b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx +++ b/packages/app/content/blog/zh/b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx @@ -1,6 +1,7 @@ --- title: 'B200 NVFP4 对比 H200 FP8 运行 GLM-5:SGLang MTP 下性价比提升高达 3.65 倍' subtitle: '两款 GPU 均运行 SGLang EAGLE MTP;Blackwell 世代在峰值处带来约 1.2 倍的性价比提升,NVIDIA GLM-5-NVFP4 检查点搭配 FlashInfer TRT-LLM 稀疏 MLA 在 8K/1K 场景下再叠加约 2.4–3.0 倍优势' +seoDescription: 'B200 NVFP4 在 SGLang MTP 下运行 GLM-5,性价比比 H200 FP8 最高提升 3.65 倍——叠加 Blackwell 世代增益与稀疏 MLA 检查点。' date: '2026-05-26' publishDate: '2026-05-26' tags: diff --git a/packages/app/content/blog/zh/b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx b/packages/app/content/blog/zh/b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx index 73da4323..a889133c 100644 --- a/packages/app/content/blog/zh/b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx +++ b/packages/app/content/blog/zh/b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx @@ -1,6 +1,7 @@ --- title: 'B200 NVFP4 vs H100 FP8 运行 MiniMax-M2.5:vLLM 下每美元性能最高提升 8.2 倍' subtitle: 'vLLM PR #36307 为 MiniMax 在 B200 上解锁了 trtllm-gen FP8 MoE 模块化内核;结合 NVFP4,在 8K/1K 负载下性能/成本从 22 tok/s/user 时的 4.0 倍扩大到 110 tok/s/user 时的 8.2 倍' +seoDescription: 'B200 NVFP4 在 vLLM 下运行 MiniMax-M2.5,每美元性能比 H100 FP8 最高提升 8.2 倍——trtllm-gen FP8 MoE 结合 NVFP4(8K/1K)。' date: '2026-05-26' publishDate: '2026-05-26' tags: diff --git a/packages/app/content/blog/zh/b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx b/packages/app/content/blog/zh/b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx index 38e84357..04c09450 100644 --- a/packages/app/content/blog/zh/b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx +++ b/packages/app/content/blog/zh/b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx @@ -1,6 +1,7 @@ --- title: 'B200 NVFP4 对比 H200 INT4 运行 Kimi K2.5/K2.6:性价比提升高达 2.95 倍' subtitle: '在 vLLM 8K/1K 工作负载下,B200 NVFP4 路径在 30–90 tok/s/user 推理区间内每百万 tokens 成本比 H200 INT4 低 2.71x–2.95x,比同一 B200 硬件上的 INT4 低 2.45x–2.74x。三个因素——B200 的 HBM 带宽、HBM 容量和 NVFP4 张量核心——可清晰分解该优势' +seoDescription: '在 vLLM 8K/1K 下,B200 NVFP4 运行 Kimi K2.5/K2.6 每 token 成本比 H200 INT4 最多低 2.95 倍,覆盖 30–90 tok/s/user 全区间。' date: '2026-05-26' publishDate: '2026-05-26' tags: diff --git a/packages/app/content/blog/zh/gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx b/packages/app/content/blog/zh/gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx index c1cd0c43..e70a6620 100644 --- a/packages/app/content/blog/zh/gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx +++ b/packages/app/content/blog/zh/gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx @@ -1,6 +1,7 @@ --- title: 'GB200 NVL72 对比 B200 运行 DeepSeek R1 670B:在 125 tok/s/user 下每 GPU 吞吐量最高达 4.4 倍' subtitle: 'DeepSeek R1 FP4 1k/1k。NVL72 的 72-GPU NVLink 扩展域允许解码使用最高 EP=32 的宽专家并行,而 B200 的 8-GPU NVLink 岛通过 RoCEv2 上限为 EP=8' +seoDescription: 'GB200 NVL72 在 125 tok/s/user 下运行 DeepSeek R1 670B,每 GPU 吞吐量比 B200 最高提升 4.4 倍——72-GPU NVLink 支持 EP=32 宽专家并行。' date: '2026-05-23' publishDate: '2026-05-23' tags: diff --git a/packages/app/content/blog/zh/gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx b/packages/app/content/blog/zh/gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx index 38fb02d1..9970710b 100644 --- a/packages/app/content/blog/zh/gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx +++ b/packages/app/content/blog/zh/gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx @@ -1,6 +1,7 @@ --- title: 'GB300 NVL72 vs GB200 NVL72 推理性能与性价比对比 — DeepSeek-V4-Pro 1.6T:吞吐量最高提升 2.83 倍' subtitle: 'DSv4-Pro FP4 8K/1K,Dynamo+vLLM,两套机架均采用分离式部署。GB300 多出 50% 的 HBM(每 GPU 288 GB vs 192 GB)解锁了 GB200 无法容纳的更宽预填充+解码配方——尽管单 GPU TCO 溢价 20%,曲线中段性价比仍提升 2.31 倍。' +seoDescription: 'GB300 NVL72 多出的 50% HBM 使 DeepSeek-V4-Pro 性价比比 GB200 NVL72 最高提升 2.31 倍(vLLM FP4 8K/1K),每 GPU 吞吐量最高提升 2.83 倍。' date: '2026-05-27' publishDate: '2026-05-27' tags: diff --git a/packages/app/content/blog/zh/inferencemax-open-source-inference-benchmarking.mdx b/packages/app/content/blog/zh/inferencemax-open-source-inference-benchmarking.mdx index ecad0f7f..4958133b 100644 --- a/packages/app/content/blog/zh/inferencemax-open-source-inference-benchmarking.mdx +++ b/packages/app/content/blog/zh/inferencemax-open-source-inference-benchmarking.mdx @@ -1,6 +1,7 @@ --- title: 'InferenceMAX:开源推理基准测试' subtitle: 'NVIDIA GB200 NVL72、AMD MI355X、每 GPU 吞吐量 Token、延迟 Tok/s/user、性价比、每百万 Token 成本、每配置兆瓦 Token 数、DeepSeek R1 670B、GPTOSS 120B、Llama3 70B' +seoDescription: 'InferenceMAX:面向 NVIDIA GB200 NVL72 与 AMD MI355X 的开源推理基准测试——吞吐量、延迟、每 token 成本与每瓦性能。' date: '2025-10-09' publishDate: '2025-10-09' tags: diff --git a/packages/app/content/blog/zh/mi355x-deepseek-v4-pro-sglang-110x-in-26-days.mdx b/packages/app/content/blog/zh/mi355x-deepseek-v4-pro-sglang-110x-in-26-days.mdx index 8a6134f2..a95695ad 100644 --- a/packages/app/content/blog/zh/mi355x-deepseek-v4-pro-sglang-110x-in-26-days.mdx +++ b/packages/app/content/blog/zh/mi355x-deepseek-v4-pro-sglang-110x-in-26-days.mdx @@ -1,6 +1,7 @@ --- title: 'MI355X 上 DeepSeek-V4-Pro 搭配 SGLang:26 天内每 GPU 吞吐量提升 110.5 倍' subtitle: 'amd/deepseek_v4 分支合入了 TileLang 注意力索引器、Triton 稀疏 MLA、融合 RoPE/Hadamard、FlyDSL MoE 以及 FP4 权重,历经 31 个性能优化 PR——将首次点亮时 20 tok/s/GPU、2.4 tok/s/user 的水平提升至 8K/1K 负载下 2,256 tok/s/GPU、9.4 tok/s/user,吞吐量与交互性同步攀升' +seoDescription: 'MI355X 在 SGLang 上 26 天内将 DeepSeek-V4-Pro 的每 GPU 吞吐量提升 110.5 倍——8K/1K 下经 TileLang、稀疏 MLA 与 FP4 从 20 提升至 2,256 tok/s/GPU。' date: '2026-05-26' publishDate: '2026-05-26' tags: diff --git a/packages/app/content/blog/zh/mi355x-glm5-fp8-sglang-40-cheaper-than-b200.mdx b/packages/app/content/blog/zh/mi355x-glm5-fp8-sglang-40-cheaper-than-b200.mdx index a4817108..54067049 100644 --- a/packages/app/content/blog/zh/mi355x-glm5-fp8-sglang-40-cheaper-than-b200.mdx +++ b/packages/app/content/blog/zh/mi355x-glm5-fp8-sglang-40-cheaper-than-b200.mdx @@ -1,6 +1,7 @@ --- title: 'AMD MI355X GLM-5 推理:SGLang FP8 单节点每百万 token 成本比 B200 最高低 40%' subtitle: 'GLM-5 发布 14 周后,AMD 在 MI355X 上同时实现了 SGLang FP8 的 MTP 和非 MTP 方案 — 通过 TileLang 实现的融合 MLA + FP8 KV 缓存在大部分性能 Pareto 前沿上将单节点 FP8 成本曲线翻转为 AMD 占优' +seoDescription: 'AMD MI355X 在 SGLang FP8 上运行 GLM-5,每百万 token 成本比 B200 最高低 40%——TileLang 融合 MLA 与 FP8 KV 缓存。' date: '2026-05-25' publishDate: '2026-05-25' tags: diff --git a/packages/app/content/blog/zh/mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx b/packages/app/content/blog/zh/mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx index ac7908b2..5e2e6738 100644 --- a/packages/app/content/blog/zh/mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx +++ b/packages/app/content/blog/zh/mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx @@ -1,6 +1,7 @@ --- title: 'AMD MI355X Qwen3.5 397B-A17B 推理:SGLang FP8 三个月内每 GPU 吞吐量提升最高 19 倍' subtitle: '从 v0.5.8(2 月)→ v0.5.10rc0(4 月)→ v0.5.12(5 月),三次 AITER 内核合入 MI355X 加上从 TP=8 到 TP=2/TP=4 的重新调优,将 Qwen3.5 8k/1k 峰值从 1.3k 推高至 6.4k tok/s/GPU,并将曲线延伸至 75 tok/s/user' +seoDescription: 'AMD MI355X 在 SGLang FP8 上 3 个月内将 Qwen3.5 397B-A17B 每 GPU 吞吐量最高提升 19 倍——经 AITER 内核从 1.3k 提升至 6.4k tok/s/GPU。' date: '2026-05-25' publishDate: '2026-05-25' tags: diff --git a/packages/app/src/app/blog/[slug]/page.tsx b/packages/app/src/app/blog/[slug]/page.tsx index 05bda7e6..e21b5b12 100644 --- a/packages/app/src/app/blog/[slug]/page.tsx +++ b/packages/app/src/app/blog/[slug]/page.tsx @@ -16,6 +16,7 @@ import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-butt import { Card } from '@/components/ui/card'; import { JsonLd } from '@/components/json-ld'; import { + blogDescription, getAllPosts, getAdjacentPosts, extractHeadings, @@ -43,10 +44,14 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> { const result = getPostBySlug(slug); if (!result) return {}; const { meta } = result; + const description = blogDescription(meta); return { - title: meta.title, - description: meta.subtitle, + // `absolute` keeps a short " | InferenceX" suffix instead of the long + // "%s | InferenceX by SemiAnalysis" root template, leaving more room for + // the headline before Google truncates the SERP title. + title: { absolute: `${meta.title} | ${SITE_NAME}` }, + description, keywords: meta.tags, authors: [{ name: AUTHOR_NAME }], alternates: { @@ -56,7 +61,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> { }, openGraph: { title: `${meta.title} | ${SITE_NAME}`, - description: meta.subtitle, + description, url: `${SITE_URL}/blog/${slug}`, type: 'article', publishedTime: `${meta.date}T00:00:00Z`, @@ -67,7 +72,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> { twitter: { card: 'summary_large_image', title: meta.title, - description: meta.subtitle, + description, site: AUTHOR_HANDLE, creator: AUTHOR_HANDLE, }, diff --git a/packages/app/src/app/compare/[slug]/page.tsx b/packages/app/src/app/compare/[slug]/page.tsx index 763b000f..b11011d5 100644 --- a/packages/app/src/app/compare/[slug]/page.tsx +++ b/packages/app/src/app/compare/[slug]/page.tsx @@ -1,12 +1,7 @@ import type { Metadata } from 'next'; import { notFound, permanentRedirect } from 'next/navigation'; -import { - HW_REGISTRY, - SITE_NAME, - SITE_URL, - SUPPORTERS_LINE, -} from '@semianalysisai/inferencex-constants'; +import { HW_REGISTRY, SITE_NAME, SITE_URL } from '@semianalysisai/inferencex-constants'; import { JsonLd } from '@/components/json-ld'; import { languageAlternates } from '@/lib/i18n'; @@ -15,11 +10,14 @@ import { canonicalCompareSlug, compareDisplayLabel, compareModelDisplayLabel, + compareModelSeoName, + compareSeoTitle, parseCompareSlug, } from '@/lib/compare-slug'; import { buildBreadcrumbJsonLd, buildJsonLd, + compareMetaDescription, compareTableNarrative, computeCompareTableData, dateRangeForPair, @@ -46,16 +44,30 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> { if (!parsed) return {}; const fullLabel = compareModelDisplayLabel(parsed.model, parsed.a, parsed.b); const gpuLabel = compareDisplayLabel(parsed.a, parsed.b); - const url = `${SITE_URL}/compare/${canonicalCompareSlug(parsed.model.slug, parsed.a, parsed.b)}`; - const description = `${gpuLabel} inference benchmark on ${parsed.model.label}: verified, reproducible head-to-head results from InferenceX, the independent open-source GPU benchmark by SemiAnalysis. ${SUPPORTERS_LINE} Compare latency, throughput & cost.`; + const modelSeoName = compareModelSeoName(parsed.model); + const canonical = canonicalCompareSlug(parsed.model.slug, parsed.a, parsed.b); + const url = `${SITE_URL}/compare/${canonical}`; + + // Lead the SEO title with the GPU pair — that's the phrase people search + // ("b200 vs b300") and must survive Google's ~60-char SERP truncation. The + // `absolute` form bypasses the long "%s | InferenceX by SemiAnalysis" root + // template so the query isn't pushed off the end. + const title = `${compareSeoTitle(gpuLabel, modelSeoName)} | ${SITE_NAME}`; + + // Stat-led meta description built from the interpolated head-to-head numbers + // at the slug's default operating point (falls back to boilerplate for + // sparse-data pairs). Fetch is blob-cached and shared with the page render. + const rows = await getCachedBenchmarks(parsed.model.dbKeys); + const { sequence, precision } = pickPairDefaults(rows, parsed.a, parsed.b); + const { ssrRows } = computeCompareTableData(rows, parsed.a, parsed.b, sequence, precision); + const description = compareMetaDescription(parsed.model, parsed.a, parsed.b, ssrRows); + return { - title: `${fullLabel} Inference Benchmark`, + title: { absolute: title }, description, alternates: { canonical: url, - languages: languageAlternates( - `/compare/${canonicalCompareSlug(parsed.model.slug, parsed.a, parsed.b)}`, - ), + languages: languageAlternates(`/compare/${canonical}`), }, openGraph: { title: `${fullLabel} | ${SITE_NAME}`, diff --git a/packages/app/src/app/zh/blog/[slug]/page.tsx b/packages/app/src/app/zh/blog/[slug]/page.tsx index 85b93660..391b90a3 100644 --- a/packages/app/src/app/zh/blog/[slug]/page.tsx +++ b/packages/app/src/app/zh/blog/[slug]/page.tsx @@ -16,7 +16,13 @@ import { ReadingProgressBar } from '@/components/blog/reading-progress-bar'; import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-buttons'; import { Card } from '@/components/ui/card'; import { JsonLd } from '@/components/json-ld'; -import { getAllPosts, getAdjacentPosts, extractHeadings, getPostBySlug } from '@/lib/blog'; +import { + blogDescription, + getAllPosts, + getAdjacentPosts, + extractHeadings, + getPostBySlug, +} from '@/lib/blog'; import { ZH_LANG_TAG, ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n'; import { AUTHOR_HANDLE, @@ -38,16 +44,19 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> { const result = getPostBySlug(slug, 'zh'); if (!result) return {}; const { meta } = result; + const description = blogDescription(meta); return { - title: meta.title, - description: meta.subtitle, + // Short " | InferenceX" suffix via `absolute` (mirrors the English page) + // so the headline keeps more of the SERP title before truncation. + title: { absolute: `${meta.title} | ${SITE_NAME}` }, + description, keywords: meta.tags, authors: [{ name: AUTHOR_NAME }], alternates: zhAlternates(`/blog/${slug}`), openGraph: { title: `${meta.title} | ${SITE_NAME}`, - description: meta.subtitle, + description, url: `${SITE_URL}/zh/blog/${slug}`, type: 'article', locale: ZH_OG_LOCALE, @@ -59,7 +68,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> { twitter: { card: 'summary_large_image', title: meta.title, - description: meta.subtitle, + description, site: AUTHOR_HANDLE, creator: AUTHOR_HANDLE, }, diff --git a/packages/app/src/app/zh/compare/[slug]/page.tsx b/packages/app/src/app/zh/compare/[slug]/page.tsx index 4cd11d03..2dae1257 100644 --- a/packages/app/src/app/zh/compare/[slug]/page.tsx +++ b/packages/app/src/app/zh/compare/[slug]/page.tsx @@ -1,12 +1,7 @@ import type { Metadata } from 'next'; import { notFound, permanentRedirect } from 'next/navigation'; -import { - HW_REGISTRY, - SITE_NAME, - SITE_URL, - SUPPORTERS_LINE_ZH, -} from '@semianalysisai/inferencex-constants'; +import { HW_REGISTRY, SITE_NAME, SITE_URL } from '@semianalysisai/inferencex-constants'; import { JsonLd } from '@/components/json-ld'; import { pickPairDefaults } from '@/lib/compare-pair-defaults'; @@ -14,6 +9,7 @@ import { canonicalCompareSlug, compareDisplayLabel, compareModelDisplayLabel, + compareModelSeoName, parseCompareSlug, } from '@/lib/compare-slug'; import { @@ -29,6 +25,7 @@ import { import { buildBreadcrumbJsonLdZh, buildJsonLdZh, + compareMetaDescriptionZh, compareTableNarrativeZh, } from '@/lib/compare-ssr-zh'; import { ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n'; @@ -48,11 +45,23 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> { if (!parsed) return {}; const fullLabel = compareModelDisplayLabel(parsed.model, parsed.a, parsed.b); const gpuLabel = compareDisplayLabel(parsed.a, parsed.b); + const modelSeoName = compareModelSeoName(parsed.model); const canonical = canonicalCompareSlug(parsed.model.slug, parsed.a, parsed.b); const url = `${SITE_URL}/zh/compare/${canonical}`; - const description = `${gpuLabel} 在 ${parsed.model.label} 上的推理基准测试:来自 InferenceX(SemiAnalysis 推出的独立开源 GPU 基准测试平台)的经验证、可复现的正面对比结果。${SUPPORTERS_LINE_ZH}对比延迟、吞吐量与成本。`; + + // GPU-pair-first title (mirrors the English page) via `absolute` so the long + // root template doesn't bury the search phrase. Model name / SKUs stay English. + const title = `${gpuLabel}:${modelSeoName} 推理基准测试 | ${SITE_NAME}`; + + // Stat-led Chinese meta description from the interpolated head-to-head numbers + // at the slug's default operating point (falls back to boilerplate when thin). + const rows = await getCachedBenchmarks(parsed.model.dbKeys); + const { sequence, precision } = pickPairDefaults(rows, parsed.a, parsed.b); + const { ssrRows } = computeCompareTableData(rows, parsed.a, parsed.b, sequence, precision); + const description = compareMetaDescriptionZh(parsed.model, parsed.a, parsed.b, ssrRows); + return { - title: `${fullLabel} 推理基准测试`, + title: { absolute: title }, description, alternates: zhAlternates(`/compare/${canonical}`), openGraph: { diff --git a/packages/app/src/lib/blog.test.ts b/packages/app/src/lib/blog.test.ts index 67633e87..e74fb1ea 100644 --- a/packages/app/src/lib/blog.test.ts +++ b/packages/app/src/lib/blog.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import { + blogDescription, extractHeadings, getAdjacentPosts, getAllPosts, @@ -9,6 +10,7 @@ import { getReadingTime, hasZhTranslation, slugify, + smartTruncate, } from './blog'; const FAKE_MDX = `--- @@ -179,6 +181,60 @@ describe('getReadingTime', () => { }); }); +describe('smartTruncate', () => { + it('returns the text unchanged (no ellipsis) when at or under the limit', () => { + expect(smartTruncate('Short and sweet.', 155)).toBe('Short and sweet.'); + const exact = 'x'.repeat(20); + expect(smartTruncate(exact, 20)).toBe(exact); + expect(smartTruncate(' trimmed ', 155)).toBe('trimmed'); + }); + + it('cuts at a word boundary (never mid-word) and appends an ellipsis', () => { + const text = Array.from({ length: 60 }, () => 'word').join(' '); // 60×"word " → 299 chars + const out = smartTruncate(text, 155); + expect(out.length).toBeLessThanOrEqual(155); + expect(out.endsWith('…')).toBe(true); + const body = out.slice(0, -1); + // Every retained token is a complete "word" — no "wor"/"rd" fragments. + expect(body.split(' ').every((t) => t === 'word')).toBe(true); + }); + + it('strips trailing punctuation before the ellipsis', () => { + const text = `${'alpha, '.repeat(40)}beta`; // lands the cut right after a comma + const out = smartTruncate(text, 50); + const body = out.slice(0, -1); + expect(out.endsWith('…')).toBe(true); + expect(/[\s,]$/u.test(body)).toBe(false); + expect(out.length).toBeLessThanOrEqual(50); + }); + + it('hard-cuts CJK prose (no spaces) and stays within the limit', () => { + const cjk = '推'.repeat(200); + const out = smartTruncate(cjk, 155); + expect(out.length).toBeLessThanOrEqual(155); + expect(out.endsWith('…')).toBe(true); + }); +}); + +describe('blogDescription', () => { + it('returns the explicit seoDescription verbatim when present', () => { + const meta = { seoDescription: 'Hand-written, punchy, ≤155 chars.', subtitle: 'x'.repeat(300) }; + expect(blogDescription(meta)).toBe('Hand-written, punchy, ≤155 chars.'); + }); + + it('smart-truncates the subtitle to ≤155 chars when no seoDescription is set', () => { + const subtitle = Array.from({ length: 60 }, () => 'word').join(' '); + const out = blogDescription({ subtitle }); + expect(out).toBe(smartTruncate(subtitle, 155)); + expect(out.length).toBeLessThanOrEqual(155); + expect(out.endsWith('…')).toBe(true); + }); + + it('passes a short subtitle through without an ellipsis', () => { + expect(blogDescription({ subtitle: 'A concise subtitle.' })).toBe('A concise subtitle.'); + }); +}); + describe('getAllPosts', () => { it('returns an array of posts sorted by date descending', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); diff --git a/packages/app/src/lib/blog.ts b/packages/app/src/lib/blog.ts index b69885ff..8fee5184 100644 --- a/packages/app/src/lib/blog.ts +++ b/packages/app/src/lib/blog.ts @@ -9,6 +9,11 @@ export interface BlogFrontmatter { subtitle: string; modifiedDate?: string; publishDate?: string; + /** Optional hand-written SEO/social meta description. When set it overrides + * the auto-truncated `subtitle` for the SERP snippet — use it on posts whose + * `subtitle` runs past ~155 chars and would otherwise truncate mid-sentence. + * Keep it ≤155 chars (English) / ≤~78 CJK chars and compelling. */ + seoDescription?: string; tags?: string[]; } @@ -49,6 +54,37 @@ export function getReadingTime(content: string): number { return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE + cjkChars / CJK_CHARS_PER_MINUTE)); } +/** Trailing whitespace + punctuation (any script), so a truncated snippet + * doesn't end in a dangling comma / dash / open bracket before the ellipsis. */ +const TRAILING_PUNCT_REGEX = /[\s\p{P}]+$/u; + +/** + * Truncate to at most `max` characters (ellipsis included) without cutting a + * word in half. Cuts at the last space that fits, strips trailing punctuation, + * and appends "…" only when the text was actually shortened. CJK prose has no + * spaces, so it falls back to a hard character cut (which is correct — there + * are no word boundaries to preserve). + */ +export function smartTruncate(text: string, max: number): string { + const clean = text.trim(); + if (clean.length <= max) return clean; + // Reserve one char for the ellipsis so the result is guaranteed ≤ max. + const slice = clean.slice(0, max - 1); + const lastSpace = slice.lastIndexOf(' '); + const boundary = lastSpace > 0 ? slice.slice(0, lastSpace) : slice; + return `${boundary.replace(TRAILING_PUNCT_REGEX, '')}…`; +} + +/** + * SERP / social meta description for a post: the explicit `seoDescription` + * frontmatter when present, otherwise the `subtitle` smart-truncated to a + * SERP-safe length. Used for the `<meta name="description">`, OpenGraph, and + * Twitter descriptions. + */ +export function blogDescription(meta: Pick<BlogPostMeta, 'seoDescription' | 'subtitle'>): string { + return meta.seoDescription ?? smartTruncate(meta.subtitle, 155); +} + export function getAllPosts(locale: BlogLocale = 'en'): BlogPostMeta[] { if (!fs.existsSync(CONTENT_DIR)) return []; diff --git a/packages/app/src/lib/compare-slug.test.ts b/packages/app/src/lib/compare-slug.test.ts index f85aac9e..c0c28cf1 100644 --- a/packages/app/src/lib/compare-slug.test.ts +++ b/packages/app/src/lib/compare-slug.test.ts @@ -6,6 +6,8 @@ import { canonicalCompareSlug, compareDisplayLabel, compareModelDisplayLabel, + compareModelSeoName, + compareSeoTitle, COMPARE_MODEL_ALIASES, COMPARE_MODEL_SLUGS, getCompareModelBySlug, @@ -258,6 +260,69 @@ describe('compareModelDisplayLabel', () => { }); }); +describe('compareModelSeoName', () => { + // Single, natural, current-version search name per model — no version-group + // slashes, no param-count suffix, no fully-qualified checkpoint id. + const EXPECTED: Record<string, string> = { + 'deepseek-v4': 'DeepSeek V4 Pro', + 'deepseek-r1': 'DeepSeek R1', + 'kimi-k26': 'Kimi K2.6', + 'glm-5-1': 'GLM-5', + 'glm-5-2': 'GLM-5.2', + 'minimax-m3': 'MiniMax M3', + 'minimax-m27': 'MiniMax M2.7', + 'qwen-3-5': 'Qwen3.5', + 'gptoss-120b': 'gpt-oss-120b', + 'llama-3-3-70b': 'Llama 3.3 70B', + }; + + it('returns the expected single-version name for every model', () => { + for (const model of COMPARE_MODEL_SLUGS) { + expect(compareModelSeoName(model), `seoName for ${model.slug}`).toBe(EXPECTED[model.slug]); + } + }); + + it('covers every model in the registry (no missing entries)', () => { + expect(Object.keys(EXPECTED).toSorted()).toEqual( + COMPARE_MODEL_SLUGS.map((m) => m.slug).toSorted(), + ); + }); + + it('never returns a version-grouped label (no slashes) and is non-empty', () => { + for (const model of COMPARE_MODEL_SLUGS) { + const name = compareModelSeoName(model); + expect(name.length, `non-empty for ${model.slug}`).toBeGreaterThan(0); + expect(name.includes('/'), `no slash in ${model.slug}`).toBe(false); + } + }); +}); + +describe('compareSeoTitle', () => { + it('leads with the GPU pair and keeps "Inference Benchmark" when it fits', () => { + const title = compareSeoTitle('B200 vs B300', 'GLM-5'); + expect(title).toBe('B200 vs B300: GLM-5 Inference Benchmark'); + expect(title.startsWith('B200 vs B300:')).toBe(true); + expect(title.length).toBeLessThanOrEqual(60); + }); + + it('drops "Inference" → "Benchmark" for a long GPU pair + model name', () => { + const title = compareSeoTitle('GB200 NVL72 vs GB300 NVL72', 'DeepSeek V4 Pro'); + // "…Inference Benchmark" would exceed ~60 chars, so it shortens. + expect(title).toBe('GB200 NVL72 vs GB300 NVL72: DeepSeek V4 Pro Benchmark'); + expect(title.includes('Inference Benchmark')).toBe(false); + expect(title.startsWith('GB200 NVL72 vs GB300 NVL72:')).toBe(true); + }); + + it('always starts with the GPU pair for every real (model × pair)', () => { + for (const model of COMPARE_MODEL_SLUGS) { + const gpuLabel = compareDisplayLabel('gb200', 'gb300'); + const title = compareSeoTitle(gpuLabel, compareModelSeoName(model)); + expect(title.startsWith(`${gpuLabel}:`), `pair-led for ${model.slug}`).toBe(true); + expect(/(?<suffix>Inference Benchmark|Benchmark)$/u.test(title)).toBe(true); + } + }); +}); + describe('getCompareModelBySlug', () => { it('returns canonical models for canonical slugs', () => { expect(getCompareModelBySlug('deepseek-r1')).toBe(DEEPSEEK_R1); diff --git a/packages/app/src/lib/compare-slug.ts b/packages/app/src/lib/compare-slug.ts index 10e9ed4f..fb636c40 100644 --- a/packages/app/src/lib/compare-slug.ts +++ b/packages/app/src/lib/compare-slug.ts @@ -28,6 +28,13 @@ export interface CompareModelSlug { dbKeys: string[]; /** Human label for OG image, metadata, and page header. */ label: string; + /** Single, natural, current-version model name for SEO `<title>` tags and + * meta descriptions — e.g. `GLM-5` (not the version-grouped `GLM 5/5.1`) + * or `Kimi K2.6` (the primary version the slug represents). Kept short so + * the GPU-pair-led title survives Google's ~60-char SERP truncation, and + * written the way people actually search rather than the fully-qualified + * HF checkpoint id. See `compareModelSeoName`. */ + seoName: string; } // Order matches the master /compare and /compare-per-dollar index display: @@ -43,12 +50,14 @@ export const COMPARE_MODEL_SLUGS: CompareModelSlug[] = [ displayName: 'DeepSeek-V4-Pro', dbKeys: ['dsv4'], label: 'DeepSeek V4 Pro 1.6T', + seoName: 'DeepSeek V4 Pro', }, { slug: 'deepseek-r1', displayName: 'DeepSeek-R1-0528', dbKeys: ['dsr1'], label: 'DeepSeek R1', + seoName: 'DeepSeek R1', }, { slug: 'kimi-k26', @@ -62,6 +71,8 @@ export const COMPARE_MODEL_SLUGS: CompareModelSlug[] = [ // surfaces every version so the URL doesn't read as "only K2.6". Param // count appended so the label conveys model scale alongside the version. label: 'Kimi K2.5/K2.6/K2.7-Code 1T', + // SEO name uses the primary version the slug canonicalizes to (K2.6). + seoName: 'Kimi K2.6', }, { slug: 'glm-5-1', @@ -69,12 +80,14 @@ export const COMPARE_MODEL_SLUGS: CompareModelSlug[] = [ // GLM-5 and GLM-5.1 remain grouped under the stable canonical slug. dbKeys: ['glm5.1', 'glm5'], label: 'GLM 5/5.1', + seoName: 'GLM-5', }, { slug: 'glm-5-2', displayName: 'GLM-5.2', dbKeys: ['glm5.2'], label: 'GLM 5.2', + seoName: 'GLM-5.2', }, { slug: 'minimax-m3', @@ -84,6 +97,7 @@ export const COMPARE_MODEL_SLUGS: CompareModelSlug[] = [ // joining the minimax-m27 group. dbKeys: ['minimaxm3'], label: 'MiniMax M3 428B', + seoName: 'MiniMax M3', }, { slug: 'minimax-m27', @@ -91,6 +105,8 @@ export const COMPARE_MODEL_SLUGS: CompareModelSlug[] = [ // Same point-release grouping pattern as Kimi and GLM. dbKeys: ['minimaxm2.7', 'minimaxm2.5'], label: 'MiniMax M2.5/M2.7', + // Primary version the slug canonicalizes to (M2.7). + seoName: 'MiniMax M2.7', }, { slug: 'qwen-3-5', @@ -98,18 +114,21 @@ export const COMPARE_MODEL_SLUGS: CompareModelSlug[] = [ dbKeys: ['qwen3.5'], // 397B total parameters, 17B active per forward pass (MoE). label: 'Qwen 3.5 397B-A17B', + seoName: 'Qwen3.5', }, { slug: 'gptoss-120b', displayName: 'gpt-oss-120b', dbKeys: ['gptoss120b'], label: 'gpt-oss 120B', + seoName: 'gpt-oss-120b', }, { slug: 'llama-3-3-70b', displayName: 'Llama-3.3-70B-Instruct-FP8', dbKeys: ['llama70b'], label: 'Llama 3.3 70B', + seoName: 'Llama 3.3 70B', }, ]; @@ -272,3 +291,25 @@ export function compareDisplayLabel(a: string, b: string): string { export function compareModelDisplayLabel(model: CompareModelSlug, a: string, b: string): string { return `${model.label} — ${compareDisplayLabel(a, b)}`; } + +/** Single natural, current-version model name for SEO titles / descriptions — + * e.g. `GLM-5`, `Kimi K2.6`, `DeepSeek R1`. Unlike `label`, it never contains + * version-group slashes ("GLM 5/5.1"), param-count suffixes ("1.6T"), or the + * fully-qualified checkpoint id — it reads the way people search. */ +export function compareModelSeoName(model: CompareModelSlug): string { + return model.seoName; +} + +/** Pre-pipe portion of the SEO `<title>`, GPU-pair first so the actual search + * query ("B200 vs B300") survives Google's ~60-char truncation. Falls back + * from "Inference Benchmark" to "Benchmark" when the longer suffix would push + * the pair-led phrase past ~60 chars for a long GPU pair / model name. + * + * Returns the text before the " | InferenceX" site-name suffix; callers add + * the suffix via `title: { absolute: ... }` so the long root template + * ("%s | InferenceX by SemiAnalysis") does not apply and truncate the query. */ +export function compareSeoTitle(gpuLabel: string, modelSeoName: string): string { + const full = `${gpuLabel}: ${modelSeoName} Inference Benchmark`; + if (full.length <= 60) return full; + return `${gpuLabel}: ${modelSeoName} Benchmark`; +} diff --git a/packages/app/src/lib/compare-ssr-zh.ts b/packages/app/src/lib/compare-ssr-zh.ts index cd48611a..149f9fac 100644 --- a/packages/app/src/lib/compare-ssr-zh.ts +++ b/packages/app/src/lib/compare-ssr-zh.ts @@ -9,17 +9,25 @@ import { AUTHOR_NAME, AUTHOR_URL, HW_REGISTRY, + SITE_NAME, SITE_URL, } from '@semianalysisai/inferencex-constants'; -import { type CompareModelSlug, compareModelDisplayLabel } from '@/lib/compare-slug'; +import { + type CompareModelSlug, + compareDisplayLabel, + compareModelDisplayLabel, + compareModelSeoName, +} from '@/lib/compare-slug'; import { bandFor, type CompareJsonLdVariant, + computeCompareStat, fmtCost, fmtPctDelta, type FullBoth, jsonLdEntryFor, + META_DESCRIPTION_MAX, type PairSummary, type PerDollarBoth, pickRotated, @@ -274,6 +282,67 @@ export function compareTableNarrativeZh( return paragraphs; } +// --------------------------------------------------------------------------- +// SEO meta description — Chinese port of compareMetaDescription +// --------------------------------------------------------------------------- + +/** First candidate ≤ max, or undefined. Local mirror of the private helper in + * compare-ssr.ts so the ladder logic stays identical between the two files. */ +function firstUnderZh(candidates: string[], max: number): string | undefined { + return candidates.find((c) => c.length <= max); +} + +/** Simplified Chinese, stat-led, ≤155-char meta description for a + * `/zh/compare/<slug>` page. 1:1 port of `compareMetaDescription`: same + * representative-row stat (`computeCompareStat`), same fallback-to-boilerplate + * and brand-clause-drop ladders. Model name, GPU SKUs and units stay English + * per the translation rules; the connective prose is Chinese. */ +export function compareMetaDescriptionZh( + model: CompareModelSlug, + a: string, + b: string, + ssrRows: SsrInterpolatedRow[], +): string { + const modelName = compareModelSeoName(model); + const gpuLabel = compareDisplayLabel(a, b); + + const fallback = + firstUnderZh( + [ + `${gpuLabel} 在 ${modelName} 上的推理基准测试:来自 ${SITE_NAME}(${AUTHOR_NAME} 出品)的经验证、可复现开源结果。对比延迟、吞吐量与成本。`, + `${gpuLabel} 在 ${modelName} 上的推理基准测试:来自 ${SITE_NAME}(${AUTHOR_NAME} 出品)的开源结果。`, + `${gpuLabel} 在 ${modelName} 上的推理基准测试,来自 ${SITE_NAME}。`, + `${gpuLabel} 在 ${modelName} 上的推理基准测试。`, + ], + META_DESCRIPTION_MAX, + ) ?? `${gpuLabel} 推理基准测试`.slice(0, META_DESCRIPTION_MAX); + + const stat = computeCompareStat(a, b, ssrRows); + if (!stat) return fallback; + + const tputClause = + stat.tputPct > 0 ? `${stat.faster} 每 GPU 吞吐量比 ${stat.slower} 高 ${stat.tputPct}%` : null; + const costClause = stat.costPct > 0 ? `${stat.cheaper} 每 token 成本低 ${stat.costPct}%` : null; + + let core: string; + if (tputClause && costClause) core = `在 ${modelName} 上,${tputClause};${costClause}。`; + else if (tputClause) core = `在 ${modelName} 上,${tputClause}。`; + else if (costClause) + core = `在 ${modelName} 上,${stat.cheaper} 每 token 成本比 ${stat.pricier} 低 ${stat.costPct}%。`; + else return fallback; + + return ( + firstUnderZh( + [ + `${core}来自 ${SITE_NAME}(${AUTHOR_NAME} 出品)的可验证开源基准测试。`, + `${core}来自 ${SITE_NAME} 的开源基准测试。`, + core, + ], + META_DESCRIPTION_MAX, + ) ?? fallback + ); +} + // --------------------------------------------------------------------------- // JSON-LD — Chinese // --------------------------------------------------------------------------- diff --git a/packages/app/src/lib/compare-ssr.test.ts b/packages/app/src/lib/compare-ssr.test.ts index 356fc869..be02369c 100644 --- a/packages/app/src/lib/compare-ssr.test.ts +++ b/packages/app/src/lib/compare-ssr.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it } from 'vitest'; import type { BenchmarkRow } from '@/lib/api'; +import type { InterpolatedResult } from '@/components/calculator/types'; -import { computeCompareImageRows, KNOWN_MODELS } from './compare-ssr'; +import { COMPARE_MODEL_SLUGS } from './compare-slug'; +import { + compareMetaDescription, + computeCompareImageRows, + KNOWN_MODELS, + META_DESCRIPTION_MAX, + type SsrInterpolatedRow, +} from './compare-ssr'; +import { compareMetaDescriptionZh } from './compare-ssr-zh'; // BenchmarkRow.id is required (stable per-point id from benchmark_results); // hand out a fresh one per stub so id-keyed logic can't collide across rows. @@ -141,3 +150,162 @@ describe('computeCompareImageRows', () => { ).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// compareMetaDescription (+ zh port) +// --------------------------------------------------------------------------- + +const GLM = COMPARE_MODEL_SLUGS.find((m) => m.slug === 'glm-5-1')!; // seoName 'GLM-5' +const DSV4 = COMPARE_MODEL_SLUGS.find((m) => m.slug === 'deepseek-v4')!; // long seoName + +/** Minimal InterpolatedResult stub — compareMetaDescription only reads + * `value` (tok/s/GPU) and `cost` ($/M tok); the rest are inert zeros. */ +function ir(value: number, cost: number): InterpolatedResult { + return { + hwKey: 'x', + resultKey: 'x', + value, + outputTputValue: value, + inputTputValue: 0, + cost, + costInput: 0, + costOutput: cost, + tpPerMw: 0, + inputTpPerMw: 0, + outputTpPerMw: 0, + concurrency: 0, + nearestPoints: [], + }; +} + +function makeSsrRows( + triples: [number, InterpolatedResult | null, InterpolatedResult | null][], +): SsrInterpolatedRow[] { + return triples.map(([target, a, b]) => ({ target, a, b })); +} + +describe('compareMetaDescription', () => { + it('leads with the throughput + cost stat when both dimensions differ', () => { + // a=B200 34% faster, b=B300 12% cheaper, at the middle (default) target. + const ssr = makeSsrRows([ + [20, ir(60, 2), ir(50, 1.7)], + [40, ir(134, 1.12), ir(100, 1)], // 134/100 = +34%, 1.12/1.0 = +12% + [60, ir(200, 0.9), ir(160, 0.85)], + ]); + const desc = compareMetaDescription(GLM, 'b200', 'b300', ssr); + expect(desc).toBe( + 'B200 delivers 34% more tok/s/GPU than B300 on GLM-5; B300 is 12% cheaper per token. Verified open-source benchmarks from InferenceX by SemiAnalysis.', + ); + expect(desc.length).toBeLessThanOrEqual(META_DESCRIPTION_MAX); + expect(desc.startsWith('B200 delivers 34% more tok/s/GPU than B300 on GLM-5')).toBe(true); + }); + + it('emits only the throughput clause when cost is within 1% (tied)', () => { + const ssr = makeSsrRows([[40, ir(134, 1), ir(100, 1)]]); + const desc = compareMetaDescription(GLM, 'b200', 'b300', ssr); + expect(desc.includes('34% more tok/s/GPU')).toBe(true); + expect(desc.includes('cheaper per token')).toBe(false); + expect(desc.length).toBeLessThanOrEqual(META_DESCRIPTION_MAX); + }); + + it('emits only the cost clause (with "than") when throughput is tied', () => { + const ssr = makeSsrRows([[40, ir(100, 1.5), ir(100, 1)]]); + const desc = compareMetaDescription(GLM, 'b200', 'b300', ssr); + expect(desc).toContain('B300 is 50% cheaper per token than B200 on GLM-5'); + expect(desc.includes('more tok/s/GPU')).toBe(false); + expect(desc.length).toBeLessThanOrEqual(META_DESCRIPTION_MAX); + }); + + it('falls back to boilerplate when there is no comparable data', () => { + const empty = compareMetaDescription(GLM, 'b200', 'b300', []); + expect(empty.startsWith('B200 vs B300 inference benchmark on GLM-5')).toBe(true); + expect(empty.includes('tok/s/GPU')).toBe(false); + expect(empty.length).toBeLessThanOrEqual(META_DESCRIPTION_MAX); + + // One side missing at every target → still boilerplate. + const oneSided = compareMetaDescription( + GLM, + 'b200', + 'b300', + makeSsrRows([[40, ir(100, 1), null]]), + ); + expect(oneSided.startsWith('B200 vs B300 inference benchmark on GLM-5')).toBe(true); + }); + + it('falls back when both dimensions are within 1%', () => { + const ssr = makeSsrRows([[40, ir(100, 1), ir(100, 1)]]); + const desc = compareMetaDescription(GLM, 'b200', 'b300', ssr); + expect(desc.startsWith('B200 vs B300 inference benchmark on GLM-5')).toBe(true); + }); + + it('prefers the middle (default) target row', () => { + // Middle row has the data; outer rows are degenerate. + const ssr = makeSsrRows([ + [20, null, null], + [40, ir(134, 1.12), ir(100, 1)], + [60, null, null], + ]); + const desc = compareMetaDescription(GLM, 'b200', 'b300', ssr); + expect(desc.includes('34% more tok/s/GPU')).toBe(true); + }); + + it('falls back to an outer usable row when the middle target is degenerate', () => { + const ssr = makeSsrRows([ + [20, ir(134, 1.12), ir(100, 1)], + [40, null, null], + [60, null, null], + ]); + const desc = compareMetaDescription(GLM, 'b200', 'b300', ssr); + expect(desc.includes('34% more tok/s/GPU')).toBe(true); + }); + + it('stays ≤155 chars even with long GPU labels, model name, and huge ratios', () => { + const ssr = makeSsrRows([[40, ir(9999, 99.9), ir(10, 0.5)]]); + const desc = compareMetaDescription(DSV4, 'gb200', 'gb300', ssr); + expect(desc.length).toBeLessThanOrEqual(META_DESCRIPTION_MAX); + // A differentiating stat is still present (not the boilerplate). + expect(desc.includes('tok/s/GPU') || desc.includes('cheaper per token')).toBe(true); + }); +}); + +describe('compareMetaDescriptionZh — structural 1:1 port', () => { + const ssr = makeSsrRows([ + [20, ir(60, 2), ir(50, 1.7)], + [40, ir(134, 1.12), ir(100, 1)], + [60, ir(200, 0.9), ir(160, 0.85)], + ]); + + it('surfaces the same numbers, model name, and GPU labels as the English version', () => { + const zh = compareMetaDescriptionZh(GLM, 'b200', 'b300', ssr); + expect(zh).toContain('GLM-5'); + expect(zh).toContain('B200'); + expect(zh).toContain('B300'); + expect(zh).toContain('34%'); + expect(zh).toContain('12%'); + expect(zh).toContain('每 GPU 吞吐量'); + expect(zh).toContain('每 token 成本'); + expect(zh.length).toBeLessThanOrEqual(META_DESCRIPTION_MAX); + }); + + it('is stat-led exactly when the English version is (and boilerplate otherwise)', () => { + // With data: both stat-led. + const en = compareMetaDescription(GLM, 'b200', 'b300', ssr); + const zh = compareMetaDescriptionZh(GLM, 'b200', 'b300', ssr); + expect(en.includes('34%')).toBe(true); + expect(zh.includes('34%')).toBe(true); + + // Without data: both boilerplate. + const enFb = compareMetaDescription(GLM, 'b200', 'b300', []); + const zhFb = compareMetaDescriptionZh(GLM, 'b200', 'b300', []); + expect(enFb.includes('inference benchmark')).toBe(true); + expect(zhFb.includes('推理基准测试')).toBe(true); + expect(zhFb.length).toBeLessThanOrEqual(META_DESCRIPTION_MAX); + }); + + it('stays ≤155 chars with long labels + huge ratios', () => { + const big = makeSsrRows([[40, ir(9999, 99.9), ir(10, 0.5)]]); + expect(compareMetaDescriptionZh(DSV4, 'gb200', 'gb300', big).length).toBeLessThanOrEqual( + META_DESCRIPTION_MAX, + ); + }); +}); diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts index 44e80939..592a8238 100644 --- a/packages/app/src/lib/compare-ssr.ts +++ b/packages/app/src/lib/compare-ssr.ts @@ -14,6 +14,7 @@ import { AUTHOR_NAME, AUTHOR_URL, HW_REGISTRY, + SITE_NAME, SITE_URL, sequenceToIslOsl, } from '@semianalysisai/inferencex-constants'; @@ -35,6 +36,7 @@ import { type ComparePair, type CompareModelSlug, compareModelDisplayLabel, + compareModelSeoName, } from '@/lib/compare-slug'; import { getHardwareConfig, getGpuSpecs } from '@/lib/constants'; import { loadFixture } from '@/lib/test-fixtures'; @@ -726,6 +728,150 @@ export function compareTableNarrative( return paragraphs; } +// --------------------------------------------------------------------------- +// SEO meta description — stat-led, ≤155 chars, per (model, GPU pair) +// --------------------------------------------------------------------------- + +/** The single interpolated head-to-head stat surfaced in the meta description. + * `tputPct` / `costPct` are 0 when that dimension is within ~1% (a tie). */ +export interface CompareStat { + aLabel: string; + bLabel: string; + gpuLabel: string; + faster: string; + slower: string; + /** Throughput-per-GPU advantage of `faster` over `slower`, as a whole + * percent (0 when the two are within 1%). */ + tputPct: number; + cheaper: string; + pricier: string; + /** Cost-per-token advantage of `cheaper` over `pricier`, as a whole percent + * (0 when the two are within 1%). */ + costPct: number; +} + +/** Does a row carry a real, comparable data point for both GPUs? */ +function statRowUsable(row: SsrInterpolatedRow): boolean { + return Boolean( + row.a && row.b && row.a.value > 0 && row.b.value > 0 && row.a.cost > 0 && row.b.cost > 0, + ); +} + +/** Pick the representative interpolated row for the meta description — the + * default (middle) interactivity target if it has data for both GPUs, else + * the first target that does. Returns null when no target has a comparable + * pair (sparse-data pairs fall back to boilerplate). */ +function pickCompareStatRow(rows: SsrInterpolatedRow[]): SsrInterpolatedRow | null { + const mid = rows[Math.floor(rows.length / 2)]; + if (mid && statRowUsable(mid)) return mid; + return rows.find(statRowUsable) ?? null; +} + +/** Language-agnostic head-to-head stat for the meta description, computed at + * the default interactivity target. Shared by the English `compareMetaDescription` + * and its Chinese port so both surface the same numbers. Returns null when the + * pair has no comparable interpolated data. */ +export function computeCompareStat( + a: string, + b: string, + ssrRows: SsrInterpolatedRow[], +): CompareStat | null { + const row = pickCompareStatRow(ssrRows); + if (!row || !row.a || !row.b) return null; + + const aLabel = HW_REGISTRY[a]?.label ?? a.toUpperCase(); + const bLabel = HW_REGISTRY[b]?.label ?? b.toUpperCase(); + const rA = row.a; + const rB = row.b; + + const aFaster = rA.value > rB.value; + const tputRatio = aFaster ? rA.value / rB.value : rB.value / rA.value; + const tputPct = Math.round((tputRatio - 1) * 100); + + const aCheaper = rA.cost < rB.cost; + const costRatio = aCheaper ? rB.cost / rA.cost : rA.cost / rB.cost; + const costPct = Math.round((costRatio - 1) * 100); + + return { + aLabel, + bLabel, + gpuLabel: compareDisplayLabel(a, b), + faster: aFaster ? aLabel : bLabel, + slower: aFaster ? bLabel : aLabel, + tputPct: tputPct >= 1 ? tputPct : 0, + cheaper: aCheaper ? aLabel : bLabel, + pricier: aCheaper ? bLabel : aLabel, + costPct: costPct >= 1 ? costPct : 0, + }; +} + +/** Length cap for meta descriptions — Google truncates SERP snippets past + * ~155–160 chars, so every branch below must stay at or under this. */ +export const META_DESCRIPTION_MAX = 155; + +/** First candidate that fits within `max`, or undefined if none do. */ +function firstUnder(candidates: string[], max: number): string | undefined { + return candidates.find((c) => c.length <= max); +} + +/** Stat-led, ≤155-char meta description for a `/compare/<slug>` page. Leads + * with the differentiating throughput / cost delta at the default interactivity + * target (the actual reason a searcher clicks) instead of the boilerplate that + * was identical across ~360 pages. Falls back to a compact, still-≤155-char + * boilerplate sentence when the pair has thin / degenerate data. + * + * A trailing brand clause is dropped down a ladder (full → short → none) so a + * long GPU-pair / model name never pushes the description over the cap. */ +export function compareMetaDescription( + model: CompareModelSlug, + a: string, + b: string, + ssrRows: SsrInterpolatedRow[], +): string { + const modelName = compareModelSeoName(model); + const gpuLabel = compareDisplayLabel(a, b); + + // Boilerplate fallback ladder — progressively shorter so one always fits. + const fallback = + firstUnder( + [ + `${gpuLabel} inference benchmark on ${modelName}: verified, reproducible open-source results from ${SITE_NAME} by ${AUTHOR_NAME}. Latency, throughput & cost.`, + `${gpuLabel} inference benchmark on ${modelName}: verified open-source results from ${SITE_NAME} by ${AUTHOR_NAME}.`, + `${gpuLabel} inference benchmark on ${modelName} from ${SITE_NAME} by ${AUTHOR_NAME}.`, + `${gpuLabel} inference benchmark on ${modelName}.`, + ], + META_DESCRIPTION_MAX, + ) ?? `${gpuLabel} inference benchmark`.slice(0, META_DESCRIPTION_MAX); + + const stat = computeCompareStat(a, b, ssrRows); + if (!stat) return fallback; + + const tputClause = + stat.tputPct > 0 + ? `${stat.faster} delivers ${stat.tputPct}% more tok/s/GPU than ${stat.slower} on ${modelName}` + : null; + const costClause = + stat.costPct > 0 ? `${stat.cheaper} is ${stat.costPct}% cheaper per token` : null; + + let core: string; + if (tputClause && costClause) core = `${tputClause}; ${costClause}.`; + else if (tputClause) core = `${tputClause}.`; + else if (costClause) + core = `${stat.cheaper} is ${stat.costPct}% cheaper per token than ${stat.pricier} on ${modelName}.`; + else return fallback; // both dimensions within 1% — nothing differentiating to lead with + + return ( + firstUnder( + [ + `${core} Verified open-source benchmarks from ${SITE_NAME} by ${AUTHOR_NAME}.`, + `${core} Verified open-source ${SITE_NAME} benchmarks.`, + core, + ], + META_DESCRIPTION_MAX, + ) ?? fallback + ); +} + // --------------------------------------------------------------------------- // Master-index helpers (shared by /compare and /compare-per-dollar) // --------------------------------------------------------------------------- diff --git a/packages/app/src/lib/compare-variant-ssr.test.ts b/packages/app/src/lib/compare-variant-ssr.test.ts index 0e85c225..857e8ef0 100644 --- a/packages/app/src/lib/compare-variant-ssr.test.ts +++ b/packages/app/src/lib/compare-variant-ssr.test.ts @@ -159,6 +159,7 @@ const MODEL_SLUG = { displayName: 'DeepSeek-R1-0528', dbKeys: ['dsr1'], label: 'DeepSeek R1', + seoName: 'DeepSeek R1', }; // --------------------------------------------------------------------------- From d3116d73233857e3f2d8187b050af4f87815999f Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:04:39 +0800 Subject: [PATCH 2/2] fix(seo): sync blog JSON-LD description with meta/OG description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot flagged that the BlogPosting JSON-LD description still used the full meta.subtitle while the HTML meta, OpenGraph, and Twitter descriptions were routed through blogDescription — so posts with a seoDescription or long subtitle exposed different text in structured data than in the SERP/social tags. Route the JSON-LD description through the same blogDescription helper (EN + zh) so they stay in sync. Logic is already covered by the blogDescription unit tests. 中文:修复博客 JSON-LD 描述与 meta/OG 描述不一致的问题。Cursor Bugbot 指出 BlogPosting JSON-LD 的 description 仍使用完整的 meta.subtitle,而 HTML meta、 OpenGraph 和 Twitter 描述已改用 blogDescription,导致设置了 seoDescription 或 副标题较长的文章在结构化数据与搜索/社交标签中显示不同文本。这里让 JSON-LD 的 description 同样走 blogDescription(EN 与 zh 两侧),保持一致;相关逻辑已由 blogDescription 单元测试覆盖。 --- packages/app/src/app/blog/[slug]/page.tsx | 5 ++++- packages/app/src/app/zh/blog/[slug]/page.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/app/src/app/blog/[slug]/page.tsx b/packages/app/src/app/blog/[slug]/page.tsx index e21b5b12..eaad761c 100644 --- a/packages/app/src/app/blog/[slug]/page.tsx +++ b/packages/app/src/app/blog/[slug]/page.tsx @@ -144,7 +144,10 @@ export default async function BlogPostPage({ params }: Props) { publisher: { '@type': 'Organization', name: AUTHOR_NAME }, datePublished: `${meta.date}T00:00:00Z`, ...(meta.modifiedDate && { dateModified: `${meta.modifiedDate}T00:00:00Z` }), - description: meta.subtitle, + // Keep structured-data description in sync with the SERP/OG/Twitter meta + // (both go through blogDescription) so they never diverge for posts with a + // seoDescription or a long subtitle. + description: blogDescription(meta), url: `${SITE_URL}/blog/${slug}`, wordCount: raw.trim().split(/\s+/u).length, timeRequired: `PT${meta.readingTime}M`, diff --git a/packages/app/src/app/zh/blog/[slug]/page.tsx b/packages/app/src/app/zh/blog/[slug]/page.tsx index 391b90a3..4d251d8b 100644 --- a/packages/app/src/app/zh/blog/[slug]/page.tsx +++ b/packages/app/src/app/zh/blog/[slug]/page.tsx @@ -140,7 +140,10 @@ export default async function ZhBlogPostPage({ params }: Props) { publisher: { '@type': 'Organization', name: AUTHOR_NAME }, datePublished: `${meta.date}T00:00:00Z`, ...(meta.modifiedDate && { dateModified: `${meta.modifiedDate}T00:00:00Z` }), - description: meta.subtitle, + // Keep structured-data description in sync with the SERP/OG/Twitter meta + // (both go through blogDescription) so they never diverge for posts with a + // seoDescription or a long subtitle. + description: blogDescription(meta), url: `${SITE_URL}/zh/blog/${slug}`, inLanguage: ZH_LANG_TAG, wordCount: raw.trim().split(/\s+/u).length,