Claude 語言漂移真正值得量的,不是某個「AI 味」單字出現幾次,而是團隊 PR 的詞彙分布是否離開自己的 baseline,以及這個變化有沒有讓 reviewer 更難回答「改了什麼、風險在哪、下一步是什麼」。把 quietly、load-bearing 列成禁字,最多只能改變表面;它既不能證明誰寫了 PR,也不能直接證明文字變難懂。
這個題目在 2026 年 8 月底突然升溫:Load-Bearing Vocabulary 的 Hacker News 討論截至 2026 年 8 月 31 日早上已超過 690 points。不過熱度只證明大家在意,不等於方法已經能做作者鑑定。
這篇專為想替 AI-assisted 開發建立量測、但不熟統計檢定的團隊寫:我們會把公開專案改造成一套 repo 級實驗,匯出自己的 PR corpus、保留人工/AI-assisted/bot/unknown 的不確定標記、凍結一份可版本化 baseline receipt、每週算 Jensen–Shannon distance,最後用盲化理解題把結果標成 harm_detected、no_detected_harm 或 inconclusive。完成後得到的是只提醒、不封鎖的 Style Drift CI,不是 Claude 偵測器。
先說結論:Claude 語言漂移要通過兩道驗收
分布偏移只負責告訴你「這週和以前不同」;理解驗收才回答「這個不同是否妨礙審查」。
第一道是分布門:同一個 repo、同一套斷詞規則下,比較本週 PR 與介入前八個完整週的詞彙分布。第二道是理解門:抽樣 A/B 兩版 PR 說明,讓不知道版本來源的 reviewer 回答三題。理解出現傷害時,不論分布有沒有警示都值得追查;兩道門同時亮起,只是讓「近期 style 介入」成為更具體的候選原因。
若分數升高,先檢查新產品詞、PR template、團隊成員或 release 類型是否改變。小樣本看不出差異時只能記 inconclusive;只有預先定義可接受差距,而且不確定區間完整落在等效界線內,才可記 no_detected_harm。不要為了壓低一個數字而懲罰清楚的用語。

Load-Bearing Vocabulary 原專案到底量了什麼?
原始 Load-Bearing Vocabulary 專案分析公開 GitHub PR 說明,但搜尋條件要求 body 至少含十個常見英文 function words 之一,因此是偏英文的選樣,不是全部 GitHub PR。其 2026 年 8 月 30 日 analysis.js納入 595 個取樣日、85 個完整週,共 461,121 篇 PR 說明、51,079,244 次詞彙出現,經門檻後保留 19,798 個詞。在該抽樣與聚類規格下,主群由最初八週平均 0.702% 上升到截至 2026 年 8 月 17 日最近四週約 39.506%。
這不是把全部 GitHub PR 都抓下來。專案每天從十個 2.4 小時區塊各抽一個五分鐘窗口,每個窗口只取搜尋 API 最早回傳的一頁;query 先排除 pull、dependabot、renovate、github-actions四個 Apps,後處理再移除明顯 bot-shaped 登入、少於五個 distinct words 的說明、同週相同詞集合,並把每位帳號每週上限設為三篇。詞彙至少要出現在 50 個不同帳號,才進入 vocabulary。
模型部分用 KL divergence 的 hard clustering,把每篇 PR 強制放進十群之一,之後才按週計數。作者在固定版本的方法說明也直接列出:K=10是看過結果後選的,seed、五分鐘窗口與每週三篇上限都會影響答案。這份透明度很重要,因為它支持的是「這個規格下出現大型詞彙分布變化」,不是「39% PR 由 Claude 撰寫」。
原專案收集的公開欄位不含可驗證的生成工具 ground truth;GitHub 帳號只告訴你誰送出文字,不告訴你文字由人、Claude、其他模型、template 或多人共同起草。

