跳到正文
曾亮懿 ZengLiangYi Blog

五种 AI 对话解析器:字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

解析成功不等于会话已经完整保留。五种来源都能输出 ParsedConversation,但各自选择正文、工具信息、思考文本和顺序的规则不同。多格式解析说明输入与数据库定位;这里专注于输出验收,避免把统一字段误当成无损格式转换。

先建立五个来源的验收矩阵

以下结论对应固定提交的五个内置适配器入口,不代表工具未来版本的存储规范。

来源 输入定位与正文选择 验收时必须观察的边界
Claude Code 项目 JSONL;带 uuid 的 user/assistant 消息;字符串或 content blocks system、已知增量等被丢弃;tool_result 不进正文;thinking 单列,工具块设标记
Codex rollout JSONL;event_msg 和 response_item 的特定分支 response_item 的 user/developer 被跳过;全文只和最近助手文本比较;工具调用标记前一条助手
Cursor workspace ItemTable 会话索引,global cursorDiskKV 的 bubble 前缀及孤立会话 type=1 为用户,其余按助手;空助手先跳过;新 _v 只警告;按时间戳排序
Trae ItemTable 的 memento/icube-ai-agent-storage,按 sessionId 找 data.list turnIndex 排序;正文与嵌套 agent 内容有优先级;时间可为合成值
Copilot 旧 .json 整体,.jsonl 的字面第一行 snapshot 不重放后续 patch;响应块分别映射正文、thinking 和工具标记

Cursor 与 Trae 的 SQL 值、JSON 结构和会话定位不是同一套 schema。共享字段降低了下游分支数量,不能替代每个来源的样本测试。

工具标记、思考文本和空消息并不等价

Claude Code的工具块只设置 hasToolUse,不保留工具输入和结果的完整轨迹。只有工具标记而无正文、thinking 的消息仍能保留;system-reminder 等标签会从正文移除。这是一种提取策略,不能证明被移除的信息对所有用途都无价值。

Cursor在提取 thinking 和工具标记之前先跳过空助手文本,所以 thinking-only 的助手 bubble 也可能丢失。hasCode 可以来自代码围栏或 codeBlocks/suggestedCodeBlocks 数组,而不只是正文中的字符。

Trae优先使用直接 content;不存在时再组合 proposal、finish 等嵌套内容和 thinking。最终保留条件是正文或 thinking 非空,单独的工具标记不够。Copilot也只在 text 或 thinking 有值时增加助手消息;因此不能把 Claude Code 的 tool-only 行为推广到所有来源。

Codex 的去重和顺序有明确作用域

Codex 解析器先从 event_msg/user_message 提取用户输入,从 agent_message 提取助手文本;response_item/message 只拼接助手的 output_text。若完整文本等于 lastAssistantMsg.content,就跳过该 response_item。它没有给全部事件或全部历史文本建立去重集合。

function_call/custom_tool_call 会把最近助手的 hasToolUse 设为 true,没有最近助手时不会新建一条工具调用消息。reasoning 等未处理分支不会进入 thinking;不能据此推断全部原始思考都不存在或都加密。缺少时间戳时使用当前时间,最终排序也不等于复原完整的事件因果关系。

Trae 则先按 turnIndex 排序,再保证输出时间单调。这里的时间可能为构造的顺序标记,不应拿来计算用户实际阅读或模型响应耗时。验证顺序时要保留原始事件位置或来源样本,不能只看统一后的 timestamp。

用 Copilot 样本检验快照和响应块

保存为 lesson.mjs,Node 24+ 可直接运行。这个教学提取器仅覆盖首行包装、选定响应块和助手保留条件,不是完整 Copilot 适配器:没有文件扫描、用户消息、时间、数据库或运行时 schema 检查。

import assert from 'node:assert/strict';

function firstSnapshot(lines) {
  try {
    const snapshot = JSON.parse(lines.split('\n')[0]);
    if (snapshot.kind !== undefined && snapshot.kind !== 0) return null;
    return snapshot.v ?? snapshot;
  } catch {
    return null;
  }
}

function assistant(response) {
  const texts = [];
  const thoughts = [];
  let hasToolUse = false;
  for (const item of response) {
    if (item.kind === 'thinking' && item.value) thoughts.push(item.value);
    else if ((item.kind === 'text' || item.kind === undefined) && item.value)
      texts.push(item.value);
    else if (item.kind === 'toolInvocationSerialized' || item.kind === 'prepareToolInvocation')
      hasToolUse = true;
  }
  const content = texts.join('\n').trim();
  const thinking = thoughts.length ? thoughts.join('\n') : null;
  return content || thinking ? { content, thinking, hasToolUse } : null;
}

const snapshot = {
  kind: 0,
  v: {
    requests: [
      {
        response: [
          { kind: 'text', value: 'first answer' },
          { kind: 'thinking', value: 'fixture thought' },
          { kind: 'prepareToolInvocation' },
          { kind: 'inlineReference', value: 'not response text' },
        ],
      },
    ],
  },
};
const patch = {
  kind: 1,
  v: { requests: [{ response: [{ kind: 'text', value: 'later answer' }] }] },
};
const lines = JSON.stringify(snapshot) + '\n' + JSON.stringify(patch);
const selected = firstSnapshot(lines);
assert.notEqual(selected, null);
assert.deepEqual(assistant(selected.requests[0].response), {
  content: 'first answer',
  thinking: 'fixture thought',
  hasToolUse: true,
});
assert.equal(firstSnapshot('broken\n' + JSON.stringify(snapshot)), null);
assert.equal(firstSnapshot(JSON.stringify(patch)), null);
assert.equal(assistant([{ kind: 'prepareToolInvocation' }]), null);
assert.deepEqual(assistant([{ kind: 'thinking', value: 'thought only' }]), {
  content: '',
  thinking: 'thought only',
  hasToolUse: false,
});
console.log('snapshot and selected response boundaries pass');

把 hasToolUse 的赋值改成 false,混合响应断言失败。把第一行选择改成最后一行,首个快照断言也失败。恢复后通过。这只验证指定样本的取舍,没有证明后续 patch 对会话内容完全无影响。

验收要记录缺失内容,而不只记录消息数量

为每个来源准备正常正文、纯工具、纯 thinking、混合块、空响应、重复文本、缺失时间、坏 JSON 和未知版本样本。逐项记录预期正文、角色、工具标记、thinking、顺序、ID 和缺失原因。Claude/Codex 的坏 JSON 行可跳过;Copilot 的首行损坏可能直接得到空会话,不能写成统一的“坏行都能恢复”。

解析结果还会进入导入链路的少于两条消息过滤和内容比较。应将“解析器丢弃一条消息”“导入器跳过会话”“实际数据库未更新”分开定位。一个输出 JSON 结构合法,只能说明它符合结构要求,不能证明来源兼容或信息完整。

分享:

相关文章

AI SDK Provider 抽象:能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出,用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #TypeScript

SourceAdapter 内部链路:身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷,验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

代码规范工具:核对 Biome、ESLint 与格式化的真实分工

阅读 ChatCrystal 的实际脚本与 Flat Config,用离线 ESLint 诊断验证规则,再区分语法检查、类型检查、格式化和 CI 门禁。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。