ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Agent日志存储实战:VARIANT+search()实现秒级全文检索与成本优化

Agent日志存储实战:VARIANT+search()实现秒级全文检索与成本优化

1. Agent 日志为什么会从"小事"变成"事故"

1.1 一组真实的 Agent 日志:JSON 是一棵不断长高的树

很多团队最初对 Agent 日志的态度和我一样:先打个文件,能看就行。直到有一天线上出问题,你需要在几十个节点、上百个文件里找一条链路的完整上下文,才意识到事情不对。

AI Agent 和传统服务日志最大的区别在于:传统日志是扁平的 key-value,一行一事件;Agent 日志是一棵深度嵌套的 JSON 树,而且字段结构极度不稳定。

我的环境里一次由 XXL-Job 定时触发的 Agent 任务,单个节点输出的日志大致长这样:

{ "trace_id": "trc_8d2e91fa", "session_id": "sess_20250321_001", "agent_name": "service_order_agent", "task_type": "scheduled", "ts": "2025-03-21 10:00:01.123", "step": 3, "node": "tool_executor", "input": { "plan_id": "plan_9xc2", "intent": "查询订单状态并通知客户" }, "output": { "reasoning": "需要先调用订单查询接口,拿到状态后再组装通知文案", "action": "call_tool", "payload": { "tool": "order_query", "request": "order_no=SO20250321001", "results": [ {"order_no": "SO20250321001", "status": "shipped", "eta": "2025-03-23"}, {"order_no": "SO20250321002", "status": "pending", "eta": null} ] } }, "llm": { "model": "gpt-4o", "prompt_tokens": 1280, "completion_tokens": 312, "latency_ms": 2345 }, "meta": { "retry_count": 1, "trigger": "xxljob://job_108", "cost_usd": 0.0042 } }

注意几个特点:第一,results是数组,长度不定;第二,eta有时是字符串、有时是 null,retry_count也不是每个节点都有;第三,不同节点(planner、tool_executor、memory_reader)输出的 JSON 结构完全不同。这种数据,用传统关系模型建模会非常痛苦——你今天不知道明天会有哪个新字段冒出来。

1.2 "慢"和"贵"的三个直接原因

我接手的时候,线上是三种方案混用的状态:

现状方案慢的原因贵的表现
文本文件按天归档检索靠 grep 遍历全量文件,一条链路日志跨多个文件,人工拼磁盘占用持续增长,热备冗余一倍
MySQL 的 JSON 字段无法对嵌套字段高效过滤,LIKE '%...%'全表扫描,几千万行开始卡单表膨胀后需要分库分表,运维成本高
Elasticsearch 全文索引冷热不分离,所有字段默认索引,写放大明显存储膨胀 3 倍左右,内存吃紧

那个阶段最疼的是两个场景:

第一个是查"某次 Agent 任务为什么反复调用某个工具"。这类排查往往要输入一个 tool 名称、一段报错关键字,或者一个 token 数字,然后按trace_id把整条链路拼出来。用文件做,半小时起步。

第二个是存储账单。Agent 跑起来的日志量远大于普通接口日志:一次对话可能触发 10 次工具调用、每次调用都带完整的入参出参。一天下来就是几十 GB 的 JSON。文本文件不压缩、无索引,又贵又慢。

当时我给自己定了两条硬性标准:检索单条链路要秒级返回,存储成本相比文本文件至少要降一半。顺着这个目标,我把目光放到了VARIANT类型和search()全文检索函数上。

2. 为什么我盯上了 search() + VARIANT 这套组合

2.1 VARIANT 解决"存得下":不建表也能存任意 JSON

VARIANT是一种半结构化列类型,简单理解就是:这一列可以直接灌任意 JSON 对象,不需要预先定义每个子字段。

它最香的地方在于"先存后解析"。Agent 的日志天然就是 JSON,而且 schema 每天都在变(加一个字段、改一层嵌套是常态)。如果用传统方式,每次字段变化都要改表结构、更新写入逻辑,开发节奏完全被拖死。用VARIANT之后,写入端只需要把原始日志整包丢进去,剩下的事情留给查询端按需提取。

