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,有意思的问题立刻全变成聚合问题:
这些问题有一个共同点:答案不在任何一条日志里,只在一堆日志的横截面里。你会看到 12 条 bash 失败记录,但不会自动知道它们共享同一个根因。你会看到某次调用很慢,但不知道它是不是常态。
而"审计"这个词的定义,恰恰就是跑这个 agent 的人之外的人也能查。日志留在本地,这一条从定义上就不成立。
@loongsuite/dsh-plugin 把 dsh 的原生事件转成 OpenTelemetry GenAI trace,一个 turn 产出一棵 span 树:
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 区域,这基本就是唯一现实的落地方式。
这是 Elastic 和时序库的分水岭。
插件也导出两个指标(gen_ai.client.operation.duration 和 gen_ai.client.token.usage),但它们不带 session、user、error 维度——所有值得 group by 的东西都在 span 上。
span 是文档,意味着 session id、工具名、错误类型、workspace 路径这些高基数字段可以直接进 WHERE 和 STATS BY,不用提前规划维度、不用建 cube。"找出所有先 web_search 失败、再退回 bash 的 session"这类问题,在预聚合的指标上根本无法表达,在这里是一行 ES|QL。
后面第四节所有洞见,没有一条是从曲线上看出来的,全部是查出来的。
ES data stream 本身只追加、不允许 update、由 ILM 管生命周期——和 dsh 的 append-only 会话日志语义同构。Elastic 只是把规模从"一台机器上的一个文件"变成"一个组织一条数据流",后面接上保留策略、RBAC、冻结层。
⚠️ 但必须说一句:ILM 不是免费保险。配错的 delete phase 删掉审计证据的效率跟
rm 一模一样。delete 阶段要显式设置,要确认策略真的挂到了数据流上(而不只是"存在"),如果这真是审计证据就老老实实做快照——生命周期策略不是备份。
下面是 lex-demo 上一段真实运行的数据:298 个 span,其中 LLM 182、STEP 45、AGENT 25、ENTRY 25、TOOL 21。

FROM traces-dsh.otel-*
| WHERE `attributes.gen_ai.span.kind` == "ENTRY"
| STATS turns = COUNT(*) BY outcome = `attributes.dsh.turn.end_reason`
| SORT turns DESC25 个 turn,19 个 error,5 个 completed,1 个 aborted。
如果只看到这里,最容易得出的结论是"这套集成不靠谱"或者"这个模型不行"。两个结论都是错的。

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 DESCRATE_LIMIT 在 LLM 层出现 144 次,在 AGENT 层 16 次,在 ENTRY 层 16 次。
注意这里有个方法论陷阱:这三个数字不能相加。 同一个失败会沿着 span 树向上传播,ENTRY 和 AGENT 上的 RATE_LIMIT 是 LLM 层失败的回声,不是独立的新错误。如果你把所有 error.type 平铺着数,会得到一个虚高一倍的错误总数,然后基于它做出错误判断。
分层看,结论一句话就出来了:数据管道全程健康,是上游模型服务在限流。 这个区分决定了你是该去改代码,还是该去调并发。

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工具 | 调用 | 结果 |
|---|---|---|
| 12 | 8 次 |
| 4 | 全部成功 |
| 5 | 3 次 |
看到 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 秒。
一个调用不可能比它自己的首字延迟还短。 这个矛盾说明两个分位数根本不是在同一群样本上算的。拆开:
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 | model | 调用 | 重试 |
|---|---|---|---|
tencent | hy4-preview | 169 | 126 |
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 日志告诉你那一次里究竟发生了什么。 前者是聚合视角,后者是逐字真相,谁也替代不了谁。
看完上面的案例,接入本身其实很短。
dsh plugin --profile web add @loongsuite/dsh-plugin
dsh plugin --profile headless add @loongsuite/dsh-pluginheadless 一定要加——CI 里跑的就是它,也是最容易漏的那个。
{
"indices": [
{
"names": ["traces-*", "logs-*", "metrics-*"],
"privileges": ["create_doc", "auto_configure"]
}
]
}别删 metrics-*。 少了它,指标导出会在 /_otlp/v1/metrics 上吃 403,而且是静默失败:exporter 重试几次然后丢弃,dsh 那边一声不吭。trace 一切正常,看起来整个部署很健康,实际上一路信号已经死了。
(traces-* 之外还要 logs-*,是因为 trace 摄入会往 logs-* 写 span event。)
写进 ~/.dsh/profiles/<profile>/cordis.patch.yml,比环境变量稳,也方便直接发给团队:
- 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: truedata_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。找不到指标的人一半是找错了地方。
dsh web # 浏览器交互 UI
dsh --profile headless "你的任务" # 一次性任务 / CI从下一个 turn/start 开始 span 自动导出,业务代码零改动。

信号 | 有吗 | 落在哪 |
|---|---|---|
Traces | 有 |
|
Metrics | 有( |
|
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 的产品分析,跟本文这套无关。确认它是关的,或者有意识地做另一个决定。对有数据驻留要求的企业,这是他们一定会问的第一个问题,最好由你主动提出来。
三件事:
最后留个判断题:你现在的 agent,一次任务实际花掉多少 token、失败在哪一步、为什么失败——这三个问题,你能在 30 秒内答上来吗?
如果不能,缺的不是更好的 prompt。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。