先说一个让人头疼的场景
你的 Agent 跑了三分钟,最后报了一个错误。
然后你盯着日志——什么都没有。你不知道它调用了哪些工具、哪一步卡住了、Token 到底花在哪里,更不知道为什么失败。
这就是可观测性缺失的典型后果。
为什么可观测性对 Agent 尤其重要
传统服务出了问题,你可以加断点、看堆栈。Agent 不一样。
Agent 是非确定性的。 相同的输入,模型可能产生完全不同的工具调用序列。你没办法在"模型决策"这一步加断点——那是一个黑盒。
调试只能靠日志反推。 模型的"想法"只体现在它输出的文字和工具调用里。你需要把这些全部记下来,事后才能重建它的推理链。
Token 费用不透明。 一个多轮 Agent 对话,到底哪一步最贵?是第三轮工具结果太长?还是 system prompt 占了大头?没有计量,你连优化方向都找不到。
生产环境出问题,你得有证据。 用户说"它给了我一个错误答案",你需要还原当时的完整执行链——用了哪些工具、返回了什么、模型看到了什么。
Session 日志:最完整的观测数据
回顾第 05 篇讲过的内容:dsh Session 是一份仅追加的类型化事件日志。
这个设计不只是为了持久化,它本身就是最完整的观测数据源。
每个 Session 事件都包含:
每个 SessionEvent 的结构:
- type:事件类型(如 'tool/call'、'turn/end'、'assistant/message')
- seq:单调递增的序号(从 0 开始)
- time:时间戳(毫秒级 Unix 时间戳)
- data:类型化的事件数据(不同 type 有不同 data 结构)
这意味着:只要你能读取 Session 日志,你就能重建整个执行过程——每一步做了什么、花了多少时间、有没有出错。
实时监听:session/event
不需要等日志写完再分析。dsh 提供了 session/event 事件,让你实时监听 Session 的每一条记录:
// 监听某个 Session 的所有事件(实时)
ctx.on('session/event', (session, event) => {
// 每条事件都会触发这个回调
console.log(`[${event.type}] seq=${event.seq} time=${event.time}`)
// 检查是否是工具调用
if (event.type === 'tool/call') {
// event.data.name 是工具名
// event.data.arguments 是原始 JSON 字符串(模型输出的,未解析)
// event.data.callId 是这次调用的唯一 ID
console.log(` Tool: ${event.data.name}`)
console.log(` Args: ${event.data.arguments}`)
}
// 检查是否是工具执行结果
if (event.type === 'tool/result') {
// 通过检查 content block 里有没有 isError: true 判断是否失败
const isError = event.data.message.content.some(
b => b.type === 'tool_result' && b.isError
)
console.log(` Result: ${isError ? 'ERROR' : 'OK'}`)
}
})
这个监听器在开发调试时非常有用——你能看到 Agent 在实时做什么,不用等它跑完。
Token 计量:ctx.tokenMeter
知道"发生了什么"只是第一步。知道"花了多少钱"同样重要。
dsh 提供了 ctx.tokenMeter,可以测量当前 Session 的 Token 压力:
// TokenMeasurement 接口(来自 packages/llm/token-meter/src/types.ts)
interface TokenMeasurement {
// 这次计量消费了多少事件(用于缓存,避免重复计算)
readonly logRevision: SessionLogOffset
// 当前请求的总 token 压力(输入 + 输出之和)
readonly totalTokens: number
// 当前 surface(模型可见的历史消息)的 token 数量
readonly surfaceTokens: number
// surface 相对于最后一次成功请求的 token 变化量(有符号,可以是负数)
readonly surfaceDeltaTokens: number
// 按位置排列的 surface 节点及其 token 数(可以看每条消息占多少)
readonly nodes: readonly TokenSurfaceNode[]
}
几个关键概念:
- surface tokens:模型这次请求实际看到的历史内容有多少 token。这决定了你的 API 费用中"输入 token"这部分。
- total tokens:输入 + 输出的总和,反映这次请求的完整费用。
- surfaceDeltaTokens:和上一次请求相比,surface 增加了多少。如果这个数字持续增大,说明上下文在膨胀,可能需要压缩策略。
使用示例:
// 在每个 Turn 结束时打印 Token 使用摘要
ctx.on('session/event', (session, event) => {
// 只关心 Turn 结束事件
if (event.type !== 'turn/end') return
// 调用 measure 获取当前 Session 的 Token 测量结果
const measurement = ctx.tokenMeter.measure(session)
console.log(`Turn ${event.data.turn} ended:`)
console.log(` Surface tokens: ${measurement.surfaceTokens}`)
console.log(` Total tokens: ${measurement.totalTokens}`)
// 显示 delta,正数表示上下文在增长
const delta = measurement.surfaceDeltaTokens
const sign = delta > 0 ? '+' : ''
console.log(` Delta: ${sign}${delta}`)
// 如果上下文增长过快,发出警告
if (delta > 2000) {
console.warn(' ⚠ Context growing fast, consider compression')
}
})
遥测 Seam:ctx.sessionTelemetry
session/event 监听适合开发调试,但生产环境你需要把数据发到外部系统——比如 Grafana、Datadog、CloudWatch。
dsh 为此设计了一个"遥测 Seam":ctx.sessionTelemetry。
Seam(接缝)这个词用得很精准——它是一个标准化的接口,让你把遥测数据接入任意后端,同时 harness 本身不依赖任何具体的监控系统。
每条遥测记录的结构:
// SessionTelemetryRecord(来自 packages/session/session-telemetry/src)
interface SessionTelemetryRecord {
// 两种 channel:
// 'ledger':Session 日志事件的完整镜像,和事件一一对应
// 'ops':运营信号,只有特殊情况才产生
channel: 'ledger' | 'ops'
// 时间戳(毫秒)
time: number
// 严重程度
severity: 'info' | 'warn' | 'error'
// 标识属性(用于查询和过滤)
// 例如:session.id、event.type、event.seq 等
attributes: Record<string, string | number>
// 完整 payload:event.data 的深拷贝
body: unknown
}
两种 channel 分别做什么
ledger channel:Session 日志的完整镜像。每一条 Session 事件都会产生一条对应的 ledger 记录。这是审计和回放的数据来源。
包括:
- 每个
assistant/message(含完整流数据) - 每个
tool/call和tool/result - 失败的
assistant/attempt(模型尝试了但最终没用) - 所有
turn/start、turn/end等生命周期事件
ops channel:运营信号,只有两种:
agent-error:Agent 在 Turn 之外失败了(比如初始化报错)shutdown:Agent 正常关闭
严重程度如何判定
error:工具结果isError: true、turn/end 带错误原因、agent-error 运营事件- 其他情况:
info
这个映射让你可以在监控系统里直接过滤 severity === 'error' 来看所有异常,不用自己写判断逻辑。
OpenTelemetry 接入
dsh 提供官方的 OTel Provider 插件:dsh-session-telemetry-otel。
接入方式(概念性):
// 在你的 Bundle 配置里加入这个插件(伪代码)
// 这会把 ctx.sessionTelemetry 接到 OTel 后端
'@deepseek-ai/dsh-session-telemetry-otel'
// 该插件内部会:
// 1. 注册 ctx.sessionTelemetry 的 OTel 后端实现
// 2. 每条 SessionTelemetryRecord 通过 OTel JS SDK 的 Logger API 发送
// 3. 支持配置不同的 Exporter(OTLP、Console、File 等)
几个设计原则值得了解:
边界公理:harness 只负责调用 emit(),批处理、重试、排队这些属于 OTel SDK 的职责,harness 不插手。这样两边都可以独立演化。
尽力而为:遥测记录可能重复也可能丢失。接收端应该基于 (session.id, format_version, event.seq) 组合来去重 ledger 记录,而不是假设每条记录恰好到达一次。
flush 是可选的:每次 Turn 结束后可以调用 flush(),但 OTel 后端默认不实现(避免并发冲突)。如果你需要强一致性,需要自行配置。
实战:写一个简单的调试插件
把上面的内容整合成一个完整的调试观测插件:
// debug-observer.ts — 调试用的可观测性插件
// 用法:在开发时加入 Bundle,生产时替换为真正的遥测后端
export const name = 'debug-observer'
// 声明依赖注入 tokenMeter
export const inject = ['tokenMeter']
export function apply(ctx: Context): void {
// ── 1. 监听工具调用 ────────────────────────────────────────
ctx.on('session/event', (session, event) => {
if (event.type !== 'tool/call') return
console.log(`[Tool Call] ${event.data.name}`)
console.log(` Call ID: ${event.data.callId}`)
// arguments 是原始 JSON 字符串(模型直接输出的,还没有被解析)
console.log(` Args: ${event.data.arguments}`)
})
// ── 2. 监听工具执行结果 ────────────────────────────────────
ctx.on('session/event', (session, event) => {
if (event.type !== 'tool/result') return
const blocks = event.data.message.content
const isError = blocks.some(b => b.type === 'tool_result' && b.isError)
const icon = isError ? '✗' : '✓'
// 从第一个 block 里拿到对应的 toolUseId(关联 tool/call 事件)
const toolUseId = blocks[0]?.toolUseId ?? 'unknown'
console.log(`[Tool Result] ${icon} (call: ${toolUseId})`)
})
// ── 3. 每个 Turn 结束时打印 Token 摘要 ────────────────────
ctx.on('session/event', (session, event) => {
if (event.type !== 'turn/end') return
const reason = event.data.reason.kind // 'complete' | 'error' | 'interrupted' 等
const measurement = ctx.tokenMeter.measure(session)
console.log(`\n[Turn ${event.data.turn}] ended: ${reason}`)
console.log(` Surface: ${measurement.surfaceTokens} tokens`)
console.log(` Total: ${measurement.totalTokens} tokens`)
const delta = measurement.surfaceDeltaTokens
const sign = delta > 0 ? '+' : ''
console.log(` Delta: ${sign}${delta}`)
// 如果是错误结束,打印具体的错误信息
if (reason === 'error') {
console.error(` Error: ${JSON.stringify(event.data.reason)}`)
}
})
// ── 4. 监听 Session 生命周期 ───────────────────────────────
ctx.on('session/created', (session) => {
console.log(`\n[Session] created: ${session.id}`)
})
ctx.on('session/disposed', (session) => {
console.log(`[Session] disposed: ${session.id}`)
})
}
这个插件在开发时可以快速加入 Bundle,看到完整的运行轨迹。生产环境则换成 dsh-session-telemetry-otel 插件,数据流向监控系统。
调试技巧:读 JSONL 日志文件
dsh 默认把 Session 日志持久化为 JSONL 文件(每行一个 JSON 对象,即一条 SessionEvent)。
以下是一些常用的命令行分析技巧:
# 查看所有工具调用(提取工具名列表)
cat session.jsonl | grep '"type":"tool/call"' | jq '.data.name'
# 查看失败的助手尝试(模型生成了但最终没用到的内容)
cat session.jsonl | grep '"type":"assistant/attempt"' | jq '.'
# 统计每轮的 token 用量(从 assistant/message 里的 usage 字段)
cat session.jsonl | grep '"type":"assistant/message"' | jq '.data.usage'
# 查看所有 Turn 的结束原因(是正常完成还是出错)
cat session.jsonl | grep '"type":"turn/end"' | jq '.data.reason.kind'
# 检查有没有工具执行失败
cat session.jsonl | grep '"type":"tool/result"' | jq 'select(.data.message.content[].isError == true)'
这些命令假设你有 jq 工具。如果是 Windows 环境,可以用 PowerShell 的 ConvertFrom-Json 做类似的分析。
可观测性层次总结
四个层次,覆盖从开发到生产:
实时观测(开发调试)
└─ session/event 监听器 → 每条事件即时打印到控制台
审计与回放(事后分析)
└─ JSONL 日志文件 → 完整重建执行链,配合 jq 分析
Token 用量分析
└─ ctx.tokenMeter.measure(session) → 每个 surface 节点的 token 计量
→ 找到上下文膨胀的罪魁祸首
生产监控(系统级)
└─ ctx.sessionTelemetry + OTel 插件 → 接入 Grafana / Datadog / CloudWatch
→ 告警、看板、错误追踪全部打通
小结
可观测性不是"有了更好"的附加项,对 Agent 来说它是调试的唯一手段。
dsh 在设计上就考虑到了这一点:Session 本身是事件日志,事件日志天然就是审计数据;ctx.tokenMeter 让 Token 消耗不再是黑盒;ctx.sessionTelemetry 提供标准化接缝,让你自由选择后端。
核心模式很简单:Session 事件监听 → JSONL 持久化 → 遥测 Seam → OTel 后端。你用哪一层取决于你的场景,但这几层可以同时运行,互不干扰。
下一篇是系列的最后一篇,我们会把前面学到的所有机制整合起来,完整地写一个生产级插件——从工具注册、Session 管理、错误处理,到可观测性,一起落地。
在 PrimeSkills 可以找到已在真实企业场景验证过的 AI Agent 技能和工作流,不是演示级的,是用在实际项目里的。
更多内容见我的个人主页