它和 MySQL JSON 最大的不同在于,VARIANT在存储层对字段做了类型感知的编码,后续你可以把里面的子字段"上提"成虚拟列用于过滤,而不是每次查都全量解析一遍。这意味着它能同时吃到"半结构化灵活存储"和"结构化高效过滤"两头的红利。

那为什么不直接用纯对象存储(比如存 JSON 文件到 S3/OSS)?因为对象存储存储成本确实低,但检索能力基本为零。你要在一个月的数据里按一个关键字找到对应链路,要么把文件拉下来扫,要么得借助额外组件,绕一圈又回到了老问题上。

2.2 search() 解决"查得快":它不是 LIKE 的替代品,是另一套机制

search()是一个全文检索函数,它的核心机制是配合倒排索引进行词项匹配,而不是像LIKE '%keyword%'那样逐行扫描子串。这两者的性能差距在千万行日志上是数量级的。

我画过一张对比表给团队看:

检索方式实现机制千万行量级平均耗时能否支持复杂嵌套
LIKE '%xxx%'全表扫描逐行匹配秒级到分钟级只能匹配字符串文本
search()+ 倒排索引索引词项定位毫秒级能对 VARIANT 内容做全文匹配
组合过滤(ts + agent_name + search)分区裁剪 + 索引毫秒级推荐用法

实际用下来,search()的典型价值场景有两个:

  • 不知道日志具体长什么样,但记得里面有某个关键词(比如rate_limit、某个报错文案),直接用search()就能捞回来。
  • 想在完整 JSON 内容里全文匹配,而不是精确匹配某个字段。比如search(raw_log, 'order_query')能命中任何一层出现过该词的内容。

不过这里有个很容易踩的坑:search()默认匹配的是整段文本内容,也就是说它既可能命中值,也可能命中字段名,甚至会命中数组里的元素。后面我会专门讲这个问题。

2.3 为什么不选 Elasticsearch,也不选 ClickHouse

选型时我把 Elasticsearch 和 ClickHouse 都试了一圈,最后放弃各有理由。

Elasticsearch 的问题在于索引爆炸。Agent 日志字段多且杂,ES 默认会对所有字段建索引,导致存储膨胀非常严重。我试过只给特定字段建 mapping 的方案,但 Agent 的日志字段又实在太不稳定,今天 mapping 里的字段明天可能就不出现了,维护成本极高。另外,Agent 日志的价值密度其实很低——大量中间过程数据只在排查时期有用,用 ES 为它们维护完整索引,性价比太差。

ClickHouse 的问题在于JSON 处理链条太长。它的 JSON 支持不弱,但要用得好,你得把经常查询的子字段提前物化成列,写一堆 DDL 和物化视图。Agent 的日志结构变动频繁,每改一次都要同步改物化逻辑,开发负担很重。而且它的全文检索能力并不突出,我不可能每次查询都把这个 JSON 列转成字符串再 LIKE 一遍。

VARIANT + search()正好卡在两者中间:存储上吸收了 JSON 的灵活性,不需要预定义 schema;查询上借倒排索引拿到了全文检索能力,又允许你用常规列(比如ts、agent_name)做精确过滤。运维上只有一个存储系统,不用再为了日志单独养一套 ES 集群。

3. 落地命令:从建表、导流到检索的完整一套

3.1 建表:分区、分桶、VARIANT 列怎么设计

先说明一下,下面的命令是基于我实际使用的这套存储引擎(Doris/StarRocks 系,支持VARIANT的版本)写的,不同版本在语法细节上可能有差异,但设计思路是通用的。

我的建表语句长这样:

CREATE TABLE agent_task_logs ( log_id BIGINT NOT NULL COMMENT '日志ID,雪花或自增', trace_id VARCHAR(128) COMMENT '一次Agent任务的链路ID', session_id VARCHAR(128) COMMENT '会话ID', agent_name VARCHAR(128) COMMENT 'Agent标识', task_type VARCHAR(64) COMMENT '任务类型:scheduled/online', ts DATETIME NOT NULL COMMENT '日志时间', raw_log VARIANT COMMENT '完整Agent日志JSON' ) DUPLICATE KEY(log_id) PARTITION BY RANGE(ts) ( START("2025-01-01") END("2026-01-01") EVERY(INTERVAL 1 DAY) ) DISTRIBUTED BY HASH(trace_id) BUCKETS 12 PROPERTIES ( "replication_num" = "1", "compression" = "zstd", "storage_medium" = "SSD" );

