抓包对比工具补一批一次性脚本:拆预设、对请求、跑 A/B 与拒答抽样。
这些脚本都是我对着酒馆真实请求手工核对用的,留在仓库里方便下次重跑;跑出来的本地数据目录含接口 key,已经写进忽略规则。
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user