首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >构建 Agent 可观测能力:你的 DSH 昨天跑了 25 次任务,19 次失败了,你知道吗

构建 Agent 可观测能力:你的 DSH 昨天跑了 25 次任务,19 次失败了,你知道吗

原创
作者头像
点火三周
发布2026-08-31 13:14:03
发布2026-08-31 13:14:03
2551
举报

一、dsh 火了,但它把最有价值的东西锁在了你的笔记本里

DeepSeek Harness(下称 dsh)开源当天拿到 13k star,插件市场很快堆到两千多个。它火起来是有硬道理的:同一个模型,在 DeepSeek 自己的 setup 下跑 Terminal-Bench 2.1 自报 87.9 分,换成参考 harness 只有 54.68。三十多分的差距全在 harness 层。这件事本身就说明了,harness 不是管道,它是 agent 的一部分。

dsh 最被称道的设计是 append-only 会话日志:凡是喂给模型的东西,都必须能从日志里重建出来。系统提示、推理过程、工具调用与结果、子代理调度、每一次上下文注入,全部按序追加,可回放、可分叉、可追溯。这条规则写死在核心里,不是外挂的。

听起来很完美。但有两个同样硬核的事实,让这份完美打了折扣:

第一,dsh 核心不导出 OpenTelemetry。 那份完整的决策记录,是一个本地文件。

第二,dsh 是刻意单机的。 --host 0.0.0.0 会直接报 usage error 退出。DeepSeek 从没打算把它做成托管服务。

于是就有了这个局面:一份"agent 每个决策的完整记录",躺在每个开发者的 ~/.dsh 里。没有保留策略,没有访问控制,rm 一下就没了,除了跑它的那个人谁也查不了。

二、一个人用没问题,一个团队用就出问题

单个开发者跑 dsh,出了问题打开 Trajectory 视图翻日志,完全够用。

但只要有第二个人,或者 CI 里跑起了 dsh --profile headless,有意思的问题立刻全变成聚合问题

  • 我们这周在 agent 上花了多少 token?钱花在哪个环节?
  • 哪个工具老是失败?是模型不会用,还是环境缺东西?
  • 一个任务实际要几个来回?agent 到底在干活还是在空转?
  • 上周三那批任务集体跑飞,根因是什么?

这些问题有一个共同点:答案不在任何一条日志里,只在一堆日志的横截面里。你会看到 12 条 bash 失败记录,但不会自动知道它们共享同一个根因。你会看到某次调用很慢,但不知道它是不是常态。

而"审计"这个词的定义,恰恰就是跑这个 agent 的人之外的人也能查。日志留在本地,这一条从定义上就不成立。


三、Elastic 能给到什么

1. 一条不改任何代码的旁路

@loongsuite/dsh-plugin 把 dsh 的原生事件转成 OpenTelemetry GenAI trace,一个 turn 产出一棵 span 树:

代码语言:shell
复制
ENTRY                      ← turn 级,带 dsh.turn.end_reason
└── AGENT                  ← 一次 agent 调用
    └── STEP               ← 一个 react 步
        ├── LLM            ← 每次真实尝试一个 span,重试也各自留痕
        └── TOOL           ← 工具调用,按 dsh call id 关联结果

关键在于它导出的是 OTLP/HTTP protobuf,而 Elasticsearch 原生 OTLP 端点接受的正好是它,而且只接受它。所以最简单的接法连 Collector 都不需要,插件直连 <cluster>/_otlp 就完事。

对国内读者这一点尤其重要:这条路不绑定 Elastic Cloud。自建、ECK、腾讯云 ES 等伙伴运营的集群一样能跑。考虑到境内没有 Elastic Cloud 区域,这基本就是唯一现实的落地方式。

2. span 是文档,所以你能"问问题"而不只是"看曲线"

这是 Elastic 和时序库的分水岭。

插件也导出两个指标(gen_ai.client.operation.durationgen_ai.client.token.usage),但它们不带 session、user、error 维度——所有值得 group by 的东西都在 span 上。

