目录

  • 引言:一行 observation,四种数据
  • 一、AgentLogsBench 到底测了什么
  • 二、四种访问模式如何互相冲突
  • 三、一个系统挂多种结构
  • 四、AgentLogsBench 的结果边界
  • 五、落到 Agent 可观测平台
  • 结语 / 资料来源

在这里插入图片描述

引言:一行 observation,四种数据

先看 AgentLogsBench 里的一行数据:

{
  "event_time": "2026-04-10 09:12:31",
  "trace_id": "trace_000001",
  "observation_id": "obs_000145",
  "parent_observation_id": "obs_000144",
  "seq_no": 7,
  "type": "TOOL",
  "status": "error",
  "tenant": "tenant_003",
  "input": "Check the deployment rollback path...",
  "output": "stderr: unable to open /workspace/state/retrieval_3bb6.sqlite",
  "total_cost": 0.0184,
  "latency_ms": 1240,
  "payload": {
    "provider": {"stop_reason": "tool_use", "cache_hit": false},
    "tool_result": {"exit_code": 1},
    "attr": {"release_ring": "canary", "customer_tier": "enterprise"}
  }
}

这不是 OpenAI 的 API 协议,而是 AgentLogsBench 自己定义的一行可观测数据。OpenAI 的 Chat Completions 或 Responses API 描述的是一次模型请求与返回,核心字段是 messagesinputoutputtool_calls 等;这里记录的是请求执行之后留下的 Trace,除了输入输出,还有父子关系、执行顺序、延迟、成本和业务属性。两者关注的不是同一层,前者用于调用模型,后者用于排障和分析。

这行记录同时具备四种性质:

  • 它是文档inputoutput 是需要全文检索的自然语言、代码与终端输出;
  • 它是半结构化记录payload 的键会跟着模型、工具、框架和业务配置演进;
  • 它是Trace 中的一环trace_idseq_noparent_observation_id 决定执行顺序与父子关系;
  • 它是指标事实表的一行:Token、成本、延迟和状态需要被实时聚合。

搜索引擎擅长第一种,文档数据库擅长第二种,OLAP 擅长第四种,第三种则依赖点查、排序和关联。传统可观测里也能找到对应场景:日志要搜索错误信息和堆栈,Trace 要按 trace_id 回放调用链,Metrics 要按时间、服务和状态聚合,Span 上的 attributes 则需要支持动态字段过滤。Agent 的麻烦在于,它把这些需求压进了同一行:正文从几百字节涨到 KB—MB,动态属性跟着工具调用持续膨胀,一条 Trace 还可能携带完整对话与中间产物。

日志量继续变大当然麻烦,但我更在意的是前面这四种性质被压进了同一行。到了这里,Agent 可观测性已经不只是传统日志系统的容量问题。

AgentLogsBench 也给数据库排了名次,不过我看这份 benchmark,重点不在排行榜。我更关心它把全文检索、JSON、Trace 回放和看板聚合塞进同一张持续写入的表以后,各个引擎会在哪里露出短板。这四件事单独做都不新鲜,放到一起才麻烦。

对应的底层结构

同一张 observation 表上的访问模式

业务场景

从错误信息、代码和工具输出中排障

按模型、工具和业务属性筛选请求

查看完整对话与工具调用过程

实时查看成本、延迟和成功率

大文本短语检索

动态 JSON 过滤与聚合

有序 Trace 回放

时间窗口聚合与 Top-N

全文索引(倒排表与 token 位置)

子列化与动态路径

trace_id keyword 倒排与排序键

列存、聚合与缓存

从现有系统的设计看,大家并没有试图发明一种万能索引。更常见的做法,是让列存负责聚合,全文索引处理大文本检索,keyword 倒排处理 trace_id 等结构化字段的等值查询,动态子列承接持续变化的 payload,排序键则保证同一条 Trace 能按顺序读出来。全文索引和 keyword 索引底层都可以使用倒排表,只是前者还要处理分词、词频和 token 位置。


一、AgentLogsBench 到底测了什么

1.1 数据形状:稳定列、大正文和动态 payload

benchmark 的评分对象只有一张 agent_observations 表,其中的数据大致可以分成四组:

数据部分 代表字段 形态与用途
Trace 标识 trace_idobservation_idparent_observation_idseq_no 点查、排序、调用关系重建
稳定业务维度 tenantappenvironmenttypestatusmodel 高频过滤、分组、Top-N
大文本 inputoutput 短语、错误特征、代码与路径检索
动态字段 payload.providertool_resultattr.* 随模型、工具和业务持续演进

