时间线上认得出并行分支了:3 路并行 · 合计 6.6s,实际 3.0s

图上一个节点连出多条边就是 fan-out,LangGraph 在同一个 superstep 里并发执行
——这一直是真并发。但在时间线上它们只是穿插出现的几行,"这三路是同时跑的、
因此省下了 3.6 秒"一个字都没说。上一轮给协作团队做了泳道,这条同样的信息
在 fan-out 这边还是缺的。

现在按时间跨度把同时跑的节点收进一个分组,标题写明几路、meta 写明省了多少:

    ⑂ 3 路并行                    合计 6.6s,实际 3.0s
       ├ 查销售库  2.2s
       ├ 查库存库  3.0s
       └ 查客户库  1.5s

**判据是"同一批出发",不是"跨度重叠"。** 第一版按包含关系排除嵌套,结果漏掉了
先跑完的那一路:三路同时开始但先后结束时,长的那条在时间上确实"包含"短的。
实测 leg0 [0.05,2.21] / leg1 [0.05,3.01] / leg2 [0.05,1.51],被漏掉的正是
最快的 leg2——而漏掉最快那路会让"省下多少"算少。改成看开始时刻是否落在同一批
(250ms 窗口),fan-out 的几路实测相差不到 1ms,子工作流那种真嵌套则是父节点
明显更早开始,自然分得开。

组内要求两两都同批,而不是链式传递:否则 A|B 重叠、B|C 重叠但 A|C 不重叠的
三个节点也会被串成一组,而它们并不是同时在跑。瞬时节点(<30ms)不参与——
它们谁跟谁都"重叠",是噪音不是信息。

**误报比漏报更糟**:它会让人以为省了时间。所以断言里专门守了反向——db、human、
loop_approve 三份顺序执行的样本一个都不能被判成并行。

fixtures 里新增的 fanout 是真跑出来的,不是手写的:三路分别 sleep 1.8 / 2.6 /
1.1 秒,事件原样收进来,所以 6.6s / 3.0s 这两个数和后端量到的是同一份。