span 是文档,意味着 session id、工具名、错误类型、workspace 路径这些高基数字段可以直接进 WHERESTATS BY,不用提前规划维度、不用建 cube。"找出所有先 web_search 失败、再退回 bash 的 session"这类问题,在预聚合的指标上根本无法表达,在这里是一行 ES|QL。

后面第四节所有洞见,没有一条是从曲线上看出来的,全部是查出来的。

3. 追加写的数据流,语义上就是审计

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

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


四、真实案例:25 次任务,19 次失败,根因是什么

下面是 lex-demo 上一段真实运行的数据:298 个 span,其中 LLM 182、STEP 45、AGENT 25、ENTRY 25、TOOL 21。

现象:76% 的任务失败了

代码语言:sql
复制
FROM traces-dsh.otel-*
| WHERE `attributes.gen_ai.span.kind` == "ENTRY"
| STATS turns = COUNT(*) BY outcome = `attributes.dsh.turn.end_reason`
| SORT turns DESC

25 个 turn,19 个 error,5 个 completed,1 个 aborted

如果只看到这里,最容易得出的结论是"这套集成不靠谱"或者"这个模型不行"。两个结论都是错的。

第一刀:把错误按 span 层级摊开

代码语言:sql
复制
FROM traces-dsh.otel-*
| WHERE `attributes.error.type` IS NOT NULL
| STATS n = COUNT(*) BY err = `attributes.error.type`,
                        layer = `attributes.gen_ai.span.kind`
| SORT n DESC

RATE_LIMIT 在 LLM 层出现 144 次,在 AGENT 层 16 次,在 ENTRY 层 16 次。

注意这里有个方法论陷阱:这三个数字不能相加。 同一个失败会沿着 span 树向上传播,ENTRY 和 AGENT 上的 RATE_LIMIT 是 LLM 层失败的回声,不是独立的新错误。如果你把所有 error.type 平铺着数,会得到一个虚高一倍的错误总数,然后基于它做出错误判断。

分层看,结论一句话就出来了:数据管道全程健康,是上游模型服务在限流。 这个区分决定了你是该去改代码,还是该去调并发。

第二刀:bash 100% 失败,但不怪模型

代码语言:sql
复制
FROM traces-dsh.otel-*
| WHERE `attributes.gen_ai.span.kind` == "TOOL"
| EVAL reason = COALESCE(`attributes.error.type`, "成功")
| STATS calls = COUNT(*) BY tool = `attributes.gen_ai.tool.name`, reason
| SORT calls DESC

工具

调用

结果

bash

12

8 次 SANDBOX_UNAVAILABLE + 4 次 TOOL_ERROR0 次成功

read

4

全部成功

write

5

3 次 FS_NOT_OBSERVED,2 次成功

看到 bash 12 次调用 12 次失败,第一反应通常是"模型不会用 bash"或者"工具描述写得烂"。

但把失败按 error.type 拆开,答案完全不同:8 次是沙箱环境根本没起来,4 次是工具自身报错。两类都是执行环境的问题,跟模型的工具使用能力毫无关系。

write 那 3 次 FS_NOT_OBSERVED 是同一类信号:文件系统没有被正确挂载观测。

这就是本文最想说的那件事:光看日志你会看到 12 条 bash 失败,但不会自动知道它们共享同一个根因。在 Elastic 里,这只是 STATS ... BY error.type 的一行距离。

第三刀:一个会骗人的延迟数字

这条是我在写这篇文章时才发现的,也是最值得单独讲的一条。

先看一个混在一起算的结果:所有 LLM span 的 duration p50 是 0.90 秒,而 TTFT(首字延迟)p50 是 2.03 秒

一个调用不可能比它自己的首字延迟还短。 这个矛盾说明两个分位数根本不是在同一群样本上算的。拆开:

代码语言:sql
复制
FROM traces-dsh.otel-*
| WHERE `attributes.gen_ai.span.kind` == "LLM"
| EVAL result = CASE(`attributes.error.type` IS NOT NULL, "failed", "ok")
| STATS n = COUNT(*),
        p50_sec = ROUND(PERCENTILE(duration, 50)/1000000000, 2),
        p95_sec = ROUND(PERCENTILE(duration, 95)/1000000000, 2),
        with_ttft = COUNT(`attributes.gen_ai.response.time_to_first_token`),
        tokens = SUM(`attributes.gen_ai.usage.input_tokens`)
  BY result