步驟一:先固定自己的 PR corpus 與標記規則
準備 Node.js 22.18.0、jq 與已登入的 gh CLI。不要用「最近 500 篇」代替時間窗:GitHub CLI 官方手冊列出的 flags 沒有保證按 mergedAt 排序,因此不能假設固定筆數就是最近合併的全集。本文會用明確的 merged:開始..結束範圍;結果碰到 GitHub Search 的 1,000 筆上限就視為截斷並停止。手冊列出的 JSON 欄位也包含本文會用到的 number、author、body、mergedAt與 labels。
mkdir -p .style-drift artifacts
node --version
gh auth status
接著在 PR 層級選用四個 label。這些值應由提交者或團隊流程標記,不要靠詞彙反推:
authorship:human:團隊定義下未使用生成式改寫。authorship:ai-assisted:AI 參與起草、改寫或摘要;不表示整篇全自動生成。authorship:bot:已知自動化帳號或 template 產生。authorship:unknown:未標記、衝突或無法確認,也是預設值。
主分布排除明確標記為 bot 的 PR,其餘 human、AI-assisted、unknown 都保留;報告把完整 input 與經過最少詞數、bot 排除、作者上限後的 scoring sample 分開列出四類數量。如此做的目的不是訓練分類器,而是讓讀者看到 provenance 覆蓋率。即使帳號以 [bot] 結尾,也只是 account-type heuristic,不應自動變成內容作者證據。
步驟二:Claude 語言漂移分數怎麼算?
小型 repo 不適合直接照搬固定十群。本文先做更容易稽核的版本:只分析 PR body;每篇 PR 對每個詞最多貢獻一次,再讓每篇 PR 的總權重相同;每位作者每週最多抽三篇。介入前八個連續完整 UTC 週會被轉成只含聚合分布、vocabulary、設定與來源 SHA-256 的 baseline receipt,之後週報只讀這個版本化檔案,不重新抓歷史文字。這些是工程起始值,不是跨 repo 通用標準。
- 這個起始版用簡化規則移除成對的三反引號 code fence、inline code 與 HTML tag,URL 只保留 domain,再做 NFC normalization;複雜或不完整 Markdown 應先換成正式 parser。
- 使用
Intl.Segmenter('zh-Hant')同時處理繁中與英文;每篇 PR 內重複詞去重。 - 只有在八個 baseline 週中至少出現於三篇 PR 的詞,才進入凍結 vocabulary;current 內所有不在這份 vocabulary 的詞(包含 baseline 時未達門檻與後來才出現者)都合併成同一個 out-of-vocabulary bucket,不回頭改寫 baseline。
- 先把每篇 PR 的詞彙權重正規化,再平均成每週分布;以 log base 2 計算 Jensen–Shannon divergence,開根號後成為 0 到 1 的 distance。
- 輪流拿 baseline 每一週對其餘七週計分;經驗 P95 只當粗略視覺線,不是顯著性檢定。歷史累積到 20 週以上,再評估是否有資格設定穩定提醒線。
分數公式可寫成 sqrt(JS(P_current || P_baseline))。它只表示兩個詞彙分布有多不同,不知道變化來自 Claude、prompt、產品名稱、團隊組成或 PR template。若 template 有固定段落,應先移除該區塊或按 template 版本分層。
步驟三:加入可直接執行的 advisory 腳本
建立 scripts/style-drift.mjs,貼上以下完整程式。freeze是一次性設定:日期不連續、每週不足 12 篇、設定無效或 vocabulary 少於 10 詞就以非零結束,不產生 baseline。report則是 fail-open advisory:已處理的資料不足不拿詞彙分數阻擋 merge;collection error 會先留下 tool_error receipt,再由 workflow 最後一步把 job 標紅。Runner、權限、receipt 或整個程序故障也會明確失敗。這份 workflow 沒有 pull_request trigger,因此紅色維運訊號本身仍不會成為 merge gate。
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const SCHEMA_VERSION = 2;
const OOV_BUCKET = "__out_of_vocabulary__";
const CONFIG = {
baselineWeeks: Number(process.env.STYLE_BASELINE_WEEKS || 8),
minDocsPerWeek: Number(process.env.STYLE_MIN_DOCS || 12),
maxDocsPerAuthorWeek: Number(process.env.STYLE_MAX_DOCS_PER_AUTHOR || 3),
minDocumentFrequency: Number(process.env.STYLE_MIN_DF || 3),
minUniqueTokensPerDocument: Number(process.env.STYLE_MIN_TOKENS || 5),
};
function requireInteger(name, value, min, max) {
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(name + " must be an integer from " + min + " to " + max);
}
}
function validateConfig(config) {
requireInteger("STYLE_BASELINE_WEEKS", config.baselineWeeks, 4, 104);
requireInteger("STYLE_MIN_DOCS", config.minDocsPerWeek, 1, 10000);
requireInteger("STYLE_MAX_DOCS_PER_AUTHOR", config.maxDocsPerAuthorWeek, 1, 1000);
requireInteger("STYLE_MIN_DF", config.minDocumentFrequency, 1, 10000);
requireInteger("STYLE_MIN_TOKENS", config.minUniqueTokensPerDocument, 1, 1000);
}
function parseDate(value, label) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) throw new Error("Invalid " + label + ": " + value);
return d;
}
function weekStart(value) {
const d = parseDate(value, "date");
const day = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - day);
d.setUTCHours(0, 0, 0, 0);
return d.toISOString().slice(0, 10);
}
function addDays(isoDate, days) {
const d = parseDate(isoDate + "T00:00:00Z", "ISO date");
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
function latestCompleteWeek() {
// Regression fixtures pin the clock; production leaves STYLE_NOW unset.
const now = process.env.STYLE_NOW
? parseDate(process.env.STYLE_NOW, "STYLE_NOW")
: new Date();
return addDays(weekStart(now), -7);
}
function expectedBaselineWeeks(endWeek, count) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(endWeek) || weekStart(endWeek + "T00:00:00Z") !== endWeek) {
throw new Error("STYLE_BASELINE_END must be a UTC Monday in YYYY-MM-DD");
}
return Array.from({ length: count }, (_, index) =>
addDays(endWeek, -7 * (count - index - 1)),
);
}
function declaredAttribution(pr) {
const values = (pr.labels || [])
.map((label) => (typeof label === "string" ? label : label?.name || ""))
.map((label) => label.toLowerCase())
.filter((label) => label.startsWith("authorship:"))
.map((label) => label.slice("authorship:".length));
const allowed = new Set(["human", "ai-assisted", "bot", "unknown"]);
if (!values.length || values.some((value) => !allowed.has(value))) return "unknown";
return new Set(values).size === 1 ? values[0] : "unknown";
}
function cleanMarkdown(text) {
return String(text || "")
.replace(/\x60\x60\x60[\s\S]*?\x60\x60\x60/g, " ")
.replace(/\x60[^\x60]*\x60/g, " ")
.replace(/https?:\/\/[^\s)]+/g, (url) => {
try {
return " url:" + new URL(url).hostname + " ";
} catch {
return " url ";
}
})
.replace(/<[^>]+>/g, " ")
.normalize("NFC")
.toLocaleLowerCase("en-US");
}
const segmenter = new Intl.Segmenter("zh-Hant", { granularity: "word" });
function documentTokens(pr) {
const text = cleanMarkdown(pr.body || "");
const tokens = [...segmenter.segment(text)]
.filter((part) => part.isWordLike)
.map((part) => part.segment.trim())
.filter((token) => /[\p{L}\p{N}]/u.test(token))
.filter((token) => !/^\d+(?:[.,]\d+)*$/.test(token));
return [...new Set(tokens)];
}
function stableKey(pr) {
return createHash("sha256")
.update("style-drift-v2:" + (pr.number ?? "?") + ":" + (pr.mergedAt ?? "?"))
.digest("hex");
}
function eligibleDocuments(prs, config) {
return prs.filter((pr) => documentTokens(pr).length >= config.minUniqueTokensPerDocument);
}
function capAuthors(prs, config) {
const groups = new Map();
for (const pr of prs) {
const author = pr.author?.login || "unknown-author";
const key = weekStart(pr.mergedAt) + ":" + author;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(pr);
}
return [...groups.values()].flatMap((group) =>
group
.sort((a, b) => stableKey(a).localeCompare(stableKey(b)))
.slice(0, config.maxDocsPerAuthorWeek),
);
}
function groupByWeek(prs) {
const grouped = new Map();
for (const pr of prs) {
const week = weekStart(pr.mergedAt);
if (!grouped.has(week)) grouped.set(week, []);
grouped.get(week).push(pr);
}
return grouped;
}
function documentFrequency(prs) {
const counts = new Map();
for (const pr of prs) {
for (const token of documentTokens(pr)) {
counts.set(token, (counts.get(token) || 0) + 1);
}
}
return counts;
}
function documentWeights(prs) {
const weights = new Map();
for (const pr of prs) {
const tokens = documentTokens(pr);
if (!tokens.length) continue;
for (const token of tokens) {
weights.set(token, (weights.get(token) || 0) + 1 / tokens.length);
}
}
return weights;
}
function addMaps(maps) {
const total = new Map();
for (const map of maps) {
for (const [token, value] of map) {
total.set(token, (total.get(token) || 0) + value);
}
}
return total;
}
function project(map, vocabulary) {
const projected = new Map([...vocabulary].map((token) => [token, 0]));
for (const [token, value] of map) {
const target = vocabulary.has(token) ? token : OOV_BUCKET;
projected.set(target, (projected.get(target) || 0) + value);
}
return projected;
}
function probabilities(counts, vocabulary) {
const denominator = [...vocabulary].reduce(
(sum, token) => sum + (counts.get(token) || 0),
0,
);
if (!(denominator > 0)) throw new Error("Distribution has zero mass");
return new Map(
[...vocabulary].map((token) => [token, (counts.get(token) || 0) / denominator]),
);
}
function jsDistance(leftCounts, rightCounts, vocabulary) {
const left = probabilities(leftCounts, vocabulary);
const right = probabilities(rightCounts, vocabulary);
let divergence = 0;
for (const token of vocabulary) {
const p = left.get(token) || 0;
const q = right.get(token) || 0;
const m = (p + q) / 2;
if (p) divergence += 0.5 * p * Math.log2(p / m);
if (q) divergence += 0.5 * q * Math.log2(q / m);
}
return Math.sqrt(Math.max(0, divergence));
}
function percentile(values, fraction) {
const sorted = [...values].sort((a, b) => a - b);
if (!sorted.length) throw new Error("No calibration scores");
const index = Math.ceil(fraction * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
}
function mapToObject(map) {
return Object.fromEntries([...map].sort(([a], [b]) => a.localeCompare(b)));
}
function objectToMap(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(label + " must be an object");
}
const map = new Map();
for (const [key, item] of Object.entries(value)) {
if (typeof item !== "number" || !Number.isFinite(item) || item < 0) {
throw new Error(label + " contains an invalid numeric value");
}
map.set(key, item);
}
return map;
}
function summarizeAttribution(prs) {
const summary = { human: 0, "ai-assisted": 0, bot: 0, unknown: 0 };
for (const pr of prs) summary[declaredAttribution(pr)] += 1;
return summary;
}
function sourceFingerprint(prs) {
const rows = prs
.map((pr) => ({
number: pr.number ?? null,
mergedAt: pr.mergedAt ?? null,
author: pr.author?.login || null,
body: pr.body || "",
labels: (pr.labels || [])
.map((label) => (typeof label === "string" ? label : label?.name || ""))
.sort(),
}))
.sort((a, b) => String(a.number).localeCompare(String(b.number)));
return createHash("sha256").update(JSON.stringify(rows)).digest("hex");
}
function redactableToken(token) {
return (
token.length <= 48 &&
!token.includes("@") &&
!token.includes("/") &&
!/^[a-f0-9]{24,}$/i.test(token) &&
!/\d{5,}/.test(token)
);
}
function topChanges(currentCounts, baselineCounts, vocabulary, limit = 8) {
const current = probabilities(currentCounts, vocabulary);
const baseline = probabilities(baselineCounts, vocabulary);
return [...vocabulary]
.filter((token) => token !== OOV_BUCKET && redactableToken(token))
.map((token) => ({
token,
delta: (current.get(token) || 0) - (baseline.get(token) || 0),
}))
.filter((row) => row.delta > 0)
.sort((a, b) => b.delta - a.delta)
.slice(0, limit);
}
function validateInputWindow(prs, allowedWeeks, label) {
const allowed = new Set(allowedWeeks);
const outside = prs.filter((pr) => !allowed.has(weekStart(pr.mergedAt)));
if (outside.length) {
throw new Error(label + " contains " + outside.length + " rows outside the exact window");
}
}
function buildBaseline(prs, config) {
validateConfig(config);
const endWeek = process.env.STYLE_BASELINE_END || "";
const weeks = expectedBaselineWeeks(endWeek, config.baselineWeeks);
if (endWeek > latestCompleteWeek()) throw new Error("Baseline cannot include an incomplete UTC week");
validateInputWindow(prs, weeks, "Baseline input");
const eligible = eligibleDocuments(prs, config);
const nonBot = eligible.filter((pr) => declaredAttribution(pr) !== "bot");
const selected = capAuthors(nonBot, config);
const grouped = groupByWeek(selected);
const documents = Object.fromEntries(
weeks.map((week) => [week, (grouped.get(week) || []).length]),
);
const insufficient = weeks.filter((week) => documents[week] < config.minDocsPerWeek);
if (insufficient.length) {
throw new Error("Baseline weeks below STYLE_MIN_DOCS: " + insufficient.join(", "));
}
const weeklyDf = new Map(
weeks.map((week) => [week, documentFrequency(grouped.get(week) || [])]),
);
const baselineDf = addMaps(weeks.map((week) => weeklyDf.get(week)));
const tokens = [...baselineDf]
.filter(
([token, count]) =>
token !== OOV_BUCKET && count >= config.minDocumentFrequency,
)
.map(([token]) => token)
.sort();
if (tokens.length < 10) {
throw new Error("insufficient_vocabulary: fewer than 10 baseline tokens passed STYLE_MIN_DF");
}
const vocabulary = new Set([...tokens, OOV_BUCKET]);
const weeklyWeights = new Map(
weeks.map((week) => [week, documentWeights(grouped.get(week) || [])]),
);
const baselineWeights = project(
addMaps(weeks.map((week) => weeklyWeights.get(week))),
vocabulary,
);
const baselineDistribution = probabilities(baselineWeights, vocabulary);
const calibrationScores = weeks.map((week) => {
const rest = addMaps(
weeks.filter((other) => other !== week).map((other) => weeklyWeights.get(other)),
);
return jsDistance(
project(weeklyWeights.get(week), vocabulary),
project(rest, vocabulary),
vocabulary,
);
});
return {
schema_version: SCHEMA_VERSION,
kind: "style-drift-baseline",
created_at: new Date().toISOString(),
baseline_end_week: endWeek,
baseline_weeks: weeks,
config,
tokenizer: {
source_field: "body",
unicode_normalization: "NFC",
segmentation: "Intl.Segmenter(zh-Hant, word)",
code_removed: true,
document_weighting: "equal",
},
source: {
input_rows: prs.length,
eligible_rows: eligible.length,
selected_rows: selected.length,
sha256: sourceFingerprint(prs),
},
input_attribution: summarizeAttribution(prs),
selected_attribution: summarizeAttribution(selected),
documents,
vocabulary: tokens,
distribution: mapToObject(baselineDistribution),
calibration_scores: calibrationScores,
advisory_reference_p95: percentile(calibrationScores, 0.95),
};
}
function validateBaseline(baseline) {
if (baseline?.schema_version !== SCHEMA_VERSION || baseline?.kind !== "style-drift-baseline") {
throw new Error("Baseline schema/kind mismatch");
}
validateConfig(baseline.config);
const expected = expectedBaselineWeeks(
baseline.baseline_end_week,
baseline.config.baselineWeeks,
);
if (JSON.stringify(expected) !== JSON.stringify(baseline.baseline_weeks)) {
throw new Error("Baseline weeks are not consecutive or do not match config");
}
if (
!Array.isArray(baseline.vocabulary) ||
baseline.vocabulary.length < 10 ||
baseline.vocabulary.some(
(token) => typeof token !== "string" || !token || token === OOV_BUCKET,
) ||
new Set(baseline.vocabulary).size !== baseline.vocabulary.length
) {
throw new Error("insufficient_vocabulary: baseline vocabulary is degenerate");
}
const vocabulary = new Set([...baseline.vocabulary, OOV_BUCKET]);
const distribution = objectToMap(baseline.distribution, "baseline.distribution");
for (const token of vocabulary) {
if (!distribution.has(token)) {
throw new Error("Baseline distribution does not cover the frozen vocabulary");
}
}
probabilities(distribution, vocabulary);
if (
typeof baseline.advisory_reference_p95 !== "number" ||
!Number.isFinite(baseline.advisory_reference_p95) ||
baseline.advisory_reference_p95 < 0 ||
baseline.advisory_reference_p95 > 1
) {
throw new Error("Baseline advisory reference is invalid");
}
return { vocabulary, distribution };
}
function baseReport(status, baseline, inputPrs, extra = {}) {
return {
schema_version: SCHEMA_VERSION,
kind: "style-drift-report",
policy: "advisory-only",
status,
expected_current_week: latestCompleteWeek(),
baseline_end_week: baseline?.baseline_end_week || null,
input_rows: inputPrs?.length || 0,
...extra,
};
}
function buildReport(prs, baseline) {
const { vocabulary, distribution } = validateBaseline(baseline);
const currentWeek = latestCompleteWeek();
if (baseline.baseline_end_week >= currentWeek) {
return baseReport("insufficient_history", baseline, prs);
}
validateInputWindow(prs, [currentWeek], "Current input");
const eligible = eligibleDocuments(prs, baseline.config);
const nonBot = eligible.filter((pr) => declaredAttribution(pr) !== "bot");
const selected = capAuthors(nonBot, baseline.config);
const inputAttribution = summarizeAttribution(prs);
const selectedAttribution = summarizeAttribution(selected);
if (selected.length < baseline.config.minDocsPerWeek) {
return baseReport("insufficient_documents", baseline, prs, {
current_week: currentWeek,
eligible_rows: eligible.length,
selected_rows: selected.length,
input_attribution: inputAttribution,
selected_attribution: selectedAttribution,
});
}
const currentWeightsRaw = documentWeights(selected);
const currentWeights = project(currentWeightsRaw, vocabulary);
const currentDistribution = probabilities(currentWeights, vocabulary);
const score = jsDistance(currentDistribution, distribution, vocabulary);
const threshold = baseline.advisory_reference_p95;
const includeTokens = process.env.STYLE_INCLUDE_TOKENS === "1";
let topRising = [];
if (includeTokens) {
const currentDf = documentFrequency(selected);
const diagnosticVocabulary = new Set([
...baseline.vocabulary,
...[...currentDf]
.filter(([, count]) => count >= baseline.config.minDocumentFrequency)
.map(([token]) => token),
]);
topRising = topChanges(
currentWeightsRaw,
objectToMap(baseline.distribution, "baseline.distribution"),
diagnosticVocabulary,
);
}
return baseReport(
score > threshold ? "review_suggested" : "within_reference",
baseline,
prs,
{
current_week: currentWeek,
eligible_rows: eligible.length,
selected_rows: selected.length,
input_attribution: inputAttribution,
selected_attribution: selectedAttribution,
score,
advisory_reference_p95: threshold,
vocabulary_size: baseline.vocabulary.length,
out_of_vocabulary_share: currentDistribution.get(OOV_BUCKET) || 0,
diagnostics_redacted: !includeTokens,
top_rising: topRising,
interpretation: "A distribution alert is not authorship evidence or comprehension evidence.",
},
);
}
function markdownSummary(report) {
const lines = [
"## PR Style Drift CI(advisory)",
"",
"- 狀態:**" + report.status + "**",
...(report.error ? ["- 工具錯誤:" + report.error] : []),
"- 預期 current week:" + (report.expected_current_week || "n/a"),
"- 分數:" + (report.score == null ? "n/a" : report.score.toFixed(4)),
"- baseline 參考線:" +
(report.advisory_reference_p95 == null ? "n/a" : report.advisory_reference_p95.toFixed(4)),
"- input 標記:" + JSON.stringify(report.input_attribution || {}),
"- scoring 標記:" + JSON.stringify(report.selected_attribution || {}),
"- out-of-vocabulary share:" +
(report.out_of_vocabulary_share == null
? "n/a"
: report.out_of_vocabulary_share.toFixed(4)),
"",
"### 詞彙診斷",
"",
...(report.diagnostics_redacted
? ["- 預設隱藏 exact tokens;只在存取受控環境設 STYLE_INCLUDE_TOKENS=1。"]
: report.top_rising?.length
? report.top_rising.map((row) => "- " + row.token + ": +" + row.delta.toFixed(5))
: ["- 無可顯示詞彙"]),
"",
"### 人工抽查",
"",
"先定義最小重要差異與評分規則,再用盲化、隨機分派的 reviewer test。小樣本無法排除傷害時,結果記為 inconclusive。",
"",
"> 本報告只提示分布偏移,不阻擋合併,也不判定 Claude 或任何人的作者身分。",
];
return lines.join("\n");
}
function writeJsonStrict(path, value) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
}
function emitReport(report, outputPath) {
let receiptError = null;
try {
writeJsonStrict(outputPath, report);
} catch (error) {
receiptError = String(error?.message || error);
}
const summary =
markdownSummary(report) +
(receiptError ? "\n\n> Receipt write failed: " + receiptError : "");
try {
if (process.env.GITHUB_STEP_SUMMARY) {
mkdirSync(dirname(process.env.GITHUB_STEP_SUMMARY), { recursive: true });
writeFileSync(process.env.GITHUB_STEP_SUMMARY, summary + "\n", { flag: "a" });
}
} catch (error) {
process.stderr.write(
"Could not append GITHUB_STEP_SUMMARY: " + String(error?.message || error) + "\n",
);
}
process.stdout.write(summary + "\n");
if (receiptError) process.stdout.write(JSON.stringify(report) + "\n");
}
function readJson(path, label) {
let value;
try {
value = JSON.parse(readFileSync(path, "utf8"));
} catch {
throw new Error(label + " is not valid JSON");
}
if (!value || typeof value !== "object") throw new Error(label + " must be JSON");
return value;
}
function toolError(error, baseline, outputPath) {
const report = baseReport("tool_error", baseline, [], {
error: String(error?.message || error),
input_attribution: {},
selected_attribution: {},
diagnostics_redacted: true,
top_rising: [],
});
emitReport(report, outputPath);
}
function printWindow() {
const start = latestCompleteWeek();
process.stdout.write("STYLE_CURRENT_START=" + start + "\n");
process.stdout.write("STYLE_CURRENT_END=" + addDays(start, 6) + "\n");
}
function usage() {
process.stderr.write(
"Usage:\n" +
" node style-drift.mjs window\n" +
" STYLE_BASELINE_END=YYYY-MM-DD node style-drift.mjs freeze input.json baseline.json\n" +
" node style-drift.mjs report current.json baseline.json report.json\n",
);
}
const [mode, ...args] = process.argv.slice(2);
if (mode === "window") {
printWindow();
} else if (mode === "freeze") {
const [inputPath, baselinePath] = args;
if (!inputPath || !baselinePath) {
usage();
process.exitCode = 2;
} else {
try {
const prs = readJson(inputPath, "Baseline input");
if (!Array.isArray(prs)) throw new Error("Baseline input must be a JSON array");
const baseline = buildBaseline(prs, CONFIG);
writeJsonStrict(baselinePath, baseline);
process.stdout.write(
"Frozen baseline: " + baseline.source.selected_rows + " rows, " +
baseline.vocabulary.length + " tokens, SHA-256 " + baseline.source.sha256 + "\n",
);
} catch (error) {
process.stderr.write("Baseline freeze failed: " + String(error?.message || error) + "\n");
process.exitCode = 1;
}
}
} else if (mode === "report") {
const [currentPath, baselinePath, outputPath = "artifacts/style-drift.json"] = args;
if (!currentPath || !baselinePath) {
usage();
process.exitCode = 2;
} else {
let baseline = null;
try {
baseline = readJson(baselinePath, "Baseline");
if (
process.env.STYLE_COLLECTION_STATUS &&
process.env.STYLE_COLLECTION_STATUS !== "success"
) {
throw new Error("Collection step status was " + process.env.STYLE_COLLECTION_STATUS);
}
const prs = readJson(currentPath, "Current input");
if (!Array.isArray(prs)) throw new Error("Current input must be a JSON array");
emitReport(buildReport(prs, baseline), outputPath);
} catch (error) {
toolError(error, baseline, outputPath);
}
}
} else {
usage();
process.exitCode = 2;
}
先用明確日期抓八個 baseline 週。以下例子以 2026 年 8 月 10 日當最後一週的 Monday,因此搜尋到 8 月 16 日;達到 1,000 筆就停止,改用可驗證完整性的 API 枚舉,或拆成更細且不重疊的日期區間後依 PR number 去重。
(
set -euo pipefail
gh pr list --state merged \
--search "merged:2026-06-22..2026-08-16" \
--limit 1000 \
--json number,author,body,mergedAt,labels \
> .style-drift/baseline-prs.json
test "$(jq length .style-drift/baseline-prs.json)" -lt 1000
STYLE_BASELINE_END=2026-08-10 node scripts/style-drift.mjs freeze \
.style-drift/baseline-prs.json \
.style-drift/baseline.json
)
確認 baseline.json不含 raw body 或作者名稱後,把它與腳本一起納入版本控制;原始 *-prs.json必須加入 .gitignore並依內部資料保留政策刪除。Receipt 仍含 exact vocabulary,敏感 repo 應維持 private 並限制讀取。本文用 170 篇合成 PR fixture 跑過 13 個 regression 情境:凍結後的歷史文字變更不改分數、缺週/空詞表/重複 vocabulary/超出 0–1 的 reference 被拒絕、OOV 保留字碰撞不會產生自相矛盾的 receipt、stale current window/collection error 變成 tool_error、標記衝突變成 unknown、receipt 寫入失敗仍留 stdout。這只驗證教學程式的機械行為,沒有下載或重跑原專案完整每日 corpus,也不構成真實 repo 的效果結論。
步驟四:把週報接進 GitHub Actions,但永遠不擋 PR
建立 .github/workflows/style-drift.yml。每週一先由腳本算出上一個完整 UTC calendar week,再用精確 merged range 收集;Markdown 寫入 GITHUB_STEP_SUMMARY,非隱藏的 artifacts/style-drift.json才上傳。Exact tokens 預設不輸出;若要在受控 private repo 內除錯,才手動設 STYLE_INCLUDE_TOKENS=1。
name: PR style drift (advisory)
on:
schedule:
- cron: '17 2 * * 1'
workflow_dispatch:
permissions:
contents: read
pull-requests: read
jobs:
report:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
with:
node-version: '22.18.0'
- name: Resolve the latest complete UTC week
run: node scripts/style-drift.mjs window >> "$GITHUB_ENV"
- name: Collect the exact merged-date range
id: collect
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .style-drift artifacts
gh pr list --state merged \
--search "merged:${STYLE_CURRENT_START}..${STYLE_CURRENT_END}" \
--limit 1000 \
--json number,author,body,mergedAt,labels \
> .style-drift/current-prs.json
test "$(jq length .style-drift/current-prs.json)" -lt 1000
- name: Build advisory report
env:
STYLE_COLLECTION_STATUS: ${{ steps.collect.outcome }}
run: |
node scripts/style-drift.mjs report \
.style-drift/current-prs.json \
.style-drift/baseline.json \
artifacts/style-drift.json
- name: Keep aggregate receipt only
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: style-drift-${{ github.run_id }}-${{ github.run_attempt }}
path: artifacts/style-drift.json
if-no-files-found: error
retention-days: 30
- name: Surface operational failure after preserving receipt
if: always()
env:
COLLECTION_OUTCOME: ${{ steps.collect.outcome }}
run: |
failed=0
if [ "$COLLECTION_OUTCOME" != "success" ]; then
echo "::error::PR collection failed; inspect the receipt."
failed=1
fi
if [ ! -f artifacts/style-drift.json ]; then
echo "::error::Style drift receipt is missing."
failed=1
else
status=$(jq -er '.status // empty' artifacts/style-drift.json 2>/dev/null) || status=""
case "$status" in
within_reference|review_suggested|insufficient_documents|insufficient_history) ;;
*)
echo "::error::Style drift report ended in ${status:-invalid_status}."
failed=1
;;
esac
fi
exit "$failed"
若 repo 每週不到 12 篇,這個版本刻意回報 insufficient_documents,不會暗中把 current 擴成多週。要改成四週窗口,必須把 window 定義、baseline pseudo-window 與 regression fixture 一起改版,不能只調一個環境變數。忙碌 repo 若單週碰到 1,000 筆上限,則改用可驗證完整性的 API 枚舉,或切成更細且不重疊的日期區間;不要接受被截斷的結果。
步驟五:用盲化 reviewer comprehension 判定 harm、no detected harm 或 inconclusive
詞彙漂移和審查品質是兩個不同問題。當 CI 顯示 review_suggested,可先抽 6 至 10 篇 PR 做流程 pilot:預先寫答案 key、確認題目可評分、估計變異,但不能用這個小樣本宣稱兩版等效。正式比較要先定義團隊在意的最小差距 Δ 與樣本計畫;每個 reviewer 對同一篇 PR 只看 A 或 B 其中一版,再在整個池中隨機分派與平衡條件,避免先看到一版而記住答案。Reviewer 不看版本來源,只回答:
- 這個 PR 改了什麼?
- 主要風險、限制或回滾點是什麼?
- 審查後的下一個動作是什麼?
每題 0 至 2 分,另外記錄完成秒數與澄清問題數。不要只測句長或禁字命中率。若需要建立 Claude Code 的 style A/B,可沿用《Claudish 與 Output Style A/B Test 教學》的 session 固定與資訊保真方法;這篇只把 style prompt 當實驗變因,不重教 Output Style。
「沒有看到變差」不等於「證明沒變差」。可以沿用 equivalence testing 的基本思路:先定義正確率或完成時間可接受的最小重要差距 Δ;只有估計區間完整落在 -Δ 到 +Δ內,才記 no_detected_harm。區間太寬就記 inconclusive;明確跨過傷害界線才記 harm_detected。把每次結果存成不含作者名稱、預設不含 exact tokens 的 review-outcomes.csv:
week,alert_id,score,reference,design,n,delta,effect_low,effect_high,outcome,composition_change,note

