跳到主要内容

结构化去重与内容存储

30 秒导读: AI agent 的每一次 LLM 调用,都要把几乎一模一样的一大坨消息(system prompt、越滚越长的对话历史、工具定义)重发一遍。Laminar 把每条消息按内容算一个 blake3 哈希,同一个项目里相同内容只在 ClickHouse 存一行,span 只保存哈希引用。本章讲清这套去重是怎么设计的,以及一个最容易被做坏的点:去重后,全文搜索为什么还能对每条 trace 返回正确的『首次出现』那一条 span

本章属于 Laminar 讲解的一章。同组其它章见:全景与阅读地图接入管线Span 与 LLM 语义抽取冷热双存储与实时引擎SQL 查询引擎PII 脱敏服务


1. 这是什么(零基础也能懂)

先看问题:同一段大文本被反复上报

想象一个编码 agent 帮你改代码。它每问一次大模型,发过去的消息数组大概长这样:

  • 一段几千 token 的 system prompt("你是一个编码助手……");
  • 到目前为止的全部对话历史(第 10 轮时,前 9 轮原封不动又发一遍);
  • 一份 工具定义(read_filerun_bash……的 JSON schema)。

一次 agent 任务几十上百次 LLM 调用,这些内容 95% 是重复的。如果每条 span 都把完整消息数组存进数据库,存储量随对话轮数平方级膨胀。

Laminar 的做法:同样的内容只存一次

一句话直觉:像 Git 存对象一样——按内容算哈希,内容相同就复用同一份,谁也不重复存。

具体到 Laminar:

  • 消息数组里每一条消息单独算一个 blake3 哈希(内容寻址);
  • 该哈希在这个项目里第一次出现,才把内容写进 ClickHouse 的 deduped_content 表;
  • span 本身不存消息正文,只存一串哈希引用(input_message_hashes 等);
  • 读的时候,视图 spans_v0 用哈希去表里把正文查回来,拼回完整数组。

一个例子

第 10 轮 LLM 调用,消息数组有 20 条,其中 18 条前面见过。这条 span 落库时:

span.input_message_hashes = [h1, h2, ..., h20] ← 20 个哈希引用
deduped_content 新增的行 = 只有 h19、h20 两条 ← 其余 18 条早已在表里
span.input = ""(空) ← 正文不再随 span 存

读的时候 spans_v0 视图按这 20 个哈希把 20 条正文查回来,重建出的 JSON 与没做去重时逐字节一致。用户和搜索都感知不到去重的存在。

本章不覆盖什么

本章只讲去重与内容落地这一件事,以及它和搜索语义的微妙关系。


2. 顶层全景(它大概怎么转)

去重发生在接入管线里(见 01-ingestion-pipeline.md)。管线分生产者(producer,收到上报请求、准备入队)和消费者(consumer,从队列取出、落库)两半。去重的哈希计算在生产者做,内容落库在消费者做。

怎么读这张图

从上到下是一条 span 从上报到落库的路;左边是生产者算哈希、右边两根柱子是两个独立的 Redis 判定;最后消费者把内容写进 ClickHouse。

一条 LLM span 上报

┌─────────────────▼─────────────────┐
│ 生产者(producer.rs) │
│ 对每条消息: │
│ canonical_json → blake3 → hash │
└───────┬───────────────────┬─────────┘
│ │
查 Redis「存储轴」s: 查 Redis「trace 轴」tn:
这内容项目里存过吗? 这 trace 见过这条吗?
│ │
▼ ▼
决定要不要把正文 决定这条算不算
送去写 deduped_content 「本 trace 首次出现」
│ │
└─────────┬─────────┘

┌─────────────────────────────────────┐
│ 消费者(processor.rs) │
│ 1. deduped_content 插入(仅缺的) │
│ 2. spans 插入(哈希引用 + 索引) │
│ 3. mark_seen:两个轴都盖 Redis 章 │ ← 只有 1、2 都成功才盖
└─────────────────────────────────────┘


读取时 spans_v0 视图
用 deduped_content_dict 查回正文

部件一句话职责

