|
| 1 | +/** |
| 2 | + * JevLoop · 两种循环形状的对比台 |
| 3 | + * |
| 4 | + * node --experimental-strip-types bench/compare.ts |
| 5 | + * node --experimental-strip-types bench/compare.ts --repeat 3 |
| 6 | + * |
| 7 | + * ══════════════════════════════════════════════════════════════ |
| 8 | + * **它回答的是哪一句话。** |
| 9 | + * |
| 10 | + * 这个项目到处写着「14:1」。那个数字是**判定次数 : 生成次数** —— |
| 11 | + * 它说的是「这条 loop 里的岔路口有几个不花生成的钱」,**不是** |
| 12 | + * 「省了 14 次大模型调用」。同样一个任务,一个像样的 ReAct 循环大概只要 |
| 13 | + * 3–4 次调用。两者不是一个量纲的东西,混着说就变成了口号。 |
| 14 | + * |
| 15 | + * 所以这个台子跑同一个任务的两条路,量三个数: |
| 16 | + * |
| 17 | + * 大模型调用次数 最直接的那个 —— 「便宜」到底便宜在哪 |
| 18 | + * 墙钟 判定的延迟优势在这儿(§8.11) |
| 19 | + * 输入/输出 token 钱是按这个算的 |
| 20 | + * |
| 21 | + * ── 读这些数字时要记住的三件事 ────────────────────────────────── |
| 22 | + * |
| 23 | + * ① **JevLoop 那一边多干了两件事**:风险分级(判定,不花钱)和交付闸门 |
| 24 | + * (过不了要**再生成一次** —— 那是实打实的成本)。所以它在生成次数上 |
| 25 | + * 有时是 2 而不是 1。这不是不公,是这条 loop 真的多做了事。 |
| 26 | + * |
| 27 | + * ② **两边都没做「谁的答案更好」的判断**。验收只看任务要求的工具调用有没有 |
| 28 | + * 发生、回答里该有的东西在不在、文件有没有真的写出来(`bench/oracle.ts`)。 |
| 29 | + * 质量不在这把尺子的量程里。 |
| 30 | + * |
| 31 | + * ③ **一次运行一个样本。** 想看得住就 `--repeat`,看中位数。 |
| 32 | + * |
| 33 | + * @module JevLoop/compare |
| 34 | + */ |
| 35 | + |
| 36 | +import { mkdtemp, writeFile, rm } from 'node:fs/promises' |
| 37 | +import { join } from 'node:path' |
| 38 | +import { tmpdir } from 'node:os' |
| 39 | + |
| 40 | +import { Decider, Meter, runAgent, loadEnv, resolveProvider, resolveGenerator } from '../src/index.ts' |
| 41 | +import { RuleJudge } from '../examples/rule-judge.ts' |
| 42 | +import { TASKS, type BenchTask } from './tasks.ts' |
| 43 | +import { answerOk, missing, checkArtifacts, matchesCall } from './oracle.ts' |
| 44 | +import { runReact, REACT_SYSTEM } from './react.ts' |
| 45 | + |
| 46 | +const C = { |
| 47 | + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, |
| 48 | + bold: (s: string) => `\x1b[1m${s}\x1b[0m`, |
| 49 | + green: (s: string) => `\x1b[32m${s}\x1b[0m`, |
| 50 | + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, |
| 51 | + red: (s: string) => `\x1b[31m${s}\x1b[0m`, |
| 52 | +} |
| 53 | + |
| 54 | +const argv = process.argv.slice(2) |
| 55 | +const only = argv.includes('--only') ? argv[argv.indexOf('--only') + 1] : undefined |
| 56 | +const repeat = argv.includes('--repeat') ? Math.max(1, Number(argv[argv.indexOf('--repeat') + 1]) || 1) : 1 |
| 57 | +const useRule = argv.includes('--rule') |
| 58 | +const MAX_STEPS = 8 |
| 59 | + |
| 60 | +/** 一条路跑一条任务的结果 */ |
| 61 | +interface Sample { |
| 62 | + shape: 'JevLoop' | 'ReAct' |
| 63 | + task: string |
| 64 | + modelCalls: number |
| 65 | + inputTokens: number |
| 66 | + outputTokens: number |
| 67 | + latencyMs: number |
| 68 | + /** |
| 69 | + * 墙钟花在哪。**没有这个分解,`40.7s` 那样的数字是不可解释的** —— |
| 70 | + * 而不可解释的数字对读的人只是一句「好慢」,指不出该改哪里。 |
| 71 | + */ |
| 72 | + decisionMs: number |
| 73 | + /** meter 记的生成耗时 —— **成功那一次**的,不含重试 */ |
| 74 | + modelMs: number |
| 75 | + /** |
| 76 | + * 生成那一步的**墙钟**(由事件时间戳量出来)。 |
| 77 | + * |
| 78 | + * ★ 它和 `modelMs` 不是一回事,而差出来的那部分是真的:生成失败重试时, |
| 79 | + * 失败那次的耗时和退避等待都**不计入** `GenerateResult.latencyMs`。 |
| 80 | + * 实测一条 `list` 任务:`generate` 那一步墙钟 **21.8s**,meter 只记了 |
| 81 | + * **11.0s** —— 另外 10.8s 是重试。拿 meter 的数当墙钟,会得出一个 |
| 82 | + * 「JevLoop 比 ReAct 慢 10 倍」的结论,而其中一半是后端抖动。 |
| 83 | + */ |
| 84 | + modelWallMs: number |
| 85 | + /** 验收:工具调用齐了、回答对了、产物在盘上 */ |
| 86 | + passed: boolean |
| 87 | + why: string[] |
| 88 | +} |
| 89 | + |
| 90 | +// ── 后端:两条路**必须**用同一个生成器,否则数字不可比 ────────── |
| 91 | + |
| 92 | +loadEnv() |
| 93 | +let warned = false |
| 94 | +const provider = useRule |
| 95 | + ? new RuleJudge() |
| 96 | + : resolveProvider({ |
| 97 | + lastResort: new RuleJudge(), |
| 98 | + onFallback: (err, from, to) => { |
| 99 | + if (warned) return |
| 100 | + warned = true |
| 101 | + console.error(C.yellow(` ▲ ${from} 不可用(${(err as Error).message.slice(0, 60)}),改用 ${to}`)) |
| 102 | + }, |
| 103 | + }) |
| 104 | +const generator = resolveGenerator() |
| 105 | + |
| 106 | +/** 铺夹具,跑,然后把目录删掉。两条路**用同一个函数**铺,免得夹具分叉 */ |
| 107 | +async function inFixture<T>(task: BenchTask, fn: (cwd: string) => Promise<T>): Promise<T> { |
| 108 | + const cwd = await mkdtemp(join(tmpdir(), `jevcompare-${task.id}-`)) |
| 109 | + try { |
| 110 | + for (const [name, content] of Object.entries(task.files)) { |
| 111 | + await writeFile(join(cwd, name), content, 'utf8') |
| 112 | + } |
| 113 | + return await fn(cwd) |
| 114 | + } finally { |
| 115 | + await rm(cwd, { recursive: true, force: true }) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * 工具调用齐了吗 —— **两条路同一套判据**,而且和判据机用的是同一个函数 |
| 121 | + * (`matchesCall`)。自己再写一份的话,`write_file` 那种「输入是路径 + 内容」 |
| 122 | + * 的形状会被判错,而错的方向是**把成功的判成失败**。 |
| 123 | + */ |
| 124 | +function callsOk(calls: { tool: string; input?: string }[], task: BenchTask): string[] { |
| 125 | + const why: string[] = [] |
| 126 | + for (const want of task.required) { |
| 127 | + if (!calls.some((c) => matchesCall(want, c))) why.push(`没调用 ${want.tool}${want.input ? `(${want.input})` : ''}`) |
| 128 | + } |
| 129 | + return why |
| 130 | +} |
| 131 | + |
| 132 | +async function runJev(task: BenchTask): Promise<Sample> { |
| 133 | + return inFixture(task, async (cwd) => { |
| 134 | + const meter = new Meter() |
| 135 | + const decider = new Decider({ provider, meter }) |
| 136 | + const calls: { tool: string; input?: string }[] = [] |
| 137 | + // ★ **整轮墙钟**,和 ReAct 那边同一个口径。 |
| 138 | + // 第一版这里写的是 `s.decisionMs + s.modelMs` —— 那只算判定和生成, |
| 139 | + // 不算工具执行,而 ReAct 那边量的是整轮。**两把尺子量出来的数不可比**, |
| 140 | + // 而它看起来完全正常(一边 13.6s、一边 3.7s),差点就成了结论。 |
| 141 | + const t0 = performance.now() |
| 142 | + /* |
| 143 | + 用**事件时间戳**量生成那一步的墙钟:`generate` 事件和它上一条之间的间隔 |
| 144 | + 就是那一步实际花掉的时间。这样重试和退避都被算进去 —— 而它们是用户 |
| 145 | + 真的等掉的时间。 |
| 146 | + */ |
| 147 | + let lastAt = t0 |
| 148 | + let modelWallMs = 0 |
| 149 | + let sawGenerate = false |
| 150 | + |
| 151 | + const result = await runAgent({ |
| 152 | + task: task.task, |
| 153 | + cwd, |
| 154 | + decider, |
| 155 | + generator, |
| 156 | + maxSteps: MAX_STEPS, |
| 157 | + onAskHuman: async () => true, |
| 158 | + ...(task.writeInput !== undefined ? { provideWriteInput: () => task.writeInput } : {}), |
| 159 | + onEvent: (e) => { |
| 160 | + const now = performance.now() |
| 161 | + if (e.type === 'generate') { |
| 162 | + modelWallMs += now - lastAt |
| 163 | + sawGenerate = true |
| 164 | + } |
| 165 | + lastAt = now |
| 166 | + if (e.type === 'tool:call') calls.push({ tool: e.tool, input: e.input }) |
| 167 | + }, |
| 168 | + }) |
| 169 | + |
| 170 | + const why = [ |
| 171 | + ...callsOk(calls, task), |
| 172 | + ...missing(result.answer, task), |
| 173 | + ...(await checkArtifacts(task, cwd)), |
| 174 | + ] |
| 175 | + const s = meter.stats |
| 176 | + return { |
| 177 | + shape: 'JevLoop' as const, |
| 178 | + task: task.id, |
| 179 | + // **只数生成**:判定不花这个钱,那正是这条 loop 的主张 |
| 180 | + modelCalls: s.modelCalls, |
| 181 | + // `MeterStats` 上的是**生成器**报的真值。判定那一边的记录里没有 token —— |
| 182 | + // 因为判定不花生成的钱,那正是这条 loop 的主张(判定模型那边确实也发 |
| 183 | + // token,但走的是另一个后端、另一个价目表,不计在这一列里) |
| 184 | + inputTokens: s.inputTokens, |
| 185 | + outputTokens: s.outputTokens, |
| 186 | + latencyMs: performance.now() - t0, |
| 187 | + decisionMs: s.decisionMs, |
| 188 | + modelMs: s.modelMs, |
| 189 | + modelWallMs: sawGenerate ? modelWallMs : s.modelMs, |
| 190 | + passed: why.length === 0 && answerOk(result.answer, task), |
| 191 | + why, |
| 192 | + } |
| 193 | + }) |
| 194 | +} |
| 195 | + |
| 196 | +async function runReAct(task: BenchTask): Promise<Sample> { |
| 197 | + return inFixture(task, async (cwd) => { |
| 198 | + const r = await runReact({ task: task.task, cwd, generator, maxSteps: MAX_STEPS }) |
| 199 | + if (r.failed) { |
| 200 | + return { |
| 201 | + shape: 'ReAct' as const, |
| 202 | + task: task.id, |
| 203 | + modelCalls: r.modelCalls, |
| 204 | + inputTokens: r.inputTokens, |
| 205 | + outputTokens: r.outputTokens, |
| 206 | + latencyMs: r.latencyMs, |
| 207 | + decisionMs: 0, |
| 208 | + modelMs: r.modelMs, |
| 209 | + modelWallMs: r.modelMs, |
| 210 | + passed: false, |
| 211 | + why: [`跑不起来:${r.failed.slice(0, 60)}`], |
| 212 | + } |
| 213 | + } |
| 214 | + const why = [...callsOk(r.calls, task), ...missing(r.answer, task), ...(await checkArtifacts(task, cwd))] |
| 215 | + return { |
| 216 | + shape: 'ReAct' as const, |
| 217 | + task: task.id, |
| 218 | + modelCalls: r.modelCalls, |
| 219 | + inputTokens: r.inputTokens, |
| 220 | + outputTokens: r.outputTokens, |
| 221 | + latencyMs: r.latencyMs, |
| 222 | + decisionMs: 0, |
| 223 | + modelMs: r.modelMs, |
| 224 | + modelWallMs: r.modelMs, |
| 225 | + passed: why.length === 0, |
| 226 | + why, |
| 227 | + } |
| 228 | + }) |
| 229 | +} |
| 230 | + |
| 231 | +// ── 汇总 ───────────────────────────────────────────────────── |
| 232 | + |
| 233 | +const median = (xs: number[]): number => { |
| 234 | + if (!xs.length) return NaN |
| 235 | + const s = [...xs].sort((a, b) => a - b) |
| 236 | + const m = s.length >> 1 |
| 237 | + return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2 |
| 238 | +} |
| 239 | + |
| 240 | +const pad = (s: string | number, n: number) => { |
| 241 | + const str = String(s) |
| 242 | + return str + ' '.repeat(Math.max(0, n - [...str].length)) |
| 243 | +} |
| 244 | +const secs = (ms: number) => `${(ms / 1000).toFixed(1)}s` |
| 245 | + |
| 246 | +async function main(): Promise<void> { |
| 247 | + const tasks = only ? TASKS.filter((t) => t.id.includes(only)) : TASKS |
| 248 | + console.log(C.bold('\nJevLoop · 两种循环形状')) |
| 249 | + console.log(C.dim(` 判定后端 : ${provider.name}`)) |
| 250 | + console.log(C.dim(` 生成后端 : ${generator.name}`)) |
| 251 | + console.log(C.dim(` 任务 : ${tasks.length} 条 × ${repeat} 遍 · maxSteps ${MAX_STEPS}(两条路相同)`)) |
| 252 | + console.log(C.dim(' 验收 : 要求的工具调用 + 回答内容 + 产物在盘上(两边同一套判据)')) |
| 253 | + |
| 254 | + const all: Sample[] = [] |
| 255 | + for (const task of tasks) { |
| 256 | + for (let i = 0; i < repeat; i++) { |
| 257 | + process.stdout.write(C.dim(`\r 跑 ${task.id} (${i + 1}/${repeat})…`.padEnd(60))) |
| 258 | + all.push(await runJev(task)) |
| 259 | + process.stdout.write(C.dim(`\r 跑 ${task.id} (${i + 1}/${repeat})… ReAct`.padEnd(60))) |
| 260 | + all.push(await runReAct(task)) |
| 261 | + } |
| 262 | + } |
| 263 | + process.stdout.write('\r'.padEnd(62) + '\r') |
| 264 | + |
| 265 | + // ── 逐任务 ── |
| 266 | + console.log('') |
| 267 | + console.log(C.bold(' ── 逐任务 ──────────────────────────────────────────────')) |
| 268 | + console.log( |
| 269 | + C.dim( |
| 270 | + ` ${pad('任务', 22)}${pad('形状', 9)}${pad('大模型调用', 11)}${pad('墙钟', 8)}${pad('输出token', 10)}验收`, |
| 271 | + ), |
| 272 | + ) |
| 273 | + for (const task of tasks) { |
| 274 | + for (const shape of ['JevLoop', 'ReAct'] as const) { |
| 275 | + const xs = all.filter((s) => s.task === task.id && s.shape === shape) |
| 276 | + if (!xs.length) continue |
| 277 | + console.log( |
| 278 | + ` ${pad(shape === 'JevLoop' ? task.id : '', 22)}${pad(shape, 9)}` + |
| 279 | + pad(median(xs.map((s) => s.modelCalls)), 11) + |
| 280 | + pad(secs(median(xs.map((s) => s.latencyMs))), 8) + |
| 281 | + pad(Math.round(median(xs.map((s) => s.outputTokens))), 10) + |
| 282 | + (xs.every((s) => s.passed) ? C.green('✓') : C.red(`✗ ${xs.find((s) => !s.passed)?.why.join(';').slice(0, 40)}`)), |
| 283 | + ) |
| 284 | + } |
| 285 | + // 分解:读到「40.7s」时**下一步能查什么**,全在这一行里 |
| 286 | + const jx = all.filter((s) => s.task === task.id && s.shape === 'JevLoop') |
| 287 | + const rx = all.filter((s) => s.task === task.id && s.shape === 'ReAct') |
| 288 | + const parts = (xs: Sample[], label: string) => { |
| 289 | + if (!xs.length) return '' |
| 290 | + const wall = median(xs.map((s) => s.latencyMs)) |
| 291 | + const dec = median(xs.map((s) => s.decisionMs)) |
| 292 | + const gen = median(xs.map((s) => s.modelWallMs)) |
| 293 | + // 余项(工具 + 框架本身)。**它就是减出来的**,因为前两项是量出来的、 |
| 294 | + // 这一项是剩下的 —— 三者必须加起来等于整轮,否则账本身就不成立 |
| 295 | + const other = Math.max(0, wall - dec - gen) |
| 296 | + // 生成的重试要标出来:它让墙钟变长,而那不是循环形状的差别 |
| 297 | + const retried = xs.some((s) => s.modelWallMs - s.modelMs > 1000) |
| 298 | + return `${label} 判定 ${secs(dec)} · 生成 ${secs(gen)}${retried ? '(含重试)' : ''} · 其他 ${secs(other)}` |
| 299 | + } |
| 300 | + console.log( |
| 301 | + C.dim(` ${' '.repeat(22)}${parts(jx, 'JevLoop')}`) + |
| 302 | + (rx.length ? C.dim(` │ ${parts(rx, 'ReAct')}`) : ''), |
| 303 | + ) |
| 304 | + console.log('') |
| 305 | + } |
| 306 | + |
| 307 | + // ── 汇总 ── |
| 308 | + console.log(C.bold(' ── 汇总 ────────────────────────────────────────────────')) |
| 309 | + console.log( |
| 310 | + C.dim(` ${pad('形状', 10)}${pad('大模型调用/任务', 16)}${pad('墙钟/任务', 12)}${pad('输出token/任务', 16)}验收`), |
| 311 | + ) |
| 312 | + for (const shape of ['JevLoop', 'ReAct'] as const) { |
| 313 | + const xs = all.filter((s) => s.shape === shape) |
| 314 | + if (!xs.length) continue |
| 315 | + const okCount = xs.filter((s) => s.passed).length |
| 316 | + console.log( |
| 317 | + ` ${pad(shape, 10)}` + |
| 318 | + pad(median(xs.map((s) => s.modelCalls)).toFixed(1), 16) + |
| 319 | + pad(secs(median(xs.map((s) => s.latencyMs))), 12) + |
| 320 | + pad(Math.round(median(xs.map((s) => s.outputTokens))), 16) + |
| 321 | + `${okCount}/${xs.length}`, |
| 322 | + ) |
| 323 | + } |
| 324 | + |
| 325 | + const j = all.filter((s) => s.shape === 'JevLoop') |
| 326 | + const r = all.filter((s) => s.shape === 'ReAct') |
| 327 | + const jc = median(j.map((s) => s.modelCalls)) |
| 328 | + const rc = median(r.map((s) => s.modelCalls)) |
| 329 | + console.log('') |
| 330 | + console.log( |
| 331 | + C.bold(` 同一个任务:大模型调用 JevLoop ${jc} 次 vs ReAct ${rc} 次`) + |
| 332 | + C.dim(` (比值 ${(rc / jc).toFixed(1)}×)`), |
| 333 | + ) |
| 334 | + console.log( |
| 335 | + C.dim( |
| 336 | + ' ★ 这个比值说的是「大模型调用少了几倍」,**不是** README 里那个「14:1」——\n' + |
| 337 | + ' 那个是**判定次数 : 生成次数**,量的是「有多少岔路口不花生成的钱」。', |
| 338 | + ), |
| 339 | + ) |
| 340 | + console.log('') |
| 341 | + |
| 342 | + // ── 对手拿到的指令 ── |
| 343 | + // |
| 344 | + // 「对比」如果说不清对手拿到的指令,读的人就只能信我。所以原样印出来。 |
| 345 | + console.log(C.dim(' ── ReAct 那一边拿到的 system prompt(原样)────────────────')) |
| 346 | + for (const line of REACT_SYSTEM.split('\n')) console.log(C.dim(` │ ${line}`)) |
| 347 | + console.log('') |
| 348 | +} |
| 349 | + |
| 350 | +await main() |
0 commit comments