Skip to content

s15 - 执行日志

工作流跑起来了,但跑完之后你只知道"成功了"或"失败了"。中间发生了什么?哪个节点最慢?哪个节点出了错?输入输出是什么? 这一章要做的事情,是把每一次执行的完整过程记录下来。

这一章要解决什么问题

s14 的工作流编辑器能画流程、能导入导出、能运行。但运行完之后,你拿到的只是一个最终结果。

如果结果不对,你怎么排查?你知道是哪个节点出了问题吗?你知道传给 LLM 的 prompt 最终长什么样吗?你知道条件判断走了哪个分支吗?

没有执行日志的工作流编辑器,就像没有 print 的代码——能跑,但你不知道它在干什么。

这一章的目标:

  1. 记录每个节点的输入、输出、耗时、状态
  2. 保留历史执行记录,支持对比
  3. 用时间线可视化执行流程
  4. 错误发生时,能看到完整的堆栈信息
  5. 把日志导出为 JSON,方便离线分析

Logger 设计

核心是一个 ExecutionLogger 类。每次运行工作流时创建一个实例,它负责记录整个执行过程。

typescript
export class ExecutionLogger {
  private run: ExecutionRun;
  private listeners: Array<(run: ExecutionRun) => void> = [];

  constructor(workflowId: string, workflowName: string) {
    this.run = {
      id: `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
      workflowId,
      workflowName,
      status: "running",
      startedAt: Date.now(),
      finishedAt: 0,
      duration: 0,
      nodeLogs: [],
    };
  }
}

每个节点的生命周期用三个方法记录:

typescript
// 节点开始执行
logger.startNode(nodeId, nodeType, input);

// 执行成功
logger.finishNode(nodeId, output);

// 执行失败
logger.failNode(nodeId, { message, stack });

为什么是三个方法而不是一个?因为节点执行是异步的,开始和结束之间可能隔了几秒(LLM 调用)。你需要在开始时就记录状态,这样 UI 才能显示"正在执行"的动画。

数据结构

一次执行产生一棵日志树:

ExecutionRun
├── id, workflowId, workflowName
├── status: "running" | "success" | "error"
├── startedAt, finishedAt, duration
└── nodeLogs: NodeLog[]
    ├── nodeId, nodeType
    ├── status: "pending" | "running" | "success" | "error" | "skipped"
    ├── input, output          // 节点的输入和输出数据
    ├── error: { message, stack }  // 如果失败,记录错误
    ├── startedAt, finishedAt
    └── duration               // 毫秒

skipped 状态用于条件分支:当上游节点出错时,后续节点全部标记为 skipped,而不是一个个报错。

执行器集成

s14 的执行器是"跑完就扔"——遍历节点、执行、返回结果。现在每一步都要经过 Logger:

typescript
for (const node of sorted) {
  if (hasError) {
    logger.skipNode(node.id, node.type);
    continue;
  }

  logger.startNode(node.id, node.type, nodeInput);

  try {
    const output = await runNode(node, nodeInput);
    context[node.id] = output;
    logger.finishNode(node.id, output);
  } catch (err) {
    hasError = true;
    logger.failNode(node.id, {
      message: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : undefined,
    });
  }
}

一个关键设计:Logger 有 onUpdate 回调。每次节点状态变化时,UI 都能实时收到通知。这意味着你不需要等执行完才能看到日志——节点一个接一个地变绿(或变红),就像看一场比赛的实时比分。

时间线可视化

ExecutionTimeline 组件把日志渲染成水平条形图:

n_input  [==]                          12ms
n_llm    [========]                   234ms
n_tool   [============]               456ms
n_output [==]                          15ms
         |----|----|----|----|----|
         0   200  400  600  800ms

每根条的颜色代表状态:绿色是成功,红色是失败,黄色是执行中(带脉冲动画),灰色是跳过。条的起始位置和长度按真实时间比例缩放。

点击任意一根条,下方会展开该节点的详细信息:完整的输入数据、输出数据、耗时、时间戳。如果节点报错,错误信息和堆栈都会完整显示。

执行历史

每次运行完成后,记录被存入 historyRuns 数组。历史面板列出所有过去的运行,每条记录显示:

  • 运行时间(几点几分几秒)
  • 工作流名称
  • 执行状态(成功/失败/运行中)
  • 节点数量
  • 总耗时

点击任意一条历史记录,可以回看那次执行的完整时间线和节点详情。这意味着你可以:

  • 对比两次运行,看为什么这次比上次慢
  • 找到上次成功的运行,检查输入输出有什么不同
  • 把失败的运行导出成 JSON,发给同事排查

导出日志

每条历史记录都可以导出为 JSON 文件。格式就是完整的 ExecutionRun 对象:

json
{
  "id": "run_1748400000_abc123",
  "workflowId": "wf_default",
  "workflowName": "示例工作流",
  "status": "error",
  "startedAt": 1748400000000,
  "finishedAt": 1748400003500,
  "duration": 3500,
  "nodeLogs": [
    {
      "nodeId": "n_input",
      "nodeType": "input",
      "status": "success",
      "input": null,
      "output": "示例输入",
      "startedAt": 1748400000100,
      "finishedAt": 1748400000112,
      "duration": 12
    },
    {
      "nodeId": "n_llm",
      "nodeType": "llm",
      "status": "error",
      "input": "示例输入",
      "output": null,
      "error": {
        "message": "LLM 调用失败: 429",
        "stack": "Error: LLM 调用失败: 429\n  at runLLMNode ..."
      },
      "startedAt": 1748400000200,
      "finishedAt": 1748400003400,
      "duration": 3200
    }
  ]
}

有了这个 JSON,你可以用任何工具分析:写脚本统计平均耗时、用 jq 按状态过滤、甚至导入到另一个界面做可视化。

节点状态指示

Canvas 里的节点组件现在会读取执行状态。通过 React Context,每个节点都能拿到自己的状态:

typescript
const status = useNodeStatus(node.id);

状态用一个小圆点表示,在节点左上角:

  • 灰色:未执行
  • 黄色闪烁:执行中
  • 绿色:成功
  • 红色:失败
  • 深灰:被跳过

连线的颜色也会跟着变——如果源节点执行失败,从它出发的线会变红。一眼就能看出问题出在哪条路径上。

三栏布局

页面现在是三栏:

┌──────────┬────────────────────┬──────────────┐
│ 节点库    │                    │ 实时│历史│详情 │
│          │                    │              │
│ 属性编辑  │     工作流画布      │  执行面板     │
│          │                    │              │
│          │                    │  时间线       │
│          │                    │  节点日志     │
│          │                    │  详情面板     │
└──────────┴────────────────────┴──────────────┘

右侧面板有三个标签页:

  • 实时:当前运行的状态概览,每个节点一行,实时更新
  • 历史:所有过去的运行记录,点击查看详情
  • 详情:选中运行的时间线 + 节点日志列表 + 选中节点的完整数据

教学边界

这一章只做一件事:记录和展示执行过程。

不涉及:

  • 日志持久化到数据库(目前存在内存中,刷新页面就没了)
  • 日志搜索和过滤
  • 性能分析(瓶颈检测、慢查询告警)
  • 多人协作下的日志同步
  • 日志量限制和自动清理

这些都在后面的章节里。

Phase 3 完结:你有了一个工作流引擎

从 s11 到 s15,你搭建了一个完整的工作流编排系统:

章节你加了什么系统有了什么能力
s11画布 + 节点用拖拽定义工作流
s12连线 + 执行节点之间传递数据,按顺序执行
s13条件分支工作流能做判断,走不同路径
s14导入导出工作流可保存、可分享、可复用
s15执行日志每次执行可追溯、可调试、可分析

这不再是一个"画个流程图跑一下"的 demo。它是一个可以真正用于生产的工作流引擎——你能定义流程、执行它、看到每一步发生了什么、出了问题能定位到具体节点。

回顾整个教程:

  • Phase 1(s01-s05):你理解了 Agent 的核心——模型调工具、工具返回结果、结果送回模型
  • Phase 2(s06-s10):你把它做成了产品——聊天界面、流式输出、Markdown 渲染、工具调用可视化
  • Phase 3(s11-s15):你跳出了对话模式——用画布编排多步骤流程,让 Agent 按你设计的路线工作

三个阶段,三个视角。从"模型怎么工作"到"用户怎么体验"到"流程怎么编排"。这是一个完整的 Agent 开发心智模型。

试着改改

1. 给日志加过滤

在历史面板加一个状态筛选:只显示成功 / 只显示失败 / 全部。当历史记录变多时,这个功能很关键。

2. 日志持久化

historyRuns 存到 localStorage。刷新页面后历史记录不丢失。这是一个很小的改动,但实用性提升很大。

3. 节点高亮动画

当时间线上的某根条被点击时,Canvas 里对应的节点加上高亮边框。帮助用户在时间线和画布之间建立视觉关联。

4. 统计面板

在历史标签页加一个简单的统计:平均执行时间、成功率、最慢节点排名。数据都在 historyRuns 里,做聚合就行。

一句话记住

执行日志不是锦上添花——看不到执行过程的工作流编辑器就是个黑箱,出了问题你只能重跑一遍猜原因。