部件干什么在哪
canonical_json把 JSON 对象键递归排序,让"只是字段顺序不同"的两条消息哈希相同traces/input_dedup.rs:62
build_message_dedup生产者:遍历消息数组,算哈希、查两个 Redis 轴,产出去重裁决traces/input_dedup.rs:206
build_tool_dedup生产者:把工具定义规整成一个数组、整体算一个哈希traces/tool_dedup.rs:168
MessageDedup裁决的线上数据结构:哈希列表 + 两组索引traces/input_dedup.rs:157
build_dedup_batch消费者:跨 span 汇总,决定哪些正文真要写 deduped_contenttraces/input_dedup.rs:311
mark_seen消费者:唯一的 Redis 写入方,落库成功后才盖章traces/input_dedup.rs:392
CHDedupedContentdeduped_content 表的行结构:(project_id, content_hash) → contentch/deduped_content.rs:21
spans_v0 视图读取时用 deduped_content_dict 把哈希还原成正文迁移 46_deduped_content_project_scope.sql

命名提醒: 代码注释和 CLAUDE.md 里常把这套东西叫 "shared_content"(消费者里那个待写的 Vec<CHDedupedContent> 变量确实叫 shared_content),但物理表和字典在本 commit 实际叫 deduped_content / deduped_content_dict(见 ch/mod.rs:72:Table::DedupedContent => "deduped_content")。本章一律用真实表名 deduped_content


3. 核心原理

3.1 内容寻址:canonical_json + blake3

它要解决的小问题: 两条语义相同、只是 JSON 字段顺序不同的消息({"role":"user","content":"hi"} vs {"content":"hi","role":"user"}),必须被认成"同一条"才能去重。

思路: 哈希之前先把 JSON 归一化——对象的键递归排序,数组顺序保留(数组顺序是有语义的,不能动)。归一化后的字符串再喂给 blake3。

真实实现: canonical_json(traces/input_dedup.rs:62-100)递归地对 Value::Object 的键排序、对 Value::Array 保序;build_message_dedup 里对每条消息 blake3::hash(canonical.as_bytes()) 得到 32 字节哈希(input_dedup.rs:229-230)。

一个关键区分——哈希用归一化 JSON,存储用原序 JSON:

用途用哪种 JSON为什么
算哈希(去重身份)canonical(键排序)字段顺序不同也能收敛成一条
落地正文(content)原序(serde preserve_order)读回来要和没去重时逐字节一致

对应代码:正文取 item.to_string()(保留摄入顺序)再过 sanitize_string(input_dedup.rs:250),而哈希始终走 canonical。这条"哈希归一、存储原样"的分工是重建保真的关键。

// traces/input_dedup.rs:229-250(节选,真实源码)
let canonical = canonical_json(item);
let hash: [u8; 32] = *blake3::hash(canonical.as_bytes()).as_bytes(); // 归一化后哈希
// ...
let content = sanitize_string(&item.to_string()); // 原序落地

3.2 两条语义轴:storage 与 trace-new(本章的心脏)

这是整套设计最精巧、最容易做错的地方。每条消息,生产者要在 Redis 里查两个互相独立的问题:

Redis key问的问题驱动什么作用域
存储轴 storages:{project}:{hash}这内容项目里最近存过吗?要不要把正文送上线 + 写 deduped_content项目级
trace 轴 trace-newtn:{project}:{trace}:{hash}这条 trace 之前见过这个哈希吗?要不要标成 *_new_message_indices(供搜索)trace 级

两个 key 的构造:storage_seen_key(input_dedup.rs:43)、trace_new_key(input_dedup.rs:52),都带 1 小时 TTL(MESSAGE_SEEN_TTL_SECONDS = 3600,input_dedup.rs:39)。

为什么必须是两个轴,而不是一个? 因为存储被提升成了项目级(跨 trace 共享),但搜索需要的是每条 trace 内的首次出现。这两件事的作用域不一样,于是会出现一个关键组合:

storage 命中 + trace 未命中(storage-hit + trace-miss): 同一段内容在 trace A 里出现过(所以 deduped_content 里已有,storage 命中),现在在 trace B 里第一次出现(trace 轴未命中)。 此时:不用再写 deduped_content(正文已在),但必须把它标成 trace B 的"首次出现",并且仍要把正文带上线——因为 Quickwit 全文索引要为 trace B 单独索引这条内容(见 3.4)。