表里固定了 22 个提升列,其余信息全部进入 payload。这个切法对应一个很现实的边界:稳定且高频参与过滤的字段值得付出 schema 演进成本;供应商元数据、工具结果和业务标注则不可能在接入第一天就全部定义好。

正文也不是传统意义上的日志行。生成器把 input 分成 5KB—20KB、20KB—50KB、50KB—100KB、100KB—250KB、250KB—1MB 五档,output 则在 1KB—50KB 之间。100M 档数据在 ClickHouse 上压缩后是 50.10 GiB,在 Elasticsearch 上是 178.71 GiB,主要体积就来自这两列正文及其索引。

payload.attr 则故意混合了三种路径:12 个每行必有的核心维度、8 个按 observation 类型出现的可选维度,以及 144 个稀疏长尾键。任意一行只有约 21—25 个 attr,但全集的路径空间超过 160。

这是一种典型的 Agent 遥测形态:少数热路径被反复查询,大量长尾路径偶尔出现,而且没有人能提前列完。

生成器还刻意避免了对数据库过于友好的均匀随机分布。7 种 Trace 原型带有不同权重:普通对话和工具查询占大头,大上下文回放、研究型 Agent、429 重试、超时降级与护栏阻断则形成长尾;环境分布是 prod 84%、staging 11%、dev 5%,租户与应用同样不是均匀分布。真实生产中最常见的过滤条件会集中命中少数值,这会同时影响压缩率、缓存命中与索引选择性。

正文里还有 18% 的代码和日志片段。自然语言分词器看到的是单词,Agent 日志里却充斥着下划线、点号、斜杠、请求 ID 与堆栈。mcp__matrix__read_file 应该被看作一个完整工具名,还是四个 token;retrieval_3bb6.sqlite 应该按文件名、扩展名还是字符 n-gram 检索,没有统一答案。分词器的选择因此不是语言学偏好,而是数据模型的一部分,错了就需要重建索引。

1.2 查询形状:四类操作共用一张表

20 条查询大致分为四组:

  • Q05、Q08、Q11、Q13、Q15:在大正文中找错误短语,再按租户、应用或时间收窄;
  • Q07、Q10、Q12、Q16—Q20:按 payload.attr.* 等动态路径过滤和聚合;
  • Q03、Q04:取回一条 Trace,并按执行顺序或父子关系展开;
  • Q01、Q02、Q06、Q09、Q14:刷新列表、成本、延迟和工具分布等看板。

最能代表检索难度的是 Q13。它要在指定租户和时间范围里同时判断三个故障短语,并精确统计 observation 与 Trace 数量:

SELECT count(*) AS observations,
       count(DISTINCT trace_id) AS traces,
       max(event_time) AS last_event_time
FROM agent_observations
WHERE biz_date BETWEEN @start_date AND @end_date
  AND tenant = @tenant
  AND type IN ('GENERATION', 'TOOL', 'RETRIEVAL', 'EVENT')
  AND ( input  MATCH_PHRASE 'timeout awaiting headers'
     OR output MATCH_PHRASE 'timeout awaiting headers'
     OR input  MATCH_PHRASE 'retry budget depleted'
     OR output MATCH_PHRASE 'retry budget depleted'
     OR input  MATCH_PHRASE 'transient upstream failure'
     OR output MATCH_PHRASE 'transient upstream failure' );

上面的查询用的是 MATCH_PHRASE,没有使用 LIKE。它会先按索引配置的 tokenizer 对查询和正文分词,再检查这些 token 是否按给定顺序相邻出现。例如查询 permission denied,正文中连续出现 permissiondenied 才算命中;只有其中一个词、顺序颠倒,或者中间插入了其他 token,都不算同一个 phrase。生成器会按 18% 的比例注入目标短语,再放入数量约为正样本 1.2 倍的 hard negative。这些负样本与目标很相似,但不应该命中:

应命中 不应命中
permission denied permissions deniedpermission-denied
rate limit exceeded rate limited
context length exceeded context too long
policy_violation policy violation
guardrail blocked guardrail block
selforigin self-origin

permission denied 为例,如果把查询退化成单关键词 LIKE '%permission%'permissions denied 也会被捞出来;改成 token AND,仍然无法证明两个词相邻且顺序正确。LIKE '%permission denied%' 可以匹配完全相同的字符串,但它处理的是原始字符子串,不等于分词器定义的 phrase 语义,在大正文上通常也只能逐行扫描。要按 MATCH_PHRASE 的语义查询,索引需要保存 token 位置,并在查询时验证顺序与距离。

这个 benchmark 先考正确性,再考性能。

1.3 四条规则,避免各引擎绕开问题

