1. 从一堆看不懂的 Trace 说起:为什么需要智能聚类
做过 Agent 开发的人大概都有过这种体验:线上跑着几百上千个会话,每个会话里 Agent 要调用工具、检索知识、生成回复、可能还要多轮反思,一天下来 Trace 数据能堆到几个 GB。打开日志平台,满屏都是span_id、parent_span_id、latency、token_count,一条条翻下去眼睛都花了,可真正想找的"哪类任务最容易失败""哪种用户问法最烧 token""哪个工具调用链最拖后腿"这些问题,靠人肉翻日志基本等于大海捞针。
这就是"智能聚类:从海量 Trace 中理解 Agent 的行为和表现"这个项目要解决的核心痛点。简单说,它做的事情是:把 Agent 运行时产生的海量 Trace 数据,通过 Embedding 把每条执行链路(或者每个 Session)转成向量,再用聚类算法自动分组,让相似的执行模式聚到一起,最后从每个簇里提炼出可读的行为标签和性能指标。你不再需要一条条看 Trace,而是看"这个 Agent 一共有 7 类典型行为,其中第 3 类占了 40% 但失败率高达 18%"。
这套东西适合谁?我梳理了一下,大概三类人最用得上。第一类是Agent 应用开发者,尤其是做多工具编排、多轮对话的,需要持续优化 prompt 和工具链;第二类是AI 平台/基础设施工程师,要监控大量 Agent 实例的运行健康度;第三类是做 Agent 评测和研究的同学,需要从真实流量里归纳行为模式,而不是靠人工构造测试集。哪怕你只是刚入门 Agent 开发,理解这套思路也能帮你建立"用数据驱动优化"的意识,而不是凭感觉改 prompt。
关键词里的Trace、Agent、Session、Embedding、智能聚类这五个词,基本就是整条技术链路的骨架:Trace 是原料,Session 是聚合单元,Embedding 是表示手段,聚类是核心算法,最终服务于理解 Agent 的行为和表现。下面我按实际落地的顺序,把每个环节拆开讲,包括我踩过的坑和验证过的参数。
2. 整体设计思路:为什么是"Embedding + 聚类"这条路
2.1 先想清楚:我们要聚的到底是什么
这是整个项目最容易走偏的地方。很多人一上来就说"我要聚类 Trace",但 Trace 的粒度差别巨大。一条 Trace 可能是一个 HTTP 请求级别的 span,也可能是一个完整的 Agent 会话(Session)包含几十个 span。聚错了粒度,后面全白搭。
我的经验是分两个层次来看:
- Span 级别:单个工具调用、单次 LLM 请求。适合分析"哪类工具调用慢""哪类 prompt 消耗 token 多"。
- Session 级别:一次完整的用户交互,包含多轮对话和多次工具调用。适合分析"哪类任务整体表现差""用户意图分布"。
实际项目里,Session 级别聚类价值最高,因为 Agent 的行为和表现本质上是"完成一个任务的全过程",而不是孤立的单步。所以这个项目的核心聚合单元是 Session,Span 级别的信息作为 Session 向量的组成部分。
提示:如果你的 Trace 系统里 Session 概念不清晰(比如没有统一的 session_id 贯穿),第一步要先把 Session 边界定义好,否则聚类结果会非常混乱。这是我在两个项目里都遇到过的前置问题。
2.2 为什么选 Embedding 而不是手工特征
传统做法是手工提取特征:调用工具次数、总 token、耗时、错误码分布……然后喂给 KMeans。这套方法能用,但有两个硬伤。
第一,语义信息丢失。两个 Session 可能工具调用次数、耗时都差不多,但一个是"查天气",一个是"写代码",行为模式完全不同。手工特征很难捕捉这种语义差异。
第二,扩展性差。Agent 每加一个新工具、新能力,特征工程就要重做一遍。而 Embedding 是把整个执行链路(包括用户输入、工具名、工具参数摘要、Agent 回复摘要)拼成一段文本,用 Embedding 模型转成向量,语义相近的自然就靠近。
所以这个项目的技术选型是:用 Embedding 做语义表示,用聚类做无监督分组,再用 LLM 给每个簇生成人类可读的标签。这个组合的好处是端到端、可扩展,新增工具不需要改特征工程。
2.3 整体架构长什么样
我把整个链路拆成五步,后面每一节都会展开:
- Trace 采集与 Session 聚合:从日志/追踪系统拉数据,按 session_id 聚合成会话。
- Session 文本化:把结构化 Trace 转成一段可读文本(这是 Embedding 的输入)。
- Embedding 生成:调用 Embedding 模型得到向量。
- 聚类:降维 + 聚类,确定簇数量。
- 簇解读:统计每个簇的指标,用 LLM 生成行为标签。
这个架构的关键设计取舍在于:文本化那一步决定了上限。你喂给 Embedding 模型的文本质量,直接决定聚类效果。后面我会专门讲怎么拼这段文本。
3. 核心细节解析:Trace 到向量的关键处理
3.1 Session 聚合:把散落的 Span 串成一条线
Trace 数据通常是扁平的 span 列表,每个 span 有trace_id、span_id、parent_span_id、name、start_time、duration、attributes。要聚合成 Session,核心是找到贯穿一次完整交互的标识。
常见的有三种情况:
- 有明确的
session_id字段:最理想,直接 group by。 - 只有
trace_id:如果一次用户交互对应一个 trace,那 trace_id 就是 session 边界。 - 都没有:只能靠时间窗口 + 用户标识来切分,比如同一用户 30 分钟内的连续请求算一个 Session。
我实测下来,时间窗口切分是最容易出错的。窗口太短会把一次任务切成好几段,太长会把不相关的任务混在一起。经验值是:对话类 Agent 用 15-30 分钟,任务型 Agent 用 5-10 分钟,具体要看你的业务节奏。
聚合后每个 Session 应该包含:用户输入序列、Agent 的每一步动作(工具调用、LLM 调用)、每步的耗时和 token、最终输出、是否有错误。
3.2 Session 文本化:这一步决定聚类质量
这是整个项目里最需要动脑子的地方。Embedding 模型吃的是文本,所以我们要把结构化的 Session 转成一段能表达"这个 Session 干了什么"的文本。
我试过几种模板,最后稳定下来的格式大概是这样:
任务类型: 用户咨询 用户输入: 帮我查一下明天北京的天气,然后推荐穿什么 执行步骤: 1. 调用工具 weather_query, 参数 {city: 北京, date: 明天}, 耗时 320ms, 成功 2. 调用工具 clothing_recommend, 参数 {weather: 晴, temp: 5-15度}, 耗时 210ms, 成功 3. 生成回复: 明天北京晴,气温5到15度,建议穿薄羽绒服... 结果: 成功 总耗时: 1.2s, 总token: 850几个关键点:
- 工具名要保留,因为工具名本身就是强语义信号。
- 参数做摘要,不要把完整 JSON 塞进去,太长会稀释语义。比如
{city: 北京, date: 明天}就够了。 - 结果状态要带上,成功/失败是行为模式的重要维度。
- 用户输入要完整保留,这是意图的核心。
注意:不要把所有 span 的原始文本都拼进去,那样向量会被噪声淹没。我见过有人把完整日志拼进去,结果聚类出来的簇全是按日志长度分的,毫无意义。
3.3 Embedding 模型选型:别盲目追排行榜
热词里有"embedding模型排行",但我要泼盆冷水:排行榜第一不一定适合你的场景。选型要看三个维度。
| 维度 | 考虑点 | 我的建议 |
|---|---|---|
| 语言 | 中文/英文/混合 | 中文场景优先选中文优化过的模型 |
| 维度 | 向量维度大小 | 768-1024 维通常够用,太高反而增加聚类成本 |
| 成本 | 调用价格/自部署 | 数据量大时自部署更划算 |
| 长度 | 最大输入长度 | Session 文本可能较长,注意截断策略 |
我实际用下来,对于中文 Agent 场景,768 维左右的中文优化模型性价比最高。如果你的 Session 文本经常超过模型最大长度,要么做摘要,要么分段 Embedding 再平均,但分段平均会损失顺序信息,慎用。
还有一个坑:同一个项目里 Embedding 模型必须固定。中途换模型会导致新旧向量不在同一空间,聚类结果直接失效。如果非要换,得全量重新生成。
3.4 降维与聚类:参数怎么定
Embedding 出来是 768 维,直接聚类不是不行,但高维空间里距离度量会退化(维度灾难)。常规做法是先降维再聚类。
降维我用UMAP,比 PCA 保留的局部结构更好。关键参数:
n_neighbors:控制局部与全局的平衡,一般 15-50,数据量大就调大。min_dist:控制点的聚集程度,聚类场景建议 0.0-0.1,让同簇点更紧。n_components:降到多少维,聚类用 5-10 维比较合适,可视化用 2 维。
聚类算法选HDBSCAN而不是 KMeans,原因是:你事先不知道有多少类。KMeans 要指定 K,而 Agent 的行为类别是未知的。HDBSCAN 能自动确定簇数量,还能把噪声点单独标出来(这些往往是异常行为,很有价值)。
HDBSCAN 的核心参数是min_cluster_size,即一个簇最少多少个样本。这个值直接影响结果:太小会碎成很多小簇,太大会把不同行为合并。我的经验是设为总样本数的 1%-5%,比如 1000 个 Session 就设 10-50。
4. 实操过程:从原始 Trace 到行为报告
4.1 环境与依赖准备
先把依赖列清楚,避免版本踩坑:
pip install pandas numpy scikit-learn umap-learn hdbscan pip install openai # 或其他 Embedding 服务 SDK如果你用本地 Embedding 模型,还需要sentence-transformers和torch。我建议先用 API 跑通流程,验证效果后再考虑自部署降本。
4.2 第一步:加载并聚合 Session
假设你的 Trace 存在 JSONL 或数据库里,先读进来:
import pandas as pd # 假设每条记录是一个 span spans = pd.read_json("traces.jsonl", lines=True) # 按 session_id 聚合 sessions = spans.groupby("session_id").apply( lambda g: g.sort_values("start_time").to_dict("records") ).reset_index(name="spans") print(f"共聚合出 {len(sessions)} 个 Session")这一步要检查几个数据质量点:有没有 session_id 为空的、有没有单个 Session span 数异常多的(可能是聚合错误)、时间戳有没有乱序。我一般会先跑一遍统计,把异常 Session 剔掉再往下走。
4.3 第二步:Session 文本化
按前面说的模板,写一个转换函数:
def session_to_text(spans): lines = [] user_input = "" steps = [] total_duration = 0 total_tokens = 0 status = "成功" for i, span in enumerate(spans, 1): name = span.get("name", "") attrs = span.get("attributes", {}) duration = span.get("duration", 0) total_duration += duration if name == "user_input": user_input = attrs.get("text", "") elif name.startswith("tool_"): tool_name = name.replace("tool_", "") params = summarize_params(attrs.get("params", {})) ok = "成功" if not span.get("error") else "失败" steps.append(f"{i}. 调用工具 {tool_name}, 参数 {params}, 耗时 {duration}ms, {ok}") elif name == "llm_call": total_tokens += attrs.get("total_tokens", 0) steps.append(f"{i}. LLM生成, 耗时 {duration}ms") text = f"用户输入: {user_input}\n执行步骤:\n" + "\n".join(steps) text += f"\n结果: {status}\n总耗时: {total_duration}ms, 总token: {total_tokens}" return text def summarize_params(params): # 只保留关键字段,避免文本过长 if isinstance(params, dict): items = list(params.items())[:3] return "{" + ", ".join(f"{k}: {v}" for k, v in items) + "}" return str(params)[:100]summarize_params这个函数很关键,它决定了参数信息保留多少。我的经验是最多保留 3 个字段,每个值截断到 50 字符,再多就是噪声。
4.4 第三步:生成 Embedding
from openai import OpenAI import numpy as np client = OpenAI() def get_embedding(text, model="text-embedding-3-small"): resp = client.embeddings.create(input=text, model=model) return resp.data[0].embedding texts = [session_to_text(s) for s in sessions["spans"]] embeddings = np.array([get_embedding(t) for t in texts]) np.save("session_embeddings.npy", embeddings)数据量大时一定要批量调用 + 加缓存。我一般会把text -> embedding存成字典缓存到本地,避免重复调用浪费钱。另外注意 API 的速率限制,加个time.sleep或并发控制。
4.5 第四步:降维 + 聚类
import umap import hdbscan # 降维 reducer = umap.UMAP( n_neighbors=30, min_dist=0.0, n_components=10, metric="cosine", random_state=42 ) reduced = reducer.fit_transform(embeddings) # 聚类 clusterer = hdbscan.HDBSCAN( min_cluster_size=20, min_samples=5, metric="euclidean" ) labels = clusterer.fit_predict(reduced) n_clusters = len(set(labels)) - (1 if -1 in labels else 0) noise_ratio = (labels == -1).mean() print(f"聚出 {n_clusters} 个簇, 噪声占比 {noise_ratio:.1%}")这里metric="cosine"很重要,因为 Embedding 向量的语义相似度用余弦距离度量最合适。降维后再用欧氏距离聚类是常规组合。
噪声占比是个重要指标。如果超过 30%,说明min_cluster_size设太大了,或者数据本身太分散。我一般会调到噪声占比在 10%-20% 之间。
4.6 第五步:簇解读与指标统计
聚类只是分组,真正有价值的是每个簇代表什么行为、表现如何。这一步要统计每个簇的指标,再用 LLM 生成标签。
def analyze_cluster(sessions, labels, cluster_id): members = sessions[labels == cluster_id] # 统计指标 avg_duration = members["total_duration"].mean() avg_tokens = members["total_tokens"].mean() error_rate = members["has_error"].mean() # 取几个代表性样本 samples = members["text"].head(5).tolist() return { "size": len(members), "avg_duration": avg_duration, "avg_tokens": avg_tokens, "error_rate": error_rate, "samples": samples }然后用 LLM 给每个簇打标签:
def label_cluster(cluster_info): prompt = f"""以下是同一类 Agent 会话的样本,请用一句话概括这类会话的行为特征: 样本1: {cluster_info['samples'][0]} 样本2: {cluster_info['samples'][1]} 样本3: {cluster_info['samples'][2]} 要求:不超过20字,突出任务类型和主要动作。""" # 调用 LLM 返回标签 return call_llm(prompt)最终产出的报告大概长这样:
| 簇ID | 行为标签 | 样本数 | 占比 | 平均耗时 | 平均token | 失败率 |
|---|---|---|---|---|---|---|
| 0 | 天气查询与穿衣推荐 | 320 | 32% | 1.2s | 850 | 2% |
| 1 | 多轮代码调试 | 180 | 18% | 8.5s | 4200 | 15% |
| 2 | 简单闲聊 | 250 | 25% | 0.6s | 320 | 0% |
| 3 | 复杂数据分析 | 90 | 9% | 12s | 6800 | 22% |
看到这张表,优化方向就一目了然了:簇 3 失败率 22% 且 token 消耗最高,优先排查;簇 1 耗时最长,看看是不是工具链有瓶颈。
5. 常见问题与排查技巧实录
5.1 聚类结果全是噪声怎么办
这是最常见的问题。表现是labels里 -1 占了一大半。原因通常有三个:
min_cluster_size太大:数据量小的时候尤其明显,1000 个样本设 50 就可能全成噪声。先降到 10 试试。- Embedding 质量差:文本化模板没设计好,向量区分度低。检查一下是不是把太多无关信息拼进去了。
- 数据本身太分散:如果 Agent 处理的都是高度个性化的任务,确实可能没有明显聚类结构。这时候要考虑换更细的粒度,或者接受"没有强聚类"这个事实。
我的排查顺序是:先调小min_cluster_size,再看几个噪声样本的文本,判断是文本问题还是数据问题。
5.2 簇的数量太多或太少
簇太多(比如 50 个簇,每个就几十个样本)说明min_cluster_size太小,或者降维参数让局部结构过度保留。调大min_cluster_size,或者把 UMAP 的n_neighbors调大。
簇太少(比如就 2-3 个簇)说明区分度不够。可能是 Embedding 模型不适合你的领域,也可能是文本化时把关键差异抹掉了。我遇到过一次,因为工具参数全被截断成一样,导致所有工具调用看起来都差不多,后来把工具名单独强化才解决。
5.3 标签生成不准确
LLM 生成的簇标签有时候很泛,比如"用户咨询""信息查询"这种没信息量的词。解决办法是给 LLM 更多上下文:除了样本,把该簇的统计指标(耗时、失败率、工具分布)也喂进去,让它结合数据生成标签。另外样本要选靠近簇中心的,而不是随机选,代表性更强。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 噪声占比 > 40% | min_cluster_size 过大 | 逐步调小,观察噪声比例 |
| 簇数量 > 30 | 参数过细 | 调大 min_cluster_size 和 n_neighbors |
| 簇内样本不相似 | Embedding 质量差 | 检查文本化模板,强化关键字段 |
| 标签太泛 | LLM 上下文不足 | 补充统计指标和中心样本 |
| 新旧结果不一致 | Embedding 模型变了 | 固定模型,全量重算 |
| 运行太慢 | 逐条调用 Embedding | 批量调用 + 本地缓存 |
5.5 几个我踩过的坑
坑一:Session 边界没对齐。有次线上 session_id 是前端生成的,但后端重试时会生成新 id,导致一次任务被拆成多个 Session。聚类出来的结果全是碎片。后来统一用 trace_id 做边界才解决。
坑二:Embedding 缓存没做版本管理。换了文本化模板后忘了清缓存,新旧向量混在一起,聚类结果诡异了好几天才发现。
坑三:忽略时间维度。Agent 的行为会随版本迭代变化,如果拿三个月的数据一起聚类,会把不同版本的行为混在一起。建议按周或按版本分批聚类,再对比不同批次的结果,这样还能看出行为漂移。
提示:聚类不是一劳永逸的。Agent 每次大改版后,行为分布都会变,建议把聚类做成定期任务(比如每天跑一次),持续监控行为变化。
6. 让这套方案真正跑起来的几个经验
6.1 从采样开始,别一上来就全量
我第一次做的时候,直接把一周的全量 Trace 拉下来跑,几百万条,Embedding 调用花了不少钱,聚类跑了半小时,结果发现文本化模板有问题,全部重来。后来学乖了:先随机采样 1000-2000 个 Session 跑通全流程,验证效果后再上全量。采样阶段成本几乎可以忽略,但能帮你快速迭代模板和参数。
6.2 把聚类结果接回监控
聚类本身不是目的,驱动优化才是。我现在的做法是:每天跑一次聚类,把每个簇的失败率、耗时、token 消耗写进监控看板。一旦某个簇的失败率突增,就自动告警。这样比盯着总体指标灵敏得多,因为总体指标会被大簇稀释,而细分到簇就能快速定位问题。
6.3 和人工标注结合
纯无监督聚类有个问题:簇的语义是"涌现"出来的,不一定符合业务视角。我的做法是定期人工 review 几个簇,把业务上关心的分类(比如"售前咨询""售后投诉")和聚类结果做映射。如果发现某个业务类别被拆散在多个簇里,说明文本化模板需要调整,要把该业务的关键特征强化进去。
6.4 关于成本和性能
Embedding 调用是主要成本。以 1000 个 Session、每个 500 token 计算,一次全量大概几十万 token,用便宜的模型成本很低。真正贵的是频繁重跑。所以缓存一定要做,而且缓存 key 要包含文本内容和模型名,避免混用。
聚类本身是 CPU 计算,1000 个样本秒级完成,10000 个样本也就几十秒。瓶颈通常在 Embedding 调用和 LLM 标签生成上,这两个都可以并发优化。
6.5 后续可以怎么扩展
这套框架跑通后,有几个自然的扩展方向。一是做行为漂移检测,对比不同时间段的聚类结果,看哪些簇在变大变小,提前发现 Agent 行为异常。二是做失败归因,把失败率高的簇单独拿出来,深入分析是工具问题还是 prompt 问题。三是和 A/B 测试结合,对比两个版本的 Agent 在同一批任务上的聚类分布,量化版本改进效果。
我个人在实际操作中的体会是,这套方案最大的价值不在于算法多高级,而在于它把"理解 Agent 行为"这件事从靠直觉变成了靠数据。以前优化 Agent 全靠拍脑袋,现在打开报告就能看到"哪类任务在拖后腿",优化方向清晰太多了。如果你也在做 Agent 开发,强烈建议把这套流程搭起来,哪怕先用最简陋的版本,也比没有强。