我们的 Agent 平台上线几个月后,一个尴尬的场景反复出现:用户问"Agent 当时为什么选了这个工具?为什么没召那篇知识库?",我们只能耸耸肩——除了 SSE 日志里的零散事件和 SQL 表里几条 token 数字,什么也拿不出来。

请求级日志(trace_id + work_log)能告诉你"一次请求花了 3 秒、用了 5000 token",但回答不了"模型在第 2 步看到了什么上下文、为什么选择了 tool A 而不是 tool B"。从请求级到决策级,中间差着一整套数据模型。

这篇文章记录我们设计 Agent Trace 数据模型的过程:怎么把 LLM 推理链路抽象成 trace/span 结构、五种 span 类型分别存什么、为什么坚持存完整快照而不是引用、以及为什么又选了 ClickHouse。


1. 问题:请求级可观测性不够

改造前,平台的观测能力是这样的:

请求级(已有)
├── trace_id → JSON 日志(结构化,但散落在 Elasticsearch)
├── work_log → MySQL("谁在什么时候做了什么",事件粒度)
├── chat_message → ClickHouse(对话全文,消息粒度)
└── reasoning_session → MySQL(token 用量,会话粒度)

决策级(缺失)
├── 模型调用时的完整 system prompt 长什么样?
├── 这次调用的 tools 清单是什么?
├── 模型思考过程(reasoning stream)原文是什么?
├── 工具调用的参数和返回值是什么?执行了多久?
├── KB 检索命中了哪些 chunk?
└── 子 Agent 消息在哪两个 session 之间传递?

上面的每一项,分散在代码的不同位置:system prompt 在 PromptBuilder 拼完就丢了,思考流在自研 ChatModel 适配层里流过就没了,工具参数在 ToolProvider 的回调闭包里用完就 GC 了。合在一起能拼出故事,但没人把它们"串"起来。

目标:一次推理 = 一个 trace,trace 内的每个决策动作 = 一个 span。任意历史会话,能回答"Agent 当时看到了什么、做了什么、为什么"。


2. 数据模型:为什么是 Trace/Span

2.1 为什么不用"一张大宽表把所有字段塞进去"

最偷懒的方案是给 chat_message 表加几十个可空列——有工具调用就填工具列,有 KB 检索就填 KB 列。这个方案在写第一行代码之前就被否决了:

问题 说明
一对多 一轮 LLM 调用可能触发 N 个工具调用 + M 次 KB 检索,大宽表无法表达
层级 工具调用是 LLM 调用的"子动作",扁平行丢失了父子关系
不同 span 的字段完全不重叠 llm_call 需要 prompt/token/thinking,tool_call 需要参数/结果/latency,强行合并 = 大量空列

Trace/Span 模型天然匹配这个场景:一个 trace 包含多棵 span 树,每种 span 类型存各自的专属信息,共享字段在顶层(trace_id、session_id、时间戳)。

2.2 Span 类型

一轮对话中的决策动作归纳为五种:

span_type 含义 专属信息
llm_call 一次 LLM 调用(含 tool-calling 的 multi-step 循环,每个 step 是一个 span) system prompt 全量、messages 历史、tools 清单、模型参数、输出全文、思考流、finish_reason、token 用量
tool_call 一次工具调用 工具名、输入参数、返回结果(截断)、耗时、是否被去重跳过
kb_search 一次知识库检索 query 文本、命中 chunk id 列表、相似度得分
send_message 向子 Agent 发送一条消息 目标 Agent、消息类型(task/clarify/progress/result)、内容
sub_agent 子 Agent 生命周期 子 session_id、启动/退出事件、总步数

一次典型的用户对话产生的 trace 结构:

Trace: trc_a1b2c3d4e5f6
├── span: spn_001  [llm_call]     ← root:模型看到 system prompt + 用户问题
│   ├── span: spn_002  [kb_search]  ← 模型调了 bt_search_knowledge
│   ├── span: spn_003  [tool_call]  ← 模型调了 web_search
│   └── span: spn_004  [send_message] ← 模型决定派活给子 Agent
├── span: spn_005  [sub_agent]    ← 子 Agent 启动
│   └── span: spn_006  [llm_call]   ← 子 Agent 的推理
└── span: spn_007  [llm_call]     ← 主 Agent 收到结果后继续推理

2.3 字段设计