这个组合就是"项目级存储不破坏搜索首次出现语义"的全部秘密。源码里对它有专门注释和回归测试(cross_trace_storage_hit_still_carries_content_for_quickwit,input_dedup.rs:521-573)。

3.3 裁决的数据结构:MessageDedup

生产者对一条 span 的一个消息字段(input 或 output)算完两个轴,产出一个 MessageDedup(input_dedup.rs:156-162):

字段含义
hashes数组里每条消息的哈希(全量,按位置对齐)
trace_new_indices本 trace 尚未见过的位置(0-based),驱动搜索的 *_new_message_indices
trace_new_contentstrace_new_indices 对齐的正文——所有 trace-new 位置都带,哪怕 storage 命中(给 Quickwit)
storage_miss_offsetstrace_new_*还需要写进 deduped_content 的那些下标

关键不变式:storage_miss ⊆ trace_new(input_dedup.rs:148-150 注释)。storage-miss(项目里从没见过)一定意味着这条 trace 也没见过,所以它必然是 trace-new。反之不成立(trace-new 可能 storage-hit)。因为有这个包含关系,只需在线上发一份 trace_new_contents,storage_miss_offsets 只是它的一个下标子集,省下重复的字节。

生产者主循环(build_message_dedup,input_dedup.rs:228-263)的逻辑,用简化伪代码演示核心分支:

# 示意,非源码
for pos, item in enumerate(messages):
h = blake3(canonical_json(item))
hashes.append(h)

# trace 轴:这条 trace 见过吗?(或本 span 内已发过)
if redis.exists(f"tn:{project}:{trace}:{h}") or h in seen_in_this_span:
continue # 既不重发正文,也不标 trace-new
seen_in_this_span.add(h)

# 是 trace-new:正文要带上(给 Quickwit),记下位置
offset = len(trace_new_indices)
trace_new_indices.append(pos)
trace_new_contents.append(original_json(item))

# 存储轴:只有项目里没存过的,才额外标进 storage_miss
if not redis.exists(f"s:{project}:{h}"):
storage_miss_offsets.append(offset)

重点看:continue 在 trace 轴命中时提前跳过,连存储轴都不查——因为既然 trace 见过,正文肯定早随首次那条落了库。

3.4 消费者汇总:build_dedup_batch 与"首次出现付一次费"

消费者一次处理一 span。build_dedup_batch(input_dedup.rs:311-384)把各 span 的裁决汇成一个 DedupBatch,并决定这一批里哪些正文真要写 deduped_content

跨字段共享的去重集合: 一个 seen_storage_in_batch: HashSet<(Uuid, [u8;32])>input、output、tool 三条路共用(processor.rs:264-286)。这样一个哈希哪怕同时作为 span A 的 input、span B 的 output、span C 的工具定义出现,一批里也只写一行,记账算给批次里第一个引用它的 span(build_dedup_batchseen_storage_in_batch.insert(...) 成功才 push 并累加字节,input_dedup.rs:360-369)。

给 Quickwit 的正文永远带上: 注意 build_dedup_batchtrace_new_contents_for_span无条件填的(input_dedup.rs:342-355)——即使某位置是 storage-hit(不写 deduped_content),它的正文也进 span_trace_new_contents,供 Quickwit 索引本 trace 的首次出现。这正是 3.2 那个 storage-hit + trace-miss 组合在消费者侧的落实。

本 trace 首次出现的所有位置
┌──────────────┴───────────────┐
│ │
storage-miss(还没入库) storage-hit(别的 trace 已入库)
│ │
├─ 写 deduped_content ─┐ │
│ │ │
└─────► span_trace_new_contents ◄───┘ ← 两者都进,喂给 Quickwit

3.5 stamping 规则:只有落库成功才盖章,且顺序严格

它要解决的小问题: Redis 里的 s: / tn: 标记是"我已经存过/见过"的承诺。如果标记盖了、但 ClickHouse 里的行其实没落成功,后续 span 会误以为内容已在,于是不带正文——结果就是数据库里出现悬空引用(重建时变成字面量 null)。

思路(不变式):

  1. 生产者绝不写 Redis。 它对两个轴的查询都是只读、尽力而为——查询出错就当"没见过",宁可多写也不少写(input_dedup.rs:199-205 的函数注释)。
  2. mark_seen 是唯一的 Redis 写入方,且在消费者侧、落库成功之后才跑。

