ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Claude Code会话实时流程图:事件流解析与最小实现

Claude Code会话实时流程图:事件流解析与最小实现 在实际的 Claude Code 长会话里最折磨人的不是模型思考慢而是你只能盯着滚动日志无法一眼看出它已经执行到哪一步、接下来还要调哪些工具、哪些操作是串行、哪些又是并行。Zoetrope 正是围绕这个痛点出现的思路把 Claude Code 会话实时转换成一幅 “live flow graph”让整个 agent 执行过程变成可以观察、可以导航、可以回放的结构化视图。本文会拆解这类工具的观察模型和数据流并提供一个不依赖第三方框架的最小可运行示例帮助你理解如何在本地把 Claude Code 的 JSON 事件流变成动态流程图也能在接入 Zoetrope 或自己造类似工具时更快定位问题。1. Claude Code 的会话数据远比滚动日志更有价值1.1 终端里只适合人读不适合机器画图Claude Code 在终端里的默认输出是给人看的。它包含自然语言解释、工具调用摘要、颜色和缩进偶尔还会有交互提示。人能够从这些信息里判断“现在模型在改写哪个文件”但程序很难稳定解析。颜色字符、省略号、不同版本的措辞变化都会让基于文本的解析非常脆弱。更重要的是滚动日志丢失了结构。一段会话里模型可能先读取文件再运行测试然后根据测试结果修改代码。这些操作之间存在明确的因果关系但日志只是按时间顺序排列的文本流。你看到Read后面跟着Edit却不知道Edit是因为Read的结果才触发的还是另一个独立分支。要画出流程图必须先获得结构化的事件数据。1.2 用 JSON 输出把会话变成事件流Claude Code 的 CLI 在非交互模式下会提供 JSON 输出能力。常见做法是在命令中同时加上非交互参数、详细参数和 JSON 输出参数。claude -p 修复 src/index.ts 的类型错误 --verbose --output-format json如果希望把事件保存下来供后续分析可以重定向到文件。claude -p 修复 src/index.ts 的类型错误 --verbose --output-format json session.jsonl其中-p表示传递 prompt 后直接执行不进入交互式 REPL。--verbose会输出更细粒度的事件例如模型思考、工具调用等。--output-format json让标准输出变成 JSONL也就是每一行是一个 JSON 对象。实际输出结构会因为版本不同而有差异但大致会包含以下类型的行。{type:system,subtype:init,timestamp:2025-01-01T00:00:00Z,sessionId:sess_001} {type:assistant,message:{role:assistant,content:[{type:text,text:开始分析项目结构。}]}} {type:assistant,message:{role:assistant,content:[{type:tool_use,id:toolu_001,name:Read,input:{file_path:src/index.ts}}]}} {type:user,message:{role:user,content:[{type:tool_result,tool_use_id:toolu_001,content:...}]}}注意这里展示的是“常见的输出结构”实际字段名可能随 Claude Code 版本更新而变化。落地前先用自己的版本跑一次确认字段后再写解析器。1.3 事件流中的关键字段JSONL 事件流里真正决定流程图结构的是下面这些字段。字段所在事件作用type顶层区分 system、assistant、user 等事件类型subtypesystem 事件区分 init、错误等子类型sessionId顶层区分多个会话message.contentassistant/user具体内容块可能是文本、工具调用或工具结果content[].type内容块text、tool_use、tool_result等content[].idtool_use 块工具调用唯一 IDcontent[].nametool_use 块工具名称如 Read、Edit、Bashcontent[].inputtool_use 块工具入参例如文件路径、命令content[].tool_use_idtool_result 块对应哪一次工具调用content[].is_errortool_result 块工具是否执行失败parentUuid若干事件父子调用链用于把节点连接成图不要只依赖parentUuid。当它缺失时tool_result.tool_use_id仍然可以把结果节点对回到对应的工具调用节点上。1.4 从事件流到流程图的映射关系流程图要表达的是 agent 会话中的因果关系和时序关系。最基本的映射如下。事件内容图中的呈现说明session 初始化根节点一张图的起点assistant 的 text 文本文本节点表示模型当前的阶段说明tool_use工具节点表示一个操作正在执行tool_result结果节点表示操作完成并连接到对应工具节点错误事件错误节点方便定位失败分支这个映射的意义在于把“模型在想什么”和“模型在做什么”分开。文本节点有助于理解意图工具节点和结果节点才是真正可以量化和排错的执行链路。2. Zoetrope 把流动的会话变成可以看的图2.1 什么是 live flow graphLive flow graph 不是静态架构图而是随着会话推进不断生长的图。Claude Code 每产生一个新事件图里就多一个节点或者一条边浏览器端同步更新。这让它特别适合观察 agent 的长任务执行过程。你可以看到某个工具调用已经 running 了很久下一秒是否进入 error也可以看到两个工具节点先后出现但其中一个是另一个的子步骤。图的“实时性”来自事件流推送普通日志做不到这一点。2.2 图上节点和边应该表达什么节点不应该只是把 JSON 里的字段名抄一遍。节点需要携带上下文例如工具名称、关键入参、执行状态。边要表达因果例如“Read 的结果触发了 Edit”“Edit 的结果触发了 Bash”。一个建议的节点分类如下。节点类型示例表现状态会话节点Session Start固定文本节点“开始分析”静态工具节点Read、Edit、Bashrunning / done / error结果节点Tool Result静态或 error错误节点系统错误error边可以根据实际场景分为顺序边和父子边。顺序边用时间戳建立父子边用parentUuid或tool_use_id建立。如果只画时间顺序整个图会退化成一条流水线只有把父子边画出来用户才能看出哪段逻辑是主线、哪段是子任务。2.3 为什么图比日志更适合长任务长任务的问题在于上下文会超过一个屏幕。日志要么不断滚动要么被截断你很难回到几十分钟前查看某个错误是怎么发生的。图提供了两个优势。第一是可定位。节点带状态颜色红色错误节点会在一堆正常节点中非常醒目。点击错误节点可以直接看到它依赖了哪些上游节点。第二是可回放。只要把事件全部保存下来就可以按时间轴重新播放整张图的生长过程。对于排查“为什么 agent 在那次改版后多调用了一次 Bash”回放比反复翻日志高效得多。2.4 Zoetrope 是观测层不是执行层这里要明确一个边界Zoetrope 这类工具不参与 Claude Code 的决策也不修改 prompt 和模型结果。它只读取会话事件然后构建图再把图推给前端展示。这个定位很重要。观测层不影响执行层意味着它可以在任何已存在的 Claude Code 工作流上叠加而不需要改动 agent 的提示词或工具定义。代价是它完全依赖 Claude Code 输出的 JSON 事件质量。一旦输出格式变化图就会断裂或缺少信息。3. 最小可运行示例自己实现一个 live flow graph下面不依赖 Zoetrope 的源码而是用 Node.js 复现它的核心原理。你可以把这个示例当作最小骨架再替换成自己喜欢的渲染库。3.1 项目结构与依赖先创建项目目录。zoetrope-demo/ server.js watch.html package.jsonserver.js负责启动一个 HTTP 服务同时维护会话图状态。watch.html是浏览器端页面通过 SSE 接收图状态并渲染。这个示例只使用 Node.js 内置模块不需要安装第三方包。package.json保持最简。{ name: zoetrope-demo, private: true, version: 0.1.0, type: module, scripts: { start: node server.js, demo: node server.js --demo } }type: module表示脚本使用 ESM 语法。如果用 CommonJS也可以把import改写为require但下面示例统一用 ESM。3.2 解析 JSONL 事件并维护图状态server.js的职责有两部分从 Claude Code 子进程或演示数据中读取事件然后在内存里维护nodes和edges数组。先写基础结构和事件解析逻辑。// server.js import http from node:http; import { spawn } from node:child_process; import { readFile } from node:fs/promises; const PORT Number(process.env.PORT || 4567); const DEMO process.argv.includes(--demo); const clients new Set(); const state { nodes: [], edges: [] }; let nextId 1; function safeText(value, max 80) { return String(value ?? ) .replace(/\s/g, ) .slice(0, max); } function addNode(id, label, meta {}) { if (!state.nodes.some((n) n.id id)) { state.nodes.push({ id, label, ts: Date.now(), ...meta }); } } function addEdge(from, to) { if (!from || !to) return; if (!state.edges.some((e) e.from from e.to to)) { state.edges.push({ from, to }); } } function parseLine(line) { try { return JSON.parse(line); } catch { return null; } } function handleEvent(event) { if (!event || typeof event ! object) return; if (event.type system event.subtype init) { const id event.sessionId || session; addNode(id, Session Start, { kind: system }); } const content event.message?.content; if (!Array.isArray(content)) return; for (const block of content) { if (block.type tool_use) { const id block.id || tool-${nextId}; const keys Object.keys(block.input || {}).join(,) || no-input; addNode(id, ${block.name}(${keys}), { kind: tool, status: running }); if (block.parentUuid) addEdge(block.parentUuid, id); } if (block.type text) { const id text-${nextId}; addNode(id, safeText(block.text), { kind: text }); } if (block.type tool_result) { const toolId block.tool_use_id; if (!toolId) continue; const toolNode state.nodes.find((n) n.id toolId); if (toolNode) { toolNode.status block.is_error ? error : done; } const resultId result-${nextId}; addNode(resultId, safeText(block.content, 40) || Tool Result, { kind: result }); addEdge(toolId, resultId); } } }代码的关键点有三个。第一tool_result必须通过tool_use_id回到工具节点而不是依赖它和tool_use在输出流中的先后顺序。这样即使两个工具调用的结果乱序返回图的关系仍然正确。第二safeText负责截断内容。工具入参可能包含大段文件内容放到节点标签里只会让图爆炸。演示版本只取 key不取 value。第三addNode和addEdge都有去重逻辑避免同一个节点或边被重复加入。真实会话里同一行事件可能被重复解析去重能避免图出现大量重复节点。3.3 通过 SSE 推送增量图实时性通过 SSE 实现。浏览器使用EventSource连接/events服务端每次解析到新事件后就把当前完整图状态广播给所有已连接客户端。function broadcast(payload {}) { const body JSON.stringify({ nodes: state.nodes, edges: state.edges, ...payload }); for (const res of clients) { res.write(data: ${body}\n\n); } } function startDemo() { const lines [ { type: system, subtype: init, sessionId: demo-session }, { type: assistant, message: { content: [{ type: text, text: 开始分析项目结构 }] } }, { type: assistant, message: { content: [{ type: tool_use, id: tool_1, name: Read, input: { file_path: src/index.js }, parentUuid: demo-session }] } }, { type: user, message: { content: [{ type: tool_result, tool_use_id: tool_1, content: const x 1 }] } }, { type: assistant, message: { content: [{ type: tool_use, id: tool_2, name: Edit, input: { file_path: src/index.js }, parentUuid: tool_1 }] } }, { type: user, message: { content: [{ type: tool_result, tool_use_id: tool_2, content: done, is_error: false }] } } ]; let index 0; const timer setInterval(() { if (index lines.length) { clearInterval(timer); return; } handleEvent(lines[index]); broadcast(); index; }, 500); }如果要用真实 Claude Code 会话则启动子进程并逐行读取 stdout。function startClaudeProcess() { const prompt process.env.CLAUDE_PROMPT || Summarize the current git diff; const child spawn(claude, [-p, prompt, --output-format, json, --verbose], { stdio: [ignore, pipe, pipe] }); child.stdout.setEncoding(utf8); let buffer ; child.stdout.on(data, (chunk) { buffer chunk; const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { const event parseLine(line.trim()); if (event) { handleEvent(event); broadcast(); } } }); child.stderr.on(data, (chunk) { process.stderr.write([claude stderr] ${chunk}); }); child.on(exit, (code) { broadcast({ done: true, code }); }); }这里使用buffer是为了处理“一次读取到半行 JSON”的边界情况。网络或管道并不会保证每次data事件都恰好包含完整一行。3.4 页面端渲染简易流程树浏览器端只需要通过EventSource订阅/events拿到完整图状态后重绘。为了让示例脱离图形库也能表达流程关系这里用“层级卡片”渲染节点并用 SVG 画边。!DOCTYPE html html langzh-CN head meta charsetutf-8 titleZoetrope Live Flow Demo/title style body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; } #status { padding: 12px 16px; border-bottom: 1px solid #334155; font-size: 14px; } #canvas { position: relative; width: 100%; min-height: 600px; padding: 20px; box-sizing: border-box; } .node { position: absolute; padding: 8px 10px; background: #1e293b; border: 1px solid #475569; border-radius: 6px; font-size: 13px; line-height: 1.4; box-sizing: border-box; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .node.system { border-color: #38bdf8; } .node.tool { border-color: #a78bfa; } .node.tool.running { border-color: #fbbf24; } .node.tool.done { border-color: #34d399; } .node.tool.error { border-color: #f87171; } .node.result { border-color: #94a3b8; } /style /head body div idstatuswaiting for events.../div div idcanvas/div script const state { nodes: [], edges: [] }; const es new EventSource(/events); es.onmessage (event) { const data JSON.parse(event.data); if (data.nodes) state.nodes data.nodes; if (data.edges) state.edges data.edges; if (data.done) { document.getElementById(status).textContent session finished; } render(); }; function findNode(id) { return state.nodes.find((n) n.id id); } function computeDepth(id) { let depth 0; let current findNode(id); const visited new Set(); while (current !visited.has(current.id)) { visited.add(current.id); const parentEdge state.edges.find((e) e.to current.id); if (!parentEdge) break; depth; current findNode(parentEdge.from); } return depth; } function render() { const canvas document.getElementById(canvas); canvas.innerHTML ; const positions {}; const svg document.createElementNS(http://www.w3.org/2000/svg, svg); svg.setAttribute(width, 1200); svg.setAttribute(height, Math.max(600, state.nodes.length * 70 40)); svg.style.position absolute; svg.style.left 0; svg.style.top 0; svg.style.zIndex 0; canvas.appendChild(svg); state.nodes.forEach((node, index) { const depth computeDepth(node.id); const left 20 depth * 210; const top 20 index * 70; positions[node.id] { left, top, width: 190, height: 44 }; const div document.createElement(div); div.className node (node.kind || ) (node.status || ); div.textContent node.label; div.style.left left px; div.style.top top px; div.style.width 190px; canvas.appendChild(div); }); for (const edge of state.edges) { const from positions[edge.from]; const to positions[edge.to]; if (!from || !to) continue; const line document.createElementNS(http://www.w3.org/2000/svg, line); line.setAttribute(x1, from.left from.width); line.setAttribute(y1, from.top from.height / 2); line.setAttribute(x2, to.left); line.setAttribute(y2, to.top to.height / 2); line.setAttribute(stroke, #94a3b8); line.setAttribute(stroke-width, 2); svg.appendChild(line); } } /script /body /html这个页面没有使用重型图形库核心思路是每收到一次事件广播更新全局state。重新计算每个节点的深度。根据深度计算水平位置根据节点顺序计算垂直位置。先在画布上放 SVG再添加节点卡片让节点卡片盖住线条边缘。3.5 运行验证与预期效果先启动演示模式。node server.js --demo浏览器打开http://localhost:4567应该看到约 3 秒内依次出现如下节点。Session Start 开始分析项目结构 Read(src/index.js) result Edit(src/index.js) result其中Read节点会先进入 running 状态等收到tool_result后变为 done并连接到一个 result 节点。下图链路是Session Start - Read - result - Edit - result如果需要接入真实 Claude Code先确认本机已安装并登录claude命令然后运行。CLAUDE_PROMPT查看当前目录文件列表 node server.js访问页面后每执行一个工具浏览器端会实时出现新节点。如果命令执行时间很短可能所有节点一下子全部出现这不一定代表 SSE 失效而是 Claude Code 的输出速度太快。4. 接入真实 Claude Code 会话的几种方式4.1 统一入口让脚本去启动 claude 命令上面示例就是这种模式。脚本通过spawn(claude, args)启动子进程然后解析 stdout。这种方式的优点是拿到的是纯 JSONL不需要关心 Claude Code 内部把会话存在哪里。缺点是只能用于非交互式任务。如果你平时习惯在交互式终端里对话这种方式会改变使用习惯。可以把它封装成一个函数在任务开始时调用。claude -p 执行测试 --output-format json --verbose | node server.js也可以用环境变量传 prompt。4.2 直接读取日志文件的方式Claude Code 的会话记录通常会落盘。常见路径可能位于用户目录的.claude文件夹下但不同版本和操作系统的路径不一致。可以先在本机确认。find ~/.claude -name *.jsonl 2/dev/null | head -20如果找到会话文件方式就变成“监听文件新增内容”。Node.js 可以使用fs.watch或轮询读取文件尾部把新增行解析成事件再走同一个handleEvent路径。这种方式适用于桌面版或者 VSCode 里不方便强行接管 CLI 的场景。它的缺点是文件写入可能不是逐行 flush需要自己处理半行缓冲而且新会话文件会不断增多必须按 session 隔离。4.3 在 VSCode、桌面版和不同模型网关下的适配Claude Code 目前有多种使用
返回列表