CREATE TABLE agent_trace (
    trace_id            String,       -- trc_ + 12位hex,一次推理 = 一个 trace
    span_id             String,       -- spn_ + 12位hex
    parent_span_id      String,       -- 父 span,root 为空
    session_id          String,       -- 归属会话
    instance_id         String,       -- Agent 实例
    space_id            String,       -- 工作空间(冗余:跨空间聚合分析)
    user_id             Int64,        -- 内部用户(冗余)
    end_user_id         String,       -- 终端用户(冗余)
    span_type           LowCardinality(String),  -- llm_call/tool_call/kb_search/send_message/sub_agent
    request_snapshot    String,       -- 请求快照 JSON,上限 64KB
    response_snapshot   String,       -- 响应快照 JSON,上限 64KB
    prompt_tokens       UInt32,
    completion_tokens   UInt32,
    total_tokens        UInt32,
    latency_ms          UInt32,       -- span 耗时
    status              LowCardinality(String),  -- ok / error
    error               Nullable(String),
    metadata            Nullable(String),  -- 扩展 JSON
    created_at          DateTime
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (session_id, created_at, span_id)
TTL created_at + INTERVAL 90 DAY DELETE;

几个设计决策:

ORDER BY (session_id, created_at, span_id)。最高频查询是"某场会话的所有 span 按时间排列"——排序键直接服务这个查询,一次范围扫描拿到整棵树,无需二次排序。

维度字段冗余(space_id / user_id / end_user_id)。跟 chat_message 表的设计哲学一致——ClickHouse 不做 JOIN。没有这些冗余列的话,"过去 7 天 tool_call 平均延迟"这种聚合查询需要先查 MySQL 拿 space_id → session_id 映射,在应用层拼进 SQL,列存优势荡然无存。

LowCardinality(String) on span_type / status。span_type 只有 5 个枚举值,status 只有 2 个。ClickHouse 的 LowCardinality 用字典编码,存储开销接近 tinyint,查询时自动展开。

PARTITION BY toYYYYMM(created_at)。按月分区,TTL 到期自动整分区删除,清理成本为零——不会触发 DELETE mutation。

UInt32 for tokens / latency。token 用量是计数,延迟是毫秒,都是非负整数。用 UInt32 而非 Int64 既省存储又自描述语义。


3. 快照策略:为什么存完整 prompt 而不是引用

这是整个设计里最贵的决定,也是最关键的。

3.1 两种方案

引用方案 快照方案(我们选的)
做法 trace 里只存 prompt_template_version=v2 + kb_ids=[...] trace 里存组装后的完整 prompt 文本
存储 几十字节 几十 KB
重放 需要重跑 PromptBuilder——技能可能已经改了、KB 文章可能更新了 拿 snapshot 直接塞进 LLM,原样重放
排查 看到的是"用了哪些组件",看不到"组件拼出来是什么" 一眼看到模型实际收到了什么

3.2 为什么引用方案不 work

假设一个场景:用户两周后反馈"Agent 那天推荐的产品不对"。你打开 trace,看到了 kb_ids=[kb_123]。你去查 kb_123 的当前内容——但这篇文章在过去两周已经被编辑过三次。你不知道模型当时看到的到底是什么。

引用方案有一个隐含假设:被引用的内容是不可变的。但 Agent 平台的 prompt 模板、技能定义、知识库文档都在持续迭代——引用等于丢失了"当时"的上下文。

只有快照才能支持回放。 回放(用同样的 prompt 原样重发给模型)是决策血缘的杀手特性——它让你能回答"如果用新版 prompt 重跑这次对话,结果会更好吗"。

3.3 截断策略

完整快照的代价是存储。一个 system prompt 可能有 8KB,加上 messages 历史轻松几十 KB。我们的策略:

  • 硬上限 64KBrequest_snapshotresponse_snapshot 各自截断在 64KB
  • 截断留痕:截断后在 JSON 末尾追加 "...[truncated at 65536 bytes]" 标记
  • 大结果后续优化:工具返回的大 JSON(如整页爬取结果)后续可落对象存储,trace 里存引用

当前内部平台的请求量(日均几十个会话),全量快照的存储开销可以忽略。等量上来之后,采样率配置是比模型复杂度更划算的优化——当前不需要。


4. 存储:为什么又是 ClickHouse

关于 ClickHouse 的详细选型论证,见《用 ClickHouse 做对话消息存储》。这里只补充 agent_trace 与 chat_message 的差异点。

相同的理由(不再展开):

  • append-only 写入模式与 MergeTree 天然契合
  • 零新组件——ClickHouse 已在生产环境运行
  • TTL 原生支持,清理成本为零

差异点

chat_message agent_trace
写入频率 每轮对话 2 条 每轮对话 5~15 条(每个决策动作一个 span)
查询模式 按 session 查消息列表 按 session 查时间轴 + 按 trace 查完整树 + 聚合分析(延迟分布、工具成功率)
单行大小 ~1 KB 10~80 KB(快照占大头)
TTL 180 天 90 天(快照更大,保留期更短)
冷热分离 不需要 后续可能需要:热数据(近 7 天)在 SSD,冷数据归档

trace 的写入频率是 chat_message 的 37 倍,而单行大一个数量级。这是"为什么选 ClickHouse"的强化论据——如果 trace 存 MySQL,两个月就能把 InnoDB 的表空间撑到几十 GB,而 ClickHouse 的列存压缩(尤其 String 列的 LZ4)能把快照 JSON 压到原来的 1/51/10。


5. 分层落地:Trace 只是 ClickHouse 的一个 Gateway

跟项目现有的分层约束一致——Domain 定义 SPI,Infrastructure 实现:

domain/trace/
├── TraceSpan.java           # 实体(纯 Java,零存储依赖)
└── TraceGateway.java        # SPI 接口:insertBatch / listBySession / getTrace

infrastructure/clickhouse/
├── TraceSpanDO.java              # ClickHouse 表映射
├── TraceSpanConvertor.java       # 实体 ↔ DO 转换
├── TraceGatewayImpl.java         # SPI 实现(JDBC 批量写入)
└── ClickHouseSchemaInitializer   # 启动时幂等建表

SPI 接口只有三个方法:

public interface TraceGateway {
    void insertBatch(List<TraceSpan> spans);
    List<TraceSpan> listBySession(String sessionId);
    List<TraceSpan> getTrace(String traceId);
}

上层调用方(埋点代码、查询 API)只看见 TraceGateway,不感知底层是 ClickHouse。跟 ChatMessageGateway 共享同一个 ClickHouseConfig 连接配置,实现模式也完全一致——JDBC PreparedStatement.addBatch() 批量写入,失败只记 error 日志不抛异常。

对于无事务的 ClickHouse,我们沿用与 chat_message 相同的 schema 管理策略:ApplicationRunner 启动时执行 CREATE TABLE IF NOT EXISTS,幂等安全。


6. 当前状态与后续

已落地(完整)

阶段 内容 详情
存储层 数据模型 + ClickHouse 表 + Gateway 实现 本文 §2~§5
异步写入 TraceRecorder:虚拟线程 + 内存队列 + 容错批量 flush Agent 上下文传播:当 ThreadLocal 不够用 §3
llm_call 埋点 root span:prompt 快照 + 思考流 + token 用量 + finish reason Trace Replay:从快照到回放 §1
tool_call 埋点 参数/结果(4KB 截断)+ latency + dedup 标记 同上
send_message/sub_agent 埋点 跨 session 血缘串链(parentTraceId 消息传参) 上下文传播 §4
查询 API GET /reasoning/traces/{sessionId}(时间轴)+ GET /admin/traces/{traceId}(完整树)
前端时间轴 span 瀑布视图 + 按 traceId 分组 + 快照展开 + 子 Agent 下钻
Trace 回放 快照原样重发 + 换模型 + 副作用隔离 + SSE 流式对比 Trace Replay 专文

后续规划

  • 聚合分析:工具成功率趋势、模型延迟分布、token 成本归因
  • 批量评测:取 N 个历史 trace,对候选模型批量回放,自动统计胜率
  • 并排 Diff:原输出和回放输出逐段 diff 高亮

7. 经验总结

  1. 请求级可观测性不够——Agent 平台需要决策级。 trace_id 能告诉你"谁调了什么",span 能告诉你"为什么这么调"。前者是运维视角,后者是产品视角。

  2. Trace/Span 不是微服务专用。 这个概念在 LLM Agent 场景一样适用——LLM 调用是 root span,工具调用和 KB 检索是子 span,父子关系天然表达"模型决定做什么 → 执行"的因果链。

  3. 存快照,不存引用。 prompt 模板和知识库是活的,引用等于丢失上下文。64KB 截断加标记是务实的折中——绝大多数 prompt 在截断线以下,超出的是异常大请求(本身就值得关注)。

  4. 用对存储。 agent_trace 的写入量和行大小都比 chat_message 高一个量级——如果当初选了 MySQL,现在已经要面对分库分表的账单。ClickHouse 的列存压缩让快照的存储开销可控。

  5. SPI 分层让存储选型可逆。 如果将来需要换存储(比如 ClickHouse → 自建列存集群,或者冷热分层),改动范围被严格限制在 infra 层的一个目录里。模块化单体不是"大泥球",它只是把物理部署推迟到了必须拆的那一天。