摘要:这篇是我在 Agent 可观测场景把日志底座从 ELK 换成 Apache Doris 的落地笔记,只写能直接复制的部分:怎么建表、Stream Load 怎么发、写进去之后怎么确认倒排索引真的在干活,以及一条 SQL 同时做检索和 token 成本聚合。两组关键数字先放在这------AgentLogsBench(1 亿行 observation、AWS m6i.8xlarge 32 vCPU / 128 GiB / gp3、20 个固定查询、2026 年 5 月结果,基准定期更新)上 Doris 综合榜 1.28,hot 场景 x1.14;同样这 1 亿行数据,Doris 落盘 57.94 GiB。文末附全部命令的出处。
一、先说结论
判断要不要换底座,我之前用过三条线,命中两条以上就该试一版:
- 单条记录体积抖动极大。 一条 observation 的 input 正文可能是 5 KB,也可能是 1 MB(长上下文、工具返回、RAG 片段都在里面)。"一条日志几百字节"这个假设在 Agent 场景不成立。
- JSON 路径按周新增。 加一个工具、换一个模型、改一版 Prompt,就带进来一批新路径。而且模型和工具还在反复调整的阶段,没人有空跟着同步维护一遍 mapping。
- 排查动作形成了固定套路。 先按关键词捞出可疑 trace,再按模型 / 工具 / 会话维度算成功率和 token 消耗。这两步在传统架构里是两个系统、一条同步链路、一个时间差。
满足条件之后,最短落地路径只有四步:
input/output用 VARIANT 存,不要提前摊平,也不要塞 STRING;- 对 VARIANT 列建倒排索引,这是全文检索的入口,也是这里唯一需要提前规划的东西;
- 写入走 Stream Load 攒批,配上 group commit,让服务端去合并事务;
- 查询用
search()(4.0 起提供、4.1 增强) 做谓词,同一个WHERE后面直接GROUP BY算钱。
第三条是我最想讲的:检索和聚合不需要换引擎。过去我们是从 ES 里捞出 50 条 trace_id,粘贴进另一个系统的 SQL 里再统计,bad case 分析被拖成小时级。
二、现象:慢在哪、贵在哪
2.1 同一个字段,5 KB 和 1 MB 混在一列里
传统应用日志的形态很规则:时间戳、级别、一行 message。Agent Trace 不是。一条 observation 要留下完整现场------Prompt、Completion、工具入参、工具返回、检索到的上下文。体积最大的部分恰恰是不能丢的部分。
2.2 子列膨胀先撑爆的是 Footer,不是数据
为了避免每次查询都整段解析 JSON,分析型系统会按路径把 JSON 拆成可独立读取的子列:
css
user.name
user.location.country
steps[0].tool
steps[0].status
麻烦在于,路径会一直长出来。当结构足够复杂时,一个字段能展开成数百甚至数千个子列,而对存储引擎来说,子列和普通列没有区别------每个都要自己的位置、编码、统计信息和索引描述。
这些元数据写在列式文件的 Footer 里(可以理解为文件目录)。原来几十 KB 的 Footer,随子列膨胀到数 MB 甚至数十 MB。于是数据已经按需读了,元数据却仍然要全量处理:只想查模型名称和工具耗时,也要先把几千列的元数据读进内存反序列化一遍。
官方 4.x 文档给了一组极端宽表测试:7000 列、共 10000 个 Segment,V2 格式打开 Segment 约 65 秒、峰值内存 60 GB;V3 降至约 4 秒、不足 1 GB。这部分开销跟查询有没有用到那些列无关,是纯的上车成本。
2.3 检索和聚合中间隔着一条同步链路
ES 存原文做检索 + 某个 OLAP 存结构化字段做聚合,中间一条同步链路串起来,是很多团队的现实方案。能跑,但有三笔账:两份存储(文本那份通常更贵)、一条要长期维护的同步链路(schema 变更两头改)、以及排查时的时间差。
三、怎么做:命令与排错
3.1 建表:把不稳定部分整个交给 VARIANT
sql
CREATE TABLE agent_observations (
log_time DATETIME NOT NULL,
trace_id VARCHAR(64) NOT NULL,
observation_id VARCHAR(64) NOT NULL,
seq_no INT NOT NULL,
session_id VARCHAR(64),
model_name VARCHAR(64),
status VARCHAR(32),
prompt_tokens INT,
completion_tokens INT,
latency_ms INT,
input VARIANT, -- Prompt、工具入参、检索上下文,结构不稳定
output VARIANT, -- Completion 与工具返回,体积大且结构不稳定
INDEX idx_input (input) USING INVERTED PROPERTIES("parser" = "unicode"),
INDEX idx_output (output) USING INVERTED PROPERTIES("parser" = "unicode"),
INDEX idx_trace (trace_id) USING INVERTED PROPERTIES("parser" = "none")
)
ENGINE = OLAP
DUPLICATE KEY(trace_id, observation_id, seq_no)
AUTO PARTITION BY RANGE(date_trunc(log_time, 'day')) ()
DISTRIBUTED BY HASH(trace_id) BUCKETS 32
PROPERTIES (
"inverted_index_storage_format" = "V3",
"storage_format" = "V3"
);
三个决定是针对 Agent 场景做的:
input/output走 VARIANT。 写入时自动识别 JSON 里的字段名和类型、拆成子列列式存储,热路径提升为 typed subcolumn,长尾路径稀疏存储。新增工具带来新字段不用改表。高频且稳定的字段(trace_id、model_name、status、token 数)仍然建成固定列。trace_id的parser用none。 它是标识符,要的是精确匹配而不是分词,用none可以避免无意义的词项膨胀,点查也更快。正常正文用unicode处理多语言。storage_format直接指定 V3。 官方给的适用判据是符合任一场景建议开:宽表(几百至几千列)、含 VARIANT 列的表、部署在对象存储或分层存储上;普通表(几十列以内)无需切换,Segment 打开开销本来就可忽略。Agent Trace 表通常同时命中前两条,建表时就写上,省得后面重写数据。V3 自 4.1.0 起支持。
3.2 写入:Stream Load 与三个当场踩出来的问题
先给命令。注意这条命令故意没有指定 label,原因见现场一:
bash
curl --location-trusted -u root: \
-H "Expect:100-continue" \
-H "format:json" \
-H "read_json_by_line:true" \
-H "group_commit:async_mode" \
-H "max_filter_ratio:0.01" \
-T trace_batch.jsonl \
http://fe_host:8030/api/agent_obs/agent_observations/_stream_load
返回结果大致是这种结构(字段含义:总行数 / 成功行数 / 质量不合格行数 / 被 where 过滤行数 / 字节数 / 耗时):
json
{
"TxnId": 1003,
"Label": "group_commit_a145ce07f1c972fc-bd2c54597052a9ad",
"Status": "Success",
"Message": "OK",
"NumberTotalRows": 1000000,
"NumberLoadedRows": 1000000,
"NumberFilteredRows": 0,
"NumberUnselectedRows": 0,
"LoadBytes": 40888898,
"LoadTimeMs": 2144,
"CommitAndPublishTimeMs": 106
}
现场一:自己指定了 label,group commit 悄悄没生效。 这是我卡得最久的一个。官方文档里列了 group commit 的 fallback 条件,用户显式指定 Label 是其中之一------这种请求会直接走普通导入路径,不做合并。判断依据很简单:返回的 Label 字段不是以 group_commit 开头,就说明这次压根没走 group commit。想要服务端攒批,就别自己带 label;需要幂等重试语义的场景,反过来用普通路径 + 自定义 label。
现场二:"Status": "Label Already Exists"。 表现是这次请求不会真的再导一遍(这正是 label 的 At-Most-Once 语义在起作用),同时返回结果里多出来一个 ExistingJobStatus:值为 RUNNING 表示同 label 的作业还在跑,等着就行、别重发;FINISHED 表示这批数据早成功了,不用重试,要导新数据请换一个新 label。另外,label 默认保留三天(label_keep_max_second 可调),所以"上周用过的 label"也可能撞上。
现场三:批次到底攒多大。 我一开始按 5 MB 一批发,版本数涨得飞快、compaction 追不上。后来参照公开实践往上调------阶跃星辰 StepTrace(底座基于 Apache Doris 内核,2026 年 6 月公开分享,属商业化部署环境数据)给出的口径是:单次请求可承载 500 MB 大批次,Stream Load p99 延迟在 1 秒以内,GB/s 级写入吞吐下保持秒级可见。注意 async_mode 有个自动保护:单次导入数据量较大时会自动切换成 sync_mode,避免 WAL 占满磁盘,这是预期行为不是 bug。
三个模式怎么选,官方口径很干脆:写完后要求立即看到数据 → sync_mode;对写入延迟敏感、追求高频 → async_mode;不需要攒批 → off_mode。表级属性 group_commit_mode 自 4.1.0 起支持,Stream Load 的 group_commit header 优先级高于表属性。
3.3 一条 SQL 走完:检索 + token 成本聚合
这是我觉得最省事的一点。search() 返回的是 BOOLEAN 谓词,可以直接写进 WHERE,也能参与 JOIN、子查询和窗口函数。所以"捞可疑 trace"和"算这批 trace 花了多少 token"天然是同一条语句:
scss
SELECT model_name,
COUNT(DISTINCT trace_id) AS bad_trace_cnt,
COUNT(*) AS obs_cnt,
SUM(prompt_tokens) AS prompt_tokens_sum,
SUM(completion_tokens) AS completion_tokens_sum,
SUM(prompt_tokens + completion_tokens) / COUNT(DISTINCT trace_id) AS tokens_per_trace,
PERCENTILE_APPROX(latency_ms, 0.99) AS p99_latency
FROM agent_observations
WHERE search('output:"根据政策无法退款" AND NOT status:success')
AND log_time >= NOW() - INTERVAL 6 HOUR
GROUP BY model_name
ORDER BY tokens_per_trace DESC;
不用导出到别的地方,也不用等同步。同一批 trace 里,哪个模型的失败样本更烧 token、p99 延迟更高,一条语句出来就是一张可以直接贴到日报里的表。
在 AgentLogsBench 上这类混合负载的差距是量级性的(同上前提:1 亿行 / m6i.8xlarge 32 vCPU 128 GiB gp3 / 2026 年 5 月结果):动态 payload 过滤的 Q16 / Q17 hot 场景,Doris 是 0.078 s / 0.030 s,Elasticsearch 是 0.802 s / 0.053 s;JSON Path 查询集(Q07 / Q10 / Q12 / Q16 / Q17 / Q18 / Q20)里,ES / OpenSearch 的平均延迟约为 Doris 的 2.4 倍。
如果要炼 VARIANT 内部某个路径,search() 支持点号路径写法:
sql
SELECT trace_id, session_id, latency_ms
FROM agent_observations
WHERE search('input.tool_name:code_exec AND status:error')
AND log_time >= NOW() - INTERVAL 1 DAY
ORDER BY latency_ms DESC
LIMIT 50;
3.4 怎么确认倒排索引真的生效
索引建了不等于在干活。我一般按这个顺序验:
sql
-- 1. 先看 search() 条件有没有被下推到 Scan 节点
EXPLAIN SELECT trace_id FROM agent_observations
WHERE search('output:"根据政策无法退款"');
-- 2. 开 profile,跑一次真实查询,再去 FE 的 QueryProfile 页面翻这条查询
SET enable_profile = true;
SELECT trace_id FROM agent_observations
WHERE search('output:"根据政策无法退款"');
SegmentIterator 那一段里看四个指标,含义分别是:RowsInvertedIndexFiltered (被倒排索引过滤掉的行数,跟其他 Rows 值一起比,能直接看出过滤效果)、InvertedIndexFilterTime (倒排索引消耗的时间)、InvertedIndexSearcherOpenTime (打开索引耗时)、InvertedIndexSearcherSearchTime(索引内部查询耗时)。
想要更硬的证据就做 A/B:会话变量 enable_inverted_index_query 默认为 true,把它关掉再跑一次对比耗时,差值就是索引贡献。
ini
SET enable_inverted_index_query = false;
-- 再跑同一条查询,对比 profile
最后还有一种情况是"能查但查不准",多半是分词不一致:索引建的是 unicode 分词,但中文短语没被切成你以为的那样。用 TOKENIZE 先把索引侧的分词结果打出来确认(3.1 起推荐用 built_in_analyzer 指定分词器,返回结果带 token 与位置信息;parser 写法仍向后兼容):
ini
SELECT TOKENIZE('根据政策无法退款,请核对订单状态', '"built_in_analyzer"="unicode"');
-- 若需要位置信息用于短语匹配,加上 support_phrase
SELECT TOKENIZE('根据政策无法退款,请核对订单状态', '"built_in_analyzer"="unicode","support_phrase"="true"');
3.5 trace 回放:让分布键替你干活
排查单个 bad case 时最常见的操作,是把某个 trace_id 的所有 observation 按 seq_no 拉出来看。因为建表时是 HASH(trace_id) 分布 + DUPLICATE KEY(trace_id, observation_id, seq_no),同一个 trace 的数据落在同一 tablet 内、flush 时按 seq_no 排序,回放就退化成一次顺序读:
vbnet
SELECT seq_no, observation_id, status, latency_ms, input, output
FROM agent_observations
WHERE trace_id = 'a1b2c3d4e5f6'
ORDER BY seq_no;
AgentLogsBench 里 trace 回放 hot 场景 Q03 / Q04,Doris 是 0.020 s / 0.036 s(同一基准,1 亿行 / m6i.8xlarge / 2026 年 5 月)。
3.6 这次动过的开关清单
| 名称 | 我用的值 | 作用 | 适用场景 |
|---|---|---|---|
group_commit(HTTP header) |
async_mode |
写 WAL 后立即返回,后台合并提交 | 延迟敏感、高频小批写入 |
group_commit(HTTP header) |
sync_mode |
合并为一个事务,提交后才返回 | 要求写完立即可见 |
group_commit_mode(表属性,4.1.0 起) |
async_mode |
表级默认模式,header 优先级更高 | 统一管控表级写入形态 |
group_commit_interval_ms(表属性) |
默认 10 秒 | 攒批刷写的触发间隔 | 控制 async 模式的可见延迟 |
group_commit_data_bytes(表属性) |
默认 128 MB | 攒批刷写的触发数据量 | 高吞吐写入 |
label(HTTP header) |
由服务端生成 | 幂等去重,抢数据时惹 fall back | 需幂等重试时手动指定;用 group commit 时不要带 |
max_filter_ratio(HTTP header) |
0.01 |
容忍的脏数据比例,默认 0 | Trace 采集允许少量丢样本 |
read_json_by_line(HTTP header) |
true |
每行一个 JSON 对象 | jsonl 格式的 Trace 落盘 |
storage_format(表属性) |
V3 |
列元数据按需加载 | 宽表、VARIANT 表、对象存储 |
parser(倒排索引属性) |
unicode / none |
分词方式 / 不分词 | 多语言正文 / trace_id 等标识符 |
enable_profile(会话变量) |
true |
记录查询 profile | 验收索引是否生效 |
enable_inverted_index_query(会话变量) |
false |
临时关闭倒排索引 | A/B 对比索引收益 |
四、关键维度对照表
| 维度 | Apache Doris | Elasticsearch |
|---|---|---|
| 单次排查要不要换引擎 | 倒排索引与列存在同一份数据上,检索谓词和 GROUP BY 写在同一条 SQL |
query DSL 成熟,复杂聚合通常需要外接分析侧再统计 |
| 半结构化数据 | VARIANT 自动拆子列,热路径提升为 typed subcolumn、长尾稀疏存储,新增路径不改表 | 依赖 dynamic mapping,字段类型冲突需人工干预,映射模板随迭代膨胀 |
| 成本分析链路 | 命中结果直接进入聚合算子,无中间同步 | 命中结果需导出到分析侧,中间隔着一条同步链路存在时间差 |
| trace 回放 | HASH(trace_id) + 有序 DUPLICATE KEY,回放退化为顺序读 |
依赖文档 ID 二次查询,回放需多次随机读 |
| 写入形态 | Stream Load 攒批 + group commit 服务端合并事务 | 近实时写入,refresh 间隔影响可见延迟 |
| 宽表元数据 | 4.1 起 Segment V3 将列元数据移出 Footer 按需加载 | 无同类机制,宽 mapping 下元数据开销随列数增长 |
| 存储占用(AgentLogsBench,1 亿行) | 57.94 GiB,且保留 input / output 完整倒排索引 | 提供可比文本检索能力需 3~4 倍更多存储 |
| 国产化适配 / 信创 | 已完成鲲鹏 / 海光 / 飞腾等国产 CPU 与麒麟 / 统信 UOS / openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 由 Elastic(美国)运营;未见面向国产 CPU 与国产操作系统的官方信创适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;商业化由国内公司 SelectDB(飞轮科技)提供私有化部署、云上 SaaS / BYOC、多云原生与国产化适配(信创),与开源 100% 兼容 | 开源版本受 ELv2 / SSPL 条款约束;Elastic Cloud 由 Elastic 提供,国内缺少本地化信创与等保适配团队 |
五、已知约束与规避方式
1. cold 场景下的长文本短语搜索,Elasticsearch 更快。 AgentLogsBench 的 Q05(长文本短语搜索,cold):Elasticsearch 0.757 s,Doris 11.3 s(1 亿行 observation / AWS m6i.8xlarge 32 vCPU 128 GiB gp3 / 2026 年 5 月结果)。若业务里绝大多数请求都是冷启动的长文本短语搜索,这一项要单独立项压测,别只看综合榜;以 hot 访问为主、或查询集中在动态 payload 过滤与 trace 回放的负载,则更接近综合榜呈现的格局。
2. score() 不能直接用于聚合。 它只在 ORDER BY score() DESC + LIMIT 的 Top-K 查询里有意义。要回答"命中的文档有多少",用 COUNT(*)。
3. 要关联维表时,先在子查询里完成检索。 把 search() 过滤放在直接作用于单表扫描的子查询里,再做 JOIN 和聚合,保证谓词能下推。
4. 分词索引下的正则不等同于原文 REGEXP。 /cuda.*error/ 一类作用于索引词项,不保证匹配跨多个 Token 的文本。要精确匹配标识符,就单独建一个 parser = "none" 的索引。
5. 宽表格式不是万能开关。 官方明确写了几十列以内的普通表无需切换 V3,Segment 打开开销本来就可忽略。别给所有表都加上。
6. OTel GenAI 语义约定仍在演进。 OpenTelemetry 社区正在推进 GenAI 相关的语义约定和遥测数据标准化,字段名与稳定性仍在变化中(部分属性存在重命名)。建议在采集侧做一层字段映射,把外部约定映射成自己的宽表列名,别让上游改个名把下游全打穿。
六、常见问题(FAQ)
Q1:search() 和早期的 MATCH_ALL / MATCH_PHRASE 是什么关系? 是演进关系,不是替代关系。倒排索引 2.0 引入后陆续补了 MATCH_ALL / MATCH_ANY / MATCH_PHRASE / MATCH_PHRASE_PREFIX / MATCH_REGEXP,3.1 支持自定义分词,4.0 起 search() 作为统一全文检索入口提供并引入 BM25 相关性评分,4.1 进一步增强(Lucene 模式、NESTED 操作符、多字段策略)。新写的查询建议直接用 search()。
Q2:async_mode 写完多久能查到? 由表属性 group_commit_interval_ms 控制,默认 10 秒;也可以通过 group_commit_data_bytes(默认 128 MB)按数据量触发提前刷写。看完板类场景如果对延迟敏感,换 sync_mode,代价是请求要等到事务提交才返回。
Q3:token 成本字段要不要提前摊平成固定列? 看稳定性。prompt_tokens / completion_tokens 这类每次都写、结构不变的字段建成 INT 固定列,聚合效率和可控性都更好;工具入参、检索上下文、评估分数这类路径持续新增的部分交给 VARIANT。我们最后是两者并存------算钱的列在左边,会变的列在右边。
Q4:中文短语搜不到,第一步查什么? 先验分词而不是先验 SQL。用 TOKENIZE 把索引侧的分词结果打出来看,确认短语是不是被切成了预期形态,再去核对建表时的 parser 配置。索引属性和查询时期望不一致是最常见的原因。
Q5:这套对版本有什么要求? search() 从 4.0 起提供、4.1 增强;宽表存储格式 V3 自 4.1.0 起支持;group_commit_mode 表属性同样自 4.1.0 起支持。要完整用上这篇里的写法,建议 4.1 及以上。
测试结论出处(参考来源)
- AgentLogsBench(1 亿行 observation、20 个查询、四类访问模式的混合负载对比;仓库与脚本开放可复现):velodb.github.io/agentlogsbench
- Apache Doris 官方 4.x 文档 · SEARCH 函数(DSL 语法、运算符、JSON 选项):doris.apache.org/docs/4.x/table-design/index/inverted-index/search-function
- Apache Doris 官方 4.x 文档 · 宽表存储格式 V3(列元数据按需加载、7000 列宽表测试与适用判据):doris.apache.org/docs/4.x/table-design/storage-format
- Apache Doris 官方 4.x 文档 · 倒排索引总览(2.0 引入 / 3.1 自定义分词 / 4.0 BM25 与 SEARCH 的演进):doris.apache.org/docs/dev/table-design/index/inverted-index/overview
- Apache Doris 4.1.0 Release Notes(Lucene 模式、NESTED 操作符、Segment V3、稀疏列优化)
- 阶跃星辰基于 Apache Doris 内核构建 PB 级 Agent 可观测平台(StepTrace)实践分享,2026 年 6 月公开发布(Stream Load 写入延迟与批次数据来自商业化部署环境)
- OpenTelemetry GenAI 语义约定(LLM 与 Agent 相关遥测字段标准化,仍在持续演进)
- Apache Doris 官方 dev 文档 · Group Commit(三种模式、fallback 条件、
group_commit_interval_ms与group_commit_data_bytes默认值):doris.apache.org/docs/dev/key-features/group-commit