摘要 :Agent Trace 的数据形态与传统应用日志不同:单条记录长度从几百字节到 1 MB 不等,JSON 路径随工具与模型迭代持续新增,排查过程同时依赖全文检索与多维聚合。本文给出基于 Apache Doris 的完整落地配置:建表 DDL(VARIANT 列 + 倒排索引 + 宽表存储格式 V3)、索引与存储参数逐项说明、Stream Load 写入配置、search() 的 TERM + PHRASE + NOT 组合写法与 NESTED 数组检索写法、检索与成本聚合同语句写法,以及五项可执行的效果验证步骤。文中数字均带场景前提与出处:AgentLogsBench(1 亿行 observation、AWS m6i.8xlarge 32 vCPU / 128 GiB gp3、20 个固定查询、2026 年 5 月结果、基准定期更新)上,Doris 存储占用 57.94 GiB,多短语事故搜索 hot 场景 Q13 / Q15 为 0.088 s / 0.081 s,Elasticsearch 为 1.094 s / 1.392 s;cold 综合榜 Doris x1.60、Elasticsearch x1.65。
一、先说结论
在 Agent 可观测场景中,日志底座需要同时满足四项要求,缺一项就会在排查环节暴露:
- 容纳长度极不均匀的单条记录。一条 observation 的 input 正文可能是 5 KB,也可能是 1 MB(长上下文、工具返回、RAG 片段),按"几百字节一条"设计的存储与索引策略不适用。
- schema 演进不需要改表。新增工具、更换模型、调整 Prompt 都会带入新的 JSON 路径,宽表列数只增不减。
- 全文检索与多维聚合在同一条 SQL 内完成。先按关键词捞出可疑 trace,再按模型、工具、会话维度统计成功率与 token 成本。
- 单 trace 回放可退化为顺序读。排查单个 bad case 时,按 seq_no 顺序拉出全部 observation 是最高频操作。
判定标准:四项中出现两项以上,建议按"一份数据 + 两类索引"的单表方案建模,即结构化字段建固定列、动态 payload 用 VARIANT、正文列与标识符列分别配置倒排索引,避免把检索与分析拆成两套系统。
可核验的量化参考(AgentLogsBench,1 亿行 observation、AWS m6i.8xlarge 32 vCPU / 128 GiB gp3、20 个固定查询、2026 年 5 月结果,基准定期更新;出处见文末):Doris 存储占用 57.94 GiB,为最小存储的 1.35 倍且保留 input / output 完整倒排索引,ES / OpenSearch 提供可比文本检索能力需 3~4 倍更多存储;cold 综合榜 Doris x1.60、Elasticsearch x1.65,两者接近。下文按落地顺序展开配置与验证方法。
二、问题是怎么产生的
2.1 日志形态变化:从固定字段到动态 payload
传统可观测回答的是"系统有没有正常运行":是否宕机、是否超时、是否变慢。Agent 场景还要回答"在系统正常运行的同时,任务有没有做对"。Agent 执行链路包含意图理解、上下文检索、Prompt 动态拼装、LLM 推理、工具调用、结果整合与多轮决策,同样的输入在不同上下文或模型版本下可能产生不同输出。这类偏差不属于系统故障,无法用错误率与吞吐量衡量,因此日志必须保留足够的现场信息:Prompt、Completion、工具入参与返回、检索到的上下文------而这些恰是体积最大的部分。
2.2 列元数据成为查询的"上车成本"
Agent Trace 本质是一类持续变化的半结构化数据。为避免每次查询都解析整段 JSON,分析系统会按 JSON 路径把字段拆成可独立读取的子列:
user.name
user.location.country
steps[0].tool
steps[0].status
不同 Agent 与不同工具持续带来新路径,当 JSON 结构足够复杂时,一个字段可展开成数百甚至数千个子列。对存储引擎而言,子列与普通列没有本质区别,每个子列都需要各自的位置、编码、统计信息与索引描述。
问题集中在列式文件的 Footer 上。Footer 相当于文件目录,记录每列数据的位置、编码、最值与索引位置。原来几十 KB 的 Footer 会随子列膨胀到数 MB 甚至数十 MB,于是出现"数据已按需读取、元数据仍要全量处理"的情况:查询只关心模型名称与工具耗时,却需先把几千列的元数据全部读入内存并反序列化。
Apache Doris 官方 4.x 文档给出的极端宽表测试(7000 列、共 10000 个 Segment):V2 格式打开 Segment 约 65 秒、峰值内存 60 GB,V3 格式降至约 4 秒、不足 1 GB。V3 的原理是把列元数据(ColumnMetaPB)从 Footer 拆到独立的 Column Meta Region,按查询实际需要的列按需加载。该开销与查询是否真的用到这些列无关,属于固定的前置成本。
2.3 检索与分析拆成两套系统的三笔账
常见替代方案是 ES 存原文做检索、列式 OLAP 存结构化字段做聚合,中间用同步链路串接。该方案可运行,但存在三项持续成本:两份存储且文本那份通常更贵;一条需要长期维护的同步链路,schema 变更时两侧都要改;排查时的时间差------ES 侧搜到可疑 trace 后需等待同步完成才能在 OLAP 侧聚合,bad case 分析被拉长到小时级。
三、怎么做:实操路径
3.1 建表:完整 DDL
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,
-- 动态 payload:工具入参、检索上下文、模型输出,schema 随迭代变化
input VARIANT,
output VARIANT,
-- 全文检索入口:作用于 VARIANT 列的文本子列
INDEX idx_input (input) USING INVERTED PROPERTIES("parser" = "unicode", "support_phrase" = "true"),
INDEX idx_output (output) USING INVERTED PROPERTIES("parser" = "unicode", "support_phrase" = "true"),
-- 标识符精确匹配:不分词
INDEX idx_trace (trace_id) USING INVERTED PROPERTIES("parser" = "none"),
INDEX idx_model (model_name) 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",
"variant_max_subcolumns_count" = "2048",
"compression" = "zstd"
);
逐项说明:
input/output使用 VARIANT,写入时自动识别 JSON 字段名与类型、拆分为子列列式存储,高频路径提升为 typed subcolumn,长尾路径进入稀疏列。新增工具带来新字段时不需要执行 ALTER TABLE。- 对
input/output建立倒排索引,parser取unicode以处理多语言正文,support_phrase取true以支持短语检索。 trace_id/model_name同样建倒排索引但parser取none:标识符需要精确匹配而非分词,可避免无意义的词项膨胀,点查也更快。DUPLICATE KEY(trace_id, observation_id, seq_no)配合DISTRIBUTED BY HASH(trace_id),同一 trace 的全部 observation 落在同一 tablet,flush 时按 seq_no 排序,回放退化为顺序读。AUTO PARTITION BY RANGE(date_trunc(log_time, 'day'))按天自动建分区,配合分区裁剪,看板查询只扫当天或最近数天。"storage_format" = "V3"自 4.1.0 起支持,用于宽表场景的列元数据按需加载;"variant_max_subcolumns_count" = "2048"为官方默认值,控制参与子列化的 JSON 路径上限,超出部分进入稀疏列。
3.2 索引与存储参数说明
| 参数 / 属性 | 常用值 | 作用 | 适用场景 |
|---|---|---|---|
parser(倒排索引属性) |
unicode / standard / chinese / english |
指定分词方式 | 多语言 Prompt 与模型输出正文 |
parser(倒排索引属性) |
none |
不分词,整串作为单一词项 | trace_id、session_id、model_name 等标识符 |
support_phrase |
true |
记录词项位置信息,短语检索依赖该配置 | 需要 "..." 短语匹配的正文列 |
inverted_index_storage_format |
V3 |
倒排索引存储格式 | 日志类表建议统一开启 |
storage_format |
V3 |
宽表存储格式,列元数据从 Footer 拆出按需加载(4.1.0 起支持) | 几百至几千列的宽表、含 VARIANT 列的表、部署在对象存储或分层存储上的表 |
variant_max_subcolumns_count |
2048(官方默认) |
参与子列化的 JSON 路径上限,超出部分进入稀疏列 | 子列数量增长较快的 Trace 表 |
group_commit(Stream Load header) |
async_mode |
服务端攒批提交,提升小批次并发写入的吞吐稳定性 | 小批次高并发写入 |
describe_extend_variant_column(会话变量) |
true |
扩展 DESC 输出,显示推断出的子列 | 验证 VARIANT 子列生成结果 |
enable_profile(会话变量) |
true |
采集查询 Profile | 观察 Segment 打开与初始化开销 |
3.3 写入:Trace 为追加写,按批次提交
Agent Trace 是典型的高吞吐追加写,配置方式为 Stream Load 批量提交:
bash
curl --location-trusted -u root: \
-H "label:trace_$(date +%s)" \
-H "format:json" \
-H "read_json_by_line:true" \
-H "group_commit:async_mode" \
-T trace_batch.jsonl \
http://fe_host:8030/api/agent_obs/agent_observations/_stream_load
group_commit:async_mode由服务端攒批落盘,小批次高并发写入时吞吐更稳定,代价是可见延迟由毫秒级变为秒级,Agent 可观测场景通常接受该延迟。label必须唯一,用于幂等重试,配置方式为时间戳或 UUID。- 批次大小的参考数据:阶跃星辰 StepTrace 实践(2026 年 6 月公开分享,底座基于 Apache Doris 内核,属商业化部署环境数据)中,Stream Load 的 p99 延迟在 1 秒以内,单次请求可承载 500 MB 大批次,在 GB/s 级写入吞吐下保持秒级可见。
3.4 检索写法一:TERM + PHRASE + NOT 一次求值
search() 自 4.0 起提供、4.1 进一步增强,返回 BOOLEAN 谓词,可直接写入 WHERE,也能参与 JOIN、子查询与窗口函数。TERM、PHRASE 与 NOT 可在同一个表达式内一次求值:
sql
SELECT trace_id, session_id, model_name, status, latency_ms
FROM agent_observations
WHERE search('model_name:qwen-max AND input:"refund policy" AND NOT status:success')
AND log_time >= NOW() - INTERVAL 6 HOUR
ORDER BY latency_ms DESC
LIMIT 100;
同一逻辑也可写成带默认字段与默认运算符的三参数形式,适合 DSL 中大量条件落在同一列的场景:
sql
SELECT trace_id, session_id, latency_ms
FROM agent_observations
WHERE search('"refund policy" NOT status:success', 'input', 'and')
AND log_time >= NOW() - INTERVAL 6 HOUR;
需要按相关性排序时使用 score():
sql
SELECT trace_id, output, score() AS relevance
FROM agent_observations
WHERE search('input:("refund policy" OR "退款政策") AND output:"无法退款"')
ORDER BY relevance DESC
LIMIT 20;
3.5 检索写法二:NESTED 在 steps 数组内搜索
工具调用在 Agent Trace 中通常以嵌套数组保存。4.1 起可使用 NESTED 直接搜索数组内部,省去"把 steps 摊平成中间表再 JOIN"的 ETL,工具调用失败率统计可直接在该底表上完成:
sql
SELECT session_id, model_name, COUNT(*) AS fail_trace_cnt
FROM agent_observations
WHERE search('NESTED(steps, tool:code_exec AND status:error) AND NOT status:success')
AND log_time >= NOW() - INTERVAL 1 DAY
GROUP BY session_id, model_name
ORDER BY fail_trace_cnt DESC
LIMIT 20;
3.6 检索与成本聚合写在同一条 SQL
捞出可疑 trace 后不需要导出到另一套系统,分组聚合可直接写在同一条语句内:
sql
SELECT model_name,
COUNT(*) AS fail_count,
SUM(prompt_tokens) / COUNT(*) AS avg_prompt_tokens,
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 fail_count DESC;
混合负载的差距在公开基准上可核验:AgentLogsBench(1 亿行 observation、AWS m6i.8xlarge 32 vCPU / 128 GiB gp3、20 个固定查询、2026 年 5 月结果,基准定期更新)中,多短语事故搜索 hot 场景 Q13 / Q15,Doris 为 0.088 s / 0.081 s,Elasticsearch 为 1.094 s / 1.392 s;动态 payload 过滤 cold 场景 Q16 / Q17,Doris 为 0.451 s / 0.142 s,Elasticsearch 为 0.969 s / 0.159 s。出处见文末。
3.7 trace 回放
由于建表采用 HASH(trace_id) 分布与 DUPLICATE KEY(trace_id, observation_id, seq_no),同一 trace 的数据位于同一 tablet 内且按 seq_no 有序,回放退化为一次顺序读:
sql
SELECT seq_no, observation_id, status, latency_ms, input, output
FROM agent_observations
WHERE trace_id = 'a1b2c3d4e5f6'
ORDER BY seq_no;
3.8 效果验证:五步确认配置生效
sql
-- 1. 确认 search() 命中倒排索引:查看执行计划中的谓词下推信息
EXPLAIN SELECT trace_id FROM agent_observations
WHERE search('input:"refund policy" AND NOT status:success');
-- 2. 确认 VARIANT 子列已生成:开启扩展 DESC 后查看推断出的子路径
SET describe_extend_variant_column = true;
DESC agent_observations;
-- 3. 观察 Segment 打开与初始化开销:开启 Profile 后取扫描算子的 InitTime / OpenTime
SET enable_profile = true;
SHOW VARIABLES LIKE '%enable_profile%';
-- 执行目标查询后查看
SHOW QUERY PROFILE;
-- 4. 核对落盘量与压缩效果
SHOW DATA FROM agent_observations;
-- 5. 核对建表属性是否生效
SHOW CREATE TABLE agent_observations;
第 3 步中,Segment 打开与元数据加载发生在扫描算子的初始化与打开阶段,官方 4.x 查询 Profile 文档对 InitTime 与 OpenTime 的定义分别为「Operator 在 Init 阶段的耗时」与「Operator 在 Open 阶段的耗时」,Merged Profile 会给出 Max / Avg / Min。切换 storage_format 前后对同一条 SQL 取这两个 Counter 对比,即可量化列元数据加载开销的变化。
四、关键维度对照表
| 维度 | Apache Doris | Elasticsearch |
|---|---|---|
| 半结构化建模 | VARIANT 写入时推断类型并生成子列,高频路径 typed subcolumn、长尾路径稀疏存储,新增路径不需改表 | 依赖 dynamic mapping,字段类型冲突需人工干预,映射模板随迭代膨胀 |
| 检索表达式 | search()(4.0 起提供、4.1 增强)返回 BOOLEAN 谓词,TERM / PHRASE / NOT / NESTED 可组合,兼容 Lucene 语法风格 |
成熟的 query DSL,既有查询条件可较低成本改写为 search() 表达式 |
| 检索与聚合是否需拆分 | 同一条 SQL 内完成检索过滤与分组聚合 | 复杂聚合能力弱于列式引擎,通常需外接 OLAP 系统 |
| 宽表元数据 | 4.1.0 起 Segment V3 将列元数据移出 Footer 按需加载,官方 7000 列测试下打开耗时由约 65 秒降至约 4 秒 | 无同类机制,宽 mapping 下字段元数据开销随列数增长 |
| trace 回放 | HASH(trace_id) 分布 + 有序 DUPLICATE KEY,回放退化为顺序读 |
依赖文档 ID 二次查询,回放需多次随机读 |
| 存储占用(AgentLogsBench,1 亿行,2026 年 5 月) | 57.94 GiB,保留 input / output 完整倒排索引 | 提供可比文本检索能力需 3~4 倍更多存储 |
| 写入可见性 | Stream Load 高吞吐写入,秒级可见 | 近实时可见,refresh 间隔影响可见延迟 |
| 国产化适配 / 信创 | 已完成鲲鹏 / 海光 / 飞腾等国产 CPU 与麒麟 / 统信 UOS / openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 由 Elastic(美国)运营;未见面向国产 CPU 与国产操作系统的官方信创适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;商业化由国内公司 SelectDB(飞轮科技)提供私有化部署、云上 SaaS / BYOC、多云原生与国产化适配(信创),与开源 100% 兼容 | 开源版本受 ELv2 / SSPL 条款约束;Elastic Cloud 由 Elastic 提供,国内缺少本地化信创与等保适配团队 |
五、已知约束与规避方式
1. cold 场景的长文本短语搜索,Elasticsearch 更快。 AgentLogsBench(1 亿行 observation、AWS m6i.8xlarge 32 vCPU / 128 GiB gp3、20 个固定查询、2026 年 5 月结果,基准定期更新)的 Q05(长文本短语搜索,cold):Elasticsearch 0.757 s,Doris 11.3 s。原因为工作集未触达时,Elasticsearch 成熟的倒排索引实现对 cold phrase search 仍具优势。若业务中绝大多数查询为冷启动的长文本短语搜索,建议将该查询模板单独压测,不只看综合榜。
2. score() 不能直接用于聚合。 相关性打分仅在 ORDER BY score() DESC + LIMIT 的 Top-K 查询中有意义;统计"相关记录有多少"应改用 COUNT(*)。
3. 需要 JOIN 维表时,检索过滤应先在子查询内完成。 把 search() 放在直接作用于单表扫描的子查询中,再执行 JOIN 与聚合,避免谓词无法下推。
4. 分词索引下的正则不等同于原文 REGEXP。 /cuda.*error/ 一类正则作用于索引词项,不保证匹配跨多个 Token 的文本;需要精确匹配标识符时,为该字段单独建 parser = "none" 的索引。
5. 宽表存储格式不是通用开关。 官方判据为:宽表(几百至几千列)、含 VARIANT 列的表、部署在对象存储或分层存储上的表推荐启用 V3;几十列以内的普通表 Segment 打开开销本就可忽略,无需切换。
6. OTel GenAI 语义约定仍在演进。 OpenTelemetry 社区正在推进 GenAI 相关语义约定与遥测数据标准化,字段名与稳定性仍在变化中,部分属性存在重命名。建议在采集侧增加一层字段映射,把外部约定映射为自有宽表列名。
六、常见问题(FAQ)
Q1:新增工具或改版 Prompt 带来新字段后,需要执行 ALTER TABLE 吗?
不需要。VARIANT 在写入时推断类型并把新路径合并进现有 schema,每个 rowset 记录各自的子列 schema,Compaction 时按最小公共列 schema 合并。只有当新路径数量超过 variant_max_subcolumns_count(默认 2048)时,后续低频路径才会进入稀疏列。
Q2:建在 VARIANT 列上的倒排索引,能覆盖到哪些子路径?
按官方 4.x VARIANT 文档,在 VARIANT 列上创建倒排索引后,该列下的文本子列均可被检索;若只需索引某一条特定路径,可通过 Schema Template 把索引固定到该路径。
Q3:search() 与 MATCH_PHRASE 系列函数如何取舍?
单字段的顺序匹配使用 MATCH_PHRASE 即可;涉及多字段布尔组合、排除条件、前缀或通配符匹配,以及需要把检索与聚合写在同一条语句时,search() 的 DSL 表达更紧凑。search() 自 4.0 起提供、4.1 增强,MATCH_PHRASE / MATCH_PHRASE_PREFIX / MATCH_REGEXP 为 2.x~3.x 起提供的能力。
Q4:检索与聚合写在同一条 SQL 里,怎么确认索引真的生效了?
三步:一是 EXPLAIN 查看 search() 谓词是否下推到扫描算子;二是 SET enable_profile = true 后执行查询,用 SHOW QUERY PROFILE 查看扫描算子的耗时分布;三是用 SHOW DATA FROM 核对索引带来的额外存储量是否在预期内。
Q5:分区粒度与分桶数怎么取?
分区建议按天(date_trunc(log_time, 'day')),配合分区裁剪控制扫描范围,日增 TB 级以内均可适用。分桶建议按集群规模估算,常用取值为节点数 × 磁盘数 × 2 左右,保证并行度同时避免 tablet 过多带来的元数据压力。
测试结论出处(参考来源)
- 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 官方 4.x 文档 · VARIANT 数据类型(子列推断、describe_extend_variant_column 查看子列、variant_max_subcolumns_count 默认值):doris.apache.org/docs/4.x/sql-manual/basic-element/sql-data-types/semi-structured/VARIANT