验证:check-decode 新增 6 条(含三份顺序样本的反向断言);check-stream 新增
10 条,宽窄两栏都验了不溢出;后端 276 passed 不受影响;tsc 干净。
This commit is contained in:
ThearchyHelios
2026-09-20 21:09:30 +08:00
parent c3f7d6ffcd
commit 00f8dd78a9
5 changed files with 454 additions and 0 deletions
+7
View File
@@ -229,6 +229,13 @@ function Preview() {
if (params.get('review') === '1') {
return <AssistantStream turns={[REVIEW_BROKEN_TURN, REVIEW_DEGRADED_TURN]} />
}
if (params.get('fanout') === '1') {
return <AssistantStream dense={params.get('dense') === '1'} turns={[{
id: 'fanout', question: '三个库各查一遍,汇总给我', phase: 'done', status: '完成',
steps: decodeRun((fixtures as any).fanout),
output: { 结果: '三路都回来了' },
}]} />
}
if (params.get('team') === '1') {
return <AssistantStream turns={[TEAM_TURN]} dense={params.get('dense') === '1'} />
}
+315
View File
@@ -2030,5 +2030,320 @@
"duration_ms": 19
}
}
],
"fanout": [
{
"seq": 1,
"type": "run.started",
"node_id": null,
"ts": 1789909355.5191329,
"data": {
"nodes": 6,
"resumed": false
}
},
{
"seq": 2,
"type": "node.started",
"node_id": "start",
"ts": 1789909355.528116,
"data": {
"node_type": "input",
"label": "start"
}
},
{
"seq": 3,
"type": "node.finished",
"node_id": "start",
"ts": 1789909355.5313249,
"data": {
"duration_ms": 0,
"attempt": 1,
"preview": {
"q": "x"
},
"vars": {
"q": "x"
},
"artifact": "a69fbbcf7209c6f659a75067c9fa03037c2ae23f55a6f854d5994209129bbbf6"
}
},
{
"seq": 4,
"type": "node.started",
"node_id": "leg0",
"ts": 1789909355.566488,
"data": {
"node_type": "code",
"label": "查销售库"
}
},
{
"seq": 5,
"type": "sandbox.start",
"node_id": "leg0",
"ts": 1789909355.5680408,
"data": {
"language": "python",
"limits": {
"timeout": 30,
"memory_mb": 512,
"cpus": 1.0,
"network": false,
"max_output": 20000
},
"isolation": "auto",
"code": "import time;time.sleep(1.8);print('查销售库 完成')",
"code_truncated": false,
"interpolated": false
}
},
{
"seq": 6,
"type": "node.started",
"node_id": "leg1",
"ts": 1789909355.569063,
"data": {
"node_type": "code",
"label": "查库存库"
}
},
{
"seq": 7,
"type": "sandbox.start",
"node_id": "leg1",
"ts": 1789909355.5698738,
"data": {
"language": "python",
"limits": {
"timeout": 30,
"memory_mb": 512,
"cpus": 1.0,
"network": false,
"max_output": 20000
},
"isolation": "auto",
"code": "import time;time.sleep(2.6);print('查库存库 完成')",
"code_truncated": false,
"interpolated": false
}
},
{
"seq": 8,
"type": "node.started",
"node_id": "leg2",
"ts": 1789909355.570695,
"data": {
"node_type": "code",
"label": "查客户库"
}
},
{
"seq": 9,
"type": "sandbox.start",
"node_id": "leg2",
"ts": 1789909355.5714948,
"data": {
"language": "python",
"limits": {
"timeout": 30,
"memory_mb": 512,
"cpus": 1.0,
"network": false,
"max_output": 20000
},
"isolation": "auto",
"code": "import time;time.sleep(1.1);print('查客户库 完成')",
"code_truncated": false,
"interpolated": false
}
},
{
"seq": 10,
"type": "sandbox.end",
"node_id": "leg2",
"ts": 1789909357.024351,
"data": {
"ok": true,
"exit_code": 0,
"duration_ms": 1456,
"backend": "microvm",
"stdout": "查客户库 完成\n",
"stderr": ""
}
},
{
"seq": 11,
"type": "node.finished",
"node_id": "leg2",
"ts": 1789909357.027894,
"data": {
"duration_ms": 1456,
"attempt": 1,
"preview": {
"ok": true,
"exit_code": 0,
"stdout": "查客户库 完成\n",
"stderr": "",
"duration_ms": 1456,
"timed_out": false,
"truncated": false,
"backend": "microvm",
"files_written": "[0 项]",
"error": null,
"session_reset": false,
"data": null,
"text": "查客户库 完成\n"
},
"vars": null,
"artifact": "696aaa83aa727152eac49f8b82dabdd860ac3d37af96c37f0854a819b0c0c350"
}
},
{
"seq": 12,
"type": "sandbox.end",
"node_id": "leg0",
"ts": 1789909357.7229152,
"data": {
"ok": true,
"exit_code": 0,
"duration_ms": 2162,
"backend": "microvm",
"stdout": "查销售库 完成\n",
"stderr": ""
}
},
{
"seq": 13,
"type": "node.finished",
"node_id": "leg0",
"ts": 1789909357.725617,
"data": {
"duration_ms": 2189,
"attempt": 1,
"preview": {
"ok": true,
"exit_code": 0,
"stdout": "查销售库 完成\n",
"stderr": "",
"duration_ms": 2162,
"timed_out": false,
"truncated": false,
"backend": "microvm",
"files_written": "[0 项]",
"error": null,
"session_reset": false,
"data": null,
"text": "查销售库 完成\n"
},
"vars": null,
"artifact": "00944f0b705f77928790de8c042792bea1237d3907293ec249133e7eab0af61f"
}
},
{
"seq": 14,
"type": "sandbox.end",
"node_id": "leg1",
"ts": 1789909358.5266712,
"data": {
"ok": true,
"exit_code": 0,
"duration_ms": 2959,
"backend": "microvm",
"stdout": "查库存库 完成\n",
"stderr": ""
}
},
{
"seq": 15,
"type": "node.finished",
"node_id": "leg1",
"ts": 1789909358.529147,
"data": {
"duration_ms": 2959,
"attempt": 1,
"preview": {
"ok": true,
"exit_code": 0,
"stdout": "查库存库 完成\n",
"stderr": "",
"duration_ms": 2959,
"timed_out": false,
"truncated": false,
"backend": "microvm",
"files_written": "[0 项]",
"error": null,
"session_reset": false,
"data": null,
"text": "查库存库 完成\n"
},
"vars": null,
"artifact": "5870ecb6df825e09b5b9057ff182a9c933656ed5ab8f42aa9bb0bd01fd962707"
}
},
{
"seq": 16,
"type": "node.started",
"node_id": "merge",
"ts": 1789909358.531316,
"data": {
"node_type": "transform",
"label": "merge"
}
},
{
"seq": 17,
"type": "node.finished",
"node_id": "merge",
"ts": 1789909358.5334458,
"data": {
"duration_ms": 0,
"attempt": 1,
"preview": "三路都回来了",
"vars": null,
"artifact": "9b4dc39b58e543ae8c5ef60ccc10c79ef2b1a720f04d40a882085731a7b28854"
}
},
{
"seq": 18,
"type": "node.started",
"node_id": "out",
"ts": 1789909358.534973,
"data": {
"node_type": "output",
"label": "out"
}
},
{
"seq": 19,
"type": "node.finished",
"node_id": "out",
"ts": 1789909358.537149,
"data": {
"duration_ms": 0,
"attempt": 1,
"preview": {
"r": ""
},
"vars": null,
"artifact": "bd61075bf9ba8f23153729bbad7fada6a82f1dbeaae23f64b746441219f40327"
}
},
{
"seq": 20,
"type": "run.finished",
"node_id": null,
"ts": 1789909358.541813,
"data": {
"output": {
"r": ""
},
"usage": {
"duration_ms": 3021
},
"duration_ms": 3021
}
}
]
}
+90
View File
@@ -319,6 +319,84 @@ function toolStep(seq: number, tool: string, args: Record<string, any>): Step {
return { ...base, kind: 'tool', title: `调用 ${tool}`, detail: argText }
}
/** 低于这个毫秒数的节点不参与并行分组:瞬时节点谁跟谁都"重叠",是噪音不是信息 */
const CONCURRENT_MIN_MS = 30
/** 开始时刻相差在这个数以内算"同一批出发"。实测 fan-out 的几路相差不到 1ms */
const SAME_WAVE_MS = 250
/**
* 认出哪几个节点是**同时**跑的,把它们收进一个分组里。
*
* 图上一个节点连出多条边就是 fan-out,LangGraph 会在同一个 superstep 里并发
* 执行它们——这是真并发(实测 3 个各 sleep 2 秒的节点,全部 +0.00s 开始、
* +2.37s 结束,墙钟 2.6 秒)。但在时间线上它们只是穿插出现的几行,
* "这三路是同时跑的、因此省下了 3.6 秒"这件事一个字都没说。
*
* 判定用时间跨度重叠,而不是看事件顺序——顺序只说明"先后发出",
* 说明不了"同时在跑"。
*
* 判据是"同一批出发":fan-out 的几路由 LangGraph 在同一个 superstep 里启动,
* 开始时刻相差以毫秒计。**不能只看跨度重叠**——三路同时开始但先后结束时,
* 长的那条在时间上确实"包含"短的,按包含关系去排会把先跑完的那路漏掉
* (实测 leg0 [0.05,2.21] / leg1 [0.05,3.01] / leg2 [0.05,1.51],
* 漏的就是 leg2)。
*
* 子工作流那种真嵌套则是父节点明显更早开始、更晚结束,出发时刻对不上。
*/
function groupConcurrent(
out: Step[], spans: Map<string, { start: number; end: number; ms: number }>,
): void {
const idx = new Map<Step, number>()
out.forEach((s, i) => idx.set(s, i))
const candidates = out.filter((s) => {
const sp = s.nodeId ? spans.get(s.nodeId) : undefined
return !!sp && sp.ms >= CONCURRENT_MIN_MS
})
if (candidates.length < 2) return
const together = (a: Step, b: Step): boolean => {
const x = spans.get(a.nodeId!)!
const y = spans.get(b.nodeId!)!
if (x.start >= y.end || y.start >= x.end) return false // 压根没重叠
return Math.abs(x.start - y.start) * 1000 <= SAME_WAVE_MS // 同一批出发
}
const used = new Set<Step>()
const groups: Step[][] = []
for (const a of candidates) {
if (used.has(a)) continue
const g = [a]
for (const b of candidates) {
if (b === a || used.has(b)) continue
// 和组里每一个都重叠才算同一批,否则 A|B 重叠、B|C 重叠但 A|C 不重叠
// 也会被串成一组,而那三个并不是同时在跑
if (g.every((m) => together(m, b))) g.push(b)
}
if (g.length > 1) { g.forEach((m) => used.add(m)); groups.push(g) }
}
for (const g of groups) {
g.sort((a, b) => (idx.get(a) ?? 0) - (idx.get(b) ?? 0))
const sp = g.map((m) => spans.get(m.nodeId!)!)
const wallMs = Math.round(
(Math.max(...sp.map((x) => x.end)) - Math.min(...sp.map((x) => x.start))) * 1000)
const sumMs = sp.reduce((acc, x) => acc + x.ms, 0)
const at = Math.min(...g.map((m) => out.indexOf(m)))
const first = g[0]
for (const m of g) out.splice(out.indexOf(m), 1)
out.splice(at, 0, {
id: `par-${first.id}`, seq: first.seq, kind: 'branch', status: 'done',
title: `${g.length} 路并行`,
// 省下多少是这一组存在的理由,放 meta 里一眼看得到
meta: sumMs > wallMs
? `合计 ${dur(sumMs)},实际 ${dur(wallMs)}`
: dur(wallMs),
children: g,
})
}
}
/**
* 把一次运行的事件解码成步骤树。
*
@@ -337,6 +415,8 @@ export function decodeRun(events: RunEvent[]): Step[] {
const pendingAgents = new Map<string, Step>()
/** node_id → 协作团队的泳道数据。边解码边攒,最后挂到该节点的顶层 Step 上 */
const teams = new Map<string, TeamRun>()
/** node_id → 起止时刻。用来事后认出"哪几个节点是同时跑的" */
const spans = new Map<string, { start: number; end: number; ms: number }>()
const teamOf = (nodeId: string | undefined): TeamRun | null => {
if (!nodeId) return null
@@ -444,6 +524,9 @@ export function decodeRun(events: RunEvent[]): Step[] {
// 人工审批恢复后 LangGraph 会重放该节点,node.started 因此来第二遍。
// 那不是"又执行了一个步骤",是同一步继续——复用原来那条,
// 否则时间线上每审批一次就多一个同名节点。
if (nodeId && !spans.has(nodeId)) {
spans.set(nodeId, { start: event.ts ?? 0, end: event.ts ?? 0, ms: 0 })
}
const existing = nodeId ? nodeSteps.get(nodeId) : undefined
if (existing) {
existing.status = 'running'
@@ -468,6 +551,11 @@ export function decodeRun(events: RunEvent[]): Step[] {
typeof p === 'object' ? p.approved : undefined,
typeof p === 'object' ? String(p.note ?? '') : '')
}
const span = nodeId ? spans.get(nodeId) : undefined
if (span) {
span.end = event.ts ?? span.end
span.ms = num(d.duration_ms) ?? Math.round((span.end - span.start) * 1000)
}
const step = nodeId ? nodeSteps.get(nodeId) : undefined
if (step) {
step.status = 'done'
@@ -883,6 +971,8 @@ export function decodeRun(events: RunEvent[]): Step[] {
}
// 还没收到 end 的工具/模型调用保持 running——它们正在进行,不是丢了
groupConcurrent(out, spans)
// 泳道挂到 supervisor 节点的顶层 Step 上。节点内部那些"第N轮交给谁""X:指令"
// 仍然留着——泳道给的是一眼看清的形状,那些步骤给的是内容,两者不重复
for (const [nodeId, team] of teams) {
+18
View File
@@ -249,6 +249,24 @@ console.log('\n=== 失败运行 ===')
!steps.some((s) => s.kind === 'lifecycle' && s.status === 'running'))
}
console.log('\n=== 并行分支 ===')
{
// 图上一个节点连出多条边就是 fan-out,LangGraph 在同一个 superstep 里并发
// 执行——这是真并发。但在时间线上它们原来只是穿插出现的几行,
// "这三路是同时跑的、因此省下了 3.6 秒"一个字都没说。
// 这份样本是真跑出来的:三路分别 sleep 1.8 / 2.6 / 1.1 秒。
const steps = mod.decodeRun(fixtures.fanout)
const group = steps.find((s) => /路并行/.test(s.title))
check('认出了并行的那一批', !!group, steps.map((s) => s.title).join(' | '))
check('三路都在组里', group?.children?.length === 3, `${group?.children?.length} 路`)
check('省下多少说出来了', /合计 6\.\ds,实际 3\.\ds/.test(group?.meta ?? ''), group?.meta)
// 顺序执行的图不能被误判成并行——误报比漏报更糟,它会让人以为省了时间
for (const name of ['db', 'human', 'loop_approve']) {
const seq = mod.decodeRun(fixtures[name])
check(`顺序执行的 ${name} 没有被误判`, !seq.some((s) => /路并行/.test(s.title)))
}
}
console.log('\n=== 通用规则 ===')
{
const all = Object.values(fixtures).flatMap((evts) => flatten(mod.decodeRun(evts)))
+24
View File
@@ -191,6 +191,30 @@ console.log('\n=== 复核说明 ===')
await page.close()
}
console.log('\n=== 并行分支 ===')
{
// fan-out 一直是真并发(实测 3 个 sleep 2 秒的节点墙钟 2.6 秒),但时间线上
// 原来只是穿插出现的几行,省下的时间一个字都没说
for (const [w, dense, label] of [[1000, '0', '宽栏'], [380, '1', '窄栏']]) {
const page = await browser.newPage({ viewport: { width: w, height: 700 } })
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.goto(`${WEB}/preview.html?fanout=1&dense=${dense}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(400)
const body = await page.locator('body').innerText()
check(`${label}:没有运行时报错`, errors.length === 0, errors.join(' | '))
check(`${label}:说破了这是三路并行`, body.includes('3 路并行'))
check(`${label}:省下多少写出来了`, /合计 6\.\ds,实际 3\.\ds/.test(body),
body.slice(body.indexOf('3 路并行'), body.indexOf('3 路并行') + 40).replace(/\n/g, ' '))
check(`${label}:三路都还看得见`,
['查销售库', '查库存库', '查客户库'].every((n) => body.includes(n)))
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth)
check(`${label}:不横向溢出`, overflow <= 0, `${overflow}px`)
await page.close()
}
}
console.log('\n=== 协作团队的泳道 ===')
{
// 这个节点以前在界面上是一条扁平的步骤序列:看得出"谁回了什么",