Skip to content

Commit afe05ad

Browse files
committed
Measure the loop shape against ReAct instead of asserting it
"decisions : model = 13 : 1" is decisions per generation. It is not "one LLM call where a normal agent needs thirteen" - a ReAct loop solving the same task makes three or four. Two different quantities, and putting them side by side is the kind of claim this project is supposed to be above. So: `npm run compare` runs seven tasks twice, once through this loop and once through a ReAct loop that asks the LLM at every branch point. Same model, same tools (the same callTool), same fixture, same maxSteps, same acceptance checks. JevLoop 1 LLM call/task 5.5s 96 output tokens/task 6/7 accepted ReAct 4 LLM calls/task 4.1s 495 output tokens/task 7/7 accepted Four times fewer calls and five times fewer output tokens, which is the claim landing on its own terms. And two results that do not flatter it: the wall clock is slightly worse over the hosted API, because thirteen network round-trips cost more than three LLM calls; and the `direct` task needed a second generation because the delivery gate rejected the first, then still missed what the task asked for. Both are in the README table rather than averaged away. The ReAct side had to be a fair opponent, so it gets the JSON protocol in its system prompt, it can recover from a malformed reply (paying a call for it), and `compare.ts` prints that prompt verbatim - a comparison that cannot show what the other side was told is not evidence. Two things had to be fixed to get numbers worth reading, and both were found by the harness disagreeing with itself: - Wall clock was measured one way on each side: decisions plus generation for JevLoop, whole run for ReAct. 13.6s against 3.7s looked like a finding and was two rulers. Now both measure the run. - The generation step's wall clock is not the meter's modelMs. A retried generation hides the failed attempt's latency and the backoff, so one run showed a 21.8s generate step against 11.0s recorded - and half of the "10x slower" conclusion was backend jitter. The comparison now times that step from event timestamps and marks a sample "(含重试)". `matchesCall` moved out of the oracle so both harnesses share one rule for whether a tool call happened: comparing `input` as a whole fails for write_file, whose input is `path\ncontent`, and the direction of that error is judging a successful write as missing. checkArtifacts moved next to it for the same reason. Verified: 311 tests, and the README numbers are the actual output of the run recorded here, not rounded for effect.
1 parent 78a0e73 commit afe05ad

6 files changed

Lines changed: 662 additions & 28 deletions

File tree

‎README.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ npm run demo -- --laya # local Laya sidecar on :7789 (open weights, free)
135135
npm run demo -- --jev # official Jev API (needs TYPESAFE_API_KEY)
136136
```
137137

138+
**Measure it against the thing it claims to beat:**
139+
140+
```bash
141+
npm run compare # this loop vs a ReAct loop, same tasks and tools
142+
npm run compare -- --repeat 3
143+
```
144+
145+
Two harnesses, measuring different things: `npm run bench` scores each **decision** against an expected trajectory, `npm run compare` scores two **loop shapes** against each other on calls, tokens, wall clock and task outcome. Neither is in the kernel's dependency path.
146+
138147
## DECISION.md — the decisions, compiled
139148

140149
Every generation of agent framework leaves behind a `.md`. `AGENTS.md` holds conventions, `SKILL.md` holds capabilities — and both are **prose for a model to read**. The model pays tokens for them every turn, it can ignore them, and nothing tells you whether it did.
@@ -320,6 +329,28 @@ Two conclusions we are not going to soften:
320329

321330
The obvious sweet spot is a strong decision model served locally. Neither of the two we could test is that: one is fast but not accurate enough, the other is accurate but round-trips.
322331

332+
### And `13 : 1` is not "we saved thirteen LLM calls"
333+
334+
`decisions : model` counts **decisions per generation**. It does not mean this loop made one LLM call where a normal agent would have made thirteen. A ReAct loop solving the same task makes three or four, not thirteen — so the two numbers are not the same quantity and comparing them is nonsense.
335+
336+
So we measured it. `npm run compare` runs the same seven tasks twice: once through this loop, once through a ReAct loop that asks the LLM at every branch point. Same model, same tools (literally the same `callTool`), same fixture, same `maxSteps`, same acceptance checks.
337+
338+
| | LLM calls / task | wall clock / task | output tokens / task | accepted |
339+
|---|---:|---:|---:|---:|
340+
| JevLoop | **1** | 5.5 s | **96** | 6 / 7 |
341+
| ReAct | **4** | 4.1 s | **495** | 7 / 7 |
342+
343+
*Hosted Jev for decisions, `deepseek-flash` for generation, 7 tasks × 1 run each.*
344+
345+
Including the parts that do not flatter the project:
346+
347+
- **Four times fewer model calls, five times fewer output tokens.** This is where the claim actually lands: picking a tool is a closed question, and a closed question does not need tokens generated one at a time. On the write task it is **1 call against 8**.
348+
- **Not faster.** Over the hosted API the wall clock is slightly *worse* — 5.5 s against 4.1 s — because thirteen network round-trips cost more than three LLM calls. Same finding as the table above, now measured end-to-end against a real alternative instead of asserted.
349+
- **It also paid for a revision.** On the `direct` task this loop made **2** generation calls: the delivery gate rejected the first answer, and the revision still missed what the task asked for. The gate is real work that ReAct does not do, and it is counted here rather than averaged away.
350+
- **One sample per task.** An acceptance failure of 1 in 7 is not a quality claim in either direction. `--repeat` exists; the medians above are one run each.
351+
352+
The ReAct side is not a straw man: it is given the JSON protocol in its system prompt, it may recover from a malformed reply (and that costs it a call), and it works in the same fixture with the same tools. `npm run compare` prints its system prompt verbatim so you can judge that rather than take our word for it.
353+
323354
### Two gotchas we hit so you don't have to
324355

325356
Both were found by running this loop against a real Laya checkpoint on an A100, not by reading docs.

‎bench/compare.ts‎

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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

Comments
 (0)