首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >DeepSeek Harness 接入 Elastic APM:把 Agent 的成本、失败和行为变成可查数据

DeepSeek Harness 接入 Elastic APM:把 Agent 的成本、失败和行为变成可查数据

原创
作者头像
点火三周
发布2026-09-01 10:18:34
发布2026-09-01 10:18:34
2320
举报

一个任务的完整画像

先看接入之后能拿到什么。

lex-demo 上有这样一个 dsh turn:跑了 309.56 秒,走完 40 个 react 轮次,40 次 LLM 调用配 40 次工具调用,输入烧掉 105 万 token,最后以 error 收场。

它死在第 40 步的 chat hy3 调用上,PI_AI_ERROR,耗时 125 毫秒。此前它已经磨了 5 分 9 秒。

上下文从第 1 步的 16,641 token 涨到第 39 步的 46,479,但每一步真正新增的内容只有几百到两千 token,其余全靠 prompt 缓存兜住——命中率 90.9%。40 次工具调用全部成功,其中 31 次是 bash,中位耗时 256 毫秒,p95 冲到 9.3 秒。

没有遥测,你对这次运行只知道一件事:失败了。

DeepSeek Harness 本身其实什么都记了。它的 append-only 会话日志有一条硬规则:凡是喂给模型的东西,都必须能从日志里重建。系统提示、推理、工具调用与结果、子代理调度,全部按序追加,可回放可分叉。

问题是这份记录出不来,也散不开。dsh 核心不导出 OpenTelemetry,日志就是个本地文件;而且它刻意单机,--host 0.0.0.0 会直接报 usage error 退出。于是每个开发者的 ~/.dsh 里躺着一份完整的决策记录,没有保留策略,没有访问控制,除了跑它的那个人谁也查不了。

一个人排障够用。第二个人进来,或者 CI 里跑起 dsh --profile headless,问题就全变成了聚合问题:这周烧了多少 token,钱花在哪一步,哪个工具老失败,一个任务实际要磨几个来回。这些答案不在任何单条日志里,只在一堆日志的横截面上。

接一条 OTLP 旁路就能补上,写路径一行不动。下面是接完之后具体能做的三件事。


一、把成本拆到步

Agent 的账单不看总数,看结构。

输入 token 总量
输入 token 总量

两个 turn 加起来输入 105.9 万 token,输出 3.3 万,输入产出比 32 : 1

这个比例是 agent 负载的正常长相:上下文主导,不是生成主导。结论很直接——省钱的战场在输入侧,抠 output 没意义。

真正有用的是把输入拆开看每一步的构成:

单个 turn 内每一步的输入构成
单个 turn 内每一步的输入构成
代码语言:sql
复制
FROM traces-apm*
| WHERE labels.gen_ai_span_kind == "LLM" AND event.outcome == "success"
| STATS cache_read   = SUM(numeric_labels.gen_ai_usage_cache_read_input_tokens),
        input_tokens = SUM(numeric_labels.gen_ai_usage_input_tokens)
  BY step = numeric_labels.dsh_step
| EVAL uncached = input_tokens - cache_read
| KEEP step, cache_read, uncached
| SORT step ASC

上下文从 16,641 涨到 46,479,翻了近三倍。每一步新增的只有几百到两千 token,剩下全是缓存读。四十步累计 105.9 万输入里,96.3 万来自缓存命中。

缓存命中率
缓存命中率

90.9%。这个数字和我在 Hermes agent 上量到的 92.4% 基本一致,说明它不是偶然,是 react 循环的固有特征:每一步都要把整段对话重新喂一遍,缓存是唯一挡在你和账单之间的东西。

图上第 1、8、23 步有三个缺口,是缓存未命中。这种断点通常意味着 prompt 前缀变了——有人动了系统提示,或者某块注入的上下文换了。盯这条曲线的断点,比盯总成本有用得多。

命中率的正确算法是 cache_read / input_tokens。dsh 上报的 inputTokens 只算未命中缓存那部分,缓存读写在另外两个桶里;而 GenAI 语义约定正好反过来,input_tokens 覆盖全部输入,另两个是它的子集。插件已经归一化成后者。把三个桶当兄弟去算,暖会话能给你算出超过 100% 的命中率——从通用 LLM 模板抄来的面板十有八九栽在这。


二、把失败定位到具体那一步

代码语言:sql
复制
FROM traces-apm*
| WHERE event.outcome == "failure"
| KEEP @timestamp, labels.gen_ai_span_kind, span.name, labels.error_type,
       numeric_labels.gen_ai_react_round, numeric_labels.dsh_step, span.duration.us
| SORT @timestamp ASC

时间

层级

名称

error.type

耗时

01:14:05

ENTRY

enter_ai_application_system

PI_AI_ERROR

309.56s

01:14:05

AGENT

invoke_agent standard

PI_AI_ERROR

309.56s

01:19:15

STEP

react step(第 40 轮)

ERROR

0.13s