顺序为什么是 deduped_content → spans → mark_seen?(processor.rs:557-598)

  • spans普通 MergeTree(不是 ReplacingMergeTree),没有幂等去重。所以必须先写 deduped_content 再写 spans——否则 spans 写成功、deduped_content 失败、重试时会把每条 span 重复插一遍。
  • mark_seen最后,因为两个轴背靠不同的表:s: 轴的真相在 deduped_content,tn: 轴的真相在 spans.*_new_message_indices。任何一张表还没落库就盖 tn: 章,都会留下一个窗口:span 插入被永久丢弃 → tn: 标记却已在 → 同 trace 后续 span 看到 tn: 命中、发了空的 *_new_message_indices没有任何一条 span 记录了这次首次出现

插入失败时返回 transient 错误,RabbitMQ 重投,不留任何幽灵 key(processor.rs:570-594)。

盖章内容从哪来:storage_keys 直接取自要写的 deduped_content 行(processor.rs:527-530);trace_new_keysinput_batch / output_batchspan_new_indices 投影回哈希(processor.rs:531-554)。这保证盖的章和真正落库的东西一一对应。

一句话记住:Redis 只是『最近见过』的加速缓存,真相永远在 ClickHouse。 Redis 掉了、TTL 过期了,最坏结果只是多写几行重复内容(ReplacingMergeTree 合并时会收敛),绝不会丢数据、绝不会产生悬空引用。这条"标记可丢失、真相在冷存储"是整套设计能容错的根基。


4. 工具调用去重(tool_dedup.rs)

工具定义(read_file / run_bash 的 schema)在一次 agent 任务里几乎一字不变地重发,是去重的又一大户。它和消息去重共用同一张 deduped_content 表和同一个 s: Redis 命名空间(tool_dedup.rs:35-41),但有两点不同。

不同一:整块当一个 blob 哈希。 消息是逐条哈希;工具定义是把整个数组规整成一份 canonical JSON,整体一个哈希,存进 span.tool_definitions_hash(单个 [u8;32],spans.rs:145)。ToolDedup 结构因此只有 { hash, content: Option<String> }(tool_dedup.rs:50-53)——content 只在 storage-miss 时为 Some

不同二:工具定义有三种上游形状,要先归一。 extract_tool_definitions(tool_dedup.rs:68-162)按优先级识别:

上游来源属性形状
Vercel AI SDKai.prompt.tools(已是对象数组)
OTel GenAI semconvgen_ai.tool.definitions(单属性,JSON 数组或 JSON 字符串)
OpenLLMetry / LangChainllm.request.functions.{N}.name|parameters|...(按索引散落,需重组)

一个易错点——peek-validate-commit(先看后删): 这个函数会在成功抽取后把源属性从 raw_attributes 里删掉(避免它们随线上传输、避免在 CHSpan.attributes 里重复计费)。但删除是"先在借用上校验、确认可抽取才提交删除"的——因为如果一个 gen_ai.tool.definitions 是坏 JSON,过早删除会让它永久丢失;保留下来,前端的 extractToolsFromAttributes 兜底还能渲染它。有专门回归测试守着这点(malformed_genai_tool_definitions_string_preserves_attribute,tool_dedup.rs:300-320)。

消费者侧 resolve_tool_dedup(tool_dedup.rs:194-213)和消息一样:只有 content 存在、且在批内 seen_storage_in_batch 里第一次见,才 push 到 shared_content 并返回计费字节;否则返回 0。

读取时 spans_v0tool_definitions_hash 通过 deduped_content_dict 还原成一个虚拟列 tool_definitions(迁移 46_...:92-101)。


5. Prompt 哈希与稳定化(prompt_hash.rs)

prompt_hash.rs 里有一组和上面按内容去重完全不同的哈希。它们的目的不是"内容相同就复用",而是"结构相同的 prompt 认成同一个模板",供 span 搜索的 prompt 指纹和(闭源的)signals 用。理解它有助于分清 Laminar 里几种哈希各管什么。

5.1 结构骨架哈希:structural_skeleton_hash