AgentLogsBench 的规则比查询本身更重要:

  1. 不允许把未知的 payload 路径预先展开成一组顶层类型列;
  2. 被评分的查询必须打在单张 observation 表上,不能把文本放 Elasticsearch、聚合放 OLAP;
  3. 不能把 release_ring 等动态路径偷偷提升成第 23 个固定列;
  4. 允许使用各引擎原生索引和嵌套映射,但不允许针对单条查询准备隐藏结果表。

执行协议是串行运行、预热后多次测量、冷查询之间清理 Linux page cache,并设置 900 秒超时。

有了这些约束,要比较的就不再是某个算子谁更快,而是同一套存储布局能不能同时服务四种互相牵制的访问模式。


二、四种访问模式如何互相冲突

2.1 大文本短语检索:同样有索引,为什么会相差一百倍

先看 100M 档的四条文本查询(秒,数据来自 benchmark 公开结果文件):

查询 Doris 冷/热 ClickHouse 冷/热 Elasticsearch 冷/热 PostgreSQL 冷/热 DuckDB 冷/热
Q05 单 token AND 11.286 / 1.428 78.229 / 3.517 0.757 / 0.283 14.797 / 3.836 52.981 / 8.793
Q08 稀有 token + 短语 0.372 / 0.135 76.042 / 11.512 1.266 / 0.452 1527.478 / 1496.702 73.549 / 27.027
Q13 三短语计数 1.208 / 0.088 70.355 / 9.324 0.722 / 1.116 2818.222 / 2764.718 44.902 / 44.637
Q15 三短语 Top50 2.192 / 0.081 72.972 / 9.536 1.537 / 1.392 2752.860 / 2691.400 43.504 / 43.458

我先看 Q08。Doris 冷查 0.372 秒,Elasticsearch 1.266 秒;ClickHouse 是 76.042 秒,和没有文本索引、直接全表扫描的 DuckDB 基本在一个量级。PostgreSQL 则用了 1527.478 秒,已经超过 25 分钟。Q13、Q15 的差距更大:Doris 和 Elasticsearch 在 0.7—2.2 秒,ClickHouse 稳定在 70 秒以上,PostgreSQL 接近 45 分钟。这里不是常数优化带来的两三倍差距,不同产品实际走了完全不同的读取路径。

Doris 和 Elasticsearch 的路径比较直接。Doris 在 inputoutput 上分别建立全文倒排索引,并开启 support_phrase 保存 token 位置;字符过滤器还会把 ._ 换成空格,用来处理 mcp__matrix__read_fileretrieval_3bb6.sqlite 这类代码和路径。Elasticsearch 把两列映射为 text,查询使用 phrase match。它们都可以先从 posting list 找到候选文档,再根据 token 位置验证短语。

ClickHouse 这组数据为什么会到 70 秒?benchmark 在 lowerUTF8(concat(input, output)) 上建了 text index,但 Q13、Q15 使用的是 positionCaseInsensitiveUTF8(...) > 0。测试使用的 26.2.13 中,position* 不是 text-index-aware 函数,所以执行器没有走 text index,而是在几十 GiB 的正文上逐行搜索字符串。

这不是说 ClickHouse 不能做全文检索,只能说明 26.2.13 上这份 DDL 和查询没有接上。text index 只支持一组明确的查询函数,不会因为两个函数看起来语义相近,就把普通字符串搜索自动改写成索引查询;查询表达式还要和索引表达式对齐。到 26.4,ClickHouse 才加入 hasPhrase 的初始索引支持,因此这里的数字也不能外推成后续版本的能力上限。

inputoutput 拼成一个索引还有另一层影响。假设 token 只出现在 output,拼接后的 posting list 仍会让针对 input 的候选过滤通过,执行器最后还要回读 input 确认。这样虽然少建了一份索引,却损失了单列谓词的选择性。Doris 分别给两列建索引,没有这个问题。

PostgreSQL 的问题还不只是表里的 25—47 分钟。我也会看 phrase 是否做对。PostgreSQL 官方文档规定,tsvector 的 position 最大为 16383,单个 tsvector 不能超过 1MB;较老版本文档进一步明确,超过上限的位置会被截到 16383。AgentLogsBench 的 input 最大达到 1MB,长文档后半部分的 token 位置可能失真,phraseto_tsquery 会出现假阳性或假阴性。查询按时返回,不代表结果一定正确。

所以这一节实际要检查两件事。第一,用 hard negative 和超长正文确认 phrase 语义没有做错;第二,从执行计划、读取行数和读取字节数确认索引真的参与。只看 DDL 里有没有 INDEX,看不出查询走的是 posting list、granule 粗筛,还是完整正文扫描。

2.2 动态 JSON:路径能写进去,不等于查得快