01:19:15

LLM

chat hy3

PI_AI_ERROR

0.125s

四行就把根因锁死了:不是超时,不是工具问题,是第 40 轮的模型调用本身返回了错误。

这张表还暴露了一个必须知道的结构特性:错误会沿 span 树向上传播。真正的失败发生在 01:19:15,但 ENTRY 和 AGENT 上那两条 PI_AI_ERROR 的时间戳是 01:14:05,也就是 turn 开始的时刻。

所以统计错误时不能把 error.type 平铺求和——同一次失败会在四个层级各留一条记录,平铺会得到一个虚高数倍的错误总数。要数就 BY labels.gen_ai_span_kind 分层数。

定位到这一步之后,在 APM 里点开这条 trace 就是完整的 span 瀑布图,40 个来回逐层展开;再拿 labels.dsh_session_idnumeric_labels.dsh_turn 回到磁盘上的会话日志,在 Trajectory 视图里逐字重放。

这个交接是整套接入的核心价值:Elastic 告诉你该看哪一次运行,append-only 日志告诉你那一次里究竟发生了什么。


三、把 Agent 的行为变成指标

成功率之外,更能说明问题的是行为形状。

循环深度。 那个 turn 走了 40 个 react 轮次。这个数字直接回答"agent 到底在干活还是在空转"——深度持续上升而成功率不动,说明它在磨。

工具分布与长尾。

工具调用
工具调用

40 次调用全部成功:bash 31 次,edit 7 次,write 2 次。bash 的 p50 只有 256 毫秒但 p95 到 9.3 秒,这种长尾说明大部分是轻量命令,少数几条在跑真活儿——编译、测试或者拉网络。

延迟,但必须先过滤失败。

LLM 调用耗时
LLM 调用耗时

40 次成功调用 p50 是 5.74 秒、p95 13.95 秒;那次失败调用 0.13 秒就返回,没有 TTFT,不消耗 token。

这次只有一条失败样本影响不大,但换一批被限流的数据就很致命:182 次 LLM 调用里 157 次被秒拒,整体 p50 被拖到 0.90 秒,而 TTFT p50 是 2.03 秒。一个调用不可能比自己的首字延迟还短,这个矛盾就是分位数被污染的报警信号。不加过滤的面板会告诉你"agent 很快,中位数不到一秒",而真在干活的那部分是 5.74 秒。

Agent 的失败率天生比普通服务高一个量级,所以所有延迟面板都要先加 WHERE event.outcome == "success"


接入

1. 装插件

代码语言:bash
复制
dsh plugin --profile web add @loongsuite/dsh-plugin
dsh plugin --profile headless add @loongsuite/dsh-plugin

headless 别漏,CI 跑的就是它。

插件把 dsh 原生事件转成 OTel GenAI trace,一个 turn 一棵树:

代码语言:shell
复制
ENTRY                      ← turn 级,落成 APM transaction
└── AGENT
    └── STEP               ← 一个 react 步
        ├── LLM            ← 每次真实尝试一个 span,重试各自留痕
        └── TOOL

2. 指向 APM Server 端点

在 Kibana 里 Add data → OpenTelemetry 拿到自己的 endpoint 和 API key:

代码语言:bash
复制
export OTEL_EXPORTER_OTLP_ENDPOINT="https://<deployment>.ingest.<region>.<csp>.elastic-cloud.com:443"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey%20<your-api-key>"
export OTEL_RESOURCE_ATTRIBUTES="service.name=dsh-agent,service.version=0.1.2,deployment.environment.name=production"

ApiKey 和 key 之间的空格必须写成 %20,这里是标准的百分号编码语法,不是笔误。

要固化就写进 ~/.dsh/profiles/<profile>/cordis.patch.yml,比环境变量稳,也方便直接发给团队:

代码语言:yaml
复制
- id: loongsuite-observability
  config:
    endpoint: https://<deployment>.ingest.<region>.<csp>.elastic-cloud.com:443
    serviceName: dsh-agent
    headers:
      authorization: ApiKey <your-api-key>
    resourceAttributes:
      service.version: 0.1.2
      deployment.environment.name: production
    captureContent: false
    exportMetrics: true

3. 一条重要的配置约束

不要设 data_stream.datasetdata_stream.namespace

用默认值,数据经 APM Server 落进 traces-apm*。APM Server 会打上 processor.event、把 ENTRY span 提升为 transaction、完成服务聚合,dsh-agent 才会出现在服务清单里,服务地图、延迟分布和 trace 瀑布图才成立。

自定义这两个属性会把数据推到 APM 默认读取的索引范围之外,服务清单会是空的。想做团队级的保留和权限隔离,用 service.namedeployment.environment.name,配合 APM 自己的 space 和角色控制,不要去改数据流路由。

(Elasticsearch 另有一个原生 OTLP 端点 /_otlp,插件可以直连,数据落成 OTel 原生格式。那条路不经过 APM Server,原始 span 全在、ES|QL 查得到,但没有 APM 加工视图。除非确定只用 ES|QL,否则走 APM Server。)