调用数

p50

p95

有 TTFT

input tokens

成功

25

5.86s

24.13s

25

187,287

失败

157

0.88s

10.51s

0

0

真相是:157 次失败调用是被限流秒拒的,不到 1 秒就返回,没有 TTFT,不消耗 token。它们把整体 p50 从 5.86 秒硬生生拉到了 0.90 秒。

一个不加过滤的延迟面板会告诉你"我们的 agent 很快,中位数不到 1 秒"。而真实在干活的那 25 次调用,中位数是 5.86 秒。

失败调用污染分位数,这个坑在 agent 可观测性里几乎必踩,因为 agent 的失败率天然比普通服务高一个量级。所有延迟面板都必须先 WHERE error.type IS NULL

顺带还有一个结论:全部 187,287 个 input token 集中在 25 次调用里。182 次调用中只有 13.7% 真正产生了成本。

第四刀:钱花在哪

输入 187,287 token,输出 4,510 token,输入产出比 41.5 : 1

这个比例高得夸张,但它是 agent 类负载的正常形态:上下文主导,不是生成主导。推论很直接——这种负载的钱主要花在 prompt 上,所以缓存效率才是省钱的主战场,抠 output 没意义。

缓存命中率 61.5%(115,200 / 187,287)。不算差,但离"同一个 workspace 里重复工作能到 80~90%"还有明显空间。

📌 缓存命中率有个经典算错方式。 dsh 上报的 inputTokens 只算未命中缓存的部分,缓存读和缓存写在另外两个桶里。而 GenAI 语义约定正好相反:gen_ai.usage.input_tokens 覆盖全部输入(含缓存),另外两个是它的子集。插件已经归一化成后者了。

所以正确公式是 cache_read / input_tokens如果你把三个桶当兄弟去算,暖会话能算出超过 100% 的命中率。 从通用 LLM 模板抄来的面板,十有八九栽在这里。

一个附带发现:三个 provider 混跑

provider

model

调用

重试

tencent

hy4-preview

169

126

google

gemini-3.6-flash

11

9

deepseek-official

deepseek-v4-flash

2

0

多模型混跑在真实团队里是常态。没有统一遥测,你得去三个后台各查一遍;有了它,这是 STATS ... BY gen_ai.provider.name 的一行。

从聚合回到单次:这才是完整闭环

从失败分诊面板点进任意一行,可以在 APM 里看完整 span 树;再拿 dsh.session.id + dsh.turn 回到磁盘上的会话日志,在 Trajectory 视图里逐字重放那一次运行。

这个交接才是整套集成的意义:Elastic 告诉你该看哪几次运行,append-only 日志告诉你那一次里究竟发生了什么。 前者是聚合视角,后者是逐字真相,谁也替代不了谁。


五、接入指南

看完上面的案例,接入本身其实很短。

Step 1 · 装插件

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

headless 一定要加——CI 里跑的就是它,也是最容易漏的那个。

Step 2 · 建一个 API key

代码语言:json
复制
{
  "indices": [
    {
      "names": ["traces-*", "logs-*", "metrics-*"],
      "privileges": ["create_doc", "auto_configure"]
    }
  ]
}

别删 metrics-* 少了它,指标导出会在 /_otlp/v1/metrics 上吃 403,而且是静默失败:exporter 重试几次然后丢弃,dsh 那边一声不吭。trace 一切正常,看起来整个部署很健康,实际上一路信号已经死了。

traces-* 之外还要 logs-*,是因为 trace 摄入会往 logs-* 写 span event。)

Step 3 · 一份配置指向集群

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

代码语言:yaml
复制
- id: loongsuite-observability
  config:
    endpoint: https://<your-cluster>:443/_otlp   # 插件自动补 /v1/traces 与 /v1/metrics
    serviceName: dsh-agent
    headers:
      authorization: ApiKey <your-api-key>
    resourceAttributes:
      data_stream.dataset: dsh
      data_stream.namespace: lex
      deployment.environment.name: dev
    captureContent: false
    exportMetrics: true