几个设计决策我展开说一下:

分区用天,不要用小时。Agent 日志的查询大部分是回溯式的——"昨天下午那次异常任务",按天分区可以在查询时直接从分区级别裁剪掉大部分数据。如果按小时分区,分区数量会非常大,管理成本高,而且查询如果没有精确到小时,反而会多扫很多分区。数据量如果大到一天都扛不住,再考虑动态分区到 6 小时。

分桶键用trace_id,不要用log_id。因为最常见的操作是"按一次任务的链路把所有日志捞出来",同一trace_id的日志会被 hash 到同一个桶,查询时可以避免跨节点的数据合并。而log_id是随机增长的,按它分桶会把同一条链路的日志打散到所有节点上。

复制副本数设 1。日志数据可以接受丢失一部分,没必要像业务数据那样搞三副本。如果公司有跨机房容灾要求再另说。这个配置直接关系到存储成本,能省就省。

3.2 写入:Stream Load / INSERT 两种方式的命令

日常写入我用的是 Stream Load,从 Kafka 实时消费 Agent 日志,直接灌进表里。命令大致是:

curl --location-trusted -u admin:admin \ -H "format: json" \ -H "columns: log_id, trace_id, session_id, agent_name, task_type, ts, raw_log" \ -T agent_log.json \ http://fe_server:8030/api/your_db/agent_task_logs/_stream_load

注意columns里的字段顺序要和 JSON 对象的 key 对应上。如果数据源里的 JSON key 名和表列名不一致,需要通过jsonpaths参数做映射:

curl --location-trusted -u admin:admin \ -H "format: json" \ -H "jsonpaths: [\"$.log_id\", \"$.trace_id\", \"$.session_id\", \"$.agent_name\", \"$.task_type\", \"$.ts\", \"$.raw_log\"]" \ -H "columns: log_id, trace_id, session_id, agent_name, task_type, ts, raw_log" \ -T agent_log.json \ http://fe_server:8030/api/your_db/agent_task_logs/_stream_load

回填历史日志时,我习惯用 Python 脚本逐批提交。这里有个经验:json.dumps时要把ensure_ascii设为False,否则中文会被转成\uXXXX,存进去之后search()搜中文关键词会匹配不到(因为索引里存的是转义后的内容)。这个坑我后面也踩到了。

3.3 建索引:全文索引和位图索引的创建命令

建完表先别急着用search(),不建索引的话它就是全表扫描。我建了两个索引:

-- 全文索引:支撑 search() 对 VARIANT 内容做全文检索 CREATE INDEX idx_log_fulltext ON agent_task_logs (raw_log) USING INVERTED; -- 位图索引:加速 trace_id / session_id 这类低基数字段的精确匹配 CREATE INDEX idx_trace_bitmap ON agent_task_logs (trace_id) USING BITMAP;

我在的这个版本支持直接在VARIANT列上建倒排索引。如果你的版本不支持,退一步的办法是:在写入时额外提取出一个search_text列(比如把 tool 名称、报错信息、模型名拼成一个文本字段),对这个字段建全文索引,效果也差不多。

3.4 查询:search() 的常用姿势和回退方案

最常用的查询模板是这个:

SELECT trace_id, agent_name, ts, raw_log FROM agent_task_logs WHERE ts >= '2025-03-20 00:00:00' AND ts < '2025-03-21 00:00:00' AND agent_name = 'service_order_agent' AND search(raw_log, 'rate_limit') ORDER BY ts DESC LIMIT 100;

它的执行顺序是:先按ts做分区裁剪,再按agent_name过滤,最后在剩下的小数据集上用search()全文匹配。这样search()面对的数据量被压到很小,性能非常稳定,基本是毫秒级返回。

如果只是精确查链路,就直接走trace_id:

SELECT * FROM agent_task_logs WHERE trace_id = 'trc_8d2e91fa' ORDER BY ts ASC;

配合位图索引,这条查询在亿级数据里也能秒回。

还有一种场景是查某个字段的具体值,而不想全文扫。这时用 JSON 路径提取即可:

