ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

对话数据导出:序列化、结构化与WordBuddy/AI导出鸭取舍

2026/9/18 18:11:26 拓冰建站 浏览量
对话数据导出:序列化、结构化与WordBuddy/AI导出鸭取舍 做对话数据导出这件事我前后折腾了差不多两年从最早手动复制粘贴到写脚本抓再到用现成工具踩的坑基本能写一本小册子。序列化、结构化、WordBuddy、AI导出鸭、重构这几个词看起来像是技术圈的黑话堆叠实际上它们描述的是同一条链路上从头到尾的三个阶段把散落在界面里的对话搬出来、把搬出来的东西变成程序能处理的字节、再把字节整理成人和机器都能读懂的资产。这篇内容我想聊的就是这条链路以及 WordBuddy 和 AI导出鸭 这两个工具在其中的取舍。它适合三类人一是需要把日常和 AI 的对话沉淀成知识库的普通用户二是想自己写导出脚本的开发者三是手里有一堆历史对话、想把它们变成可检索数据集的人。不管你之前有没有接触过序列化和结构化这两个概念看完应该都能上手。1. 对话数据导出的老问题为什么复制粘贴永远是错的1.1 导出的本质是数据搬家不是截图大部分人第一次想导出对话是因为某段内容写得好想存下来。第一反应是选中、复制、粘贴到文档里。这个动作在十次以内没问题超过十次就会暴露一个事实你搬走的是渲染结果不是数据。渲染结果里混着界面上的装饰信息比如头像占位、折叠按钮的文字、代码块的行号、引用块的符号还有对话平台自己的提示语。等你想把这一百段对话做成检索表的时候会发现这些噪音全都在得重新清洗一遍。我见过最夸张的一次有位朋友存了三百多段对话每段都带着七八行平台提示清洗花了两天。这就是把导出当成截图的代价。真正的导出应该是数据搬家原始的角色、内容、时间、附件、层级关系原样搬走界面的东西一个都不要。1.2 序列化和结构化差的不是格式而是语义很多人把这两个词混着用其实差别挺大。序列化解决的是怎么存的问题它把内存里的对象变成一串字节流或者字符串方便写文件、走网络、进缓存。JSON、MessagePack、Protocol Buffers 都是序列化格式Redis 存对象也要先序列化。结构化解决的是怎么理解的问题它要求数据本身携带字段名、类型和层级关系读的人不需要猜。一个简单的判断方法拿到一份数据你能不能用一行代码取出第三轮的助手回复取不出来就说明还没结构化只是序列化过而已。序列化是运输结构化是贴标签。运输完了不贴标签堆在仓库里就是一堆纸箱。这也是为什么很多工具号称支持导出 JSON实际导出来的东西除了自己谁也用不了因为它只有一个巨大的字符串字段。2. 序列化到底解决了什么把对话对象冻成字节2.1 序列化的三种常见载体怎么选对话数据的序列化载体实际可选的无非三类文本型JSON、YAML、JSONL、二进制型MessagePack、CBOR、Protobuf、以及混合型SQLite 行存储、Parquet 列存储。选择依据不是哪个先进而是下游怎么用。需要人眼看的、需要进版本管理的用 JSON 或 JSONL。需要跑分析、几万条以上、要按字段聚合的用 Parquet 或 SQLite。需要走网络传输、体积敏感的用 MessagePack 或 Protobuf。我个人的习惯是中间层统一用 JSONL一行一个会话对象因为它是流式的追加写入不会破坏已有内容出错了也容易定位到具体哪一行。注意JSON 的转义规则和很多语言的字符串字面量不一样。斜杠、反斜杠、控制字符、Unicode 转义在跨语言解析时经常出问题尤其是内容里带反斜杠的代码片段。导出前一定要做一次往返测试序列化再反序列化对比原文是否完全一致。2.2 为什么绝大多数导出工具止步于序列化因为到这里就能用了。用户下载一个 JSON 文件打开能看到内容任务就算完成。但能用和好用之间隔着一整套建模工作字段命名要不要统一、时间用时间戳还是 ISO 字符串、多模态附件怎么挂、对话分支怎么表达、被折叠的思考过程算不算正文。这些问题不解决导出文件就只是把屏幕内容换了个容器。WordBuddy 和 AI导出鸭 在这件事上的共同点是先定义了一份中间表示再从这个中间表示渲染出各种最终格式。这个设计的好处是新增一种输出格式只需要写一个渲染器不用动解析逻辑。这就是重构导出范式里最有价值的那一步。2.3 序列化方案的横向对比方案可读性体积追加写入按字段查询适合场景JSON高大差需全量加载小规模、需人工查看JSONL高大好需流式扫描增量导出、日志式存储MessagePack低小一般需全量加载传输、缓存SQLite中中好强本地检索、长期积累Parquet低很小差强大规模分析这张表里我最想强调的是追加写入这一列。很多人选 JSON 是因为好看但对话是持续产生的每天导出一次全量文件一个月后文件会大到打不开。JSONL 和 SQLite 才是日常积累的正确选择。3. 结构化的关键一跃从能读到能算3.1 结构化的三层含义结构化的第一层是字段化每个信息单元有名字比如role、content、timestamp。第二层是类型化timestamp是字符串还是数字是秒还是毫秒必须一致不能同一个文件里两种混着来。第三层是可寻址能用路径或查询语句精确取到任意一个节点比如第 5 个会话的第 12 轮里所有代码块的语言标签。这三层缺一层下游就得多写一堆兜底代码。我在实际项目里最常见的问题是第二层同一个导出文件里有的时间是2025-03-01 10:00:00有的是2025-03-01T10:00:00Z还有的直接是空。后来我定了个死规矩所有时间统一转成带时区的 ISO 8601 字符串同时附一个毫秒时间戳字段两条都留谁用谁取。3.2 对话数据该怎么建模对话不是普通的列表它有天然的层级会话包含轮次轮次包含内容块内容块可能有多个类型文本、代码、图片、表格、引用。硬塞进一个content字符串是浪费信息拆成块数组又要考虑顺序和上下文连续性的问题。我采用的模型是这样的会话是顶层对象轮次是数组轮次里再放一个blocks数组。每个 block 有type字段文本块和代码块分开存代码块额外带lang和text。连续文本块在渲染时再合并成一段。这样既保留了结构又不至于把一段话拆成一堆碎块。3.3 文档结构化解析的复用价值同样的建模思路其实可以直接套用到非对话场景。PDF 抽出来的内容、网页抓下来的正文、扫描件 OCR 的结果本质上都是非结构化内容要变成有字段的东西。区别只在于对话的角色是明确的而文档的层级需要靠启发式规则去猜。所以如果你已经在做对话导出顺手就能把这套管子接到文档解析上中间表示那一层几乎不用改。这也是我觉得做这类工具最值的地方一次建模多处复用。4. WordBuddy 和 AI导出鸭的实现路径拆解4.1 两个工具的定位差异WordBuddy 更偏把对话变成文档它的强项是输出带层级的 Word 或 Markdown适合给人看、给团队传阅。AI导出鸭 更偏把对话变成数据输出侧重 JSON、表格和可检索的结构。两个工具解决的是同一批用户的不同阶段需求先要一份能发出去的文档再要一份能进库的数据。这个差异决定了它们的架构重点不同。文档导向的工具渲染层的复杂度会高很多因为要处理标题层级、目录、样式、分页。数据导向的工具解析层和校验层更重要因为字段错一个下游统计全废。实际操作中我经常两个都用先用一个拿到人看的版本再用另一个拿到机器用的版本最后用会话 ID 对齐。4.2 采集层先把内容原样拿出来采集层的核心原则是不做任何解释。拿到什么就存什么清洗放到后面。原因很简单采集阶段你还没有完整的上下文不知道某个字段后面用不用得上提前丢掉就再也拿不回来了。采集层要处理的现实问题主要有三个。一是分页和虚拟滚动长对话不会一次性全在内存里得滚动触发加载。二是内容被折叠比如某些工具会把长回复折叠起来必须点开才能拿到完整文本。三是多模态附件图片、文件、表格以什么形式引用是存链接还是存 base64这个要在采集阶段就决定后期改代价很大。我一般存链接加原始文件名base64 会让文件膨胀得离谱。4.3 归一化中间层整个流程的心脏中间层是把不同来源的数据对齐到同一套字段上的地方。这一层做得好不好直接决定了后面能不能加新格式、能不能加新来源。我的做法是定义一份 schema所有来源都先转成这个 schema再谈输出。{ schema_version: 1.0, session_id: s_20250301_001, source: wordbuddy, title: 序列化方案选型讨论, created_at: 2025-03-01T10:00:0008:00, created_ts: 1740808800000, turns: [ { index: 0, role: user, blocks: [ {type: text, text: JSONL 和 SQLite 哪个更适合长期积累} ], attachments: [], timestamp: 2025-03-01T10:00:1208:00 }, { index: 1, role: assistant, blocks: [ {type: text, text: 看你的查询需求。}, {type: code, lang: python, text: import json} ], attachments: [], timestamp: 2025-03-01T10:00:2008:00 } ], meta: {tags: [存储, 选型], turn_count: 2} }这份 schema 里有几个点我想单独说。schema_version一定要有不然半年后你自己都忘了字段含义。index从 0 开始且必须连续方便定位。role用固定枚举值不要一会儿user一会儿human。created_at和created_ts双写前者给人看后者给程序算。4.4 渲染层一份数据多种出口渲染层的设计原则是幂等。同一份中间数据渲染两次结果必须完全一样不能因为字典遍历顺序不同就产生差异。Python 里可以用sort_keysTrue保证字典序Markdown 渲染要固定标题层级规则。渲染层的另一个要点是转义。Markdown 里内容包含#、*、|这些符号的时候如果直接输出会破坏结构。表格单元格里的竖线尤其容易出问题必须转义成\|。代码块要用足够长的围栏内容里出现三个反引号的时候外面就得用四个。这些都是实际导出几百次之后才总结出来的细节。5. 实操从一段对话到一个可查询数据集5.1 环境准备和依赖选择Python 3.10 以上就够了依赖只要两个jsonschema用来做校验sqlite3是标准库自带的。如果你想做更好的表格输出可以加pandas但它不是必须的我后面给的是纯标准库版本避免环境问题。python -m venv venv source venv/bin/activate pip install jsonschema选 SQLite 作为最终落库目标是因为它单文件、零配置、支持 SQL 查询、还能直接给各种分析工具读。对话数据量级不大几十万轮以下SQLite 完全够用没必要上服务型数据库。5.2 定义 schema 并做校验先把 schema 写成文件这样校验和文档是同一份东西不会脱节。import json from jsonschema import validate, ValidationError SCHEMA { type: object, required: [schema_version, session_id, turns], properties: { schema_version: {type: string}, session_id: {type: string, minLength: 1}, turns: { type: array, items: { type: object, required: [index, role, blocks], properties: { index: {type: integer, minimum: 0}, role: {enum: [user, assistant, system]}, blocks: { type: array, items: { type: object, required: [type], properties: { type: {enum: [text, code, image, table]}, text: {type: string}, lang: {type: string} } } } } } } } } def check(session: dict) - bool: try: validate(instancesession, schemaSCHEMA) return True except ValidationError as e: print(校验失败:, e.message, 路径:, list(e.path)) return False校验这一步看起来多余但它是唯一能在入库前拦住脏数据的地方。我建议把校验放在写库之前而不是之后。写完再发现字段不对就得写迁移脚本那个成本高得多。校验失败的会话不要直接丢掉单独写到一个rejected.jsonl里人工看一眼是不是解析逻辑有问题。5.3 清洗规则和参数估算清洗要做的事情不多但每条都得想清楚。我常用的规则有这几条连续空白字符压缩成一个空格但代码块内部不动、去掉首尾空白、把全角空格统一成半角、把过长的空行合并。代码块要单独处理因为缩进和换行是它的语义。关于文本长度的统计这里有个容易被忽略的点len()在 Python 里数的是 Unicode 码点中文一个字算一个英文一个字母也算一个但实际展示宽度差很多。如果你要估算模型上下文占用中文大致按 1 字约等于 0.6 到 1 个 token 来估英文大致按 4 个字符约等于 1 个 token 来估。粗略公式可以写成def rough_tokens(text: str) - int: zh sum(1 for c in text if \u4e00 c \u9fff) other len(text) - zh return int(zh * 0.8 other / 4)这个估算误差在百分之二十以内用来做容量规划足够了。真要精确值就得上对应的分词器但大多数场景没必要为了百分之几的误差引入一个几百兆的依赖。5.4 写入 SQLite 并建立索引数据结构化之后落库就很简单了。表设计上我倾向于把会话和轮次拆成两张表用session_id关联这样查询某个会话的所有轮次和含某个关键词的所有轮次两个方向都快。import sqlite3, json def init_db(pathconversations.db): conn sqlite3.connect(path) conn.executescript( CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, source TEXT, title TEXT, created_at TEXT, created_ts INTEGER, turn_count INTEGER, meta_json TEXT ); CREATE TABLE IF NOT EXISTS turns ( session_id TEXT, idx INTEGER, role TEXT, text TEXT, code_langs TEXT, char_count INTEGER, token_est INTEGER, PRIMARY KEY (session_id, idx) ); CREATE INDEX IF NOT EXISTS idx_turns_role ON turns(role); CREATE INDEX IF NOT EXISTS idx_sessions_ts ON sessions(created_ts); ) return conn写库的时候用事务批量提交一千条一批。实测下来逐条提交五万轮要几分钟批量提交只要几秒。executemany加上显式事务是标准做法别指望自动提交能有好性能。另外注意SQLite 默认的journal_mode是delete频繁写入建议切成wal并发读写体验会好很多。5.5 渲染成 Markdown 和 CSV有了中间层渲染就是纯函数。Markdown 渲染要注意标题层级我会把会话标题放在第一级轮次用第三级内容原样。这样生成的文档可以直接进笔记软件。def to_markdown(session: dict) - str: lines [f# {session[title]}, ] for t in session[turns]: who {user: 用户, assistant: 助手, system: 系统}[t[role]] lines.append(f### 第 {t[index] 1} 轮 · {who}) lines.append() for b in t[blocks]: if b[type] code: lang b.get(lang, ) lines.append(f{lang}) lines.append(b[text]) lines.append() else: lines.append(b.get(text, )) lines.append() return \n.join(lines)CSV 渲染有个坑内容里的换行符会破坏行结构必须要么替换成\n字面量要么给字段加引号并转义内部引号。很多人导出 CSV 之后发现行数对不上十有八九是这个原因。同时 CSV 用 Excel 打开还有编码问题建议加 UTF-8 BOM或者在文件头注明编码。6. 常见问题与排查技巧实录6.1 问题速查表现象可能原因排查动作解决方式导出文件打不开编码不是 UTF-8用二进制模式看前几字节统一转 UTF-8必要时加 BOM行数比预期少内容含换行破坏 CSV检查含引号字段转义换行和引号时间字段无法排序格式混用抽样看十条记录统一 ISO 8601 加时间戳代码块渲染错乱围栏长度不够搜索内容里的反引号围栏加长一层校验频繁失败角色枚举不统一统计 role 取值分布建映射表做归一化文件越来越大全量覆盖导出看文件增长曲线改增量追加按会话去重中文变乱码中间层做了错误编码转换对比原始字节全程用 str落盘时才编码这张表里我最想提醒的是校验频繁失败这一条。刚开始做的时候我以为是 schema 写得太严后来统计了一下 role 字段的取值发现有七八种写法user、User、human、我都出现过。这不是 schema 的问题是没做归一化。加一张映射表就能解决而且这张表会随着新来源不断加长属于正常现象。6.2 几条踩过坑才懂的实操心得第一条先小批量跑通再全量。我吃过一次亏写完脚本直接对着几千个会话跑跑到一半才发现某个字段解析逻辑错了前面白跑。后来改成先抽十个样本人工核对输出确认没问题再放开。这个习惯能省掉大量重跑时间。第二条中间结果一定要落盘。采集和渲染之间那一步即使只是内存里的字典也建议写到 JSONL。因为渲染逻辑改起来很频繁如果每次都重新采集效率极低。落盘之后渲染层可以随便调几秒钟就能重跑一遍。第三条给每一条记录算一个内容指纹。用内容的哈希值做去重比按 ID 去重更可靠因为同一个会话在不同工具里 ID 可能不一样。指纹可以是内容归一化之后的哈希这样导入多次也不会产生重复。具体做法是把每个轮次的内容规范化后拼起来取哈希做INSERT OR IGNORE。第四条保留原始数据副本。结构化是有损的一旦你把某个字段丢了想找回来只能重新采集。所以我会同时保留一份原始响应的副本压缩存着几个月之后还能回溯。注意去重用的哈希不要用 MD5虽然它算得快但碰撞在实际数据里真的会碰到。用 SHA-256 更稳速度差异在这个数据量级下可以忽略。7. 导出链路上的安全与合规约束7.1 外部输入一律当纯文本处理这一条必须单独拿出来说。导出工具天然要处理外部输入而外部输入是最不可控的部分。我的原则很简单来自外部的数据只作为纯文本读取绝不进入任何会自动构造对象、执行逻辑的流程。序列化格式本身是数据格式问题从来不在于格式而在于有没有人把它当成代码去执行。工程上要做的就是切断这条路径解析用最保守的纯文本解析器不要用支持类型标记和类型推断的宽松解析模式。具体到操作上有这么几个习惯解析 JSON 永远用json.loads这种只产出基础类型的接口不用任何会还原自定义类型的扩展参数不要从外部文件里读取配置来动态决定加载哪个类中间层 schema 里所有字段都用基础类型不用联合类型和递归结构。另外导出的文件如果要在团队里流转建议带上来源和生成时间的元信息方便追溯。这些做法本身不复杂但能挡掉绝大多数问题。7.2 敏感信息的脱敏和留存策略对话里太容易出现不该扩散的东西联系方式、内部地址、密钥片段、真实姓名。导出之前跑一遍脱敏规则把手机号、邮箱、身份证号这类模式替换成占位符成本很低。正则匹配替换几万条记录也就一两秒的事。import re RULES [ (re.compile(r\b1[3-9]\d{9}\b), [PHONE]), (re.compile(r[\w.-][\w-]\.[\w.]), [EMAIL]), (re.compile(r\b[A-Za-z0-9_-]{32,}\b), [TOKEN]), ] def scrub(text: str) - str: for pattern, repl in RULES: text pattern.sub(repl, text) return text留存策略也要提前想好。我自己的做法是分三层原始副本本地保留不超过三个月结构化数据长期保留但去掉附件正文导出的成品文档随任务清理。这样既不会丢关键信息也不会让存储无限膨胀。8. 我对这套范式的几点个人体会重构导出范式这件事说到底不是换个文件格式而是把导出从一个动作变成一个流程。动作做完就结束了流程才有迭代空间。WordBuddy 和 AI导出鸭 值得借鉴的地方是它们都把中间层显式地抽了出来而不是把解析和渲染揉在一起。这个设计一旦立住后面加来源、加格式、加校验都是小改动。我自己在用了这套结构一年之后最直观的变化是以前每换一个工具就要重写一遍清洗脚本现在只需要写一个新的解析器输出到同一份中间表示后面全部复用。前期多花的那两天建模时间后面省下来的可能是几十个小时。如果你现在手里的对话数据还只是零散的文档我建议先花一个下午把 schema 定下来哪怕字段先少一点也比边做边改强。最后分享一个小技巧给中间表示加一个ingest_source字段记录这条数据是从哪个工具、哪个版本、哪次采集进来的。平时看着没用等哪天某批数据出了问题这个字段能帮你精准圈定受影响的范围省掉大量人工排查。