抓包对比工具补一批一次性脚本:拆预设、对请求、跑 A/B 与拒答抽样。

这些脚本都是我对着酒馆真实请求手工核对用的,留在仓库里方便下次重跑;跑出来的本地数据目录含接口 key,已经写进忽略规则。
This commit is contained in:
2026-10-26 01:09:56 +08:00
parent e2413b0677
commit 1a5ef3b2bc
24 changed files with 1568 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
// A/B:同一场景、同一模型、同参数,把多个预设各跑 N 轮,输出对比报告(走本仓库直聊的真实调用)。
// 用法: node ab.mjs --preset <a.json> --preset <b.json> [--runs 2] [--profile deepseek-flash] [--port 23388]
import { spawn } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { waitFor } from "./cdp.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "../..");
const OUT = path.join(HERE, "captures");
function readArgs(argv) {
const out = { presets: [], runs: 2, profile: "deepseek-flash", port: 23388, dataRoot: path.join(HERE, ".ab-data"), timeoutMs: 240000 };
for (let i = 0; i < argv.length; i++) {
const k = argv[i], v = argv[i + 1];
if (k === "--preset") { out.presets.push(path.resolve(String(v))); i++; }
else if (k === "--runs") { out.runs = Number(v); i++; }
else if (k === "--profile") { out.profile = String(v); i++; }
else if (k === "--port") { out.port = Number(v); i++; }
else if (k === "--timeout") { out.timeoutMs = Number(v) * 1000; i++; }
}
if (out.presets.length < 2) throw new Error("至少要两个 --preset");
return out;
}
// 同一场景,两边共用
const SCENE = {
charName: "阿箬",
charDescription: "三十上下的客栈老板娘,眉眼有倦色,说话慢,手上总在做事。",
charPersonality: "沉静、寡言、看人极准,不轻易信人。",
scenario: "雨夜,城郊旧客栈。他是第一次独自押镖的年轻镖师,也是今夜唯一的客人。",
exampleDialogue: "",
text: "把刀搁在桌上,说:「上一壶热的。」",
};
async function postJson(url, body, timeoutMs) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: ctrl.signal });
const text = await res.text();
let data = null;
try { data = JSON.parse(text); } catch (e) { data = { raw: text.slice(0, 1200) }; }
if (!res.ok) throw new Error("HTTP " + res.status + " " + JSON.stringify(data).slice(0, 600));
return data;
} finally {
clearTimeout(timer);
}
}
function pickProfile(name) {
const file = path.join(os.homedir(), ".writing-agent", "profiles.json");
const data = JSON.parse(readFileSync(file, "utf8"));
const hit = (data.profiles || []).find((p) => p.name === name) || (data.profiles || []).find((p) => p.apiKey);
if (!hit) throw new Error("找不到可用 profile: " + name);
return hit;
}
async function main() {
const args = readArgs(process.argv.slice(2));
mkdirSync(OUT, { recursive: true });
const profile = pickProfile(args.profile);
const dataRoot = args.dataRoot;
rmSync(dataRoot, { recursive: true, force: true });
mkdirSync(dataRoot, { recursive: true });
const now = new Date().toISOString();
writeFileSync(path.join(dataRoot, "profiles.json"), JSON.stringify({
version: 1,
profiles: [{ id: "ab", name: "ab", baseUrl: profile.baseUrl, apiKey: profile.apiKey, model: profile.model, createdAt: now, updatedAt: now }],
groups: [],
}, null, 2), "utf8");
writeFileSync(path.join(dataRoot, "settings.json"), JSON.stringify({
version: 1, activeProfileId: "ab", activePresetId: null, messageSquash: "follow-preset", presetToolPolicy: "adapt",
}, null, 2), "utf8");
const env = { ...process.env, PORT: String(args.port), WRITING_AGENT_DATA_DIR: dataRoot };
const server = spawn(process.execPath, [path.join(REPO, "node_modules/tsx/dist/cli.mjs"), path.join(REPO, "src/server/web-server.ts")], { cwd: REPO, env, stdio: ["ignore", "pipe", "pipe"] });
const logs = [];
server.stdout.on("data", (d) => logs.push(String(d)));
server.stderr.on("data", (d) => logs.push(String(d)));
const base = "http://127.0.0.1:" + args.port;
const rows = [];
try {
await waitFor(async () => (await fetch(base + "/api/health").catch(() => null)) !== null, { timeoutMs: 60000, label: "服务起来" });
console.log("[ab] 模型: " + profile.model + " (" + profile.baseUrl + ") 跑 " + args.runs + " 轮/预设");
for (const file of args.presets) {
const name = path.basename(file, ".json");
const raw = JSON.parse(readFileSync(file, "utf8"));
const imported = await postJson(base + "/api/presets/import", { raw, name, activate: true }, 60000);
const presetId = imported.preset.id;
const promptChars = (raw.prompts || []).reduce((n, p) => n + String(p.content || "").length, 0);
console.log("[ab] " + name + ": 已导入 id=" + presetId + ",预设正文 " + promptChars + " 字");
for (let i = 1; i <= args.runs; i++) {
const t0 = Date.now();
let reply = "", err = "", usage = null;
try {
const result = await postJson(base + "/api/direct-chat/ephemeral/complete", {
text: SCENE.text, presetId, complete: true, turns: [],
windows: [{ id: "w1", profileId: "ab" }],
systemPrompt: "", loreBefore: "", seedHistory: "", loreAfter: "", postTurn: "",
charName: SCENE.charName, exampleDialogue: SCENE.exampleDialogue,
charDescription: SCENE.charDescription, charPersonality: SCENE.charPersonality,
scenario: SCENE.scenario,
}, args.timeoutMs);
const first = result.multi && result.multi.results && result.multi.results[0];
if (!first) throw new Error("没有 results: " + JSON.stringify(result).slice(0, 400));
if (first.error) { err = String(first.error); } else {
reply = String(first.reply || "");
usage = first.usage || result.usage || null;
}
} catch (e) {
err = e.message;
}
const ms = Date.now() - t0;
rows.push({ preset: name, run: i, ms, chars: reply.length, reply, err, usage, promptChars });
console.log("[ab] " + name + " 第" + i + "轮: " + ms + "ms " + reply.length + " 字" + (err ? " 失败: " + err.slice(0, 120) : ""));
}
}
} finally {
try { server.kill(); } catch (e) {}
}
// 从 stats 里补 token
let stats = [];
try {
const day = new Date().toISOString().slice(0, 10);
const file = path.join(dataRoot, "stats", day + ".jsonl");
stats = readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
} catch (e) {}
const lines = ["# A/B 报告 " + new Date().toLocaleString("zh-CN"), ""];
lines.push("模型: `" + profile.model + "` | 预设正文合计字数见下表 | 每预设 " + args.runs + " 轮");
lines.push("");
lines.push("场景: " + SCENE.scenario);
lines.push("角色: " + SCENE.charName + " — " + SCENE.charDescription);
lines.push("用户输入: " + SCENE.text);
lines.push("");
lines.push("| 预设 | 预设正文字数 | 轮次 | 耗时(ms) | 输出字数 | prompt tokens | completion tokens | 结果 |");
lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |");
rows.forEach((r, idx) => {
const s = stats[idx] || {};
lines.push("| " + r.preset + " | " + r.promptChars + " | " + r.run + " | " + r.ms + " | " + r.chars + " | " + (s.promptTokens ?? "—") + " | " + (s.completionTokens ?? "—") + " | " + (r.err ? "❌ " + r.err.slice(0, 60) : "✅") + " |");
});
for (const name of new Set(rows.map((r) => r.preset))) {
lines.push("");
lines.push("## " + name);
for (const r of rows.filter((x) => x.preset === name)) {
lines.push("");
lines.push("### 第 " + r.run + " 轮(" + r.ms + "ms," + r.chars + " 字)");
lines.push("");
lines.push(r.err ? "失败: " + r.err : "```\n" + r.reply + "\n```");
}
}
const report = path.join(OUT, "ab-" + new Date().toISOString().replace(/[:.]/g, "-") + ".md");
writeFileSync(report, lines.join("\n") + "\n", "utf8");
console.log("\n" + lines.slice(5, 12 + rows.length).join("\n"));
console.log("\n[ab] 报告: " + report);
if (logs.some((l) => l.includes("失败"))) console.log("[ab] 服务端日志尾部:\n" + logs.join("").slice(-1500));
}
await main();