它要解决的小问题: 同一个 agent 的 system prompt,每次调用里塞的动态内容不一样(Model: gpt-4o vs Model: claude-3、当前时间、用户名),但它其实是同一个模板。想把它们认成一个,就不能对全文做哈希。

思路: 只取 prompt 的结构骨架 = 首句 + 排序去重后的 XML 标签名。structural_skeleton_hash(prompt_hash.rs:30-83):

  1. 先剥掉易变的 SDK 版本头(如 Claude Code 的 x-anthropic-billing-header,prompt_hash.rs:21-23);
  2. 首句——在 20 字符后遇到换行、或后接空白的句点为界(gpt-4.1 里的点不算,prompt_hash.rs:39-64);
  3. 抽出所有 XML/HTML 标签名,小写、排序、去重(prompt_hash.rs:73-78);
  4. 首句|标签名 拼成骨架,Sha3_256,取前 8 个 hex 字符。

于是"同模板换配置值"哈希相同,"不同 agent"哈希不同——测试 test_structural_skeleton_hash_stable_across_dynamic_content(prompt_hash.rs:169)、..._differs_for_different_agents(prompt_hash.rs:198)分别守住这两侧。

5.2 提取 system 消息:extract_system_message

extract_system_message(prompt_hash.rs:87-136)从消息数组里找出 role:"system" 的那条,返回 (系统文本, 剩余消息)。它能吃多种 content 形状:OpenAI 的纯字符串、Anthropic 的 [{text,type}] 块、Gemini/OTel GenAI 的 parts

compute_prompt_hash(traces/utils.rs:247-249)把两者串起来:先 extract_system_message 拿到系统文本,再 structural_skeleton_hash 算指纹。

5.3 别混淆:debug_input_hash 是第三种哈希

input_dedup.rs 里还住着一个 debug_input_hash(input_dedup.rs:119-132),它服务的是调试器回放缓存,和本章的去重、和 5.1 的骨架哈希都不同:

哈希位置粒度归一用途
逐消息去重哈希build_message_dedup每条消息canonical JSON内容去重、存储复用
debug_input_hashinput_dedup.rs:119整个数组(去掉所有 system)canonical JSON,blake3调试回放缓存的 key
structural_skeleton_hashprompt_hash.rs:30system prompt 结构首句+标签,sha3,8 字符prompt 模板指纹

debug_input_hash 特意role 剥掉所有 system 消息(不复用 extract_system_message),因为编码 agent 会在两次迭代间自己改 system prompt,而缓存 key 不能因此变(input_dedup.rs:102-118 注释)。三种哈希各司其职,读源码时别看到 blake3 就以为是同一套。


6. 内容落地到 ClickHouse

6.1 deduped_content 表

物理行结构 CHDedupedContent(ch/deduped_content.rs:21-26):

// ch/deduped_content.rs:20-26(真实源码)
pub struct CHDedupedContent {
pub project_id: Uuid,
pub content_hash: [u8; 32],
pub content: String,
}

建表(迁移 46_deduped_content_project_scope.sql:5-14):

CREATE TABLE IF NOT EXISTS deduped_content
(
project_id UUID,
content_hash FixedString(32),
content String CODEC(ZSTD(3)),
last_seen_at DateTime64(9, 'UTC') DEFAULT now() ...
)
ENGINE = ReplacingMergeTree(last_seen_at)
ORDER BY (project_id, content_hash);

两个设计点:

  • ORDER BY (project_id, content_hash) = 表就是内容寻址的:同一项目同一哈希天然是一行。这也解释了为什么 build_dedup_batch 的批内去重 HashSet 用 (project_id, hash) 元组当 key(必须和表的排序键对齐)。
  • ReplacingMergeTree(last_seen_at) = 就算因为 Redis 失效导致同一 (project, hash) 被重复写了几行,后台合并会按 last_seen_at 收敛成一行。这是"Redis 标记可丢、真相在 CH"能成立的兜底。

6.2 spans 表上的引用列

去重后,正文不再随 span 存,span 只带引用(ch/spans.rs):

类型含义
input_message_hashesArray(FixedString(32))input 每条消息的哈希引用(spans.rs:122)
input_new_message_indicesArray(UInt16)input 里"本 trace 首次出现"的位置(spans.rs:129)
output_message_hashesArray(FixedString(32))output 的哈希引用(spans.rs:136)
output_new_message_indicesArray(UInt16)output 的首次出现位置(spans.rs:140)
tool_definitions_hashFixedString(32)工具定义整块的单哈希;空 = 无工具/旧 span(spans.rs:145)