SELECT trace_id, raw_log->>'$.llm.model' AS model, raw_log->>'$.node' AS node, raw_log->>'$.llm.latency_ms' AS latency_ms FROM agent_task_logs WHERE ts >= '2025-03-20 00:00:00' AND raw_log->>'$.llm.latency_ms' > '2000';

注意->>返回的是字符串,做数值比较时要么让引擎自动转换,要么显式CAST,否则会有类型问题。这个问题我在下一节展开讲。

4. 当场踩出来的六个问题:坑在哪、怎么定位、怎么解

4.1 坑一:VARIANT 里的字段类型"上蹿下跳",查询直接报错

现象:某天线上报了一个怪错,SELECT raw_log->>'$.llm.prompt_tokens'大部分行正常,但零星几行报类型错误,类似Invalid type in json: number或者cannot cast ... to string。

定位过程:我先怀疑是脏数据,抽了几条报错的样本,发现这几行的prompt_tokens确实存在,但有的行它是数字,有的行它是字符串"1280",还有一行直接是null。VARIANT在存储时对每个字段做了类型推断,但推断是针对每一行的,它不是全局统一 schema——同一个字段在 A 行是 BIGINT,在 B 行可能是 STRING。

根因:Agent 日志本身来源复杂,有的节点是 Python 直接打的 JSON,数字就是数字;有的节点走了字符串模板拼出来,数字变成了字符串。日志到达时序还不固定,同一批数据里字段类型自然不一致。

解决:写入端统一类型,把prompt_tokens这类数值字段在传出前全部int()一下。同时查询端做兜底,用TRY_CAST代替直接CAST:

SELECT trace_id, TRY_CAST(raw_log->>'$.llm.prompt_tokens' AS BIGINT) AS prompt_tokens, TRY_CAST(raw_log->>'$.llm.latency_ms' AS BIGINT) AS latency_ms FROM agent_task_logs WHERE ts >= '2025-03-20 00:00:00' AND TRY_CAST(raw_log->>'$.llm.latency_ms' AS BIGINT) > 2000;

TRY_CAST遇到无法转换的值会返回NULL,查询不会整体失败,最多丢几条异常行。排查时再用WHERE raw_log->>'$.llm.latency_ms' IS NOT NULL单独捞出来看。

4.2 坑二:search() 查了但没走索引,全表扫描

现象:某条查询一开始还正常,加了search()之后越来越慢,EXPLAIN 发现它对raw_log做的是全表扫描,完全没走倒排索引。

定位过程:我看了一下 SQL,发现同事写的查询长这样:

WHERE search(raw_log, 'rate_limit') OR agent_name = 'xxx'

问题出就出在这个OR上。查询优化器一旦看到OR两边条件类型不一致,会因为无法把索引条件单独下推,直接选择全表扫描来保证正确性。另外还有一个常见原因:查询里对列做了函数包裹,比如search(CAST(raw_log AS STRING), 'rate_limit'),索引直接在列上失效。

根因:search()要命中倒排索引,前提是索引列本身作为一个整体参与匹配,不能被表达式包裹,也不能让它处于OR的另一分支导致优化器无法抉择。

解决:改成AND连接,并把这个经验写进了团队规范:

提示:search()必须直接作用在索引列上,查询中不要对该列做任何函数包裹;过滤条件之间尽量用AND组合,避免OR导致索引失效。如果必须用OR,拆成两条 SQL 再合并结果。

4.3 坑三:嵌套数组里的消息内容,search() 搜不到

现象:用户反馈查"某个工具返回结果里面的一段文本"搜不到,但这段文本明明就在日志里。我拿相同关键字直接LIKE '%...%'又能搜到。

定位过程:对比search()和LIKE的差异时发现,search()对VARIANT列的处理,并不是把整段 JSON 当纯文本做索引,而是按文档模型索引了标量字段。数组内部的元素如果没展开,倒排索引里根本不会收录。也就是说,results[0].eta里的值,对于search()来说是"不可见"的。

根因:全文索引在VARIANT上的收录范围,默认是结构化后的叶子节点,不包含数组内部的可变长内容。这是我当时没料到的行为差异。

解决:对高频检索的数组字段,在写入端单独展平出一列。我建了一个tool_call_text列,专门存储本次日志中所有工具调用的名称、入参和出参摘要拼接出的文本,然后对这个列建倒排索引。代价是存储略增,但查询逻辑简单直接,而且语义清晰——你要搜工具调用相关的内容,就搜这列。