这部分看 Q16 和 Q17。Q16 按三个低基数路径 release_ringcustomer_tiertraffic_cluster 过滤,Q17 则用高基数 request_keyworkflow_variant 点查。100M 档结果如下(秒):

查询 Doris 冷/热 ClickHouse 冷/热 Elasticsearch 冷/热 PostgreSQL 冷/热 DuckDB 冷/热
Q16 三个低基数路径过滤 0.451 / 0.078 22.189 / 2.667 0.969 / 0.802 1689.146 / 1627.072 2533.656 / 2546.002
Q17 高基数 request_key 点查 0.142 / 0.030 10.585 / 2.339 0.159 / 0.053 1831.839 / 1769.845 2486.025 / 2503.892

这组数据分成了三档。Doris 和 Elasticsearch 都在 1 秒以内;ClickHouse 是 10—22 秒;PostgreSQL 和 DuckDB 则需要 28—42 分钟。至少在这份配置里,仅仅说明系统支持 JSON 没有多少区分度。

Elasticsearch 通过 dynamic template 把 payload.attr.* 映射成 keyword,Q16、Q17 的等值条件可以直接走 keyword 倒排。Doris 的 VARIANT 会把识别出的 JSON path 物化成内部子列,查询只读取涉及的路径;从结果看,低基数组合过滤和高基数点查都没有退化成整个 payload 的解析。

ClickHouse JSON 同样会对子路径做子列化,所以它不需要像 blob 一样反复解析完整 payload。但这份 DDL 没有给 Q16、Q17 涉及的动态路径建立选择性索引,查询仍要扫描对应子列。10—22 秒比 PostgreSQL、DuckDB 好很多,却和 Doris、Elasticsearch 的亚秒点查不是一条执行路径。子列化可以少读、少解析,却不会自动找到少量候选行。

PostgreSQL 虽然在整个 payload 上建了 jsonb_path_ops GIN,Q16、Q17 使用的却是 payload #>> '{...}' = value。这类表达式没有对应的 expression index,现有 GIN 不能直接服务它。DuckDB 也没有为这些路径建立索引,两边最终都要在 100M 行上读取和比较动态字段。

这里仍有一个边界。benchmark 的 payload 总路径数约 156,低于 ClickHouse max_dynamic_paths 的默认 1024,也低于 Doris 常用的 2048 子列预算。两边几乎都能把全部路径物化为子列,专门处理上千长尾路径的 shared data 或稀疏结构并没有真正承压。

所以我从 Q16、Q17 能带走的判断只有一半:热路径进入类型列之后,裁剪、压缩和索引质量会直接拉开差距;至于路径数量超过子列预算以后谁的长尾兜底更好,这份数据还没有测到。

2.3 Trace 回放:点查很快,排序键也不一定要以 trace_id 开头

Q03 按 trace_id 取回一条 Trace 的全部 observation,再按 seq_no 排序;Q04 在 SQL 引擎里还会按 parent_observation_id 自连接,补出父子调用关系。100M 档结果如下(秒):

查询 Doris 冷/热 ClickHouse 冷/热 Elasticsearch 冷/热 PostgreSQL 冷/热 DuckDB 冷/热
Q03 有序 Trace 回放 0.088 / 0.020 9.176 / 2.289 0.043 / 0.034 21.831 / 3.593 0.015 / 0.003
Q04 父子调用关系 0.166 / 0.036 13.520 / 2.534 0.033 / 0.033¹ 21.778 / 3.636 3.180 / 0.743

¹ Elasticsearch 的 Q04 只取回同一 Trace 的 observation 并排序,没有执行父子连接,工作量与 SQL 引擎不相同。这个数字放在这里只为了披露原始结果,不能用于 Q04 排名。

Q03 的结果和我原来只比较 Doris、ClickHouse 时得到的印象并不一样。DuckDB 冷查 0.015 秒,Elasticsearch 0.043 秒,Doris 0.088 秒,都在亚秒级。Elasticsearch 的 trace_idkeyword,精确点查可以直接使用 keyword 倒排;Doris 的排序键以 trace_id 开头,同一条 Trace 在文件中连续。

DuckDB 没有显式的 trace_id 索引,却跑出了最快结果。这份数据按 Trace 生成并串行导入,同一条 Trace 的 observation 具有很强的局部性,DuckDB 可以从 row group 的统计信息中快速排除无关数据。这个结果依赖数据顺序,不能因此认为 trace_id 不需要索引;换成乱序摄入或多批次 compaction,还需要重新测试。

ClickHouse 使用 (biz_date, trace_id, seq_no) 排序,PostgreSQL 的 B-tree 也是 (biz_date, trace_id, seq_no)。Q03 只给 trace_id,没有日期条件,二者都无法有效使用索引前缀,冷查分别是 9.176 秒和 21.831 秒。差距来自物理布局,不是 ORDER BY seq_no 这一步本身。

