核心摘要
Agent 可观测性有三个不同任务:
- Trace 记录足以重建一次运行的有界事件;
- Eval 判断结果是否满足契约;
- Debug 定位导致失败的状态转移、策略决策、依赖或预算。
目标不是捕获每个 Token,而是在最小化敏感数据的同时保留回答运维问题所需的证据,并让质量信号独立于模型自己的解释。
为什么 Agent 可观测性不同
传统 APM 可以把 HTTP 成功作为技术信号,但 Agent 返回 200 时,仍可能选错 Tool、越过租户边界、引用没有依据的资料,或花费超过预算。反过来,下游超时是技术失败,即使 Agent 正确拒绝了危险操作。
| 问题 | 技术遥测 | Agent 证据 |
|---|---|---|
| 请求完成了吗? | 状态码、耗时、异常 | 最终结果与取消原因 |
| 运行时做了什么? | Span 树与依赖调用 | 状态转移、Tool 调用、策略决策 |
| 回答质量如何? | APM 通常不可见 | 契约、证据、人工或模型评估 |
| 系统安全吗? | 认证错误与网络事件 | 拒绝操作、跨租户尝试、外发行为 |
| 花费多少? | CPU、内存 | Token、厂商价格、Tool 与重试预算 |
不要从状态码、模型解释或单一分数推断语义质量。每种决策都应记录对应的独立证据。
先定义事件契约
在选择 LangSmith、Langfuse、Phoenix、其他平台或自建存储之前,先定义由应用拥有的事件契约:
{
"event": "tool_call_completed",
"schema_version": 3,
"run_id": "run_7f...",
"trace_id": "4bf92f...",
"step": 4,
"tenant_hash": "h:...",
"subject_hash": "h:...",
"tool": "invoice.list",
"tool_version": "2026-05-1",
"argument_digest": "sha256:...",
"policy": "allow",
"state_version": 12,
"duration_ms": 184,
"result_class": "bounded_page",
"error_code": null,
"usage": {
"input_tokens": 812,
"output_tokens": 164,
"estimated_cost_usd": 0.0019
}
}
建议的事件包括 run_started、model_call_completed、tool_call_requested、policy_decision、tool_call_completed、state_transition、budget_exhausted、run_completed 和 run_failed。每个事件都应有稳定的 Schema 版本、Run ID 和脱敏策略。
应用事件契约让后端可以迁移。Trace 平台可以负责存储传输元数据,但即使更换 SDK 或模型厂商,业务事件仍然有意义。
Trace 边界与隐私
OpenTelemetry 是厂商无关的 Trace、Metric 和 Log 传输基础。它不替应用决定应该记录什么。使用 Span 表达耗时和因果关系,使用事件或日志表达结构化业务结果。
OpenTelemetry GenAI 当前状态
截至 2026-10-05,OpenTelemetry 将 GenAI Agent Span 语义约定标记为 Development。当前词汇覆盖创建或调用 Agent、调用 Workflow、Plan、执行 Tool 以及 Memory 操作。应把这些名称视为持续演进的互操作层:固定实现所用的约定版本,保留应用自己的事件版本,并在升级前测试 Exporter。
gen_ai.system_instructions 属性是 Opt-In,不能为了方便默认开启。指令可能包含隐私数据、策略细节或攻击载荷。OpenTelemetry 核心 API 与 W3C Trace Context 可以在 Agent 专用属性演进时提供稳定的传输层关联,但二者都不定义业务结果、授权决策或隐私策略。
各类信号的职责
| 信号 | 负责表达 | 不能证明 |
|---|---|---|
| Trace/Span | Agent Run、Model Call、Retrieval、Tool Call 和 Handoff 的因果与耗时 | 任务质量或授权 |
| Span Event/Log | 策略拒绝、重试、缓存决策或状态转移等有界事件 | 时间序列服务目标 |
| Metric | 聚合的速率、延迟、Token、成本、队列、错误与策略计数 | 某次运行为何失败 |
| Evaluation | 对可观察产物做契约、证据、安全和任务质量判断 | 运行时因果或权限 |
整条运行使用统一关联模型:以 AgentRun 为根,为 Model Call、Retrieval、Tool Call 和 Handoff 建立有界子 Span。Evaluation 作为独立版本化记录,通过 run_id、Evaluator 版本、数据切片和证据 ID 关联。生产级 RAG 评估指南定义检索和答案质量门禁;Agent 工具安全指南定义运行时权限,遥测只能观察它,不能替代它。
不要采用以下默认做法:
- 在每个 Span 中写入原始 Prompt 或完整 Tool 参数;
- 保存完整模型回答、隐藏推理或私有检索文档;
- 保存 Bearer Token、API Key、Authorization Header 或原始用户 ID;
- 把无界 Tool Result 放进 Span 属性;
- 用一个
success=true隐藏策略拒绝和语义失败。
只有在存在明确关联目的时才使用 Hash 或稳定伪名。Hash 并不会自动使数据匿名:输入空间很小时,映射可能被反推。遥测存储也必须按租户做访问控制,并将删除请求传播到导出物和评估数据集。
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
)
type RunContext struct {
RunID string
TenantID string
SubjectID string
}
type ToolInput struct {
Name string
Version string
Arguments map[string]any
PolicyDecision string
StateVersion int
DurationMS int64
ResultClass string
ErrorCode string
}
type ToolEvent struct {
Name string `json:"name"`
Attributes map[string]any `json:"attributes"`
}
func digestText(value string) string {
sum := sha256.Sum256([]byte(value))
return "sha256:" + hex.EncodeToString(sum[:])
}
func digestJSON(value any) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", err
}
return digestText(string(data)), nil
}
func recordToolEvent(context RunContext, input ToolInput) (ToolEvent, error) {
argumentDigest, err := digestJSON(input.Arguments)
if err != nil {
return ToolEvent{}, err
}
attributes := map[string]any{
"agent.run_id": context.RunID,
"agent.tenant_hash": digestText(context.TenantID),
"agent.subject_hash": digestText(context.SubjectID),
"agent.tool.name": input.Name,
"agent.tool.version": input.Version,
"agent.argument_digest": argumentDigest,
"agent.policy.decision": input.PolicyDecision,
"agent.state.version": input.StateVersion,
"agent.duration_ms": input.DurationMS,
"agent.result.class": input.ResultClass,
}
if input.ErrorCode != "" {
attributes["agent.error.code"] = input.ErrorCode
}
return ToolEvent{Name: "ToolCall", Attributes: attributes}, nil
}
func main() {
event, err := recordToolEvent(
RunContext{RunID: "run_7f", TenantID: "tenant_a", SubjectID: "user_9"},
ToolInput{
Name: "invoice.list",
Version: "2026-10-1",
Arguments: map[string]any{"status": "open"},
PolicyDecision: "allow",
StateVersion: 12,
DurationMS: 184,
ResultClass: "bounded_page",
},
)
if err != nil {
panic(err)
}
output, err := json.MarshalIndent(event, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(output))
}
这个标准库程序输出应用自有事件,只包含摘要和结果类别,不包含参数负载。Adapter 可以把这些字段映射到固定版本的 OpenTelemetry 约定。若法律或运维目的确实需要保留少量字段,应放进独立的访问受控审计记录。
Span 命名与上下文传播
使用 AgentRun、ModelCall、ToolCall、PolicyCheck 和 StateTransition 等稳定、低基数 Span 名称。不要把用户输入、资源 ID 或任意 Tool 参数放入名称。跨网关和可信 Tool 服务传播 W3C Trace Context;跨越租户或安全边界时,创建新的内部 Run ID。
区分以下标识:
trace_id:传输层因果关联;run_id:一次 Agent 执行及其重试/回放身份;request_id:外部请求或幂等边界;argument_digest:不暴露内容的精确输入摘要。
Tool 被拒绝时,记录策略结果和原因类别,但不要把 Trace 后端当作授权系统。
质量评估是独立平面
评估必须针对明确问题使用明确 Oracle:
| 评估层 | 示例 Oracle | 适用场景 |
|---|---|---|
| Contract | Schema、枚举、引用、预算、策略 | 每次运行或 CI |
| Scenario | 预期 Tool、Resource、状态和结果 | 回归测试 |
| Replay | 固定模型、Prompt、Fixture 和 Tool Double | 版本比较 |
| Abuse | 注入、跨租户、外泄、重复副作用 | 安全门禁 |
| Human Review | Rubric 与复议流程 | 高影响或歧义场景 |
| Model Judge | 校准 Rubric 与标注集 | 可扩展语义信号 |
不要让被评估的模型给自己的隐藏推理打分。评估可观察的输出、证据支持、策略决策和副作用。Judge 返回的置信度不是校准概率,除非已经用标注样本完成校准。
更安全的 Judge 契约
只传入 Rubric 需要的最少数据。把检索文本和模型回答视为不可信输入,使用清晰边界防止它们改变评估指令:
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
)
type JudgeResult struct {
Supported string `json:"supported"`
Relevant string `json:"relevant"`
MissingEvidence []string `json:"missingEvidence"`
Rationale string `json:"rationale"`
}
func validateJudgeResult(data []byte) (JudgeResult, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var result JudgeResult
if err := decoder.Decode(&result); err != nil {
return JudgeResult{}, err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return JudgeResult{}, errors.New("invalid trailing JSON")
}
allowed := map[string]bool{"yes": true, "no": true, "uncertain": true}
if !allowed[result.Supported] || !allowed[result.Relevant] {
return JudgeResult{}, errors.New("invalid judgment enum")
}
if result.MissingEvidence == nil {
return JudgeResult{}, errors.New("missingEvidence must be an array")
}
return result, nil
}
func main() {
raw := []byte(`{
"supported":"uncertain",
"relevant":"yes",
"missingEvidence":["invoice status"],
"rationale":"The answer lacks a current source."
}`)
result, err := validateJudgeResult(raw)
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", result)
}
权限、Tool 参数、结构化输出、证据 ID、预算和幂等性使用确定性检查。模型评估分数应与校准集比较,持续监控评估者一致性;高影响分歧进入人工复核。
能解释结果的指标
不要只做平均延迟和一个质量分数的仪表盘。指标应映射到具体动作:
- 运行完成、取消和预算耗尽率;
- 每次运行的 Model Call、Tool Call、重试和状态转移数量;
- 按策略版本统计 allow/confirm/deny,包括跨租户拒绝;
- Tool 参数校验失败和结果超限;
- 抽样评估中的证据覆盖率和无依据声明率;
- 按模型、Tool、租户类别和结果统计 p50/p95/p99 延迟;
- 输入/输出 Token、估算厂商成本和每个成功任务成本;
- 排队时间、下游错误类别和取消传播。
指标标签应使用有界枚举。包含用户问题或任意资源 ID 的标签会造成高基数和隐私问题。
不保存思维链也能调试循环
Agent 循环可以通过状态和动作事件观测:
package main
import "fmt"
type StepEvent struct {
Step int
Tool string
ArgumentDigest string
StateVersion int
Policy string
Outcome string
}
func sameSignature(left, right StepEvent) bool {
return left.Tool == right.Tool &&
left.ArgumentDigest == right.ArgumentDigest &&
left.StateVersion == right.StateVersion &&
left.Policy == right.Policy &&
left.Outcome == right.Outcome
}
func detectStall(events []StepEvent, window int) bool {
if window <= 0 || len(events) < window*2 {
return false
}
start := len(events) - window*2
for index := 0; index < window; index++ {
if !sameSignature(events[start+index], events[start+window+index]) {
return false
}
}
return true
}
func main() {
events := []StepEvent{
{Step: 1, Tool: "search", ArgumentDigest: "a", StateVersion: 4, Policy: "allow", Outcome: "empty"},
{Step: 2, Tool: "search", ArgumentDigest: "b", StateVersion: 4, Policy: "allow", Outcome: "empty"},
{Step: 3, Tool: "search", ArgumentDigest: "a", StateVersion: 4, Policy: "allow", Outcome: "empty"},
{Step: 4, Tool: "search", ArgumentDigest: "b", StateVersion: 4, Policy: "allow", Outcome: "empty"},
}
fmt.Println(detectStall(events, 2))
}
重复签名还必须结合最大步数、状态版本不变、重复幂等键和依赖超时。重复调用同一个 Tool 不一定是循环,分页和轮询可能是合法行为。最大步数和取消必须由运行时执行,而不是由遥测后端代替。
回放时保存脱敏事件 Fixture 和确定性 Tool Double,使用固定模型或已记录的模型响应重跑,再比较事件契约和业务结果。如果会再次执行外部副作用,就不能把它称为安全的“时间旅行”;应使用 Dry Run 或补偿语义。
采样与成本控制
采样是策略决策,不存在统一比例:
| 信号 | 可采用的策略 |
|---|---|
| 高影响操作或明确事故 | 保留所需审计字段 |
| 策略拒绝、超时或预算超限 | 保留扩展的脱敏 Trace |
| 普通低风险运行 | 保留元数据和有界样本 |
| 聚合健康指标 | 保留不含内容的计数和直方图 |
按 run_id 做确定性采样,确保子 Span 一致。Tail Sampling 可以等待结果信息后执行,但隐私过滤必须在导出前完成。成本估算应包含事件大小、流量、后端留存、评估调用和删除要求。
如何选择技术栈
按照契约和数据控制选择组件,而不是使用通用排行榜:
| 需求 | 能力候选 |
|---|---|
| 厂商无关传输 | OpenTelemetry SDK 与 Collector |
| Trace 查询 | 兼容 OTel 的后端 |
| Prompt/Eval 工作流 | 满足数据驻留、留存和访问要求的平台 |
| 回放与回归 | 版本化 Fixture、数据集和确定性 Tool Double |
| 看板与告警 | 使用有界标签的指标后端 |
托管和自托管产品的 SDK、数据驻留、删除、导出和价格都会变化。选型前应核对厂商当前文档。某个平台深度集成 LangChain,不代表它支持你的 Agent 运行时或授权模型。
生产控制
访问与留存
- 分离运维、开发、评估和租户对遥测的访问;
- 加密传输和存储,轮转导出器凭证;
- 将原始内容、事件元数据、聚合指标和评估产物放进不同留存类别;
- 将主体删除传播到 Trace 后端、缓存、导出物和评估数据集;
- 记录脱敏版本和策略版本,使 Schema 变更后仍可解释事件。
告警
围绕工作负载基线告警:
- 拒绝或跨租户尝试突然增加;
- 重复状态且没有进展;
- p99 延迟或排队时间超过服务目标;
- 每个成功任务成本超出预算;
- 校准样本中无依据证据或契约失败增加;
- 导出器失败或遥测丢失。
不要把 90%、80%、15% 或固定“生产就绪等级”写成通用阈值。正确阈值取决于影响、流量、模型和业务容忍度。
发布门禁
启用新 Tool 或模型前,至少需要确定性契约测试、场景测试、滥用测试、回放对比和隐私评审。高影响操作需要人工审批或独立授权工作流;Trace 或 Judge 分数不能授予权限。
常见失败模式
| 失败 | 需要的证据 | 修复动作 |
|---|---|---|
| 重复循环 | 状态版本、动作摘要、预算事件 | 停止运行,修复转移或预算 |
| Tool 选错 | 候选集合、选中 Tool、Schema 结果 | 改善边界并增加场景用例 |
| 无依据回答 | 证据 ID 与声明检查 | 要求证据或拒答 |
| 跨租户尝试 | 主体摘要、租户策略结果 | 拒绝、告警并检查调用路径 |
| 成本失控 | Token、重试、模型路由 | 执行预算和路由策略 |
| 遥测泄露 | 脱敏审计和访问日志 | 撤销访问、删除导出物并修过滤器 |
常见问题
Agent Trace 应该记录什么?
记录有界标识、耗时、版本、用量、策略结果、结果类别和最终业务结果。按用途脱敏或省略内容。Trace 不是隐藏推理逐字稿。
OpenTelemetry 是 Agent 专用标准吗?
不是。它是厂商无关的可观测性基础。应用仍需定义 Agent 事件、隐私规则和业务语义,再把事件映射到 Span、Log 和 Metric。
LLM Judge 可以直接作为发布门禁吗?
不能单独使用。确定性检查、校准的模型评估、回放、滥用测试和高影响人工复核应共同组成门禁。
不保存思维链,如何排查 Agent 循环?
使用有界 Step Event、状态版本、参数摘要、策略结果和预算;用脱敏 Fixture 与 Tool Double 回放。
遥测应该保留多久?
根据用途、敏感度、法律义务、事故需求和删除传播选择,不存在统一期限或采样率。
总结
好的 Agent 可观测性是一套受控的证据系统。OpenTelemetry 可以承载因果上下文,但应用事件契约才定义什么重要;评估应检查可观察契约,而不是相信模型解释;调试应重建状态和动作转移,而不是把私有 Prompt 或隐藏思维变成永久日志。把 Trace、评估、策略、隐私和回放放在一起设计,才能回答“发生了什么、应该改变什么”,同时避免制造第二个数据治理问题。