ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Agent可观测性改造实践:Apache Doris如何支撑链路追踪与全链路回放

Agent可观测性改造实践:Apache Doris如何支撑链路追踪与全链路回放 从把 Agent 的会话日志从 ELK 里捞出来逐条翻到最终在一个界面里按 trace_id 把一次任务从用户提问、规划、工具调用、模型思考到最终回复的全过程完整回放中间隔着的不是一个查询语句的优化而是一整套数据架构的重新设计。这是我在给公司内部 AI Agent 平台做可观测性改造时最深的感受。我们做的 Agent 不是单轮问答机器人而是能自主规划、调用内部 API、操作数据库、生成报表的复杂智能体。这类系统最大的问题在于它到底为什么做出了某个决定、中间走了哪些弯路、工具调用失败后是怎么兜底的这些信息在传统日志体系里几乎完全不可见。我在这篇文章里会完整复盘这次改造过程为什么选 Apache Doris 作为可观测性数据的核心存储Agent 的可观测性数据和传统微服务的 trace 有什么本质区别以及我们最终落地的表结构、采集链路和查询方案。内容偏实战适合正在做 Agent 平台建设、或者被AI 应用链路追踪折磨得焦头烂额的后端和运维同学参考。1. 先搞清楚为什么 Agent 这么难观测1.1 黑盒问题的真正根源状态不只是报错传统微服务的可观测性建立在一个基础前提上每个服务有明确的接口边界请求是同步或异步的有限状态机监控的核心是有没有报错、延迟多少、吞吐多少。但 Agent 完全不是这个逻辑。以我们自研的 Agent 平台为例一次用户请求会经历意图识别LLM 调用→ 任务规划LLM 调用生成多步计划→ 工具选择LLM 调用决定调哪个 API→ 工具执行真实调用内部服务→ 结果分析LLM 调用判断是否完成→ 可能循环多轮。每一次 LLM 调用是不可控的同样的输入可能产生不同的规划结果模型可能突然决定调用一个完全没必要的工具也可能陷入自我纠正的循环里出不来。这里就出现了一个根本性的观测难点Agent 的状态不是一个请求的状态码而是模型内部决策过程的产物。即便所有下游工具调用都成功了、HTTP 状态码全是 200Agent 交出来的最终结果也可能是错的——因为它在前置的某一步理解错了用户意图或者选择了一个不合适的工具。这种逻辑层面的错误完全不会在传统监控系统里暴露出来。我见过太多团队排查 Agent 问题的痛苦场景用户反馈我刚才让它查一下上个月的销售数据它给了一堆完全无关的报表。开发同学打开 Kibana搜日志关键词翻到当时那一次会话——由于没有链路 ID 贯穿只能按时间戳盲猜找到的日志是割裂的LLM 的 prompt 和 response 在一条日志里工具调用参数在另一条日志里中间还夹杂着其他并发的请求记录。翻了一个小时只能得出一个结论看起来它好像调用错了工具。至于为什么调用错无从得知。所以说做 Agent 可观测性第一件事不是选型而是承认一个事实你需要观测的数据维度比传统监控多得多而且大量数据是非结构化的、语义化的。不仅要看成功了没有还要看它怎么想的。1.2 需求梳理透明化到底要解决哪些具体问题跟业务方和算法团队反复对齐之后我们梳理出了 Agent 可观测性要解决的六大核心问题。这六个问题基本可以覆盖绝大多数 Agent 平台的可观测性需求链路回放一次完整的用户请求经历了哪些内部步骤每一步的输入输出是什么耗时多少整体执行路径长什么样。这是排查一切问题的地基没有链路回放后面所有分析都无从谈起。工具调用审计Agent 在哪些环节调用了哪个工具传了什么参数拿到什么结果调用是否成功失败后有没有重试或者换路径这个需求直接关系到Agent 是不是在乱调系统这个最让平台方紧张的问题。Token 成本归因一次任务的成本是多少其中哪一步消耗的 Token 最多是规划阶段、工具分析阶段还是最终汇总阶段这个数据直接决定 Agent 能不能规模化上线——老板最关心的问题永远是这玩意儿跑一次到底花多少钱。模型行为洞察同一个问题不同版本的模型规划路径有什么差异模型是不是偏向调用某个工具哪些 user utterance 触发了模型陷入循环这类分析依赖于把原始会话数据做成聚合统计。异常根因定位用户体感变慢了、变傻了、结果不对到底是模型推理慢、工具调用超时、还是上游数据质量问题需要有维度去拆分定位。业务语义监控不只是技术指标的告警还要做语义级别的监控比如最近一小时的失败会话里有多少是因为工具权限不足导致的这种基于业务标签的统计。把这些需求落到数据层面我们发现要处理的数据形态极其复杂。既有结构化的执行元数据时间戳、时长、状态码又有半结构化的 JSON工具的入参出参还有大段的非结构化文本完整的 prompt 和 response、模型思考过程。数据量上我们单日 Agent 执行次数在百万级别每次执行平均产生 15~30 条 span 事件加上 prompt 原文单日新增数据量在 200GB 左右。这个体量和形态组合基本把传统监控系统的能力边界逼到了极限。1.3 传统方案的失效点不是不好用是不对口我们不是没试过传统方案。相反初期踩了一整圈坑才最终确定用 Apache Doris 重做底层存储。先说 Elasticsearch。这是我们最初的选择因为 Agent 的日志天然适合全文检索prompt 和 response 都是大文本ES 的倒排索引似乎是天作之合。但用下来有三个致命问题第一ES 在高并发聚合查询上的能力和它的检索能力完全不成正比。当我们需要按工具名、模型版本、耗时区间做多维统计时ES 的聚合响应时间从几百毫秒直接飙到十几秒甚至把集群 CPU 打满。第二写入毛刺非常明显Agent 的调用高峰很集中突发流量时 ES 的 bulk 队列直接积压导致数据延迟几个小时才可见这对实时排查问题来说是不可接受的。第三需要同时维护检索链路和分析链路两套系统一旦需要 join trace 数据做关联分析ES 的关联能力约等于没有。再说 ClickHouse。它的分析性能确实强悍我们一度很心动。但问题出在高并发点查和明细级检索上。Agent 链路回放需要按 trace_id 随机查某一条完整链路这是一个典型的点查场景ClickHouse 在这种场景下的表现远不如它的聚合分析那么亮眼。而且 ClickHouse 对更新的支持比较弱而 Agent 的事件数据经常需要补写或修正比如异步工具回调回来之后要更新前序 span 的状态这在使用上就很别扭。另外ClickHouse 的运维成本在集群规模大了之后非常可观我们没有专职的 DBA 团队去伺候它。最后是关系型数据库比如 MySQL 或者 PostgreSQL。它们在数据一致性、事务能力上有绝对优势但面对我们每天 200GB 增量、并且要支持多维聚合分析的数据量根本扛不住。简单说单表上亿行之后任何一个带 group by 的查询都能把主库拖死更别提全文检索这种需求了。选型进入到死胡同的时候我们把注意力放到 Doris 上。事实上它恰好补齐了上面所有方案的核心短板既有 ES 那样的倒排索引和全文检索能力又有 ClickHouse 那样的高性能聚合分析能力还能支持高并发点查和轻量级更新同时兼容 MySQL 协议——这让我们的后端团队几乎零学习成本上手。后面我会展开讲具体的架构设计先说结论最终选型 Doris不是因为它某一个单点能力最强而是因为它是唯一一个能同时覆盖我们全部六类需求的系统。2. 为什么是 Apache Doris可观测性场景下的技术解剖2.1 Doris 的核心机制对 Agent 场景的适配性Apache Doris 是 MPP 架构的实时 OLAP 数据库技术上最大的两个亮点是列式存储引擎和极简的分布式架构。对于可观测性这个场景有几个能力是真正关键的。第一是倒排索引和全文检索能力。Doris 从 2.0 版本开始支持倒排索引这就意味着可以直接在 Doris 里执行MATCH_ANY、MATCH_ALL、MATCH_PHRASE这类全文检索查询。对于我们这种需要从海量 prompt 文本里搜关键词的场景这是刚需。比如排查用户问了什么会导致 Agent 调用某个危险工具直接对 prompt 列做全文检索秒级出结果。第二是高并发点查能力。Doris 在点查场景下的能力经常被忽略。它的表模型配合前缀索引可以在毫秒级返回单行查询而且支持大规模的并发查询——这意味着几百个用户同时打开链路追踪页面、各自查自己关注的 trace_id完全不会有压力。这一点是 ClickHouse 很难做到的。第三是Unique 模型和部分列更新。Agent 的事件数据天然不是一次性写入永久不变的。一次工具调用发出去了要等回调回来才拿到最终结果中间可能要更新两次状态。Doris 的 Unique Key 模型配合UPDATE语法可以方便地做行级更新这让我们能维护一份实时正确的执行状态表。第四是JSON 类型的原生支持。Doris 从 2.1 版本开始支持 JSON 半结构化类型可以建立 JSON 索引并对 JSON 内部的字段做过滤和提取。Agent 的工具入参出参几乎全是任意结构的 JSON有了这个能力我们就可以把事件数据直接以原始 JSON 形式存储不需要在前置环节做繁琐的 schema 映射。2.2 和 Elasticsearch 的对比体验存储与计算的一体化说一个实际对比过的数据加深一下体感。我们做过一轮简单的压测同一批 Agent 事件数据分别写入 ES 和 Doris各用三个常用查询模式去跑——按 trace_id 点查、按关键词全文检索、按时间范围做 group by 聚合统计。ES 在关键词检索上确实强单条查询大概 50ms 内能返回但聚合统计一到百万级文档量就立刻退化到 8 秒以上而且并发上来之后整个集群的查询抖动很大。Doris 这边点查约 5ms全文检索约 100ms聚合统计在同样数据量下 2 秒内稳定完成并且 QPS 到 100 时响应时间依旧平稳。更重要的是Doris 一套系统同时撑起这三种负载不需要像 ES 那样组建热节点和冷节点分治也不需要额外搭一套分析引擎做旁路。当然ES 在复杂的 Bool 查询、模糊匹配的灵活性上还是有优势。但对我们这个场景来说Doris 的能力边界已经够了而且一个系统搞定所有事带来的运维收益是巨大的。2.3 数据生命周期管理不靠抽稀靠分区与冷热分层Agent 可观测性数据有个特点越新的数据越重要。排查正在发生的问题、监控线上模型的实时表现靠的是最近几分钟的数据而三十天前的链路明细除了做离线分析几乎不会被检索。所以存储成本控制的关键不是删数据而是分层。Doris 支持分区和冷热分层存储。我们按天创建分区最近 7 天的热分区存储在高速本地盘更早的分区自动迁移到对象存储S3 兼容存储。这个操作完全透明查询依然走统一入口只是数据落盘位置不同。实际算下来热数据 冷数据的总体存储成本比全量放 ES 低了差不多 60%而查询的体感几乎没有差别。还有一个很实用的机制是TTL和自动删除分区。我们可以动态设置每个分区的过期时间比如明细数据保留 60 天聚合结果保留 180 天。这对运维来说省了无数条定时清理脚本。3. 可观测性架构整体设计从采集到分析的完整链路3.1 整体拓扑与数据流我们的架构最终形成了四个层次埋点采集层 → 传输层 → 存储分析层 → 消费展示层。埋点采集层用的是 OpenTelemetry 生态。Agent Runtime 是我们自研的 Python 执行框架在上面做了 OTel SDK 的埋点集成。每一次 LLM 调用、工具调用、状态跳转都会生成对应的 Span 和 Event。这里有一个重要设计决策不使用 OTel 的标准 trace 语义来硬套 Agent 场景而是在 Span 的 Attribute 里塞入 Agent 专属的语义字段。传输层是标准链路OTel Collector 接收 Agent 上报的 trace 数据做基础清洗后写入 Kafka。Kafka 在这里做两件事一是削峰填谷Agent 调用高峰时写 Kafka 的延迟是毫秒级不会对业务造成阻塞二是多路分发同一份数据可以同时被 Doris 消费做实时分析、被离线数仓消费做训练数据预处理。存储分析层就是核心的 Doris 集群。我们在 Doris 里建了三大类表链路明细表TraceSpan、执行状态表ExecutionState、维度汇总表ToolDimension / ModelDimension 等。后面会详细讲每一类表的设计。消费展示层的工具有两块一块是 Grafana 直接连 Doris 的数据源用来做监控大盘和告警另一块是我们自研的 Agent 诊断平台后端直连 Doris 查询前端做链路可视化和会话回放。因为 Doris 兼容 MySQL 协议我们后端就是一个标准的 MySQL 客户端完全不用引入额外的查询 SDK。3.2 为什么把 Kafka 放在中间链路这里想多讲几句选型的思考。最初我们认为既然是 OTel Collector 直接可以跟 Doris 对接似乎可以省掉 Kafka减少一层组件。但实际评估后Kafka 是必须保留的。最核心的原因是解耦写入洪峰。Agent 的调用往往具有突发性比如某个用户批量触发了一批报表生成任务瞬间几千次 Agent 执行一起开始。如果业务直接同步写 DorisDoris 的导入压力会直接传导到业务线程一旦 Doris 因为 compaction 出现短暂的导入延迟Agent 执行链路就会整体变慢这是我们绝对不能接受的。Kafka 引入后OTel Collector 只面对 Kafka 这一个大吞吐量的写入端业务侧完全无感。另一个原因是消费幂等和重放。数据进 Kafka 之后Doris 侧消费任务失败了可以从位点重放不会丢数据。而 Agent 执行数据是不可再生的一旦丢了链路就没法追溯了——所以用 Kafka 把数据安全地落地再慢慢导 Doris是最稳妥的。3.3 核心表结构设计详解这一节是全文的重中之重。我把我们线上实际在用的表结构拿出来逐个讲清楚设计动机。先说明一下下面的 DDL 是从生产环境脱敏后摘录的关键字段省略了一些业务专属字段但核心设计都在。链路明细表 TraceSpan这个是整个可观测性系统的地基存储 Agent 执行过程中产生的每一条 span 记录。设计思路是一行数据代表 Agent 执行链路中的一个节点通过trace_id、span_id、parent_span_id三个字段串起整条执行路径。CREATE TABLE agent_trace_span ( trace_id VARCHAR(64) COMMENT 一次用户请求的全链路ID, span_id VARCHAR(64) COMMENT 当前节点ID, parent_span_id VARCHAR(64) COMMENT 父节点ID根节点的parent为空, agent_id VARCHAR(128) COMMENT Agent实例标识, session_id VARCHAR(128) COMMENT 用户会话ID, user_id VARCHAR(128) COMMENT 用户ID用于权限隔离和用户维度分析, span_type VARCHAR(32) COMMENT 节点类型: llm_call/tool_call/agent_plan/agent_status/..., span_name VARCHAR(256) COMMENT 节点名称比如调用CRM查询接口, status VARCHAR(16) COMMENT 成功/失败/超时/重试中, start_time DATETIME(3) COMMENT 节点开始时间, duration_ms BIGINT COMMENT 节点耗时(毫秒), model_name VARCHAR(128) COMMENT LLM调用时使用的模型名非LLM节点为空, tool_name VARCHAR(256) COMMENT 工具调用时的工具名非工具节点为空, prompt_text TEXT COMMENT LLM调用的prompt原文, response_text TEXT COMMENT LLM返回的response原文, input_params JSON COMMENT 工具入参任意JSON结构, output_result JSON COMMENT 工具出参任意JSON结构, error_message TEXT COMMENT 异常信息, extra_attrs JSON COMMENT 预留扩展字段放各类自定义属性, ingestion_time DATETIME(3) COMMENT 写入时间 ) UNIQUE KEY(trace_id, span_id) DISTRIBUTED BY HASH(trace_id) BUCKETS 48 PARTITION BY RANGE(ingestion_time) () PROPERTIES ( replication_num 3, dynamic_partition.enable true, dynamic_partition.time_unit DAY, dynamic_partition.start -60, dynamic_partition.end 3, dynamic_partition.prefix p, storage_policy default, compaction_policy time_series );这个表的设计有几个关键决策值得展开说说。建倒排索引是这一步里的关键操作ALTER TABLE agent_trace_span ADD INDEX idx_prompt (prompt_text) USING INVERTED; ALTER TABLE agent_trace_span ADD INDEX idx_tool (tool_name) USING INVERTED; ALTER TABLE agent_trace_span ADD INDEX idx_error (error_message) USING INVERTED;倒排索引建在prompt_text、tool_name、error_message上不是为了锦上添花而是为了解决两类真实问题第一排查Agent 说了什么导致调用出错直接在 prompt 里搜关键词即可定位到具体的 trace_id第二新上线一个工具后想观察它被调用的整体情况直接对 tool_name 做 filter 就可以秒级拿到全量链路。选用 UNIQUE KEY 模型是因为在 Agent 执行场景里一个 span 不是一次写入就不变了。异步工具调用的典型流程是发请求 - 记录一条 span状态为 pending- 工具回调 - 更新这条 span 的状态、耗时和出参。如果没有更新能力就只能插入一条新记录那链路就乱了。UNIQUE KEY(trace_id, span_id) 保证了同一个节点不会出现重复数据更新时按主键覆盖即可。按天动态分区覆盖了我们的数据生命周期诉求。新数据自动创建分区写入老分区自动淘汰完全不需要人工介入。执行状态表 ExecutionState这张表负责回答现在跑着的任务执行到哪了这类实时监控问题。和 TraceSpan 不同这张表每个 trace_id 只有一行是 Agent 整体执行状态的最新快照。CREATE TABLE agent_execution_state ( trace_id VARCHAR(64) COMMENT 全链路ID, agent_id VARCHAR(128) COMMENT Agent实例标识, session_id VARCHAR(128) COMMENT 会话ID, status VARCHAR(16) COMMENT 任务最终/当前状态: running/success/failed/timeout/completed_with_errors, current_stage VARCHAR(32) COMMENT 当前所处的执行阶段如planning/tool_executing/analyzing, steps_planned INT COMMENT 规划的总步数, steps_completed INT COMMENT 已完成的步数, llm_call_count INT COMMENT LLM调用总次数, tool_call_count INT COMMENT 工具调用总次数, total_tokens INT COMMENT 累计Token消耗, total_cost DECIMAL(10, 4) COMMENT 累计费用(USD), root_error_message TEXT COMMENT 导致失败/异常的根本错误信息, start_time DATETIME(3) COMMENT 任务开始时间, update_time DATETIME(3) COMMENT 最后一次状态更新时间, extra_attrs JSON COMMENT 预留字段 ) UNIQUE KEY(trace_id) DISTRIBUTED BY HASH(trace_id) BUCKETS 24 PARTITION BY RANGE(update_time) () PROPERTIES ( dynamic_partition.enable true, dynamic_partition.time_unit DAY, dynamic_partition.start -30, dynamic_partition.end 3, dynamic_partition.prefix p );这张表的查询模式非常明确高并发点查 少量维度聚合。用户打开执行中任务页面时后端就是按statusrunning做扫描点进具体任务时就是按 trace_id 点查。由于分布方式也是 HASH(trace_id)点查的响应速度很快。这里有一个细节值得注意TraceSpan 表和 ExecutionState 表的分区键不同。TraceSpan 用写入时间ingestion_time分区因为分析通常按照问题发生的时间来检索ExecutionState 用 update_time 分区因为这张表天然是按最近变更来刷选的。分区键选错会让查询多扫很多无关分区这是设计初期容易忽略的坑。工具维度汇总表 ToolDailySummary这张表不是必需的但我想拿它当作用 Doris 做周期性 ETL的示例。我们每隔 10 分钟会跑一轮 Doris 内部的INSERT INTO ... SELECT任务把过去 10 分钟的 trace span 聚合到工具维度写入这张汇总表。这样查询工具调用趋势、成功率、P95 耗时的时候只需要扫这一张小表毫秒级出结果不用每次都对巨大的明细表做全量聚合。CREATE TABLE tool_daily_summary ( tool_name VARCHAR(256) COMMENT 工具名, stat_date DATE COMMENT 统计日期, stat_hour TINYINT COMMENT 统计小时, call_count BIGINT COMMENT 调用次数, success_count BIGINT COMMENT 成功次数, fail_count BIGINT COMMENT 失败次数, timeout_count BIGINT COMMENT 超时次数, total_duration_ms BIGINT COMMENT 总耗时(毫秒), avg_duration_ms BIGINT COMMENT 平均耗时, p50_duration_ms BIGINT COMMENT P50耗时, p95_duration_ms BIGINT COMMENT P95耗时, p99_duration_ms BIGINT COMMENT P99耗时, unique_users BIGINT COMMENT 触发该工具调用的去重用户数 ) UNIQUE KEY(tool_name, stat_date, stat_hour) DISTRIBUTED BY HASH(tool_name) BUCKETS 12;这个表用了 UNIQUE KEY 模型而不是聚合模型原因是 10 分钟的聚合任务会重复写入同一个小时的数据因为任务可能失败重跑UNIQUE KEY 保证了重跑时按主键覆盖不会产生重复计数。这里踩过一个坑后面在问题章节细说。4. 实操落地与核心查询场景实现4.1 埋点设计Agent Runtime 里到底埋了哪些点先给一段我们实际在 Agent Runtime 里的埋点代码展示一个工具调用的 span 是怎么创建的。这段代码是简化过的真实环境里还包含上下文注入、超时处理等逻辑但核心结构不变。from opentelemetry import trace from opentelemetry.trace import SpanKind, Status, StatusCode import json tracer trace.get_tracer(agent.runtime.tool) def execute_tool_with_tracing(tool_name, tool_params, agent_context): # 创建span明确指定span_type和tool_name span tracer.start_span( nameftool_call:{tool_name}, kindSpanKind.INTERNAL, attributes{ trace_id: agent_context.trace_id, span_type: tool_call, tool_name: tool_name, agent_id: agent_context.agent_id, session_id: agent_context.session_id, } ) span.set_attribute(input_params, json.dumps(tool_params)) try: result actual_tool_execute(tool_name, tool_params) span.set_attribute(output_result, json.dumps(result)) span.set_status(Status(StatusCode.OK)) return result except Exception as e: span.set_attribute(error_message, str(e)) span.set_status(Status(StatusCode.ERROR)) raise finally: span.end()在 OpenTelemetry 里trace_id、span_id、parent_span_id之间的关联关系由 SDK 自动维护业务埋点不需要手动传。这一点特别重要——如果让每个开发手动在日志里串 ID一定会有遗漏链路就断了。埋点最核心的原则是全自动、零手工、统一走 SDK。我们埋点的类型分五类每类都有明确的语义约定planningAgent 在规划阶段生成执行计划的动作span_name 可带规划结果的摘要。llm_call每一次 LLM 调用record prompt 原文和 response 原文并且带上 token 用量信息。tool_call工具调用记录入参、出参、错误信息。agent_statusAgent 状态跳变的记录比如从 planning 进入 tool_executing或进入 need_help 状态。system_event系统级事件比如上下文窗口超限、权限校验拒绝、重试机制触发等。4.2 数据导入Routine Load 消费 Kafka 的配置实例数据从 Kafka 导入 Doris我们用的是 Routine Load。相比手动 Stream LoadRoutine Load 的优势是可以长期常驻、按位点自动消费、自动提交 offset非常适合Kafka 里有持续不断的数据流这个场景。下面是我们生产环境在用的 Routine Load 创建语句脱敏后的核心参数CREATE ROUTINE LOAD agent_obs_span_load ON agent_trace_span COLUMNS( trace_id, span_id, parent_span_id, agent_id, session_id, user_id, span_type, span_name, status, start_time, duration_ms, model_name, tool_name, prompt_text, response_text, input_params, output_result, error_message, extra_attrs, ingestion_time now() ) PROPERTIES ( desired_concurrent_number 8, max_batch_interval 10, max_batch_rows 200000, max_error_number 100, strict_mode false, format json, jsonpaths [\$.trace_id\, \$.span_id\, ...] ) FROM KAFKA ( kafka_broker_list 192.168.1.10:9092,192.168.1.11:9092, kafka_topic agent-observability-span, kafka_partitions 0,1,2,3,4,5, kafka_offsets OFFSET_BEGINNING );几个参数的经验值desired_concurrent_number设成了 8配合 Kafka 的 6 个分区基本能保证每分区一个并发消费。设太大会导致小批次过多、频繁提交没必要设太小则消费跟不上生产速度。max_batch_interval设成 10 秒这是为了平衡实时性和批大小。如果 Agent 高峰期每秒产生几万条事件10 秒一个批次已经能把链路延迟控制在秒级用户打开追踪页面看到的数据最多滞后十几秒。strict_mode设为 false允许部分字段缺失时用默认值填充。因为 Agent 事件的字段在不同阶段差异很大比如 LLM 调用节点没有 tool_name工具调用节点没有 model_name如果用 strict mode这些事件会因为字段缺失被丢弃链路就不完整了。4.3 链路回放查询把一次 Agent 执行的全过程捞出来链路回放是使用频率最高的功能。用户报障之后第一件事永远是把当时那条 trace 完整拉出来看看。我们用两步完成第一步按 trace_id 查 ExecutionState 表拿到任务整体状态和错误摘要快速判断任务是否失败、卡在哪个阶段。SELECT trace_id, status, current_stage, steps_planned, steps_completed, llm_call_count, tool_call_count, total_cost, root_error_message FROM agent_execution_state WHERE trace_id a1b2c3d4e5f6a7b8c9d0e1f2;第二步按 trace_id 查 TraceSpan 表按开始时间排序拿到完整节点列表。SELECT span_id, parent_span_id, span_type, span_name, status, start_time, duration_ms, model_name, tool_name, prompt_text, response_text, input_params, output_result, error_message FROM agent_trace_span WHERE trace_id a1b2c3d4e5f6a7b8c9d0e1f2 ORDER BY start_time ASC;在自研的诊断平台前端我们用树形组件展示这些节点的父子关系用户点开任意节点就能看到这一层的完整输入输出。这一步的实现逻辑不复杂核心就是把 parent_span_id 的层级关系在内存里构建成树。这个功能在 ES 时代几乎没法用因为当时没有统一的 trace_id 贯穿所有日志一条链路的数据散落在几个索引里拼都拼不回来。Doris 的 UNIQUE KEY 模型让我们能保证链路的完整性和一致性这是整个透明化体验的基础。4.4 根因分析从整个任务慢定位到哪一次工具调用慢用户反馈 Agent 变慢了是高频问题。但 Agent 慢的原因和传统接口慢完全不同——可能是一个工具调用重试了三次、可能是 LLM 推理自己在死循环、也可能是上下文太长导致 token 处理变慢。我们需要把慢拆解到具体节点。我们实现了一个Top N 慢节点分析查询给定一个时间范围和一个 Agent 标识把该 Agent 在最慢的 20 条任务里最耗时的节点逐条列出来。WITH slow_tasks AS ( SELECT trace_id FROM agent_execution_state WHERE agent_id sales_analyst_v3 AND update_time now() - INTERVAL 1 HOUR AND update_time now() AND status success ORDER BY total_cost DESC LIMIT 20 ) SELECT t.trace_id, t.span_type, t.span_name, t.tool_name, t.model_name, t.duration_ms, t.status, t.error_message FROM agent_trace_span t INNER JOIN slow_tasks s ON t.trace_id s.trace_id WHERE t.duration_ms 5000 ORDER BY t.duration_ms DESC LIMIT 50;这个查询用到了 Doris 的 CTE 和 JOIN 能力实际响应时间在 200ms 左右。它解决了一个很实际的问题不再需要用户在几十条任务里人工挑一个看起来慢的然后逐条翻 span 去找瓶颈。系统直接把最有可能出问题的节点顶到最前面。我们运维团队后来在这个查询基础上做了自动告警规则如果同一工具在连续 10 个慢任务里出现超过 5 次就发告警某某工具可能是全局性能瓶颈。这个规则上线之后很多潜在的退化在用户察觉之前就被处理掉了。这就是从黑盒到透明的红利——不只是出了问题能查而是能主动发现隐患。4.5 Token 成本与模型行为分析成本分析是我们另一个重点场景。每次 LLM 调用的 token 数都记录在 span 的extra_attrs里但我们不只是要看单次调用的成本而是要做多维归因。下面这个查询回答的问题是最近 7 天每个 Agent 上花费的钱主要花在哪个环节SELECT agent_id, CASE WHEN span_type llm_call THEN LLM调用 WHEN span_type tool_call THEN 工具调用 WHEN span_type planning THEN 任务规划 ELSE 其他 END AS cost_stage, SUM(extra_attrs.total_tokens) AS total_tokens, SUM(extra_attrs.total_cost) AS total_cost FROM agent_trace_span WHERE ingestion_time now() - INTERVAL 7 DAY AND extra_attrs.total_tokens IS NOT NULL GROUP BY agent_id, cost_stage ORDER BY total_cost DESC;注意这里用到了extra_attrs.total_tokens这种 JSON 字段内部的提取语法。在 Doris 里可以像访问普通列一样访问 JSON 子字段需要保证该字段在数据写入时有统一的类型这让我们不需要为每种事件类型都建一张专门的表扩展性非常强。跑出来的结果出乎我们意料原本以为工具调用是最贵的环节毕竟调的 LLM 多但分析后发现任务规划阶段占了 40% 以上的成本因为规划经常因为意图不明而重复调用模型。于是我们给规划阶段加了一个意图确认的前置交互让用户先确认需求再进入规划——规划失败率明显下降整体成本降了 17%。这个优化能做成完全是因为成本数据被清晰地拆到了每个环节、每个 Agent 上如果没有这套数据这种优化根本无从谈起。4.6 全文检索从海量交互里捞出关键会话前面提到我们在 TraceSpan 上建了倒排索引这里给出实际用法。比如运营同学反馈有用户说 Agent 推荐的报表不对好像是模型对它说了上季度同比增长理解错了我想找到所有提到同比的失败会话。SELECT trace_id, session_id, start_time, status, error_message FROM agent_trace_span WHERE prompt_text MATCH_ANY 同比,增长率,季报 AND span_type llm_call AND ingestion_time now() - INTERVAL 7 DAY LIMIT 100;MATCH_ANY是 Doris 倒排索引的匹配语法相当于OR语义。这个查询在全量 10 亿行级别的数据上响应时间在 1 秒内。在 ES 时代这个查询也能做但问题在于我们不想为了这个查询单独维护一套 ES 集群Doris 一体化的价值就在这里体现了。4.7 可视化Grafana 与自研页面的搭配可视化层我们做了双轨制。监控大盘全部走 GrafanaDoris 提供了 MySQL 协议的数据源所以在 Grafana 里加一个 MySQL 数据源指向 Doris 就完事了。我们用 Grafana 做了三个核心面板Agent 执行热度图按小时统计不同 Agent 的执行成功/失败数量一眼看出哪个时间点出现了失败波峰。工具调用 Top 榜每分钟刷新列出最近 10 分钟被调用最多的工具及其成功率。Token 消耗趋势展示全平台 Token 消耗的时序曲线配合预算线做费用监控。自研诊断平台则用 Grafana 数据源的插件直接查询 Doris做链路回放、根因分析、会话详情展示这类 Grafana 不太方便的交互式页面。前后端走 Rest API后端 Java 项目里直接用 MyBatis 连 Doris连数据源切换的成本都省了。5. 落地过程中踩过的坑与排查实录5.1 倒排索引和查询性能的隐形前置条件倒排索引不是建了就一定生效。我们第一次建完索引后执行一个对prompt_text的MATCH_ANY查询发现响应时间竟然要十几秒完全没有利用到索引。后来排查发现查询条件里必须带上索引列的常量过滤表达式等值条件来触发索引匹配否则优化器可能选择全表扫描。这个问题的本质是 Doris 的查询优化器需要看到明确的过滤条件才能选择索引路径。我们踩坑后的修正方法把所有全文检索查询统一封装成固定模板模板中强制带上ingestion_time的时间范围和span_type的等值条件这样优化器能清晰判断走索引是最优路径。加上条件之后同样的查询从十几秒降到了百毫秒级。另一个细节是倒排索引对短语匹配和高频词的查询效率一般。如果用户搜一个在大量会话里都出现的常见词比如你好匹配结果集巨大索引优势就不明显了。我们后来在业务层加了一个推荐检索词的逻辑帮用户聚焦更具体的业务关键词算是亡羊补牢。5.2 数据倾斜工具调用事件多到把单个分桶压垮写入一段事件后我们很快发现一个严重的性能问题某个热门工具比如查询订单的调用量占了全局 80% 以上而在按HASH(trace_id)分桶的 TraceSpan 表上数据分布完全取决于 trace_id。初看似乎没问题因为 trace_id 是随机的但实施起来发现并不是这样。实际情况是我们的批处理任务会批量触发 Agent很多 trace_id 是通过 UUID 生成的看起来随机但大量热点工具的调用集中在少数几个高频用户/高频会话里这些会话的 trace 在短时间内高频产生大量 span导致对应的几个分桶数据量暴涨其他分桶却很空闲。Doris 的查询是按分桶并行的个别桶数据量过大就会拖慢整个查询。我们做的调整有两点。第一是把 TraceSpan 表的分桶键从HASH(trace_id)改为HASH(trace_id, span_type)这样即使同一个 trace 有大量工具调用也会被分散到不同的分桶。第二是对最热的几个工具做了定期任务将它们的 span 数据单独导到一张热点工具表查询时走分表。这两个调整让查询性能的 P95 从 2 秒降到了 400ms 以内。5.3 UNIQUE KEY 模型的重跑陷阱聚合任务不能直接覆盖前面提到tool_daily_summary用了 UNIQUE KEY 模型这里有一个生产环境真实踩过的坑。最初我们在做 10 分钟聚合任务时用INSERT INTO tool_daily_summary SELECT ... FROM agent_trace_span WHERE ...以为 UNIQUE KEY 模型会在再次写入相同主键时自动覆盖旧数据。但实际运行发现如果聚合任务因为上游数据延迟导致同一统计窗口的数据被重复聚合并且重跑时的结果比第一次少比如第一次跑了 1000 条调用第二次只读到 900 条UNIQUE KEY 会直接用第二次的 900 条覆盖第一次的 1000 条造成计数丢失。解决方案不是改用聚合模型而是给聚合任务增加一个写入前去重的幂等逻辑。我们的做法是在聚合任务里先按 trace_id 对明细数据做 distinct确保同一个 trace_id 只被统计一次再写入汇总表。这个方法简单有效血泪教训写在这里希望大家别重蹈覆辙。5.4 大字段写入的隐性成本prompt 原文不能一股脑全存我们最开始的设计是把每次 LLM 调用的完整 prompt 和 response 都明文存入 TraceSpan 表。这带来的两个问题超出了预期第一个是存储膨胀。一次复杂的 Agent 任务里可能调用 LLM 10 次以上每次调用光 prompt 可能就有 3000~5000 个 token加上 response单次链路的大文本数据能达到 20~50KB 甚至更多。我们算了下一天的原始文本数据直接膨胀到了 600GB存储成本瞬间失控。第二个是写入性能下降。大字段的写入对 Doris 的 compaction 压力很大特别是前面还有一个索引要实时更新。我们曾经在高峰期观察到 Doris 的 compaction 积压写入延迟从秒级退化为分钟级最后排查发现主要就是大字段带来的写入放大。最终的处理方案是将大文本字段从 TraceSpan 主表中剥离存到单独的对象存储OSSTraceSpan 里只保留一个text_ref_url字段指向对象存储的文件路径。需要看原文时再按 URL 去取。这样既保留了完整语义又把 Doris 的存储和写入成本降到可接受范围。这是一个典型的时间换空间决策实际操作中强烈建议一开始就做后期拆分的成本要高很多。5.5 时间语义混淆事件时间与写入时间必须分清楚这个坑比较隐蔽但影响了我们早期的多个报表结果。Agent 事件里有两个时间概念事件实际发生的时间event_time和数据写入 Doris 的时间ingestion_time。由于 Kafka 削峰和 Routine Load 批次处理这两个时间可能相差数十秒甚至几分钟。如果我们用ingestion_time做分区但业务报表却按start_time事件时间统计就会导致下午 3 点的数据被统计到了 3 点 05 分的分区里。我们早期的成本报表就因此出现了 10% 左右的偏差排查了半天才发现是时间口径不一致。最终的约定是所有统计类查询必须显式指定使用哪个时间字段分区字段和统计字段不能混用。在 Constraint 层面我们没法强制只能靠代码规范和查询模板约束。对于 Doris 来说分区键 查询过滤条件必须用同一个时间字段否则会大量扫盘这是设计表结构之前必须想清楚的事。6. 后续演进从可观测到可优化的闭环改造完成不是终点。Doris 落地之后我们最意外的收获是这套可观测性数据不光能看还能反向喂给 Agent 做自我优化。具体来说我们正在做三件事第一件事是失败链路的自动标注。我们会在 TraceSpan 里给每个失败的 span 打上失败原因标签比如权限不足工具超时模型输出格式错误然后定期用这些标签训练一个失败原因分类器。这样新的链路失败时系统可以第一时间预测失败原因而不是等人去看日志。第二件事是基于链路数据的 Prompt 优化。既然我们已经存储了全部的 prompt 和 response就可以定期离线分析哪些 prompt 导致模型进入了低质量循环、哪些 prompt 触发了不必要的工具调用。分析结果用于回头改进 Agent 的系统提示词和工具描述。这个循环在之前黑盒状态下是完全不可想象的。第三件事是建立语义级SLO。传统 SLO 看可用性、延迟对 Agent 来说这些远远不够。我们正在设计一套任务目标达成率的指标即在 ExecutionState 表上增加一个task_goal_achieved字段由后置的用户反馈或结果校验逻辑填值用 Doris 的聚合能力实时监控多少比例的任务真正完成了用户目标。这个指标才是 Agent 质量的核心。最后说一点个人的体会。在整个改造中我发现真正困难的不是 Doris 的技术细节而是对自己的 Agent 系统建立清晰的观测模型。你需要知道哪些环节是关键决策点、哪些信息必须完整保留、哪些字段需要支持检索、哪些数据直接决定成本——这些问题想清楚技术选型反而水到渠成。Apache Doris 给了我们一个足够宽的数据底座去承载这些思考但它替代不了对 Agent 系统本身的深度理解。希望这篇复盘能帮你少走一些弯路如果你的 Agent 也处在能跑但看不懂的阶段从建好第一张 trace 表开始一步一步把黑盒变成透明这中间的收益远比你想象的大。
返回列表