首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >通过 OpenTelemetry 分析 AI Agent 数据

通过 OpenTelemetry 分析 AI Agent 数据

原创
作者头像
点火三周
发布2026-07-19 16:08:29
发布2026-07-19 16:08:29
660
举报
文章被收录于专栏:Elastic Stack专栏Elastic Stack专栏

—— 以 hermes_agent 的 gen_ai.* 数据为例

本文以 Elastic 中一个真实的 Agent 服务 hermes_agent(数据流 traces-generic.otel-default)为例,讲清楚:LLM 的每一次调用会在 OpenTelemetry span 里留下哪些字段、每个字段装的是什么、input 与 output 字段之间是什么关系、以及如何用 ES|QL 去分析、理解并利用这些数据。文中所有字段清单、示例数值均来自该集群的真实 mapping 与查询结果。


1. 背景:一次 LLM 调用 = 一个 span

当 Agent 框架接入 OpenTelemetry 的 GenAI instrumentation 后,每一次对大模型的请求/响应回合都会生成一个 span(在 hermes 中操作名为 chat / openai.chat)。这个 span 记录了这次调用的全部上下文:喂给模型的 prompt、模型的回复、调用了哪些工具、以及消耗了多少 token。

原始的对话内容存在两个大字段里:

  • attributes.gen_ai.input.messages —— 本次请求发给模型的完整消息数组(JSON 字符串)
  • attributes.gen_ai.output.messages —— 模型本次返回的消息数组(JSON 字符串)