不过,生产系统通常不会只为 Q03 选择排序键。Jaeger v2.18 的 ClickHouse schema 按 (service_name, name, start_time) 排序,再用 trace_id bloom filter 和物化视图补回 Trace 点查。Jaeger 公布的一轮测试里,按 trace_id 排序时 Trace 回放约 27ms,但多条件搜索约 880ms;换成服务、操作和时间排序后,回放约 100ms,搜索降到约 140ms。

这个取舍是不对称的:搜索退化可能扫过整个 keyspace,Trace 点查则可以用 bloom filter、时间边界、keyword 倒排或 projection 补偿。Tempo 的 block bloom filter 与 Jaeger 的 Trace ID bloom filter 走的也是这条路线。bloom filter 只能确认目标值不在某个 granule;projection 可以把数据再按 (trace_id, seq_no) 排一份,查询更快,但代价接近复制一份数据。

Q04 还暴露了存储层和应用层的边界。多数 Trace 系统先取回同一 Trace 的所有 span,再在应用内按 parent_span_id 拼树,而不是在存储层自连接。表里的 Q04 可以测试 SQL 引擎的关联能力,却不一定对应产品里的真实调用路径。

这一点和我们之前做 Agent message 存储时遇到的问题很像。最开始我们想直接在底层用树来建模消息,每条 message 都通过 parent_id 指向上一条消息。这样做很自然,fork 只是从任意节点拉出一个新分支,消息顺序和 turn 顺序也可以沿着树恢复。

后来因为一些历史原因,我们发现 harness 传下来的 parent_id 不能完全相信。底层一旦把它当作事实,缺失或错误的父子关系就会直接污染存储模型,回放、fork 和顺序都跟着出问题。最后只能退一步:用 Hybrid Logical Clock(HLC)作为排序键,物理时间相同时再靠逻辑计数维持顺序;幂等键则使用消息的 CRC。存储层只保证消息能稳定排序、重复写入可以识别,不再强行维护一棵绝对正确的树。

后续如果需要拼 Agent 调用树,就在应用层取回同一条 Trace 的 observation,再结合仍然可用的 parent_id 做重建。这样没有树模型那么漂亮,但至少没有把底层正确性绑定在一个不完全可信的字段上。对我来说,Q03 更接近存储层必须保证的能力,Q04 则更像一个可以延后到应用层的派生视图。

所以 Q03 的快慢不能单独决定排序键。它只说明当前数据顺序和索引结构是否适合已知 trace_id 的回放;主排序服务哪种搜索,再给 Trace 点查挂什么补偿结构,才是生产系统实际要做的取舍。

2.4 写入侧:每一种加速结构都要收费

这一节没有和 Q03、Q16 类似的查询延迟表,因为 AgentLogsBench 测的是批量导入完成后的静态查询,没有模拟持续摄入。它能提供的写入侧数据只有导入耗时和最终存储占用。先看 100M 档结果:

引擎 存储 相对 ClickHouse 导入耗时 相对 ClickHouse 主要换取
ClickHouse 50.10 GiB 1.00x 2755s 1.00x 最省空间、最快导入
Apache Doris 57.94 GiB 1.16x 4396s 1.60x 列存、VARIANT 与两列全文索引共存
DuckDB 100.26 GiB 2.00x 10111s 3.67x 无索引扫描基线
Elasticsearch 178.71 GiB 3.57x 13926s 5.05x 冷态短语检索
PostgreSQL 350.26 GiB 6.99x 51250s 18.60x JSONB GIN 与全文 GIN

ClickHouse 是这张表的空间和导入基线。Doris 比它多用了 16% 的存储、导入慢 60%,同时带着 inputoutput 两列全文索引和 VARIANT 子列。至少在这组数据上,全文索引与列存共址没有把空间放大到搜索引擎的量级,主要代价落在写入时间上。

Elasticsearch 的存储是 ClickHouse 的 3.57 倍,导入耗时是 5.05 倍,换来的是 2.1 节里亚秒到秒级的冷态文本检索。PostgreSQL 同时维护 JSONB GIN 和全文 GIN,最终占用 350.26 GiB,导入超过 14 小时;但前两节的数据也说明,索引建得多不代表查询表达式一定能用上。DuckDB 没有这些索引结构,仍用了 ClickHouse 两倍的空间,不过它在这里承担的是扫描基线,不适合只用导入速度评价。

全文索引让短语查询变快,但会增加存储和导入成本;JSON 子列化让动态过滤变快,每条新路径也要付出类型推断和元数据开销;排序键让某类过滤接近顺序读取,却只能选择一种列顺序。这张表只能看到一次性批量导入的账,还看不到持续写入时 segment 创建、索引构建和 compaction 互相竞争的成本。

