实现秒级全文检索与成本优化)
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_noSO20250321001, 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有时是字符串、有时是 nullretry_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 解决存得下不建表也能存任意 JSONVARIANT是一种半结构化列类型简单理解就是这一列可以直接灌任意 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_idSELECT * 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代替直接CASTSELECT 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 报警。定位过程我把报错行抽出来看发现两类问题。第一类是文本里带了未转义的控制字符或非法 UnicodeAgent 模型生成的文本里有奇怪字符直接塞进 JSON 导致解析失败第二类是某些 tool 返回了非常大的字段比如一次把一张几万行的表格整体塞进日志单行超过导入限制。根因日志数据不像业务数据那样干净Agent 的输出是自由的什么样都可能有。写入管道没有在入口做清洗把脏数据全压给了存储层。解决做了三道防线在 Kafka Producer 端做转义和裁剪非法字符替换为空格单字段超过 1MB 的部分截断。Stream Load 设置max_filter_ratio允许少量错误行跳过而不是整体失败。对超长 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_loadmax_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 KEYtrace_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。相比默认的 LZ4zstd 在 JSON 这类高重复文本上的压缩率能再提升 30% 到 40%代价是 CPU 消耗略高。日志场景 CPU 本来就不是瓶颈果断换。TTL 按策略分层。热数据保留 7 天在 SSD7 到 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 日志存储的朋友有帮助。