Skip to main content
返回博客
博客

底层机制:JSONL Session 解析如何驱动 Agent 路由

深入剖析 MadoHub 如何读取 Claude Code 的 session 日志,以提取干净、结构化的响应,用于智能路由。

Agent 路由里最难的问题,并不是给目标 Agent 生成提示词,而是从源 Agent 那里提取一段干净的响应。

终端输出很脏。ANSI 转义码、进度 spinner、换行折行、颜色序列 - 直接抓终端就像面对一堵噪音墙。你可以去掉控制码,但还是会留下格式化残留、半截行和混杂内容。

MadoHub 用的是另一种方式。我们不抓终端输出,而是读取 Claude Code 的结构化 session 日志 - 那些 JSONL 文件里保存着每一轮对话的干净语义表示。

这篇文章会解释它是怎么工作的。

终端抓取的问题

当 Claude Code 在终端里运行时,它的输出包括:

  • ANSI 转义序列 - 用于颜色、光标定位和格式化。
  • Unicode 标记 - 比如 ❯(提示符)、✽(思考)、⏺(工具使用)。
  • 进度指示器 - 会覆盖前面的行。
  • 权限对话框 - 带有交互元素。
  • 多行代码块 - 带语法高亮转义码。

去掉 ANSI 码会让文本更干净,但还不够。进度指示会留下半截行。代码块会丢失结构。"Agent 输出" 和 "shell 噪音" 的边界也会变得模糊。

对于简单路由(比如“发送最后 10 行”),抓终端输出已经够用了。但对于智能路由 - 也就是 AI 模型需要理解 Agent 实际做了什么,并生成上下文提示词 - 你需要干净、结构化的数据。

Claude Code 如何存储 session

Claude Code 会用 JSONL 格式写结构化 session 日志(每一行一个 JSON 对象)。解析路径大概是:

tmux pane → ps process-tree BFS
  → agent PID (first descendant whose command matches claude/codex/amp/opencode)
    → ~/.claude/sessions/{PID}.json
      → sessionId field
        → ~/.claude/projects/*/{sessionId}.jsonl
          → conversation turns (user/assistant messages)

每一轮对话都包含消息角色、内容和元数据 - 没有任何终端格式信息。这也是 Claude Code 内部用来维护对话上下文的数据。

解析链路

MadoHub 通过一个多步骤链路来解析 JSONL 文件:

第 1 步:获取 PTY 进程 ID

每个终端图块背后都是一个 tmux pane,MadoHub 会从终端创建那一刻起追踪该 pane 的 PID。这个 PID 是定位真正 Agent 进程的起点 —— 该进程通常是这个 pane shell 的子进程。

第 2 步:找到 Agent PID 及其 session 文件

Claude Code 在 ~/.claude/sessions/ 下维护一个 session 目录,但每个文件是以 Agent 的进程 ID 命名的 —— 而不是 session ID。为了找到这个 PID,MadoHub 会从 tmux pane 开始遍历进程树(用 ps -eo pid=,ppid=,comm= 对所有子孙进程做广度优先搜索),直到找到第一个命令名匹配已知 Agent(claude、codex、amp、opencode)的进程。然后 MadoHub 读取该 PID 对应的 ~/.claude/sessions/{PID}.json,其中包含一个指向实际对话日志的 sessionId 字段。

第 3 步:定位并读取 JSONL

MadoHub 拿到 session 文件里的 sessionId 后,会扫描 ~/.claude/projects/ 下的每个子目录,寻找名为 {sessionId}.jsonl 的文件 —— 那就是对话记录。MadoHub 解析它,并提取最后一条 assistant 消息 - 也就是 Claude Code 最近一次给出的响应。

这条消息是干净文本:没有 ANSI 码,没有终端残影,没有进度指示器。它就是 Claude Code “说”的内容,而且是它原本想呈现的格式。

第 4 步:解析失败时怎么办

如果进程树遍历找不到匹配的 Agent 进程,或者 session 文件已经消失(例如进程已退出),MadoHub 在这一轮就没有可用的 JSONL 路径。这时它会回退到终端输出提取 —— 和那些完全不产生 session 日志的 Agent 所用的 ANSI 去除方法一样。

干净响应能带来什么

有了干净、结构化的响应,路由 transform 就可以:

理解上下文:AI 路由模型拿到的是语义内容本身 - “我把认证中间件重构成了使用 JWT token,而不是 session cookie。改了 3 个文件:src/auth/middleware.ts、src/auth/tokens.ts 和 src/config/auth.ts” - 而不是一堆彩色终端输出。

生成精确提示词:"审查 src/auth/middleware.ts、src/auth/tokens.ts 和 src/config/auth.ts 中的 JWT token 迁移。检查 token 刷新流程是否正确处理了过期,并确认中间件正确验证了 token 签名。"

保留代码块:如果 Agent 的响应里包含代码片段,它们会保留原始格式 - 不会被终端换行和 ANSI 颜色搞坏。

缓存与性能

长会话里,JSONL 文件可能会变得很大。MadoHub 会按 tile ID 缓存最后一次解析出的响应,避免在每次路由触发时都重新解析整个文件。缓存会在检测到新的完成事件时失效。

解析链路也针对常见情况做了优化:进程树遍历和 session 文件读取都很快,而且绝大多数情况下都能解析成功。相比之下,扫描 ~/.claude/projects/ 寻找匹配的 {sessionId}.jsonl 文件是更耗时的一步,因为它得搜索每一个项目子目录。

局限性

基于 JSONL 的状态检测已经不再是 Claude Code 独有的了 —— Codex 现在也有自己的、基于 JSONL 的状态管线(task_complete → completed,turn_aborted/error → failed,user_message/task_started/agent_message → working,ri_message → stopped)。真正还有差异的地方是响应文本的提取:本文描述的这种干净、结构化的 session 日志格式 —— 直接从对话记录里读取最后一条 assistant 消息 —— 仍然是 Claude Code 独有的。对于 Codex 和 shell 命令,MadoHub 仍然会回退到终端输出提取并去除 ANSI 码,来获取实际的响应文本。

raw 和 full-output transform 类型适用于任何 Agent,因为它们直接使用终端输出。ai-routing 和 summary transform 在 Claude Code 上效果最好,因为它们能接收到干净的 JSONL 内容。

为什么这很重要

路由输出的质量,直接取决于提取出的响应质量。垃圾进,垃圾出。通过读取结构化 session 日志,而不是抓取终端输出,MadoHub 得到的是源 Agent 实际产出的最高保真版本。

这是一个会产生复利效应的设计决策。更好的提取 → 更好的 transform 提示词 → 更准确的目标 Agent 指令 → 更少的浪费轮次 → 更快的工作流收敛。对干净提取的投入,会在路由流水线的每一步都得到回报。