混合负载的另一面是,同一个引擎在不同问题上可能相差一个数量级。Elasticsearch 的 Q05 冷态短语检索只要 0.757 秒,但 Q02 按高基数 trace_id 分组汇总成本时需要约 15—16 秒。全文索引能迅速找到包含某个词的文档集合,却不能消除高基数分组的状态维护、doc values 读取与归并成本。反过来,列存引擎可以高效聚合数值列,一旦文本谓词退化成逐行函数,前面的列式优势又会被正文扫描吞掉。

支持全文、JSON、SQL 和 Trace 只是一张功能清单。真正决定混合负载表现的是:查询能否在早期用高选择性结构缩小候选集,以及缩小之后需要回读多宽的行。Q05 返回完整 payload,Q13 只返回计数,两者即使使用同一个全文索引,冷态回读成本也会完全不同。

静态 benchmark 还有一个生产里拿不满的数字:热态延迟。

Doris Query Cache 依赖 tablet 版本。INSERT、UPDATE 或 compaction 改变版本后,原缓存就会 miss;Condition Cache 也更适合段稳定、谓词重复的读多写少场景。Agent 遥测恰恰是持续写入,新的 segment 和 compaction 会不断缩短缓存寿命。

Langfuse 在 ClickHouse 上遇到的是同一矛盾的另一种表现。它用 ReplacingMergeTree 接收同一 observation 的 start、end、update 等多个版本,后台 merge 最终去重;读侧加 FINAL 可以得到最新结果,却增加查询时合并成本。官方后来提供了跳过 FINAL 和读写计算分离的配置,本质上仍是在正确性、摄入和看板延迟之间重新分账。

也就是说,这里的热态是批量导入完成后的静态热态,不能等同于持续摄入下的生产热态。


三、一个系统挂多种结构

把四种访问模式放到一起,没有一种物理结构能同时把它们做到最好。

全文索引擅长 term 和 phrase,却不擅长高基数聚合;列存擅长扫描、裁剪和聚合,却无法凭空获得 token 位置;按 Trace 排序有利于回放,却会让服务、时间和多维搜索失去主键前缀;动态 JSON 保住 schema 演进,又必须想办法让热路径进入类型化执行链。

从 Doris、Tempo、Quickwit 这些系统的实现看,比较常见的处理方式有三种。

3.1 热路径提列,长尾留动态结构

是否把一个字段提升为固定列,不应该只看它是否重要,还要看结构是否稳定,以及是否高频出现在过滤、排序和分组中。

trace_idtenanttypestatusevent_time 显然应该是列。release_ring 处在中间地带:如果引擎能自动把它子列化并建立可裁剪的数据流,就可以留在 payload;如果只能在查询时逐行解析,那就要付出 schema 演进成本把它提升出来。

真正需要治理的是长尾。系统必须明确回答三个问题:热路径由谁识别;超过子列预算后进入什么结构;读取一个稀疏长尾路径时要加载多少无关数据。

这比确认系统是否支持 JSON 类型更接近性能答案。

3.2 列存、全文索引和动态子列共址

AgentLogsBench 的单表约束并不意味着只能有一种内部结构。Doris 的 tablet 里可以同时存在列存 segment、全文倒排和 VARIANT 子列;Tempo 的 block 同时带 Parquet 数据、Trace ID bloom filter 与 row-group index;Quickwit 的 split 也把全文倒排、列式 fast field 和原始文档组织成一个不可变单元。

这些实现选的数据库不同,但底层思路相近:一份数据只保留一套生命周期和一致性边界,旁边可以挂多种为不同谓词服务的索引。

这也是列存与搜索引擎逐渐互相靠近的原因。列存系统开始补 text index,搜索系统开始补列式 doc values、预聚合和更紧凑的日志存储。它们争的不是谁变成谁,而是谁能用更低的写入与空间放大,把更多访问模式留在同一个文件单元里。

共址还有一个容易被忽略的收益:compaction、保留期、删除与租户隔离只需要管理一份主数据。如果全文检索和分析分别落在两套系统里,写入要做双写,延迟与失败状态需要对齐,删除和权限变更也要传播;一次排障还可能遇到搜索侧已经可见、聚合侧尚未可见的时间差。多索引共址没有消除索引成本,但消除了跨系统一致性成本。

它也不是无条件优于拆分。索引构建会竞争摄入 CPU,compaction 会同时重写列数据和索引文件,某种查询的峰值负载可能拖累另一种查询。一个系统挂多种结构,仍然需要资源隔离、后台任务限速和读写分离;只是数据事实与生命周期不再复制两份。

