先看接入之后能拿到什么。
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 的账单不看总数,看结构。

两个 turn 加起来输入 105.9 万 token,输出 3.3 万,输入产出比 32 : 1。
这个比例是 agent 负载的正常长相:上下文主导,不是生成主导。结论很直接——省钱的战场在输入侧,抠 output 没意义。
真正有用的是把输入拆开看每一步的构成:

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 模板抄来的面板十有八九栽在这。
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 |
|
| 309.56s |
01:14:05 | AGENT |
|
| 309.56s |
01:19:15 | STEP |
|
| 0.13s |
01:19:15 | LLM |
|
| 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_id 和 numeric_labels.dsh_turn 回到磁盘上的会话日志,在 Trajectory 视图里逐字重放。

这个交接是整套接入的核心价值:Elastic 告诉你该看哪一次运行,append-only 日志告诉你那一次里究竟发生了什么。
成功率之外,更能说明问题的是行为形状。
循环深度。 那个 turn 走了 40 个 react 轮次。这个数字直接回答"agent 到底在干活还是在空转"——深度持续上升而成功率不动,说明它在磨。
工具分布与长尾。

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

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"。
dsh plugin --profile web add @loongsuite/dsh-plugin
dsh plugin --profile headless add @loongsuite/dsh-pluginheadless 别漏,CI 跑的就是它。
插件把 dsh 原生事件转成 OTel GenAI trace,一个 turn 一棵树:
ENTRY ← turn 级,落成 APM transaction
└── AGENT
└── STEP ← 一个 react 步
├── LLM ← 每次真实尝试一个 span,重试各自留痕
└── TOOL在 Kibana 里 Add data → OpenTelemetry 拿到自己的 endpoint 和 API key:
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,比环境变量稳,也方便直接发给团队:
- 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不要设 data_stream.dataset 和 data_stream.namespace。
用默认值,数据经 APM Server 落进 traces-apm*。APM Server 会打上 processor.event、把 ENTRY span 提升为 transaction、完成服务聚合,dsh-agent 才会出现在服务清单里,服务地图、延迟分布和 trace 瀑布图才成立。
自定义这两个属性会把数据推到 APM 默认读取的索引范围之外,服务清单会是空的。想做团队级的保留和权限隔离,用 service.name 加 deployment.environment.name,配合 APM 自己的 space 和角色控制,不要去改数据流路由。
(Elasticsearch 另有一个原生 OTLP 端点 /_otlp,插件可以直连,数据落成 OTel 原生格式。那条路不经过 APM Server,原始 span 全在、ES|QL 查得到,但没有 APM 加工视图。除非确定只用 ES|QL,否则走 APM Server。)
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 实际字段 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
span 耗时(原生为纳秒) |
|
另外两个字段是 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 删除。