ALTER TABLE agent_task_logs ADD COLUMN tool_call_text VARCHAR(2048) COMMENT '工具调用可检索文本'; CREATE INDEX idx_tool_text ON agent_task_logs (tool_call_text) USING INVERTED;

写入时在数据链路里加一步拼接。注意控制长度,超长部分截断,避免单行超大字段拖慢导入。

4.4 坑四:分区键选错,冷热数据混在一起

现象:上线几周后我发现一个奇怪现象:按天分区的表,某些天分区特别大,某些天分区特别小。去查了一下,原来这个 Agent 的 XXL-Job 任务只在每天特定几个时段集中爆发,白天空闲时段几乎没日志。

定位过程:这本身不算错误,但带来了两个实际问题:一是冷热数据没法区分——新建一个分区策略去冷热迁移时,如果按天分区,一个分区里既有高峰期数据又有低峰期数据,没法精准地把"老数据"单独降温;二是某些天的数据量大,单分区在 Compaction 时资源占用明显,查询那几天偶尔会变慢。

根因:分区是物理存储的基本单位,分区设计只考虑了时间,没考虑 Agent 日志的"潮汐"特性。

解决:我换了思路,不再追求"一天一个分区",而是把分区粒度降到 6 小时,同时配合"动态分区 + TTL 过期"。这样高峰期 4 个分区可能数据量都很大,低峰期分区很小,后续做冷热分层时,可以精确识别出"哪些分区是可以降级到对象存储的"。

动态分区配置参考:

PROPERTIES ( "dynamic_partition.enable" = "true", "dynamic_partition.time_unit" = "HOUR", "dynamic_partition.start" = "-672", "dynamic_partition.end" = "1", "dynamic_partition.prefix" = "p", "dynamic_partition.buckets" = "12" )

这样配合PARTITION BY RANGE(ts),系统会自动创建和淘汰分区,不用手工维护。对于超过 28 天的分区,我再建一条存储策略转到冷存储,成本立刻降下来。

4.5 坑五:导入 JSON 里带非法字符和大字段,Stream Load 反复失败

现象:从 Kafka 实时导入时,Stream Load 时不时报error rows,任务失败后同一批数据反复重试,延迟越来越大,Kafka 消费 Lag 报警。

定位过程:我把报错行抽出来看,发现两类问题。第一类是文本里带了未转义的控制字符或非法 Unicode,Agent 模型生成的文本里有奇怪字符,直接塞进 JSON 导致解析失败;第二类是某些 tool 返回了非常大的字段(比如一次把一张几万行的表格整体塞进日志),单行超过导入限制。

根因:日志数据不像业务数据那样干净,Agent 的输出是自由的,什么样都可能有。写入管道没有在入口做清洗,把脏数据全压给了存储层。

解决:做了三道防线:

  1. 在 Kafka Producer 端做转义和裁剪,非法字符替换为空格,单字段超过 1MB 的部分截断。
  2. Stream Load 设置max_filter_ratio,允许少量错误行跳过而不是整体失败。
  3. 对超长 JSON 字段在数据链路里做truncate,比如超过 10MB 整行丢弃并记告警。

命令上主要是加了一个参数:

curl --location-trusted -u admin:admin \ -H "format: json" \ -H "max_filter_ratio: 0.01" \ -T agent_log.json \ http://fe_server:8030/api/your_db/agent_task_logs/_stream_load

max_filter_ratio设成 0.01,意思是允许 1% 的错误行被过滤掉,而不是让整个批次失败。这在大数据量导入时非常重要——你永远不可能保证模型生成的日志 100% 合规,关键是让坏行不影响好行的写入。

4.6 坑六:XXL-Job 场景下按 trace_id 拉全链路日志依然慢

现象:这是我们最后才暴露的问题。单独跑WHERE trace_id = 'xxx'时很快,但业务方经常一次性提交几十个trace_id去批量拉日志,比如"把今天 XXL-Job 调度失败的所有任务日志都拉出来"。这时候查询变成WHERE trace_id IN (...),性能立刻劣化到秒级以上,偶尔还超时。