3.3 附件、产物与 Semantic FS

AgentLogsBench 把 input 做到了 1MB,但真实 Agent 消息受模型上下文限制,正文通常没有这么大,直接留在索引层更方便检索和回放。现在需要单独外置的主要是附件和 Agent 产物,例如代码包、报告、数据文件、图片和工具生成的中间结果。

常见做法是把这些内容上传云盘,消息里只保留路径或对象 ID。我更愿意把这层叫作 Semantic FS:底层仍然可以是对象存储,但除了文件本身,还要保留它属于哪个任务和 Trace、由谁生成、谁可以访问,以及如何按语义重新找到它。

这样一来,索引层保存消息正文、Trace 元数据和产物指针,Semantic FS 保存附件与产物。AgentLogsBench 测到了前一部分,没有覆盖后一部分。


四、AgentLogsBench 的结果边界

这是一份由 VeloDB——Apache Doris 背后的公司——发布并自测的 benchmark。它对 workload 的建模很有价值,但具体名次要带着四个前提看。

第一,DDL 与查询翻译本身就是变量。 ClickHouse 的 text index 建在拼接表达式上,查询却使用不感知索引的 position*;70—78 秒反映的是这套写法,而不是所有可能写法。另一方面,测试版本 26.2.13 还没有 26.4 才出现的 hasPhrase 初始支持,因此不能拿后续能力倒推当时一定存在一条等价的精确短语索引路径。

第二,部分查询的工作量并不完全一致。 Q04 在 SQL 引擎中要通过父子 ID 展开调用关系,Elasticsearch 版本只取回同一 Trace 的记录并排序,没有做父子连接。Q13 中 SQL 引擎使用精确去重,Elasticsearch cardinality 是近似聚合;Q19 的 Top-N 形状也不完全相同。这些数字可以比较响应时间,不能假设每个引擎都完成了完全相同的计算。

第三,性能验证没有自动保证语义正确。 PostgreSQL 的 position 上限说明一条查询可以按时结束,却没有正确区分长文档中的短语。mini fixture 的 fingerprint 也覆盖不了 KB—MB 正文上的全部边界。跨引擎 benchmark 应该先逐行比结果,再比较耗时。

第四,版本快照会快速过时。 ClickHouse 的 TextBench 在结构化程度更高、正文更短、查询使用 idiomatic token 函数的 OTel 日志上得出了与本 benchmark 相反的结果;JSONBench 则独立复现了 PostgreSQL 和 DuckDB 在动态 JSON 查询上的明显劣势。两者并不矛盾,日志分析本来就不是一种固定 workload,正文长度、短语语义和查询函数一变,结果就可能翻转。

冷态与热态的定义也要跟着执行协议读。这里的冷查询主要通过清理 Linux page cache 制造存储冷启动,并不等于重启数据库、重建 JVM 或清空引擎内部全部缓存;热态则发生在批量导入结束、数据基本静止之后。前者更接近 page-cache-cold,后者更接近围绕同一批数据反复钻取,两者都不能直接替代持续写入时的线上 p99。

看跨引擎 benchmark 时,我更愿意把证据分成四层:第一层是结果是否逐行等价;第二层是查询计划是否走到各引擎的原生快路径;第三层是读延迟、写入耗时与存储放大是否一起披露;第四层才是综合排名。前面任何一层没有对齐,最后一层的分数都只能用来发现问题,不能直接用来下采购结论。

我会保留它对 workload 的建模,也会参考那些被其他 benchmark 和生产案例重复验证的趋势。至于具体倍数和综合排名,只能看作这个版本、配置与查询实现下的一张快照。

即使把排行榜全部删掉,AgentLogsBench 的核心价值仍然成立:单表约束、KB—MB 正文、hard negative、动态路径与 Trace 回放,确实把 Agent 可观测存储最难兼容的四类访问模式放到了一起。


五、落到 Agent 可观测平台

5.1 只把稳定且高频过滤的字段提升为列

提升列不能只考虑以后是否可能用到。结构稳定,并且已经高频参与过滤、分组或排序的字段才值得提升。过度提升会把 payload 演进变成迁移工程;完全不提升则让每次查询都付出动态解析成本。

落地时可以直接从查询日志反推:统计各 JSON path 在 WHERE、GROUP BY、ORDER BY 中的出现频率,再决定哪些字段进入显式 schema、哪些依赖自动子列化、哪些只保留在稀疏结构中。

5.2 用 hard negative 和超长正文先测正确性

测试数据里必须同时有 permission deniedpermissions deniedguardrail blockedguardrail block,还要把目标词放在超过 100KB 的长文档后半段。只测单个词能否命中,无法验证短语、位置、stemming 和分隔符行为。