注意 CHSpan::from_db_span(spans.rs:148-227)把这五个字段初始化为空——它们由消费者在 build_dedup_batch 之后才填,from_db_span 只负责非去重字段。

6.3 读取:spans_v0 视图 + deduped_content_dict

读取不直接 join 大表,而是走一个 ClickHouse 字典 deduped_content_dict(缓存字典,COMPLEX_KEY_CACHE,LIFETIME(MIN 30 MAX 60))。字典在前端启动时用 CREATE OR REPLACE 建好(ensureDedupedContentDict,frontend/instrumentation.ts:144-168),不走迁移——因为迁移工具会对 SQL 做 MD5 校验,把凭据写进迁移文件会在轮换凭据时炸掉。

spans_v0 视图(迁移 46_...:31-108)重建 input 的核心表达式:

if(
notEmpty(input_message_hashes),
'[' || arrayStringConcat(
arrayMap(
h -> coalesce(
dictGetOrNull('deduped_content_dict', 'content', tuple(project_id, h)),
dictGetOrNull('llm_messages_dict', 'content', tuple(project_id, trace_id, h)), -- 旧 span 兜底
'null'
),
input_message_hashes
), ','
) || ']',
input -- 没有哈希引用时,回退到原始 input
) AS input

三个要点:

  • 前向兼容: 新写只进 deduped_content(项目级);旧数据(迁移前的 trace 级 llm_messages 表)靠 coalesce 的第二支 llm_messages_dict 继续解析。这是一次不删旧表、只加新表的前向迁移。
  • 空引用回退: input_message_hashes 为空(非 LLM span、旧 span)时,视图直接读原始 input 列,行为不变。
  • output / tools 同理: output 只查 deduped_content_dict(没有旧数据),tool_definitions_hash 还原成虚拟列 tool_definitions

7. *_new_message_indices 如何服务搜索

这是把 3.2 那条"首次出现语义"落到搜索上的最后一环——也是本章要交付的核心理解之一。

问题: 去重后,一条消息的正文只随"首次出现的那条 span"落库。如果搜索时每条 span 都去匹配它引用的全部历史消息,那么同一段文字会在整条 trace 的每条 span 上都命中,搜索结果全是重复。

解法: 搜索匹配只针对每条 span 的"新消息子集",即 input_new_message_indices 指向的那些位置。老的重复历史,由更早那条"首次引入"它的 span 负责被搜到。

搜索片段查询(search/snippets.rs:203-247)直接读原始 spans(不是 spans_v0),用 input_new_message_indices 把匹配范围缩到新消息:

input 片段 = arrayMap(
i -> coalesce(
dictGet(deduped_content_dict, ..., (project_id, input_message_hashes[i+1])),
dictGet(llm_messages_dict, ..., ...), -- 旧 span 兜底
'null'
),
input_new_message_indices -- 只走首次出现的位置
)

(CH 数组是 1-indexed,所以 [i+1]。)

闭环回到 3.2: 之所以能对每条 trace 都返回正确的"首次出现"那条 span,正是因为 tn: trace 轴是独立于 s: 存储轴的一个 Redis 作用域。存储被提到项目级(跨 trace 省存储),但 input_new_message_indices 仍按 trace 计算——storage-hit + trace-miss 的那条内容,deduped_content 不再重写,却仍在 trace B 的 span 上标了 new_message_index 并把正文喂给了 Quickwit。去重省了存储,搜索不丢首次出现。 两个目标靠"两条轴 + 内容永远为 trace-new 带上线"同时达成。