这两个字段是 JSON 大文本,直接查不好用。因此 我们需要编写合适的 ingest pipeline(gen_ai_messages_parser,在写入时把这两个 JSON 解析开,抽取出一系列结构化的子字段,方便用 ES|QL 直接聚合、过滤、可视化。


2. Pipeline 能从 messages 里抽出哪些字段

pipeline 用一个对称的 Painless 函数 parseMessages 分别处理 input 和 output 两侧,遍历每条 message,按 role / content / tool_calls / parts(reasoning、tool_call、tool_call_response)拆解,产出如下字段。

2.1 input 侧(attributes.gen_ai.input.*

字段

类型

含义

messages

match_only_text

原始完整消息数组(JSON 文本)

messages_roles

keyword(多值)

每条消息的角色序列:system / user / assistant / tool

messages_text

match_only_text(多值)

每条消息的文本内容(content / parts.content)

messages_tool_names

keyword(多值)

历史中出现过的工具调用名(如 read_file、terminal、search_files、skill_view、skills_list、skill_manage)

messages_tool_arguments

match_only_text(多值)

每个工具调用的参数(JSON 字符串)

messages_tool_ids

keyword(多值)

每个工具调用的唯一 id(用于把调用和其响应配对)

messages_tool_responses

keyword / match_only_text(多值)

工具执行的返回结果(可能很大,如文件内容、terminal 输出)

messages_tool_response_ids

keyword(多值)

工具响应对应的 id(与 tool_ids 配对)

2.2 output 侧(attributes.gen_ai.output.*

字段

类型

含义

messages

match_only_text

模型本次返回的原始消息数组(JSON 文本)

messages_roles

keyword(多值)

本次输出的角色(通常是 assistant)

messages_text

match_only_text(多值)

模型本次生成的文本回复

messages_tool_names

keyword(多值)

模型本轮新发起的工具调用名(往往是单值

messages_tool_arguments

match_only_text(多值)

本轮工具调用的参数

messages_tool_ids

keyword(多值)

本轮新工具调用的 id

注意:output 侧没有 tool_responses / tool_response_ids —— 因为工具还没执行,响应会在下一轮作为 input 的 tool 消息回灌。

2.3 pipeline 额外能力(若原始 parts 中带 reasoning)

  • *_reasoning(多值):思维链 / reasoning 内容
  • *_reasoning_length:reasoning 总字符数(可直接聚合,衡量模型“想”了多少)

2.4 pipeline 逻辑要点(避免踩坑)

  1. 同一个 parseMessages 函数对称处理 input/output,所以两侧字段差异不是代码 bug,而是原始数据结构不同(见第 3 节)。
  2. 兼容两种消息格式:OpenAI 的 tool_calls[].function.name/arguments/id,以及 parts 格式的 parts[].type = reasoning / tool_call / tool_call_response
  3. 只有非空才写字段(if (!list.isEmpty())),因此IS NOT NULL 过滤某字段 = 过滤出“有该类内容”的 span
  4. 最后会 remove 掉临时字段和 *_messages_tool_calls

3. 核心关系:input 是“累积快照”,output 是“单轮增量”

这是理解整份数据的关键,也是最容易被误解的地方。

3.1 现象

  • output.messages_tool_names 常常是单值(如 terminal);
  • input.messages_tool_names 常常是长数组(如 [read_file, search_files, skill_view, skills_list, terminal, …])。

很多人第一反应是“pipeline 把 output 解析错了”。其实不是。

3.2 原因

一次 chat span = 一个请求/响应回合:

  • input.messages = 这次发给模型的完整对话历史:system prompt + user 提问 + 到目前为止所有轮次的 assistant 消息和 tool 结果。历史里每条 assistant 消息都可能带着当时的 tool_call,于是 input 累积了多轮所有工具调用_tool_names 自然是长数组。
  • output.messages = 模型这一轮新生成的回复。一个回合通常只发起一个工具调用 → output 的 _tool_names 往往是单值

3.3 两者的真实链路

代码语言:bash
复制
Span N     output = 第 N 轮新增的 1 条 assistant 消息(含 1 个 tool_call)
             │  该消息 + 工具执行结果被追加进历史
             ▼
Span N+1   input  = 历史全文 = [前 N 轮所有消息] + [第 N 轮那条] + [新的 tool 结果]

所以:

  • 下一条的 input 确实包含上一条 output 的那个 tool call —— 但它只是数组尾部的一个元素,前面堆着更早所有轮次。
  • input 单调增长(累积),output 每轮单点(增量)。数量天然不对等,是正确行为。

3.4 用真实数据坐实

取一条 hermes span:

字段

output.messages_tool_names

[skill_manage](本轮只发起 1 个调用 → 单值)

input.messages_tool_names

[read_file, search_files, skill_view, skills_list, terminal, …](累积多个)

input.messages_roles

[system, user, assistant, tool, assistant, tool, …] 交替 —— 典型的“完整历史”形状

3.5 如何对齐两侧语义(按需选择)

  1. 统计“每轮真正调用了什么工具” → 只用 output 侧(增量、干净、无重复)。
  2. 还原“本轮相对上轮新增了什么” → 用 input[N+1]input[N] 做跨文档 diff(成本较高)。
  3. 保持现状 → input 当“该轮之前的完整上下文”,output 当“该轮动作”,各取所需。

这是 OTel GenAI instrumentation 的固有语义(input=prompt 全量、output=completion 增量),pipeline 侧无法也不应“修正”,因为两边记录的本来就是不同的东西。


4. Token 与成本字段:gen_ai.usage.*

每个 chat span 都带本次调用的 token 计量:

字段

含义

attributes.gen_ai.usage.input_tokens

本次请求的 prompt token 数(= 累积历史的 token 化结果)

attributes.gen_ai.usage.output_tokens

本次生成的 completion token 数

attributes.gen_ai.usage.total_tokens

合计

attributes.gen_ai.usage.cache_read.input_tokens

命中 prompt cache 的 input token(打折计费的部分)

attributes.gen_ai.usage.reasoning_tokens

reasoning 消耗的 token(若模型/框架上报)

4.1 关键结论:累积快照,每一轮都要为全量历史付费

input.messages 不只是 trace 里的记录形状,它就是真实发给模型的 prompt。因此每一次调用都会把这个累积快照完整 token 化,计入本次的 input_tokens:

  • 每一轮都从头重算 input token:第 N 轮 input ≈ 前 N-1 轮全部消息 + 本轮新增;
  • 成本随轮次单调增长,早期的 system prompt 和大 tool 结果会被重复计费约 K 次(K=轮数);
  • 总成本大致是 O(轮数²)(每轮线性变长 × 轮数),不是线性;
  • 长会话里 input_tokens 往往远大于 output_tokens

4.2 真实数据:同一会话内 input token 的爬升

下表是 hermes 同一段会话(2026-07-19 01:03–01:06)按时间排序的连续 span,可以清楚看到 input token 单调累积(output 每轮只是小增量):

时间

input_tokens

output_tokens

01:03:25

43,424

42

01:03:27

50,049

41

01:03:30

56,087

45

01:03:32

62,797

37

01:03:39

68,000

37

01:03:42

70,009

43

01:03:44

71,821

36

01:06:08

79,201

118

01:06:18

80,670

10

01:06:22

88,869

18

01:06:26

92,270

562

01:06:43

95,243

118

一段会话下来,仅 input 侧就累计消耗了几十万 token,其中绝大部分是同一批历史消息被反复计费

4.3 降本手段(按性价比)

  1. 开启 prompt caching:对稳定前缀(system prompt、早期历史)缓存,命中后该部分 input token 大幅折扣(常见 50%–90%)。对“长稳定前缀 + 尾部小增量”的 agent 场景收益最大 —— 正是本例形状。用 cache_read.input_tokens 监控命中率。
  2. 裁剪 / 摘要历史:超过一定轮数把早期消息压缩成摘要,或滑动窗口只保留最近 N 轮。
  3. 压缩 tool_responses:文件内容、terminal 输出这类大段工具结果,回灌进 input 前截断或摘要 —— 这是本数据里最重的 token 来源之一。

5. 其它有用的 gen_ai 元数据字段

字段

含义

attributes.gen_ai.operation.name

操作类型(如 chat

attributes.gen_ai.provider.name / system

模型提供方

attributes.gen_ai.request.model / response.model

请求/实际使用的模型名

attributes.gen_ai.request.max_tokens / temperature

采样参数

attributes.gen_ai.response.id

供应商返回的响应 id

attributes.gen_ai.response.finish_reasons

结束原因(stop / tool_calls / length 等,判断是否被截断、是否触发工具)

attributes.gen_ai.is_streaming

是否流式

attributes.gen_ai.tool.definitions

本次提供给模型的工具定义清单

resource.attributes.service.name

服务名(本例 hermes_agent)

trace.id / @timestamp

会话/回合关联与排序的关键


6. 实战 ES|QL 分析范式

提示:pipeline 抽出的字段名里含点号,在 ES|QL 中需用反引号包裹,如 `attributes.gen_ai.output.messages_tool_names`

6.1 每轮真正调用了哪些工具(增量视角,用 output)

代码语言:sql
复制
FROM traces-generic.otel-default
| WHERE resource.attributes.service.name == "hermes_agent"
    AND `attributes.gen_ai.output.messages_tool_names` IS NOT NULL
| STATS calls = COUNT(*) BY tool = `attributes.gen_ai.output.messages_tool_names`
| SORT calls DESC

6.2 每次调用的 token 成本,及历史累积规模

代码语言:sql
复制
FROM traces-generic.otel-default
| WHERE resource.attributes.service.name == "hermes_agent"
    AND `attributes.gen_ai.usage.input_tokens` IS NOT NULL
| EVAL in_msgs  = MV_COUNT(`attributes.gen_ai.input.messages_roles`),
       in_tools = MV_COUNT(`attributes.gen_ai.input.messages_tool_names`)
| KEEP @timestamp, trace.id,
       `attributes.gen_ai.usage.input_tokens`,
       `attributes.gen_ai.usage.output_tokens`,
       in_msgs, in_tools
| SORT @timestamp DESC
| LIMIT 50

6.3 整体成本汇总 + 缓存命中效果

代码语言:sql
复制
FROM traces-generic.otel-default
| WHERE resource.attributes.service.name == "hermes_agent"
| STATS total_in   = SUM(`attributes.gen_ai.usage.input_tokens`),
        total_out  = SUM(`attributes.gen_ai.usage.output_tokens`),
        cached_in  = SUM(`attributes.gen_ai.usage.cache_read.input_tokens`),
        calls      = COUNT(*)
| EVAL cache_hit_ratio = cached_in::double / total_in::double

6.4 找出被截断/异常结束的调用

代码语言:sql
复制
FROM traces-generic.otel-default
| WHERE resource.attributes.service.name == "hermes_agent"
| STATS n = COUNT(*) BY reason = `attributes.gen_ai.response.finish_reasons`
| SORT n DESC

6.5 按会话(trace.id)看 input token 的增长曲线

代码语言:sql
复制
FROM traces-generic.otel-default
| WHERE resource.attributes.service.name == "hermes_agent"
    AND trace.id == "<某个会话的 trace.id>"
| KEEP @timestamp, `attributes.gen_ai.usage.input_tokens`,
       `attributes.gen_ai.usage.output_tokens`,
       `attributes.gen_ai.output.messages_tool_names`
| SORT @timestamp ASC

7. 一个容易踩的运维坑:字段类型跨 backing index 冲突

因为这些 gen_ai.* 字段是 pipeline 在写入时动态生成的,如果索引模板没有显式定义它们的类型,每次 data stream rollover 时动态 mapping 会各自猜类型(有的猜 keyword,有的猜 text / match_only_text)。当 ES|QL 跨多个 backing index 查询时,同名字段类型不一致就会报 verification_exception

处理要点:

  • @custom 组件模板里显式固定 gen_ai 相关字段类型;大文本字段(*_tool_responses*_tool_arguments)建议用 match_only_text + 一个 .raw keyword 子字段(ignore_above: 8191),兼顾全文检索与精确聚合。
  • 注意有两条路径都要覆盖:pipeline 产出的顶层 gen_ai.*,以及 OTel passthrough 的 attributes.gen_ai.*。只固定一条,另一条仍会因动态 mapping 冲突。
  • 改完模板后 rollover 让新 backing index 生效;对历史 backing index 若需统一,可 reindex(必要时挂上 gen_ai_messages_parser pipeline 重新抽取字段)。

8. 小结

  1. 一次 LLM 调用 = 一个 spangen_ai.input.messages / gen_ai.output.messages 承载原始对话,pipeline 把它们拆成可分析的结构化字段。
  2. input = 累积历史快照,output = 单轮增量:这解释了为什么 input 的 tool_names 是长数组、output 常是单值 —— 这是正确语义,不是 bug。
  3. 每次调用都为全量历史付费,input token 随轮次单调增长(≈O(轮数²));真实数据可见同一会话 input token 从 4 万涨到 9.5 万。
  4. 降本靠 prompt caching + 历史裁剪/摘要 + 压缩大 tool 结果,并用 usage.* 字段持续监控。
  5. 用 ES|QL 围绕 output(每轮动作)、input(上下文规模)、usage(成本)、response.finish_reasons(质量/截断)四个维度,就能全面刻画一个 Agent 的行为与开销。
  6. 运维上记得显式固定 gen_ai.* 字段类型(两条路径都要),避免 rollover 造成跨索引查询冲突。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • —— 以 hermes_agent 的 gen_ai.* 数据为例
  • 1. 背景:一次 LLM 调用 = 一个 span
  • 2. Pipeline 能从 messages 里抽出哪些字段
    • 2.1 input 侧(attributes.gen_ai.input.*)
    • 2.2 output 侧(attributes.gen_ai.output.*)
    • 2.3 pipeline 额外能力(若原始 parts 中带 reasoning)
    • 2.4 pipeline 逻辑要点(避免踩坑)
  • 3. 核心关系:input 是“累积快照”,output 是“单轮增量”
    • 3.1 现象
    • 3.2 原因
    • 3.3 两者的真实链路
    • 3.4 用真实数据坐实
    • 3.5 如何对齐两侧语义(按需选择)
  • 4. Token 与成本字段:gen_ai.usage.*
    • 4.1 关键结论:累积快照,每一轮都要为全量历史付费
    • 4.2 真实数据:同一会话内 input token 的爬升
    • 4.3 降本手段(按性价比)
  • 5. 其它有用的 gen_ai 元数据字段
  • 6. 实战 ES|QL 分析范式
    • 6.1 每轮真正调用了哪些工具(增量视角,用 output)
    • 6.2 每次调用的 token 成本,及历史累积规模
    • 6.3 整体成本汇总 + 缓存命中效果
    • 6.4 找出被截断/异常结束的调用
    • 6.5 按会话(trace.id)看 input token 的增长曲线
  • 7. 一个容易踩的运维坑:字段类型跨 backing index 冲突
  • 8. 小结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档