Agent 项目跑着跑着日志这块迟早会变成拖后腿的那一环。我最近在做一个多轮工具调用的 Agent 服务每个请求会拆成若干步推理每步都要落一条结构化日志——工具名、入参、出参、耗时、token 数、会话 ID、trace ID一个都不能少。上线第一周日志量就冲到千万级然后问题来了运维那边查一条报错日志要等十几秒存储成本按周翻倍。这不是个例凡是认真做 Agent 可观测性的团队基本都会撞上这堵墙。我最后的解法是把日志的写入和检索拆开写入走Stream Load批量灌进一张带VARIANT列的宽表检索统一收敛到一个search()函数上再给高频过滤字段补倒排索引。整套方案落地之后单条日志的存储成本降了大概六成P99 检索延迟从十几秒压到几百毫秒。这篇文章就把这套东西的完整命令、参数取舍以及我当场踩出来的几个坑原原本本讲一遍。适合正在做 Agent 可观测性、日志平台或者单纯被日志成本和查询速度折磨的工程师参考有 SQL 基础就能跟着复现。1. 先搞清楚 Agent 日志为什么这么难伺候1.1 半结构化才是 Agent 日志的真面目传统后端日志大多是固定 schema时间、级别、模块、消息字段就那么几个建一张窄表绰绰有余。Agent 日志完全不是这个形态。它天然是半结构化的——同一个tool_call事件调搜索工具时参数是{query, top_k}调代码执行工具时参数变成{code, timeout, language}调数据库工具又是另一套。你要是硬按固定列去建表要么字段爆炸要么大量字段永远为 NULL。更麻烦的是嵌套。一次 Agent 推理的日志长这样外层是会话和 trace中间是若干 step每个 step 里又嵌着 tool_call 和 tool_result。这种层级结构用关系型表去表达得拆成三四张表做关联查询时 JOIN 到天荒地老。而 Agent 排障最典型的诉求恰恰是把某个 trace 的完整链路捞出来看多表 JOIN 在这种场景下就是性能杀手。所以第一件事得想明白Agent 日志的存储模型必须能容纳字段不固定 层级嵌套这两个特征。这也是我最终选 VARIANT 类型而不是传统 JSON 字符串的根本原因后面会细说。1.2 慢和贵其实是同一个病根很多人把检索慢和存储贵当成两个独立问题分别优化其实它俩同源。存储贵的直接原因是日志里塞了大量重复的、低信息密度的字段——比如每条日志都带一份完整的工具 schema 描述或者把整个 HTTP header 原样存进去。检索慢的原因则是这些冗余字段没有索引查询时只能全表扫描。我做过一次统计我们最初的日志表里真正会被查询用到的字段不到 30%剩下 70% 是先存着万一以后要用的字段。这 70% 既推高了存储成本又因为表变宽拖慢了扫描速度。所以正确的思路是用列式存储 动态类型把冷字段的成本压下去用倒排索引把热字段的检索速度提上来一刀解决两个问题。提示在动手改存储方案之前先花半天时间统计一下你们日志表里各字段的实际查询频率。这一步能帮你砍掉大量无用字段收益往往比换存储引擎还大。1.3 为什么是 search() VARIANT 这套组合市面上做日志的方案不少我选这套组合的逻辑是这样的。VARIANT 类型解决的是字段不固定——它能把任意 JSON 结构存进一列同时保留按路径下钻查询的能力不用预先定义 schema。search() 函数解决的是检索入口分散——它把全文检索、字段过滤、模糊匹配统一成一个函数调用不用在应用层拼各种 LIKE 和等值条件。倒排索引解决的是高频字段要快——给 trace_id、tool_name 这类天天查的字段建索引查询直接走索引不走扫描。这三者配合起来写入侧用 Stream Load 批量导入保证吞吐存储侧用 VARIANT 压缩冷字段检索侧用 search() 统一入口加倒排索引加速。整套链路是自洽的不是东拼西凑。下面几节我按建表 → 写入 → 检索 → 踩坑的顺序展开。2. 建表VARIANT 列怎么设计才不翻车2.1 表结构设计的核心取舍建表这一步决定了后面所有事情的上限我改了三版才定下来。核心思路是把高频过滤字段提成独立列把低频、结构不定的字段塞进 VARIANT 列。提成独立列的好处是可以直接建索引、直接做等值过滤不用走 VARIANT 的路径解析。塞进 VARIANT 的字段则享受动态 schema 的灵活性。我最终的建表语句大致长这样CREATE TABLE agent_logs ( log_time DATETIME(3) NOT NULL, trace_id VARCHAR(64) NOT NULL, session_id VARCHAR(64), step_index INT, event_type VARCHAR(32), tool_name VARCHAR(64), duration_ms INT, payload VARIANT ) DUPLICATE KEY(log_time, trace_id) DISTRIBUTED BY HASH(trace_id) BUCKETS 32 PROPERTIES ( replication_num 3, storage_medium SSD );这里每个字段都有讲究。log_time用毫秒精度因为 Agent 的 step 之间间隔可能只有几十毫秒秒级精度根本区分不开顺序。trace_id和session_id提成独立列因为这俩是排障时最常用的过滤条件。step_index提出来是为了能按步骤排序还原推理链路。event_type和tool_name提出来是因为要建倒排索引。真正结构不定的东西——工具入参、工具出参、模型返回的原始 JSON、各种元数据——全部丢进payload这个 VARIANT 列。这样表结构稳定但内容可以无限扩展。2.2 VARIANT 列里到底该放什么VARIANT 虽然灵活但不是垃圾桶什么都往里塞会出问题。我的原则是会被用来做范围查询或排序的字段不要放 VARIANT只做展示或偶尔下钻的字段放 VARIANT。具体到 Agent 日志我放进 payload 的有tool_input工具入参对象、tool_output工具返回对象、model_response模型原始输出、error_detail错误堆栈和上下文、token_usage各类 token 计数。这些字段的共同点是结构随工具和模型变化且很少被用来做过滤条件。反过来像duration_ms这种经常要查耗时超过 5 秒的请求的字段我坚决提成独立列。因为 VARIANT 里的数值做范围查询性能远不如原生数值列而且没法建高效的索引。注意VARIANT 列里的字段路径解析是有开销的。如果你发现某个 payload 里的字段被频繁查询别犹豫把它提成独立列。我一开始把token_usage放在 payload 里后来发现要按 token 数做统计提出来之后查询快了将近一个数量级。2.3 分桶和副本参数怎么定DISTRIBUTED BY HASH(trace_id) BUCKETS 32这行看着简单其实很关键。分桶键选trace_id而不是log_time是因为 Agent 排障几乎总是按 trace 捞全链路同一个 trace 的日志落在同一个桶里查询时只扫一个桶效率最高。如果按时间分桶同一个 trace 的日志可能散落在多个桶查询要跨桶聚合。桶数量 32 是根据我们的数据量估的。经验值是单桶数据量控制在 1GB 到 10GB 之间比较健康。我们日均日志约 50GB保留 30 天就是 1.5TB除以 32 约 47GB 每桶偏大但可接受。如果你们数据量更大桶数要相应增加但别超过集群的 CPU 核数太多否则小查询会浪费调度开销。副本数 3 是生产环境标配保证单节点故障不影响可用性。存储介质选 SSD 是因为日志检索对随机读延迟敏感机械盘在倒排索引场景下会明显拖后腿。这几个参数没有银弹得根据你们的集群规模和数据量实测调整。3. Stream Load 写入批量灌数据的正确姿势3.1 为什么不用逐条 INSERTAgent 日志的写入特点是高频、小条、持续。如果每条日志都发一条 INSERT数据库要处理海量的单行事务写入吞吐上不去还会产生大量小文件拖慢后续查询。我实测过逐条 INSERT 的吞吐大概只有批量导入的十分之一而且随着数据量增长会越来越慢。Stream Load 是专门为批量导入设计的它把一批数据打包成一个 HTTP 请求发过去服务端一次性写入。对 Agent 日志这种场景我一般攒 500 到 2000 条一批或者按时间窗口攒 1 到 2 秒哪个先到就触发。这样既保证了吞吐又控制了延迟。3.2 一条完整的 Stream Load 命令下面是我实际在用的导入命令用 curl 发的curl --location-trusted -u user:password \ -H label:agent_logs_$(date %Y%m%d%H%M%S)_$RANDOM \ -H format: json \ -H strip_outer_array: true \ -H jsonpaths: [\$.log_time\,\$.trace_id\,\$.session_id\,\$.step_index\,\$.event_type\,\$.tool_name\,\$.duration_ms\,\$.payload\] \ -H columns: log_time, trace_id, session_id, step_index, event_type, tool_name, duration_ms, payload \ -T /tmp/agent_logs_batch.json \ http://fe_host:8030/api/agent_logs/_stream_load几个参数必须解释清楚。label是这次导入的唯一标识用来做幂等——如果同一个 label 重复提交服务端会拒绝避免重复写入。我用时间戳加随机数拼 label保证唯一。strip_outer_array: true表示我传的是一个 JSON 数组服务端会拆成多行。jsonpaths和columns配合把 JSON 里的字段映射到表的列上。payload这一列比较特殊它对应 VARIANT 类型传进去的应该是一个 JSON 对象而不是字符串。如果你的客户端把 payload 序列化成了字符串导入后 VARIANT 里存的会是字符串而不是对象后续路径查询就失效了。这个坑我踩过排查了半天。3.3 批量攒批的工程实现攒批逻辑我是在应用侧做的用一个带缓冲的 channel 加定时器。伪代码大概是这样import json, time, threading, requests buffer [] lock threading.Lock() BATCH_SIZE 1000 FLUSH_INTERVAL 1.5 def emit_log(log_dict): with lock: buffer.append(log_dict) if len(buffer) BATCH_SIZE: flush() def flush(): global buffer with lock: if not buffer: return batch buffer buffer [] body json.dumps(batch, ensure_asciiFalse) requests.put( http://fe_host:8030/api/agent_logs/_stream_load, headers{ label: fagent_logs_{int(time.time()*1000)}_{id(batch)}, format: json, strip_outer_array: true, }, databody.encode(utf-8), auth(user, password), ) def timer_loop(): while True: time.sleep(FLUSH_INTERVAL) flush()这里有个细节flush里先把 buffer 换出来再发请求避免发送期间阻塞新的日志写入。定时器保证即使日志量小也不会让数据在内存里待太久。生产环境还要加失败重试和本地落盘兜底防止导入失败丢日志。提示Stream Load 单批数据别太大我试过一批塞 10 万条结果请求超时。控制在 1 万条以内、单批 10MB 以内比较稳。数据量大的话多开几个并发导入比单批塞大更可靠。4. search() 检索把查询入口收敛成一个函数4.1 search() 到底解决了什么问题在没有 search() 之前我们的查询代码是这样的全文检索用 LIKE %keyword%字段过滤用一堆 AND 条件模糊匹配再拼几个 OR。这种写法有两个问题一是 LIKE 全表扫描慢得要命二是查询逻辑散落在各处维护起来一团糟。search() 把这些能力统一了。它接受一个查询表达式内部会自动决定走全文索引还是字段过滤应用层不用关心底层怎么执行。对我们来说最大的收益是查询代码从几十行拼 SQL 变成了一行函数调用而且性能还更好。4.2 典型查询场景的写法排障时最常用的几个查询我列一下实际写法。按 trace 捞全链路按步骤排序SELECT log_time, step_index, event_type, tool_name, duration_ms, payload FROM agent_logs WHERE trace_id abc123def456 ORDER BY step_index ASC;这个查询走 trace_id 的等值过滤如果建了索引会非常快。全文检索错误信息SELECT log_time, trace_id, tool_name, payload FROM agent_logs WHERE search(payload.error_detail, timeout) AND log_time NOW() - INTERVAL 1 HOUR LIMIT 100;这里 search() 在 payload 的 error_detail 路径下做全文匹配配合时间范围过滤能快速定位最近的超时错误。按工具名和耗时过滤SELECT tool_name, COUNT(*) AS cnt, AVG(duration_ms) AS avg_ms FROM agent_logs WHERE event_type tool_call AND duration_ms 5000 AND log_time NOW() - INTERVAL 1 DAY GROUP BY tool_name ORDER BY avg_ms DESC;这个查询用来找哪些工具最慢是性能优化的常用入口。4.3 倒排索引该建在哪些字段上倒排索引不是越多越好每个索引都会增加写入开销和存储占用。我的原则是只给高频过滤且基数适中的字段建索引。具体到 Agent 日志我建了这几个字段是否建索引理由trace_id是排障必查基数高等值过滤收益大tool_name是按工具统计和过滤频繁基数适中event_type是枚举值少过滤选择性好session_id是按会话排查时常用duration_ms否范围查询为主倒排索引帮助有限payload否结构不定靠 search() 全文检索建索引的语法大致是CREATE INDEX idx_trace ON agent_logs(trace_id) USING INVERTED; CREATE INDEX idx_tool ON agent_logs(tool_name) USING INVERTED; CREATE INDEX idx_event ON agent_logs(event_type) USING INVERTED;duration_ms我没建倒排索引因为它的查询几乎都是范围条件倒排索引对范围查询的加速不如对等值查询明显。如果确实需要可以考虑建前缀索引或者用其他索引类型。注意倒排索引对写入性能有影响。我实测建了三个索引之后Stream Load 的吞吐下降了约 15%。这个代价换来查询速度的大幅提升是值得的但你要心里有数别指望索引零成本。5. 当场踩出来的几个坑和排查过程5.1 VARIANT 里存成字符串导致路径查询失效这个坑最隐蔽。我导入日志之后用payload.tool_input.query去查死活查不到数据但SELECT payload明明能看到内容。排查了半天才发现问题出在导入环节我的客户端把tool_input这个对象序列化成了 JSON 字符串再放进 payload结果 VARIANT 里存的是一个字符串而不是嵌套对象。判断方法很简单查一下类型SELECT typeof(payload.tool_input) FROM agent_logs LIMIT 1;如果返回的是字符串类型而不是对象类型那就是这个问题。修复方式是在导入时确保 payload 是真正的 JSON 对象别提前序列化。这个坑的教训是VARIANT 的路径查询依赖真实的嵌套结构字符串化的 JSON 在它眼里就是一坨文本。5.2 攒批过大导致导入超时前面提过我试过一批塞 10 万条日志结果 Stream Load 请求直接超时而且因为超时后重试同一批数据被提交了两次虽然 label 幂等挡住了重复写入但白白浪费了一轮资源。排查过程是这样的先看导入返回的错误信息提示请求体过大然后逐步减小批量发现 1 万条以内稳定超过 5 万条开始偶发超时。最终我把批量上限设成 5000 条单批大小控制在 5MB 以内再没出过问题。这里还有个连带问题批量太大时如果导入失败重试的代价也大。小批量失败重试快整体可用性反而更高。所以别贪大稳定压倒一切。5.3 时间范围查询没走索引导致全表扫描有一次运维反馈查最近一小时的错误日志要等半分钟我一看查询语句问题很明显SELECT * FROM agent_logs WHERE search(payload.error_detail, exception) AND log_time 2024-01-01 00:00:00;这个查询里log_time是范围条件但表的分桶键是trace_id所以时间范围过滤没法做桶裁剪只能全表扫描。加上 search() 本身也要扫两个慢操作叠加自然慢。优化思路是给时间维度也做分区。我改成了按天分区PARTITION BY RANGE(log_time) ( PARTITION p20240101 VALUES [(2024-01-01), (2024-01-02)), PARTITION p20240102 VALUES [(2024-01-02), (2024-01-03)) )这样时间范围查询能直接裁剪掉不相关的分区扫描量大幅下降。改完之后同样的查询从半分钟降到两秒以内。5.4 倒排索引和 VARIANT 路径查询的配合问题最后一个坑比较微妙。我给tool_name建了倒排索引但有一次查询里同时用了tool_name search和search(payload.tool_input.query, keyword)结果发现查询计划没有用上倒排索引还是全表扫。原因是 search() 函数的存在让优化器倾向于走全文检索路径忽略了同一查询里其他字段的索引。解决办法是把查询拆开先用索引字段缩小范围再在结果集上做全文检索SELECT * FROM ( SELECT * FROM agent_logs WHERE tool_name search AND log_time NOW() - INTERVAL 1 HOUR ) t WHERE search(payload.tool_input.query, keyword);这样内层查询走 tool_name 索引和时间分区裁剪外层只在小结果集上做全文检索整体快很多。这个坑的教训是别指望优化器总能帮你选最优路径复杂查询要手动拆解。6. 成本与性能的实测对比6.1 改造前后的关键指标我把改造前后的数据整理成了一张表方便你判断这套方案值不值得上指标改造前改造后变化单条日志平均存储约 2.1KB约 0.8KB降 62%P99 检索延迟12-15 秒300-600 毫秒降 95%写入吞吐约 2 万条/秒约 8 万条/秒升 4 倍30 天存储成本基准约 40%降 60%存储降这么多主要靠两点一是砍掉了 70% 的无用字段二是 VARIANT 列式存储对重复度高的 JSON 压缩效果很好。检索延迟降这么多靠的是倒排索引加分区裁剪把全表扫描变成了索引查找。6.2 哪些场景不适合这套方案不是所有日志场景都适合 search() VARIANT。如果你的日志 schema 非常固定字段就那么十来个且从不变化那老老实实用窄表加普通索引就行VARIANT 反而增加复杂度。如果你的查询几乎全是精确的等值过滤没有全文检索需求那 search() 的价值也有限。这套方案最适合的场景是字段结构多变、有嵌套、需要全文检索、且数据量大到传统方案扛不住。Agent 日志恰好全中所以收益明显。判断标准很简单如果你现在正被字段加不完和查询越来越慢同时折磨那这套方案大概率适合你。6.3 后续还能怎么优化我现在还在继续打磨这套东西。下一步想做的有几个方向一是给 payload 里的高频路径建专门的索引进一步加速下钻查询二是做冷热分层把超过 7 天的日志自动转到低成本存储热数据留在 SSD三是把 search() 的查询模式做成模板让运维同学不用写 SQL 也能查。冷热分层这块我初步测了一下把 30 天前的日志转到对象存储查询时按需加载成本还能再降一半代价是冷查询延迟会高一些。这个取舍要看你们的实际需求如果排障主要看最近几天那冷数据慢一点完全可以接受。最后分享一个我踩坑踩出来的小经验改存储方案之前一定要先做数据采样分析。我一开始凭直觉砍字段结果砍掉了一个其实每周都会查一次的字段导致那周的排障特别痛苦。后来我老老实实统计了两周的查询日志按字段的查询频率排序才做出靠谱的取舍。数据不会骗人直觉会。