data_stream.dataset 是点睛之笔。 ES 按 <type>-<dataset>.otel-<namespace> 路由,不设的话所有东西落进 traces-generic.otel-default 跟别的遥测混在一起。设了之后数据流变成 traces-dsh.otel-lex,于是可以单独挂 ILM、保留期独立于业务 trace、按团队授权时不会顺手把别人的数据放出去。

注意这两个属性对三个信号都生效:trace 在 traces-dsh.otel-lex,指标就在 metrics-dsh.otel-lex,不在 metrics-generic.otel-default。找不到指标的人一半是找错了地方。

Step 4 · 跑

代码语言:bash
复制
dsh web                              # 浏览器交互 UI
dsh --profile headless "你的任务"     # 一次性任务 / CI

从下一个 turn/start 开始 span 自动导出,业务代码零改动。

该期待哪些信号

信号

有吗

落在哪

Traces

traces-dsh.otel-lex

Metrics

有(exportMetrics 默认 true)

metrics-dsh.otel-lex

Logs

没有

插件完全不导出 OpenTelemetry logs。ES 的 trace 摄入确实会往 logs-* 写东西,但写的是 span event,而 span event 只在内容捕获开到 SPAN_AND_EVENT 时才产生。默认 captureContent: false 的情况下,logs 数据流为空或不存在是正确结果,不是故障

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

关于内容捕获

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

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

如果开,把内容路由到独立 dataset(比如 dsh.content),短保留 + 字段级安全收紧到特定角色,结构遥测那条走长保留做审计。两条流的保留期和权限可以完全不同,这才是敢开的前提。

上线第一天该确认的一件事:dsh 自带一个 dsh-session-telemetry-otel 插件,通过 DSH_TELEMETRY_MODE 控制,默认关闭,开了会把 OTLP 日志发到 DeepSeek 自己的端点。那是 DeepSeek 的产品分析,跟本文这套无关。确认它是关的,或者有意识地做另一个决定。对有数据驻留要求的企业,这是他们一定会问的第一个问题,最好由你主动提出来。


七、小结

三件事:

  1. dsh 把决策记录做到了极致,但把它锁在了单机上。 加一条 OTLP 旁路,不动写路径,就能把它变成团队级可查、可留存、可授权的资产。
  2. Elastic 的价值在"可问答",不在"可画图"。 本文所有洞见——bash 12 次全败的环境根因、限流而非集成问题、被失败调用污染的延迟分位数、41.5:1 的输入产出比——没有一条是盯曲线看出来的,全是查出来的。
  3. 失败的数据一样有价值。 这次实测 76% 的 turn 失败,但正是这些 error span 把根因暴露得清清楚楚。可观测性的意义不是让数据好看,是让它可解释。

最后留个判断题:你现在的 agent,一次任务实际花掉多少 token、失败在哪一步、为什么失败——这三个问题,你能在 30 秒内答上来吗?

如果不能,缺的不是更好的 prompt。

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

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

目录
  • 一、dsh 火了,但它把最有价值的东西锁在了你的笔记本里
  • 二、一个人用没问题,一个团队用就出问题
  • 三、Elastic 能给到什么
    • 1. 一条不改任何代码的旁路
    • 2. span 是文档,所以你能"问问题"而不只是"看曲线"
    • 3. 追加写的数据流,语义上就是审计
  • 四、真实案例:25 次任务,19 次失败,根因是什么
    • 现象:76% 的任务失败了
    • 第一刀:把错误按 span 层级摊开
    • 第二刀:bash 100% 失败,但不怪模型
    • 第三刀:一个会骗人的延迟数字
    • 第四刀:钱花在哪
    • 一个附带发现:三个 provider 混跑
    • 从聚合回到单次:这才是完整闭环
  • 五、接入指南
    • Step 1 · 装插件
    • Step 2 · 建一个 API key
    • Step 3 · 一份配置指向集群
    • Step 4 · 跑
    • 该期待哪些信号
    • 关于内容捕获
  • 七、小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档