定位过程:EXPLAIN 之后发现,IN列表里的trace_id每个都尝试走位图索引,但对几十个值逐个去索引查,最后还得回到各个分桶去取数据,数据取回后还要做一次内存合并,整体耗时叠加严重。更深层的原因是,我的表分桶键虽然用了trace_id,但表的主键模型是 DUPLICATE KEY,trace_id 不是排序键,它只是分桶的哈希依据,并不能在桶内推进快速定位。

根因:对"批量精确值查询"这种场景,单纯靠位图索引不够,需要让trace_id本身成为某种"可搜索的前缀",或者在查询端做并发拆分。

解决:我做了两个改动。第一,给trace_id加了一个 Bloom Filter 索引,它在IN查询时能做快速排除,减少无效分桶扫描;第二,写了一个查询路由,把一次几十个trace_id的查询拆成多个WHERE trace_id = single的并发请求再合并结果,实测耗时从 5 秒压到 1 秒内。

第二个改动的本质是把存储层不擅长的"大量 IN 合并"转移到应用层做并发,效果立竿见影。如果你不想在应用层加复杂度,还有一个方案是让trace_id作为表的主键排序字段,但那样会和按时间分区的设计冲突,需要权衡。

5. 参数调优和后续扩展:把成本再压一截

5.1 压缩、TTL 和分级存储的收尾配置

VARIANT 列本身已经是不错的压缩格式,但在我的环境里,还得配合三个收尾动作才能把成本真正打下来:

  • 压缩算法用 zstd。相比默认的 LZ4,zstd 在 JSON 这类高重复文本上的压缩率能再提升 30% 到 40%,代价是 CPU 消耗略高。日志场景 CPU 本来就不是瓶颈,果断换。
  • TTL 按策略分层。热数据保留 7 天在 SSD,7 到 28 天放普通 HDD,超过 28 天的归档到对象存储。只保留单副本+冷存储的模式下,整体存储成本大概比我接手时降了 60%。
  • 按需关闭不必要的字段索引。这个要回到选型时的教训:不是所有字段都需要索引。常规字段保留位图索引、高频文本字段保留倒排索引,其余全部裸存。

5.2 一条推荐的前端查询 SQL 模板

综合前面所有踩坑经验,我整理出团队目前统一使用的查询模板:

SELECT trace_id, session_id, agent_name, ts, raw_log->>'$.node' AS node, raw_log->>'$.llm.model' AS model, TRY_CAST(raw_log->>'$.llm.latency_ms' AS BIGINT) AS latency_ms, tool_call_text FROM agent_task_logs WHERE ts >= '2025-03-21 00:00:00' AND ts < '2025-03-22 00:00:00' AND agent_name = 'service_order_agent' AND search(tool_call_text, 'order_query') ORDER BY ts ASC LIMIT 200;

所有可能参与范围过滤的字段,都显式提取为虚拟列再做TRY_CAST;所有需要全文匹配的内容,放进search()直接命中索引;ORDER BY ts配合分区裁剪,保证结果集是有序的,前端渲染全链路不用再人工排序。

5.3 个人体会:什么时候这套方案并不适合

讲了这么多好处,也想说说它的边界。我实际用下来的感受是:如果你们团队的 Agent 日志量很小(每天几百 MB 以下),直接用 PostgreSQL 的 JSONB 就能解决,没必要引入一套新的存储引擎;如果你们的日志分析需要频繁跨 Agent、跨会话做图关系分析(比如统计不同 Agent 之间的调用依赖),那 VARAINT + search() 也不是最佳选择,导出到图数据库或数仓会更合适。

它真正擅长的是"大量半结构化日志 + 按关键字快速找回 + 按链路聚合"这个组合场景。我目前这套系统每天处理约 30 GB Agent 日志,写入稳定,查询秒级,存储成本比纯文本文件压缩归档还低——这个结果让我比较满意。

最后再分享一个小经验:VARIANT 用得好不好,一半取决于写入端的日志规范。建表、建索引都只是底盘,真正决定检索体验的,是你是否在写入前就把"哪些内容需要可搜索"想清楚了。工具调用文本的展平列、数值字段的类型统一、脏文本的清洗,这三件小事每一项都比调参数影响更大。希望对正在折腾 Agent 日志存储的朋友有帮助。

返回列表