我们的 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。我们的策略:
- 硬上限 64KB:
request_snapshot和response_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. 经验总结
请求级可观测性不够——Agent 平台需要决策级。 trace_id 能告诉你"谁调了什么",span 能告诉你"为什么这么调"。前者是运维视角,后者是产品视角。
Trace/Span 不是微服务专用。 这个概念在 LLM Agent 场景一样适用——LLM 调用是 root span,工具调用和 KB 检索是子 span,父子关系天然表达"模型决定做什么 → 执行"的因果链。
存快照,不存引用。 prompt 模板和知识库是活的,引用等于丢失上下文。64KB 截断加标记是务实的折中——绝大多数 prompt 在截断线以下,超出的是异常大请求(本身就值得关注)。
用对存储。 agent_trace 的写入量和行大小都比 chat_message 高一个量级——如果当初选了 MySQL,现在已经要面对分库分表的账单。ClickHouse 的列存压缩让快照的存储开销可控。
SPI 分层让存储选型可逆。 如果将来需要换存储(比如 ClickHouse → 自建列存集群,或者冷热分层),改动范围被严格限制在 infra 层的一个目录里。模块化单体不是"大泥球",它只是把物理部署推迟到了必须拆的那一天。