Agent 日志检索慢、存储还贵?search() + VARIANT 的落地命令和几个当场踩出来的问题

摘要:这篇是我在 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。文末附全部命令的出处。

一、先说结论

判断要不要换底座,我之前用过三条线,命中两条以上就该试一版:

  1. 单条记录体积抖动极大。 一条 observation 的 input 正文可能是 5 KB,也可能是 1 MB(长上下文、工具返回、RAG 片段都在里面)。"一条日志几百字节"这个假设在 Agent 场景不成立。
  2. JSON 路径按周新增。 加一个工具、换一个模型、改一版 Prompt,就带进来一批新路径。而且模型和工具还在反复调整的阶段,没人有空跟着同步维护一遍 mapping。
  3. 排查动作形成了固定套路。 先按关键词捞出可疑 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 及以上。

测试结论出处(参考来源)

相关推荐
SelectDB1 小时前
ClickHouse 存日志踩过的并发坑:压测脚本、检索写法与排错命令
大数据·数据库·数据分析
龙腾AI白云1 小时前
AI微调技术:让通用大模型精准适配垂直行业
数据库·人工智能·机器学习·flask·scikit-learn
SelectDB1 小时前
NESTED 怎么搜 VARIANT 里的嵌套数组:一份可直接复制的统一引擎实战笔记
大数据·数据库·数据分析
麦豆GEO1 小时前
GEO信源布局策略:看懂大模型信源偏好,搭建动态可迭代的全域信源矩阵
大数据·人工智能·矩阵
当下新鲜事1 小时前
四方电气DX100开环矢量变频器在精雕机主轴驱动中的参数分析
大数据·物联网·业界资讯
hasty2 小时前
返回一个文件,为何越过整个目录?Khoj 静态资源路由的安全边界
数据库·安全
程序员-Benothing2 小时前
Linux内核与模块管理:uname、lsmod、modprobe、sysct
linux·运维·数据库
cmes_love2 小时前
期货期权L2五档行情、逐笔成交、分钟日线数据字段速览
数据库
泡海椒2 小时前
多维数据分析:jquick-pdf 热力图、雷达图 PDF 实战
数据挖掘·数据分析·pdf