本文以 Elastic 中一个真实的 Agent 服务
hermes_agent(数据流traces-generic.otel-default)为例,讲清楚:LLM 的每一次调用会在 OpenTelemetry span 里留下哪些字段、每个字段装的是什么、input 与 output 字段之间是什么关系、以及如何用 ES|QL 去分析、理解并利用这些数据。文中所有字段清单、示例数值均来自该集群的真实 mapping 与查询结果。
当 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 直接聚合、过滤、可视化。
pipeline 用一个对称的 Painless 函数 parseMessages 分别处理 input 和 output 两侧,遍历每条 message,按 role / content / tool_calls / parts(reasoning、tool_call、tool_call_response)拆解,产出如下字段。
attributes.gen_ai.input.*)字段 | 类型 | 含义 |
|---|---|---|
| match_only_text | 原始完整消息数组(JSON 文本) |
| keyword(多值) | 每条消息的角色序列: |
| match_only_text(多值) | 每条消息的文本内容(content / parts.content) |
| keyword(多值) | 历史中出现过的工具调用名(如 read_file、terminal、search_files、skill_view、skills_list、skill_manage) |
| match_only_text(多值) | 每个工具调用的参数(JSON 字符串) |
| keyword(多值) | 每个工具调用的唯一 id(用于把调用和其响应配对) |
| keyword / match_only_text(多值) | 工具执行的返回结果(可能很大,如文件内容、terminal 输出) |
| keyword(多值) | 工具响应对应的 id(与 tool_ids 配对) |
attributes.gen_ai.output.*)字段 | 类型 | 含义 |
|---|---|---|
| match_only_text | 模型本次返回的原始消息数组(JSON 文本) |
| keyword(多值) | 本次输出的角色(通常是 assistant) |
| match_only_text(多值) | 模型本次生成的文本回复 |
| keyword(多值) | 模型本轮新发起的工具调用名(往往是单值) |
| match_only_text(多值) | 本轮工具调用的参数 |
| keyword(多值) | 本轮新工具调用的 id |
注意:output 侧没有 tool_responses / tool_response_ids —— 因为工具还没执行,响应会在下一轮作为 input 的 tool 消息回灌。
*_reasoning(多值):思维链 / reasoning 内容*_reasoning_length:reasoning 总字符数(可直接聚合,衡量模型“想”了多少)parseMessages 函数对称处理 input/output,所以两侧字段差异不是代码 bug,而是原始数据结构不同(见第 3 节)。tool_calls[].function.name/arguments/id,以及 parts 格式的 parts[].type = reasoning / tool_call / tool_call_response。if (!list.isEmpty())),因此用 IS NOT NULL 过滤某字段 = 过滤出“有该类内容”的 span。remove 掉临时字段和 *_messages_tool_calls。这是理解整份数据的关键,也是最容易被误解的地方。
output.messages_tool_names 常常是单值(如 terminal);input.messages_tool_names 常常是长数组(如 [read_file, search_files, skill_view, skills_list, terminal, …])。很多人第一反应是“pipeline 把 output 解析错了”。其实不是。
一次 chat span = 一个请求/响应回合:
input.messages = 这次发给模型的完整对话历史:system prompt + user 提问 + 到目前为止所有轮次的 assistant 消息和 tool 结果。历史里每条 assistant 消息都可能带着当时的 tool_call,于是 input 累积了多轮所有工具调用 → _tool_names 自然是长数组。output.messages = 模型这一轮新生成的回复。一个回合通常只发起一个工具调用 → output 的 _tool_names 往往是单值。Span N output = 第 N 轮新增的 1 条 assistant 消息(含 1 个 tool_call)
│ 该消息 + 工具执行结果被追加进历史
▼
Span N+1 input = 历史全文 = [前 N 轮所有消息] + [第 N 轮那条] + [新的 tool 结果]所以:
取一条 hermes span:
字段 | 值 |
|---|---|
|
|
|
|
|
|
input[N+1] 减 input[N] 做跨文档 diff(成本较高)。这是 OTel GenAI instrumentation 的固有语义(input=prompt 全量、output=completion 增量),pipeline 侧无法也不应“修正”,因为两边记录的本来就是不同的东西。
gen_ai.usage.*每个 chat span 都带本次调用的 token 计量:
字段 | 含义 |
|---|---|
| 本次请求的 prompt token 数(= 累积历史的 token 化结果) |
| 本次生成的 completion token 数 |
| 合计 |
| 命中 prompt cache 的 input token(打折计费的部分) |
| reasoning 消耗的 token(若模型/框架上报) |
input.messages 不只是 trace 里的记录形状,它就是真实发给模型的 prompt。因此每一次调用都会把这个累积快照完整 token 化,计入本次的 input_tokens:
下表是 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,其中绝大部分是同一批历史消息被反复计费。
cache_read.input_tokens 监控命中率。字段 | 含义 |
|---|---|
| 操作类型(如 |
| 模型提供方 |
| 请求/实际使用的模型名 |
| 采样参数 |
| 供应商返回的响应 id |
| 结束原因(stop / tool_calls / length 等,判断是否被截断、是否触发工具) |
| 是否流式 |
| 本次提供给模型的工具定义清单 |
| 服务名(本例 hermes_agent) |
| 会话/回合关联与排序的关键 |
提示:pipeline 抽出的字段名里含点号,在 ES|QL 中需用反引号包裹,如
`attributes.gen_ai.output.messages_tool_names`。
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 DESCFROM 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 50FROM 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::doubleFROM traces-generic.otel-default
| WHERE resource.attributes.service.name == "hermes_agent"
| STATS n = COUNT(*) BY reason = `attributes.gen_ai.response.finish_reasons`
| SORT n DESCFROM 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因为这些 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),兼顾全文检索与精确聚合。gen_ai.*,以及 OTel passthrough 的 attributes.gen_ai.*。只固定一条,另一条仍会因动态 mapping 冲突。gen_ai_messages_parser pipeline 重新抽取字段)。gen_ai.input.messages / gen_ai.output.messages 承载原始对话,pipeline 把它们拆成可分析的结构化字段。usage.* 字段持续监控。原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。