8. 巧妙之处(可带走的技术)

  • 哈希归一、存储原样。 去重身份用键排序的 canonical JSON,落地正文用原序 JSON——一个求"内容相等就收敛",一个求"读回来逐字节一致",互不干扰。见 input_dedup.rs:229-250
  • 两条独立的 Redis 轴。 用 key 的作用域(项目级 s: vs trace 级 tn:)把"省存储"和"保搜索语义"两个正交诉求拆开,而不是硬塞进一个标记。见 input_dedup.rs:43-59
  • storage-miss ⊆ trace-new 的包含关系。 只发一份 trace_new_contents,storage_miss_offsets 只是它的下标子集,线上零冗余。见 input_dedup.rs:148-150
  • "只有落库成功才盖章"+ 严格 deduped_content → spans → mark_seen 顺序。 把"Redis 标记"和"CH 真相"的因果锁死,任何一步失败都 transient 重投、不留幽灵 key。见 processor.rs:557-598
  • peek-validate-commit 抽取工具定义。 先校验可抽取才删除源属性,坏 JSON 不会被静默吞掉。见 tool_dedup.rs:68-162
  • 前向迁移用 coalesce 双字典。 不删旧表,读路径 deduped_content_dict 优先、llm_messages_dict 兜底,一次发布平滑切换。见迁移 46_...:61-75

9. 边界与局限

  • Redis 只是加速,不是真相。 TTL 过期或 Redis 抖动,最坏是多写重复行(ReplacingMergeTree 合并收敛),不丢数据——这是设计意图,不是缺陷。
  • 去重只作用于 LLM span 的数组型 input/output。 build_message_dedup 开头即 if !span.is_llm_span() { return None },且要求非空 JSON 数组(input_dedup.rs:211-219);非 LLM / 非数组字段原样存,不去重。
  • 旧线上格式仍在队列里流。 MessageDedup 有个手写的向后兼容 Deserialize(input_dedup.rs:170-197):旧形状用 new_indices/new_contents 字段名、没有 storage_miss_offsets,反序列化时合成 storage_miss = 0..trace_new.len()(旧的 trace 级模型下每个 trace-new 都是 storage-miss)。等队列里旧消息排空后可删。
  • 恶意/畸形裁决防御性跳过。 build_dedup_batch 对越界下标、长度不匹配都 continue 跳过,不 panic、不让整批无限重投(input_dedup.rs:344-353)。
  • 哈希碰撞在实践中忽略。 消息 blob 和工具定义 blob 共用 deduped_contents: 命名空间,注释坦言"理论上两者哈希相同会合成一行,实践中不会"(tool_dedup.rs:14-17)。

10. 代码地图(导航索引)

主题文件路径符号名
JSON 归一化(键排序)app-server/src/traces/input_dedup.rscanonical_json
两个 Redis 轴的 key 构造app-server/src/traces/input_dedup.rsstorage_seen_key / trace_new_key
生产者:算哈希 + 两轴裁决app-server/src/traces/input_dedup.rsbuild_message_dedup
裁决数据结构app-server/src/traces/input_dedup.rsMessageDedup
向后兼容反序列化app-server/src/traces/input_dedup.rsimpl Deserialize for MessageDedup
消费者:跨 span 汇总app-server/src/traces/input_dedup.rsbuild_dedup_batch / DedupBatch
唯一的 Redis 写入方app-server/src/traces/input_dedup.rsmark_seen
调试回放缓存的整数组哈希app-server/src/traces/input_dedup.rsdebug_input_hash
工具定义抽取(三形状)app-server/src/traces/tool_dedup.rsextract_tool_definitions
工具定义去重裁决app-server/src/traces/tool_dedup.rsbuild_tool_dedup / resolve_tool_dedup / ToolDedup
Prompt 结构骨架哈希app-server/src/traces/prompt_hash.rsstructural_skeleton_hash
提取 system 消息app-server/src/traces/prompt_hash.rsextract_system_message
Prompt 指纹组合app-server/src/traces/utils.rscompute_prompt_hash
deduped_content 行结构app-server/src/ch/deduped_content.rsCHDedupedContent / get_content_by_hash
Table 枚举映射app-server/src/ch/mod.rsTable::DedupedContent
CHSpan 引用列app-server/src/ch/spans.rsCHSpan(input_message_hashes 等 5 列)
落库编排 + 严格顺序app-server/src/traces/processor.rsprocess_span_messages(span_branch)
建表 + spans_v0 视图frontend/lib/clickhouse/migrations/46_deduped_content_project_scope.sqldeduped_content / spans_v0
读取字典启动创建frontend/instrumentation.tsensureDedupedContentDict
搜索片段(按新消息索引)app-server/src/search/snippets.rsbuild_snippet_query