4. 跑

代码语言:bash
复制
dsh web
dsh --profile headless "你的任务"

从下一个 turn/start 开始自动导出,业务代码零改动。插件挂到已在运行的 profile 上不补历史,早期 trace 不完整属正常。

该期待哪些信号

trace 有,指标有,日志没有——插件不导出 OTel logs,别去找那条数据流。

trace 每 5 秒 flush 一次,指标每 60 秒。短测试之后 trace 立刻可见而指标一条没有,先别排障,停掉 dsh 会把待发批次强制刷出去。

如果是自己建 API key 而不是用 Kibana 生成的,记得把 metrics-* 一起授权。少了它指标导出会静默吃 403:exporter 重试几次然后丢弃,dsh 一声不吭,trace 却一切正常,看起来部署很健康,实际上一路信号已经死了。


字段对照表

走 APM Server 之后数据落成 classic APM 格式,字段名和 OTel 语义约定不是一回事。规律三条:点号变下划线,字符串进 labels.*,数字进 numeric_labels.*(类型 scaled_float)。

OTel 语义约定

classic APM 实际字段

gen_ai.span.kind

labels.gen_ai_span_kind

gen_ai.tool.name

labels.gen_ai_tool_name

gen_ai.provider.name

labels.gen_ai_provider_name

gen_ai.request.model

labels.gen_ai_request_model

error.type

labels.error_type

dsh.session.id

labels.dsh_session_id

dsh.turn.end_reason

labels.dsh_turn_end_reason

dsh.turn / dsh.step

numeric_labels.dsh_turn / numeric_labels.dsh_step

dsh.llm.attempt

numeric_labels.dsh_llm_attempt

gen_ai.usage.input_tokens

numeric_labels.gen_ai_usage_input_tokens

gen_ai.usage.cache_read.input_tokens

numeric_labels.gen_ai_usage_cache_read_input_tokens

gen_ai.response.time_to_first_token

numeric_labels.gen_ai_response_time_to_first_token

span 耗时(原生为纳秒)

span.duration.us / transaction.duration.us微秒

另外两个字段是 APM 自己加的,原生模式没有,但比自己判断好用:event.outcome 直接给 success / failure,processor.event 区分 transaction 和 span。

一个查询上的注意点:字段要等有文档包含它之后才能当列解析。早期还没产生 TOOL span 时查 labels.gen_ai_tool_name 会报 Unknown column,这是正常行为,不是属性漂移。ES|QL 也不接受中文列名,EVAL 结果 = ... 会直接触发词法器报错。


内容捕获

默认关闭,对多数场景这个默认是对的。关着照样能拿到本文所有结构性指标:token、延迟、工具名、失败率、循环深度。拿不到的是 prompt、回复、工具参数和结果。

要开就先想清楚这会把源码和凭据发进集群。开之前定好保留和权限,并在必须保持无内容的 profile 上显式captureContent: false——否则机器上一个 export 就能把它打开。

开了之后把内容路由到独立 dataset,短保留加字段级安全收紧到特定角色,结构遥测那条走长保留做审计。两条流的保留期和权限完全可以不同,这是敢开的前提。

另外,dsh 自带一个 dsh-session-telemetry-otel 插件,由 DSH_TELEMETRY_MODE 控制,默认关闭,开启后会把 OTLP 日志发到 DeepSeek 自己的端点。那是 DeepSeek 的产品分析,与本文这套无关。有数据驻留要求的客户第一个就问这个,最好主动说明。


保留与审计

Elasticsearch 的 data stream 只追加、不允许 update、由 ILM 管生命周期,和 dsh 的 append-only 会话日志语义同构。Elastic 做的事是把规模从"一台机器上的一个文件"变成"一个组织一条数据流",后面接保留策略、RBAC 和冻结层。

"审计"的定义是跑这个 agent 的人之外的人也能查,而这正是本地日志从定义上做不到的。

但 ILM 不是免费保险。配错的 delete phase 删掉审计证据的效率和 rm 一样。delete 阶段要显式设置,要确认策略真的挂到了数据流上而不只是"存在",如果这确实是审计证据就老实做快照——生命周期策略不是备份。


DeepSeek Harness 仍是 developer preview,按设计发布不兼容变更,插件目前只支持 >=0.1.0-rc.6 <0.2.0。上面的字段表在 0.1.2 上实测,升级后重新做一次字段侦察再信你的面板。

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

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

目录
  • 一个任务的完整画像
  • 一、把成本拆到步
  • 二、把失败定位到具体那一步
  • 三、把 Agent 的行为变成指标
  • 接入
    • 1. 装插件
    • 2. 指向 APM Server 端点
    • 3. 一条重要的配置约束
    • 4. 跑
    • 该期待哪些信号
  • 字段对照表
  • 内容捕获
  • 保留与审计
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档