如何解讀六種結果,而不是做禁字表?
- 偏移是 × harm_detected:追查 prompt、template、PR 類型與漏掉的風險資訊;先修內容規格。
- 偏移是 × no_detected_harm:記為 false-positive candidate;累積重複結果後再校正,不處罰用詞。
- 偏移是 × inconclusive:維持 shadow mode,增加預先規劃的樣本,不偷改 Δ。
- 偏移否 × harm_detected:詞彙指標漏報;補看結構、事實保真與任務品質。
- 偏移否 × no_detected_harm:留在 baseline 內;持續監控 composition 與 template 版本。
- 偏移否 × inconclusive:理解實驗本身資訊不足;先改善設計,不拿詞彙分數代替答案。
這裡最容易犯的錯,是為了讓 score 下降而要求 Claude 避免若干單字。Lexical constraint 可能把表達推向變形、同義詞或繞句,但不保證資訊更清楚。真正可驗收的 prompt 應該寫正向要求,例如「明確寫出改動機制、主要風險、測試證據與下一步」,再看理解題是否改善。
Prompt 變更也可能外溢到任務能力。Anthropic 在 2026 年 4 月事故回顧中報告:以更廣的評估集做 ablation 時,其中一項 evaluation 在 Opus 4.6 與 4.7 都下降 3%,因此相關 verbosity prompt 隨 4 月 20 日版本撤回。這不是所有評估平均下降 3%。所以 A/B 除了可讀性,仍要保留測試是否正確、風險是否完整與 code quality 指標。想理解規則、工具與驗收層如何包住模型,可先讀《AI Agent Harness 是什麼》與《AI Agent Harness 實作教學》。
Style Drift CI 和 Anthropic 文字浮水印有什麼不同?
Anthropic 在 2026 年 8 月 14 日的文字浮水印公告說明,未來 Claude 模型會在不加入隱藏字元或 token 的情況下產生統計訊號,舊模型則預計逐步加入。官方同時限定:偵測器最多指出 Claude 可能參與,不能判定作品所有權、特定使用者、組織或對話;短樣本、程式碼與輕度編輯的訊號也較弱。
這和本文完全不同。浮水印是模型生成時加入的 provenance signal;Style Drift CI 是你對 repo corpus 做的事後分布比較。後者沒有浮水印金鑰、標記真值與已校正錯誤率。SynthID-Text 的 Nature 論文也區分生成時浮水印與 post-hoc classifier,並把生成式浮水印定位成 detection 的互補方法,而非完整解法。不要把詞彙群誤叫成 watermark。
Claude 語言漂移常見問題 FAQ
1. 看到 load-bearing 就能判定 Claude 寫的嗎?
不能。它在原資料中是群體層級的相關詞,既不專屬 Claude,也可能由人類、其他模型或團隊慣用語產生。單篇作者需要獨立 provenance,不能由一個 token 決定。
2. 為什麼不用 AI detector 直接分類?
因為分布漂移回答的是 P_t(X) 是否改變,作者分類需要有標記資料、base rate 與校正過的 P(Claude|X)。兩個問題不是同一個估計量。
3. 八週 baseline 足夠嗎?
只夠啟動 shadow mode,P95 也會很粗。累積到 20 個以上完整週後,另開版本重新凍結 baseline、重跑 regression,再比較門檻;不要覆寫舊 receipt。PR 類型、template 或季節性差異明顯時,應分層建立各自 baseline。
4. 中文 PR 可以使用這份腳本嗎?
可以,腳本使用 Intl.Segmenter('zh-Hant'),也保留英文工程詞。不過專有名詞、日文或無空格語言的切分仍要用你的 fixture 抽查,不要假設 tokenizer 永遠正確。
5. unknown 要不要算成人工?
不要。Unknown 的價值正是保留未知;本文把它納入團隊整體 prose 分布,但在 attribution coverage 中單獨報告。
6. CI 何時可以改成 blocking?
詞彙分數本身不應 blocking。若長期實驗證明某個客觀缺項穩定造成理解失敗,可以另設「缺少測試證據/回滾點」之類的內容 gate;仍不要以 Claude-like 單字阻擋合併。
7. 可以拿報告比較不同工程師嗎?
不建議。小樣本作者排名會放大隱私、誤判與團隊壓力。保留週級聚合、設定每位作者上限,人工表也不要存姓名。
8. 既有 Output Style 或寫作規則還有用嗎?
有,但它們是介入變因,不是評分真值。可先用《AI 寫作去 AI 味系統》整理正向規則,再用本文的分布與理解雙軌驗收;若 context 越塞越長,也要搭配《Claude 怎麼省 token》檢查成本與規則層級。
五個重點帶走
- Claude 語言漂移是 corpus 分布問題,不是單字作者鑑定。
- 小型 repo 先用等 PR 權重、作者週上限與自己的凍結 baseline,不照搬公開專案的固定十群。
- Human、AI-assisted、bot、unknown 都要保留;unknown 絕不自動等於 human。
- CI 只寫 advisory summary;已處理的報告狀態維持 exit code 0,基礎設施故障則明確失敗,真正決策來自盲化 reviewer comprehension。
- 小樣本看不出傷害時先記 inconclusive;達到預設等效界線的重複結果,才是 false-positive candidate。
想把這套量測接到更多 Agent 開發流程,可瀏覽 AlphaLab AI 專區;需要循序練習完整工作流,則可從 AlphaLab 線上課程開始。
接著閱讀
左右滑動查看更多推薦
現在就做:先跑四週 shadow report,不改任何人的字
今天先建立四個 attribution labels、用連續八週資料產生並版本化 baseline receipt,再讓 workflow 跑四週 shadow report。第一次警示只用 6 至 10 篇 PR 測試題目、答案 key 與隨機分派流程,結果一律先當 pilot,不宣稱等效。四週後再預先訂 Δ、樣本數與停止規則,問警示是否真的預測理解下降。若答案還不清楚,就記 inconclusive 並讓它繼續 advisory;一個誠實的未知,比一張看似精準的 Claude 作者排行榜更有用。