正确性用例应该固定成回归集。分词器、索引格式或数据库版本一变,先跑结果对比,再跑性能对比。

5.3 用执行计划确认索引真正参与

不能看到 DDL 里有 INDEX 就认为工作完成。查询函数是否受索引支持、表达式是否完全对齐、大小写归一发生在索引侧还是查询侧、多列拼接是否破坏字段级选择性,都会决定索引能否工作。

上线前至少检查三项:读取行数、读取字节数、命中的 index/granule 数。一个短语查询如果仍然读取整列正文,后面的 tokenizer 调优没有意义。

5.4 时间或服务做主排序,Trace 点查用二级结构补偿

默认把时间、服务或租户放在排序键前缀,让看板和搜索先收窄数据范围;trace_id 使用 bloom filter、keyword 倒排、物化视图或 projection 补点查。只有当系统绝大多数读取都是已知 Trace ID 后取完整 Trace,并且几乎没有多维搜索时,才值得把 trace_id 放在首位。

这个选择要用真实查询比例决定,不能由 Q03 一条延迟决定。

5.5 附件和产物外置,并测三种温度

消息正文通常继续留在索引层,附件和 Agent 产物则进入 Semantic FS,消息里保存对象指针。设计时要同时处理上传失败、对象与消息的可见顺序、删除传播和租户权限,避免正文能看到、附件却不存在,或者拿到对象地址就绕过鉴权。

性能测试则至少分三档:清理操作系统缓存后的冷态、围绕同一数据反复钻取的热态,以及持续摄入和 compaction 同时发生的生产态。第三档通常最接近真实看板延迟,也最容易把静态 benchmark 的热态优势打掉。

验收时如果只留平均查询延迟,基本测不出这种系统的问题。短语召回与误报、动态路径过滤、Trace 回放、看板 p99、摄入吞吐、存储放大和 compaction 积压都要记录,再按真实查询比例加权。否则很容易拿一条最漂亮的查询证明选型正确,真正占用值班时间的慢路径则要等上线后才暴露。

选型要对完整工作负载负责,排行榜里的单项冠军只能说明它赢了那一项。


结语

回到开头那行 observation,它同时是文档、动态 JSON、Trace 节点和指标事实。这就是 Agent 日志和传统日志相比最麻烦的变化。

单一物理结构很难兼顾这四种访问模式。现有系统通常用列存处理聚合,用全文索引处理大文本检索,用 keyword 倒排处理 trace_id 等结构化字段的等值查询,再通过动态子列承接不断变化的 payload;Trace 内部的顺序则交给排序键。全文索引与 keyword 索引底层都可以使用倒排表,只是保存的信息和查询语义不同。消息正文通常仍在索引层,附件和 Agent 产物则通过对象指针关联到 Semantic FS。

还有一个我比较在意的缺口:AgentLogsBench 没有测试时序数据库。这个数据集其实很适合时序数据库,event_time 是天然的时间轴,数据以追加写为主,查询又经常带租户、时间范围和最近窗口;成本、延迟、Token 与成功率本来就是时序聚合,Trace 和大文本则补上了关联与检索需求。它不是一份只能由搜索引擎或 OLAP 接住的数据。

从存储形态看,现在不少存算分离的时序数据库也在使用 Parquet 或类似的列式文件,旁边再挂全文索引、结构化字段的 keyword 倒排和向量索引。JSON 路径一旦被识别并拆成子列,放进这类列式结构并不别扭,真正麻烦的仍然是路径持续增长、类型冲突和索引预算。这样来看,Agent 可观测完全可能成为时序数据库继续往上接的一块市场。

不过 OLAP 与时序数据库做到今天,底层实现已经越来越像了:对象存储、列式文件、compaction、谓词下推、动态列、倒排和缓存,大家都在补同一批能力。两者的差别更多留在针对业务负载的小优化上。比如近时缓存可以改善最近几分钟看板与排障查询,降采样和连续聚合可以减少成本、延迟趋势的扫描量,保留策略与冷热分层也更贴近可观测数据的生命周期。完整对话、工具输出和 Trace 当然不能随便降采样,但它们旁边的指标可以。

所以我现在越来越觉得,系统做到最后,拼的还是对特殊业务负载怎么做 trade-off,以及愿意投入多少人把工程和运营质量堆上去。纯技术层面已经很难再出现一个系统在所有路径上都比另一个系统优秀很多。


资料来源

Benchmark 原始材料

引擎官方文档

生产案例与交叉验证

文中所有性能数字均为 AgentLogsBench 或厂商公开测试,不等同于独立第三方测评;版本、查询写法和数据形状变化后应重新验证。

更多推荐