本文面向:想了解 5 种 AI 对话格式各自解析策略的开发者。
预计阅读时间:12 分钟
最终效果:理解 JSONL 流式解析、SQLite KV 查询、Event Stream 重建、快照读取的完整策略,以及各格式的特殊处理技巧。
五种格式,五种世界
AI 编程工具没有统一的对话存储标准。每家都按自己的理解设计数据格式,有的追加写日志,有的用数据库,有的存快照。ChatCrystal 需要同时支持 5 种数据源,每种的解析逻辑都截然不同。本文逐个拆解每种格式的特点和对应的解析策略。
一、Claude Code:JSONL 流式日志 + 噪音过滤
Claude Code 的对话存储在 ~/.claude/projects/ 下,按项目目录组织。每个会话是一个 .jsonl 文件,每行一个 JSON 对象。
数据特点: 这是流式写入的日志。每一行代表一个事件------包括流式 delta(文本片段、工具调用片段、思考片段)、完整消息、系统事件等。一个用户提问可能产生几十行日志。
核心难点:噪音过滤。 解析器定义了一个 SKIP_TYPES 集合:
javascript
const SKIP_TYPES = new Set([
'file-history-snapshot', 'last-prompt', 'progress',
'agent_progress', 'hook_progress', 'queue-operation',
'message', // 流式 delta
'tool_use', // 流式工具 delta
'tool_result', // 流式工具结果 delta
'thinking', // 流式思考 delta
'text', // 流式文本 delta
'tool_reference',
]);
过滤逻辑有两层:第一层检查 uuid 是否存在(流式 delta 没有 uuid),第二层检查 type 是否在黑名单中。所有 system 类型也被跳过(包括 turn_duration、api_error 等)。最终只保留 user 和 assistant 类型的完整消息。
内容提取: 消息的 content 字段可能是纯字符串,也可能是 ContentBlock 数组。解析器遍历数组,按 block type 分别处理:text 提取文本,thinking 提取思考过程,tool_use 标记工具调用,tool_result 跳过。
内容清洗: sanitizeContent() 函数用正则移除 Claude Code 注入的 XML 标签:
ini
result = result.replace(/<system-reminder>[\s\S]*?</system-reminder>/g, '');
result = result.replace(/<command-name>[^<]*</command-name>/g, '');
result = result.replace(/<command-message>[^<]*</command-message>/g, '');
result = result.replace(/<command-args>[^<]*</command-args>/g, '');
result = result.replace(/<local-command-stdout>[^<]*</local-command-stdout>/g, '');
result = result.replace(/<local-command-caveat>[\s\S]*?</local-command-caveat>/g, '');
这些标签是 Claude Code 的运行时注入,对知识提取没有价值。
二、Codex CLI:事件流 + 多源去重
Codex CLI 的会话记录在 ~/.codex/sessions/ 下,文件名格式为 rollout-{ISO时间}-{sessionId}.jsonl。与 Claude Code 不同,Codex 的 JSONL 不是简单的消息追加,而是一个结构化的事件流。
事件类型路由: 每行 JSON 有 type 和 payload 两个顶层字段。解析器按 type 分支处理:
session_meta--- 会话元数据(ID、工作目录、Git 分支)。只提取一次。event_msg+payload.type === 'user_message'--- 用户消息。需要调用extractUserPrompt()去除 IDE 上下文前缀(Codex VS Code 会注入 "## My request for Codex:" 标记)。event_msg+payload.type === 'agent_message'--- 助手文本回复。response_item+payload.type === 'message'--- 完整消息对象(含output_textcontent blocks)。response_item+payload.type === 'function_call'或'custom_tool_call'--- 工具调用,标记到上一条助手消息的hasToolUse。
去重问题: Codex 会同时通过 event_msg/agent_message 和 response_item/message 两种路径输出助手回复。解析器用 lastAssistantMsg 指针检测重复:如果 response_item 的文本与上一条 agent_message 完全相同,就跳过。
Slug 来源: Codex 有一个 ~/.codex/session_index.jsonl 文件,存储会话 ID 到线程名称的映射。解析器在 parse() 结束时加载这个索引来填充 slug 字段。
三、Cursor:跨两个 SQLite 数据库
Cursor 基于 VS Code,对话数据存在 SQLite 的 KV 表里。但它的数据分布在两个位置:
- Workspace DB (
workspaceStorage/{hash}/state.vscdb) --- 存储composer.composerData,包含该工作区所有 composer 会话的元数据列表。 - Global DB (
globalStorage/state.vscdb) --- 存储cursorDiskKV表,包含所有 bubble(消息气泡)的实际内容。
扫描流程: 先遍历 workspaceStorage/ 下所有子目录,读取 workspace.json 获取项目路径,打开 state.vscdb 查询 composer.composerData。这是一个 JSON 字符串,解析后得到 allComposers 数组,每个元素有 composerId、createdAt、name 等字段。
孤儿发现: 有些 composer 的 bubble 数据存在于 global DB 中,但在任何 workspace DB 的 composerData 里都找不到(比如工作区被删除了)。findOrphanBubbleComposers() 用 SQL 的 SUBSTR + INSTR 从 cursorDiskKV 的 key 中提取 composerId,过滤掉已知的,再验证是否有实际文本内容。
Bubble 解析: 每个 bubble 的 key 格式为 bubbleId:{composerId}:{bubbleId},value 是 JSON。BubbleData 用 type 字段区分用户(1)和助手(2),还有 toolResults、codeBlocks、allThinkingBlocks 等结构化字段。Cursor 的 bubble schema 有版本号(_v),解析器会对未知版本发出警告。
四、Trae:单 key 存储 + agentTaskContent 嵌套
Trae 同样基于 VS Code,数据也存在 state.vscdb 里,但存储方式与 Cursor 完全不同。
单一 KV 结构: Trae 把所有会话数据存在一个 key 下:memento/icube-ai-agent-storage。这个 value 是一个 JSON 字符串,解析后是 { list: TraeSession[], currentSessionId: string }。每个 TraeSession 包含 sessionId、createdAt、updatedAt 和一个 messages 数组。
消息结构: TraeMessage 有 role、content、turnIndex、timestamp 等字段。排序时用 turnIndex 而不是 timestamp,因为助手消息的时间戳有时不可靠。
agentTaskContent 提取: Trae 的 SOLO Builder agent 不直接把回复写在 content 里,而是存在 agentTaskContent 中。这个结构包含:
proposal--- 提案文本proposalReasoningContent--- 顶层推理过程guideline.planItems[]--- 计划步骤列表,每个步骤有toolName、thought、reasoningContent
解析策略是:优先用 content(直接内容),如果为空则回退到 agentTaskContent。在 agentTaskContent 中,toolName === "finish" 的步骤的 thought 通常是完整回复。工具使用检测则看是否存在 toolName 不是 "finish" 的 planItem。
时间戳合成: Trae 的 assistant 消息时间戳可能早于 user 消息。解析器用一个单调递增的 orderMs 计数器:如果真实时间戳大于当前计数器就用真实的,否则用计数器值并递增 1ms。
五、Copilot:JSONL 快照 + 只读首行
GitHub Copilot 的对话存在 VS Code 的 workspaceStorage/{hash}/chatSessions/ 和 globalStorage/emptyWindowChatSessions/ 目录下,文件格式为 .jsonl 或 .json。
快照机制: .jsonl 文件的第一行是完整会话快照(kind:0),后续行是 UI 状态 patch(kind:1)。ChatCrystal 只读第一行------用 readFirstLine() 函数打开流,读一行就关闭。.json 文件则是单一 JSON 对象(旧格式),直接用 readFile() 读取整个文件内容:
typescript
async function readFirstLine(filePath: string): Promise<string | null> {
const stream = createReadStream(filePath, { encoding: 'utf-8' });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
rl.close();
stream.destroy();
return line;
}
return null;
}
格式归一化: .jsonl 的快照包裹在 {kind:0, v:{...}} 中,.json 的数据直接在顶层。解析器用 snapshot.v ?? snapshot 统一处理。
Request-Response 结构: 会话数据的核心是 requests 数组。每个 request 有 message(用户消息)和 response(助手回复数组)。response 数组的每个元素有 kind 字段:text 是文本、thinking 是思考过程、toolInvocationSerialized 是工具调用、mcpServersStarting 和 inlineReference 是噪音。
时间戳处理: 助手回复的时间戳设为 request.timestamp + 1(加 1 毫秒),保证在同一 request 内助手消息排在用户消息之后。
总结对比
| 维度 | Claude Code | Codex | Cursor | Trae | Copilot |
|---|---|---|---|---|---|
| 文件格式 | JSONL | JSONL | SQLite KV | SQLite KV | JSONL/JSON |
| 存储粒度 | 每会话一文件 | 每会话一文件 | 跨两个 DB | 单 key 全量 | 每会话一文件 |
| 噪音程度 | 高(流式 delta) | 中(事件类型多) | 低(结构化) | 低(但嵌套深) | 低(快照模式) |
| 工具调用标记 | content block type | function_call event | toolResults + isAgentic | planItems | toolInvocationSerialized |
| 思考过程 | thinking block | 加密(不可用) | allThinkingBlocks | proposalReasoningContent | thinking kind |
| 特殊处理 | XML 标签清洗 | IDE 上下文剥离 | 孤儿发现 | 时间戳合成 | 只读首行 |
五种格式,五种解析策略,但最终都归一化成同一个 ParsedConversation。这就是 SourceAdapter 插件架构的价值------格式差异被隔离在适配器内部,下游的导入、存储、搜索逻辑完全不需要关心数据从哪来。
项目地址:github.com/ZengLiangYi...
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。